@alteriom/painlessmesh 1.10.0 → 2.0.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 (54) hide show
  1. package/BRIDGE_TO_INTERNET.md +167 -29
  2. package/CHANGELOG.md +483 -0
  3. package/CONTRIBUTING.md +56 -53
  4. package/README.md +100 -95
  5. package/RELEASE_GUIDE.md +81 -780
  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/sharedGateway/README.md +1 -2
  30. package/keywords.txt +50 -1
  31. package/library.json +8 -6
  32. package/library.properties +2 -2
  33. package/package.json +3 -3
  34. package/src/AlteriomPainlessMesh.h +3 -3
  35. package/src/arduino/wifi.hpp +556 -126
  36. package/src/painlessMesh.h +2 -2
  37. package/src/painlessMeshSTA.cpp +607 -87
  38. package/src/painlessMeshSTA.h +135 -3
  39. package/src/painlessmesh/ack.hpp +283 -0
  40. package/src/painlessmesh/buffer.hpp +70 -8
  41. package/src/painlessmesh/callback.hpp +38 -5
  42. package/src/painlessmesh/configuration.hpp +69 -1
  43. package/src/painlessmesh/connection.hpp +12 -5
  44. package/src/painlessmesh/gateway.hpp +270 -5
  45. package/src/painlessmesh/layout.hpp +70 -2
  46. package/src/painlessmesh/logger.hpp +15 -0
  47. package/src/painlessmesh/mesh.hpp +552 -48
  48. package/src/painlessmesh/ntp.hpp +2 -4
  49. package/src/painlessmesh/plugin.hpp +30 -6
  50. package/src/painlessmesh/protocol.hpp +55 -2
  51. package/src/painlessmesh/router.hpp +192 -77
  52. package/src/painlessmesh/tcp.hpp +10 -0
  53. package/src/painlessmesh/message_tracker.hpp +0 -311
  54. /package/examples/sendToInternet/{mock_server_test.ino → mock_server_test/mock_server_test.ino} +0 -0
@@ -33,6 +33,35 @@ void ICACHE_FLASH_ATTR StationScan::init(painlessmesh::wifi::Mesh *pMesh,
33
33
  channel = pchannel;
34
34
  hidden = phidden;
35
35
 
36
+ // A node re-initialised in place — promoted to bridge, or back to a
37
+ // regular node — keeps this object, and everything it had learned in
38
+ // its previous life came with it: a newly promoted bridge ran an
39
+ // all-channel re-detection because the request was still set from
40
+ // when it was a rootless regular node, and its empty-scan count,
41
+ // back-offs and "has ever seen a root" carried over the same way. A
42
+ // new life starts with none of that. The manual flag is set by
43
+ // stationManual() after this call, for the link that needs it.
44
+ manual = false;
45
+ consecutiveEmptyScans = 0;
46
+ scanRequested = false;
47
+ redetectRequested = false;
48
+ scanAllChannels = false;
49
+ scanSlice = 0;
50
+ huntChannel = 0;
51
+ huntPending = false;
52
+ huntCounts.clear();
53
+ huntRssi.clear();
54
+ orphanScanBackoff = 0;
55
+ orphanRedetects = 0;
56
+ everRooted = false;
57
+ rootedChannel = 0;
58
+ homeStays = 0;
59
+ pendingElsewhere = 0;
60
+ partitionScans = 0;
61
+ halfOpenDropped = false;
62
+ connectAttemptStarted = 0;
63
+ aps.clear();
64
+
36
65
  task.set(SCAN_INTERVAL, TASK_FOREVER, [this]() { stationScan(); });
37
66
  }
38
67
 
