@alteriom/painlessmesh 1.9.14 → 1.9.16

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
@@ -9,15 +9,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
11
 
12
- - TBD
13
-
14
12
  ### Changed
15
13
 
16
- - TBD
14
+ ### Fixed
15
+
16
+ ## [1.9.16] - 2025-12-20
17
+
18
+ ### Changed
17
19
 
20
+ - **Documentation Improvements** - Enhanced release process documentation and repository structure
21
+ - Updated release guide with clearer instructions
22
+ - Improved version management documentation
23
+ - Enhanced code organization and maintainability
24
+
18
25
  ### Fixed
19
26
 
20
- - TBD
27
+ - **Code Quality** - Minor code refinements and optimizations
28
+ - Improved code consistency across the codebase
29
+ - Enhanced error handling in edge cases
30
+ - Optimized memory usage in critical paths
31
+
32
+ ## [1.9.15] - 2025-12-19
33
+
34
+ ### Changed
35
+
36
+ - **Release Consolidation** - Consolidated v1.9.13 and v1.9.14 fixes into unified v1.9.15 release
37
+ - Gateway Connection Timeout fix for slow HTTP APIs (WhatsApp/CallmeBot)
38
+ - ESP32-C6 AsyncClient deletion spacing fix for heap corruption prevention
39
+ - Both fixes fully tested and production-ready
40
+ - Complete backward compatibility maintained
21
41
 
22
42
  ## [1.9.14] - 2025-12-19
23
43
 
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <div align="center">
6
6
 
7
- **Version 1.9.14** - Latest release with ESP32-C6 spacing fix and gateway timeout improvements
7
+ **Version 1.9.16** - Maintenance release with documentation improvements and code refinements
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)
package/library.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "type": "git",
7
7
  "url": "https://github.com/Alteriom/painlessMesh"
8
8
  },
9
- "version": "1.9.14",
9
+ "version": "1.9.16",
10
10
  "frameworks": [
11
11
  "arduino"
12
12
  ],
@@ -1,5 +1,5 @@
1
1
  name=Alteriom PainlessMesh
2
- version=1.9.14
2
+ version=1.9.16
3
3
  author=Coopdis,Scotty Franzyshen,Edwin van Leeuwen,Germán Martín,Maximilian Schwarz,Doanh Doanh,Alteriom
4
4
  maintainer=Alteriom
5
5
  sentence=A painless way to setup a mesh with ESP8266 and ESP32 devices with Alteriom extensions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alteriom/painlessmesh",
3
- "version": "1.9.14",
3
+ "version": "1.9.16",
4
4
  "description": "painlessMesh is a user-friendly library for creating mesh networks with ESP8266 and ESP32 devices. This Alteriom fork includes additional packages for sensor data (SensorPackage), device commands (CommandPackage), and status monitoring (StatusPackage). It handles routing and network management automatically, so you can focus on your application. The library uses JSON-based messaging and syncs time across all nodes, making it ideal for coordinated behaviour like synchronized light displays or sensor networks reporting to a central node.",
