@alteriom/painlessmesh 1.10.0 → 2.0.1

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.
Files changed (55) hide show
  1. package/BRIDGE_TO_INTERNET.md +167 -29
  2. package/CHANGELOG.md +542 -0
  3. package/CONTRIBUTING.md +56 -53
  4. package/README.md +102 -95
  5. package/RELEASE_GUIDE.md +94 -788
  6. package/examples/alteriom/README.md +8 -10
  7. package/examples/alteriom/alteriom.ino +2 -2
  8. package/examples/alteriom/alteriom_sensor_package.hpp +17 -11
  9. package/examples/alteriom/mppt_example/alteriom_custom_package_template.hpp +320 -0
  10. package/examples/alteriom/mppt_example/alteriom_sensor_package.hpp +1389 -0
  11. package/examples/alteriom/mppt_example/{alteriom_mppt_example.ino → mppt_example.ino} +4 -0
  12. package/examples/basic/test/simulator/README.md +3 -3
  13. package/examples/bridge_failover/README.md +51 -14
  14. package/examples/commandControl/commandControl.ino +86 -0
  15. package/examples/commandControl/platformio.ini +26 -0
  16. package/examples/mqttBridge/mqttBridge.ino +4 -0
  17. package/examples/mqttBridge/platformio.ini +1 -1
  18. package/examples/otaSender/otaSender.ino +5 -1
  19. package/examples/priority/README.md +1 -1
  20. package/examples/priority/{priority_basic_example.ino → priority_basic_example/priority_basic_example.ino} +4 -4
  21. package/examples/priority/{priority_with_queue.ino → priority_with_queue/priority_with_queue.ino} +20 -2
  22. package/examples/reliableSensorLogging/platformio.ini +26 -0
  23. package/examples/reliableSensorLogging/reliableSensorLogging.ino +151 -0
  24. package/examples/sendToInternet/README.md +12 -5
  25. package/examples/sendToInternet/{CMakeLists.txt → pc_node/CMakeLists.txt} +7 -7
  26. package/examples/sendToInternet/{PC_NODE_README.md → pc_node/PC_NODE_README.md} +15 -15
  27. package/examples/sendToInternet/{build.sh → pc_node/build.sh} +5 -5
  28. package/examples/sendToInternet/{pc_mesh_node.cpp → pc_node/pc_mesh_node.cpp} +12 -1
  29. package/examples/sendToInternet/platformio.ini +2 -2
  30. package/examples/sharedGateway/README.md +1 -2
  31. package/keywords.txt +50 -1
  32. package/library.json +8 -6
  33. package/library.properties +2 -2
  34. package/package.json +3 -3
  35. package/src/AlteriomPainlessMesh.h +4 -4
  36. package/src/arduino/wifi.hpp +556 -126
  37. package/src/painlessMesh.h +2 -2
  38. package/src/painlessMeshSTA.cpp +607 -87
  39. package/src/painlessMeshSTA.h +135 -3
  40. package/src/painlessmesh/ack.hpp +283 -0
  41. package/src/painlessmesh/buffer.hpp +70 -8
  42. package/src/painlessmesh/callback.hpp +38 -5
  43. package/src/painlessmesh/configuration.hpp +69 -1
  44. package/src/painlessmesh/connection.hpp +12 -5
  45. package/src/painlessmesh/gateway.hpp +270 -5
  46. package/src/painlessmesh/layout.hpp +70 -2
  47. package/src/painlessmesh/logger.hpp +15 -0
  48. package/src/painlessmesh/mesh.hpp +552 -48
  49. package/src/painlessmesh/ntp.hpp +2 -4
  50. package/src/painlessmesh/plugin.hpp +30 -6
  51. package/src/painlessmesh/protocol.hpp +55 -2
  52. package/src/painlessmesh/router.hpp +192 -77
  53. package/src/painlessmesh/tcp.hpp +10 -0
  54. package/src/painlessmesh/message_tracker.hpp +0 -311
  55. /package/examples/sendToInternet/{mock_server_test.ino → mock_server_test/mock_server_test.ino} +0 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,547 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.1] - 2026-09-07