@@ -42,23 +71,87 @@ void ICACHE_FLASH_ATTR StationScan::stationScan() {
42
71
  Log(CONNECTION, "stationScan(): %s\n", ssid.c_str());
43
72
 
44
73
  // If channel is 0, auto-detect the mesh channel first
45
- if (channel == 0 && mesh->_meshChannel == 0) {
74
+ if (channel == 0) {
46
75
  Log(STARTUP, "stationScan(): Auto-detecting mesh channel...\n");
47
- uint8_t detectedChannel = scanForMeshChannel(ssid, hidden);
76
+ uint8_t detectedChannel = scanForMeshChannel(
77
+ ssid, hidden, 0,
78
+ mesh->routerCredentialsConfigured ? mesh->routerSSID : TSTRING(""));
48
79
  if (detectedChannel > 0) {
80
+ uint8_t oldChannel = mesh->_meshChannel;
49
81
  mesh->_meshChannel = detectedChannel;
50
82
  channel = detectedChannel;
51
83
  Log(STARTUP, "stationScan(): Mesh channel auto-detected: %d\n", detectedChannel);
84
+ // init() has already created the AP. When channel 0 was requested and
85
+ // no peer was visible during that first instant, the ESP Wi-Fi stack
86
+ // created it on channel 1. Recreate it on the detected channel before
87
+ // connecting the station, otherwise a temporary station disconnect can
88
+ // snap the AP back to channel 1 and isolate a failover candidate.
89
+ if (oldChannel != detectedChannel && (WiFi.getMode() & WIFI_AP)) {
90
+ WiFi.softAPdisconnect(true);
91
+ delay(200);
92
+ mesh->apInit(mesh->getNodeId());
93
+ delay(100);
94
+ }
52
95
  } else {
53
- // Mesh not found, fall back to channel 1
54
- mesh->_meshChannel = 1;
55
- channel = 1;
56
- Log(CONNECTION, "stationScan(): Mesh not found, falling back to channel 1\n");
96
+ // Keep channel == 0 so the next station scan retries all-channel
97
+ // detection. Permanently replacing it with channel 1 after one miss
98
+ // made a node unable to follow a bridge that was still starting or had
99
+ // just moved the mesh to its router channel.
100
+ if (mesh->_meshChannel == 0) mesh->_meshChannel = 1;
101
+ Log(CONNECTION,
102
+ "stationScan(): Mesh not found, using channel 1 temporarily and "
103
+ "retrying auto-detection\n");
57
104
  }
58
105
  }
59
106
 
107
+ // Channel 0 scans them all. A re-detection dwells a shorter time per
108
+ // channel than the single-channel scan: thirteen channels at 300 ms is
109
+ // four seconds off the mesh channel, and the point of doing it
110
+ // asynchronously is lost if the radio is away that long.
111
+ uint8_t scanChannel = channel;
112
+ bool allChannels = false;
113
+ uint8_t slice = 0;
114
+ if (redetectRequested) {
115
+ redetectRequested = false;
116
+ size_t stationsUnderAp = 0;
117
+ for (auto&& sub : mesh->subs) {
118
+ if (sub->connected() && !sub->station) ++stationsUnderAp;
119
+ }
120
+ if (stationsUnderAp == 0) {
121
+ Log(CONNECTION,
122
+ "stationScan(): re-detecting the mesh channel, scanning all channels\n");
123
+ allChannels = true;
124
+ } else {
125
+ Log(CONNECTION,
126
+ "stationScan(): re-detecting the mesh channel a channel at a time: "
127
+ "%u station(s) under this AP would drop during an all-channel scan\n",
128
+ (unsigned)stationsUnderAp);
129
+ huntChannel = 1;
130
+ huntCounts.clear();
131
+ huntRssi.clear();
132
+ }
133
+ }
134
+ if (huntChannel != 0) {
135
+ if (huntChannel == mesh->_meshChannel) ++huntChannel;
136
+ if (huntChannel > 13) {
137
+ // Every other channel has been looked at; this scan is the node's own
138
+ // channel, and scanComplete() decides with the tally.
139
+ huntChannel = 0;
140
+ huntPending = true;
141
+ } else {
142
+ slice = huntChannel;
143
+ scanChannel = slice;
144
+ }
145
+ }
146
+ if (allChannels) scanChannel = 0;
60
147
  #ifdef ESP32
61
- WiFi.scanNetworks(true, hidden, false, 300U, channel);
148
+ // A slice dwells as long as a regular scan: 120 ms on one channel missed
149
+ // the bridge's AP on the rig, and the hunt concluded the mesh was on no
150
+ // other channel. The all-channel scan keeps the short dwell, since it
151
+ // runs only on a node with nothing under its AP.
152
+ int16_t started = WiFi.scanNetworks(true, hidden, false,
153
+ allChannels ? 120U : 300U,
154
+ scanChannel);
62
155
  #elif defined(ESP8266)
63
156
  // WiFi.scanNetworksAsync([&](int networks) { this->scanComplete(); }, true);
64
157
  // Try 600 times (60 seconds). If not completed after that, give up
@@ -71,39 +164,160 @@ void ICACHE_FLASH_ATTR StationScan::stationScan() {
71
164
  });
72
165
  mesh->mScheduler->addTask(asyncTask);
73
166
  asyncTask.enableDelayed();
74
- WiFi.scanNetworks(true, hidden, channel);
167
+ int16_t started = WiFi.scanNetworks(true, hidden, scanChannel);
75
168
  #endif
76
169
 
170
+ if (started == WIFI_SCAN_FAILED) {
171
+ // The radio refused to start a scan — on ESP32 that is what it does
172
+ // while the station is mid-association. No scan-done event will ever
173
+ // come, so the ten-interval safety net below would be a five-minute
174
+ // silence; a node that has just lost its uplink cannot afford it.
175
+ Log(ERROR, "stationScan(): scan could not start, retrying in %d s\n",
176
+ (int)(0.5 * SCAN_INTERVAL / TASK_SECOND));
177
+ #ifdef ESP8266
178
+ asyncTask.disable();
179
+ #endif
180
+ task.delay(0.5 * SCAN_INTERVAL);
181
+ return;
182
+ }
183
+ scanRequested = true;
184
+ scanAllChannels = allChannels;
185
+ scanSlice = slice;
186
+
77
187
  task.delay(10 * SCAN_INTERVAL); // Scan should be completed by then and next
78
188
  // step called. If not then we restart here.
79
189
  return;
80
190
  }
81
191
 