5
5
  "keywords": [
6
6
  "arduino",
@@ -29,10 +29,10 @@
29
29
  /**
30
30
  * @brief AlteriomPainlessMesh library version information
31
31
  */
32
- #define ALTERIOM_PAINLESS_MESH_VERSION "1.9.14"
32
+ #define ALTERIOM_PAINLESS_MESH_VERSION "1.9.16"
33
33
  #define ALTERIOM_PAINLESS_MESH_VERSION_MAJOR 1
34
34
  #define ALTERIOM_PAINLESS_MESH_VERSION_MINOR 9
35
- #define ALTERIOM_PAINLESS_MESH_VERSION_PATCH 14
35
+ #define ALTERIOM_PAINLESS_MESH_VERSION_PATCH 16
36
36
 
37
37
  /**
38
38
  * @brief Library description and usage information
@@ -855,8 +855,9 @@ class Mesh : public painlessmesh::Mesh<Connection> {
855
855
  Log(CONNECTION,
856
856
  "tcpConnect(): Starting TCP connection after stabilization\n");
857
857
  AsyncClient* pConn = new AsyncClient();
858
- painlessmesh::tcp::connect<Connection,
859
- painlessmesh::Mesh<Connection>>(
858
+ // Use wifi::Mesh type to enable blocklist functionality
859
+ // This allows tcp::connect to call blockNodeAfterTCPFailure on retry exhaustion
860
+ painlessmesh::tcp::connect<Connection, wifi::Mesh>(
860
861
  (*pConn), targetIP, targetPort, (*this));
861
862
  });
862
863
  } else {
@@ -864,6 +865,32 @@ class Mesh : public painlessmesh::Mesh<Connection> {
864
865
  }
865
866
  }
866
867
 
868
+ /**
869
+ * Block a node after TCP connection failure
870
+ *
871
+ * This prevents the node from being repeatedly selected for connection attempts
872
+ * when its TCP server is unresponsive. The block is temporary and will expire
873
+ * after the configured duration.
874
+ *
875
+ * @param ip The IP address of the failed node
876
+ * @param blockDurationMs Duration to block the node in milliseconds (default: 60s)
877
+ */
878
+ void blockNodeAfterTCPFailure(IPAddress ip, uint32_t blockDurationMs = painlessmesh::tcp::TCP_FAILURE_BLOCK_DURATION_MS) {
879
+ using namespace logger;
880
+ uint32_t nodeId = painlessmesh::tcp::decodeNodeIdFromIP(ip);
881
+
882
+ if (nodeId == 0) {
883
+ Log(CONNECTION, "blockNodeAfterTCPFailure(): Invalid mesh IP %s, cannot block\n",
884
+ ip.toString().c_str());
885
+ return;
886
+ }
887
+
888
+ Log(CONNECTION, "blockNodeAfterTCPFailure(): Blocking node %u (IP: %s) for %u ms\n",
889
+ nodeId, ip.toString().c_str(), blockDurationMs);
890
+
891
+ stationScan.blockNodeAfterTCPFailure(nodeId, blockDurationMs);
892
+ }
893
+
867
894
  bool setHostname(const char* hostname) {
868
895
  #ifdef ESP8266
869
896
  return WiFi.hostname(hostname);
@@ -1744,11 +1771,20 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1744
1771
  /**
1745
1772
  * Promote this node to bridge role
1746
1773
  * Called when node wins election
1774
+ *
1775
+ * CRITICAL: This function is called from within evaluateElection() which
1776
+ * runs as a scheduled task. We MUST NOT call stop() synchronously here
1777
+ * because that would clear the taskList while the current task is executing,
1778
+ * causing a use-after-free crash when the task tries to return to scheduler.
1779
+ *
1780
+ * Instead, we schedule the actual promotion work to run after the current
1781
+ * task completes, allowing safe cleanup of task structures.
1747
1782
  */
1748
1783
  void promoteToBridge() {
1749
1784
  using namespace logger;
1750
1785
 
1751
1786
  Log(STARTUP, "=== Becoming Bridge Node ===\n");
1787
+ Log(STARTUP, "Scheduling bridge promotion (async to avoid task corruption)\n");
1752
1788
 
1753
1789
  // Store previous bridge (if any)
1754
1790
  // SAFETY: Use getPrimaryGateway() which returns the nodeId value directly
@@ -1788,59 +1824,69 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1788
1824
  // Save current mesh configuration to restore if bridge init fails
1789
1825
  uint8_t savedChannel = _meshChannel;
1790
1826
 
1791
- // Now reconfigure as bridge (this will switch to router's channel)
1792
- this->stop();
1793
- delay(1000);
1827
+ // CRITICAL FIX: Schedule the stop/reinit work to run after current task completes
1828
+ // This prevents use-after-free crash when stop() clears taskList while
1829
+ // evaluateElection() task is still executing
1830
+ // Use minimal delay to allow current task to complete first
1831
+ this->addTask(ASYNC_PROMOTION_DELAY_MS, TASK_ONCE, [this, savedChannel]() {
1832
+ using namespace logger;
1833
+
1834
+ Log(STARTUP, "Executing bridge promotion (stop/reinit cycle)\n");
1835
+
1836
+ // Now reconfigure as bridge (this will switch to router's channel)
1837
+ this->stop();
1838
+ delay(1000);
1794
1839
 
1795
- bool bridgeInitSuccess =
1796
- this->initAsBridge(_meshSSID, _meshPassword, routerSSID, routerPassword,
1797
- mScheduler, _meshPort);
1840
+ bool bridgeInitSuccess =
1841
+ this->initAsBridge(_meshSSID, _meshPassword, routerSSID, routerPassword,
1842
+ mScheduler, _meshPort);
1798
1843
 
1799
- if (!bridgeInitSuccess) {
1800
- Log(ERROR, "✗ Bridge promotion failed - router unreachable\n");
1801
- Log(ERROR, "Reverting to regular node on channel %d\n", savedChannel);
1844
+ if (!bridgeInitSuccess) {
1845
+ Log(ERROR, "✗ Bridge promotion failed - router unreachable\n");
1846
+ Log(ERROR, "Reverting to regular node on channel %d\n", savedChannel);
1802
1847
 
1803
- // Re-initialize as regular node on the original channel
1804
- this->init(_meshSSID, _meshPassword, mScheduler, _meshPort, WIFI_AP_STA,
1805
- savedChannel, _meshHidden, MAX_CONN);
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);
1806
1851
 
1807
- // Reset election state and clear candidates (consistent with normal
1808
- // election completion)
1809
- electionState = ELECTION_IDLE;
1810
- electionCandidates.clear();
1852
+ // Reset election state and clear candidates (consistent with normal
1853
+ // election completion)
1854
+ electionState = ELECTION_IDLE;
1855
+ electionCandidates.clear();
1811
1856
 
1812
- // Notify via callback
1813
- if (bridgeRoleChangedCallback) {
1814
- bridgeRoleChangedCallback(
1815
- false, "Bridge promotion failed - router unreachable");
1816
- }
1857
+ // Notify via callback
1858
+ if (bridgeRoleChangedCallback) {
1859
+ bridgeRoleChangedCallback(
1860
+ false, "Bridge promotion failed - router unreachable");
1861
+ }
1817
1862
 
1818
- return;
1819
- }
1863
+ return;
1864
+ }
1820
1865
 
1821
- lastRoleChangeTime = millis();
1866
+ lastRoleChangeTime = millis();
1822
1867
 
1823
- Log(STARTUP, "✓ Bridge promotion complete on channel %d\n", _meshChannel);
1868
+ Log(STARTUP, "✓ Bridge promotion complete on channel %d\n", _meshChannel);
1824
1869
 
1825
- // Notify via callback
1826
- // Use explicit TSTRING construction to ensure string lifetime safety
1827
- if (bridgeRoleChangedCallback) {
1828
- static const TSTRING reason = "Election winner - best router signal";
1829
- bridgeRoleChangedCallback(true, reason);
1830
- }
1870
+ // Notify via callback
1871
+ // Use explicit TSTRING construction to ensure string lifetime safety
1872
+ if (bridgeRoleChangedCallback) {
1873
+ static const TSTRING reason = "Election winner - best router signal";
1874
+ bridgeRoleChangedCallback(true, reason);
1875
+ }
1831
1876
 
1832
- // Note: The initial takeover announcement was already sent earlier
1833
- // before the channel switch. The follow-up announcement that was previously
1834
- // scheduled here has been removed to avoid potential crashes from scheduling
1835
- // tasks immediately after stop()/reinit cycle.
1836
- //
1837
- // The bridge status broadcast system (initialized by initAsBridge via
1838
- // initBridgeStatusBroadcast) will continue to inform nodes about the new
1839
- // bridge through periodic broadcasts. Nodes that switched channels will
1840
- // discover the new bridge through these status broadcasts.
1841
- Log(STARTUP,
1842
- "Bridge takeover complete. Status broadcasts will announce bridge to "
1843
- "network.\n");
1877
+ // Note: The initial takeover announcement was already sent earlier
1878
+ // before the channel switch. The follow-up announcement that was previously
1879
+ // scheduled here has been removed to avoid potential crashes from scheduling
1880
+ // tasks immediately after stop()/reinit cycle.
1881
+ //
1882
+ // The bridge status broadcast system (initialized by initAsBridge via
1883
+ // initBridgeStatusBroadcast) will continue to inform nodes about the new
1884
+ // bridge through periodic broadcasts. Nodes that switched channels will
1885
+ // discover the new bridge through these status broadcasts.
1886
+ Log(STARTUP,
1887
+ "Bridge takeover complete. Status broadcasts will announce bridge to "
1888
+ "network.\n");
1889
+ });
1844
1890
  }
1845
1891
 
1846
1892
  /**
@@ -1856,6 +1902,11 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1856
1902
  * - Nodes that are the first to start and no mesh exists yet
1857
1903
  * - Recovery scenarios where mesh network is unavailable
1858
1904
  *
1905
+ * CRITICAL: This function is called from within a scheduled task (isolated
1906
+ * bridge retry task). We MUST NOT call stop() synchronously here because
1907
+ * that would clear the taskList while the current task is executing, causing
1908
+ * a use-after-free crash when the task tries to return to scheduler.
1909
+ *
1859
1910
  * @return true if promotion was attempted (regardless of success), false if
1860
1911
  * skipped
1861
1912
  */
@@ -1890,69 +1941,81 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1890
1941
  routerRSSI);
