@alteriom/painlessmesh 1.9.20 → 1.10.0

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,12 +7,133 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.10.0] - 2026-08-12
11
+
12
+ Feature release making the TCP connect-retry envelope tunable per mesh instance
13
+ (#378), alongside two documentation corrections that retire long-standing claims
14
+ the library never actually implemented (#385) and an example build fix (#360).
15
+
16
+ **Upgrading is behaviour-neutral.** Every new setting defaults to the value that
17
+ was previously hardcoded, so a sketch that does not call `setTcpRetryConfig()`
18
+ behaves exactly as it did on 1.9.21. Nothing was removed: the deprecated queue
19
+ macros keep their historical values for source compatibility.
20
+
21
+ > **Note for npm users:** v1.9.21 was never published to npm — the `NPM_TOKEN`
22
+ > used by CI had expired (#381), which failed the npm publish job while the
23
+ > GitHub Release, GitHub Packages, PlatformIO and Arduino channels all succeeded.
24
+ > npm's previous version is therefore **1.9.20**, and upgrading from npm brings
25
+ > in both 1.9.21 and 1.10.0. See the 1.9.21 entry below for what that release
26
+ > contained — it was a crash-fix release, and npm users have been missing it.
27
+
10
28
  ### Added
11
29
 
30
+ - **User-configurable TCP retry parameters (#378)** — the five TCP connect
31
+ retry values that were hardcoded as `static const` in
32
+ `src/painlessmesh/tcp.hpp` are now tunable per mesh instance via
33
+ `mesh.setTcpRetryConfig()` / `mesh.getTcpRetryConfig()`, using the new
34
+ `painlessmesh::tcp::TcpRetryConfig` struct (`maxRetries`, `retryDelayMs`,
35
+ `stabilizationDelayMs`, `exhaustionReconnectDelayMs`,
36
+ `failureBlockDurationMs`). This lets latency-sensitive meshes (see
37
+ discussion #368), high-reliability industrial deployments and
38
+ battery-powered nodes each pick their own retry envelope without forking
39
+ the library.
40
+
41
+ The struct's defaults are spelled as the existing constants, so **behaviour
42
+ is unchanged for any sketch that does not call the new setter**, and the
43
+ constants themselves remain in place. `maxRetries` is clamped to 10 and
44
+ `retryDelayMs` to 50–60000 ms, since an unbounded retry count is a
45
+ heap/recursion hazard and a zero delay produces a hot reconnect loop; the
46
+ remaining fields accept 0 as a meaningful "disable this step" value.
47
+ New `examples/tcpRetryConfig/` demonstrates real-time, high-reliability and
48
+ battery-saver profiles.
49
+
12
50
  ### Changed
13
51
 
52
+ - **`MessageQueue` documented honestly as a manual buffer (#385)** —
53
+ removed the "messages are automatically delivered when connection is
54
+ restored" claim from `MessageQueue` and the `mesh.enableMessageQueue`
55
+ / `queueMessage` / `flushMessageQueue` doc comments. Nothing in the
56
+ library ever transmitted queued messages or observed connectivity
57
+ changes; the app has always owned the send loop. The docs now say so,
58
+ and the `flushMessageQueue` example shows the intended pattern of
59
+ wiring the drain into `onLocalInternetChanged`.
60
+
61
+ ### Deprecated
62
+
63
+ - **Compatibility queue macros kept as ignored no-ops (#385)** —
64
+ `MIN_FREE_MEMORY` and `MAX_MESSAGE_QUEUE` remain defined in
65
+ `painlessmesh/configuration.hpp` (and `test/boost/Arduino.h`) for
66
+ source compatibility, but nothing in the library reads them. They were
67
+ placeholders for the auto-flush behavior that never landed.
68
+ `MessageQueue` has always taken its own per-instance `maxSize`
69
+ constructor argument. Their historical default values
70
+ (`MIN_FREE_MEMORY 4000`, `MAX_MESSAGE_QUEUE 50`) are preserved so any
71
+ downstream code that referenced the macros keeps its prior behavior.
72
+
14
73
  ### Fixed
15
74
 
75
+ - **`bridge_failover` example failed to compile (#360)** — the two
76
+ `mesh.onBridgeCoordination*` lambdas referenced
77
+ `plugin::BridgeCoordinationPackage` with a bare `plugin::` prefix, but
78
+ `painlessMesh.h` only lifts `painlessmesh::logger` to global scope, so
79
+ the type did not resolve (`'plugin' does not name a type`). Both lambda
80
+ parameters are now fully qualified as
81
+ `painlessmesh::plugin::BridgeCoordinationPackage`, matching the
82
+ convention used across every other example (otaSender, namedMesh,
83
+ alteriom_*).
84
+
85
+ ## [1.9.21] - 2026-08-04
86
+
87
+ Crash-fix release resolving a family of use-after-free bugs in the task and
88
+ TCP-connection lifecycle. Root-caused and fixed by @vaz82 (PR #376) with
89
+ reports and field testing from @fidla73 and @miloshev (issue #373); finalized
90
+ with TaskScheduler's native self-destruct mechanism and regression coverage.
91
+
92
+ ### Fixed
93
+
94
+ - **Use-after-free in `Task::disable()` on connection teardown (#373)** —
95
+ `scheduleAsyncClientDeletion()`'s cleanup task deleted itself inside its
96
+ own `onDisable` callback, but TaskScheduler's `Task::disable()` writes to
97
+ the task object after `onDisable` returns. Crashed nodes (StoreProhibited,
98
+ `EXCVADDR 0x8`) on every peer disconnect. The cleanup task now uses
99
+ TaskScheduler's `_TASK_SELF_DESTRUCT` support (enabled in
100
+ `painlessTaskOptions.h`): the Scheduler deletes the task from within
101
+ `execute()`, safely outside the `disable()` call stack.
102
+ - **`PackageHandler::stop()` destroying the currently-executing task** —
103
+ when `stop()` runs from within a task's own callback (bridge promotion
104
+ path), it destroyed that task's closure mid-execution via
105
+ `setCallback(NULL)`/`shared_ptr` release. `stop()` now accepts the
106
+ scheduler, detects the running task via `getCurrentTask()`, and leaves it
107
+ in `taskList` for safe reuse by `addTask()`.
108
+ - **Stale `_pcb` window in `~BufferedConnection()`** — `client->close()` was
109
+ skipped when `freeable()` returned true, leaving a non-null-but-stale pcb
110
+ that lwIP's timers could recycle during the deferred-deletion window
111
+ (`heap_caps_free`/`memp_free` assertion failures, `tcp_arg()` wild-pointer
112
+ stores). `close()` is now called unconditionally at destruction.
113
+ - **`onError`/`onConnect` double-handling race in `tcp::connect()`** — if
114
+ WiFi dropped as the TCP handshake completed, AsyncTCP could fire both
115
+ callbacks for the same `AsyncClient`, handing it to two owners and
116
+ scheduling its deletion twice. A shared claim guard now ensures exactly
117
+ one callback processes the client.
118
+ - **Bridge promotion state capture** — the deferred stop/reinit lambda in
119
+ `promoteToBridge()` (and the isolated-node variant) now captures mesh
120
+ credentials, scheduler, and callback by value so `stop()` cannot mutate
121
+ them before the reinit reads them.
122
+ - **Off-by-one buffer overflow in `ReceiveBuffer::push()`** — when a
123
+ received chunk was ≥ `TCP_MSS`, the null terminator was written one byte
124
+ past the end of the shared temp buffer, corrupting adjacent memory on
125
+ every large read. Found by the new AddressSanitizer CI job on its first
126
+ run; `read_len` now reserves one byte for the terminator.
127
+
128
+ ### Added
129
+
130
+ - Regression test `catch_connection_cleanup.cpp` covering the #373
131
+ schedule → fire → self-destruct cleanup lifecycle and
132
+ `~BufferedConnection` churn.
133
+ - AddressSanitizer job in CI (gcc + `-fsanitize=address`) so use-after-free
134
+ and double-free regressions in the task/connection lifecycle fail the
135
+ build instead of crashing devices in the field.
136
+
16
137
  ## [1.9.20] - 2026-03-27
17
138
 
18
139
  ### Added
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <div align="center">
6
6
 
7
- **Version 1.9.20** - Full repo cleanup, bug fixes, and documentation consistency
7
+ **Version 1.10.0** - Tunable TCP connect-retry behaviour via `setTcpRetryConfig()` (#378)
8
8
 
9
9
  [![CI/CD Pipeline](https://github.com/Alteriom/painlessMesh/actions/workflows/ci.yml/badge.svg)](https://github.com/Alteriom/painlessMesh/actions/workflows/ci.yml)
10
10
  [![Documentation](https://github.com/Alteriom/painlessMesh/actions/workflows/docs.yml/badge.svg)](https://github.com/Alteriom/painlessMesh/actions/workflows/docs.yml)
@@ -560,9 +560,29 @@ These are the message types used by applications built on painlessMesh:
560
560
  - **Event Coordination** - Synchronized displays, distributed processing
561
561
  - **Bridge Networks** - Connect mesh to WiFi/Internet/MQTT - [📖 Bridge Guide](BRIDGE_TO_INTERNET.md)
562
562
 
563
- ## Latest Release: v1.9.20 (March 27, 2026)
563
+ ## Latest Release: v1.10.0 (August 12, 2026)
564
564
 
565
- **Full Repo Cleanup, Bug Fixes & Bridge Coordination Callbacks**
565
+ **Tunable TCP Connect-Retry Behaviour (issue #378)**
566
+
567
+ - The five TCP connect-retry values that were hardcoded in `tcp.hpp` are now tunable per mesh instance via `mesh.setTcpRetryConfig()` / `mesh.getTcpRetryConfig()` (#378, PR #395)
568
+ - Defaults match the previous constants exactly — **no behaviour change unless you call the setter**
569
+ - New `examples/tcpRetryConfig/` with real-time, high-reliability and battery-saver profiles
570
+ - `MessageQueue` documentation corrected: it is a manual buffer, never an auto-flush queue (#385)
571
+ - `MIN_FREE_MEMORY` / `MAX_MESSAGE_QUEUE` deprecated as ignored no-ops, values preserved for source compatibility (#385)
572
+ - Fixed `bridge_failover` example failing to compile on an unqualified `plugin::` type (#360)
573
+
574
+ > **npm users:** v1.9.21 was never published to npm ([#381](https://github.com/Alteriom/painlessMesh/issues/381) — expired token). npm's previous version is 1.9.20, so upgrading from npm picks up both releases. GitHub, PlatformIO and Arduino were unaffected.
575
+
576
+ **Previous Release: v1.9.21 (August 4, 2026)**
577
+
578
+ **Crash Fixes: Task & TCP Connection Lifecycle (issue #373)**
579
+
580
+ - Fixed use-after-free in `Task::disable()` that crashed nodes on every peer disconnect (#373, PR #376 by @vaz82)
581
+ - Fixed `PackageHandler::stop()` destroying the currently-executing task during bridge promotion
582
+ - Fixed stale-pcb heap corruption window in `~BufferedConnection()` and an `onError`/`onConnect` double-handling race
583
+ - New AddressSanitizer CI job and regression test for the task/connection cleanup lifecycle
584
+
585
+ **Previous Release: v1.9.20 (March 27, 2026)** — Full repo cleanup, bug fixes & bridge coordination callbacks:
566
586
 
567
587
  - New `onBridgeCoordination()` and `onBridgeCoordinationChanged()` monitoring callbacks
568
588
  - Fixed 13 critical/high/medium bugs (double-free, RSSI overflow, memory leaks, blocking delays)
package/RELEASE_GUIDE.md CHANGED
@@ -209,11 +209,22 @@ Each release triggers the **PlatformIO Library Publishing** workflow:
209
209
 
210
210
  #### Automatic Workflow Trigger
211
211
 
212
- The PlatformIO workflow automatically triggers on:
212
+ The PlatformIO workflow is started by:
213
213
 
214
- - New GitHub releases (tags)
214
+ - The `platformio-dispatch` job in **Automated Release**, which calls
215
+ `gh workflow run platformio-publish.yml --ref v<version> -f version=<version>`
216
+ right after the release is created
217
+ - A GitHub release published by a human (via the UI or a PAT)
215
218
  - Manual workflow dispatch for testing
216
219
 
220
+ > **Why the explicit dispatch?** `platformio-publish.yml` also listens for
221
+ > `release: published`, but that event never fires for releases created by
222
+ > `release.yml`: GitHub suppresses events raised by the built-in `GITHUB_TOKEN`.
223
+ > `workflow_dispatch` is one of the two documented exceptions to that rule, so
224
+ > the release workflow dispatches the publish explicitly and then verifies a run
225
+ > actually appeared. Before this was added, PlatformIO publication silently did
226
+ > not happen and had to be dispatched by hand (v1.9.21).
227
+
217
228
  ### PlatformIO Package Contents
218
229
 
219
230
  Published package includes:
@@ -454,13 +465,127 @@ npm run build
454
465
  npm run test
455
466
  ```
456
467
 
457
- **NPM Token Invalid**
468
+ **NPM Token Expired / Invalid (`E401 Unauthorized`)**
469
+
470
+ Symptom: the `npm-publish` job fails at *Verify NPM authentication* with
471
+ `401 Unauthorized - GET https://registry.npmjs.org/-/whoami`. npm tokens
472
+ expire; everything else in the release (tag, GitHub Release, zip asset,
473
+ GitHub Packages, PlatformIO) succeeds independently, so **the release can look
474
+ green-ish while npmjs.org is missing the version**. Always confirm with
475
+ `npm view @alteriom/painlessmesh version`.
476
+
477
+ Rotating the token is operator-only — it cannot be automated from CI:
478
+
458
479
  ```bash
459
- # Verify NPM authentication
460
- npm whoami
461
- # If not logged in: npm login
480
+ # 1. Mint a fresh granular token, scoped to @alteriom/painlessmesh, with
481
+ # "Read and write" AND the "Bypass 2FA" option enabled <-- see EOTP below
482
+ # https://www.npmjs.com/settings/tokens
483
+ # 2. Verify the new token before saving it (recommended).
484
+ # Ask the registry directly — do NOT use a bare `npm whoami`, which answers
485
+ # for whatever credential your local ~/.npmrc already holds and will happily
486
+ # pass while the new token is bad:
487
+ curl -sS -H "Authorization: Bearer <new-token>" \
488
+ https://registry.npmjs.org/-/whoami # -> {"username":"..."} , not 401
489
+
490
+ # 3. Update the NPM_TOKEN *organisation* secret — NOT a repository secret.
491
+ # painlessMesh has no repo-level NPM_TOKEN and must not gain one; see
492
+ # "Where NPM_TOKEN actually lives" below.
493
+ # https://github.com/organizations/Alteriom/settings/secrets/actions
494
+
495
+ # 4a. Re-run the failed release job (keeps the original run's context)
496
+ gh run rerun <run-id> --failed --repo Alteriom/painlessMesh
497
+
498
+ # 4b. …or republish a missed version out-of-band. Pass the TAG as ref:
499
+ # without it the workflow builds the default branch, and if main has moved
500
+ # on since the tag it would upload today's code under the old version
501
+ # number. The workflow now refuses that outright — pass ref so you never
502
+ # have to rely on the guard catching it.
503
+ gh workflow run manual-publish.yml --repo Alteriom/painlessMesh \
504
+ -f ref=v1.9.21 -f publish_npm=true -f publish_github=false
505
+
506
+ # 5. Confirm the version actually landed
507
+ npm view @alteriom/painlessmesh version
462
508
  ```
463
509
 
510
+ #### Where NPM_TOKEN actually lives
511
+
512
+ `NPM_TOKEN` is an **organisation** secret on `Alteriom`, shared by every repo
513
+ that publishes to npm. painlessMesh has **no repository-level copy**, and adding
514
+ one is a trap rather than a tightening:
515
+
516
+ > A repository secret silently takes precedence over an organisation secret of
517
+ > the same name. The repo then stops seeing org-wide rotations and keeps using
518
+ > its own copy until that copy expires — which is invisible until a release day
519
+ > fails.
520
+
521
+ Two sibling repos already sit in that state, with repo-level `NPM_TOKEN` copies
522
+ that shadow the org secret (`webhook-client`, `repository-metadata-manager`).
523
+ Rotate the org secret and those two are still broken; delete the repo-level copy
524
+ and they inherit the fresh one. Check before assuming a rotation reached a repo:
525
+
526
+ ```bash
527
+ # Empty output = good (inherits the org secret)
528
+ gh api repos/Alteriom/<repo>/actions/secrets \
529
+ --jq '.secrets[] | select(.name=="NPM_TOKEN") | "SHADOWED, updated \(.updated_at)"'
530
+ ```
531
+
532
+ While rotating `NPM_TOKEN`, check `PLATFORMIO_AUTH_TOKEN` too — it expires the
533
+ same way and `platformio-publish.yml` hard-fails on an invalid one.
534
+
535
+ **NPM asks for a one-time password (`EOTP`)**
536
+
537
+ Symptom: authentication *succeeds* — `npm whoami` prints the username — and then
538
+ `npm publish` fails with:
539
+
540
+ ```
541
+ npm error code EOTP
542
+ npm error This operation requires a one-time password from your authenticator.
543
+ ```
544
+
545
+ The token is valid but is not allowed to bypass 2FA, and CI has no authenticator
546
+ to answer the challenge with. **A rotation that fixes `E401` lands here if the
547
+ replacement token is minted without the bypass option** — which is what happened
548
+ on the second rotation attempt for #381.
549
+
550
+ npm removed the legacy token types (`read-only` / `automation` / `publish`) in
551
+ **November 2025**; only granular access tokens exist now. The old *Automation*
552
+ token bypassed 2FA by virtue of its type, so this was never a decision anyone had
553
+ to make. On a granular token it is an explicit checkbox, and a token minted from
554
+ muscle memory does not have it:
555
+
556
+ > **Bypass 2FA** — required. Takes precedence over account-level and
557
+ > package-level 2FA settings for publishing.
558
+
559
+ Re-mint at <https://www.npmjs.com/settings/tokens> with *Read and write* on
560
+ `@alteriom/painlessmesh` **and Bypass 2FA enabled**, update the secret, re-run.
561
+ `npm whoami` cannot detect this ahead of time — it passes for both token kinds,
562
+ so the failure necessarily surfaces at the publish call.
563
+
564
+ ### Trusted publishing (OIDC) — the way out of token rotation
565
+
566
+ Both failures above are symptoms of the same thing: a long-lived credential that
567
+ expires silently and is only exercised on release day. npm's replacement is
568
+ **trusted publishing** — the workflow authenticates to npm over OIDC, and
569
+ `NPM_TOKEN` stops existing.
570
+
571
+ This is on a clock rather than merely being nicer: as of **2026-07-31** bypass-2FA
572
+ tokens can no longer manage tokens, package access, or trusted-publishing config,
573
+ and npm has targeted **January 2027** for removing *direct publish* from them —
574
+ after which they can only stage a publish for a maintainer to approve with 2FA.
575
+ The current setup stops working at that point.
576
+
577
+ Requirements, none of which this repo blocks on today:
578
+
579
+ | Requirement | Status here |
580
+ |---|---|
581
+ | `id-token: write` permission | ✅ already set in `release.yml` and `manual-publish.yml` |
582
+ | npm CLI ≥ 11.5.1, Node ≥ 22.14.0 | ❌ workflows pin `node-version: '18'` — needs a bump |
583
+ | Trusted publisher registered on npmjs.com | ❌ operator, one-time, per workflow file |
584
+
585
+ The npmjs.com side is under *Package settings → Trusted publisher*: org
586
+ `Alteriom`, repository `painlessMesh`, workflow filename `release.yml` (add a
587
+ second entry for `manual-publish.yml` if that path should keep working).
588
+
464
589
  **GitHub Packages Authentication**
465
590
  ```bash
466
591
  # Check if GITHUB_TOKEN has packages:write permission
@@ -508,11 +633,18 @@ If this happens, you can manually publish packages:
508
633
  4. Click **Run workflow**
509
634
 
510
635
  The manual workflow will:
511
- - Read the current version from `library.properties`
636
+ - Read the current version from `library.properties` and refuse to run if it
637
+ disagrees with `package.json` (npm publishes the `package.json` version)
638
+ - Validate `NPM_TOKEN` against the registry before attempting to publish, so an
639
+ expired token fails immediately with rotation instructions
512
640
  - Publish to NPM (if selected)
513
641
  - Publish to GitHub Packages (if selected)
514
642
  - Show success/failure status for each
515
643
 
644
+ It does **not** publish to the PlatformIO registry — use
645
+ `gh workflow run platformio-publish.yml --ref v<version> -f version=<version>`
646
+ for that.
647
+
516
648
  Alternatively, from command line:
517
649
  ```bash
518
650
  # Trigger via GitHub CLI
@@ -647,9 +779,16 @@ Monitor your releases:
647
779
  ### Required GitHub Secrets
648
780
 
649
781
  - `GITHUB_TOKEN`: Automatically provided by GitHub Actions
650
- - `NPM_TOKEN`: Required for NPM publishing (add in repository secrets)
782
+ - `NPM_TOKEN`: Required for NPM publishing. Lives in the **Alteriom
783
+ organisation** secrets and is inherited — do not add a repository-level copy,
784
+ which would shadow it (see [Where NPM_TOKEN actually lives](#where-npm_token-actually-lives))
651
785
  - `PLATFORMIO_AUTH_TOKEN`: Required for PlatformIO Library Registry publishing
652
786
 
787
+ Both `NPM_TOKEN` and `PLATFORMIO_AUTH_TOKEN` are user-minted tokens that
788
+ **expire**. Their expiry is invisible until a release fails, so rotate them
789
+ together and re-check after any expiry date you set. See
790
+ [NPM Token Expired / Invalid](#-troubleshooting) for the rotation runbook.
791
+
653
792
  ### Repository Settings
654
793
  - **Actions**: Enabled with write permissions
655
794
  - **Packages**: Enabled for GitHub Packages publication
@@ -17,7 +17,7 @@
17
17
  * QUICK START
18
18
  * -----------
19
19
  * To create your own custom package:
20
- * 1. Pick an unused Type ID from the table below (use 203+ range)
20
+ * 1. Pick an unused Type ID from the table below (use 206+ range)
21
21
  * 2. Choose a base class: BroadcastPackage (all nodes) or SinglePackage (one
22
22
  * node)
23
23
  * 3. Add your data fields with appropriate types
@@ -30,8 +30,8 @@
30
30
  *
31
31
  * 200 : SensorPackage (environmental sensors: temp, humidity, pressure)
32
32
  * 202 : StatusPackage (device health and configuration)
33
- * 203 : MpptPackage (MPPT solar charge controller data) <-- this file
34
33
  * 204 : MetricsPackage (network performance metrics)
34
+ * 205 : MpptPackage (MPPT solar charge controller data) <-- this file
35
35
  * 400 : CommandPackage (device control commands)
36
36
  * 600 : MeshNodeListPackage
37
37
  * 601 : MeshTopologyPackage
@@ -44,7 +44,7 @@
44
44
  * 612 : BridgeTakeoverPackage
45
45
  * 614 : NTPTimeSyncPackage
46
46
  *
47
- * Available ranges: 205-399 (add your package here and update this table).
47
+ * Available ranges: 206-399 (add your package here and update this table).
48
48
  *
49
49
  *
50
50
  * CHOOSING BASE CLASS
@@ -138,15 +138,15 @@
138
138
  * TSTRING myText = "";
139
139
  *
140
140
  * // MQTT message_type (set to your chosen type ID)
141
- * uint16_t messageType = 205;
141
+ * uint16_t messageType = 206;
142
142
  *
143
- * MyCustomPackage() : BroadcastPackage(205) {}
143
+ * MyCustomPackage() : BroadcastPackage(206) {}
144
144
  *
145
145
  * MyCustomPackage(JsonObject jsonObj) : BroadcastPackage(jsonObj) {
146
146
  * myId = jsonObj["id"];
147
147
  * myValue = jsonObj["val"];
148
148
  * myText = jsonObj["txt"].as<TSTRING>();
149
- * messageType = jsonObj["message_type"] | 205;
149
+ * messageType = jsonObj["message_type"] | 206;
150
150
  * }
151
151
  *
152
152
  * JsonObject addTo(JsonObject&& jsonObj) const {
@@ -173,7 +173,7 @@
173
173
  * CONCRETE EXAMPLE: MpptPackage
174
174
  * ==============================
175
175
  *
176
- * The MpptPackage (Type 203) transmits real-time telemetry from an MPPT solar
176
+ * The MpptPackage (Type 205) transmits real-time telemetry from an MPPT solar
177
177
  * charge controller (e.g. Renegy, Epever, Victron). It is a BroadcastPackage
178
178
  * so every node in the mesh receives the data automatically.
179
179
  *
@@ -219,7 +219,7 @@ enum ChargeState : uint8_t {
219
219
  * values from your hardware, assign them to the struct fields, then call
220
220
  * sendBroadcast() as shown in alteriom_mppt_example.ino.
221
221
  *
222
- * Type ID: 203
222
+ * Type ID: 205
223
223
  */
224
224
  class MpptPackage : public painlessmesh::plugin::BroadcastPackage {
225
225
  public:
@@ -247,13 +247,13 @@ class MpptPackage : public painlessmesh::plugin::BroadcastPackage {
247
247
  uint32_t timestamp = 0;
248
248
 
249
249
  // MQTT Schema message_type for fast classification at the bridge
250
- uint16_t messageType = 203; // MPPT_DATA
250
+ uint16_t messageType = 205; // MPPT_DATA
251
251
 
252
252
  // -------------------------------------------------------------------------
253
253
  // Constructors
254
254
  // -------------------------------------------------------------------------
255
255
 
256
- MpptPackage() : BroadcastPackage(203) {}
256
+ MpptPackage() : BroadcastPackage(205) {}
257
257
 
258
258
  /**
259
259
  * @brief Deserialise from a JSON object received over the mesh
@@ -272,7 +272,7 @@ class MpptPackage : public painlessmesh::plugin::BroadcastPackage {
272
272
  controllerTemp = jsonObj["ct"];
273
273
  deviceId = jsonObj["did"];
274
274
  timestamp = jsonObj["ts"];
275
- messageType = jsonObj["message_type"] | 203;
275
+ messageType = jsonObj["message_type"] | 205;
276
276
  }
277
277
 
278
278
  // -------------------------------------------------------------------------
@@ -149,7 +149,7 @@ void handleIncomingPackage(uint32_t from, String& msg) {
149
149
  uint16_t msgType = obj["type"];
150
150
 
151
151
  switch (msgType) {
152
- case 203: { // MpptPackage
152
+ case 205: { // MpptPackage
153
153
  MpptPackage received(obj);
154
154
  Serial.printf(
155
155
  "MPPT from %u: PV=%.1fV/%.1fA/%dW Bat=%.1fV/%d%% "
@@ -240,7 +240,7 @@ void setup() {
240
240
 
241
241
  // Monitor bridge coordination (fires every ~30s per bridge)
242
242
  mesh.onBridgeCoordination(
243
- [](const plugin::BridgeCoordinationPackage& pkg, uint32_t fromNode) {
243
+ [](const painlessmesh::plugin::BridgeCoordinationPackage& pkg, uint32_t fromNode) {
244
244
  Serial.printf("Bridge %u: priority=%d, load=%d%%\n",
245
245
  fromNode, pkg.priority, pkg.load);
246
246
  }
@@ -248,7 +248,7 @@ void setup() {
248
248
 
249
249
  // Get notified when bridge state changes (new/updated/lost)
250
250
  mesh.onBridgeCoordinationChanged(
251
- [](const plugin::BridgeCoordinationPackage& pkg, uint32_t fromNode,
251
+ [](const painlessmesh::plugin::BridgeCoordinationPackage& pkg, uint32_t fromNode,
252
252
  TSTRING changeType) {
253
253
  Serial.printf("Bridge %s: %u (role=%s)\n",
254
254
  changeType.c_str(), fromNode, pkg.role.c_str());
@@ -0,0 +1,110 @@
1
+ # tcpRetryConfig
2
+
3
+ Tuning painlessMesh's TCP connection retry behaviour with
4
+ `setTcpRetryConfig()` (issue
5
+ [#378](https://github.com/Alteriom/painlessMesh/issues/378)).
6
+
7
+ ## What this controls
8
+
9
+ When a node acquires an IP and tries to open its TCP connection to the mesh,
10
+ that connection can fail — the parent's TCP server may not be ready yet, the
11
+ network stack may still be settling, or several nodes may be connecting at
12
+ once. painlessMesh retries with exponential backoff before giving up and
13
+ falling back to a full WiFi reconnect.
14
+
15
+ Five parameters describe that behaviour:
16
+
17
+ | Field | Default | Meaning |
18
+ |---|---|---|
19
+ | `maxRetries` | 5 | TCP connect attempts after the first before giving up |
20
+ | `retryDelayMs` | 1000 | Base delay between retries; scaled 1x, 2x, 4x, 8x, 8x |
21
+ | `stabilizationDelayMs` | 500 | Wait after IP acquisition before the first attempt |
22
+ | `exhaustionReconnectDelayMs` | 10000 | Wait before the WiFi reconnect that follows exhaustion |
23
+ | `failureBlockDurationMs` | 60000 | How long a failed peer is skipped during AP selection |
24
+
25
+ With the defaults, a node that cannot reach its parent spends
26
+ 1 + 2 + 4 + 8 + 8 = **23 s** retrying, then waits another **10 s** before
27
+ reconnecting WiFi, and will not re-select that same peer for **60 s**.
28
+
29
+ ## Profiles in this sketch
30
+
31
+ Switch with `#define ACTIVE_PROFILE`.
32
+
33
+ | | `PROFILE_REALTIME` | default | `PROFILE_RELIABLE` | `PROFILE_BATTERY` |
34
+ |---|---|---|---|---|
35
+ | `maxRetries` | 1 | 5 | 10 | 2 |
36
+ | `retryDelayMs` | 200 | 1000 | 2000 | 3000 |
37
+ | `stabilizationDelayMs` | 100 | 500 | 1000 | 500 |
38
+ | `exhaustionReconnectDelayMs` | 1000 | 10000 | 30000 | 60000 |
39
+ | `failureBlockDurationMs` | 5000 | 60000 | 180000 | 300000 |
40
+ | worst-case retry time | 0.2 s | 23 s | 126 s | 9 s |
41
+ | full failure cycle | 1.2 s | 33 s | 156 s | 69 s |
42
+
43
+ - **`PROFILE_REALTIME`** — real-time sensor and LED meshes, the use case from
44
+ [discussion #368](https://github.com/Alteriom/painlessMesh/discussions/368).
45
+ A node stuck in a 23 s backoff is worse than one that drops and re-scans, so
46
+ fail fast and move on.
47
+ - **`PROFILE_RELIABLE`** — industrial meshes where getting connected matters
48
+ more than how long it takes. `maxRetries = 10` is the maximum the library
49
+ accepts.
50
+ - **`PROFILE_BATTERY`** — every retry is radio-on time. Few attempts, spaced
51
+ widely, and a long blocklist so the node stops waking up for a peer that is
52
+ known to be down.
53
+
54
+ ## Clamping
55
+
56
+ `setTcpRetryConfig()` coerces the two values that can render a node unusable:
57
+
58
+ - `maxRetries` is capped at **10**. Each retry allocates an `AsyncClient` and
59
+ schedules a task, so an unbounded value is a heap and recursion-depth hazard
60
+ on ESP8266.
61
+ - `retryDelayMs` is held between **50 ms** and **60000 ms**. A zero delay would
62
+ schedule retries with no spacing — a hot loop allocating an `AsyncClient`
63
+ every scheduler tick. The ceiling keeps `retryDelayMs * 8` clear of `uint32_t`
64
+ overflow.
65
+
66
+ Everything else passes through untouched, including zeros, which are
67
+ meaningful:
68
+
69
+ - `maxRetries = 0` — do not retry at all; fall straight back to a WiFi
70
+ reconnect on the first TCP error.
71
+ - `stabilizationDelayMs = 0` — attempt the TCP connection immediately on IP
72
+ acquisition.
73
+ - `exhaustionReconnectDelayMs = 0` — reconnect WiFi immediately after
74
+ exhaustion.
75
+ - `failureBlockDurationMs = 0` — never blocklist a failed peer.
76
+
77
+ Call `getTcpRetryConfig()` after setting to see what actually took effect; the
78
+ sketch prints this at startup.
79
+
80
+ ## The defaults exist for a reason
81
+
82
+ painlessMesh 1.9.x deliberately *raised* these values (retries 3 → 5, base
83
+ delay 500 ms → 1000 ms) to fix real-world mesh instability. Tuning them back
84
+ down reintroduces the problems that change fixed. Symptoms of an over-aggressive
85
+ profile:
86
+
87
+ - **Connection churn** — nodes repeatedly connect and drop. `maxRetries` is too
88
+ low for how long your parent actually takes to be ready; raise it or raise
89
+ `stabilizationDelayMs`.
90
+ - **Rapid reconnect loops / network congestion** — a node hammers a parent whose
91
+ TCP server is down. `exhaustionReconnectDelayMs` is too short.
92
+ - **The same dead peer is picked over and over** — `failureBlockDurationMs` is
93
+ shorter than one full retry-plus-reconnect cycle, so the peer comes off the
94
+ blocklist before the node has finished failing over. Keep
95
+ `failureBlockDurationMs` greater than
96
+ `(sum of retry backoffs) + exhaustionReconnectDelayMs`. All three profiles
97
+ above satisfy this; `catch_tcp_blocklist.cpp` pins it as a test.
98
+
99
+ Change one parameter at a time and watch the serial log with
100
+ `mesh.setDebugMsgTypes(ERROR | STARTUP | CONNECTION)`.
101
+
102
+ ## Building
103
+
104
+ ```bash
105
+ # PlatformIO
106
+ pio run -e esp32 # or -e esp8266
107
+
108
+ # Arduino CLI
109
+ arduino-cli compile --fqbn esp32:esp32:esp32 examples/tcpRetryConfig/tcpRetryConfig.ino
110
+ ```
@@ -0,0 +1,26 @@
1
+ [platformio]
2
+ src_dir = .
3
+
4
+ [env]
5
+ lib_deps =
6
+ bblanchon/ArduinoJson
7
+ arkhipenko/TaskScheduler
8
+
9
+ lib_ldf_mode = deep+
10
+ [env:esp8266]
11
+ platform = espressif8266
12
+ board = nodemcuv2
13
+ framework = arduino
14
+ lib_extra_dirs = ../../
15
+ lib_deps =
16
+ ${env.lib_deps} ; Inherit common dependencies
17
+ esp32async/ESPAsyncTCP@^2.0.0 ; Only for ESP8266
18
+
19
+ [env:esp32]
20
+ platform = espressif32
21
+ board = esp32dev
22
+ framework = arduino
23
+ lib_extra_dirs = ../../
24
+ lib_deps =
25
+ ${env.lib_deps} ; Inherit common dependencies
26
+ esp32async/AsyncTCP