@alteriom/painlessmesh 1.9.19 → 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/README.md +102 -63
  3. package/RELEASE_GUIDE.md +147 -8
  4. package/examples/alteriom/README.md +4 -4
  5. package/examples/alteriom/alteriom_custom_package_template.hpp +320 -0
  6. package/examples/alteriom/alteriom_sensor_package.hpp +1 -1
  7. package/examples/alteriom/mppt_example/alteriom_mppt_example.ino +208 -0
  8. package/examples/bridge_failover/bridge_failover.ino +17 -0
  9. package/examples/sendToInternet/CMakeLists.txt +54 -0
  10. package/examples/sendToInternet/PC_NODE_README.md +517 -0
  11. package/examples/sendToInternet/README.md +39 -1
  12. package/examples/sendToInternet/build.sh +153 -0
  13. package/examples/sendToInternet/mock_server_test.ino +361 -0
  14. package/examples/sendToInternet/pc_mesh_node.cpp +361 -0
  15. package/examples/tcpRetryConfig/README.md +110 -0
  16. package/examples/tcpRetryConfig/platformio.ini +26 -0
  17. package/examples/tcpRetryConfig/tcpRetryConfig.ino +154 -0
  18. package/keywords.txt +3 -0
  19. package/library.json +4 -1
  20. package/library.properties +1 -1
  21. package/package.json +3 -3
  22. package/src/AlteriomPainlessMesh.h +6 -14
  23. package/src/arduino/wifi.hpp +352 -114
  24. package/src/connection.cpp +10 -0
  25. package/src/painlessMesh.h +2 -15
  26. package/src/painlessTaskOptions.h +9 -0
  27. package/src/painlessmesh/buffer.hpp +4 -1
  28. package/src/painlessmesh/configuration.hpp +13 -2
  29. package/src/painlessmesh/connection.hpp +36 -21
  30. package/src/painlessmesh/gateway.hpp +0 -1061
  31. package/src/painlessmesh/mesh.hpp +102 -107
  32. package/src/painlessmesh/message_queue.hpp +25 -15
  33. package/src/painlessmesh/metrics.hpp +2 -262
  34. package/src/painlessmesh/plugin.hpp +27 -5
  35. package/src/painlessmesh/tcp.hpp +158 -29
  36. package/src/painlessmesh/validation.hpp +0 -143
  37. package/docs/README.md +0 -132
  38. package/docs/alteriom/overview.md +0 -531
  39. package/docs/api/core-api.md +0 -607
  40. package/docs/api/shared-gateway.md +0 -1207
  41. package/docs/architecture/mesh-architecture.md +0 -399
  42. package/docs/architecture/plugin-system.md +0 -517
  43. package/docs/getting-started/arduino-manual-install.md +0 -313
  44. package/docs/getting-started/first-mesh.md +0 -410
  45. package/docs/getting-started/installation.md +0 -275
  46. package/docs/getting-started/quickstart.md +0 -158
  47. package/docs/troubleshooting/common-issues.md +0 -679
  48. package/docs/troubleshooting/debugging.md +0 -455
  49. package/docs/troubleshooting/external-device-connection.md +0 -283
  50. package/docs/troubleshooting/faq.md +0 -574
  51. package/docs/tutorials/basic-examples.md +0 -718