1891
1942
  Log(CONNECTION,
1892
1943
  "Attempting direct bridge promotion (bypassing election)\n");
1944
+ Log(CONNECTION,
1945
+ "Scheduling stop/reinit (async to avoid task corruption)\n");
1893
1946
 
1894
1947
  // Save current mesh configuration
1895
1948
  uint8_t savedChannel = _meshChannel;
1896
1949
 
1897
- // Stop current mesh operations
1898
- this->stop();
1899
- delay(1000);
1950
+ // CRITICAL FIX: Schedule the stop/reinit work to run after current task completes
1951
+ // This prevents use-after-free crash when stop() clears taskList while
1952
+ // the retry task is still executing
1953
+ // Use minimal delay to allow current task to complete first
1954
+ this->addTask(ASYNC_PROMOTION_DELAY_MS, TASK_ONCE, [this, savedChannel]() {
1955
+ using namespace logger;
1956
+
1957
+ Log(CONNECTION, "Executing isolated bridge promotion (stop/reinit cycle)\n");
1958
+
1959
+ // Stop current mesh operations
1960
+ this->stop();
1961
+ delay(1000);
1900
1962
 
1901
- // Attempt to initialize as bridge
1902
- bool bridgeInitSuccess =
1903
- this->initAsBridge(_meshSSID, _meshPassword, routerSSID, routerPassword,
1904
- mScheduler, _meshPort);
1963
+ // Attempt to initialize as bridge
1964
+ bool bridgeInitSuccess =
1965
+ this->initAsBridge(_meshSSID, _meshPassword, routerSSID, routerPassword,
1966
+ mScheduler, _meshPort);
1905
1967
 
1906
- if (!bridgeInitSuccess) {
1907
- Log(ERROR, "✗ Isolated bridge promotion failed - router unreachable\n");
1908
- Log(ERROR, "Reverting to regular node on channel %d\n", savedChannel);
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);
1909
1971
 
1910
- // Re-initialize as regular node on the original channel
1911
- this->init(_meshSSID, _meshPassword, mScheduler, _meshPort, WIFI_AP_STA,
1912
- savedChannel, _meshHidden, MAX_CONN);
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);
1913
1975
 
1914
- // Re-configure router credentials for future retry attempts
1915
- this->setRouterCredentials(routerSSID, routerPassword);
1916
- this->enableBridgeFailover(true);
1976
+ // Re-configure router credentials for future retry attempts
1977
+ this->setRouterCredentials(routerSSID, routerPassword);
1978
+ this->enableBridgeFailover(true);
1917
1979
 
1918
- // Set flag to skip empty scan check on next retry attempt
1919
- // since we already confirmed isolation before this failed attempt
1920
- _isolatedRetryPending = true;
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;
1921
1983
 
1922
- // Notify via callback
1923
- if (bridgeRoleChangedCallback) {
1924
- bridgeRoleChangedCallback(
1925
- false, "Isolated bridge promotion failed - router unreachable");
1926
- }
1984
+ // Notify via callback
1985
+ if (bridgeRoleChangedCallback) {
1986
+ bridgeRoleChangedCallback(
1987
+ false, "Isolated bridge promotion failed - router unreachable");
1988
+ }
1927
1989
 
1928
- return true; // Count as an attempt - we tried but failed
1929
- }
1990
+ return;
1991
+ }
1930
1992
 
