@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
@@ -62,13 +62,20 @@ class Mesh : public painlessmesh::Mesh<Connection> {
62
62
  // Shut Wifi down and start with a blank slage
63
63
  if (WiFi.status() != WL_DISCONNECTED) WiFi.disconnect();
64
64
 
65
- Log(STARTUP, "init(): %d\n",
66
- #if ESP_ARDUINO_VERSION_MAJOR >= 3
67
- // Disable autoconnect
68
- WiFi.setAutoReconnect(false));
69
- #else
70
- // Disable autoconnect
71
- WiFi.setAutoConnect(false));
65
+ // The mesh reconnects on its own terms: it scans, chooses an AP, and
66
+ // connects. The core must not. On core 2.x this used to call
67
+ // setAutoConnect(false) — the "connect at boot from stored
68
+ // credentials" flag, a different thing — and left the core's
69
+ // auto-reconnect at its default of on. After a parent's AP vanished
70
+ // (BEACON_TIMEOUT) the core then tried the gone BSSID again every seven
71
+ // seconds (NO_AP_FOUND, a full-channel search each time) for as long as
72
+ // it stayed gone, and every attempt kept the radio busy so the mesh's
73
+ // own scan "could not start": the node sat outside the mesh for the
74
+ // rest of the test. Measured on the rig on every core-2.x board (ESP32,
75
+ // C3, S3); the core-3.x boards, which got the right call, did not do it.
76
+ Log(STARTUP, "init(): %d\n", WiFi.setAutoReconnect(false));
77
+ #if ESP_ARDUINO_VERSION_MAJOR < 3
78
+ WiFi.setAutoConnect(false);
72
79
  #endif
73
80
  WiFi.persistent(false);
74
81
 
