@alteriom/painlessmesh 2.0.0 → 2.0.2

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,115 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.2] - 2026-09-08
11
+
12
+ Two gateway defects that reached users on 2.0.1, both in code the desktop test
13
+ suite cannot compile, and both now asserted on real hardware. **Upgrade if a
14
+ node of yours is a bridge**: on 2.0.1 a bridge could not use its own uplink at
15
+ all, and any request that failed below HTTP was reported to the origin node as
16
+ `HTTP 65535`.
17
+
18
+ ### Fixed
19
+
20
+ - **A bridge could not reach the Internet through its own uplink** (#445).
21
+ `sendToInternet()` serves a request locally only when `hasLocalInternet()` is
22
+ true. That flag is set by the Internet health checker, and `initAsBridge()`
23
+ never started it — only `initAsSharedGateway()` did — so on a bridge it was
24
+ false for the life of the node. Every request the bridge itself made fell
25
+ through to the mesh-routing path and failed with *"No active mesh
26
+ connections"* whenever no peer had joined yet, which is exactly what a
27
+ single-node sketch does at start-up. `initAsBridge()` now starts the checker,
28
+ without touching a `setInternetCheckTarget()` the sketch made before `init()`.
29
+
30
+ - **A request that never reached a server was reported as `HTTP 65535`** (#446).
31
+ `HTTPClient` returns an `int`: positive is an HTTP status, negative is one of
32
+ its own transport errors (`-1` refused, `-11` read timeout). The gateway
33
+ stored that in a `uint16_t`, so `-1` wrapped to 65535 — a value that passes
34
+ `httpCode > 0`. Three things followed: the origin node was told "HTTP 65535",
35
+ the `errorToString()` branch was unreachable, and `handleGatewayAck()` filed a
36
+ retryable network failure as a permanent HTTP status. A transport failure now
37
+ arrives as `httpStatus 0` with the real reason in the error string, which is
38
+ what the origin node already treats as retryable.
39
+
40
+ - **A lone gateway consumed its retries without issuing a single request.**
41
+ `retryInternetRequest()` knew only the remote path: it required an active mesh
42
+ connection and then routed to a discovered bridge, so a gateway serving its
43
+ own request satisfied neither check and reported *"Max retries exceeded"* in
44
+ place of the transport error that actually happened — the same dishonest
45
+ failure as #446, one layer up. A retry whose gateway is this node now goes
46
+ back to the local handler.
47
+
48
+ ### Changed
49
+
50
+ - The HTTP result classification moved into `gateway::classifyHttpResult()`, so
51
+ the desktop test suite exercises the same function `wifi.hpp` calls. The
52
+ previous test duplicated the classification — and duplicated the `uint16_t`
53
+ with it, which is why it mirrored #446 instead of catching it.
54
+
55
+ ### Validated
56
+
57
+ Both fixes are asserted on the ESP32 farm, not only in desktop tests: a bridge
58
+ must report Internet on its own uplink, must relay its own request through it,
59
+ must do so with **no mesh peer at all**, and must return `httpStatus 0` with a
60
+ real reason for a refused port, an unresolvable host and a destination that
61
+ answers past the timeout. Those rows did not exist when 2.0.1 shipped — the
62
+ gateway suite passed on the run that carried both bugs.
63
+
64
+
65
+ ## [2.0.1] - 2026-09-07
66
+
67
+ A packaging and documentation release. **No library behaviour changed**: the
68
+ mesh, routing, gateway, failover and OTA code of 2.0.1 is 2.0.0's, and the only
69
+ edit under `src/` is the version macro. It exists to repair two defects in what
70
+ 2.0.0 *shipped*, both of which cost a user time before they ever compile
71
+ anything.
72
+
73
+ ### Fixed
74
+
75
+ - **The installation instructions shipped in 2.0.0 name a PlatformIO package
76
+ that has no 2.0.0.** The README, user guide and documentation site gave
77
+ `alteriom/AlteriomPainlessMesh`, and that registry owner stops at 1.10.0 — its
78
+ account is not one this project can publish from. Every automated release
79
+ since 1.7.6 went out under `sparck75`, which is now what the documentation
80
+ names. **Name the owner in `lib_deps`**: the bare name `AlteriomPainlessMesh`
81
+ matches both owners and PlatformIO warns rather than choosing.
82
+
83
+ ```ini
84
+ lib_deps =
85
+ sparck75/AlteriomPainlessMesh@^2.0.0
86
+ ```
87
+
88
+ - **The v2.0.0 GitHub release carries no library archive.** This repository
89
+ publishes immutable releases, so the workflow's upload step — which ran after
90
+ the release was created — was refused with HTTP 422. The job failed before its
91
+ release decision, which also skipped the npm, GitHub Packages and PlatformIO
92
+ jobs; those three were published by hand. The archive is now attached in the
93
+ same call that creates the release, and the decision to publish is taken
94
+ before any step that can fail, so a failed upload can no longer suppress the
95
+ publications.
96
+
97
+ - **PlatformIO publication went under the token's account rather than the
98
+ organisation.** `pio pkg publish` files a package under the account unless
99
+ `--owner` names the owner, and the workflow never passed it — which is how the
100
+ duplicate ownership above arose in the first place. It now passes `--owner`,
101
+ checks the version against that owner's own version table (the previous check
102
+ read a `Version:` line `pio pkg show` never prints), publishes the release
103
+ tag's sources rather than whichever branch was dispatched, and treats an
104
+ already-listed version as success instead of a failure.
105
+
106
+ - **`scripts/validate-release.sh` reported a version mismatch that was not
107
+ there.** Without `jq` it fell back to a grep that matches every `"version"`
108
+ line in `library.json`, dependencies included, so the value it compared was
109
+ multi-line. It takes the first match now — the top-level one. CI was never
110
+ affected; it has `jq`.
111
+
112
+ ### Upgrading
113
+
114
+ Nothing in the library behaves differently, so upgrading from 2.0.0 is optional.
115
+ Take it if you install through PlatformIO, or if you want the release archive
116
+ attached to the GitHub release. Everything in the 2.0.0 entry below still
117
+ applies, including its *Before you upgrade* section.
118
+
10
119
  ## [2.0.0] - 2026-09-07
11
120
 
12
121
  painlessMesh 2.0 is a major release. It adds per-message delivery
@@ -317,6 +426,11 @@ processes scan results from the loop for that reason (see the ESP32 entry).
317
426
  every PR like the other twenty.
318
427
  - `keywords.txt` now lists the bridge, gateway, failover, queue and capacity
319
428
  API so the Arduino IDE highlights it.
429
+ - **PlatformIO package owner.** 2.0.0 is published as `sparck75/AlteriomPainlessMesh`,
430
+ the owner every automated release since 1.7.6 went under. The registry also
431
+ holds `alteriom/AlteriomPainlessMesh`, which stops at 1.10.0: its account is
432
+ not one the project can publish from. Name the owner in `lib_deps`; the bare
433
+ name matches both.
320
434
 
321
435
  ### Added (post-review series)
322
436
 
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![Release](https://github.com/Alteriom/painlessMesh/actions/workflows/release.yml/badge.svg)](https://github.com/Alteriom/painlessMesh/actions/workflows/release.yml)
12
12
  [![GitHub release](https://img.shields.io/github/v/release/Alteriom/painlessMesh?label=version)](https://github.com/Alteriom/painlessMesh/releases)
13
13
  [![NPM Version](https://img.shields.io/npm/v/@alteriom/painlessmesh?label=npm)](https://www.npmjs.com/package/@alteriom/painlessmesh)
14
- [![PlatformIO Registry](https://badges.registry.platformio.org/packages/alteriom/library/AlteriomPainlessMesh.svg)](https://registry.platformio.org/libraries/alteriom/AlteriomPainlessMesh)
14
+ [![PlatformIO Registry](https://badges.registry.platformio.org/packages/sparck75/library/AlteriomPainlessMesh.svg)](https://registry.platformio.org/libraries/sparck75/AlteriomPainlessMesh)
15
15
 
16
16
  </div>
17
17
 
@@ -410,11 +410,11 @@ git clone https://github.com/Alteriom/painlessMesh.git AlteriomPainlessMesh
410
410
 
411
411
  ### PlatformIO
412
412
 
413
- The library is published on the [PlatformIO registry](https://registry.platformio.org/libraries/alteriom/AlteriomPainlessMesh) as `alteriom/AlteriomPainlessMesh`:
413
+ The library is published on the [PlatformIO registry](https://registry.platformio.org/libraries/sparck75/AlteriomPainlessMesh) as `sparck75/AlteriomPainlessMesh`. The registry also lists an `alteriom/AlteriomPainlessMesh` that stops at 1.10.0; its owner account is not one the project can publish from, so 2.0.0 and later live under `sparck75`, which has carried every automated release since 1.7.6. Name the owner in `lib_deps`, since the bare name matches both:
414
414
 
415
415
  ```ini
416
416
  lib_deps =
417
- alteriom/AlteriomPainlessMesh@^2.0.0
417
+ sparck75/AlteriomPainlessMesh@^2.0.0
418
418
  ```
419
419
 
420
420
  ### Dependencies
@@ -596,9 +596,13 @@ 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.0 (September 7, 2026)
599
+ ## Latest Release: v2.0.2 (September 8, 2026)
600
600
 
601
- **Delivery confirmation, a unified send path, and a mesh that holds together on real hardware**
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.
602
+
603
+ **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
+
605
+ **2.0.0 — delivery confirmation, a unified send path, and a mesh that holds together on real hardware**
602
606
 
603
607
  - `sendSingle()` and `sendBroadcast()` accept a delivery callback — it fires with `delivered=true` and the round-trip latency on acknowledgment, or `delivered=false` on timeout — and a `SendOptions` struct that carries a priority and a callback in one call. Priority is kept across hops.
604
608
  - Thirty-odd defects in gateway failover, channel following, routing and the station scan, every one found in the serial logs of a six-family hardware rig and fixed there: a bridge that stops says so, the mesh follows an elected bridge to its channel and treats it as home, a node is in one place in every neighbour's tree, a dead connection is not a route, a stale scan does not consume a live one.
package/RELEASE_GUIDE.md CHANGED
@@ -21,7 +21,12 @@ the version commit reaches `main`.
21
21
  workflow owns tags and publication.
22
22
 
23
23
  The workflow tags only when the push to `main` carries a version-file
24
- change or a head commit whose message starts with `release:`. When the
24
+ change or a head commit whose message starts with `release:`. The
25
+ repository publishes immutable releases: a release takes its assets and
26
+ notes when it is created and cannot be edited afterwards, so a failed
27
+ publication is repaired by `manual-publish.yml` (npm, GitHub Packages) and
28
+ `platformio-publish.yml`, both dispatched with the tag, not by re-running
29
+ the release workflow. When the
25
30
  version metadata was bumped earlier in the cycle (as for 2.0.0), merge the
26
31
  release pull request with a commit titled `release: vX.Y.Z` — a squash or
27
32
  merge commit with that title — or the push is ignored.
@@ -77,12 +82,24 @@ The pull request must also pass all required GitHub checks, including:
77
82
  - formatting and documentation checks
78
83
  - CodeQL
79
84
  - simulator scenarios
80
- - ESP32, ESP32-C3, and ESP32-S3 hardware-in-the-loop coverage when the change
85
+ - hardware-in-the-loop coverage on the six-family board bank when the change
81
86
  touches radio, routing, gateway, OTA, or platform-specific behavior
82
87
 
83
- For a hardware defect, attach the HIL run identifier and report link to the
84
- pull request. A unit test alone is not sufficient evidence for a radio or
85
- multi-device timing fix.
88
+ ### How the hardware result arrives
89
+
90
+ The Alteriom ESP32 farm is a separate, private repository, so nothing in this
91
+ one triggers it — a public workflow can hold no credential for it. The farm
92
+ watches instead: it picks up `main` and open non-fork pull requests that touch
93
+ `src/`, `examples/`, `test/` or the library metadata, runs the simulator gate
94
+ and then the physical suite, and posts the verdict back as a **`farm/hil`
95
+ commit status** with a link to the run. Expect it within roughly half an hour
96
+ of a push; nothing is required of the author.
97
+
98
+ For a hardware defect, link that run in the pull request. A unit test alone is
99
+ not sufficient evidence for a radio or multi-device timing fix — and a desktop
100
+ test that re-implements the logic it is checking is not evidence at all
101
+ (`catch_http_status_codes.cpp` copied the `uint16_t` that caused #446 and so
102
+ mirrored the bug instead of catching it).
86
103
 
87
104
  ## Review gate
88
105
 
@@ -9,7 +9,7 @@ platform = espressif32
9
9
  board = esp32dev
10
10
  framework = arduino
11
11
  lib_deps =
12
- alteriom/AlteriomPainlessMesh
12
+ sparck75/AlteriomPainlessMesh
13
13
  bblanchon/ArduinoJson@^7.0.0
14
14
  monitor_speed = 115200
15
15
 
@@ -18,6 +18,6 @@ platform = espressif8266
18
18
  board = nodemcuv2
19
19
  framework = arduino
20
20
  lib_deps =
21
- alteriom/AlteriomPainlessMesh
21
+ sparck75/AlteriomPainlessMesh
22
22
  bblanchon/ArduinoJson@^7.0.0
23
23
  monitor_speed = 115200
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.0",
9
+ "version": "2.0.2",
10
10
  "frameworks": [
11
11
  "arduino"
12
12
  ],
@@ -1,5 +1,5 @@
1
1
  name=Alteriom PainlessMesh
2
- version=2.0.0
2
+ version=2.0.2
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.0",
3
+ "version": "2.0.2",
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.0"
32
+ #define ALTERIOM_PAINLESS_MESH_VERSION "2.0.2"
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 0
35
+ #define ALTERIOM_PAINLESS_MESH_VERSION_PATCH 2
36
36
 
37
37
  /**
38
38
  * @brief Library description and usage information
@@ -492,6 +492,20 @@ class Mesh : public painlessmesh::Mesh<Connection> {
492
492
  // Step 6: Setup gateway Internet handler
493
493
  initGatewayInternetHandler();
494
494
 
495
+ // Step 7: Start the health checker that drives hasLocalInternet().
496
+ // Without it that flag stays false for the life of the node, so the
497
+ // local short-circuit in sendToInternet() never fires and a bridge
498
+ // asking its own uplink fell through to the mesh-routing path -- which
499
+ // fails with "No active mesh connections" whenever the bridge has no
500
+ // peer yet, even though the router link is up (issue #445).
501
+ // initAsSharedGateway() has always done this; initAsBridge() did not.
502
+ //
503
+ // No configureInternetHealthCheck() call here: a bridge has no
504
+ // SharedGatewayConfig, the checker's own defaults (8.8.8.8:53, 30 s,
505
+ // 5 s) already match that struct's, and configuring would clobber any
506
+ // setInternetCheckTarget() the sketch made before init.
507
+ enableInternetHealthCheck();
508
+
495
509
  Log(STARTUP, "=== Bridge Mode Active ===\n");
496
510
  Log(STARTUP, " Mesh SSID: %s\n", meshSSID.c_str());
497
511
  Log(STARTUP, " Mesh Channel: %d%s\n", detectedChannel,
@@ -2864,7 +2878,15 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2864
2878
  http.setTimeout(GATEWAY_HTTP_TIMEOUT_MS);
2865
2879
 
2866
2880
  bool success = false;
2867
- uint16_t httpCode = 0;
2881
+ // HTTPClient returns an int: a positive value is an HTTP status
2882
+ // code, a negative one is its own transport error (HTTPC_ERROR_*,
2883
+ // -1 for a refused connection, -11 for a read timeout, ...). This
2884
+ // was a uint16_t, which wrapped -1 to 65535 -- a value that passes
2885
+ // `httpCode > 0`, so every transport failure was reported to the
2886
+ // origin node as "HTTP 65535" (issue #446), the errorToString()
2887
+ // branch below was unreachable, and handleGatewayAck() classified a
2888
+ // transient network failure as a non-retryable HTTP status.
2889
+ int httpCode = 0;
2868
2890
  TSTRING error = "";
2869
2891
 
2870
2892
  #ifdef ESP8266
@@ -2903,21 +2925,22 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2903
2925
  httpCode = http.GET();
2904
2926
  }
2905
2927
 
2906
- if (httpCode > 0) {
2907
- // Only specific 2xx status codes indicate genuine success
2908
- // 200 OK: Standard successful response
2909
- // 201 Created: Resource successfully created
2910
- // 202 Accepted: Request accepted for processing
2911
- // 204 No Content: Successful with no response body
2912
- //
2913
- // Other 2xx codes like 203 (Non-Authoritative Information) often
2914
- // indicate cached/proxied responses that may not represent actual
2915
- // delivery to the destination service (e.g., WhatsApp API).
2916
- //
2917
- // 3xx redirects are not automatically followed
2918
- success = (httpCode == 200 || httpCode == 201 ||
2919
- httpCode == 202 || httpCode == 204);
2920
-
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).
2934
+ //
2935
+ // 3xx redirects are not automatically followed.
2936
+ //
2937
+ // The classification lives in painlessmesh::gateway so the desktop
2938
+ // test suite can exercise it; this file is ESP-only and never
2939
+ // compiled by the Catch2 build.
2940
+ const auto outcome = gateway::classifyHttpResult(httpCode);
2941
+ success = outcome.success;
2942
+
2943
+ if (!outcome.transportError) {
2921
2944
  if (success) {
2922
2945
  Log(COMMUNICATION, "HTTP request completed: code=%d\n", httpCode);
2923
2946
  } else if (httpCode >= 200 && httpCode < 300) {
@@ -2957,8 +2980,9 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2957
2980
 
2958
2981
  http.end();
2959
2982
 
2960
- // Send acknowledgment back
2961
- finish(success, httpCode, error);
2983
+ // Send acknowledgment back. Transport errors carry status 0, the
2984
+ // value handleGatewayAck() treats as a retryable network error.
2985
+ finish(success, outcome.ackStatus, error);
2962
2986
  #else
2963
2987
  // Non-ESP platform - send error
2964
2988
  finish(false, 0, "HTTP client not available on this platform");
@@ -965,6 +965,54 @@ class GatewayDataPackage : public plugin::SinglePackage {
965
965
 
966
966
  };
967
967
 
968
+ /**
969
+ * @brief The outcome of a gateway HTTP request, classified for the ack
970
+ *
971
+ * HTTPClient::GET()/POST() return an int with two distinct meanings: a
972
+ * positive value is an HTTP status code from the destination, while a
973
+ * negative value is one of the client's own transport errors (HTTPC_ERROR_*,
974
+ * e.g. -1 for a refused connection or -11 for a read timeout). Zero is not
975
+ * produced by either.
976
+ *
977
+ * GatewayAckPackage::httpStatus is a uint16_t, so a negative code cannot be
978
+ * forwarded as-is. Transport errors are reported as httpStatus 0, which is
979
+ * what Mesh::handleGatewayAck() already treats as a retryable network error;
980
+ * the human-readable cause travels in GatewayAckPackage::error instead.
981
+ */
982
+ struct HttpRequestOutcome {
983
+ /** True only for status codes that indicate genuine delivery. */
984
+ bool success = false;
985
+
986
+ /** Value to place in GatewayAckPackage::httpStatus (0 for transport errors). */
987
+ uint16_t ackStatus = 0;
988
+
989
+ /** True when the client failed before any HTTP status was received. */
990
+ bool transportError = false;
991
+ };
992
+
993
+ /**
994
+ * @brief Classify an HTTPClient return value for the gateway acknowledgment
995
+ *
996
+ * Only 200, 201, 202 and 204 count as success. Other 2xx codes (notably 203,
997
+ * Non-Authoritative Information) indicate a cached or proxied response rather
998
+ * than delivery to the destination service, so they are failures.
999
+ *
1000
+ * @param rawCode The int returned by HTTPClient::GET() or ::POST()
1001
+ * @return Classified outcome, safe to place in a GatewayAckPackage
1002
+ */
1003
+ inline HttpRequestOutcome classifyHttpResult(int rawCode) {
1004
+ HttpRequestOutcome outcome;
1005
+ if (rawCode <= 0) {
1006
+ outcome.transportError = true;
1007
+ return outcome;
1008
+ }
1009
+ outcome.ackStatus =
1010
+ static_cast<uint16_t>(rawCode > 0xFFFF ? 0xFFFF : rawCode);
1011
+ outcome.success = (rawCode == 200 || rawCode == 201 || rawCode == 202 ||
1012
+ rawCode == 204);
1013
+ return outcome;
1014
+ }
1015
+
968
1016
  /**
969
1017
  * @brief Gateway Acknowledgment Package for delivery confirmations
970
1018
  *
@@ -1546,8 +1546,17 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1546
1546
  void enableInternetHealthCheck() {
1547
1547
  using namespace logger;
1548
1548
  if (internetHealthCheckTask != nullptr) {
1549
- Log(GENERAL, "enableInternetHealthCheck(): Already enabled\n");
1550
- return;
1549
+ if (internetHealthCheckTask->isEnabled()) {
1550
+ Log(GENERAL, "enableInternetHealthCheck(): Already enabled\n");
1551
+ return;
1552
+ }
1553
+ // A stop()/re-init cycle -- what bridge promotion does before calling
1554
+ // initAsBridge() again -- disables every task and drops it from the
1555
+ // reusable pool, but this member still points at it. Refusing here
1556
+ // would leave a re-initialised node with no health check at all, and
1557
+ // so with hasLocalInternet() stuck false (issue #445). Re-arm instead,
1558
+ // releasing the spent task so the pool can reclaim it.
1559
+ internetHealthCheckTask = nullptr;
1551
1560
  }
1552
1561
 
1553
1562
  Log(GENERAL, "enableInternetHealthCheck(): Starting health check task (interval: %u ms)\n",
@@ -1678,20 +1687,8 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1678
1687
  request.callback = callback;
1679
1688
  pendingInternetRequests[messageId] = request;
1680
1689
 
1681
- gateway::GatewayDataPackage pkg;
1682
- pkg.from = this->nodeId;
1683
- pkg.dest = this->nodeId;
1684
- pkg.messageId = messageId;
1685
- pkg.originNode = this->nodeId;
1686
- pkg.timestamp = this->getNodeTime();
1687
- pkg.priority = priority;
1688
- pkg.destination = destination;
1689
- pkg.payload = payload;
1690
- pkg.contentType = "application/json";
1691
- pkg.retryCount = 0;
1692
- pkg.requiresAck = true;
1693
- protocol::Variant variant(&pkg);
1694
- this->callbackList.execute(protocol::GATEWAY_DATA, variant, nullptr, 0);
1690
+ dispatchInternetRequestLocally(messageId, priority, 0, destination,
1691
+ payload);
1695
1692
  return messageId;
1696
1693
  }
1697
1694
 
@@ -2041,6 +2038,37 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2041
2038
  }
2042
2039
  }
2043
2040
 
2041
+ /**
2042
+ * Run a gateway request against this node's own uplink
2043
+ *
2044
+ * The package goes to this node's own GATEWAY_DATA handler, so the
2045
+ * acknowledgment, pending-request tracking and callback semantics stay
2046
+ * identical to a request served by a peer gateway.
2047
+ *
2048
+ * The handler runs synchronously and its acknowledgment can erase the
2049
+ * pending entry, so callers must not hold a reference into
2050
+ * pendingInternetRequests across this call -- pass copies.
2051
+ */
2052
+ void dispatchInternetRequestLocally(uint32_t messageId, uint8_t priority,
2053
+ uint8_t retryCount,
2054
+ const TSTRING& destination,
2055
+ const TSTRING& payload) {
2056
+ gateway::GatewayDataPackage pkg;
2057
+ pkg.from = this->nodeId;
2058
+ pkg.dest = this->nodeId;
2059
+ pkg.messageId = messageId;
2060
+ pkg.originNode = this->nodeId;
2061
+ pkg.timestamp = this->getNodeTime();
2062
+ pkg.priority = priority;
2063
+ pkg.destination = destination;
2064
+ pkg.payload = payload;
2065
+ pkg.contentType = "application/json";
2066
+ pkg.retryCount = retryCount;
2067
+ pkg.requiresAck = true;
2068
+ protocol::Variant variant(&pkg);
2069
+ this->callbackList.execute(protocol::GATEWAY_DATA, variant, nullptr, 0);
2070
+ }
2071
+
2044
2072
  /**
2045
2073
  * Schedule retry for a failed Internet request
2046
2074
  */
@@ -2087,6 +2115,28 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2087
2115
 
2088
2116
  PendingInternetRequest& request = it->second;
2089
2117
 
2118
+ // A request this node is serving from its own uplink never had a mesh
2119
+ // hop to repeat. The routing path below needs an active peer and a
2120
+ // discovered bridge, so a lone gateway would burn every retry without
2121
+ // issuing a single HTTP request and then report "Max retries exceeded"
2122
+ // in place of the transport error that actually occurred -- the same
2123
+ // dishonest failure issue #446 was about.
2124
+ //
2125
+ // Copy what the redispatch needs first: the local handler runs
2126
+ // synchronously and can erase this entry, leaving `request` dangling.
2127
+ if (request.gatewayNodeId == this->nodeId && hasLocalInternet()) {
2128
+ const uint8_t priority = request.priority;
2129
+ const uint8_t retryCount = request.retryCount;
2130
+ const TSTRING destination = request.destination;
2131
+ const TSTRING payload = request.payload;
2132
+ Log(logger::COMMUNICATION,
2133
+ "retryInternetRequest(): Retrying msgId=%u on the local uplink\n",
2134
+ messageId);
2135
+ dispatchInternetRequestLocally(messageId, priority, retryCount,
2136
+ destination, payload);
2137
+ return;
2138
+ }
2139
+
2090
2140
  // Check mesh connectivity before attempting retry
2091
2141
  // During bridge failover, connection may be temporarily lost
2092
2142
  if (!hasActiveMeshConnections()) {