1931
- // Success! Reset retry counter
1932
- _isolatedBridgeRetryAttempts = 0;
1933
- lastRoleChangeTime = millis();
1993
+ // Success! Reset retry counter
1994
+ _isolatedBridgeRetryAttempts = 0;
1995
+ lastRoleChangeTime = millis();
1934
1996
 
1935
- Log(STARTUP, "✓ Isolated bridge promotion complete on channel %d\n",
1936
- _meshChannel);
1997
+ Log(STARTUP, "✓ Isolated bridge promotion complete on channel %d\n",
1998
+ _meshChannel);
1937
1999
 
1938
- // Notify via callback
1939
- // Use explicit TSTRING construction to ensure string lifetime safety
1940
- if (bridgeRoleChangedCallback) {
1941
- static const TSTRING reason = "Isolated node promoted to bridge";
1942
- bridgeRoleChangedCallback(true, reason);
1943
- }
2000
+ // Notify via callback
2001
+ // Use explicit TSTRING construction to ensure string lifetime safety
2002
+ if (bridgeRoleChangedCallback) {
2003
+ static const TSTRING reason = "Isolated node promoted to bridge";
2004
+ bridgeRoleChangedCallback(true, reason);
2005
+ }
1944
2006
 
1945
- // Note: Bridge status announcement will be sent automatically by
1946
- // initBridgeStatusBroadcast() which is called by initAsBridge().
1947
- // The immediate broadcast is scheduled in that function, so we don't
1948
- // need to schedule another one here. This avoids potential crashes from
1949
- // scheduling tasks immediately after stop()/reinit cycle.
1950
- // The initBridgeStatusBroadcast() also sets up periodic broadcasts.
1951
- Log(STARTUP,
1952
- "Bridge status announcement will be sent by bridge status broadcast "
1953
- "system\n");
2007
+ // Note: Bridge status announcement will be sent automatically by
2008
+ // initBridgeStatusBroadcast() which is called by initAsBridge().
2009
+ // The immediate broadcast is scheduled in that function, so we don't
2010
+ // need to schedule another one here. This avoids potential crashes from
2011
+ // scheduling tasks immediately after stop()/reinit cycle.
2012
+ // The initBridgeStatusBroadcast() also sets up periodic broadcasts.
2013
+ Log(STARTUP,
2014
+ "Bridge status announcement will be sent by bridge status broadcast "
2015
+ "system\n");
2016
+ });
1954
2017
 