@@ -76,6 +83,20 @@ class Mesh : public painlessmesh::Mesh<Connection> {
76
83
  if (!WiFi.mode(connectMode)) {
77
84
  Log(GENERAL, "WiFi.mode() false");
78
85
  }
86
+ #ifdef ESP32
87
+ #if SOC_WIFI_SUPPORT_5G && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2)
88
+ // The mesh lives on 2.4 GHz. A dual-band part left in its default band
89
+ // mode scans the 5 GHz channels as well every time it looks for the
90
+ // mesh: measured on an ESP32-C5, the all-channel scan took 13–14 s
91
+ // against 4 s on a single-band part — at boot, where it is the whole
92
+ // of the C5's 15 s to its first reply, and on every channel
93
+ // re-detection, where it swallowed the console commands sent
94
+ // meanwhile. Nothing the mesh does needs the other band.
95
+ if (!WiFi.setBandMode(WIFI_BAND_MODE_2G_ONLY)) {
96
+ Log(ERROR, "init(): could not restrict the radio to 2.4 GHz\n");
97
+ }
98
+ #endif
99
+ #endif
79
100
 
80
101
  _meshSSID = ssid;
81
102
  _meshPassword = password;
@@ -98,6 +119,17 @@ class Mesh : public painlessmesh::Mesh<Connection> {
98
119
 
99
120
  this->init(nodeId);
100
121
 
122
+ #ifdef ESP8266
123
+ // The ESP8266 is specified for small meshes, or as a leaf in larger
124
+ // ones. Say so at runtime when a deployment has put it in the middle of
125
+ // one: see Mesh::capacityCheck(). Thirty seconds is often enough to
126
+ // catch it and rare enough to cost nothing.
127
+ this->addTask(30 * TASK_SECOND, TASK_FOREVER, [this]() {
128
+ this->capacityCheck(ESP.getFreeHeap(),
129
+ painlessmesh::Mesh<Connection>::ESP8266_CAPACITY_FLOOR);
130
+ });
131
+ #endif
132
+
101
133
  // Add bridge election package handler (Type BRIDGE_ELECTION)
102
134
  this->callbackList.onPackage(
103
135
  protocol::BRIDGE_ELECTION,
@@ -112,11 +144,12 @@ class Mesh : public painlessmesh::Mesh<Connection> {
112
144
  if (obj["routerRSSI"].is<int>()) {
113
145
  uint32_t fromNode = obj["from"];
114
146
  int8_t routerRSSI = obj["routerRSSI"];
147
+ uint8_t routerChannel = obj["routerChannel"] | 0;
115
148
  uint32_t uptime = obj["uptime"] | 0;
116
149
  uint32_t freeMemory = obj["freeMemory"] | 0;
117
150
 
118
- this->handleBridgeElection(fromNode, routerRSSI, uptime,
119
- freeMemory);
151
+ this->handleBridgeElection(fromNode, routerRSSI, routerChannel,
152
+ uptime, freeMemory);
120
153
 
121
154
  Log(CONNECTION, "Bridge election candidate from %u: RSSI %d dBm\n",
122
155
  fromNode, routerRSSI);
@@ -139,9 +172,20 @@ class Mesh : public painlessmesh::Mesh<Connection> {
139
172
  uint32_t newBridge = obj["from"];
140
173
  uint32_t previousBridge = obj["previousBridge"];
141
174
  TSTRING reason = obj["reason"].as<TSTRING>();
175
+ uint8_t routerChannel = obj["routerChannel"] | 0;
142
176
 
143
- Log(CONNECTION, "Bridge takeover: Node %u replaced %u (%s)\n",
144
- newBridge, previousBridge, reason.c_str());
177
+ Log(CONNECTION,
178
+ "Bridge takeover: Node %u replaced %u on channel %u (%s)\n",
179
+ newBridge, previousBridge, routerChannel, reason.c_str());
180
+
181
+ // AP+STA radios must use one channel. Follow the elected bridge
182
+ // promptly instead of waiting for the slow empty-scan recovery.
183
+ if (gateway::shouldFollowBridgeChannel(
184
+ this->nodeId, newBridge, _meshChannel, routerChannel)) {
185
+ this->addTask(1000, TASK_ONCE, [this, routerChannel]() {
186
+ stationScan.followBridgeChannel(routerChannel);
187
+ });
188
+ }
145
189
 
146
190
  // Notify callback if this node was not the winner
147
191
  if (newBridge != this->nodeId && bridgeRoleChangedCallback) {
@@ -171,57 +215,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
171
215
 
172
216
  // Add periodic monitoring task to detect when no bridge exists
173
217
  // This handles the case where no node was initially configured as a bridge
174
- this->addTask(30000, TASK_FOREVER, [this]() {
175
- // Only check if failover is enabled and we have credentials
176
- if (!bridgeFailoverEnabled || !routerCredentialsConfigured) {
177
- return;
178
- }
179
-
180
- // Don't check if we're already a bridge
181
- if (this->isBridge()) {
182
- return;
183
- }
184
-
185
- // Skip check during startup period to allow initial bridge discovery
186
- if (millis() < electionStartupDelayMs) {
187
- return;
188
- }
189
-
190
- // IMPORTANT: Don't trigger election if we're disconnected from the mesh
191
- // When isolated, we can't receive bridge status broadcasts, so lack of
192
- // healthy bridge could simply mean WE are disconnected, not that the
193
- // bridge is unavailable. Wait until mesh connectivity is restored before
194
- // considering an election.
195
- if (!this->hasActiveMeshConnections()) {
196
- Log(CONNECTION,
197
- "Bridge monitor: Skipping - no active mesh connections\n");
198
- return;
199
- }
200
-
201
- // Check if there are any healthy bridges
202
- bool hasHealthyBridge = false;
203
- for (const auto& bridge : this->getBridges()) {
204
- if (bridge.isHealthy(bridgeTimeoutMs) && bridge.internetConnected) {
205
- hasHealthyBridge = true;
206
- break;
207
- }
208
- }
209
-
210
- // If no healthy bridge exists, trigger an election
211
- if (!hasHealthyBridge) {
212
- Log(CONNECTION,
213
- "Bridge monitor: No healthy bridge detected, triggering "
214
- "election\n");
215
- // Random delay to prevent simultaneous elections when multiple nodes
216
- // start together
217
- uint32_t randomDelay =
218
- random(electionRandomDelayMinMs, electionRandomDelayMaxMs);
219
- Log(CONNECTION, "Bridge monitor: Scheduling election in %u ms\n",
220
- randomDelay);
221
- this->addTask(randomDelay, TASK_ONCE,
222
- [this]() { this->startBridgeElection(); });
223
- }
224
- });
218
+ this->addTask(30000, TASK_FOREVER, [this]() { this->checkForBridge(); });
225
219
 
226
220
  // Add separate periodic task for isolated bridge retry
227
221
  // This handles the case where a node:
@@ -395,11 +389,10 @@ class Mesh : public painlessmesh::Mesh<Connection> {
395
389
  // Shut Wifi down and start with a blank slate
396
390
  if (WiFi.status() != WL_DISCONNECTED) WiFi.disconnect();
397
391
 
398
- Log(STARTUP, "initAsBridge(): %d\n",
399
- #if ESP_ARDUINO_VERSION_MAJOR >= 3
400
- WiFi.setAutoReconnect(false));
401
- #else
402
- WiFi.setAutoConnect(false));
392
+ // See init(): the core's auto-reconnect is off on every core version.
393
+ Log(STARTUP, "initAsBridge(): %d\n", WiFi.setAutoReconnect(false));
394
+ #if ESP_ARDUINO_VERSION_MAJOR < 3
395
+ WiFi.setAutoConnect(false);
403
396
  #endif
404
397
  WiFi.persistent(false);
405
398
  WiFi.mode(WIFI_STA);
@@ -625,13 +618,12 @@ class Mesh : public painlessmesh::Mesh<Connection> {
625
618
  // We need to ensure mesh and router operate on the same channel
626
619
  if (WiFi.status() != WL_DISCONNECTED) WiFi.disconnect();
627
620
 
628
- #if ESP_ARDUINO_VERSION_MAJOR >= 3
621
+ // See init(): the core's auto-reconnect is off on every core version.
629
622
  WiFi.setAutoReconnect(false);
630
- Log(STARTUP, "initAsSharedGateway(): AutoReconnect disabled\n");
631
- #else
623
+ #if ESP_ARDUINO_VERSION_MAJOR < 3
632
624
  WiFi.setAutoConnect(false);
633
- Log(STARTUP, "initAsSharedGateway(): AutoConnect disabled\n");
634
625
  #endif
626
+ Log(STARTUP, "initAsSharedGateway(): AutoReconnect disabled\n");
635
627
  WiFi.persistent(false);
636
628
  WiFi.mode(WIFI_STA);
637
629
 
@@ -699,6 +691,14 @@ class Mesh : public painlessmesh::Mesh<Connection> {
699
691
  // Step 5: Setup gateway Internet handler
700
692
  initGatewayInternetHandler();
701
693
 
694
+ // Step 6: Start the health checker that drives hasLocalInternet().
695
+ // Previously shared-gateway nodes associated and received an IP address,
696
+ // but the checker was never configured or scheduled, so local Internet
697
+ // availability remained false forever and every request was needlessly
698
+ // routed toward a mesh gateway.
699
+ configureInternetHealthCheck(_sharedGatewayConfig);
700
+ enableInternetHealthCheck();
701
+
702
702
  // Store router credentials for reconnection
703
703
  setRouterCredentials(routerSSID, routerPassword);
704
704
 
@@ -749,6 +749,18 @@ class Mesh : public painlessmesh::Mesh<Connection> {
749
749
  static_cast<bool>(_meshHidden));
750
750
  stationScan.manual = true;
751
751
 
752
+ // A manual station is the router link of a bridge or shared gateway:
753
+ // one known AP, no mesh scan choosing among candidates. For that link
754
+ // the core's own auto-reconnect is the right mechanism, and it is what
755
+ // kept every bridge's upstream alive until now — by accident, on core
756
+ // 2.x, where init() had never really turned it off. With it off
757
+ // everywhere, a bridge whose second association with the router failed
758
+ // (init() drops the first to start the mesh) sat at WL_IDLE_STATUS for
759
+ // the whole run with no upstream, and no node ever learned of the
760
+ // Internet. On for the manual link; the mesh station in init() keeps
761
+ // it off.
762
+ WiFi.setAutoReconnect(true);
763
+
752
764
  // Directly initiate connection - ESP will auto-detect router's channel
753
765
  WiFi.begin(ssid.c_str(), password.c_str());
754
766
 
@@ -769,9 +781,61 @@ class Mesh : public painlessmesh::Mesh<Connection> {
769
781
  // Schedule reconnection after disconnect completes
770
782
  // The WiFi event handler will signal when disconnect is complete
771
783
  _pendingStationReconnect = true;
772
- } else {
773
- // Already disconnected, reconnect immediately
784
+ } else if (_pendingStationReconnect) {
785
+ // A drop this node asked for: reconnect from the last scan
774
786
  handleStationDisconnectComplete();
787
+ } else if (this->stationScan.droppedByMove()) {
788
+ // The drop this node's own channel follow caused.
789
+ // followBridgeChannel() closed the link and scans next;
790
+ // nothing to do here, and above all no re-detection.
791
+ using namespace logger;
792
+ Log(CONNECTION,
793
+ "Station link closed by this node's channel move\n");
794
+ } else if (!this->stationScan.stationLinkUp) {
795
+ // An attempt that never got an address — the association
796
+ // timed out, or the AP was gone by the time it was tried.
797
+ // Not a loss: scan this channel again, where the next AP is.
798
+ // Re-detecting here sent the sender in sweep 45 run 2, just
799
+ // arrived on the bridge's channel after a peer there had
800
+ // left, to look at the old channel and consider going back,
801
+ // with the bridge's AP at -55 dBm beside it.
802
+ using namespace logger;
803
+ Log(CONNECTION,
804
+ "Station attempt failed, scanning this channel again\n");
805
+ this->stationScan.task.forceNextIteration();
806
+ } else {
807
+ // A drop nobody asked for — the AP this station was on went
808
+ // away (its node rebooted, or a bridge moved the mesh). Scan
809
+ // now rather than when the current slow-scan delay runs out,
810
+ // which is up to four intervals away for a node that was
811
+ // stable a moment ago.
812
+ using namespace logger;
813
+ Log(CONNECTION,
814
+ "Station link lost unexpectedly, scanning now\n");
815
+ if (this->shouldContainRoot && this->stationScan.atHome()) {
816
+ // At home the bridge's AP is on this channel; the uplink
817
+ // that went was a relay (in sweep 46 run 2, the backup
818
+ // rebooting into its failover role). Scan this channel
819
+ // now: the all-channel hunt below took the sender twenty
820
+ // seconds, and its request through the bridge ran out of
821
+ // retries in the gap.
822
+ Log(CONNECTION,
823
+ "Station link lost at home: scanning this channel\n");
824
+ } else if (this->shouldContainRoot) {
825
+ // In a mesh that should have a root, the AP that went away
826
+ // most likely left for the bridge's channel, and the nodes
827
+ // still on this one are about to. Re-attaching here first
828
+ // cost the node the failover test sends from a hundred
829
+ // seconds at the bridge's start: it joined one remnant,
830
+ // lost it, joined the next, lost that, and only then looked
831
+ // at every channel. Look at every channel now; the rules in
832
+ // scanComplete() decide whether to go.
833
+ Log(CONNECTION,
834
+ "Station link lost in a rooted mesh: re-detecting the "
835
+ "mesh channel on this scan\n");
836
+ this->stationScan.redetectOnNextScan();
837
+ }
838
+ this->stationScan.task.forceNextIteration();
775
839
  }
776
840
  }
777
841
  });
@@ -794,10 +858,36 @@ class Mesh : public painlessmesh::Mesh<Connection> {
794
858
  void tcpServerInit() {
795
859
  using namespace logger;
796
860
  Log(GENERAL, "tcpServerInit():\n");
861
+ // A listener that exists and listens is kept. It is bound to every
862
+ // address, so the AP a re-initialised node brings up is served by it,
863
+ // and re-creating it is not merely needless: the connections the old
864
+ // one accepted share its local port, and lwIP refuses to bind a new
865
+ // listener to a port any of them still holds — for the two minutes
866
+ // they sit in TIME_WAIT after stop() closes them. A node promoted to
867
+ // bridge on the rig logged "bind error: -8" on every attempt for
868
+ // exactly that long, reset every peer that came to join it, and only
869
+ // then had a bridge's listener. AsyncTCP keeps its pcb private, so
870
+ // SO_REUSEADDR cannot be set from here; not re-binding is the fix.
871
+ if (_tcpListener != nullptr) {
872
+ if (_tcpListener->status() == 1) {
873
+ Log(CONNECTION,
874
+ "tcpServerInit(): listener on port %d already listening, kept\n",
875
+ _meshPort);
876
+ return;
877
+ }
878
+ delete _tcpListener;
879
+ _tcpListener = nullptr;
880
+ }
797
881
  _tcpListener = new AsyncServer(_meshPort);
798
882
  painlessmesh::tcp::initServer<Connection, painlessmesh::Mesh<Connection>>(
799
883
  (*_tcpListener), (*this));
800
884
  Log(STARTUP, "AP tcp server established on port %d\n", _meshPort);
885
+ // The listener's state, at the level the rig keeps: a node promoted to
886
+ // bridge re-creates its listener, and one such node reset every
887
+ // connection to its AP for its whole time as bridge while its log said
888
+ // nothing. LISTEN is 1 on both cores; anything else is the finding.
889
+ Log(CONNECTION, "tcpServerInit(): listener on port %d, state %u\n",
890
+ _meshPort, (unsigned)_tcpListener->status());
801
891
  return;
802
892
  }
803
893
 
@@ -1304,7 +1394,87 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1304
1394
  */
1305
1395
  bool isMultiBridgeEnabled() const { return multiBridgeEnabled; }
1306
1396
 
1397
+ // The bridge monitor's check, one tick of the 30 s task: with no
1398
+ // healthy bridge known, an election is scheduled. Also run when a
1399
+ // bridge announces that it is stepping down, as soon as the startup
1400
+ // period allows.
1401
+ void checkForBridge() {
1402
+ using namespace logger;
1403
+ // Only a failover candidate checks: one with failover enabled and the
1404
+ // router's credentials. Every other node scheduled an election it
1405
+ // could not join, every 30 s, when this guard went missing.
1406
+ if (!bridgeFailoverEnabled || !routerCredentialsConfigured) {
1407
+ return;
1408
+ }
1409
+
1410
+ // Don't check if we're already a bridge
1411
+ if (this->isBridge()) {
1412
+ return;
1413
+ }
1414
+
1415
+ // Skip check during startup period to allow initial bridge discovery
1416
+ if (millis() < electionStartupDelayMs) {
1417
+ return;
1418
+ }
1419
+
1420
+ // IMPORTANT: Don't trigger election if we're disconnected from the mesh
1421
+ // When isolated, we can't receive bridge status broadcasts, so lack of
1422
+ // healthy bridge could simply mean WE are disconnected, not that the
1423
+ // bridge is unavailable. Wait until mesh connectivity is restored before
1424
+ // considering an election.
1425
+ if (!this->hasActiveMeshConnections()) {
1426
+ Log(CONNECTION,
1427
+ "Bridge monitor: Skipping - no active mesh connections\n");
1428
+ return;
1429
+ }
1430
+
1431
+ // Check if there are any healthy bridges
1432
+ bool hasHealthyBridge = false;
1433
+ for (const auto& bridge : this->getBridges()) {
1434
+ if (bridge.isHealthy(bridgeTimeoutMs) && bridge.internetConnected) {
1435
+ hasHealthyBridge = true;
1436
+ break;
1437
+ }
1438
+ }
1439
+
1440
+ // If no healthy bridge exists, trigger an election
1441
+ if (!hasHealthyBridge) {
1442
+ Log(CONNECTION,
1443
+ "Bridge monitor: No healthy bridge detected, triggering "
1444
+ "election\n");
1445
+ // Random delay to prevent simultaneous elections when multiple nodes
1446
+ // start together
1447
+ uint32_t randomDelay =
1448
+ random(electionRandomDelayMinMs, electionRandomDelayMaxMs);
1449
+ Log(CONNECTION, "Bridge monitor: Scheduling election in %u ms\n",
1450
+ randomDelay);
1451
+ this->addTask(randomDelay, TASK_ONCE,
1452
+ [this]() { this->startBridgeElection(); });
1453
+ }
1454
+ }
1455
+
1456
+ // Schedule checkForBridge() once, after the startup period if it is
1457
+ // still running, plus a second for the news to settle.
1458
+ void checkForBridgeSoon() {
1459
+ if (bridgeCheckPending) return;
1460
+ uint32_t wait = millis() < electionStartupDelayMs
1461
+ ? electionStartupDelayMs - millis()
1462
+ : 0;
1463
+ bridgeCheckPending = true;
1464
+ this->addTask(wait + 1000, TASK_ONCE, [this]() {
1465
+ bridgeCheckPending = false;
1466
+ this->checkForBridge();
1467
+ });
1468
+ }
1469
+
1307
1470
  void stop() {
1471
+ // A bridge stepping down says so before its connections close, so the
1472
+ // candidates hold an election now rather than when its last status
1473
+ // ages out. The short wait lets the broadcast leave the buffers.
1474
+ if (this->isBridge() && bridgeStatusBroadcastEnabled) {
1475
+ sendBridgeStatus(true);
1476
+ delay(200);
1477
+ }
1308
1478
  // remove all WiFi events
1309
1479
  #ifdef ESP32
1310
1480
  WiFi.removeEvent(eventScanDoneHandler);
@@ -1327,8 +1497,12 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1327
1497
  // Shutdown wifi hardware
1328
1498
  if (WiFi.status() != WL_DISCONNECTED) WiFi.disconnect();
1329
1499
 
1330
- // Delete the tcp server
1331
- delete _tcpListener;
1500
+ // The TCP listener stays. A node that stops to re-initialise in place
1501
+ // — a promotion to bridge, a return to a regular node — needs a
1502
+ // listener again at once, and a new one cannot bind while the
1503
+ // connections this one accepted are still in TIME_WAIT on the same
1504
+ // port (see tcpServerInit()). The connections themselves are closed
1505
+ // above; a client accepted in the gap before init() is closed by it.
1332
1506
  }
1333
1507
 
1334
1508
  protected:
@@ -1437,6 +1611,24 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1437
1611
  "Node %u connection changed, sending bridge status directly\n",
1438
1612
  nodeId);
1439
1613
 
1614
+ // The direct send below reaches the neighbour whose connection
1615
+ // changed. A node that joined behind that neighbour is why it changed,
1616
+ // and it learns of this bridge only from the next periodic broadcast,
1617
+ // up to thirty seconds on: on the rig the node the discovery fixture
1618
+ // sends from joined a child of the bridge at 185 s and heard the
1619
+ // bridge at 214 s, after the fixture's window. So a change also
1620
+ // brings the broadcast forward, at most once every five seconds.
1621
+ if (millis() - _lastBridgeStatusBroadcast >= 5000) {
1622
+ this->addTask(1000, TASK_ONCE, [this]() {
1623
+ if (millis() - _lastBridgeStatusBroadcast >= 5000) {
1624
+ Log(CONNECTION,
1625
+ "Topology changed; broadcasting bridge status now rather "
1626
+ "than at the next interval\n");
1627
+ this->sendBridgeStatus();
1628
+ }
1629
+ });
1630
+ }
1631
+
1440
1632
  // Small delay to ensure connection is fully stable, then send directly to
1441
1633
  // the new node This avoids issues with time sync blocking broadcast
1442
1634
  // messages
@@ -1656,20 +1848,60 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1656
1848
  * @param routerSSID SSID of router to scan for
1657
1849
  * @return RSSI in dBm (negative number, -127 to 0), or 0 if not found
1658
1850
  */
1659
- int8_t scanRouterSignalStrength(TSTRING routerSSID) {
1851
+ int8_t scanRouterSignalStrength(TSTRING routerSSID,
1852
+ uint8_t* routerChannel = nullptr) {
1660
1853
  using namespace logger;
1661
1854
  Log(CONNECTION, "scanRouterSignalStrength(): Scanning for %s...\n",
1662
1855
  routerSSID.c_str());
1663
1856
 
1664
1857
  int n = WiFi.scanNetworks(false, false);
1858
+ if (n == WIFI_SCAN_RUNNING) {
1859
+ // The station task's asynchronous scan is in flight; a synchronous
1860
+ // scan cannot start until it ends, and -2 is not "no networks". A
1861
+ // failover backup read it as the router being out of sight — twice,
1862
+ // thirty seconds apart — and never stood for election while the
1863
+ // primary was gone. Wait the scan out, bounded, then look again.
1864
+ Log(CONNECTION,
1865
+ "scanRouterSignalStrength(): a scan is already running, waiting "
1866
+ "for it\n");
1867
+ uint32_t waitedUntil = millis() + 8000;
1868
+ #ifdef ESP32
1869
+ // Not WiFi.scanComplete(): on this core it gives a scan twenty
1870
+ // dwell times to finish and then declares it failed — 2.4 s for the
1871
+ // 120 ms all-channel re-detection, which takes six in AP+STA mode
1872
+ // as the radio keeps returning to serve the AP. Asked here at three
1873
+ // seconds, it dropped the scanning flag, the scan below started on
1874
+ // top of the one in flight, and both came back with nothing: the
1875
+ // backup read "router not found" and the station "mesh not found on
1876
+ // any channel", and lost the sighting it needed to follow the mesh.
1877
+ // The scanning bit is the flag the driver clears when the scan
1878
+ // really ends.
1879
+ while ((WiFiGenericClass::getStatusBits() & WIFI_SCANNING_BIT) &&
1880
+ (int32_t)(waitedUntil - millis()) > 0) {
1881
+ delay(50);
1882
+ }
1883
+ // The scan-done event hands the station its results next; let it
1884
+ // read them before this scan deletes them.
1885
+ delay(300);
1886
+ #else
1887
+ while (WiFi.scanComplete() == WIFI_SCAN_RUNNING &&
1888
+ (int32_t)(waitedUntil - millis()) > 0) {
1889
+ delay(50);
1890
+ }
1891
+ #endif
1892
+ n = WiFi.scanNetworks(false, false);
1893
+ }
1665
1894
  Log(CONNECTION, "scanRouterSignalStrength(): Found %d networks\n", n);
1666
1895
 
1667
1896
  for (int i = 0; i < n; i++) {
1668
1897
  if (WiFi.SSID(i) == routerSSID) {
1669
1898
  int8_t rssi = WiFi.RSSI(i);
1899
+ uint8_t channel = WiFi.channel(i);
1900
+ if (routerChannel != nullptr) *routerChannel = channel;
1670
1901
  Log(CONNECTION,
1671
- "scanRouterSignalStrength(): Found %s with RSSI %d dBm\n",
1672
- routerSSID.c_str(), rssi);
1902
+ "scanRouterSignalStrength(): Found %s with RSSI %d dBm on "
1903
+ "channel %u\n",
1904
+ routerSSID.c_str(), rssi, channel);
1673
1905
  return rssi;
1674
1906
  }
1675
1907
  }
@@ -1702,10 +1934,23 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1702
1934
  return;
1703
1935
  }
1704
1936
 
1705
- // Prevent rapid role changes
1937
+ // Prevent rapid role changes — but look again when the hold is over.
1938
+ // A candidate that stood down a moment ago, or that only just booted,
1939
+ // returned here silently and waited for the next 30 s tick to find
1940
+ // out that there was still no bridge.
1706
1941
  if (millis() - lastRoleChangeTime < 60000) {
1942
+ uint32_t wait = 60000 - (millis() - lastRoleChangeTime);
1707
1943
  Log(CONNECTION,
1708
- "startBridgeElection(): Too soon after last role change\n");
1944
+ "startBridgeElection(): Too soon after last role change, "
1945
+ "checking again in %u ms\n",
1946
+ wait);
1947
+ if (!bridgeCheckPending) {
1948
+ bridgeCheckPending = true;
1949
+ this->addTask(wait, TASK_ONCE, [this]() {
1950
+ bridgeCheckPending = false;
1951
+ this->checkForBridge();
1952
+ });
1953
+ }
1709
1954
  return;
1710
1955
  }
1711
1956
 
@@ -1724,7 +1969,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1724
1969
 
1725
1970
  // Schedule a retry after channel re-sync has had a chance to run
1726
1971
  // The channel re-sync threshold is StationScan::EMPTY_SCAN_THRESHOLD
1727
- // scans (default 6) Fast scan interval is 0.5 * SCAN_INTERVAL = 15
1972
+ // scans (2, ~30 s). Fast scan interval is 0.5 * SCAN_INTERVAL = 15
1728
1973
  // seconds Wait for re-sync to complete plus a buffer
1729
1974
  uint32_t retryDelay =
1730
1975
  (StationScan::EMPTY_SCAN_THRESHOLD - emptyScans + 2) * 15000;
@@ -1739,7 +1984,9 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1739
1984
  electionState = ELECTION_SCANNING;
1740
1985
 
1741
1986
  // Scan for router to get RSSI
1742
- int8_t routerRSSI = scanRouterSignalStrength(routerSSID);
1987
+ uint8_t routerChannel = 0;
1988
+ int8_t routerRSSI =
1989
+ scanRouterSignalStrength(routerSSID, &routerChannel);
1743
1990
 
1744
1991
  if (routerRSSI == 0) {
1745
1992
  Log(CONNECTION,
@@ -1758,6 +2005,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1758
2005
  BridgeCandidate selfCandidate;
1759
2006
  selfCandidate.nodeId = this->nodeId;
1760
2007
  selfCandidate.routerRSSI = routerRSSI;
2008
+ selfCandidate.routerChannel = routerChannel;
1761
2009
  selfCandidate.uptime = millis();
1762
2010
  selfCandidate.freeMemory = ESP.getFreeHeap();
1763
2011
  electionCandidates.push_back(selfCandidate);
@@ -1770,6 +2018,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1770
2018
  obj["from"] = this->nodeId;
1771
2019
  obj["routing"] = 2; // BROADCAST
1772
2020
  obj["routerRSSI"] = routerRSSI;
2021
+ obj["routerChannel"] = routerChannel;
1773
2022
  obj["uptime"] = millis();
1774
2023
  obj["freeMemory"] = ESP.getFreeHeap();
1775
2024
  obj["timestamp"] = this->getNodeTime();
@@ -1782,7 +2031,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1782
2031
  // Send election message using raw broadcast to preserve type
1783
2032
  // BRIDGE_ELECTION
1784
2033
  protocol::Variant variant(msg);
1785
- router::broadcast<protocol::Variant, Connection>(variant, (*this), 0);
2034
+ router::broadcast<Connection>(variant, (*this), 0);
1786
2035
 
1787
2036
  Log(CONNECTION, "startBridgeElection(): Candidacy broadcast sent\n");
1788
2037
 
@@ -1902,7 +2151,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1902
2151
 
1903
2152
  if (winner->nodeId == this->nodeId) {
1904
2153
  Log(CONNECTION, "[TARGET] I WON! Promoting to bridge...\n");
1905
- promoteToBridge();
2154
+ promoteToBridge(winner->routerChannel);
1906
2155
  } else {
1907
2156
  Log(CONNECTION, "Winner is node %u, remaining as regular node\n",
1908
2157
  winner->nodeId);
@@ -1924,7 +2173,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1924
2173
  * Instead, we schedule the actual promotion work to run after the current
1925
2174
  * task completes, allowing safe cleanup of task structures.
1926
2175
  */
1927
- void promoteToBridge() {
2176
+ void promoteToBridge(uint8_t routerChannel) {
1928
2177
  using namespace logger;
1929
2178
 
1930
2179
  Log(STARTUP, "=== Becoming Bridge Node ===\n");
@@ -1950,6 +2199,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1950
2199
  obj["previousBridge"] = previousBridgeId;
1951
2200
  obj["reason"] = "Election winner - best router signal";
1952
2201
  obj["routerRSSI"] = 0; // Not yet connected to router
2202
+ obj["routerChannel"] = routerChannel;
1953
2203
  obj["timestamp"] = this->getNodeTime();
1954
2204
  obj["message_type"] = protocol::BRIDGE_TAKEOVER;
1955
2205
 
@@ -1959,7 +2209,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1959
2209
  // Send takeover message using raw broadcast to preserve type
1960
2210
  // BRIDGE_TAKEOVER
1961
2211
  protocol::Variant variant(msg);
1962
- router::broadcast<protocol::Variant, Connection>(variant, (*this), 0);
2212
+ router::broadcast<Connection>(variant, (*this), 0);
1963
2213
 
1964
2214
  // Give time for announcement to propagate before channel switch
1965
2215
  // Allow event loop processing during hardware settling
@@ -2152,7 +2402,8 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2152
2402
  * Called by package handler when election message arrives
2153
2403
  */
2154
2404
  void handleBridgeElection(uint32_t fromNode, int8_t routerRSSI,
2155
- uint32_t uptime, uint32_t freeMemory) {
2405
+ uint8_t routerChannel, uint32_t uptime,
2406
+ uint32_t freeMemory) {
2156
2407
  using namespace logger;
2157
2408
 
2158
2409
  if (electionState != ELECTION_COLLECTING) {
@@ -2174,6 +2425,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2174
2425
  BridgeCandidate candidate;
2175
2426
  candidate.nodeId = fromNode;
2176
2427
  candidate.routerRSSI = routerRSSI;
2428
+ candidate.routerChannel = routerChannel;
2177
2429
  candidate.uptime = uptime;
2178
2430
  candidate.freeMemory = freeMemory;
2179
2431
 
@@ -2188,13 +2440,29 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2188
2440
  * Send bridge status broadcast
2189
2441
  * Called periodically by bridge nodes to report connectivity status
2190
2442
  */
2191
- void sendBridgeStatus() {
2443
+ void sendBridgeStatus(bool leaving = false) {
2192
2444
  using namespace logger;
2193
2445
 
2194
2446
  if (!this->bridgeStatusBroadcastEnabled) {
2195
2447
  return;
2196
2448
  }
2197
2449
 
2450
+ // A bridge that nobody can reach is no bridge. On the rig a node
2451
+ // promoted in place reset every TCP connection to its AP for its whole
2452
+ // time as bridge — its listener, re-created by the promotion's
2453
+ // stop/re-init, was not listening, and nothing looked. This task runs
2454
+ // every thirty seconds on a bridge: if the listener is not in LISTEN
2455
+ // (1 on both cores) it is re-created, and the log says so.
2456
+ if (_tcpListener != nullptr && _tcpListener->status() != 1) {
2457
+ Log(ERROR,
2458
+ "sendBridgeStatus(): TCP listener on port %d is in state %u, not "
2459
+ "LISTEN; re-creating it\n",
2460
+ _meshPort, (unsigned)_tcpListener->status());
2461
+ delete _tcpListener;
2462
+ _tcpListener = nullptr;
2463
+ tcpServerInit();
2464
+ }
2465
+
2198
2466
  // Create bridge status package
2199
2467
  // We need to include the package header here since we're in wifi namespace
2200
2468
  // The package will be sent as a JSON string
@@ -2212,9 +2480,13 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2212
2480
  // 2. Some networks (mobile hotspots) may not provide gateway IP via DHCP
2213
2481
  // 3. Having a valid local IP + being connected is sufficient for internet
2214
2482
  // access
2215
- bool hasInternet = (WiFi.status() == WL_CONNECTED) &&
2483
+ bool hasInternet = !leaving && (WiFi.status() == WL_CONNECTED) &&
2216
2484
  (WiFi.localIP() != IPAddress(0, 0, 0, 0));
2217
2485
  obj["internetConnected"] = hasInternet;
2486
+ // A bridge stepping down says so. Every candidate would otherwise hold
2487
+ // it healthy until its last status aged out, a minute and more, before
2488
+ // holding an election — on the rig, past the failover contract.
2489
+ if (leaving) obj["leaving"] = true;
2218
2490
 
2219
2491
  int8_t rssi = WiFi.RSSI();
2220
2492
  uint8_t channel = WiFi.channel();
@@ -2230,8 +2502,9 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2230
2502
  String msg;
2231
2503
  serializeJson(doc, msg);
2232
2504
 
2233
- Log(GENERAL, "sendBridgeStatus(): Broadcasting status (Internet: %s)\n",
2234
- hasInternet ? "Connected" : "Disconnected");
2505
+ Log(GENERAL, "sendBridgeStatus(): Broadcasting status (Internet: %s)%s\n",
2506
+ hasInternet ? "Connected" : "Disconnected",
2507
+ leaving ? " — stepping down" : "");
2235
2508
  Log(GENERAL,
2236
2509
  "sendBridgeStatus(): WiFi status=%d, localIP=%s, gatewayIP=%s\n",
2237
2510
  WiFi.status(), WiFi.localIP().toString().c_str(),
@@ -2246,7 +2519,8 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2246
2519
  // Using sendBroadcast(msg) would wrap it in type 8 (BROADCAST) and hide
2247
2520
  // type BRIDGE_STATUS
2248
2521
  protocol::Variant variant(msg);
2249
- router::broadcast<protocol::Variant, Connection>(variant, (*this), 0);
2522
+ router::broadcast<Connection>(variant, (*this), 0);
2523
+ _lastBridgeStatusBroadcast = millis();
2250
2524
  }
2251
2525
 
2252
2526
  /**
@@ -2265,7 +2539,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2265
2539
  static uint32_t lastCheckTime = 0;
2266
2540
  static bool lastResult = false;
2267
2541
  uint32_t now = millis();
2268
- if (lastCheckTime > 0 && (now - lastCheckTime) < 60000) {
2542
+ if (lastCheckTime > 0 && (now - lastCheckTime) < GATEWAY_CONNECTIVITY_CACHE_MS) {
2269
2543
  return lastResult;
2270
2544
  }
2271
2545
 
@@ -2287,9 +2561,13 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2287
2561
  // Using Google's servers as they have high availability globally
2288
2562
  IPAddress result;
2289
2563
 
2290
- #if defined(ESP32) || defined(ESP8266)
2291
- // Both ESP32 and ESP8266 support WiFi.hostByName()
2292
- int dnsResult = WiFi.hostByName("www.google.com", result);
2564
+ #if defined(ESP8266)
2565
+ // ESP8266's hostByName() has a timeout overload, so the resolver cannot
2566
+ // stall the cooperative scheduler past GATEWAY_DNS_TIMEOUT_MS — that term
2567
+ // is part of gatewayBlockingBudgetMs() and covered by its static_assert
2568
+ // (issue #416).
2569
+ int dnsResult =
2570
+ WiFi.hostByName("www.google.com", result, GATEWAY_DNS_TIMEOUT_MS);
2293
2571
 
2294
2572
  // Check if DNS resolution succeeded
2295
2573
  if (dnsResult != 1) {
@@ -2308,6 +2586,20 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2308
2586
  lastResult = false;
2309
2587
  return false;
2310
2588
  }
2589
+ #elif defined(ESP32)
2590
+ // ESP32's hostByName() has no timeout parameter in the cores this library
2591
+ // targets, so a standalone DNS probe would be an unbounded stall on the
2592
+ // cooperative scheduler whenever DNS is slow or blackholed — the exact
2593
+ // failure mode of issues #318/#332, recurring once per
2594
+ // GATEWAY_CONNECTIVITY_CACHE_MS. The probe is therefore skipped on ESP32
2595
+ // (issue #416): actual reachability is established by the captive-portal
2596
+ // probe that runs right after this check on the same path — an HTTP
2597
+ // round trip bounded by GATEWAY_CAPTIVE_PORTAL_TIMEOUT_MS that fails on
2598
+ // a router without Internet just as the DNS probe would.
2599
+ (void)result;
2600
+ lastCheckTime = millis();
2601
+ lastResult = true;
2602
+ return true;
2311
2603
  #else
2312
2604
  // Other platforms: assume internet is available if WiFi connected
2313
2605
  // (no reliable way to test without platform-specific APIs)
@@ -2341,8 +2633,24 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2341
2633
  using namespace logger;
2342
2634
 
2343
2635
  #if defined(ESP32) || defined(ESP8266)
2636
+ // Cache the verdict, exactly as hasActualInternetAccess() does. This probe
2637
+ // is a full HTTP round trip to an external host; running it per message
2638
+ // put that round trip in front of every single mesh->Internet send, on the
2639
+ // cooperative scheduler, where it stacks with the request's own timeout.
2640
+ static uint32_t lastCheckTime = 0;
2641
+ static bool lastResult = true;
2642
+ uint32_t now = millis();
2643
+ if (lastCheckTime > 0 && (now - lastCheckTime) < GATEWAY_CONNECTIVITY_CACHE_MS) {
2644
+ return lastResult;
2645
+ }
2646
+
2647
+ // Record the attempt up front so that every early return below is cached
2648
+ // too -- otherwise a failing probe would be retried on every message.
2649
+ lastCheckTime = millis();
2650
+ lastResult = false;
2651
+
2344
2652
  HTTPClient http;
2345
- http.setTimeout(5000); // 5 second timeout for quick check
2653
+ http.setTimeout(GATEWAY_CAPTIVE_PORTAL_TIMEOUT_MS);
2346
2654
 
2347
2655
  WiFiClient client;
2348
2656
 
@@ -2387,6 +2695,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2387
2695
  }
2388
2696
 
2389
2697
  Log(COMMUNICATION, "detectCaptivePortal(): No captive portal detected\n");
2698
+ lastResult = true;
2390
2699
  return true;
2391
2700
 
2392
2701
  #else
@@ -2398,8 +2707,10 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2398
2707
  /**
2399
2708
  * Helper method to send gateway acknowledgment
2400
2709
  */
2401
- void sendGatewayAck(const gateway::GatewayDataPackage& request, bool success,
2402
- uint16_t httpStatus, const TSTRING& error) {
2710
+ void sendGatewayAck(
2711
+ const gateway::GatewayDataPackage& request, bool success,
2712
+ uint16_t httpStatus, const TSTRING& error,
2713
+ std::shared_ptr<Connection> ingressConnection = nullptr) {
2403
2714
  using namespace logger;
2404
2715
 
2405
2716
  gateway::GatewayAckPackage ack;
@@ -2412,7 +2723,28 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2412
2723
  ack.error = error;
2413
2724
  ack.timestamp = this->getNodeTime();
2414
2725
 
2726
+ if (request.originNode == this->nodeId) {
2727
+ protocol::Variant variant(&ack);
2728
+ this->callbackList.execute(protocol::GATEWAY_ACK, variant, nullptr, 0);
2729
+ Log(COMMUNICATION,
2730
+ "Completed local GATEWAY_ACK (success=%d, http=%d)\n", success,
2731
+ httpStatus);
2732
+ return;
2733
+ }
2734
+
2415
2735
  auto conn = router::findRoute<Connection>((*this), request.originNode);
2736
+ if (!conn && ingressConnection) {
2737
+ // A newly promoted gateway can receive data before its NodeTree has
2738
+ // converged enough for findRoute() to resolve the request origin. The
2739
+ // ingress connection is nevertheless a valid reverse path: the request
2740
+ // just arrived through it and every intermediate node can continue
2741
+ // routing the addressed acknowledgment toward originNode.
2742
+ conn = ingressConnection;
2743
+ Log(COMMUNICATION,
2744
+ "Routing GATEWAY_ACK to node %u through request ingress while "
2745
+ "topology converges\n",
2746
+ request.originNode);
2747
+ }
2416
2748
  if (conn) {
2417
2749
  protocol::Variant variant(&ack);
2418
2750
  router::send(std::move(variant), conn);
@@ -2433,15 +2765,26 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2433
2765
  * makes the request, and sends back a GATEWAY_ACK with the result.
2434
2766
  *
2435
2767
  * Security notes:
2436
- * - HTTPS on ESP8266 uses setInsecure() which disables SSL certificate
2437
- * validation to reduce memory overhead. This makes connections vulnerable to
2438
- * MITM attacks.
2439
- * - ESP32 uses default SSL settings with certificate validation.
2768
+ * - Neither target authenticates the TLS peer. The library configures no
2769
+ * trust anchor anywhere: there is no setCACert, no certificate bundle and
2770
+ * no fingerprint API on the gateway config, so an https:// destination is
2771
+ * not protected against an active man in the middle.
2772
+ * - ESP8266 calls setInsecure(), which disables certificate validation
2773
+ * outright.
2774
+ * - ESP32 calls http.begin(url) with no trust anchor supplied. What that
2775
+ * yields depends entirely on the Arduino-ESP32 core in use; do not read
2776
+ * it as verified. (An earlier revision of this comment claimed ESP32
2777
+ * "uses default SSL settings with certificate validation" -- that was
2778
+ * never backed by anything in src/.)
2779
+ * Treat gateway HTTPS as transport encryption without authentication, and
2780
+ * see SECURITY.md for the full threat model.
2440
2781
  *
2441
2782
  * Limitations:
2442
2783
  * - HTTP redirects (3xx) are not automatically followed
2443
2784
  * - Only 2xx status codes are treated as success
2444
- * - Request timeout is fixed at 30 seconds
2785
+ * - Request timeout is GATEWAY_HTTP_TIMEOUT_MS, which is derived from
2786
+ * NODE_TIMEOUT and asserted to stay below it (see
2787
+ * painlessmesh/gateway.hpp)
2445
2788
  */
2446
2789
  void initGatewayInternetHandler() {
2447
2790
  using namespace logger;
@@ -2450,44 +2793,68 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2450
2793
 
2451
2794
  this->callbackList.onPackage(
2452
2795
  protocol::GATEWAY_DATA, [this](protocol::Variant& variant,
2453
- std::shared_ptr<Connection> connection, uint32_t) {
2796
+ std::shared_ptr<Connection> ingress,
2797
+ uint32_t) {
2454
2798
  auto pkg = variant.to<gateway::GatewayDataPackage>();
2455
2799
 
2456
2800
  Log(COMMUNICATION,
2457
2801
  "Gateway received Internet request: msgId=%u dest=%s\n",
2458
2802
  pkg.messageId, pkg.destination.c_str());
2459
2803
 
2460
- // Disable connection timeout during HTTP request processing
2461
- // HTTP requests can take up to 30 seconds (GATEWAY_HTTP_TIMEOUT_MS)
2462
- // but mesh connections timeout after 10 seconds (NODE_TIMEOUT).
2463
- // We disable the timeout here to prevent connection drop during
2464
- // long-running HTTP requests. The timeout will be automatically
2465
- // re-enabled when the next sync packet is received.
2466
- if (connection) {
2467
- connection->timeOutTask.disable();
2468
- Log(COMMUNICATION,
2469
- "Gateway disabled connection timeout for node %u during HTTP request\n",
2470
- connection->nodeId);
2471
- }
2804
+ // Every exit path below goes through finish(), which re-arms the
2805
+ // mesh watchdog on all peers before the ack is routed.
2806
+ //
2807
+ // Everything from here on can block: the connectivity probes are
2808
+ // network round trips and the request itself runs to
2809
+ // GATEWAY_HTTP_TIMEOUT_MS. Nothing on the cooperative scheduler runs
2810
+ // during that, but wall-clock time keeps passing, so any peer whose
2811
+ // NODE_TIMEOUT deadline fell inside the stall is overdue the instant
2812
+ // the scheduler resumes and gets closed despite never having gone
2813
+ // missing (issues #318, #332).
2814
+ //
2815
+ // The previous mitigation disabled timeOutTask on the requesting
2816
+ // connection only. That left every other peer exposed, and it acted
2817
+ // before the stall rather than after it, which is the half that
2818
+ // cannot work: the deadline is wall-clock, so pushing it out ahead of
2819
+ // a stall longer than the window changes nothing.
2820
+ //
2821
+ // Compensation is by the *measured* stall (issue #417): exit paths
2822
+ // that never blocked measure ~0 ms and grant nothing, so a peer
2823
+ // that genuinely stopped answering NODE_SYNC still gets reaped even
2824
+ // under continuous gateway traffic from other peers.
2825
+ const auto blockingStartedMs = millis();
2826
+ auto finish = [this, &pkg, ingress, blockingStartedMs](
2827
+ bool ok, uint16_t code, const TSTRING& err) {
2828
+ const auto stalledMs = millis() - blockingStartedMs;
2829
+ auto refreshed = gateway::refreshPeerWatchdogs(*this, stalledMs);
2830
+ if (refreshed > 0) {
2831
+ Log(COMMUNICATION,
2832
+ "Gateway postponed the mesh watchdog on %u peer(s) by %lu ms "
2833
+ "after a blocking Internet request\n",
2834
+ static_cast<unsigned>(refreshed),
2835
+ static_cast<unsigned long>(stalledMs));
2836
+ }
2837
+ this->sendGatewayAck(pkg, ok, code, err, ingress);
2838
+ };
2472
2839
 
2473
2840
  // Check Internet connectivity
2474
2841
  // First check WiFi status for quick fail
2475
2842
  if (WiFi.status() != WL_CONNECTED) {
2476
- sendGatewayAck(pkg, false, 0, "Gateway WiFi not connected");
2843
+ finish(false, 0, "Gateway WiFi not connected");
2477
2844
  return true; // Consume package - we handled it (with error)
2478
2845
  }
2479
2846
 
2480
2847
  // Then check actual internet access (DNS resolution)
2481
2848
  // This detects when WiFi is connected but router has no internet
2482
2849
  if (!hasActualInternetAccess()) {
2483
- sendGatewayAck(pkg, false, 0, "Router has no internet access - check WAN connection");
2850
+ finish(false, 0, "Router has no internet access - check WAN connection");
2484
2851
  return true; // Consume package - we handled it (with error)
2485
2852
  }
2486
2853
 
2487
2854
  // Finally, check for captive portal interference
2488
2855
  // This detects when DNS works but HTTP requests are intercepted
2489
2856
  if (!detectCaptivePortal()) {
2490
- sendGatewayAck(pkg, false, 0, "Captive portal detected - requires web authentication. Check router/WiFi settings");
2857
+ finish(false, 0, "Captive portal detected - requires web authentication. Check router/WiFi settings");
2491
2858
  return true; // Consume package - we handled it (with error)
2492
2859
  }
2493
2860
 
@@ -2509,7 +2876,9 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2509
2876
 
2510
2877
  if (pkg.destination.startsWith("https://")) {
2511
2878
  #ifdef ESP32
2512
- // ESP32: Use default SSL settings with certificate validation
2879
+ // ESP32: no trust anchor is configured, so the certificate is not
2880
+ // validated against anything this library supplies. See the
2881
+ // security notes on initGatewayInternetHandler() and SECURITY.md.
2513
2882
  http.begin(pkg.destination.c_str());
2514
2883
  #elif defined(ESP8266)
2515
2884
  // ESP8266: Use insecure mode to reduce memory overhead
@@ -2589,10 +2958,10 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2589
2958
  http.end();
2590
2959
 
2591
2960
  // Send acknowledgment back
2592
- sendGatewayAck(pkg, success, httpCode, error);
2961
+ finish(success, httpCode, error);
2593
2962
  #else
2594
2963
  // Non-ESP platform - send error
2595
- sendGatewayAck(pkg, false, 0, "HTTP client not available on this platform");
2964
+ finish(false, 0, "HTTP client not available on this platform");
2596
2965
  #endif
2597
2966
 
2598
2967
  return true; // Consume package - we have processed it and sent
@@ -2602,13 +2971,61 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2602
2971
 
2603
2972
  void eventHandleInit() {
2604
2973
  using namespace logger;
2974
+ // Where the station scan learns the channel the mesh is rooted on.
2975
+ // It used to look at its own scan's end, which a node connected to a
2976
+ // rooted mesh reaches once a minute: the node the failover test sends
2977
+ // from joined the bridge, the bridge left 24 s later, and no scan had
2978
+ // run in between — so it had no home to keep and followed a partition
2979
+ // off the router's channel. Topology changes are the moment to look.
2980
+ // Only "ever rooted" comes from the tree: the tree can carry a root that
2981
+ // is gone. On the rig a node's cached tree still showed the bridge as
2982
+ // its root for the seconds after the bridge had moved to its router's
2983
+ // channel, a topology change fired in that window, and the node
2984
+ // recorded the channel it was left on as home — then sat there alone,
2985
+ // seeing the mesh's four nodes on the router's channel and "staying".
2986
+ // Home is learnt from the bridge itself, below: its status message
2987
+ // carries the channel it is on, and a message just received is live.
2988
+ auto noteRoot = [this](uint32_t) {
2989
+ if (layout::isRooted(this->asNodeTree())) this->stationScan.noteEverRooted();
2990
+ };
2991
+ this->newConnectionCallbacks.push_back(noteRoot);
2992
+ this->changedConnectionCallbacks.push_back(noteRoot);
2993
+ this->callbackList.onPackage(
2994
+ protocol::BRIDGE_STATUS,
2995
+ [this](protocol::Variant& variant, std::shared_ptr<Connection>, uint32_t) {
2996
+ JsonDocument doc;
2997
+ TSTRING str;
2998
+ variant.printTo(str);
2999
+ if (deserializeJson(doc, str)) return false;
3000
+ JsonObject obj = doc.as<JsonObject>();
3001
+ if (obj["leaving"] | false) {
3002
+ // The bridge is stepping down; the generic handler forgets it.
3003
+ // Look for another now, not at the monitor's next tick.
3004
+ this->checkForBridgeSoon();
3005
+ return false;
3006
+ }
3007
+ uint8_t routerChannel = obj["routerChannel"] | 0;
3008
+ if (gateway::isValidMeshChannel(routerChannel)) {
3009
+ this->stationScan.noteRooted(routerChannel);
3010
+ }
3011
+ return false; // the generic handler records the bridge
3012
+ });
2605
3013
  #ifdef ESP32
2606
3014
  eventScanDoneHandler = WiFi.onEvent(
2607
3015
  [this](WiFiEvent_t event, WiFiEventInfo_t info) {
2608
3016
  if (this->semaphoreTake()) {
2609
3017
  Log(CONNECTION,
2610
3018
  "eventScanDoneHandler: ARDUINO_EVENT_WIFI_SCAN_DONE\n");
2611
- this->stationScan.scanComplete();
3019
+ // Not scanComplete() here. This callback runs on the core's
3020
+ // network-event task, under the lock that task dispatches
3021
+ // with, and scanComplete() can end in followBridgeChannel(),
3022
+ // which restarts the AP. On core 3.x that waits for events
3023
+ // only this task can deliver: on the rig every esp32-c5 and
3024
+ // esp32-c6 that followed the bridge's channel went silent for
3025
+ // the rest of the run, and stop() then blocked in
3026
+ // removeEvent() on the same lock. The result is consumed by
3027
+ // the station task instead, from update().
3028
+ this->stationScan.scanDone();
2612
3029
  this->semaphoreGive();
2613
3030
  }
2614
3031
  },
@@ -2638,7 +3055,9 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2638
3055
  Log(CONNECTION,
2639
3056
  "eventSTADisconnectedHandler: "
2640
3057
  "ARDUINO_EVENT_WIFI_STA_DISCONNECTED\n");
3058
+ this->stationScan.stationAttemptOver();
2641
3059
  this->droppedConnectionCallbacks.execute(0, true);
3060
+ this->stationScan.stationDown();
2642
3061
  // Handle station disconnect completion after callbacks
2643
3062
  this->handleStationDisconnectComplete();
2644
3063
  this->semaphoreGive();
@@ -2655,6 +3074,8 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2655
3074
  if (this->semaphoreTake()) {
2656
3075
  Log(CONNECTION,
2657
3076
  "eventSTAGotIPHandler: ARDUINO_EVENT_WIFI_STA_GOT_IP\n");
3077
+ this->stationScan.stationAttemptOver();
3078
+ this->stationScan.stationUp();
2658
3079
  this->tcpConnect(); // Connect to TCP port
2659
3080
  this->semaphoreGive();
2660
3081
  }
@@ -2676,7 +3097,9 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2676
3097
  eventSTADisconnectedHandler = WiFi.onStationModeDisconnected(
2677
3098
  [&](const WiFiEventStationModeDisconnected& event) {
2678
3099
  Log(CONNECTION, "Event: Station Mode Disconnected\n");
3100
+ this->stationScan.stationAttemptOver();
2679
3101
  this->droppedConnectionCallbacks.execute(0, true);
3102
+ this->stationScan.stationDown();
2680
3103
  // Handle station disconnect completion after callbacks
2681
3104
  this->handleStationDisconnectComplete();
2682
3105
  });
@@ -2687,6 +3110,8 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2687
3110
  "Event: Station Mode Got IP (IP: %s Mask: %s Gateway: %s)\n",
2688
3111
  event.ip.toString().c_str(), event.mask.toString().c_str(),
2689
3112
  event.gw.toString().c_str());
3113
+ this->stationScan.stationAttemptOver();
3114
+ this->stationScan.stationUp();
2690
3115
  this->tcpConnect(); // Connect to TCP port
2691
3116
  });
2692
3117
  #endif // ESP32
@@ -2705,6 +3130,9 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2705
3130
  #endif // ESP8266
2706
3131
  AsyncServer* _tcpListener;
2707
3132
  std::shared_ptr<Task> bridgeStatusTask;
3133
+ // millis() of the last status broadcast, periodic or brought forward by a
3134
+ // topology change; the latter is held to one every five seconds.
3135
+ uint32_t _lastBridgeStatusBroadcast = 0;
2708
3136
 
2709
3137
  // Station disconnect handling state
2710
3138
  bool _pendingStationReconnect = false;
@@ -2715,6 +3143,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2715
3143
  struct BridgeCandidate {
2716
3144
  uint32_t nodeId;
2717
3145
  int8_t routerRSSI;
3146
+ uint8_t routerChannel;
2718
3147
  uint32_t uptime;
2719
3148
  uint32_t freeMemory;
2720
3149
  };
@@ -2733,6 +3162,9 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2733
3162
  uint32_t electionRandomDelayMaxMs =
2734
3163
  3000; // Default max 3 seconds random delay
2735
3164
  uint32_t lastRoleChangeTime = 0;
3165
+ // A checkForBridge() is already scheduled (a bridge stepping down, or
3166
+ // the role-change hold); one at a time is enough.
3167
+ bool bridgeCheckPending = false;
2736
3168
  ElectionState electionState = ELECTION_IDLE;
2737
3169
  uint32_t electionDeadline = 0;
2738
3170
  std::vector<BridgeCandidate> electionCandidates;
@@ -2795,8 +3227,6 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2795
3227
  static const uint8_t MIN_WIFI_CHANNEL = 1;
2796
3228
  static const uint8_t MAX_WIFI_CHANNEL =
2797
3229
  14; // Support channels 1-14 for regions that allow it
2798
- static const uint32_t GATEWAY_HTTP_TIMEOUT_MS =
2799
- 30000; // 30 second timeout for gateway HTTP requests
2800
3230
 
2801
3231
  /**
2802
3232
  * Initialize shared gateway monitoring