11
+
12
+ A packaging and documentation release. **No library behaviour changed**: the
13
+ mesh, routing, gateway, failover and OTA code of 2.0.1 is 2.0.0's, and the only
14
+ edit under `src/` is the version macro. It exists to repair two defects in what
15
+ 2.0.0 *shipped*, both of which cost a user time before they ever compile
16
+ anything.
17
+
18
+ ### Fixed
19
+
20
+ - **The installation instructions shipped in 2.0.0 name a PlatformIO package
21
+ that has no 2.0.0.** The README, user guide and documentation site gave
22
+ `alteriom/AlteriomPainlessMesh`, and that registry owner stops at 1.10.0 — its
23
+ account is not one this project can publish from. Every automated release
24
+ since 1.7.6 went out under `sparck75`, which is now what the documentation
25
+ names. **Name the owner in `lib_deps`**: the bare name `AlteriomPainlessMesh`
26
+ matches both owners and PlatformIO warns rather than choosing.
27
+
28
+ ```ini
29
+ lib_deps =
30
+ sparck75/AlteriomPainlessMesh@^2.0.0
31
+ ```
32
+
33
+ - **The v2.0.0 GitHub release carries no library archive.** This repository
34
+ publishes immutable releases, so the workflow's upload step — which ran after
35
+ the release was created — was refused with HTTP 422. The job failed before its
36
+ release decision, which also skipped the npm, GitHub Packages and PlatformIO
37
+ jobs; those three were published by hand. The archive is now attached in the
38
+ same call that creates the release, and the decision to publish is taken
39
+ before any step that can fail, so a failed upload can no longer suppress the
40
+ publications.
41
+
42
+ - **PlatformIO publication went under the token's account rather than the
43
+ organisation.** `pio pkg publish` files a package under the account unless
44
+ `--owner` names the owner, and the workflow never passed it — which is how the
45
+ duplicate ownership above arose in the first place. It now passes `--owner`,
46
+ checks the version against that owner's own version table (the previous check
47
+ read a `Version:` line `pio pkg show` never prints), publishes the release
48
+ tag's sources rather than whichever branch was dispatched, and treats an
49
+ already-listed version as success instead of a failure.
50
+
51
+ - **`scripts/validate-release.sh` reported a version mismatch that was not
52
+ there.** Without `jq` it fell back to a grep that matches every `"version"`
53
+ line in `library.json`, dependencies included, so the value it compared was
54
+ multi-line. It takes the first match now — the top-level one. CI was never
55
+ affected; it has `jq`.
56
+
57
+ ### Upgrading
58
+
59
+ Nothing in the library behaves differently, so upgrading from 2.0.0 is optional.
60
+ Take it if you install through PlatformIO, or if you want the release archive
61
+ attached to the GitHub release. Everything in the 2.0.0 entry below still
62
+ applies, including its *Before you upgrade* section.
63
+
64
+ ## [2.0.0] - 2026-09-07
65
+
66
+ painlessMesh 2.0 is a major release. It adds per-message delivery
67
+ confirmation and a unified send path, and it is the first release whose
68
+ gateway, failover, routing and radio behaviour was validated on hardware:
69
+ every entry in the *hardware-validated series* below was found in the serial
70
+ logs of the Alteriom HIL rig — an ESP32, ESP32-C3, ESP32-C5, ESP32-C6,
71
+ ESP32-S3 and ESP8266 in one mesh, with a real router and upstream — and
72
+ confirmed there. The release candidate passed the rig's whole suite (26
73
+ scenarios: mesh formation, delivery and acknowledgement, priorities,
74
+ dedicated and shared gateways, Internet relay, gateway failover, mesh OTA,
75
+ sustained soak) three times in a row, 25 passed and 1 skipped per run; the
76
+ skip is the power-cut scenario, which needs per-port USB power the rig does
77
+ not have.
78
+
79
+ ### Before you upgrade
80
+
81
+ **Wire protocol.** 2.0 adds to the protocol; it does not change what 1.x
82
+ nodes already send. A 1.x node forwards what it does not understand and
83
+ ignores fields it does not know, so a mixed fleet keeps working, but the
84
+ new behaviour only holds end-to-end once every node on the path runs 2.0:
85
+
86
+ - `MESSAGE_ACK` (type 630) — the acknowledgement a 2.0 receiver returns for
87
+ a message sent with a delivery callback. A 1.x receiver never sends one,
88
+ so the sender's callback reports `delivered = false` for it.
89
+ - `msgId` on `SINGLE` and `BROADCAST`, only when a callback was requested.
90
+ - `prio` on `SINGLE` and `BROADCAST`, only when it differs from normal; a
91
+ 1.x forwarder ignores it and forwards at normal priority.
92
+ - `routerChannel` on `BRIDGE_ELECTION` and `BRIDGE_TAKEOVER`, so peers
93
+ follow an elected bridge to its channel at once; a peer that misses it
94
+ still recovers by scanning.
95
+ - `leaving: true` on `BRIDGE_STATUS` when a bridge stops cleanly
96
+ (`mesh.stop()`), so candidates hold their election within seconds instead
97
+ of waiting for the bridge's last status to age out.
98
+
99
+ **Upgrade receivers and forwarders before the senders that will rely on
100
+ delivery callbacks or priorities**, and only read `delivered = false` as a
101
+ loss signal once the whole mesh runs 2.0.
102
+
103
+ **Defaults that changed.**
104
+
105
+ - `GATEWAY_HTTP_TIMEOUT_MS` was a hardcoded 30 s; it is now derived from
106
+ `NODE_TIMEOUT` (2000 ms at the stock 10 s watchdog), the captive-portal
107
+ probe is bounded by `GATEWAY_CAPTIVE_PORTAL_TIMEOUT_MS` (1000 ms) and cached
108
+ for 60 s, and the DNS probe by `GATEWAY_DNS_TIMEOUT_MS`. A gateway that
109
+ needs longer must raise `NODE_TIMEOUT` with it; a `static_assert` says so.
110
+ - A node that finds no mesh re-detects the channel after 2 empty scans
111
+ (about 30 s) instead of 6 (90 s).
112
+ - The Arduino core's station auto-reconnect is off for the mesh link on
113
+ every core; the library reconnects by its own scan rules. A bridge's
114
+ router link keeps the core's auto-reconnect.
115
+ - On ESP8266 the library checks free heap every 30 s and logs an `ERROR`
116
+ below 12 KB with more than one child attached; see the ESP8266 entry.
117
+
118
+ **Arduino cores.** The ESP32 Arduino core 2.0.x and 3.x are both supported;
119
+ the ESP32-C5 and ESP32-C6 need 3.x. Core 3.x dispatches Wi-Fi events under a
120
+ lock that forbids reconfiguring the radio from inside a callback; 2.0
121
+ processes scan results from the loop for that reason (see the ESP32 entry).
122
+
123
+ ### Added (hardware-validated series)
124
+
125
+ - **`LogClass::setSink()`** (#427) — a sketch whose serial port carries a
126
+ line protocol can take the library's log lines through a callback and
127
+ frame them itself, instead of having them printed from the Wi-Fi event
128
+ task into the middle of its own output.
129
+ - **`overCapacity()` and `apChildren()`** (#432) — whether an ESP8266 is
130
+ below 12 KB free with more than one child attached, and how many children
131
+ are attached, for sketches that want to warn or shed load.
132
+ - **`setContainsRoot()`** is now what a mesh that contains a bridge should
133
+ set on every node: it lets a node that is connected to a partition the
134
+ bridge has left notice it has no root and go looking for the bridge's
135
+ channel (#427). `initAsBridge()` and the failover path set it.
136
+
137
+ ### Fixed — gateway and failover (hardware-validated series)
138
+
139
+ - **Gateway takeover left the mesh partitioned on the old radio channel
140
+ (#424)** — a failover candidate correctly moved its AP+STA radio to the
141
+ Internet router's channel after election, but peers learned only that a
142
+ takeover occurred, not which channel to follow. They remained disconnected
143
+ until the slow all-channel recovery scan, exceeding the failover deadline
144
+ and leaving `getPrimaryGateway()` at zero. Election and takeover packages
145
+ now carry the candidate's router channel. Peers validate the announcement,
146
+ discard stale scan state, move both interfaces after the takeover has
147
+ propagated, and resume discovery immediately. A missed or older takeover
148
+ message remains compatible with the scan-based recovery path.
149
+
150
+ - **A bridge that stops cleanly says so (#435)** — the backup that booted
151
+ beside a live primary took its status and held it healthy until the
152
+ status aged out (`bridgeTimeoutMs`, 60 s), then waited for the 30 s
153
+ monitor tick, and missed the rig's 120 s promotion window whenever the
154
+ primary left shortly after it came up. `mesh.stop()` on a bridge now
155
+ broadcasts a status marked `leaving` before closing its connections; every
156
+ node forgets that bridge at once (`forgetBridge()`), a candidate checks
157
+ for a bridge as soon as the startup period allows instead of at the next
158
+ tick, and the "too soon after last role change" hold schedules a retry
159
+ for when it ends rather than returning silently. A bridge that loses power
160
+ still announces nothing; that path is as slow as the status timeout.
161
+
162
+ - **A backup joins the mesh on the router's channel (#435)** — a candidate
163
+ with router credentials used to pick the strongest mesh AP across a split
164
+ mesh; it now joins on the router's channel, where a bridge would be. The
165
+ election's router scan waits on the driver's scanning bit instead of
166
+ `WiFi.scanComplete()`, whose 20-dwell timeout declared the station's own
167
+ all-channel scan failed at 2.4 s and let the two scans collide.
168
+
169
+ - **Home is the channel the bridge's status names (#435)** — a rootless
170
+ node keeps the channel it was last rooted on as home: it does not leave
171
+ home for a partition elsewhere, and away from home it goes back as soon as
172
+ the mesh is visible there, whatever the sizes, since a freshly promoted
173
+ bridge is one AP on the router's channel. Home is learned from the
174
+ bridge's status message, not from a cached tree that could still carry a
175
+ bridge that has gone, and it is forgotten after four re-detections that
176
+ found the mesh elsewhere and no root here. A topology change on the bridge
177
+ brings its status broadcast forward (at most once per 5 s), so a node
178
+ joining anywhere in the tree hears the bridge within seconds.
179
+
180
+ - **A leaf at home, or a failover candidate, does not leave a rootless
181
+ partition (#435)** — the rule that sends a leaf looking for the root had
182
+ fired on the backup itself and dropped its own link in the middle of the
183
+ election.
184
+
185
+ - **A failover candidate is the only node that runs the bridge check
186
+ (#435)** — regular nodes without router credentials no longer schedule
187
+ elections they cannot join.
188
+
189
+ - **Shared-gateway local Internet health never became operational (#422)**
190
+ — the TCP probe used by `InternetHealthChecker` was unimplemented on
191
+ ESP32/ESP8266 and the health task was never started, so
192
+ `hasLocalInternet()` stayed false for the life of a node and every request
193
+ it could have served itself went to a mesh gateway. Shared-gateway
194
+ initialisation now configures and starts the health monitor, a node with
195
+ its own healthy uplink executes Internet requests locally, and local
196
+ gateway acknowledgements complete through the pending-request path.
197
+
198
+ - **ESP8266 health check timeout was set in seconds, not milliseconds
199
+ (#426)** — the 5000 ms check became a 5 ms `WiFiClient` timeout, DNS never
200
+ resolved inside it, and an ESP8266 shared gateway never reported local
201
+ Internet.
202
+
203
+ - **A promoted backup's HTTP result could not reach the requester (#423)**
204
+ — an armed requester-route watchdog is preserved across bounded gateway
205
+ HTTP work and `GATEWAY_ACK` returns through the request's ingress while
206
+ the newly promoted route converges. Automatic channel discovery retries
207
+ when a node first sees no peer, reconfiguring the AP once the channel is
208
+ known.
209
+
210
+ - **A bridge whose TCP listener is not listening re-creates it, and a node
211
+ re-initialised in place keeps the one it has (#430, #435)** — `stop()`
212
+ deleted the listener and the re-bind hit `ERR_USE` for the 2×MSL
213
+ `TIME_WAIT` (120 s), so a node that re-initialised (a promotion, a return
214
+ to regular mode) accepted nobody for two minutes. A client accepted while
215
+ the mesh semaphore is held is closed rather than left half-open, and
216
+ `tcpServerInit()` logs the listener's state.
217
+
218
+ - **Gateway Internet requests partitioned the mesh around the gateway
219
+ (#318, #332, #416, #417)** — the gateway relays messages from inside the
220
+ cooperative TaskScheduler using blocking `HTTPClient` calls, so nothing
221
+ else ran for the duration while wall-clock time kept passing. With a 30 s
222
+ HTTP timeout against a 10 s `NODE_TIMEOUT`, every peer watchdog that fell
223
+ due mid-request fired the instant the scheduler resumed, closing
224
+ connections to nodes that had never gone missing (*"Internet available via
225
+ gateway: YES / Mesh connections active: NO"*). The blocking budget is now a
226
+ wall-clock model derived from `NODE_TIMEOUT` — both socket waits of each
227
+ HTTP call, the captive-portal probe and the DNS probe — enforced by a
228
+ `static_assert`, and after a blocking request the gateway postpones every
229
+ running peer watchdog by exactly the measured stall (`Task::adjust()`),
230
+ so a genuinely dead peer is still reaped on schedule. One residual is
231
+ documented rather than closed: ESP32's in-request hostname resolution
232
+ happens inside the core before the socket timeout applies; `SECURITY.md`
233
+ states it plainly.
234
+
235
+ - **Captive-portal probe ran on every gateway message** —
236
+ `detectCaptivePortal()` made an uncached HTTP round trip before *each*
237
+ mesh→Internet send. It is now cached for `GATEWAY_CONNECTIVITY_CACHE_MS`
238
+ (60 s) and bounded by `GATEWAY_CAPTIVE_PORTAL_TIMEOUT_MS`.
239
+
240
+ ### Fixed — channel following and the station scan (hardware-validated series)
241
+
242
+ - **Stranded followers find the bridge's channel (#427, #428)** — when a
243
+ bridge moved the mesh to its router's channel, the nodes behind its direct
244
+ children stayed connected to each other on the old channel and were gated
245
+ out of re-detection for good. Re-detection now also runs for a connected
246
+ node that should have a root and has none; the scan collects every channel
247
+ the mesh is on and prefers one other than the node's own; the move closes
248
+ the station link so an orphan leaves its old partition; an unexpected
249
+ station loss scans at once instead of sleeping out a delay of up to two
250
+ minutes; a scan that could not start is retried in half an interval
251
+ instead of five minutes; an association that never gets an address is
252
+ dropped after half an interval; a connected node that keeps finding the
253
+ mesh only on its own channel backs off instead of scanning every 15 s;
254
+ and a stale scan-done event no longer consumes the scan still in flight
255
+ (#428 restored channel auto-detection after the first fix broke it).
256
+
257
+ - **A connected node changes channel only for a strictly bigger partition;
258
+ a disconnected one follows the mesh wherever it is (#434, #435)** — a
259
+ node in a partition had followed any other channel it saw the mesh on,
260
+ including a lone node still in gateway mode during a teardown. Every
261
+ follow now waits for a second sighting one scan later (a teardown
262
+ straggler is seen once, a bridge twice); a rootless node follows a
263
+ partition that persists whatever its size; a leaf leaves a rootless
264
+ partition only if the mesh ever had a root; a re-init in place resets the
265
+ scan state.
266
+
267
+ - **Re-detection with stations under the AP is done a channel at a time
268
+ (#435)** — an all-channel scan takes the AP off its channel for two to
269
+ three seconds, and the ESP8266 station under it did not survive that. A
270
+ node with stations hunts one channel per scan (300 ms dwell, 1.5 s at home
271
+ between slices) and decides on the own-channel scan after the last slice;
272
+ a node with nothing under its AP keeps the fast all-channel scan.
273
+
274
+ - **Link loss and re-detection are judged by what was lost (#435)** — an
275
+ uplink lost in a rooted mesh re-detects the channel at once (the AP most
276
+ likely left for the bridge's channel); an uplink lost *at home* rescans
277
+ this channel, where the bridge's AP is, instead of hunting all thirteen
278
+ (the hunt cost a sender its request); the station drop a node's own
279
+ channel move causes, and an association attempt that never got an
280
+ address, are not losses (judged as losses they re-detected the channel
281
+ just left, found the remnant bigger, and moved back). The half-open guard
282
+ judges only an attempt still in progress; it had dropped a fresh
283
+ association by the clock of an attempt made 109 s earlier.
284
+
285
+ - **The core's station auto-reconnect is off on every core (#435)** — on
286
+ Arduino core 2.x as on 3.x. The core's own reconnect raced the library's
287
+ scan and re-attached nodes to APs that were leaving; the bridge's router
288
+ link is the exception and keeps it.
289
+
290
+ - **ESP32 Arduino core 3.x: the scan result is read from the loop, not on
291
+ the network-event task (#435)** — core 3.x dispatches Wi-Fi callbacks
292
+ under the lock it also takes in `removeEvent()`, and the library ran
293
+ `scanComplete()` inside the scan-done callback, where a channel follow
294
+ restarts the AP and waits for events only the blocked task can deliver.
295
+ Every ESP32-C5 and ESP32-C6 that followed the bridge's channel had gone
296
+ silent for the rest of its run — still answering the sketch, never
297
+ scanning again — and `stop()` then blocked on the same lock. The callback
298
+ now only yields the station task to `scanComplete()`.
299
+
300
+ ### Fixed — routing (hardware-validated series)
301
+
302
+ - **A connection that has already dropped no longer refuses a live one, and
303
+ is no longer a route (#429)** — a closed connection stays in the layout
304
+ until the next cleanup, and `handleNodeSync()` had turned away a working
305
+ direct connection on its authority, moments before erasing it. Only live
306
+ connections count as routes now, for the duplicate check and for every
307
+ send; `write()` on a closed connection returns `false` instead of queueing
308
+ into the void (27 of 33 unacknowledged deliveries correlated on the rig
309
+ had never arrived). A partitioned leaf that sees nodes it has no route to
310
+ on two consecutive scans reconnects toward them; interior nodes do not
311
+ jump, since dropping an interior link fragments the subtree it carries.
312
+
313
+ - **A node that comes back is not refused for where it used to be (#433)**
314
+ — after any restart, the returning node was associated by each AP in turn
315
+ and dropped a second later, for 30–100 s, because a neighbour's tree
316
+ still listed its old place. The loop check is now the tree the arriving
317
+ node presents; a stale direct link is closed at once and a stale place in
318
+ a neighbour's tree is forgotten (`layout::forget()`).
319
+
320
+ - **A node is in one place (#435)** — when a neighbour's sync presents the
321
+ nodes below it, every other neighbour's cached tree forgets them
322
+ (`layout::forgetAll()`); every board on the rig had carried one node twice
323
+ and routed by the older copy. A restated sync is not news: a neighbour's
324
+ sync is adopted only when the tree it presents differs from its last one
325
+ (a fingerprint per neighbour, covering the time-authority flag), which
326
+ ended a sync storm of one exchange every 30–80 ms between two claimants;
327
+ and a neighbour that stops presenting a node lets the others' restatements
328
+ back in, so a pruned node can return. A stale mention of this node in a
329
+ presented tree is a loop only if there is another live route to the
330
+ presenter.
331
+
332
+ ### Fixed — ESP8266 (hardware-validated series)
333
+
334
+ - **The ESP8266 is specified for small meshes, or as a leaf in larger ones
335
+ (#432)** — measured as an interior node of a seven-node mesh it runs at
336
+ 10–13 KB free, and below about 8 KB a single 8 KB package or one OTA part
337
+ fails to allocate; every ESP32 family holds within a few percent of its
338
+ starting heap in the same mesh. Configure a leaf with
339
+ `init(..., maxconn = 0)`. The library checks every 30 s on ESP8266 and logs
340
+ an `ERROR` below 12 KB free with more than one child attached.
341
+
342
+ ### Security
343
+
344
+ - **Corrected a false claim about gateway TLS.** The source comment on
345
+ `initGatewayInternetHandler()` asserted that "ESP32 uses default SSL settings
346
+ with certificate validation". Nothing in `src/` ever backed that: there is no
347
+ `setCACert`, no certificate bundle and no fingerprint API anywhere in the
348
+ library, and the ESP32 path calls bare `http.begin(url)`. Gateway HTTPS is
349
+ transport encryption **without** peer authentication on both targets.
350
+ No behaviour changed — only the claim.
351
+
352
+ - **Added a threat model to `SECURITY.md`** covering mesh membership
353
+ (one shared password, no per-node identity or revocation), OTA (MD5 is an
354
+ integrity check against an unauthenticated announcer, not a signature),
355
+ gateway TLS, and the absence of rate limiting — along with which of these are
356
+ known-and-documented rather than reportable vulnerabilities.
357
+
358
+ ### Packaging and examples
359
+
360
+ - **`mqttBridge` example fails to compile in Arduino IDE (#398)** — the
361
+ `PubSubClient` library was missing from `library.properties`'s `depends`
362
+ field, so installing this library through the Arduino IDE Library Manager
363
+ never pulled in `PubSubClient`. It is now listed, the example says so, and
364
+ `examples/mqttBridge/platformio.ini` pins the same `knolleary/PubSubClient`
365
+ package used by `examples/bridge`.
366
+ - **`examples/alteriom/mppt_example` never compiled** — its sketch was named
367
+ differently from its directory, so the CI example loop skipped it, and it
368
+ included two headers from the parent example directory by bare name, which
369
+ the Arduino build cannot resolve. The sketch is now `mppt_example.ino`,
370
+ carries the two headers it needs, and is compiled for esp32 and esp8266 on
371
+ every PR like the other twenty.
372
+ - `keywords.txt` now lists the bridge, gateway, failover, queue and capacity
373
+ API so the Arduino IDE highlights it.
374
+ - **PlatformIO package owner.** 2.0.0 is published as `sparck75/AlteriomPainlessMesh`,
375
+ the owner every automated release since 1.7.6 went under. The registry also
376
+ holds `alteriom/AlteriomPainlessMesh`, which stops at 1.10.0: its account is
377
+ not one the project can publish from. Name the owner in `lib_deps`; the bare
378
+ name matches both.
379
+
380
+ ### Added (post-review series)
381
+
382
+ - **Unified send path: `SendOptions` (#384)** — `sendSingle()` and
383
+ `sendBroadcast()` gained overloads taking a
384
+ `painlessmesh::SendOptions{priority, ackCallback, ackTimeoutMs}` struct,
385
+ so a message can be both prioritized and delivery-confirmed in one call —
386
+ something the separate priority and ack overload families could not
387
+ express. All pre-existing overloads still compile and now delegate to the
388
+ unified path.
389
+ - **Priority is carried across hops (#384)** — the priority level is now
390
+ serialized on the wire (as `"prio"`, only when it deviates from NORMAL, so
391
+ default sends carry zero overhead) and every forwarding node re-enqueues
392
+ the package at the sender's priority. Previously priority only affected
393
+ the first hop's transmit queue and was silently dropped on forwarding.
394
+ Pre-2.0 nodes ignore the field and forward at normal priority. Named
395
+ constants `PRIORITY_CRITICAL/HIGH/NORMAL/LOW` are exposed in
396
+ `painlessmesh::protocol`.
397
+
398
+ ### Fixed (post-review series)
399
+
400
+ - **Gateway blocking budget is now a wall-clock model, DNS included (#416)**
401
+ — `gatewayBlockingBudgetMs()` counts **both** socket waits of each HTTP
402
+ call (request/header read plus body read — `HTTPClient::setTimeout()`
403
+ bounds one wait, not a whole call) for the destination request *and* the
404
+ captive-portal probe, plus a new `GATEWAY_DNS_TIMEOUT_MS` term for the
405
+ DNS reachability probe. On ESP8266 the probe now uses the `hostByName()`
406
+ timeout overload; on ESP32, whose core has no resolver timeout, the
407
+ standalone probe is skipped and the (timed) captive-portal probe
408
+ establishes reachability instead. **Defaults changed to keep the honest
409
+ budget inside `NODE_TIMEOUT`:** at the stock 10 s watchdog,
410
+ `GATEWAY_HTTP_TIMEOUT_MS` is now 2000 ms (was 5000) and
411
+ `GATEWAY_CAPTIVE_PORTAL_TIMEOUT_MS` 1000 ms (was 2000); raise them
412
+ together with `NODE_TIMEOUT` if your endpoint needs longer — the
413
+ `static_assert` enforces the pairing. One residual is documented rather
414
+ than closed: ESP32's in-request hostname resolution happens inside the
415
+ core before the socket timeout applies and cannot be bounded there;
416
+ SECURITY.md states it plainly.
417
+ - **Gateway watchdog compensation now equals the measured stall (#417)** —
418
+ `gateway::refreshPeerWatchdogs()` takes the measured blocking duration and
419
+ postpones each running peer watchdog by exactly that long via
420
+ `Task::adjust()`, instead of restarting every watchdog from zero on every
421
+ exit path. The full reset was strictly more generous than the time the
422
+ scheduler actually lost, and the excess starved the reaper: a genuinely
423
+ dead peer stayed connected indefinitely as long as any *live* peer
424
+ generated gateway traffic more often than `NODE_TIMEOUT`. Paths that never
425
+ blocked now compensate nothing. Regression-tested in
426
+ `catch_gateway_watchdog.cpp`, including the dead-peer-under-continuous-
427
+ traffic case.
428
+ - **Outbound send buffer is bounded (#388)** — each connection's
429
+ `SentBuffer` now holds at most `PAINLESSMESH_MAX_SENT_BUFFER_MESSAGES`
430
+ (default 64, build-time overridable) messages. Previously a peer that
431
+ stopped draining (stalled TCP connection) grew the outbound list until
432
+ allocation failed on the ESP8266 heap. At the cap, an incoming message
433
+ evicts the newest message of a strictly lower priority class (mirroring
434
+ `MessageQueue::makeSpace()`); if nothing lower-priority is queued, the
435
+ push is rejected and the send reports failure. Drops are counted in
436
+ `getStats().dropped`; a partially-transmitted message is never evicted.
437
+
438
+ ### Removed (post-review series)
439
+
440
+ - **`MessageTracker` dead code (#386)** — `message_tracker.hpp` defined a
441
+ full dedup/ack-tracking class that was `#include`d but never instantiated
442
+ or called anywhere in the tree, costing compile time and flash in every
443
+ build. Removed together with its unit test. Broadcast flood dedup, the
444
+ integration it was meant for, remains future work with its own design
445
+ pass.
446
+
447
+ - **Routing no longer copies the connection list per packet (#387)** — every
448
+ `router::` send/broadcast/forward took the mesh layout **by value**,
449
+ copying a `std::list` of `shared_ptr`s (one heap allocation per
450
+ connection) on every packet sent, broadcast, or forwarded. All routing
451
+ functions now take the layout by const reference — measurable allocation
452
+ and fragmentation relief on ESP8266.
453
+
454
+ Feature release adding per-message delivery confirmation (issue #379). The
455
+ release is a major version bump because it introduces a new wire-protocol
456
+ message type: pre-2.0 nodes forward acknowledgment packets but never send
457
+ them, so delivery confirmation only works reliably once every participating
458
+ node runs 2.0.0. All existing sketches compile and behave unchanged.
459
+
460
+ ### Mixed-fleet rollout order
461
+
462
+ During a rolling upgrade, a v1.x node never replies with a `MESSAGE_ACK`, so
463
+ a v2.0 sender's delivery callback fires `delivered = false` against every
464
+ un-upgraded peer — indistinguishable from real packet loss. This is not a
465
+ bug in the ACK feature; it is the expected behavior of a mixed fleet.
466
+ **Upgrade leaf/receiver nodes before the senders that will use the ack
467
+ callbacks**, and only rely on `delivered = false` as a loss signal once the
468
+ whole mesh runs 2.0.0. The same applies to cross-hop priority: pre-2.0
469
+ forwarders ignore the `prio` field and forward at normal priority, so
470
+ priority guarantees only hold end-to-end on an upgraded path.
471
+
472
+ ### Added
473
+
474
+ - **Per-message delivery confirmation and acknowledgment API (#379)** —
475
+ `sendSingle()` and `sendBroadcast()` gained overloads that accept a
476
+ `painlessmesh::ack::deliveryCallback_t` callback and an acknowledgment
477
+ timeout (default 5000 ms). When a callback is provided the outgoing
478
+ message is tagged with a unique `msgId`, the receiving node automatically
479
+ replies with a `MessageAckPackage` (new protocol type 630, routed as a
480
+ SINGLE package so it traverses multiple hops), and the callback fires
481
+ with `delivered = true` plus the measured round-trip latency — or
482
+ `delivered = false` when the timeout elapses. Broadcast tracking
483
+ snapshots the mesh layout at send time and fires the callback once per
484
+ expected node.
485
+ - `checkAcks()` — non-blocking poll that processes acknowledgment timeouts
486
+ and returns the number of messages still pending (timeouts are also
487
+ processed automatically inside `mesh.update()`).
488
+ - `pendingAcks()` — number of messages still awaiting acknowledgment.
489
+ - New header `painlessmesh/ack.hpp` with the platform-independent
490
+ `AckTracker` (unit-tested, uint32 wraparound safe) and
491
+ `MessageAckPackage`.
492
+ - Arduino examples `reliableSensorLogging` (buffered retries until the
493
+ gateway confirms) and `commandControl` (per-node broadcast confirmation).
494
+ - Unit tests (`catch_message_ack.cpp`) covering serialization, ack
495
+ matching, timeout, duplicate/unknown acks, broadcast fan-in and clock
496
+ wraparound, plus an end-to-end multi-node scenario in the TCP
497
+ integration suite.
498
+
499
+ ### Changed
500
+
501
+ - `protocol::Single` / `protocol::Broadcast` carry an optional `msgId`
502
+ field. It is only serialized when delivery confirmation was requested,
503
+ so plain sends have zero added wire overhead.
504
+ - `protocol::Variant` gained lightweight `from()` and `msgId()` field
505
+ peeks; the receive-path ACK handlers use them instead of materializing
506
+ a full package (no per-message payload copy on the hot path).
507
+
508
+ ### Hardening (post-review, pre-release)
509
+
510
+ A full adversarial review of the ACK feature before release led to:
511
+
512
+ - `AckTracker::expire()` now collects and erases expired entries before
513
+ firing any callback — a delivery callback that reentered the tracker
514
+ (retry `track()`, `checkAcks()`, or `clear()` via `mesh.stop()`) could
515
+ previously invalidate the live iterator (use-after-free).
516
+ - `mesh.stop()` reached from inside a scheduler callback no longer
517
+ deletes the internally-owned `Scheduler` out from under its own
518
+ `execute()` (same bug class as #373); the ack poll task is also
519
+ disabled before its handle is cleared so a reentrant stop cannot
520
+ orphan it.
521
+ - Message ids are seeded from `validation::SecureRandom` at `init()` —
522
+ previously the counter restarted at 1 every boot, so a delayed
523
+ pre-reboot ACK could confirm a fresh message (false
524
+ `delivered = true`).
525
+ - Pending acknowledgments are capped at `PAINLESSMESH_MAX_PENDING_ACKS`
526
+ (default 32, build-time overridable); sends beyond the cap are
527
+ rejected instead of growing the tracker unbounded on the ESP8266 heap.
528
+ - Broadcast ACK replies are staggered by nodeId within a 50 ms window so
529
+ an N-node broadcast does not converge N simultaneous ACK unicasts on
530
+ the sender.
531
+ - The ack timeout poll interval is build-time configurable
532
+ (`PAINLESSMESH_ACK_CHECK_INTERVAL_MS`, default 100 ms) and its
533
+ battery/light-sleep implications are documented.
534
+
535
+ ### CI / packaging fixes
536
+
537
+ - Fixed the Arduino example-compile loop in CI (`ci.yml`): a quoted glob
538
+ meant **no example sketch was ever compiled** — the job reported green
539
+ while compiling nothing. All examples now build for esp32 and esp8266
540
+ on every PR.
541
+ - Added the two new examples to `library.json`'s `examples` array so
542
+ they appear in the PlatformIO registry listing.
543
+ - `doxygen/Doxyfile` `PROJECT_NUMBER` bumped from the stale v1.6.1 to
544
+ v2.0.0; stale 1.6.1 install snippets in the wiki docs updated.
545
+ - Wiki sync now publishes `docsify-site/` documentation (it previously
546
+ copied from a `docs/` directory that does not exist) and triggers on
547
+ docsify changes.
548
+ - Removed dead links from the docsify sidebar.
549
+
550
+
10
551
  ## [1.10.0] - 2026-08-12
11
552
 
12
553
  Feature release making the TCP connect-retry envelope tunable per mesh instance
@@ -82,6 +623,7 @@ macros keep their historical values for source compatibility.
82
623
  convention used across every other example (otaSender, namedMesh,
83
624
  alteriom_*).
84
625
 
626
+
85
627
  ## [1.9.21] - 2026-08-04
86
628
 
87
629
  Crash-fix release resolving a family of use-after-free bugs in the task and