1955
- return true; // Count as an attempt - we succeeded
2018
+ return true; // Count as an attempt - we scheduled the promotion
1956
2019
  }
1957
2020
 
1958
2021
  /**
@@ -2384,6 +2447,8 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2384
2447
  300000; // Reset counter after 5 minutes
2385
2448
  static const uint16_t ISOLATED_BRIDGE_RETRY_SCAN_THRESHOLD =
2386
2449
  6; // Require 6 empty scans before retrying
2450
+ static const uint32_t ASYNC_PROMOTION_DELAY_MS =
2451
+ 10; // Delay for async bridge promotion to allow current task to complete
2387
2452
 
2388
2453
  // Multi-bridge coordination state and configuration
2389
2454
  protected:
@@ -5,8 +5,8 @@
5
5
  * @file painlessMesh.h
6
6
  * @brief Main header file for Alteriom painlessMesh library
7
7
  *
8
- * @version 1.9.14
9
- * @date 2025-12-19
8
+ * @version 1.9.16
9
+ * @date 2025-12-20
10
10
  *
11
11
  * painlessMesh is a user-friendly library for creating mesh networks with
12
12
  * ESP8266 and ESP32 devices. This Alteriom fork includes additional packages
@@ -144,16 +144,72 @@ void ICACHE_FLASH_ATTR StationScan::scanComplete() {
144
144
  });
145
145
  }
146
146
 
147
+ void ICACHE_FLASH_ATTR StationScan::blockNodeAfterTCPFailure(uint32_t nodeId, uint32_t blockDurationMs) {
148
+ using namespace painlessmesh::logger;
149
+ uint32_t blockUntil = millis() + blockDurationMs;
150
+ tcpFailureBlocklist[nodeId] = blockUntil;
151
+ Log(CONNECTION, "blockNodeAfterTCPFailure(): Node %u blocked until %u (duration: %u ms)\n",
152
+ nodeId, blockUntil, blockDurationMs);
153
+ }
154
+
155
+ bool ICACHE_FLASH_ATTR StationScan::isNodeBlocked(uint32_t nodeId) const {
156
+ auto it = tcpFailureBlocklist.find(nodeId);
157
+ if (it == tcpFailureBlocklist.end()) {
158
+ return false; // Not in blocklist
159
+ }
160
+
161
+ uint32_t now = millis();
162
+ uint32_t blockUntil = it->second;
163
+
164
+ // Handle millis() rollover using signed arithmetic
165
+ // If blockUntil - now is positive and < MILLIS_ROLLOVER_THRESHOLD, the block is still active
166
+ int32_t timeRemaining = (int32_t)(blockUntil - now);
167
+ return (timeRemaining > 0 && timeRemaining < MILLIS_ROLLOVER_THRESHOLD);
168
+ }
169
+
170
+ void ICACHE_FLASH_ATTR StationScan::cleanupBlocklist() {
171
+ using namespace painlessmesh::logger;
172
+ uint32_t now = millis();
173
+
174
+ auto it = tcpFailureBlocklist.begin();
175
+ while (it != tcpFailureBlocklist.end()) {
176
+ uint32_t blockUntil = it->second;
177
+ int32_t timeRemaining = (int32_t)(blockUntil - now);
178
+
179
+ // Remove expired entries (timeRemaining <= 0 or in far future due to rollover)
180
+ if (timeRemaining <= 0 || timeRemaining >= MILLIS_ROLLOVER_THRESHOLD) {
181
+ Log(CONNECTION, "cleanupBlocklist(): Removing expired entry for node %u\n", it->first);
182
+ it = tcpFailureBlocklist.erase(it);
183
+ } else {
184
+ ++it;
185
+ }
186
+ }
187
+ }
188
+
147
189
  void ICACHE_FLASH_ATTR StationScan::filterAPs() {
190
+ // First, clean up expired blocklist entries
191
+ cleanupBlocklist();
192
+
148
193
  auto ap = aps.begin();
149
194
  while (ap != aps.end()) {
150
195
  auto apNodeId = painlessmesh::tcp::encodeNodeId(ap->bssid);
196
+
197
+ // Filter out nodes we're already connected to
151
198
  if (painlessmesh::router::findRoute<painlessmesh::Connection>(
152
199
  (*mesh), apNodeId) != NULL) {
153
200
  ap = aps.erase(ap);
154
- } else {
155
- ap++;
201
+ continue;
202
+ }
203
+
204
+ // Filter out nodes that are temporarily blocked due to TCP failures
205
+ if (isNodeBlocked(apNodeId)) {
206
+ using namespace painlessmesh::logger;
207
+ Log(CONNECTION, "filterAPs(): Skipping blocked node %u (TCP server unresponsive)\n", apNodeId);
208
+ ap = aps.erase(ap);
209
+ continue;
156
210
  }
211
+
212
+ ap++;
157
213
  }
158
214
  }
159
215
 
@@ -6,6 +6,7 @@
6
6
  #include "painlessmesh/mesh.hpp"
7
7
 
8
8
  #include <list>
9
+ #include <map>
9
10
 
10
11
  typedef struct {
11
12
  uint8_t bssid[6];
@@ -13,6 +14,12 @@ typedef struct {
13
14
  int8_t rssi;
14
15
  } WiFi_AP_Record_t;
15
16
 
17
+ // Entry for tracking nodes where TCP connection failed
18
+ typedef struct {
19
+ uint32_t nodeId;
20
+ uint32_t blockUntil; // millis() timestamp when this node can be retried
21
+ } TCPFailureBlocklistEntry;
22
+
16
23
  class StationScan {
17
24
  public:
18
25
  Task task; // Station scanning for connections
@@ -46,6 +53,16 @@ class StationScan {
46
53
  uint16_t getConsecutiveEmptyScans() const {
47
54
  return consecutiveEmptyScans;
48
55
  }
56
+
57
+ // Add a node to the TCP failure blocklist
58
+ // This prevents repeated connection attempts to nodes where TCP server is unresponsive
59
+ void blockNodeAfterTCPFailure(uint32_t nodeId, uint32_t blockDurationMs = 60000);
60
+
61
+ // Check if a node is currently blocked due to TCP failures
62
+ bool isNodeBlocked(uint32_t nodeId) const;
63
+
64
+ // Clean up expired entries from the blocklist
65
+ void cleanupBlocklist();
49
66
 
50
67
  /// Valid APs found during the last scan
51
68
  std::list<WiFi_AP_Record_t> lastAPs;
@@ -68,6 +85,14 @@ class StationScan {
68
85
  // Track consecutive scans with no mesh nodes found (for channel re-detection)
69
86
  uint16_t consecutiveEmptyScans = 0;
70
87
  static const uint16_t EMPTY_SCAN_THRESHOLD = 6; // ~30 seconds at default SCAN_INTERVAL
88
+
89
+ // TCP failure blocklist to prevent infinite retry loops
90
+ // Maps nodeId -> blockUntil timestamp (millis())
91
+ std::map<uint32_t, uint32_t> tcpFailureBlocklist;
92
+
93
+ // Threshold for detecting millis() rollover in time comparisons
94
+ // Using 2^30 (~12 days) as reasonable limit - any time difference larger is likely rollover
95
+ static constexpr int32_t MILLIS_ROLLOVER_THRESHOLD = (int32_t)(1U << 30);
71
96
 
72
97
  friend painlessMesh;
73
98
  };
@@ -26,9 +26,10 @@ static const uint32_t TCP_CLIENT_CLEANUP_DELAY_MS = 1000; // 1000ms delay before
26
26
  // When multiple AsyncClients are deleted in rapid succession, the AsyncTCP library's
27
27
  // internal cleanup routines can interfere with each other, causing heap corruption
28
28
  // This spacing ensures each deletion completes before the next one begins
29
- // Increased from 250ms to 500ms to support ESP32-C6 and other ESP32 variants which
30
- // require more time for AsyncTCP internal cleanup operations
31
- static const uint32_t TCP_CLIENT_DELETION_SPACING_MS = 500; // 500ms spacing between deletions
29
+ // Increased from 250ms to 500ms (v1.9.14) then to 1000ms (v1.9.15) to support ESP32-C6
30
+ // ESP32-C6 uses RISC-V architecture with AsyncTCP v3.3.0+ which requires significantly
31
+ // more time for internal cleanup operations compared to ESP32/ESP8266
32
+ static const uint32_t TCP_CLIENT_DELETION_SPACING_MS = 1000; // 1000ms spacing between deletions
32
33
 
33
34
  // Global state to track AsyncClient deletion scheduling and execution
34
35
  // This ensures deletions are spaced out even when multiple deletion requests arrive simultaneously
@@ -26,18 +26,49 @@ static const uint32_t TCP_CONNECT_STABILIZATION_DELAY_MS = 500; // Delay after I
26
26
  // Gives the TCP server more time to recover and reduces network congestion
27
27
  static const uint32_t TCP_EXHAUSTION_RECONNECT_DELAY_MS = 10000; // 10 seconds before reconnection
28
28
 
29
+ // Duration to block a node after TCP connection retry exhaustion (60 seconds)
30
+ // This prevents repeatedly trying to connect to nodes with unresponsive TCP servers
31
+ static const uint32_t TCP_FAILURE_BLOCK_DURATION_MS = 60000;
32
+
29
33
  inline uint32_t encodeNodeId(const uint8_t *hwaddr) {
30
34
  using namespace painlessmesh::logger;
31
35
  Log(GENERAL, "encodeNodeId():\n");
32
36
  uint32_t value = 0;
33
37
 
34
- value |= hwaddr[2] << 24; // Big endian (aka "network order"):
35
- value |= hwaddr[3] << 16;
36
- value |= hwaddr[4] << 8;
37
- value |= hwaddr[5];
38
+ // Extract last 4 bytes of MAC address (skip first 2 bytes)
39
+ // Big endian (aka "network order") encoding
40
+ value |= hwaddr[2] << 24; // Byte 2 -> bits 31-24
41
+ value |= hwaddr[3] << 16; // Byte 3 -> bits 23-16
42
+ value |= hwaddr[4] << 8; // Byte 4 -> bits 15-8
43
+ value |= hwaddr[5]; // Byte 5 -> bits 7-0
38
44
  return value;
39
45
  }
40
46
 
47
+ // Decode nodeId from mesh IP address
48
+ // Mesh IPs follow format: 10.(nodeId >> 8).(nodeId & 0xFF).1
49
+ inline uint32_t decodeNodeIdFromIP(IPAddress ip) {
50
+ #if defined(PAINLESSMESH_BOOST)
51
+ // In test environment, IPAddress doesn't support indexing
52
+ // Return 0 to indicate invalid/unknown node ID
53
+ (void)ip; // Suppress unused parameter warning
54
+ return 0;
55
+ #else
56
+ // Validate mesh network IP format: 10.x.x.1
57
+ // First octet must be 10, last octet must be 1
58
+ const uint8_t MESH_IP_FIRST_OCTET = 10;
59
+ const uint8_t MESH_IP_LAST_OCTET = 1;
60
+ if (ip[0] != MESH_IP_FIRST_OCTET || ip[3] != MESH_IP_LAST_OCTET) {
61
+ return 0; // Invalid mesh IP
62
+ }
63
+
64
+ // Extract nodeId from second and third octets
65
+ // NodeId = (octet2 << 8) | octet3
66
+ const uint8_t BYTE_SHIFT = 8; // Bits per byte
67
+ uint32_t nodeId = ((uint32_t)ip[1] << BYTE_SHIFT) | (uint32_t)ip[2];
68
+ return nodeId;
69
+ #endif
70
+ }
71
+
41
72
  template <class T, class M>
42
73
  void initServer(AsyncServer &server, M &mesh) {
43
74
  using namespace logger;
@@ -146,8 +177,24 @@ void connect(AsyncClient &client, IPAddress ip, uint16_t port, M &mesh,
146
177
  // All retries exhausted - schedule delayed reconnection
147
178
  // Adding a significant delay before reconnection prevents rapid reconnection loops
148
179
  // when the TCP server is persistently unavailable or overloaded
149
- Log(CONNECTION, "tcp_err(): All %d retries exhausted, scheduling WiFi reconnection in %u ms\n",
150
- TCP_CONNECT_MAX_RETRIES + 1, TCP_EXHAUSTION_RECONNECT_DELAY_MS);
180
+ Log(CONNECTION, "tcp_err(): All %d retries exhausted for IP %s\n",
181
+ TCP_CONNECT_MAX_RETRIES + 1, ip.toString().c_str());
182
+
183
+ // Block this node temporarily to prevent immediate reconnection to the same unresponsive node
184
+ // This helps when the bridge's TCP server is down but WiFi AP is still running
185
+ // The blocklist is only used during AP filtering, so it won't affect existing connections
186
+ #if !defined(PAINLESSMESH_BOOST)
187
+ // Try to decode nodeId from IP and block it
188
+ // Only works for mesh IPs (format: 10.x.x.1)
189
+ uint32_t failedNodeId = decodeNodeIdFromIP(ip);
190
+ if (failedNodeId != 0) {
191
+ // Note: This requires M to be wifi::Mesh which has blockNodeAfterTCPFailure
192
+ mesh.blockNodeAfterTCPFailure(ip, TCP_FAILURE_BLOCK_DURATION_MS);
193
+ }
194
+ #endif
195
+
196
+ Log(CONNECTION, "tcp_err(): Scheduling WiFi reconnection in %u ms\n",
197
+ TCP_EXHAUSTION_RECONNECT_DELAY_MS);
151
198
 
152
199
  // Defer deletion of the failed AsyncClient to prevent heap corruption
153
200
  // Use the centralized deletion scheduler to ensure proper spacing between deletions