@alteriom/painlessmesh 2.0.1 → 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,61 @@ 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
+
10
65
  ## [2.0.1] - 2026-09-07
11
66
 
12
67
  A packaging and documentation release. **No library behaviour changed**: the
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.1 (September 7, 2026)
599
+ ## Latest Release: v2.0.2 (September 8, 2026)
600
600
 
601
- **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 carries no library archive because the upload was refused by an immutable release. Both are fixed; upgrading from 2.0.0 is optional.
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.
602
604
 
603
605
  **2.0.0 — delivery confirmation, a unified send path, and a mesh that holds together on real hardware**
604
606
 
package/RELEASE_GUIDE.md CHANGED
@@ -82,12 +82,24 @@ The pull request must also pass all required GitHub checks, including:
82
82
  - formatting and documentation checks
83
83
  - CodeQL
84
84
  - simulator scenarios
85
- - 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
86
86
  touches radio, routing, gateway, OTA, or platform-specific behavior
87
87
 
88
- For a hardware defect, attach the HIL run identifier and report link to the
89
- pull request. A unit test alone is not sufficient evidence for a radio or
90
- 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).
91
103
 
92
104
  ## Review gate
93
105
 
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.1",
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.1
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.1",
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.1"
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 1
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()) {