@@ -410,7 +410,8 @@ class Mesh : public painlessmesh::Mesh<Connection> {
410
410
  // Wait for connection (with timeout)
411
411
  int timeout = 30; // 30 seconds timeout
412
412
  while (WiFi.status() != WL_CONNECTED && timeout > 0) {
413
- delay(1000);
413
+ // Allow event loop processing during hardware settling
414
+ for (int i = 0; i < 100; i++) { delay(10); yield(); }
414
415
  timeout--;
415
416
  Log(STARTUP, ".");
416
417
  }
@@ -423,20 +424,20 @@ class Mesh : public painlessmesh::Mesh<Connection> {
423
424
  // Validate channel is in valid range (1-13 for 2.4GHz)
424
425
  if (detectedChannel < 1 || detectedChannel > 13) {
425
426
  Log(ERROR,
426
- "\n✗ Invalid channel detected: %d, falling back to channel 1\n",
427
+ "\n[FAIL] Invalid channel detected: %d, falling back to channel 1\n",
427
428
  detectedChannel);
428
429
  detectedChannel = 1;
429
430
  } else {
430
- Log(STARTUP, "\n✓ Router connected on channel %d\n", detectedChannel);
431
- Log(STARTUP, "✓ Router IP: %s\n", WiFi.localIP().toString().c_str());
431
+ Log(STARTUP, "\n[OK] Router connected on channel %d\n", detectedChannel);
432
+ Log(STARTUP, "[OK] Router IP: %s\n", WiFi.localIP().toString().c_str());
432
433
  routerConnected = true;
433
434
  }
434
435
  } else {
435
- Log(STARTUP, "\n⚠ Router connection unavailable during initialization\n");
436
+ Log(STARTUP, "\n[WARN] Router connection unavailable during initialization\n");
436
437
 
437
438
  // Scan for router to detect its channel even though we can't connect
438
439
  // This minimizes channel mismatch when router becomes available later
439
- Log(STARTUP, "⚠ Scanning for router '%s' to detect channel...\n", routerSSID.c_str());
440
+ Log(STARTUP, "[WARN] Scanning for router '%s' to detect channel...\n", routerSSID.c_str());
440
441
 
441
442
  // ESP32 and ESP8266 have different scanNetworks signatures
442
443
  #ifdef ESP32
@@ -451,7 +452,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
451
452
  uint8_t scannedChannel = WiFi.channel(i);
452
453
  if (scannedChannel >= 1 && scannedChannel <= 13) {
453
454
  detectedChannel = scannedChannel;
454
- Log(STARTUP, "✓ Router found on channel %d (not connected, will retry)\n",
455
+ Log(STARTUP, "[OK] Router found on channel %d (not connected, will retry)\n",
455
456
  detectedChannel);
456
457
  break;
457
458
  }
@@ -461,11 +462,11 @@ class Mesh : public painlessmesh::Mesh<Connection> {
461
462
  }
462
463
 
463
464
  if (detectedChannel == 1) {
464
- Log(STARTUP, "⚠ Router not found in scan, using default channel %d\n", detectedChannel);
465
+ Log(STARTUP, "[WARN] Router not found in scan, using default channel %d\n", detectedChannel);
465
466
  }
466
467
 
467
- Log(STARTUP, "⚠ Proceeding with bridge setup on channel %d\n", detectedChannel);
468
- Log(STARTUP, "⚠ Bridge will retry router connection in background\n");
468
+ Log(STARTUP, "[WARN] Proceeding with bridge setup on channel %d\n", detectedChannel);
469
+ Log(STARTUP, "[WARN] Bridge will retry router connection in background\n");
469
470
  }
470
471
 
471
472
  Log(STARTUP, "Step 2: Initializing mesh on channel %d...\n",
@@ -512,9 +513,8 @@ class Mesh : public painlessmesh::Mesh<Connection> {
512
513
  Log(STARTUP, "INFO: Router connection will be established automatically when available\n");
513
514
  }
514
515
 
515
- // Return true - bridge mesh functionality is active even without router
516
- // The mesh network is operational and nodes can connect
517
- // Router connection will be retried automatically via stationManual
516
+ // Always returns true: bridge mesh functionality is active regardless of
517
+ // router connection status. The router connection is opportunistic.
518
518
  return true;
519
519
  }
520
520
 
@@ -641,7 +641,8 @@ class Mesh : public painlessmesh::Mesh<Connection> {
641
641
  // Wait for connection with timeout (using constant for configurability)
642
642
  int timeout = ROUTER_CONNECTION_TIMEOUT_SECONDS;
643
643
  while (WiFi.status() != WL_CONNECTED && timeout > 0) {
644
- delay(1000);
644
+ // Allow event loop processing during hardware settling
645
+ for (int i = 0; i < 100; i++) { delay(10); yield(); }
645
646
  timeout--;
646
647
  Log(STARTUP, ".");
647
648
  }
@@ -654,15 +655,15 @@ class Mesh : public painlessmesh::Mesh<Connection> {
654
655
  if (detectedChannel < MIN_WIFI_CHANNEL ||
655
656
  detectedChannel > MAX_WIFI_CHANNEL) {
656
657
  Log(ERROR,
657
- "\n✗ Invalid channel detected: %d, falling back to channel 1\n",
658
+ "\n[FAIL] Invalid channel detected: %d, falling back to channel 1\n",
658
659
  detectedChannel);
659
660
  detectedChannel = 1;
660
661
  } else {
661
- Log(STARTUP, "\n✓ Router connected on channel %d\n", detectedChannel);
662
- Log(STARTUP, "✓ Router IP: %s\n", WiFi.localIP().toString().c_str());
662
+ Log(STARTUP, "\n[OK] Router connected on channel %d\n", detectedChannel);
663
+ Log(STARTUP, "[OK] Router IP: %s\n", WiFi.localIP().toString().c_str());
663
664
  }
664
665
  } else {
665
- Log(ERROR, "\n✗ Failed to connect to router during channel detection\n");
666
+ Log(ERROR, "\n[FAIL] Failed to connect to router during channel detection\n");
666
667
  Log(ERROR,
667
668
  "Continuing with default channel 1, will retry router connection "
668
669
  "later\n");
@@ -816,8 +817,9 @@ class Mesh : public painlessmesh::Mesh<Connection> {
816
817
  * TCP Connection Retry:
817
818
  * The TCP connection now includes automatic retry with exponential backoff.
818
819
  * If the initial connection fails (error -14 ERR_CONN or other errors),
819
- * the system will retry up to TCP_CONNECT_MAX_RETRIES times before
820
- * triggering a full WiFi reconnection cycle. This helps handle:
820
+ * the system will retry up to the configured maxRetries times before
821
+ * triggering a full WiFi reconnection cycle (see setTcpRetryConfig()).
822
+ * This helps handle:
821
823
  * - Timing issues where TCP server is not ready immediately
822
824
  * - Network stack stabilization after IP acquisition
823
825
  * - Transient network conditions
@@ -841,8 +843,11 @@ class Mesh : public painlessmesh::Mesh<Connection> {
841
843
  // This helps prevent error -14 (ERR_CONN) by allowing the network stack
842
844
  // and TCP server to be fully ready. The delay is added via task scheduler
843
845
  // to avoid blocking the event loop.
846
+ // The delay is read at schedule time, so calling setTcpRetryConfig()
847
+ // while a connect is already pending affects the next attempt, not the
848
+ // in-flight one.
844
849
  this->addTask(
845
- painlessmesh::tcp::TCP_CONNECT_STABILIZATION_DELAY_MS, TASK_ONCE,
850
+ this->getTcpRetryConfig().stabilizationDelayMs, TASK_ONCE,
846
851
  [this, targetIP, targetPort]() {
847
852
  // Verify WiFi is still connected after the delay
848
853
  if (WiFi.status() != WL_CONNECTED || !WiFi.localIP()) {
@@ -998,6 +1003,123 @@ class Mesh : public painlessmesh::Mesh<Connection> {
998
1003
  bridgeRoleChangedCallback = callback;
999
1004
  }
1000
1005
 
1006
+ /**
1007
+ * Set callback for bridge coordination messages
1008
+ *
1009
+ * Called every time a coordination message (Type 613) is received from
1010
+ * another bridge. Useful for monitoring bridge health and status.
1011
+ *
1012
+ * For non-bridge nodes, this also registers a Type 613 handler so that
1013
+ * regular mesh nodes can monitor bridge coordination traffic.
1014
+ *
1015
+ * @param callback Function called with the coordination package and sender
1016
+ * node ID
1017
+ */
1018
+ void onBridgeCoordination(
1019
+ std::function<void(const plugin::BridgeCoordinationPackage&, uint32_t)>
1020
+ callback) {
1021
+ using namespace logger;
1022
+ bridgeCoordinationCallback = callback;
1023
+
1024
+ // Non-bridge nodes don't run initBridgeCoordination(), so register
1025
+ // a Type 613 handler here so they can still receive coordination traffic.
1026
+ if (!this->isBridge()) {
1027
+ this->callbackList.onPackage(
1028
+ 613,
1029
+ [this](protocol::Variant& variant, std::shared_ptr<Connection>,
1030
+ uint32_t) {
1031
+ JsonDocument doc;
1032
+ TSTRING str;
1033
+ variant.printTo(str);
1034
+ deserializeJson(doc, str);
1035
+ JsonObject obj = doc.as<JsonObject>();
1036
+
1037
+ if (obj["priority"].is<unsigned int>()) {
1038
+ uint32_t fromNode = obj["from"];
1039
+ plugin::BridgeCoordinationPackage pkg(obj);
1040
+
1041
+ if (this->bridgeCoordinationCallback) {
1042
+ this->bridgeCoordinationCallback(pkg, fromNode);
1043
+ }
1044
+
1045
+ // Change detection for non-bridge nodes
1046
+ if (this->bridgeCoordinationChangedCallback) {
1047
+ auto it = this->lastBridgeCoordinationState.find(fromNode);
1048
+ if (it == this->lastBridgeCoordinationState.end()) {
1049
+ this->lastBridgeCoordinationState[fromNode] = {
1050
+ pkg.priority, pkg.role, pkg.load, (uint32_t)millis()};
1051
+ this->bridgeCoordinationChangedCallback(pkg, fromNode,
1052
+ "new");
1053
+ } else {
1054
+ auto& prev = it->second;
1055
+ bool changed = (prev.priority != pkg.priority ||
1056
+ prev.role != pkg.role ||
1057
+ prev.load != pkg.load);
1058
+ prev.priority = pkg.priority;
1059
+ prev.role = pkg.role;
1060
+ prev.load = pkg.load;
1061
+ prev.lastSeen = (uint32_t)millis();
1062
+ if (changed) {
1063
+ this->bridgeCoordinationChangedCallback(pkg, fromNode,
1064
+ "updated");
1065
+ }
1066
+ }
1067
+ }
1068
+ }
1069
+ return false;
1070
+ });
1071
+ Log(GENERAL,
1072
+ "onBridgeCoordination(): Registered Type 613 handler for non-bridge "
1073
+ "node\n");
1074
+ }
1075
+ }
1076
+
1077
+ /**
1078
+ * Set callback for bridge coordination state changes
1079
+ *
1080
+ * Called when a new bridge is discovered, an existing bridge changes its
1081
+ * priority/role/load, or a bridge is lost (no coordination message for 60s).
1082
+ *
1083
+ * @param callback Function called with the coordination package, sender node
1084
+ * ID, and change type ("new", "updated", or "lost")
1085
+ */
1086
+ void onBridgeCoordinationChanged(
1087
+ std::function<void(const plugin::BridgeCoordinationPackage&, uint32_t,
1088
+ TSTRING)>
1089
+ callback) {
1090
+ using namespace logger;
1091
+ bridgeCoordinationChangedCallback = callback;
1092
+
1093
+ // Start periodic lost-detection task (every 30 seconds, check for
1094
+ // bridges that haven't sent a coordination message in 60 seconds)
1095
+ bridgeLostDetectionTask = this->addTask(
1096
+ 30000, TASK_FOREVER, [this]() {
1097
+ uint32_t now = (uint32_t)millis();
1098
+ for (auto it = this->lastBridgeCoordinationState.begin();
1099
+ it != this->lastBridgeCoordinationState.end();) {
1100
+ if ((now - it->second.lastSeen) > 60000) {
1101
+ uint32_t lostNode = it->first;
1102
+ // Create a package with last known state for the callback
1103
+ plugin::BridgeCoordinationPackage pkg;
1104
+ pkg.from = lostNode;
1105
+ pkg.priority = it->second.priority;
1106
+ pkg.role = it->second.role;
1107
+ pkg.load = it->second.load;
1108
+ it = this->lastBridgeCoordinationState.erase(it);
1109
+ if (this->bridgeCoordinationChangedCallback) {
1110
+ this->bridgeCoordinationChangedCallback(pkg, lostNode, "lost");
1111
+ }
1112
+ } else {
1113
+ ++it;
1114
+ }
1115
+ }
1116
+ });
1117
+
1118
+ Log(GENERAL,
1119
+ "onBridgeCoordinationChanged(): Lost detection task started "
1120
+ "(interval: 30s, timeout: 60s)\n");
1121
+ }
1122
+
1001
1123
  /**
1002
1124
  * Enable or disable multi-bridge coordination mode
1003
1125
  *
@@ -1175,17 +1297,6 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1175
1297
  }
1176
1298
  }
1177
1299
 
1178
- /**
1179
- * Select a specific bridge for next transmission
1180
- *
1181
- * This overrides the automatic bridge selection for one message.
1182
- *
1183
- * @param bridgeNodeId Node ID of bridge to use
1184
- */
1185
- void selectBridge(uint32_t bridgeNodeId) {
1186
- selectedBridgeOverride = bridgeNodeId;
1187
- }
1188
-
1189
1300
  /**
1190
1301
  * Check if multi-bridge mode is enabled
1191
1302
  *
@@ -1432,6 +1543,9 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1432
1543
  if (peerId != this->nodeId &&
1433
1544
  std::find(knownBridgePeers.begin(), knownBridgePeers.end(),
1434
1545
  peerId) == knownBridgePeers.end()) {
1546
+ if (knownBridgePeers.size() >= 32) {
1547
+ knownBridgePeers.erase(knownBridgePeers.begin());
1548
+ }
1435
1549
  knownBridgePeers.push_back(peerId);
1436
1550
  }
1437
1551
  }
@@ -1441,6 +1555,36 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1441
1555
  "Bridge coordination from %u: priority=%d, role=%s, "
1442
1556
  "load=%d%%\n",
1443
1557
  fromNode, priority, role.c_str(), load);
1558
+
1559
+ // Invoke bridge coordination callback if set
1560
+ if (this->bridgeCoordinationCallback) {
1561
+ plugin::BridgeCoordinationPackage pkg(obj);
1562
+ this->bridgeCoordinationCallback(pkg, fromNode);
1563
+ }
1564
+
1565
+ // Change detection and bridgeCoordinationChangedCallback
1566
+ if (this->bridgeCoordinationChangedCallback) {
1567
+ plugin::BridgeCoordinationPackage pkg(obj);
1568
+ auto it = this->lastBridgeCoordinationState.find(fromNode);
1569
+ if (it == this->lastBridgeCoordinationState.end()) {
1570
+ this->lastBridgeCoordinationState[fromNode] = {
1571
+ priority, role, load, (uint32_t)millis()};
1572
+ this->bridgeCoordinationChangedCallback(pkg, fromNode, "new");
1573
+ } else {
1574
+ auto& prev = it->second;
1575
+ bool changed = (prev.priority != priority ||
1576
+ prev.role != role ||
1577
+ prev.load != load);
1578
+ prev.priority = priority;
1579
+ prev.role = role;
1580
+ prev.load = load;
1581
+ prev.lastSeen = (uint32_t)millis();
1582
+ if (changed) {
1583
+ this->bridgeCoordinationChangedCallback(pkg, fromNode,
1584
+ "updated");
1585
+ }
1586
+ }
1587
+ }
1444
1588
  }
1445
1589
  return false; // Don't consume the package
1446
1590
  });
@@ -1757,7 +1901,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1757
1901
  }
1758
1902
 
1759
1903
  if (winner->nodeId == this->nodeId) {
1760
- Log(CONNECTION, "🎯 I WON! Promoting to bridge...\n");
1904
+ Log(CONNECTION, "[TARGET] I WON! Promoting to bridge...\n");
1761
1905
  promoteToBridge();
1762
1906
  } else {
1763
1907
  Log(CONNECTION, "Winner is node %u, remaining as regular node\n",
@@ -1818,60 +1962,57 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1818
1962
  router::broadcast<protocol::Variant, Connection>(variant, (*this), 0);
1819
1963
 
1820
1964
  // Give time for announcement to propagate before channel switch
1821
- delay(1000);
1822
- Log(STARTUP, "✓ Takeover announcement sent on channel %d\n", _meshChannel);
1823
-
1824
- // Save current mesh configuration to restore if bridge init fails
1965
+ // Allow event loop processing during hardware settling
1966
+ for (int i = 0; i < 100; i++) { delay(10); yield(); }
1967
+ Log(STARTUP, "[OK] Takeover announcement sent on channel %d\n", _meshChannel);
1968
+
1969
+ // Save current mesh configuration to restore if bridge init fails.
1970
+ // The copies below are captured by value into the deferred lambda so the
1971
+ // stop() call inside it cannot clear/mutate these members before the
1972
+ // reinit reads them. (The Mesh object itself outlives the lambda; this
1973
+ // protects against member mutation, not object lifetime.)
1974
+ // TODO(#373 follow-up): if the mesh owns its scheduler
1975
+ // (!isExternalScheduler), stop() deletes mScheduler and savedScheduler
1976
+ // dangles before initAsBridge() uses it. Pre-existing limitation —
1977
+ // bridge promotion requires a user-supplied scheduler.
1825
1978
  uint8_t savedChannel = _meshChannel;
1979
+ TSTRING savedMeshSSID = _meshSSID;
1980
+ TSTRING savedMeshPassword = _meshPassword;
1981
+ TSTRING savedRouterSSID = routerSSID;
1982
+ TSTRING savedRouterPassword = routerPassword;
1983
+ Scheduler *savedScheduler = mScheduler;
1984
+ auto savedBridgeRoleChangedCallback = bridgeRoleChangedCallback;
1826
1985
 
1827
1986
  // CRITICAL FIX: Schedule the stop/reinit work to run after current task completes
1828
1987
  // This prevents use-after-free crash when stop() clears taskList while
1829
1988
  // evaluateElection() task is still executing
1830
1989
  // Use minimal delay to allow current task to complete first
1831
- this->addTask(ASYNC_PROMOTION_DELAY_MS, TASK_ONCE, [this, savedChannel]() {
1990
+ this->addTask(ASYNC_PROMOTION_DELAY_MS, TASK_ONCE,
1991
+ [this, savedChannel, savedMeshSSID, savedMeshPassword,
1992
+ savedRouterSSID, savedRouterPassword, savedScheduler,
1993
+ savedBridgeRoleChangedCallback]() {
1832
1994
  using namespace logger;
1833
1995
 
1834
1996
  Log(STARTUP, "Executing bridge promotion (stop/reinit cycle)\n");
1835
1997
 
1836
1998
  // Now reconfigure as bridge (this will switch to router's channel)
1837
1999
  this->stop();
1838
- delay(1000);
1839
-
1840
- bool bridgeInitSuccess =
1841
- this->initAsBridge(_meshSSID, _meshPassword, routerSSID, routerPassword,
1842
- mScheduler, _meshPort);
1843
-
1844
- if (!bridgeInitSuccess) {
1845
- Log(ERROR, "✗ Bridge promotion failed - router unreachable\n");
1846
- Log(ERROR, "Reverting to regular node on channel %d\n", savedChannel);
2000
+ // Allow event loop processing during hardware settling
2001
+ for (int i = 0; i < 100; i++) { delay(10); yield(); }
1847
2002
 
1848
- // Re-initialize as regular node on the original channel
1849
- this->init(_meshSSID, _meshPassword, mScheduler, _meshPort, WIFI_AP_STA,
1850
- savedChannel, _meshHidden, MAX_CONN);
1851
-
1852
- // Reset election state and clear candidates (consistent with normal
1853
- // election completion)
1854
- electionState = ELECTION_IDLE;
1855
- electionCandidates.clear();
1856
-
1857
- // Notify via callback
1858
- if (bridgeRoleChangedCallback) {
1859
- bridgeRoleChangedCallback(
1860
- false, "Bridge promotion failed - router unreachable");
1861
- }
1862
-
1863
- return;
1864
- }
2003
+ // initAsBridge always returns true: bridge mesh functionality is active
2004
+ // regardless of router connection status (router connection is opportunistic)
2005
+ this->initAsBridge(savedMeshSSID, savedMeshPassword, savedRouterSSID,
2006
+ savedRouterPassword, savedScheduler, _meshPort);
1865
2007
 
1866
2008
  lastRoleChangeTime = millis();
1867
2009
 
1868
- Log(STARTUP, "✓ Bridge promotion complete on channel %d\n", _meshChannel);
2010
+ Log(STARTUP, "[OK] Bridge promotion complete on channel %d\n", _meshChannel);
1869
2011
 
1870
2012
  // Notify via callback
1871
- // Use explicit TSTRING construction to ensure string lifetime safety
1872
- if (bridgeRoleChangedCallback) {
2013
+ if (savedBridgeRoleChangedCallback) {
1873
2014
  static const TSTRING reason = "Election winner - best router signal";
1874
- bridgeRoleChangedCallback(true, reason);
2015
+ savedBridgeRoleChangedCallback(true, reason);
1875
2016
  }
1876
2017
 
1877
2018
  // Note: The initial takeover announcement was already sent earlier
@@ -1944,64 +2085,52 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1944
2085
  Log(CONNECTION,
1945
2086
  "Scheduling stop/reinit (async to avoid task corruption)\n");
1946
2087
 
1947
- // Save current mesh configuration
2088
+ // Save current mesh configuration. Captured by value into the deferred
2089
+ // lambda so stop() cannot clear/mutate these members before the reinit
2090
+ // reads them (see the matching comment in promoteToBridge(); same
2091
+ // TODO(#373 follow-up) about savedScheduler and internal schedulers
2092
+ // applies here).
1948
2093
  uint8_t savedChannel = _meshChannel;
2094
+ TSTRING savedMeshSSID = _meshSSID;
2095
+ TSTRING savedMeshPassword = _meshPassword;
2096
+ TSTRING savedRouterSSID = routerSSID;
2097
+ TSTRING savedRouterPassword = routerPassword;
2098
+ Scheduler *savedScheduler = mScheduler;
2099
+ auto savedBridgeRoleChangedCallback = bridgeRoleChangedCallback;
1949
2100
 
1950
2101
  // CRITICAL FIX: Schedule the stop/reinit work to run after current task completes
1951
2102
  // This prevents use-after-free crash when stop() clears taskList while
1952
2103
  // the retry task is still executing
1953
2104
  // Use minimal delay to allow current task to complete first
1954
- this->addTask(ASYNC_PROMOTION_DELAY_MS, TASK_ONCE, [this, savedChannel]() {
2105
+ this->addTask(ASYNC_PROMOTION_DELAY_MS, TASK_ONCE,
2106
+ [this, savedChannel, savedMeshSSID, savedMeshPassword,
2107
+ savedRouterSSID, savedRouterPassword, savedScheduler,
2108
+ savedBridgeRoleChangedCallback]() {
1955
2109
  using namespace logger;
1956
2110
 
1957
2111
  Log(CONNECTION, "Executing isolated bridge promotion (stop/reinit cycle)\n");
1958
2112
 
1959
2113
  // Stop current mesh operations
1960
2114
  this->stop();
1961
- delay(1000);
1962
-
1963
- // Attempt to initialize as bridge
1964
- bool bridgeInitSuccess =
1965
- this->initAsBridge(_meshSSID, _meshPassword, routerSSID, routerPassword,
1966
- mScheduler, _meshPort);
1967
-
1968
- if (!bridgeInitSuccess) {
1969
- Log(ERROR, "✗ Isolated bridge promotion failed - router unreachable\n");
1970
- Log(ERROR, "Reverting to regular node on channel %d\n", savedChannel);
1971
-
1972
- // Re-initialize as regular node on the original channel
1973
- this->init(_meshSSID, _meshPassword, mScheduler, _meshPort, WIFI_AP_STA,
1974
- savedChannel, _meshHidden, MAX_CONN);
1975
-
1976
- // Re-configure router credentials for future retry attempts
1977
- this->setRouterCredentials(routerSSID, routerPassword);
1978
- this->enableBridgeFailover(true);
1979
-
1980
- // Set flag to skip empty scan check on next retry attempt
1981
- // since we already confirmed isolation before this failed attempt
1982
- _isolatedRetryPending = true;
1983
-
1984
- // Notify via callback
1985
- if (bridgeRoleChangedCallback) {
1986
- bridgeRoleChangedCallback(
1987
- false, "Isolated bridge promotion failed - router unreachable");
1988
- }
2115
+ // Allow event loop processing during hardware settling
2116
+ for (int i = 0; i < 100; i++) { delay(10); yield(); }
1989
2117
 
1990
- return;
1991
- }
2118
+ // initAsBridge always returns true: bridge mesh functionality is active
2119
+ // regardless of router connection status (router connection is opportunistic)
2120
+ this->initAsBridge(savedMeshSSID, savedMeshPassword, savedRouterSSID,
2121
+ savedRouterPassword, savedScheduler, _meshPort);
1992
2122
 
1993
- // Success! Reset retry counter
2123
+ // Reset retry counter
1994
2124
  _isolatedBridgeRetryAttempts = 0;
1995
2125
  lastRoleChangeTime = millis();
1996
2126
 
1997
- Log(STARTUP, "✓ Isolated bridge promotion complete on channel %d\n",
2127
+ Log(STARTUP, "[OK] Isolated bridge promotion complete on channel %d\n",
1998
2128
  _meshChannel);
1999
2129
 
2000
2130
  // Notify via callback
2001
- // Use explicit TSTRING construction to ensure string lifetime safety
2002
- if (bridgeRoleChangedCallback) {
2131
+ if (savedBridgeRoleChangedCallback) {
2003
2132
  static const TSTRING reason = "Isolated node promoted to bridge";
2004
- bridgeRoleChangedCallback(true, reason);
2133
+ savedBridgeRoleChangedCallback(true, reason);
2005
2134
  }
2006
2135
 
2007
2136
  // Note: Bridge status announcement will be sent automatically by
@@ -2131,50 +2260,141 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2131
2260
  */
2132
2261
  bool hasActualInternetAccess() {
2133
2262
  using namespace logger;
2134
-
2263
+
2264
+ // Cache result to avoid blocking DNS/HTTP on every call
2265
+ static uint32_t lastCheckTime = 0;
2266
+ static bool lastResult = false;
2267
+ uint32_t now = millis();
2268
+ if (lastCheckTime > 0 && (now - lastCheckTime) < 60000) {
2269
+ return lastResult;
2270
+ }
2271
+
2135
2272
  // First check WiFi connection
2136
2273
  if (WiFi.status() != WL_CONNECTED) {
2274
+ lastCheckTime = millis();
2275
+ lastResult = false;
2137
2276
  return false;
2138
2277
  }
2139
-
2278
+
2140
2279
  // Check if we have a valid local IP
2141
2280
  if (WiFi.localIP() == IPAddress(0, 0, 0, 0)) {
2281
+ lastCheckTime = millis();
2282
+ lastResult = false;
2142
2283
  return false;
2143
2284
  }
2144
-
2285
+
2145
2286
  // Try to resolve a well-known DNS name
2146
2287
  // Using Google's servers as they have high availability globally
2147
2288
  IPAddress result;
2148
-
2289
+
2149
2290
  #if defined(ESP32) || defined(ESP8266)
2150
2291
  // Both ESP32 and ESP8266 support WiFi.hostByName()
2151
2292
  int dnsResult = WiFi.hostByName("www.google.com", result);
2152
-
2293
+
2153
2294
  // Check if DNS resolution succeeded
2154
2295
  if (dnsResult != 1) {
2155
2296
  Log(COMMUNICATION, "hasActualInternetAccess(): DNS resolution failed (code=%d)\n", dnsResult);
2297
+ lastCheckTime = millis();
2298
+ lastResult = false;
2156
2299
  return false;
2157
2300
  }
2158
-
2301
+
2159
2302
  // Additional validation: Check if resolved IP is valid
2160
2303
  // Some ESP8266 versions may return success but set IP to 255.255.255.255 on error
2161
2304
  if (result == IPAddress(0, 0, 0, 0) || result == IPAddress(255, 255, 255, 255)) {
2162
2305
  TSTRING resultStr = result.toString();
2163
2306
  Log(COMMUNICATION, "hasActualInternetAccess(): Invalid DNS result IP: %s\n", resultStr.c_str());
2307
+ lastCheckTime = millis();
2308
+ lastResult = false;
2164
2309
  return false;
2165
2310
  }
2166
2311
  #else
2167
2312
  // Other platforms: assume internet is available if WiFi connected
2168
2313
  // (no reliable way to test without platform-specific APIs)
2314
+ lastCheckTime = millis();
2315
+ lastResult = true;
2169
2316
  return true;
2170
2317
  #endif
2171
-
2318
+
2172
2319
  TSTRING resultStr = result.toString();
2173
- Log(COMMUNICATION, "hasActualInternetAccess(): Internet connectivity verified (resolved to %s)\n",
2320
+ Log(COMMUNICATION, "hasActualInternetAccess(): Internet connectivity verified (resolved to %s)\n",
2174
2321
  resultStr.c_str());
2322
+ lastCheckTime = millis();
2323
+ lastResult = true;
2175
2324
  return true;
2176
2325
  }
2177
2326
 
2327
+ /**
2328
+ * Detect captive portal by making a lightweight HTTP request
2329
+ *
2330
+ * Captive portals often allow DNS resolution but intercept HTTP requests,
2331
+ * returning redirects, cached responses (HTTP 203), or their own HTML.
2332
+ * This function makes a simple HTTP GET request to a known endpoint and
2333
+ * verifies the response to detect such interference.
2334
+ *
2335
+ * Test endpoint used:
2336
+ * - http://captive.apple.com/hotspot-detect.html - Returns "Success" (Apple standard)
2337
+ *
2338
+ * @return true if no captive portal detected, false if portal found or check fails
2339
+ */
2340
+ bool detectCaptivePortal() {
2341
+ using namespace logger;
2342
+
2343
+ #if defined(ESP32) || defined(ESP8266)
2344
+ HTTPClient http;
2345
+ http.setTimeout(5000); // 5 second timeout for quick check
2346
+
2347
+ WiFiClient client;
2348
+
2349
+ // Use Apple's captive portal detection endpoint
2350
+ // This is a well-maintained, reliable endpoint used by iOS devices
2351
+ const char* testUrl = "http://captive.apple.com/hotspot-detect.html";
2352
+
2353
+ Log(COMMUNICATION, "detectCaptivePortal(): Testing %s\n", testUrl);
2354
+
2355
+ #ifdef ESP8266
2356
+ if (!http.begin(client, testUrl)) {
2357
+ Log(COMMUNICATION, "detectCaptivePortal(): Failed to begin HTTP client - treating as potential network restriction\n");
2358
+ return false; // Conservative approach: treat initialization failure as potential captive portal or network restriction
2359
+ }
2360
+ #else
2361
+ // ESP32
2362
+ if (!http.begin(testUrl)) {
2363
+ Log(COMMUNICATION, "detectCaptivePortal(): Failed to begin HTTP client - treating as potential network restriction\n");
2364
+ return false;
2365
+ }
2366
+ #endif
2367
+
2368
+ int httpCode = http.GET();
2369
+
2370
+ if (httpCode != 200) {
2371
+ // Any response other than HTTP 200 indicates captive portal or network issue
2372
+ Log(COMMUNICATION, "detectCaptivePortal(): Unexpected HTTP code %d (expected 200)\n", httpCode);
2373
+ http.end();
2374
+ return false;
2375
+ }
2376
+
2377
+ // Check response content
2378
+ String response = http.getString();
2379
+ http.end();
2380
+
2381
+ // Verify the response contains "Success" - this is Apple's standard response
2382
+ // Full response: "<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>"
2383
+ // We check for presence of "Success" to be robust against minor format variations
2384
+ if (response.indexOf("Success") == -1) {
2385
+ Log(COMMUNICATION, "detectCaptivePortal(): Response doesn't contain 'Success', likely captive portal\n");
2386
+ return false;
2387
+ }
2388
+
2389
+ Log(COMMUNICATION, "detectCaptivePortal(): No captive portal detected\n");
2390
+ return true;
2391
+
2392
+ #else
2393
+ // Non-ESP platforms: can't reliably test, assume no captive portal
2394
+ return true;
2395
+ #endif
2396
+ }
2397
+
2178
2398
  /**
2179
2399
  * Helper method to send gateway acknowledgment
2180
2400
  */
@@ -2263,6 +2483,13 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2263
2483
  sendGatewayAck(pkg, false, 0, "Router has no internet access - check WAN connection");
2264
2484
  return true; // Consume package - we handled it (with error)
2265
2485
  }
2486
+
2487
+ // Finally, check for captive portal interference
2488
+ // This detects when DNS works but HTTP requests are intercepted
2489
+ if (!detectCaptivePortal()) {
2490
+ sendGatewayAck(pkg, false, 0, "Captive portal detected - requires web authentication. Check router/WiFi settings");
2491
+ return true; // Consume package - we handled it (with error)
2492
+ }
2266
2493
 
2267
2494
  #if defined(ESP32) || defined(ESP8266)
2268
2495
  // Make HTTP/HTTPS request
@@ -2538,9 +2765,20 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2538
2765
  std::shared_ptr<Task> bridgeCoordinationTask;
2539
2766
  std::map<uint32_t, uint8_t> bridgePriorities; // nodeId -> priority mapping
2540
2767
  std::vector<uint32_t> knownBridgePeers; // List of peer bridge node IDs
2541
- uint32_t selectedBridgeOverride = 0; // Manual bridge selection override
2542
2768
  size_t lastSelectedBridgeIndex = 0; // For round-robin selection
2543
2769
 
2770
+ // Bridge coordination monitoring callbacks and state
2771
+ struct BridgeCoordinationState {
2772
+ uint8_t priority;
2773
+ TSTRING role;
2774
+ uint8_t load;
2775
+ uint32_t lastSeen;
2776
+ };
2777
+ std::map<uint32_t, BridgeCoordinationState> lastBridgeCoordinationState;
2778
+ std::function<void(const plugin::BridgeCoordinationPackage&, uint32_t)> bridgeCoordinationCallback;
2779
+ std::function<void(const plugin::BridgeCoordinationPackage&, uint32_t, TSTRING)> bridgeCoordinationChangedCallback;
2780
+ std::shared_ptr<Task> bridgeLostDetectionTask;
2781
+
2544
2782
  // Shared gateway mode state and configuration
2545
2783
  bool _sharedGatewayMode = false;
2546
2784
  gateway::SharedGatewayConfig _sharedGatewayConfig;