192
+ void ICACHE_FLASH_ATTR StationScan::scanDone() {
193
+ using namespace painlessmesh::logger;
194
+ if (!scanRequested) {
195
+ // A synchronous scan's event (see scanComplete()). Not ours, and the
196
+ // task may be holding a yielded connectToAP() that must not be lost.
197
+ Log(CONNECTION, "scanDone(): not this task's scan, ignoring\n");
198
+ return;
199
+ }
200
+ task.yield([this]() { scanComplete(); });
201
+ }
202
+
82
203
  void ICACHE_FLASH_ATTR StationScan::scanComplete() {
83
204
  using namespace painlessmesh::logger;
205
+ // Reached by yield from scanDone(): the task's callback is the scan
206
+ // again from here, whatever this decides.
207
+ task.setCallback([this]() { stationScan(); });
208
+ if (!scanRequested) {
209
+ // The scan-done event of a synchronous scan — channel re-detection or
210
+ // a bridge takeover — whose results are consumed and deleted by the
211
+ // code that ran it. Treating it as ours found nothing, logged a scan
212
+ // failure, and rescanned immediately: on a rootless mesh every node
213
+ // did that back to back, and an OTA transfer through them stalled.
214
+ Log(CONNECTION, "scanComplete(): not this task's scan, ignoring\n");
215
+ return;
216
+ }
217
+
218
+ auto num = WiFi.scanComplete();
219
+ if (num == WIFI_SCAN_RUNNING) {
220
+ // A stale scan-done event — a synchronous scan's, delivered after this
221
+ // task started its own — while ours is still in flight. Ours is still
222
+ // owed a result, so the flag stays; clearing it here left the real
223
+ // completion ignored and the node never joined.
224
+ Log(CONNECTION,
225
+ "scanComplete(): a scan is still running, waiting for it\n");
226
+ task.delay(10 * SCAN_INTERVAL); // as stationScan() left it
227
+ return;
228
+ }
229
+ scanRequested = false;
84
230
  Log(CONNECTION, "scanComplete(): Scan finished\n");
85
231
 
86
232
  aps.clear();
87
233
  Log(CONNECTION, "scanComplete():-- > Cleared old APs.\n");
88
234
 
89
- auto num = WiFi.scanComplete();
90
235
  if (num == WIFI_SCAN_FAILED) {
91
236
  Log(ERROR, "wifi scan failed. Retrying....\n");
92
237
  task.forceNextIteration();
93
238
  return;
94
- } else if (num == WIFI_SCAN_RUNNING) {
95
- Log(ERROR,
96
- "scanComplete should never be called when scan is still running.\n");
97
- return;
98
239
  }
99
240
 
100
241
  Log(CONNECTION, "scanComplete(): num = %d\n", num);
101
242
 
243
+ // One slice of a sliced hunt: tally the mesh APs it saw on channels other
244
+ // than this node's, then rest at home before the next slice. Nothing
245
+ // else is decided until the hunt has covered every channel.
246
+ uint8_t slice = scanSlice;
247
+ scanSlice = 0;
248
+ if (slice != 0) {
249
+ size_t seen = 0;
250
+ for (auto i = 0; i < num; ++i) {
251
+ TSTRING found = WiFi.SSID(i);
252
+ bool isMesh = found == ssid || (found.equals("") && mesh->_meshHidden);
253
+ uint8_t ch = WiFi.channel(i);
254
+ if (!isMesh || ch == mesh->_meshChannel ||
255
+ !painlessmesh::gateway::isValidMeshChannel(ch))
256
+ continue;
257
+ ++huntCounts[ch];
258
+ ++seen;
259
+ int8_t rssi = WiFi.RSSI(i);
260
+ if (!huntRssi.count(ch) || rssi > huntRssi[ch]) huntRssi[ch] = rssi;
261
+ }
262
+ Log(CONNECTION, "scanComplete(): hunt slice channel %u: %u mesh AP(s)\n",
263
+ slice, (unsigned)seen);
264
+ ++huntChannel;
265
+ task.delay(1500 * TASK_MILLISECOND);
266
+ return;
267
+ }
268
+
269
+ // A re-detection scan covered every channel. The mesh seen on a channel
270
+ // other than this node's may be the partition it is looking for — or a
271
+ // straggler: on the rig, a node still in gateway mode on the router's
272
+ // channel while the others are already back on the mesh channel, seen
273
+ // by a node that had three peers here and left them for it. What tells
274
+ // the two apart is size. The mesh APs on each other channel are
275
+ // counted; the channel with the most is the candidate, the strongest
276
+ // signal breaks a tie, and whether to go is decided below. A sliced hunt
277
+ // arrives here with its tally already made.
278
+ bool redetecting = scanAllChannels || huntPending;
279
+ scanAllChannels = false;
280
+ uint8_t elsewhere = 0;
281
+ int8_t elsewhereRssi = -128;
282
+ size_t elsewhereCount = 0;
283
+ std::map<uint8_t, size_t> meshApsOnChannel;
284
+ if (huntPending) {
285
+ huntPending = false;
286
+ for (auto&& entry : huntCounts) {
287
+ meshApsOnChannel[entry.first] = entry.second;
288
+ int8_t rssi = huntRssi.count(entry.first) ? huntRssi[entry.first] : -128;
289
+ if (entry.second > elsewhereCount ||
290
+ (entry.second == elsewhereCount && rssi > elsewhereRssi)) {
291
+ elsewhere = entry.first;
292
+ elsewhereCount = entry.second;
293
+ elsewhereRssi = rssi;
294
+ }
295
+ }
296
+ Log(CONNECTION,
297
+ "scanComplete(): sliced hunt done: mesh on %u other channel(s)%s\n",
298
+ (unsigned)huntCounts.size(), elsewhere ? "" : ", none elsewhere");
299
+ huntCounts.clear();
300
+ huntRssi.clear();
301
+ }
302
+
102
303
  for (auto i = 0; i < num; ++i) {
103
304
  WiFi_AP_Record_t record;
104
305
  record.ssid = WiFi.SSID(i);
306
+ bool isMesh = record.ssid == ssid ||
307
+ (record.ssid.equals("") && mesh->_meshHidden);
105
308
 
106
309
  if (WiFi.channel(i) != mesh->_meshChannel) {
310
+ if (redetecting && isMesh &&
311
+ painlessmesh::gateway::isValidMeshChannel(WiFi.channel(i))) {
312
+ uint8_t ch = WiFi.channel(i);
313
+ size_t count = ++meshApsOnChannel[ch];
314
+ if (count > elsewhereCount ||
315
+ (count == elsewhereCount && WiFi.RSSI(i) > elsewhereRssi)) {
316
+ elsewhere = ch;
317
+ elsewhereCount = count;
318
+ elsewhereRssi = WiFi.RSSI(i);
319
+ }
320
+ }
107
321
  continue;
108
322
  }
109
323
 
@@ -127,6 +341,128 @@ void ICACHE_FLASH_ATTR StationScan::scanComplete() {
127
341
 
128
342
  Log(CONNECTION, "\tFound %d nodes\n", aps.size());
129
343
 
344
+ if (redetecting) {
345
+ // A disconnected node follows the mesh wherever it is. A connected
346
+ // node is already in a partition, and leaves it only for a bigger
347
+ // one: the APs it can see on its own channel against those on the
348
+ // other. Strictly bigger — at the start of a test, with one AP up on
349
+ // each channel, a tie is exactly the straggler case, and following it
350
+ // took a node and its subtree out of the mesh for a minute at a time.
351
+ // A stranded partition still finds a bridge that has moved: its top
352
+ // node lost its station link and follows unconditionally, and each
353
+ // node it takes along drops its own children the same way.
354
+ // Size cannot tell where the root is: a bridge that has just moved to
355
+ // the router's channel is one AP against the rest of the mesh, and it
356
+ // is the one to follow. Time can: a node still in gateway mode during
357
+ // the sequential teardown — the straggler the rig saw a node follow and
358
+ // sit alone with for a minute — is gone by the next scan; a bridge, or
359
+ // the partition that has formed around it, is not. So a bigger
360
+ // partition elsewhere is followed at once, and a smaller one only when
361
+ // the same channel shows the mesh on two consecutive re-detections. A
362
+ // disconnected node makes no connection while it looks again: joined
363
+ // to this channel it would be "connected", and the rootless partition
364
+ // it joined would take a re-detection or two longer to leave.
365
+ // Not even a bigger one at once: during the teardown two nodes still
366
+ // in gateway mode outnumbered the one AP a connected node could see
367
+ // on its own channel, and it left the soak for them.
368
+ bool connected = WiFi.status() == WL_CONNECTED;
369
+ bool follow = false;
370
+ // Home first. A node that was rooted knows the bridge's channel, and a
371
+ // bridge — the old one back, or the backup promoted in its place — is
372
+ // pinned to it by its router. Away from home with the mesh visible
373
+ // there, home is the channel to follow whatever the sizes; at home
374
+ // with a partition here, nothing elsewhere is worth leaving for. A
375
+ // node alone at home with nothing here follows the mesh as before,
376
+ // and comes back with it when a bridge appears.
377
+ bool atHome = rootedChannel != 0 && mesh->_meshChannel == rootedChannel;
378
+ if (rootedChannel != 0 && !atHome && meshApsOnChannel[rootedChannel] > 0 &&
379
+ elsewhere != rootedChannel) {
380
+ Log(CONNECTION,
381
+ "scanComplete(): Mesh on channel %d, where it was rooted; going "
382
+ "there rather than channel %d\n",
383
+ rootedChannel, elsewhere);
384
+ elsewhere = rootedChannel;
385
+ elsewhereCount = meshApsOnChannel[rootedChannel];
386
+ }
387
+ if (atHome && elsewhere > 0 && (connected || !aps.empty())) {
388
+ if (++homeStays >= 4) {
389
+ Log(CONNECTION,
390
+ "scanComplete(): The mesh has been elsewhere for %u re-detections "
391
+ "with no root here; forgetting this as home\n",
392
+ (unsigned)homeStays);
393
+ rootedChannel = 0;
394
+ homeStays = 0;
395
+ } else {
396
+ Log(CONNECTION,
397
+ "scanComplete(): Mesh also on channel %d with %u nodes; this is "
398
+ "the channel the mesh was rooted on, staying (%u of 4)\n",
399
+ elsewhere, (unsigned)elsewhereCount, (unsigned)homeStays);
400
+ elsewhere = 0;
401
+ }
402
+ } else if (atHome) {
403
+ homeStays = 0;
404
+ }
405
+ if (elsewhere > 0) {
406
+ if (!connected && aps.empty()) {
407
+ follow = true; // nothing here to lose, nothing to look again for
408
+ } else if (pendingElsewhere == elsewhere) {
409
+ follow = true; // still there a scan later: not a straggler
410
+ } else {
411
+ Log(CONNECTION,
412
+ "scanComplete(): Mesh also on channel %d with %u nodes, %u "
413
+ "here; looking again before following\n",
414
+ elsewhere, (unsigned)elsewhereCount, (unsigned)aps.size());
415
+ pendingElsewhere = elsewhere;
416
+ redetectRequested = true;
417
+ // Look again soon, connected or not. A connected node used to fall
418
+ // through to connectToAP(), whose "no root in sight" back-off put
419
+ // the second look up to a minute away: on the rig the node the
420
+ // bridge-discovery fixture sends from saw the new bridge's channel
421
+ // at 103 s and looked again at 172 s, and the fixture's window had
422
+ // closed. Skipping one round of connectToAP() costs nothing the
423
+ // second look does not give back.
424
+ aps.clear();
425
+ task.delay(0.5 * SCAN_INTERVAL);
426
+ return;
427
+ }
428
+ } else {
429
+ pendingElsewhere = 0;
430
+ }
431
+ if (follow) pendingElsewhere = 0;
432
+ if (follow) {
433
+ Log(CONNECTION,
434
+ "scanComplete(): Mesh found on different channel %d (was %d): %u "
435
+ "nodes there, %u here; following it\n",
436
+ elsewhere, mesh->_meshChannel, (unsigned)elsewhereCount,
437
+ (unsigned)aps.size());
438
+ // followBridgeChannel() does the whole move: it closes the station
439
+ // link, so an orphan actually leaves its old partition instead of
440
+ // restarting its AP on the new channel while still attached to the
441
+ // old one.
442
+ followBridgeChannel(elsewhere);
443
+ return;
444
+ }
445
+ if (elsewhere > 0) {
446
+ Log(CONNECTION,
447
+ "scanComplete(): Mesh also on channel %d with %u nodes; this "
448
+ "channel has %u, staying\n",
449
+ elsewhere, (unsigned)elsewhereCount, (unsigned)aps.size());
450
+ consecutiveEmptyScans = 0;
451
+ } else if (aps.empty()) {
452
+ // The mesh is on no channel at all. The empty-scan count stands: it
453
+ // is what lets an isolated bridge retry once it passes
454
+ // ISOLATED_BRIDGE_RETRY_SCAN_THRESHOLD.
455
+ Log(CONNECTION,
456
+ "scanComplete(): Mesh not found on any channel during re-scan\n");
457
+ } else {
458
+ Log(CONNECTION,
459
+ "scanComplete(): Mesh found on current channel %d, no channel "
460
+ "change needed\n",
461
+ mesh->_meshChannel);
462
+ consecutiveEmptyScans = 0;
463
+ }
464
+ }
465
+
130
466
  task.yield([this]() {
131
467
  // Task filter all unknown
132
468
  filterAPs();
@@ -222,6 +558,8 @@ void ICACHE_FLASH_ATTR StationScan::requestIP(WiFi_AP_Record_t &ap) {
222
558
  mesh->_meshChannel,
223
559
  ap.bssid[0], ap.bssid[1], ap.bssid[2],
224
560
  ap.bssid[3], ap.bssid[4], ap.bssid[5]);
561
+ connectAttemptStarted = millis();
562
+ halfOpenDropped = false;
225
563
  WiFi.begin(ap.ssid.c_str(), password.c_str(), mesh->_meshChannel, ap.bssid);
226
564
  return;
227
565
  }
@@ -257,71 +595,73 @@ void ICACHE_FLASH_ATTR StationScan::connectToAP() {
257
595
  }
258
596
  }
259
597
  }
598
+
599
+ #ifdef ESP32
600
+ if (WiFi.status() == WL_IDLE_STATUS && !halfOpenDropped &&
601
+ connectAttemptStarted != 0 &&
602
+ millis() - connectAttemptStarted > (uint32_t)(0.5 * SCAN_INTERVAL)) {
603
+ // The Arduino core reports WL_IDLE_STATUS from association until an
604
+ // address arrives. Half a scan interval after the attempt began, that
605
+ // means this station is associated with an AP that never gave it an
606
+ // address — a peer whose DHCP server was restarting, or one that
607
+ // rebooted under it. Nothing times that out: no disconnect event comes,
608
+ // and the mesh never learns of the failure. Drop the half-open link;
609
+ // the disconnect event schedules the rescan.
610
+ //
611
+ // Once per attempt. When the status is WL_IDLE_STATUS with nothing
612
+ // to disconnect — the core's own retry left it there — the disconnect
613
+ // changes nothing, and this guard, firing on every pass, returned
614
+ // before the scan results were ever looked at: the failover test's
615
+ // sender logged "dropping it" every thirty seconds with the count
616
+ // growing past three minutes and never connected to anything again.
617
+ // The second pass falls through to the scan, whose requestIP() starts
618
+ // a fresh attempt.
619
+ Log(CONNECTION,
620
+ "connectToAP(): Station associated without an address for %u ms, "
621
+ "dropping it\n",
622
+ millis() - connectAttemptStarted);
623
+ halfOpenDropped = true;
624
+ WiFi.disconnect();
625
+ task.delay(SCAN_INTERVAL); // Only reached if the event never fires
626
+ return;
627
+ }
628
+ #endif
260
629
  bool isRooted = layout::isRooted(mesh->asNodeTree());
630
+ if (isRooted) everRooted = true;
261
631
  if (aps.empty()) {
262
632
  // No unknown nodes found
263
633
  consecutiveEmptyScans++;
634
+ partitionScans = 0; // nothing unrouted in sight: not partitioned
264
635
 
265
- // If we've had multiple consecutive empty scans and we're not connected,
266
- // trigger a full channel re-scan to find the mesh
267
- if (consecutiveEmptyScans >= EMPTY_SCAN_THRESHOLD &&
268
- WiFi.status() != WL_CONNECTED &&
269
- channel > 0) {
636
+ // Re-detect the mesh channel once the empty scans pile up. Two cases
637
+ // need it, and the second used to be excluded:
638
+ // - the station is disconnected, so the mesh has left this channel;
639
+ // - the station is connected but to a partition with no root while the
640
+ // mesh is meant to have one — the node is orphaned. That is what a
641
+ // bridge start does to everyone it does not directly serve: it moves
642
+ // to the router's channel, the nodes it served drop and re-scan, and
643
+ // the nodes behind *them* stay connected to each other on the old
644
+ // channel, filter their peers as known, count empty scans, and were
645
+ // gated out of re-detection by the WL_CONNECTED check for good.
646
+ bool orphaned = mesh->shouldContainRoot && !isRooted;
647
+ if (consecutiveEmptyScans >= EMPTY_SCAN_THRESHOLD &&
648
+ (WiFi.status() != WL_CONNECTED || orphaned) && channel > 0) {
270
649
  Log(CONNECTION,
271
- "connectToAP(): No mesh nodes found for %d scans, triggering channel re-detection\n",
272
- consecutiveEmptyScans);
273
-
274
- // Perform full channel scan to find the mesh
275
- uint8_t detectedChannel = scanForMeshChannel(ssid, hidden);
276
- if (detectedChannel > 0 && detectedChannel != mesh->_meshChannel) {
277
- Log(CONNECTION,
278
- "connectToAP(): Mesh found on different channel %d (was %d), updating...\n",
279
- detectedChannel, mesh->_meshChannel);
280
-
281
- // Update mesh channel
282
- uint8_t oldChannel = mesh->_meshChannel;
283
- mesh->_meshChannel = detectedChannel;
284
- channel = detectedChannel;
285
-
286
- // Restart AP on new channel to match the mesh
287
- // This ensures this node's AP is also discoverable on the correct channel
288
- if (WiFi.getMode() & WIFI_AP) {
289
- Log(CONNECTION,
290
- "connectToAP(): Restarting AP from channel %d to channel %d\n",
291
- oldChannel, detectedChannel);
292
-
293
- // Disconnect AP and allow WiFi stack to fully reset
294
- // Using true parameter ensures DHCP server is properly stopped
295
- WiFi.softAPdisconnect(true);
296
- delay(200); // Increased delay to ensure complete WiFi stack reset
297
-
298
- // Call apInit via friend class access (StationScan is friend of wifi::Mesh)
299
- mesh->apInit(mesh->getNodeId());
300
-
301
- // Additional stabilization delay after AP restart
302
- // This ensures DHCP server is fully initialized before clients connect
303
- delay(100);
304
-
305
- Log(CONNECTION, "connectToAP(): AP restarted on channel %d\n", detectedChannel);
306
- }
307
- // Reset counter only when mesh is found on a new channel
308
- // This allows isolated bridge retry to continue when mesh is truly absent
309
- consecutiveEmptyScans = 0;
310
- } else if (detectedChannel == 0) {
311
- Log(CONNECTION,
312
- "connectToAP(): Mesh not found on any channel during re-scan\n");
313
- // Do NOT reset consecutiveEmptyScans here - mesh is still absent
314
- // This allows isolated bridge retry mechanism to trigger when
315
- // the counter exceeds ISOLATED_BRIDGE_RETRY_SCAN_THRESHOLD
316
- } else {
317
- // detectedChannel == mesh->_meshChannel
318
- // Mesh found on same channel we're already on - no channel change needed
319
- // Reset counter since mesh exists, nodes may appear in subsequent scans
320
- Log(CONNECTION,
321
- "connectToAP(): Mesh found on current channel %d, no channel change needed\n",
322
- detectedChannel);
323
- consecutiveEmptyScans = 0;
324
- }
650
+ "connectToAP(): No mesh nodes found for %d scans%s, re-detecting "
651
+ "the mesh channel on the next scan\n",
652
+ consecutiveEmptyScans, orphaned ? " (connected but unrooted)" : "");
653
+ // The next scan of this task covers every channel and scanComplete()
654
+ // follows the mesh if it is elsewhere. It used to run a synchronous
655
+ // all-channel scan right here: four to seven seconds with the main
656
+ // loop held and the radio off the mesh channel, on every node of a
657
+ // rootless mesh in turn, and an ACK owed through the scanning node
658
+ // arrived after the sender's budget — the soak's recurring loss.
659
+ // The re-detection is one scan interval later than it was; the
660
+ // interval below is the disconnected node's fast one or the orphan's
661
+ // backed-off one, so a stranded follower still catches up within
662
+ // the gateway contract, and a mesh that is simply rootless is not
663
+ // deaf for seconds at a time.
664
+ redetectRequested = true;
325
665
  }
326
666
 
327
667
  if (WiFi.status() == WL_CONNECTED &&
@@ -331,6 +671,66 @@ void ICACHE_FLASH_ATTR StationScan::connectToAP() {
331
671
  "connectToAP(): Already connected, and no unknown nodes found: "
332
672
  "scan rate set to slow\n");
333
673
  task.delay(4 * SCAN_INTERVAL);
674
+ } else if (orphaned && WiFi.status() == WL_CONNECTED) {
675
+ // Connected, told the mesh has a root, and not seeing one. The first
676
+ // re-detections come quickly — that is how a follower stranded by a
677
+ // bridge's channel move catches up — but a mesh that is simply
678
+ // rootless must not keep every node scanning all channels every
679
+ // half interval for as long as it stays so. Back off to two
680
+ // intervals; anything new on the air resets it.
681
+ // A leaf that has re-detected twice while connected and rootless,
682
+ // and found the mesh only on its own channel, is in a rootless
683
+ // partition that its scans cannot get it out of: every AP it can
684
+ // see is "known" — in its tree — including a bridge that was
685
+ // promoted a minute ago and is listed where it used to be, before
686
+ // its restart. On the rig the failover test's sender sat like that
687
+ // through the whole promotion window. Dropping the station link
688
+ // empties the tree, so the next scan sees every AP as new and the
689
+ // bridge's among them. Only a leaf: an interior node would take its
690
+ // subtree with it. The count resets when anything new is heard.
691
+ // And only if this mesh ever had a root: one that never did is
692
+ // rootless by design, and its leaves must not keep leaving.
693
+ size_t apChildren = 0;
694
+ for (auto&& sub : mesh->subs) {
695
+ if (sub->connected() && !sub->station) ++apChildren;
696
+ }
697
+ // Not from home, and not a failover candidate. At home the root will
698
+ // reappear here — the old bridge back, or a backup promoted in this
699
+ // partition — and a candidate must stay connected to hold the
700
+ // election at all: the bridge monitor skips a node with no mesh
701
+ // connections. On the rig the backup left its partition at 90 s,
702
+ // which skipped the election, rejoined, sent its candidacy at 129 s
703
+ // and left again at 133 s, before the votes were counted.
704
+ bool atHome = rootedChannel != 0 && mesh->_meshChannel == rootedChannel;
705
+ bool candidate =
706
+ mesh->bridgeFailoverEnabled && mesh->routerCredentialsConfigured;
707
+ bool stranded = ++orphanRedetects >= 2 && apChildren == 0 && everRooted;
708
+ if (stranded && (atHome || candidate)) {
709
+ Log(CONNECTION,
710
+ "connectToAP(): Still no root after %u re-detections; staying: "
711
+ "%s\n",
712
+ (unsigned)orphanRedetects,
713
+ candidate ? "this node is a failover candidate"
714
+ : "this is the channel the mesh was rooted on");
715
+ }
716
+ if (stranded && !atHome && !candidate) {
717
+ Log(CONNECTION,
718
+ "connectToAP(): Still no root after %u re-detections and nothing "
719
+ "new in sight; leaving this partition to look for it\n",
720
+ (unsigned)orphanRedetects);
721
+ orphanRedetects = 0;
722
+ orphanScanBackoff = 0;
723
+ mesh->closeConnectionSTA();
724
+ mesh->stability = 0;
725
+ task.delay(0.5 * SCAN_INTERVAL);
726
+ return;
727
+ }
728
+ uint32_t interval = (0.5 * SCAN_INTERVAL) * (1u << orphanScanBackoff);
729
+ Log(CONNECTION,
730
+ "connectToAP(): No root in sight, next scan in %u s\n",
731
+ (unsigned)(interval / TASK_SECOND));
732
+ task.delay(interval);
733
+ if (orphanScanBackoff < 2) orphanScanBackoff++;
334
734
  } else {
335
735
  // else scan fast (SCAN_INTERVAL)
336
736
  Log(CONNECTION,
@@ -342,6 +742,7 @@ void ICACHE_FLASH_ATTR StationScan::connectToAP() {
342
742
  } else {
343
743
  // Reset counter when APs are found
344
744
  consecutiveEmptyScans = 0;
745
+ orphanScanBackoff = 0;
345
746
  if (WiFi.status() == WL_CONNECTED) {
346
747
  // TODO: Use %u instead of String() here and below
347
748
  // Also prob is always equal to stability, so we should use that directly
@@ -349,8 +750,39 @@ void ICACHE_FLASH_ATTR StationScan::connectToAP() {
349
750
  "connectToAP(): Unknown nodes found. Current stability: %s\n",
350
751
  String(mesh->stability).c_str());
351
752
 
753
+ // A node that is connected, told the mesh has a root, cannot see one,
754
+ // and can see nodes it has no route to is in a partition — and the
755
+ // root is in the other one. The probabilistic reconfigure below is
756
+ // gated by `stability`, which only grows on scans that find nothing
757
+ // unknown, so a partitioned node's probability is near zero after
758
+ // its first attempt and the partition stands. On the rig one such
759
+ // partition held for the whole of a five-minute OTA transfer while
760
+ // the receiver's ten requests went to a sender it had no route to.
761
+ // Two consecutive scans showing the other partition is enough grace
762
+ // for a transient; then it reconnects, deterministically.
763
+ // Only a leaf may jump. An interior node that drops its station link
764
+ // takes its whole subtree with it and creates the fragmentation it
765
+ // was meant to heal — measured: fourteen such jumps in one suite and
766
+ // every delivery test failed. Leaves jumping one at a time still
767
+ // converge: each leaf that leaves makes its parent a leaf.
768
+ size_t apChildren = 0;
769
+ for (auto&& sub : mesh->subs) {
770
+ if (sub->connected() && !sub->station) ++apChildren;
771
+ }
772
+ if (!isRooted && mesh->shouldContainRoot && apChildren == 0) {
773
+ ++partitionScans;
774
+ } else {
775
+ partitionScans = 0;
776
+ }
352
777
  int prob = mesh->stability;
353
- if (!isRooted && random(0, 1000) < prob) {
778
+ if (!isRooted && (partitionScans >= 2 || random(0, 1000) < prob)) {
779
+ if (partitionScans >= 2) {
780
+ Log(CONNECTION,
781
+ "connectToAP(): Nodes without a route seen on %u scans while "
782
+ "unrooted; joining that partition\n",
783
+ partitionScans);
784
+ partitionScans = 0;
785
+ }
354
786
  Log(CONNECTION, "connectToAP(): Reconfigure network: %s\n",
355
787
  String(prob).c_str());
356
788
  // close STA connection, this will trigger station disconnect which
@@ -374,18 +806,75 @@ void ICACHE_FLASH_ATTR StationScan::connectToAP() {
374
806
  aps.pop_front(); // drop bestAP from mesh list, so if doesn't work out,
375
807
  // we can try the next one
376
808
  requestIP(ap);
377
- // Trying to connect, if that fails we will reconnect later
809
+ // A rejected attempt raises a disconnect event, which rescans at
810
+ // once; this delay only bounds the silent failures — an association
811
+ // that never gets an address — and two minutes was too long for a
812
+ // node whose bridge has just moved.
378
813
  Log(CONNECTION,
379
- "connectToAP(): Trying to connect, scan rate set to "
380
- "4*normal\n");
381
- task.delay(4 * SCAN_INTERVAL);
814
+ "connectToAP(): Trying to connect, next scan in one interval\n");
815
+ task.delay(SCAN_INTERVAL);
382
816
  }
383
817
  }
384
818
  }
385
819
 
820
+ bool ICACHE_FLASH_ATTR StationScan::followBridgeChannel(
821
+ uint8_t targetChannel) {
822
+ using namespace painlessmesh::logger;
823
+
824
+ if (!painlessmesh::gateway::isValidMeshChannel(targetChannel) ||
825
+ mesh == nullptr) {
826
+ Log(ERROR,
827
+ "followBridgeChannel(): Ignoring invalid bridge channel %u\n",
828
+ targetChannel);
829
+ return false;
830
+ }
831
+
832
+ if (mesh->_meshChannel == targetChannel) return false;
833
+
834
+ uint8_t previousChannel = mesh->_meshChannel;
835
+ Log(CONNECTION,
836
+ "followBridgeChannel(): Moving mesh from channel %u to bridge channel "
837
+ "%u\n",
838
+ previousChannel, targetChannel);
839
+
840
+ // Discard any asynchronous result from the old channel before changing the
841
+ // radio, otherwise its callback can move the node back after the takeover.
842
+ WiFi.scanDelete();
843
+ task.disable();
844
+ // The drops this causes are this node's doing, not a loss: judged as one
845
+ // they re-detected the channel just left, found the remnant there bigger,
846
+ // and moved back — on the rig two nodes followed the bridge to its
847
+ // channel and were back on the old one 28 s later, as the sender
848
+ // arrived.
849
+ channelMovedAt = millis();
850
+ mesh->closeConnectionSTA();
851
+ WiFi.disconnect();
852
+ delay(100);
853
+
854
+ mesh->_meshChannel = targetChannel;
855
+ channel = targetChannel;
856
+ consecutiveEmptyScans = 0;
857
+
858
+ if (WiFi.getMode() & WIFI_AP) {
859
+ WiFi.softAPdisconnect(true);
860
+ delay(100);
861
+ mesh->apInit(mesh->getNodeId());
862
+ delay(100);
863
+ }
864
+
865
+ // Resume discovery immediately. The old recovery path required repeated
866
+ // empty scans and exceeded the gateway failover contract.
867
+ orphanScanBackoff = 0;
868
+ task.enable();
869
+ task.forceNextIteration();
870
+ return true;
871
+ }
872
+
386
873
  // Helper function to scan all channels for a specific mesh SSID
387
874
  // Returns the channel number if found, or 0 if not found
388
- uint8_t ICACHE_FLASH_ATTR StationScan::scanForMeshChannel(TSTRING meshSSID, bool meshHidden) {
875
+ uint8_t ICACHE_FLASH_ATTR StationScan::scanForMeshChannel(TSTRING meshSSID, bool meshHidden,
876
+ uint8_t avoidChannel,
877
+ TSTRING routerSSID) {
389
878
  using namespace painlessmesh::logger;
390
879
  Log(CONNECTION, "scanForMeshChannel(): Scanning all channels for mesh '%s'...\n", meshSSID.c_str());
391
880
 
@@ -398,32 +887,63 @@ uint8_t ICACHE_FLASH_ATTR StationScan::scanForMeshChannel(TSTRING meshSSID, bool
398
887
 
399
888
  if (numNetworks == WIFI_SCAN_FAILED) {
400
889
  Log(ERROR, "scanForMeshChannel(): WiFi scan failed\n");
890
+ // The only exit that used to leave the results allocated, and the one
891
+ // taken when the radio is already busy — which is exactly when a node
892
+ // is retrying this every re-detection interval.
893
+ WiFi.scanDelete();
401
894
  return 0;
402
895
  }
403
896
 
404
897
  Log(CONNECTION, "scanForMeshChannel(): Found %d networks\n", numNetworks);
405
898
 
406
- // Search for the mesh SSID in scan results
899
+ // Collect every channel the mesh is on, then choose. Returning the first
900
+ // match made a stranded node's fate depend on scan order: seeing its own
901
+ // partition first, it concluded nothing had changed and stayed put.
902
+ std::vector<painlessmesh::gateway::MeshChannelCandidate> candidates;
903
+ uint8_t routerChannel = 0;
407
904
  for (int16_t i = 0; i < numNetworks; ++i) {
408
905
  TSTRING foundSSID = WiFi.SSID(i);
409
906
  uint8_t foundChannel = WiFi.channel(i);
410
907
  int32_t rssi = WiFi.RSSI(i);
411
-
412
- // Check if this is our mesh network
908
+
909
+ if (routerSSID.length() > 0 && foundSSID == routerSSID &&
910
+ foundChannel >= 1 && foundChannel <= 13) {
911
+ Log(CONNECTION,
912
+ "scanForMeshChannel(): Router %s on channel %d (RSSI: %d)\n",
913
+ routerSSID.c_str(), foundChannel, rssi);
914
+ routerChannel = foundChannel;
915
+ }
413
916
  if (foundSSID == meshSSID || (foundSSID == "" && meshHidden)) {
414
- // Validate channel is in valid range (1-13 for 2.4GHz)
415
917
  if (foundChannel >= 1 && foundChannel <= 13) {
416
- Log(CONNECTION, "scanForMeshChannel(): Found mesh on channel %d (RSSI: %d)\n",
918
+ Log(CONNECTION, "scanForMeshChannel(): Found mesh on channel %d (RSSI: %d)\n",
417
919
  foundChannel, rssi);
418
- WiFi.scanDelete();
419
- return foundChannel;
920
+ candidates.push_back({foundChannel, rssi});
420
921
  } else {
421
- Log(ERROR, "scanForMeshChannel(): Found mesh on invalid channel %d, ignoring\n",
922
+ Log(ERROR, "scanForMeshChannel(): Found mesh on invalid channel %d, ignoring\n",
422
923
  foundChannel);
423
924
  }
424
925
  }
425
926
  }
426
-
927
+
928
+ uint8_t chosen = painlessmesh::gateway::pickMeshChannel(candidates, avoidChannel,
929
+ routerChannel);
930
+ if (chosen != 0) {
931
+ if (routerChannel != 0 && chosen == routerChannel && candidates.size() > 1) {
932
+ Log(CONNECTION,
933
+ "scanForMeshChannel(): Mesh on %u channels; taking the router's, "
934
+ "channel %d, where a bridge would be\n",
935
+ (unsigned)candidates.size(), chosen);
936
+ }
937
+ if (avoidChannel != 0 && chosen != avoidChannel) {
938
+ Log(CONNECTION,
939
+ "scanForMeshChannel(): Mesh also on channel %d; preferring it over "
940
+ "current channel %d\n",
941
+ chosen, avoidChannel);
942
+ }
943
+ WiFi.scanDelete();
944
+ return chosen;
945
+ }
946
+
427
947
  Log(CONNECTION, "scanForMeshChannel(): Mesh '%s' not found on any channel\n", meshSSID.c_str());
428
948
  WiFi.scanDelete();
429
949
  return 0; // Not found