@alteriom/painlessmesh 2.0.2 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,96 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.3] - 2026-09-11
11
+
12
+ `sendToInternet()` fixes from one user's WhatsApp integration (#450, #452,
13
+ #453), and one the hardware rig found while validating them. **Upgrade if a
14
+ node of yours is a bridge or relays to the Internet**: on 2.0.2 a bridge's own
15
+ sends were refused for 30 s after boot, a service that answered a refusal with
16
+ HTTP 201 was reported as delivered, a destination that does not resolve
17
+ stalled an ESP32 gateway on every attempt, and a bridge that rebooted as a
18
+ regular node swallowed every request its peers still sent it. Every fix was
19
+ reproduced first -- against the new HTTP test point, which CI now runs, or
20
+ over real TCP on the desktop -- and is covered by a row on the hardware farm.
21
+
22
+ ### Fixed
23
+
24
+ - **A bridge's own `sendToInternet()` was refused for the first 30 s after
25
+ `initAsBridge()`** (#450). The Internet health check is armed one line after
26
+ `stationManual()` re-issues `WiFi.begin()`, so its first probe ran while the
27
+ station was still associating and failed, and the next was a full interval
28
+ away. Every send from the bridge in that window fell through to the mesh
29
+ path and was refused with "No active mesh connections" -- #445 again, with
30
+ a 30 s hole instead of forever. `sendToInternet()` and the retry path now
31
+ spend one on-demand probe per interval when the flag says no, and the
32
+ station's got-IP event re-probes at once on a bridge or shared gateway.
33
+ - **A gateway reported a refusal as delivered, and every failure as a bare
34
+ number** (#450, #452). The gateway decided on the HTTP status alone and
35
+ discarded the response body. CallMeBot's WhatsApp API answers "Too many
36
+ requests" with HTTP 201 and HTTP 203, the same page under both, so a refused
37
+ message sent through a gateway on 2.0.2 could be reported as delivered; and
38
+ it answers HTTP 208 to messages it never delivers, which 2.0.2 reported as
39
+ "Ambiguous response" with nothing to say why. The gateway now reads the
40
+ start of the body (bounded to 512 bytes and 250 ms). Only 200, 201, 202 and
41
+ 204 count as delivered, and not when the body says the service refused the
42
+ request; every other 2xx is a failure. Every failure reaches the origin
43
+ node's callback with a one-line, tag-free excerpt of the body -- for example
44
+ `HTTP 201: service refused the request: Oops! Too many requests...` or
45
+ `HTTP 208: not a delivery the gateway can confirm: ...` -- and is logged at
46
+ ERROR level, so a sketch on the default log levels sees what the service
47
+ said.
48
+ - **A destination whose name does not resolve stalled an ESP32 gateway on
49
+ every attempt** (#453). A failed lookup surfaced as `connection refused`
50
+ after the resolver's own patience, which this library cannot bound on ESP32,
51
+ and every attempt -- the origin node's retries and, since the bridge fix
52
+ above, the bridge's own sends -- paid it again, until relayed requests timed
53
+ out on their origin nodes. An ESP32 gateway now resolves the host once,
54
+ reports `DNS lookup failed for <host>`, and refuses that host for 60 s
55
+ without another lookup. ESP8266 is unchanged: its core bounds the lookup by
56
+ the HTTP timeout. The gateway blocking budget is unchanged.
57
+ - **A bridge that rebooted as a regular node swallowed every Internet request
58
+ routed to it.** Found on the hardware rig while validating this release. A
59
+ bridge that reboots, crashes, loses power or is reflashed announces nothing
60
+ -- only a bridge stepping down in-process sends `leaving` -- so its peers
61
+ kept it in their bridge list for the 60 s they trust a status, and could
62
+ prefer it over a live bridge on RSSI. A regular node had no handler for
63
+ gateway requests and dropped them without a reply; each sender waited out
64
+ its 30 s request timeout and reported "Request timed out". Every node now
65
+ answers a gateway request it cannot serve with
66
+ `Node <id> is not an Internet gateway`; the sender forgets that node as a
67
+ gateway and sends again at once through the next one, without spending a
68
+ retry. When it knows no other gateway, the request rides the ordinary retry
69
+ backoff -- on the rig the live bridge was advertised half a second later --
70
+ and fails with `No Internet gateway available: ...` only when none appears.
71
+ - **Mismatched log format arguments in `routePackage()`** (CodeQL
72
+ `cpp/wrong-type-format-argument`). A message that failed to parse was logged
73
+ with its `size_t` lengths through `%d`/`%u` -- the wrong width on 64-bit
74
+ hosts -- and with its `DeserializationError` object passed through `%u`,
75
+ undefined behaviour on every platform, in the ArduinoJson 7 path every
76
+ current build compiles as well as the legacy one CodeQL flagged.
77
+
78
+ ### Changed
79
+
80
+ - **The mock HTTP server is now the gateway test point.** It keeps a delivery
81
+ ledger (`GET /requests/{tag}`) and emulates CallMeBot's status quirks
82
+ (`GET /callmebot/whatsapp.php`, profile chosen by `apikey`). The desktop CI
83
+ job starts it and `PAINLESSMESH_TESTPOINT` points the suite at it, so the
84
+ library's delivery verdict is checked against what the service actually did,
85
+ not against a table of status codes. The Alteriom farm's gateway probe serves
86
+ the same routes.
87
+ - **`Mesh::sendGatewayAck()` is public and portable.** It moved from the ESP
88
+ gateway code into `painlessmesh::Mesh`, because every node now needs it to
89
+ answer a request it cannot serve.
90
+ - **`Log()` is format-checked in the desktop build.** The test build marks it
91
+ printf-like, so CI's `-Wall -Werror` rejects an argument that does not match
92
+ its specifier. Device builds are unchanged: on ESP-IDF 5 `uint32_t` is
93
+ `unsigned long`, and every `%u` of a node id would warn there.
94
+ - **`sendToInternet` example.** The startup WhatsApp retries every 30 s until
95
+ a gateway with Internet is known instead of firing once before a regular
96
+ node has joined, and the cloud endpoint moved to a `CLOUD_URL` define whose
97
+ placeholder is skipped with a message rather than reported as
98
+ `connection refused`.
99
+
10
100
  ## [2.0.2] - 2026-09-08
11
101
 
12
102
  Two gateway defects that reached users on 2.0.1, both in code the desktop test
package/CONTRIBUTING.md CHANGED
@@ -55,6 +55,21 @@ and OTA behaviour is validated on the Alteriom hardware-in-the-loop farm; a
55
55
  maintainer runs it on a pull request by adding the `run-hil` label, and the
56
56
  release gate is three consecutive clean runs of the whole suite.
57
57
 
58
+ ### The HTTP test point
59
+
60
+ `test/mock-http-server/server.py` is the controlled Internet destination for
61
+ `sendToInternet()` tests at every level. It answers the way real services do,
62
+ including the ways they get it wrong (its CallMeBot emulation returns refusals
63
+ as HTTP 201 and 203, as the real API does), and it keeps a delivery ledger:
64
+ `GET /requests/{tag}` says whether the service actually accepted a request.
65
+ The desktop CI job starts one and exports `PAINLESSMESH_TESTPOINT`, and
66
+ `catch_issue450_testpoint_semantics` makes real HTTP requests to it and
67
+ requires the library's verdict to match the ledger. The farm's gateway probe
68
+ serves the same routes with the same record shape, so a hardware row and a
69
+ desktop scenario are measured against one source of truth. When a gateway bug
70
+ comes in, add the service behaviour that exposed it to the server first, then
71
+ the test that fails against it.
72
+
58
73
  ### Adding tests for new features
59
74
 
60
75
  1. **Unit tests**: add to `test/catch/` for new components.
package/README.md CHANGED
@@ -596,9 +596,11 @@ These are the message types used by applications built on painlessMesh:
596
596
  - **Event Coordination** - Synchronized displays, distributed processing
597
597
  - **Bridge Networks** - Connect mesh to WiFi/Internet/MQTT - [📖 Bridge Guide](BRIDGE_TO_INTERNET.md)
598
598
 
599
- ## Latest Release: v2.0.2 (September 8, 2026)
599
+ ## Latest Release: v2.0.3 (September 11, 2026)
600
600
 
601
- **Two gateway fixes — upgrade if any node of yours is a bridge.** On 2.0.1 a bridge could not reach the Internet through its own uplink at all (`initAsBridge()` never started the health checker that `sendToInternet()`'s local path depends on, so a bridge with no peer yet failed with "No active mesh connections"), and any request that failed *below* HTTP — refused, unresolvable, timed out — was reported to the origin node as `HTTP 65535`, a truncated `-1`, which also stopped it from being retried. Both are now asserted on the hardware rig, including a bridge with no mesh peer at all.
601
+ **`sendToInternet()` fixes — upgrade if any node of yours is a bridge or relays to the Internet.** On 2.0.2 a bridge's own sends were refused with "No active mesh connections" for the first 30 s after `initAsBridge()`, because the health check's first probe ran while the station was still associating. The gateway also decided delivery on the HTTP status alone: CallMeBot answers a refusal with HTTP 201, so a message it refused could be reported as sent, and every failure reached the origin node as a bare number. The gateway now reads the start of the response body, counts only 200/201/202/204 without a refusing body as delivered, and hands every failure to your callback with the service's own words. A destination whose name does not resolve no longer stalls an ESP32 gateway on every attempt: it is refused for 60 s after one failed lookup. And a bridge that reboots, crashes or is reflashed as a regular node no longer swallows the requests its peers still send it for a minute: it answers that it is not a gateway, and the sender moves to the next one at once.
602
+
603
+ **2.0.2 — two gateway fixes.** On 2.0.1 a bridge could not reach the Internet through its own uplink at all (`initAsBridge()` never started the health checker that `sendToInternet()`'s local path depends on, so a bridge with no peer yet failed with "No active mesh connections"), and any request that failed *below* HTTP — refused, unresolvable, timed out — was reported to the origin node as `HTTP 65535`, a truncated `-1`, which also stopped it from being retried. Both are now asserted on the hardware rig, including a bridge with no mesh peer at all.
602
604
 
603
605
  **2.0.1 — a packaging fix over 2.0.0, no library behaviour changed.** 2.0.0's installation instructions named a PlatformIO package that has no 2.0.0 (`alteriom/…` stops at 1.10.0; releases go out under `sparck75`), and its GitHub release carried no library archive because the upload was refused by an immutable release.
604
606
 
package/RELEASE_GUIDE.md CHANGED
@@ -38,13 +38,23 @@ These files must always contain the same semantic version:
38
38
  - `library.properties`
39
39
  - `library.json`
40
40
  - `package.json`
41
+ - `package-lock.json` (the root `version` and `packages[""].version`)
42
+ - `src/AlteriomPainlessMesh.h` (the version string and the MAJOR/MINOR/PATCH
43
+ defines a sketch compiles against)
44
+ - `doxygen/Doxyfile` (`PROJECT_NUMBER`, the number on every generated API page)
41
45
 
42
- Use the repository script to change them together:
46
+ Use the repository script to change them together; it updates all six and then
47
+ verifies they agree:
43
48
 
44
49
  ```bash
45
50
  ./scripts/bump-version.sh patch
51
+ ./scripts/bump-version.sh patch 2.0.3 --yes # no prompt, for CI or an agent
46
52
  ```
47
53
 
54
+ `jq` is used when it is installed and awk otherwise, and both paths change only
55
+ the package's own version — never a dependency range. `test/ci/test_bump_version.sh`
56
+ holds that, and the Code Quality job runs it.
57
+
48
58
  You may use `minor`, `major`, or an explicit version when the release plan
49
59
  requires it. Version 2.0 patch releases must remain wire-compatible with the
50
60
  2.0 protocol.
@@ -187,10 +187,10 @@ The callback provides `httpStatus` to indicate the result:
187
187
 
188
188
  **FAILURE (success = false):**
189
189
  - `203 Non-Authoritative Information` - **Cached/proxied response, NOT actual delivery**
190
- - `4xx` - Client error (bad request, unauthorized, not found, etc.)
191
- - `5xx` - Server error (service unavailable, gateway timeout, etc.)
192
190
 
193
- ⚠️ **Important:** HTTP 203 is treated as **FAILURE** because it indicates the response came from a cache or proxy, not from the actual WhatsApp API server. If you see `HTTP Status: 203`, the message was **NOT delivered**.
191
+ ⚠️ **The status alone does not say whether WhatsApp got the message.** CallMeBot answers a refusal (for example "Too many requests") with HTTP 201 or HTTP 203, the same error page under both. The gateway therefore reads the start of the response body: a 2xx whose body says the service refused the request is reported as a failure, with the service's own words in the callback's `error` string, and HTTP 203 stays a failure because a proxy, not the WhatsApp API, may have answered. If your callback prints `HTTP 201: service refused the request: Oops! Too many requests...`, that is CallMeBot talking, and the message was **NOT delivered**. Likewise `HTTP 208: not a delivery the gateway can confirm: ...`: CallMeBot answers 208 to messages that never arrive (issues #450 and #452), so only 200, 201, 202 and 204 with a clean body are reported as sent, and the rest of the line is whatever the service said.
192
+
193
+ Also replace `CLOUD_URL` at the top of the sketch: the placeholder `api.example.com` does not resolve, and until you change it the sketch skips the cloud send and says so instead of reporting `connection refused` every minute.
194
194
 
195
195
  ## Files
196
196
 
@@ -74,6 +74,19 @@
74
74
  #define WHATSAPP_PHONE "+1234567890" // Your phone number with country code
75
75
  #define WHATSAPP_APIKEY "your_api_key" // Your Callmebot API key
76
76
 
77
+ // ============================================
78
+ // Cloud API Configuration
79
+ // ============================================
80
+ // The endpoint the periodic sensor readings are POSTed to. Replace it with
81
+ // your own; the placeholder below does not resolve, so until you do the
82
+ // sketch skips the cloud send and says so rather than reporting
83
+ // "connection refused" every minute (issue #450).
84
+ // Example endpoints:
85
+ // - ThingsBoard: "https://demo.thingsboard.io/api/v1/YOUR_TOKEN/telemetry"
86
+ // - AWS IoT: "https://YOUR_ENDPOINT.iot.us-east-1.amazonaws.com/topics/sensors"
87
+ // - Custom API: "https://api.yourserver.com/sensors/data"
88
+ #define CLOUD_URL "https://api.example.com/sensors"
89
+
77
90
  // ============================================
78
91
  // Sensor Simulation Configuration
79
92
  // ============================================
@@ -115,6 +128,15 @@ String urlEncode(const String& str);
115
128
  // Task to periodically send sensor data (simulated)
116
129
  Task taskSendSensorData(60000, TASK_FOREVER, &sendSensorDataToCloud);
117
130
 
131
+ // The startup WhatsApp used to be a single 30 s one-shot. A regular node that
132
+ // took longer than that to find the mesh -- following a bridge to another
133
+ // channel takes about a minute -- fired it with no gateway in sight and never
134
+ // tried again (issue #450). It now retries until a gateway with Internet is
135
+ // known, then disables itself.
136
+ void sendStartupAlert();
137
+ Task taskStartupAlert(30000, TASK_FOREVER, &sendStartupAlert);
138
+ String startupMsg;
139
+
118
140
  // ============================================
119
141
  // URL Encoding Helper
120
142
  // ============================================
@@ -184,13 +206,12 @@ void sendAlertToWhatsApp(String message) {
184
206
 
185
207
  // Use sendToInternet() to route the request through a gateway
186
208
  // The callback will be invoked when we get a response (or timeout)
187
- //
188
- // SUCCESS CODES: Only specific HTTP codes indicate genuine delivery:
189
- // - 200 OK: Standard success (most common for WhatsApp API)
190
- // - 201 Created, 202 Accepted, 204 No Content
191
- //
192
- // FAILURE: HTTP 203 (Non-Authoritative) is treated as FAILURE because
193
- // it indicates a cached/proxied response, not actual delivery to WhatsApp.
209
+ //
210
+ // The gateway reads the response body, not only the status: CallMeBot
211
+ // answers a refusal ("Too many requests") with HTTP 201 or 203, so a 2xx
212
+ // alone proves nothing. A refusal arrives here as success=false with the
213
+ // service's own words in `error`; HTTP 203 stays a failure because a proxy,
214
+ // not WhatsApp, may have answered.
194
215
  uint32_t msgId = mesh.sendToInternet(
195
216
  url,
196
217
  "", // No payload needed for GET request - params are in URL
@@ -249,14 +270,13 @@ void sendSensorDataToCloud() {
249
270
  return;
250
271
  }
251
272
 
252
- // Send to cloud API (replace with your actual endpoint)
253
- // Example endpoints:
254
- // - ThingsBoard: "https://demo.thingsboard.io/api/v1/YOUR_TOKEN/telemetry"
255
- // - AWS IoT: "https://YOUR_ENDPOINT.iot.us-east-1.amazonaws.com/topics/sensors"
256
- // - Custom API: "https://api.yourserver.com/sensors/data"
257
-
258
- String cloudUrl = "https://api.example.com/sensors"; // Replace with your endpoint
259
-
273
+ // Send to the cloud API configured at the top of the sketch
274
+ String cloudUrl = CLOUD_URL;
275
+ if (cloudUrl.indexOf("example.com") >= 0) {
276
+ Serial.println(" (CLOUD_URL is still the placeholder; set your endpoint to send readings)");
277
+ return;
278
+ }
279
+
260
280
  uint32_t msgId = mesh.sendToInternet(
261
281
  cloudUrl,
262
282
  payload,
@@ -276,6 +296,21 @@ void sendSensorDataToCloud() {
276
296
  // Mesh Callbacks
277
297
  // ============================================
278
298
 
299
+ /**
300
+ * Send the startup notification once a gateway with Internet is known.
301
+ *
302
+ * Runs every 30 s from taskStartupAlert until it has handed the message to
303
+ * sendToInternet(), then disables itself.
304
+ */
305
+ void sendStartupAlert() {
306
+ if (!mesh.hasInternetConnection()) {
307
+ Serial.println("(startup WhatsApp waiting for a gateway with Internet; retrying in 30 s)");
308
+ return;
309
+ }
310
+ sendAlertToWhatsApp(startupMsg);
311
+ taskStartupAlert.disable();
312
+ }
313
+
279
314
  void receivedCallback(uint32_t from, String& msg) {
280
315
  Serial.printf("📨 Received from %u: %s\n", from, msg.c_str());
281
316
  }
@@ -347,14 +382,14 @@ void setup() {
347
382
  Serial.printf("Is Bridge: %s\n", mesh.isBridge() ? "YES" : "NO");
348
383
  Serial.println("================================================\n");
349
384
 
350
- // Send a startup notification via WhatsApp (demonstrates sendToInternet)
351
- String startupMsg = "🚀 Node " + String(mesh.getNodeId()) + " started!";
352
-
353
- // Delay to allow mesh to connect first
354
- Serial.println("Will attempt to send startup WhatsApp in 30 seconds...\n");
355
- mesh.addTask([startupMsg]() {
356
- sendAlertToWhatsApp(startupMsg);
357
- }, 30000); // 30 second delay
385
+ // Send a startup notification via WhatsApp (demonstrates sendToInternet).
386
+ // First attempt in 30 s, then every 30 s until a gateway with Internet is
387
+ // known: a bridge can use its own uplink at once, a regular node has to
388
+ // find the mesh first.
389
+ startupMsg = "🚀 Node " + String(mesh.getNodeId()) + " started!";
390
+ Serial.println("Will send the startup WhatsApp as soon as a gateway with Internet is known (first try in 30 s)...\n");
391
+ userScheduler.addTask(taskStartupAlert);
392
+ taskStartupAlert.enableDelayed();
358
393
  }
359
394
 
360
395
  // ============================================
package/library.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "type": "git",
7
7
  "url": "https://github.com/Alteriom/painlessMesh"
8
8
  },
9
- "version": "2.0.2",
9
+ "version": "2.0.3",
10
10
  "frameworks": [
11
11
  "arduino"
12
12
  ],
@@ -1,5 +1,5 @@
1
1
  name=Alteriom PainlessMesh
2
- version=2.0.2
2
+ version=2.0.3
3
3
  author=Coopdis,Scotty Franzyshen,Edwin van Leeuwen,Germán Martín,Maximilian Schwarz,Doanh Doanh,Alteriom
4
4
  maintainer=Alteriom
5
5
  sentence=A painless way to setup a mesh with ESP8266 and ESP32 devices with Alteriom extensions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alteriom/painlessmesh",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "painlessMesh is a user-friendly library for creating mesh networks with ESP8266 and ESP32 devices. This Alteriom fork includes additional packages for sensor data (SensorPackage), device commands (CommandPackage), and status monitoring (StatusPackage). It handles routing and network management automatically, so you can focus on your application. The library uses JSON-based messaging and syncs time across all nodes, making it ideal for coordinated behaviour like synchronized light displays or sensor networks reporting to a central node.",
5
5
  "keywords": [
6
6
  "arduino",
@@ -29,10 +29,10 @@
29
29
  /**
30
30
  * @brief AlteriomPainlessMesh library version information
31
31
  */
32
- #define ALTERIOM_PAINLESS_MESH_VERSION "2.0.2"
32
+ #define ALTERIOM_PAINLESS_MESH_VERSION "2.0.3"
33
33
  #define ALTERIOM_PAINLESS_MESH_VERSION_MAJOR 2
34
34
  #define ALTERIOM_PAINLESS_MESH_VERSION_MINOR 0
35
- #define ALTERIOM_PAINLESS_MESH_VERSION_PATCH 2
35
+ #define ALTERIOM_PAINLESS_MESH_VERSION_PATCH 3
36
36
 
37
37
  /**
38
38
  * @brief Library description and usage information
@@ -2721,54 +2721,53 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2721
2721
  /**
2722
2722
  * Helper method to send gateway acknowledgment
2723
2723
  */
2724
- void sendGatewayAck(
2725
- const gateway::GatewayDataPackage& request, bool success,
2726
- uint16_t httpStatus, const TSTRING& error,
2727
- std::shared_ptr<Connection> ingressConnection = nullptr) {
2728
- using namespace logger;
2729
-
2730
- gateway::GatewayAckPackage ack;
2731
- ack.from = this->nodeId;
2732
- ack.dest = request.originNode;
2733
- ack.messageId = request.messageId;
2734
- ack.originNode = request.originNode;
2735
- ack.success = success;
2736
- ack.httpStatus = httpStatus;
2737
- ack.error = error;
2738
- ack.timestamp = this->getNodeTime();
2739
-
2740
- if (request.originNode == this->nodeId) {
2741
- protocol::Variant variant(&ack);
2742
- this->callbackList.execute(protocol::GATEWAY_ACK, variant, nullptr, 0);
2743
- Log(COMMUNICATION,
2744
- "Completed local GATEWAY_ACK (success=%d, http=%d)\n", success,
2745
- httpStatus);
2746
- return;
2747
- }
2724
+ /** Hosts that recently failed to resolve; fed on ESP32 only, see the
2725
+ * GATEWAY_DATA handler. */
2726
+ gateway::NegativeDnsCache negativeDns;
2748
2727
 
2749
- auto conn = router::findRoute<Connection>((*this), request.originNode);
2750
- if (!conn && ingressConnection) {
2751
- // A newly promoted gateway can receive data before its NodeTree has
2752
- // converged enough for findRoute() to resolve the request origin. The
2753
- // ingress connection is nevertheless a valid reverse path: the request
2754
- // just arrived through it and every intermediate node can continue
2755
- // routing the addressed acknowledgment toward originNode.
2756
- conn = ingressConnection;
2757
- Log(COMMUNICATION,
2758
- "Routing GATEWAY_ACK to node %u through request ingress while "
2759
- "topology converges\n",
2760
- request.originNode);
2761
- }
2762
- if (conn) {
2763
- protocol::Variant variant(&ack);
2764
- router::send(std::move(variant), conn);
2765
- Log(COMMUNICATION, "Sent GATEWAY_ACK to node %u (success=%d, http=%d)\n",
2766
- request.originNode, success, httpStatus);
2767
- } else {
2768
- Log(ERROR, "Failed to send GATEWAY_ACK: no route to node %u\n",
2769
- request.originNode);
2728
+ #if defined(ESP32) || defined(ESP8266)
2729
+ /**
2730
+ * Read the start of an HTTP response body, bounded in bytes and in time
2731
+ *
2732
+ * The gateway used to discard the body, so a service that answers a refusal
2733
+ * with a 2xx (CallMeBot, issue #450) was reported as delivered, and a
2734
+ * failure reached the origin node as a bare status. When the length is
2735
+ * known and small the whole body is taken; otherwise up to maxBytes are
2736
+ * read from the stream, waiting at most GATEWAY_RESPONSE_HEAD_TIMEOUT_MS,
2737
+ * which keeps a large or chunked page from eating the heap or the
2738
+ * cooperative scheduler.
2739
+ */
2740
+ static TSTRING readResponseHead(HTTPClient& http, size_t maxBytes) {
2741
+ const int size = http.getSize();
2742
+ if (size >= 0 && static_cast<size_t>(size) <= maxBytes) {
2743
+ return http.getString();
2744
+ }
2745
+ TSTRING head;
2746
+ WiFiClient* stream = http.getStreamPtr();
2747
+ if (stream == nullptr) return head;
2748
+ const uint32_t deadline =
2749
+ millis() + gateway::GATEWAY_RESPONSE_HEAD_TIMEOUT_MS;
2750
+ while (head.length() < maxBytes &&
2751
+ static_cast<int32_t>(deadline - millis()) > 0) {
2752
+ int available = stream->available();
2753
+ if (available <= 0) {
2754
+ if (!stream->connected()) break;
2755
+ delay(1);
2756
+ continue;
2757
+ }
2758
+ while (available-- > 0 && head.length() < maxBytes) {
2759
+ const int c = stream->read();
2760
+ if (c < 0) break;
2761
+ head += static_cast<char>(c);
2762
+ }
2770
2763
  }
2764
+ return head;
2771
2765
  }
2766
+ #endif
2767
+
2768
+ // sendGatewayAck() is portable and lives in painlessmesh::Mesh
2769
+ // (mesh.hpp): every node needs it, not only a gateway, to answer a
2770
+ // request it cannot serve.
2772
2771
 
2773
2772
  /**
2774
2773
  * Initialize gateway Internet handler
@@ -2873,6 +2872,49 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2873
2872
  }
2874
2873
 
2875
2874
  #if defined(ESP32) || defined(ESP8266)
2875
+ #ifdef ESP32
2876
+ // A destination whose name failed to resolve is refused for
2877
+ // GATEWAY_DNS_NEGATIVE_TTL_MS without another lookup (issue #453).
2878
+ // HTTPClient reports a failed lookup as "connection refused" after
2879
+ // the resolver's own patience -- on ESP32 not boundable by this
2880
+ // library -- and it did so on every attempt: the origin node's
2881
+ // retries and, since #451, the bridge's own sends each paid it, and
2882
+ // the scheduler stalled long enough for relayed requests to time
2883
+ // out on their origin nodes. Resolving here, once, names the real
2884
+ // failure and caps the stall to one lookup per TTL per host.
2885
+ // ESP32 only: the ESP8266 core bounds HTTPClient's own lookup by the
2886
+ // HTTP timeout, and a second bounded wait would not fit the
2887
+ // blocking budget.
2888
+ const TSTRING host = gateway::hostFromUrl(pkg.destination);
2889
+ if (host.length() > 0 && !gateway::looksLikeIpLiteral(host)) {
2890
+ uint32_t failedAgoMs = 0;
2891
+ if (negativeDns.isFailing(host, millis(), &failedAgoMs)) {
2892
+ char dnsBuf[160];
2893
+ snprintf(dnsBuf, sizeof(dnsBuf),
2894
+ "DNS lookup for %s failed %lus ago; not retried for "
2895
+ "another %lus",
2896
+ host.c_str(),
2897
+ static_cast<unsigned long>(failedAgoMs / 1000),
2898
+ static_cast<unsigned long>(
2899
+ (gateway::GATEWAY_DNS_NEGATIVE_TTL_MS - failedAgoMs) /
2900
+ 1000));
2901
+ Log(ERROR, "%s\n", dnsBuf);
2902
+ finish(false, 0, TSTRING(dnsBuf));
2903
+ return true;
2904
+ }
2905
+ IPAddress resolved;
2906
+ if (WiFi.hostByName(host.c_str(), resolved) != 1) {
2907
+ negativeDns.remember(host, millis());
2908
+ char dnsBuf[160];
2909
+ snprintf(dnsBuf, sizeof(dnsBuf), "DNS lookup failed for %s",
2910
+ host.c_str());
2911
+ Log(ERROR, "%s\n", dnsBuf);
2912
+ finish(false, 0, TSTRING(dnsBuf));
2913
+ return true;
2914
+ }
2915
+ }
2916
+ #endif // ESP32
2917
+
2876
2918
  // Make HTTP/HTTPS request
2877
2919
  HTTPClient http;
2878
2920
  http.setTimeout(GATEWAY_HTTP_TIMEOUT_MS);
@@ -2925,51 +2967,70 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2925
2967
  httpCode = http.GET();
2926
2968
  }
2927
2969
 
2928
- // Only specific 2xx status codes indicate genuine success:
2929
- // 200 OK, 201 Created, 202 Accepted, 204 No Content.
2930
- //
2931
- // Other 2xx codes like 203 (Non-Authoritative Information) often
2932
- // indicate cached/proxied responses that may not represent actual
2933
- // delivery to the destination service (e.g., WhatsApp API).
2970
+ // A 2xx is the origin's acceptance, except 203 (a proxy transformed
2971
+ // the response) and except when the body says the service refused
2972
+ // the request: CallMeBot answers "Too many requests" under 201 and
2973
+ // 203 (issue #450). The body is therefore read -- bounded -- before
2974
+ // classifying, and it is what the origin node is told on failure.
2934
2975
  //
2935
2976
  // 3xx redirects are not automatically followed.
2936
2977
  //
2937
2978
  // The classification lives in painlessmesh::gateway so the desktop
2938
2979
  // test suite can exercise it; this file is ESP-only and never
2939
2980
  // compiled by the Catch2 build.
2940
- const auto outcome = gateway::classifyHttpResult(httpCode);
2981
+ TSTRING responseHead;
2982
+ if (httpCode > 0) {
2983
+ responseHead =
2984
+ readResponseHead(http, gateway::GATEWAY_RESPONSE_HEAD_BYTES);
2985
+ }
2986
+ const auto outcome =
2987
+ gateway::classifyHttpResult(httpCode, responseHead);
2941
2988
  success = outcome.success;
2942
2989
 
2943
2990
  if (!outcome.transportError) {
2991
+ char errorBuf[192];
2992
+ const char* reason = outcome.reason.c_str();
2993
+ const char* sep = outcome.reason.length() > 0 ? ": " : "";
2944
2994
  if (success) {
2945
2995
  Log(COMMUNICATION, "HTTP request completed: code=%d\n", httpCode);
2946
- } else if (httpCode >= 200 && httpCode < 300) {
2947
- // Other 2xx codes - ambiguous success
2948
- // HTTP 203 is retryable, so log at COMMUNICATION level to reduce noise
2949
- char errorBuf[128];
2950
- snprintf(errorBuf, sizeof(errorBuf),
2951
- "Ambiguous response - HTTP %d may indicate cached/proxied response, not actual delivery",
2952
- httpCode);
2996
+ } else if (outcome.refusedByBody) {
2997
+ // Success-class status, refusing body. Not retryable: the
2998
+ // service answered, and the origin node gets its words.
2999
+ snprintf(errorBuf, sizeof(errorBuf),
3000
+ "HTTP %d: service refused the request%s%s", httpCode,
3001
+ sep, reason);
3002
+ error = TSTRING(errorBuf);
3003
+ Log(ERROR, "HTTP request refused by the service: %s\n", errorBuf);
3004
+ } else if (outcome.unverifiedStatus) {
3005
+ // A 2xx outside 200/201/202/204. CallMeBot answers 208 to a
3006
+ // message that never arrives (issue #452), so this is a
3007
+ // failure, and the body -- the only place the service says
3008
+ // what it did -- goes to the origin node and to the log at
3009
+ // ERROR level, where a sketch running the default levels
3010
+ // sees it. 203 stays retryable; the rest are final.
3011
+ snprintf(errorBuf, sizeof(errorBuf),
3012
+ "HTTP %d: not a delivery the gateway can confirm%s%s",
3013
+ httpCode, sep, reason);
2953
3014
  error = TSTRING(errorBuf);
2954
- Log(COMMUNICATION, "HTTP request ambiguous: code=%d (treated as failure, will retry)\n", httpCode);
3015
+ Log(ERROR, "HTTP response unverified: %s\n", errorBuf);
2955
3016
  } else if (httpCode >= 500 && httpCode < 600) {
2956
3017
  // 5xx server errors are retryable, log at COMMUNICATION level
2957
- char errorBuf[32];
2958
- snprintf(errorBuf, sizeof(errorBuf), "HTTP %d", httpCode);
3018
+ snprintf(errorBuf, sizeof(errorBuf), "HTTP %d%s%s", httpCode, sep,
3019
+ reason);
2959
3020
  error = TSTRING(errorBuf);
2960
3021
  Log(COMMUNICATION, "HTTP server error: code=%d (will retry)\n", httpCode);
2961
3022
  } else if (httpCode == 429) {
2962
3023
  // HTTP 429 rate limit is retryable, log at COMMUNICATION level
2963
- char errorBuf[32];
2964
- snprintf(errorBuf, sizeof(errorBuf), "HTTP %d", httpCode);
3024
+ snprintf(errorBuf, sizeof(errorBuf), "HTTP %d%s%s", httpCode, sep,
3025
+ reason);
2965
3026
  error = TSTRING(errorBuf);
2966
3027
  Log(COMMUNICATION, "HTTP rate limit: code=%d (will retry)\n", httpCode);
2967
3028
  } else {
2968
3029
  // 1xx, 3xx, 4xx (except 429) - non-retryable, log at ERROR level
2969
- char errorBuf[32];
2970
- snprintf(errorBuf, sizeof(errorBuf), "HTTP %d", httpCode);
3030
+ snprintf(errorBuf, sizeof(errorBuf), "HTTP %d%s%s", httpCode, sep,
3031
+ reason);
2971
3032
  error = TSTRING(errorBuf);
2972
- Log(ERROR, "HTTP request failed: code=%d\n", httpCode);
3033
+ Log(ERROR, "HTTP request failed: %s\n", errorBuf);
2973
3034
  }
2974
3035
  } else {
2975
3036
  // Network errors (httpCode <= 0) are retryable but indicate serious issues
@@ -2993,6 +3054,20 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2993
3054
  });
2994
3055
  }
2995
3056
 
3057
+ /**
3058
+ * The station just got an IP. On a node that runs the Internet health
3059
+ * check -- a bridge or a shared gateway, whose station is the manual router
3060
+ * link -- the last probe most likely ran during association and failed,
3061
+ * and the next is a full interval away (issue #450). Probe again now, from
3062
+ * the scheduler rather than the Wi-Fi event task, so hasLocalInternet()
3063
+ * and the bridge's own sends stop waiting out the interval. A regular
3064
+ * node's mesh station never enables the check and is left alone.
3065
+ */
3066
+ void probeUplinkAfterAssociation() {
3067
+ if (!this->isInternetHealthCheckEnabled() || !stationScan.manual) return;
3068
+ this->addTask([this]() { this->checkInternetNow(); });
3069
+ }
3070
+
2996
3071
  void eventHandleInit() {
2997
3072
  using namespace logger;
2998
3073
  // Where the station scan learns the channel the mesh is rooted on.
@@ -3101,6 +3176,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
3101
3176
  this->stationScan.stationAttemptOver();
3102
3177
  this->stationScan.stationUp();
3103
3178
  this->tcpConnect(); // Connect to TCP port
3179
+ this->probeUplinkAfterAssociation();
3104
3180
  this->semaphoreGive();
3105
3181
  }
3106
3182
  },
@@ -3137,6 +3213,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
3137
3213
  this->stationScan.stationAttemptOver();
3138
3214
  this->stationScan.stationUp();
3139
3215
  this->tcpConnect(); // Connect to TCP port
3216
+ this->probeUplinkAfterAssociation();
3140
3217
  });
3141
3218
  #endif // ESP32
3142
3219
  return;
@@ -76,6 +76,13 @@ class PackageCallbackList {
76
76
  return size;
77
77
  }
78
78
 
79
+ /** How many callbacks are registered for one package id. */
80
+ size_t count(int id) {
81
+ auto generation = clearPending ? pendingCallbackMap : callbackMap;
82
+ auto it = generation->find(id);
83
+ return it == generation->end() ? 0 : it->second.size();
84
+ }
85
+
79
86
  void clear() {
80
87
  if (dispatchDepth > 0) {
81
88
  clearPending = true;