Repository navigation
Expand file tree
/
Copy pathWifiManager.cpp
More file actions
3293 lines (3155 loc) · 171 KB
/
Copy pathWifiManager.cpp
File metadata and controls
3293 lines (3155 loc) · 171 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Dobrev IT Ltd
//
// This file is part of RetiMesh Node.
//
// RetiMesh Node is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the
// Free Software Foundation, either version 3 of the License, or (at your
// option) any later version.
//
// RetiMesh Node is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
// Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with RetiMesh Node. If not, see <https://www.gnu.org/licenses/>.
// ============================================================================
// WifiManager.cpp — see WifiManager.h for the service overview.
// ============================================================================
#include "WifiManager.h"
#include <sys/stat.h>
#include "QrCode.h"
#include "Pmu.h"
#include "Gps.h"
#include "Compass.h"
#include "Environment.h"
#include "EnvReportPolicy.h"
#include "Imu.h"
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <memory>
#include <new>
#include <esp_wifi.h>
#include <esp_heap_caps.h>
#include <ESPmDNS.h>
#include "LoRaRadio.h"
#include "LoRaFem.h"
#include "Neighbors.h"
#include "RnsAnnounce.h"
#include "RnsTransport.h"
#include "LxmfInbox.h"
#include "RnsAdmin.h"
#include "Mdns.h"
#include "Diag.h"
#include "SettingsRules.h"
#include "SettingsFields.h"
#include "SdCard.h"
#include "StoreHome.h"
#include "OtaUpdate.h"
#include "AutoInterface.h"
#include "Power.h"
#include "LocalLink.h"
#include "PppUart.h"
#include "Bootloader.h"
WifiManager wifiManager;
// One answer for every write path that has to refuse while a restart is on
// its way: a write accepted now may or may not reach NVS before it, and the
// caller could not tell which.
static const char kRestartingMsg[] = "the node is restarting";
static const char PORTAL_URL[] = "http://10.42.0.1/";
static const char ADMIN_USER[] = "admin";
// How many times tick() re-tries the AP config patch after startAccessPoint()
// had it refused with ESP_ERR_WIFI_STATE (a station connect in flight). 15
// tries at the 2 s gate span ~30 s — one full station-watchdog reconnect
// period — so at least one attempt lands in the quiet gap between connects;
// bounded, so a driver refusing for some other reason cannot buy itself a
// permanent 2 s poll.
static const uint8_t kApPatchAttempts = 15;
// ---------------------------------------------------------------------------
// Body accumulation for JSON POSTs. Chunks land in request->_tempObject,
// which the request destructor free()s. Returns the complete body on the
// final chunk, nullptr while more is coming (or after sending an error).
// ---------------------------------------------------------------------------
static const char* collectBody(AsyncWebServerRequest* request, const uint8_t* data,
size_t len, size_t index, size_t total) {
if (total == 0 || total > 2048) {
if (index == 0) request->send(413, "application/json", "{\"error\":\"too large\"}");
return nullptr;
}
if (index == 0) request->_tempObject = malloc(total + 1);
auto* body = static_cast<char*>(request->_tempObject);
if (body == nullptr) {
if (index + len >= total) request->send(500);
return nullptr;
}
memcpy(body + index, data, len);
if (index + len < total) return nullptr;
body[total] = '\0';
return body;
}
// Whether the whole document actually reached `out`.
//
// ArduinoJson's String writer cannot be asked this directly. Its constructor
// assigns a null const char*, which on ESP32 is String::invalidate() and frees
// whatever the String held — so reserving room up front is undone before the
// first byte is written. Its bulk write() returns the length it was given
// whatever the individual writes did, and its destructor drops the result of
// the final flush(). A short heap therefore does not fail: concat() refuses,
// the appends are lost, and serializeJson still reports success.
//
// The length is the evidence that survives all of that. measureJson() says how
// long the document is, String::length() says how much of it arrived, and
// overflowed() covers the other half — a document the heap could not hold in
// the first place, which serialises cleanly and is simply missing values.
//
// This matters because the failure is silent and looks like ours: about two in
// five status polls on a Wireless Paper came back as half a document under a
// 200, and the dashboard's parse error is the only symptom.
static bool serializedWhole(const JsonDocument& doc, String& out, bool pretty) {
const size_t need = pretty ? measureJsonPretty(doc) : measureJson(doc);
if (pretty) serializeJsonPretty(doc, out);
else serializeJson(doc, out);
return !doc.overflowed() && out.length() == need;
}
// What a node says when it could not afford to answer. One string, because a
// caller telling the two apart by text has to match it exactly.
static const char* const kLowMemoryMsg =
"ran out of memory while replying — nothing was changed; try again";
// The same answer for the one path that cannot afford to build it: sendJson()
// is what a handler fails *inside*, so it cannot say whether the work was
// done and must not claim it was not. Named once so its two uses cannot drift.
static const char* const kLowMemoryJson = "{\"error\":\"low memory\"}";
// A short literal reply that a browser must not keep. The refusals below are
// readings as much as the documents are, and the one it would hurt most to
// reuse: a browser holding on to a 503 goes on showing a node as out of memory
// long after it has recovered.
static void sendNoStore(AsyncWebServerRequest* r, int code, const char* type, const char* body) {
AsyncWebServerResponse* res = r->beginResponse(code, type, body);
res->addHeader("Cache-Control", "no-store");
r->send(res);
}
// A reply that hands its body over rather than letting it be copied.
//
// request->send(code, type, String) builds an AsyncBasicResponse, and that
// constructor copies the body into a second String (WebResponses.cpp: the
// content length is then read off the copy). Both are alive at the same
// moment, so answering an eight-kilobyte /api/status wants two eight-kilobyte
// contiguous blocks, and a node whose heap has one left cannot find the
// second. What happens then is the worst of the outcomes available, because
// Arduino String fails an allocation by going empty rather than by saying so:
// the response takes its content length from the empty copy and the node
// answers 200 OK, Content-Length: 0. A caller sees a successful request with
// nothing in it, and no log line anywhere says why.
//
// Found on a node that had been up for days: /api/status and /api/qr both came
// back empty under a 200 while a two-byte reply from the same node was intact,
// and four other nodes answered those same requests with four to eight
// kilobytes. Note where that leaves the check above — it had done its job, the
// document serialised whole, and the reply was dropped one layer underneath
// it. A guard on our own serialisation cannot see this.
//
// So the body is moved into a filler the library calls as the socket drains,
// and it is never copied again. It sits behind a shared_ptr rather than being
// captured by move because the library copies the std::function twice on the
// way in — beginResponse takes it by value, and AsyncCallbackResponse's
// constructor assigns it again — and a String captured by move would be copied
// by both, which is the allocation this exists to remove. Copying a pointer
// costs nothing, and the body outlives the handler either way: the filler runs
// on the TCP task long after this function has returned.
//
// Returns nullptr when even that could not be afforded, which is a caller's
// cue to answer with something small. An empty body is one of those cases
// rather than a reply: no caller here can produce one honestly — sendJson()
// and handleExport() have already checked the document serialised to its
// measured length, handleBoardGet() starts from "[]", and a QR code always
// carries its own header — so a body that arrives empty is a String that
// failed to allocate and said nothing, which answered as 200 is exactly the
// silent empty reply this function exists to stop.
static AsyncWebServerResponse* ownedBodyResponse(AsyncWebServerRequest* r, int code,
const char* type, String&& body) {
const size_t len = body.length();
if (!len) return nullptr;
AsyncWebServerResponse* res = nullptr;
try {
std::shared_ptr<String> held = std::make_shared<String>(std::move(body));
res = r->beginResponse(type, len,
[held](uint8_t* buf, size_t maxLen, size_t index) -> size_t {
const size_t have = held->length();
const size_t left = index < have ? have - index : 0;
const size_t n = left < maxLen ? left : maxLen;
if (n) memcpy(buf, held->c_str() + index, n);
return n;
});
} catch (const std::bad_alloc&) {
return nullptr; // the pointer and its control block, not the body
}
res->setCode(code); // beginResponse throws rather than returning null
return res;
}
static void sendJson(AsyncWebServerRequest* r, int code, const JsonDocument& doc) {
String out;
if (!serializedWhole(doc, out, false)) {
sendNoStore(r, 503, "application/json", kLowMemoryJson);
return;
}
AsyncWebServerResponse* res = ownedBodyResponse(r, code, "application/json", std::move(out));
if (!res) {
sendNoStore(r, 503, "application/json", kLowMemoryJson);
return;
}
// Readings, not documents. A browser that reuses one of these shows an
// uptime, a heap figure or a task phase from some earlier minute and gives
// no sign it has done so — which is worse than a stale page, because a page
// that looks wrong is noticed and a number that looks plausible is not.
res->addHeader("Cache-Control", "no-store");
r->send(res);
}
static void sendError(AsyncWebServerRequest* r, int code, const char* msg) {
JsonDocument d;
d["error"] = msg;
sendJson(r, code, d);
}
// One JSON integer into a narrower settings field, through the shared width
// rule (SettingsRules::narrowInt — the comment there says why the JSON
// layer's own narrowing cannot be trusted with it). Absent or non-integer
// values leave the field alone, exactly as the is<int>() gates always have:
// a key the request does not carry is not part of the request. The value is
// read wide first — long long holds every integer JSON can carry — so an
// integer the field cannot hold is refused with a 400 rather than arriving
// at the validate*() rules as some other number. Sends the refusal itself;
// callers just return, as they do after authed().
template <typename T>
static bool jsonNarrow(AsyncWebServerRequest* r, JsonVariantConst v, T& out, const char* what) {
if (!v.is<long long>()) return true;
char msg[64];
if (SettingsRules::narrowInt(v.as<long long>(), out, what, msg, sizeof(msg))) return true;
sendError(r, 400, msg);
return false;
}
// ---------------------------------------------------------------------------
bool WifiManager::wifiEnabled() const { return settings.links().wifiEnabled(); }
// The bootloader plan and state, written once. Three handlers used to spell
// this out separately and had already drifted: the same fact was api_enabled
// in two of them and bootloader_api in the others, methods in one and
// bootloader_methods in another, so a tool reading two endpoints needed two
// parsers for one thing.
static void bootloaderJson(JsonObject o) {
const Bootloader::Plan p = Bootloader::plan();
const Bootloader::Pending r = Bootloader::snapshot();
o["software_entry"] = p.has(Bootloader::Method::SoftwareApi);
o["api_enabled"] = settings.maintenance().bootloaderApi;
o["pending"] = r.armed();
o["state"] = Bootloader::stateName(r.state);
if (r.armed()) {
o["target"] = Bootloader::targetName(r.target);
o["source"] = Bootloader::sourceName(r.source);
o["due_in_ms"] = r.dueInMs(millis());
}
o["primary"] = Bootloader::methodName(p.primary());
const Diag::LastRestart lr = Diag::boot().lastRestart;
if (lr.known) {
JsonObject last = o["last_restart"].to<JsonObject>();
last["to_persist_ms"] = lr.toPersistMs;
last["to_boot_ms"] = lr.toBootMs;
}
o["recovery"] = Bootloader::manualRecovery();
JsonArray methods = o["methods"].to<JsonArray>();
for (size_t i = 0; i < p.count; i++) methods.add(Bootloader::methodName(p.methods[i]));
}
// Whether this request came in over a host-facing link: judged by the
// address it was accepted at and the address it came from, together. See
// LocalLink::requestIsHostFacing for why neither is enough on its own.
static bool fromHostFacingLink(AsyncWebServerRequest* r) {
return LocalLink::requestIsHostFacing(LocalLink::hostOrder(r->client()->localIP()),
LocalLink::hostOrder(r->client()->remoteIP()));
}
// The maintenance switches, bound to their keys once. The same table shape
// LocalLink::fields() gives the links, for the same reason: four handlers
// spelled these three keys out by hand, and the fourth copy is the one that
// gets missed when a switch is added.
struct MaintField { const char* key; bool MaintenanceSettings::*on; };
static const MaintField kMaintFields[] = {
{ "bootloader_api", &MaintenanceSettings::bootloaderApi },
{ "bootloader_from_lan", &MaintenanceSettings::bootloaderFromLan },
{ "console_enabled", &MaintenanceSettings::consoleEnabled },
{ "console_tcp", &MaintenanceSettings::consoleTcp },
{ "web_ui", &MaintenanceSettings::webUi },
{ "mdns", &MaintenanceSettings::mdns },
{ "rns_admin", &MaintenanceSettings::rnsAdmin },
{ "lxmf_commands", &MaintenanceSettings::lxmfCommands },
};
void WifiManager::begin() {
// WiFi.setSleep() only reaches esp_wifi_set_ps() once the interface it
// names has actually started; called any earlier (Power::begin() runs
// before this) it just caches the request. STA_START/AP_START fire every
// time an interface comes up — first boot, a mode change, a reconnect — so
// hooking them here is what makes the profile's setting actually stick
// rather than leaving the driver on whatever ESP-IDF defaulted to.
WiFi.onEvent([](WiFiEvent_t event) {
if (event == ARDUINO_EVENT_WIFI_STA_START || event == ARDUINO_EVENT_WIFI_AP_START) {
Power::applyWifiSleep();
// Same lifecycle as the sleep setting: the TX ceiling only lands once
// the driver is started, and starting is exactly what these events say.
Power::applyWifiTxPower();
}
// Belt-and-braces for the listen interval: staConnect() writes it into
// the config before the connect goes out, and this re-applies it after
// every association in case something rebuilt the config in between.
// Not STA_START — that fires inside WiFi.begin(), where a write from
// this (event-task) hook races the config the core is building on the
// caller's task. On a config that already holds the value the helper's
// early-out makes this a read and no write, so an ordinary connect
// never touches an associated station's config.
if (event == ARDUINO_EVENT_WIFI_STA_CONNECTED) wifiManager.applyStaListenInterval();
});
if (wifiEnabled()) {
startAccessPoint();
// Captive portal: answer every DNS query on the access point with our
// own address. The OS connectivity probes then hit port 80 and get
// redirected below, which pops the "sign in to network" sheet on
// Android/iOS/Windows. Bound to the AP's address, not to every
// interface: a host on the USB link must not have its names steered
// here (CaptiveDns.h).
} else {
// Wi-Fi off is a configuration, not a failure: the names are still
// derived (the display and the console show them) and the radio is left
// down. The web server below starts regardless, bound to every interface,
// so a USB or PPP link — or WIFI ON at the console — reaches it.
resolveNames();
// lwIP and the netif layer have to exist for the servers below to bind.
// Network.begin() is the core's own stack bring-up — esp_netif, the
// default event loop and the event task — and the first thing
// WiFi.mode() does before it touches the driver. On core 2 that step had
// no public name; calling the private one on its own overflowed the IPC
// task on core 1 once the radio attached its receive interrupt, and the
// driver was started and stopped again just to get past it. Core 3
// publishes the step, so the node asks for it and leaves the radio down.
// What the driver's start-up did for the IPC task was never named, so
// this path stands on a real boot with Wi-Fi off and not on an argument.
Network.begin();
log_w("Wi-Fi is switched off in settings; the access point will not start");
}
// Split in two on purpose (Diag.h). The radio is what the switch is
// supposed to buy, and the server below is what a node pays whether the
// switch is on or off — so a node with Wi-Fi off shows exactly what making
// the server lazy would be worth, on the board it is running on.
Diag::cost(wifiEnabled() ? "wifi radio" : "netif stack (wifi off)");
// The portal is the largest thing a small board can decline: 28624 B of
// byte-addressable RAM with Wi-Fi on, measured on a Heltec Wireless Stick
// that has about 213 KB of it and needs 53 KB for Reticulum. Off means the
// routes are never registered and nothing listens on port 80; an operator
// reaches the node over the console's own listener instead, which costs a
// few hundred bytes to do the same job (ConsoleServer.h).
const bool webUi = settings.maintenance().webUi;
// Named once. Both boot paths — Wi-Fi off and Wi-Fi on — bill the same
// thing, and two copies of the words meant a later change to one of them
// would have made the bill disagree with itself depending on the path.
const char* const kHttpDnsLabel = webUi ? "http + dns" : "dns (no portal)";
// The resolver is not the portal's, and does not go off with it. Where the
// USB link exists it runs whether or not Wi-Fi does: the link's lease names
// the node as DNS and ESP-IDF's server cannot be told to name nobody
// (UsbNcm.cpp), so with nothing on port 53 an attached host waits out its
// full resolver timeout on every lookup instead of being refused and
// moving on — which is the failure CaptiveDns exists to prevent. A board
// with neither link has nothing to answer, and AsyncUDP's task goes unmade.
//
// The access point, not Wi-Fi in general. Those were the same question until
// the two links were switched apart, and they are not the same now: a
// station-only node has no AP_IP, so the address handed here is one it does
// not hold, every query is refused, and the responder answers for nothing.
// The USB link still wants it for the fast refusal described above.
if (settings.links().wifiApEnabled || HAS_USB_NCM) {
if (!_dns.begin(AP_IP)) log_w("captive DNS: could not bind port 53");
}
if (!webUi) {
log_w("the web portal is switched off; HTTP :%d does not answer. The console does, "
"on the cable and on TCP :%d", HTTP_PORT, CONSOLE_TCP_PORT);
} else {
setupRoutes();
_http.begin();
}
// http://retimesh.local/ (and http://<ssid>.local/) for clients whose
// captive-portal detection does not fire.
// Compare the stamp baked into this firmware against the one in the image it
// is serving. They are produced together by tools/asset_stamp.py, so a
// mismatch means only one half was flashed — the portal will be subtly wrong
// in ways nothing else reports.
{
File f = LittleFS.open("/assets.json", "r");
if (f) {
JsonDocument sd;
if (deserializeJson(sd, f) == DeserializationError::Ok) _assetStamp = sd["stamp"] | "";
f.close();
}
if (_assetStamp == ASSET_STAMP) {
log_i("web assets match this firmware (build %s)", ASSET_STAMP);
} else {
log_w("web assets were built from a different firmware: image has \"%s\", firmware "
"expects \"%s\". The portal may be missing controls this firmware supports, or "
"offer some it does not. Upload the filesystem to match — note that doing so "
"erases anything else on it, including the Reticulum store on boards with no "
"SD card.",
_assetStamp.isEmpty() ? "(none)" : _assetStamp.c_str(), ASSET_STAMP);
}
}
deriveHostname();
if (!wifiEnabled()) {
Diag::cost(kHttpDnsLabel);
if (webUi) log_i("HTTP :%d and RNS TCP :%d listening on every local link; Wi-Fi off",
HTTP_PORT, RNS_TCP_PORT);
else log_i("RNS TCP :%d listening on every local link; Wi-Fi and the portal are off, "
"and the console answers on TCP :%d", RNS_TCP_PORT, CONSOLE_TCP_PORT);
return;
}
// Billed apart from the resolver above it. The two used to share a line, so
// the one number covered a service the node needs and a convenience it does
// not — which is no use at all when the question is what a board with eight
// kilobytes left can afford to decline.
Diag::cost(kHttpDnsLabel);
startMdns();
Diag::cost(settings.maintenance().mdns ? "mdns" : "mdns (off)");
// What actually came up, which is no longer always an access point. Announcing
// a SoftAP and its address on a station-only node is a log line that sends
// somebody looking for a network that was never started — and the address it
// printed, 0.0.0.0, looks like a fault rather than an absence.
if (settings.links().wifiApEnabled) {
log_i("SoftAP \"%s\" (%s) up at %s (http:%d, rns:%d)", _ssid, _securityName,
WiFi.softAPIP().toString().c_str(), HTTP_PORT, RNS_TCP_PORT);
} else {
log_i("no access point: station only (http:%d, rns:%d on whatever the LAN gives)",
HTTP_PORT, RNS_TCP_PORT);
}
}
// The mDNS responder and its records, once per boot. Out of begin() because
// begin() is no longer the only Wi-Fi bring-up: a node that boots with Wi-Fi
// off returns before this and starts its network at runtime instead
// (syncRadioShape), and the responder has to come up on that path too.
// The boot-time Diag::cost lines stay in begin() — they are boot accounting.
// Once running, the component follows the AP netif's own down/up cycles by
// itself (apServicesDown says how); this is only ever needed once.
void WifiManager::startMdns() {
_mdnsUp = true; // the question is settled for this boot either way:
// maintenance.mdns is restart-applied, and a failed start
// already said so in the log
if (!settings.maintenance().mdns) {
log_i("mDNS: off — this node answers on its address, not by name "
"(6 KB of byte-addressable RAM a small board can spend elsewhere)");
return;
}
const bool webUi = settings.maintenance().webUi;
if (MDNS.begin(_hostname)) {
// Only the services that actually answer: a browser sent to _http._tcp on
// a node whose portal is off gets a connection refused and no
// explanation. Named once, and walked once, rather than the rule being
// spelled again inside the loop.
const char* services[2] = { "rns", nullptr };
size_t nServices = 1;
MDNS.addService("rns", "tcp", RNS_TCP_PORT);
if (webUi) {
MDNS.addService("http", "tcp", HTTP_PORT);
services[nServices++] = "http";
}
// So a browser or a script can tell the nodes apart without opening each
// one — and, now, reach the one it picked: this is the delivery address,
// the same value /api/status reports as lxmf_address. It used to be the
// retimesh.node hash, which distinguished nodes perfectly well and was
// useless for anything after that, since nothing announces it.
//
// Derived from the identity rather than read from the transport, which has
// not started when mDNS is registered and would publish an empty string
// here for the life of the record.
for (size_t i = 0; i < nServices; i++) {
const char* svc = services[i];
MDNS.addServiceTxt(svc, "tcp", "name", _ssid);
MDNS.addServiceTxt(svc, "tcp", "node", nodeIdentity.lxmfHex());
MDNS.addServiceTxt(svc, "tcp", "fw", FW_VERSION);
MDNS.addServiceTxt(svc, "tcp", "board", BOARD_NAME);
}
if (webUi) log_i("mDNS: http://%s.local (rns on :%d) — browse _rns._tcp to find every node",
_hostname, RNS_TCP_PORT);
else log_i("mDNS: %s.local, rns on :%d — no portal is advertised, it is switched off",
_hostname, RNS_TCP_PORT);
} else {
log_w("mDNS start failed");
}
}
// What this node calls itself, worked out without starting anything. The store
// migration runs before the radios are up and writes the node's name onto the
// card it is claiming, and it used to run after this — so the name went on the
// card empty, and a card whose owner had no name is a card a person cannot
// identify in their hand. Idempotent, and startAccessPoint() still calls it, so
// there is one derivation rather than an early copy and a real one.
void WifiManager::resolveNames() {
const WifiSettings& w = settings.wifi();
#ifdef AP_SSID
strlcpy(_ssid, AP_SSID, sizeof(_ssid));
#else
if (w.ssid[0] != '\0') {
strlcpy(_ssid, w.ssid, sizeof(_ssid));
} else {
// Factory base MAC from efuse: stable across boots and identical to
// the STA MAC (the SoftAP MAC is base+1, so it is deliberately not
// used).
uint64_t mac = ESP.getEfuseMac(); // little-endian: octet 0 in the LSB
snprintf(_ssid, sizeof(_ssid), "%s-%02X%02X%02X", AP_SSID_PREFIX,
(uint8_t)(mac >> 24), (uint8_t)(mac >> 32), (uint8_t)(mac >> 40));
}
#endif
deriveHostname();
}
// The radio's mode follows the two switches rather than the one. A station
// without an access point is now a shape this can be in — it is the shape a
// carried node wants, since the access point must beacon and cannot sleep —
// and asking for WIFI_AP_STA there would put the beacon back on the air.
//
// The idle policy's verdict is a term inside this rule, not a second rule
// beside it: while the policy holds the AP down (tick() mirrors its verdict
// into _apSuppressed), the settings shape simply has no AP in it, and every
// consumer — the convergence, the bring-up, the join-failure fallback —
// inherits that from the one place. Suppressed with no station to keep is
// the one shape the old rule never produced: the radio fully off.
wifi_mode_t WifiManager::settingsWifiMode(bool& wantAp, bool& wantSta) const {
wantAp = settings.links().wifiApEnabled && !_apSuppressed;
wantSta = settings.links().wifiStaEnabled && stationConfigured();
if (wantAp) return wantSta ? WIFI_AP_STA : WIFI_AP;
if (!_apSuppressed || wantSta) return WIFI_STA; // the shape this always was
return WIFI_MODE_NULL; // idle-off, nothing else to run
}
// The station's listen interval — how many AP beacon intervals it may doze
// between wakes under the battery profile's max modem sleep (the driver
// ignores it in every other power-save mode). One read-modify-write of the
// station config: staConnect() calls it with the config freshly written and
// nothing in flight, the STA_CONNECTED hook re-applies it after every
// association, and the settings commit calls it for a live change. The
// driver reads a stored 0 as its default of 3, so 0 and 3 are one value
// with two spellings — normalized before the early-out, or a node on the
// default setting would rewrite an associated station's config on every
// unrelated live commit (and esp_wifi_set_config on an associated station
// may bounce the link). Whether the driver's doze then actually follows the
// interval on the air is a bench question; the write itself is now checked
// and logged.
void WifiManager::applyStaListenInterval() {
wifi_config_t conf;
if (esp_wifi_get_config(WIFI_IF_STA, &conf) != ESP_OK) return; // no station interface up
const uint8_t want = settings.wifi().staListenInterval;
const uint8_t have = conf.sta.listen_interval ? conf.sta.listen_interval : 3;
if (have == want) return; // nothing to write
conf.sta.listen_interval = want;
const esp_err_t err = esp_wifi_set_config(WIFI_IF_STA, &conf);
if (err != ESP_OK)
log_w("station: listen interval %u not applied (err 0x%x) — the driver dozes at its default",
(unsigned)want, err);
else
// The bench watches for this: a rewrite from the STA_CONNECTED hook
// means something rebuilt the config after staConnect()'s clean write.
log_i("station: listen interval %u -> %u written", (unsigned)have, (unsigned)want);
}
// One station connect for every site that starts a join — the boot's, the
// glass's, and the fallback after a failed join. WiFi.begin() alone rebuilds
// the whole station config (a memset, core STA.cpp) and issues the connect
// in the same call, and esp_wifi_set_config is documented to refuse with
// ESP_ERR_WIFI_STATE while that connect is in flight (esp_wifi.h) — so a
// listen interval applied "right after begin()" was a write into a closing
// door, and the discarded refusal left the driver on its default. The core
// offers the two halves separately: begin() with connect=false does
// everything up to the connect — interface up, config written, dynamic IP
// asked for — which opens a quiet gap for the read-modify-write, and
// esp_wifi_connect() is then exactly the call the core's own tryConnect
// path would have made. The core's auto-reconnect and WiFi.reconnect()
// re-use the stored config without rebuilding it (STA.cpp), so what is
// written here is what every later association runs.
//
// The halves are called on WiFi.STA directly because WiFi.begin()'s
// wl_status_t cannot gate this: on success it returns the sticky
// STA.status(), which the event handler parks at WL_CONNECT_FAILED after
// any AUTH_FAIL and which begin/connect/disconnect never reset (core
// WiFiSTA.cpp, STA.cpp) — so after one wrong-password association, every
// later configure of the station, including one carrying the corrected
// password, would read as a failure and never reach the connect. The
// booleans are the real results of the same two calls WiFi.begin() makes.
void WifiManager::staConnect(const char* ssid, const char* password) {
if (!WiFi.STA.begin(/*tryConnect=*/false) ||
!WiFi.STA.connect(ssid, (password && password[0]) ? password : nullptr,
0, nullptr, /*tryConnect=*/false)) {
log_w("station: could not configure \"%s\"", ssid);
return; // the core did not take the config; nothing to connect
}
applyStaListenInterval(); // clean write: the connect has not been issued yet
const esp_err_t err = esp_wifi_connect();
if (err != ESP_OK) log_w("station: connect to \"%s\" failed (err 0x%x)", ssid, err);
}
// WPA needs 8..63 characters; anything else means an open network. One rule
// for the password softAP() runs with and the auth mode the config patch
// writes, so the two cannot drift apart.
static bool apSecured(const WifiSettings& w) {
return w.security != ApSecurity::Open && strlen(w.password) >= 8;
}
// One read-modify-write of the AP config. After softAP(): the Arduino core
// builds its config with beacon_interval = 100 on every softAP() call and
// writes it whenever any field it compares differs (core AP.cpp —
// softap_config_equal ignores beacon_interval, so an identical call skips
// the write and a standing patch survives it, but any real change writes the
// fresh config and silently reverts the beacon). The beacon slows on every
// shape of AP (400 TU ≈ 410 ms: a 4x cut in beacon airtime and current, for
// phones taking a moment longer to list the network — Config.h says why this
// is a build default rather than a setting); the auth mode joins the same
// transaction only where WPA3 is asked for, because the Arduino wrapper
// knows only open/WPA2 — through ESP-IDF the AP config accepts
// WPA2_WPA3_PSK / WPA3_PSK (cipher forced to CCMP, PMF implied by SAE), and
// mixed mode lets WPA2-only clients still join.
//
// esp_wifi_set_config is documented to refuse with ESP_ERR_WIFI_STATE while
// a station connect is in flight (esp_wifi.h). At boot the caller dodges
// that by ordering this before the join; at a runtime AP re-up the station
// is not ours to schedule around — the 30 s watchdog's reconnect keeps a
// connect in flight much of the time on a LAN-down node — so that refusal
// is reported through inFlight and tick() retries just this RMW until the
// connect settles. Every other failure is logged here and final.
//
// The auth-mode inputs come from the snapshot startAccessPoint() froze when
// it armed the patch, never from settings.wifi() here: a save with restart-
// applied fields can land between two retries, and a retry that re-read the
// settings would patch the next boot's security onto the AP the operator
// was just told keeps its shape until the reboot (the members say more).
bool WifiManager::applyApConfigPatch(bool& inFlight) {
inFlight = false;
const bool patchWpa3 = _apPatchWpa3;
wifi_config_t conf;
if (esp_wifi_get_config(WIFI_IF_AP, &conf) != ESP_OK) {
log_w("AP config could not be read — the beacon%s patch was skipped",
patchWpa3 ? "/WPA3" : "");
return false;
}
conf.ap.beacon_interval = WIFI_AP_BEACON_TU;
if (patchWpa3) {
conf.ap.authmode = _apPatchWpa3Only ? WIFI_AUTH_WPA3_PSK
: WIFI_AUTH_WPA2_WPA3_PSK;
conf.ap.pairwise_cipher = WIFI_CIPHER_TYPE_CCMP;
}
const esp_err_t err = esp_wifi_set_config(WIFI_IF_AP, &conf);
if (err == ESP_OK) {
if (patchWpa3)
_securityName = _apPatchWpa3Only ? "wpa3" : "wpa2wpa3";
return true;
}
inFlight = (err == ESP_ERR_WIFI_STATE);
if (!inFlight)
log_w("AP config patch rejected by the Wi-Fi driver (err 0x%x) — beacons stay at "
"100 TU%s", err, patchWpa3 ? " and the AP stays on WPA2" : "");
return false;
}
void WifiManager::startAccessPoint() {
const WifiSettings& w = settings.wifi();
resolveNames();
// Every node used to answer to the same "retimesh.local", so the second one
// on a network either lost the race or was silently renamed by conflict
// resolution to something nobody could predict — which made more than one
// node on one LAN unusable. The access-point name is already unique per node
// and already what the display and the portal show, so the mDNS name is that
// name rather than a second derivation from the MAC that could drift from it.
//
// mDNS labels are letters, digits and hyphens, compared without regard to
// case, so an SSID someone has renamed to "Shed roof" still yields a legal
// "shed-roof.local".
const bool secured = apSecured(w); // the one WPA-validity rule, above
const char* pass = secured ? w.password : nullptr;
bool wantAp, wantSta;
const wifi_mode_t mode = settingsWifiMode(wantAp, wantSta);
WiFi.persistent(false);
WiFi.mode(mode);
// IPv6 link-local on both links, asked for before they start: core 3
// creates the address when the interface comes up and only then, so a
// request made afterwards — which is when AutoInterface used to make it —
// waits for a start that has already happened. AutoInterface reads the
// addresses; the links are brought up here, so they are enabled here. And
// only for it: AutoInterface is this firmware's one consumer of link-local
// IPv6, so with peering off the links stay IPv4-only rather than paying
// DAD, MLD reports and router solicitations on both for nothing.
const bool wantIPv6 = AutoInterface::wanted();
if (wantAp) {
if (wantIPv6) WiFi.softAPenableIPv6();
WiFi.softAPConfig(AP_IP, AP_IP, AP_NETMASK);
WiFi.softAP(_ssid, pass, w.channel, w.hidden ? 1 : 0, w.maxStations);
}
if (wantIPv6) WiFi.enableIPv6();
_securityName = secured ? "wpa2" : "open";
const bool wantWpa3 = secured && w.security != ApSecurity::WPA2;
if (wantWpa3 && !WPA3_SOFTAP_SUPPORTED)
log_w("WPA3 needs an ESP-IDF 5 core; this build runs the AP as WPA2");
// The AP config read-modify-write (applyApConfigPatch above), and it must
// sit exactly here: after softAP() built the config, and before the
// station join below — esp_wifi_set_config refuses with ESP_ERR_WIFI_STATE
// while a station connect is in flight, so sitting after the join exposed
// the patch to that refusal on every AP+STA boot. This ordering only
// protects the boot path, though: at a runtime AP re-up the watchdog's
// reconnect can already have a connect in flight, so that one refusal
// arms a bounded retry that tick() serves once the connect settles.
if (wantAp) {
// The patch inputs are frozen here, at arm time, from the same settings
// read this bring-up runs on. Every attempt — the one below and tick()'s
// retries — patches THIS shape; a save landing mid-retry changes the
// next boot, never a standing retry (WifiManager.h, at the members).
_apPatchWpa3 = wantWpa3 && WPA3_SOFTAP_SUPPORTED;
_apPatchWpa3Only = (w.security == ApSecurity::WPA3);
bool inFlight = false;
if (applyApConfigPatch(inFlight)) {
_apPatchRetries = 0;
} else if (inFlight) {
_apPatchRetries = kApPatchAttempts;
_apPatchGate.prime(millis());
log_w("AP config patch deferred: a station connect is in flight — retrying from tick() "
"(beacons at 100 TU%s until it lands)",
wantWpa3 && WPA3_SOFTAP_SUPPORTED ? ", WPA2" : "");
} else {
_apPatchRetries = 0; // a final rejection; the helper logged it
}
}
// Station mode: join the configured LAN too. The AP and the STA share
// one radio, so the AP follows the LAN's channel once connected. Not when
// the station is already associated: this function is also the AP's
// runtime re-up path (tick()'s convergence — one bring-up, so the beacon
// patch, WPA3 and IPv6 reapply by construction), and on an AP+STA node a
// wake would otherwise rebuild the config and re-issue the connect under
// a healthy association, bouncing the LAN link to bring back the AP. At
// boot the station cannot be connected yet, so the boot path is unchanged.
if (wantSta && !stationConnected()) {
WiFi.setAutoReconnect(true);
staConnect(w.staSsid, w.staPassword);
log_i("station: joining \"%s\"", w.staSsid);
_staRetryAt = millis() + 30000;
}
}
void WifiManager::tick() {
// The AP idle policy, ahead of everything: neither block touches the
// driver beyond a read, so they are safe wherever a pass is — including
// the passes below that return early while a join is still trying, which
// would otherwise sit on a wake for the join window's full twenty seconds.
//
// The wake first, so the pass that serves it evaluates nothing stale: the
// policy's clock re-arms, the suppression term drops, and the convergence
// below (raised here, served with nothing in flight) brings the AP back —
// the whole return is one tick pass plus the driver's own start-up.
if (_apWakeReq) {
_apWakeReq = false;
_apIdle.wake(millis());
if (_apSuppressed) {
_apSuppressed = false;
_modeSyncReq = true;
log_i("access point: woken — coming back up");
}
}
// A Wi-Fi save with restart-applied fields landed (cancelApConfigPatch):
// any patch retry still armed was built for the settings before that save,
// and serving it now would read the new ones — patching the next boot's
// security onto the running AP. Served here, ahead of the convergence, so
// a bring-up later in this same pass may re-arm afresh; that retry is
// current by construction.
if (_apPatchCancelReq) {
_apPatchCancelReq = false;
_apPatchRetries = 0;
}
// The verdict, at a 1 s cadence — softAPgetStationNum() is a driver call,
// and the policy needs no finer clock than the minutes it counts in. With
// the feature off the policy answers "never" from its first line and the
// station count is never asked for: a node with the switch off runs the
// ask as two setting reads, the mode-bit read and a comparison. The body
// lives in askApIdlePolicy() because the gate only bounds how often the
// driver is polled, never the final word: syncRadioShape makes one more,
// ungated ask at the instant a staged teardown comes due.
if (_apIdleGate.due(millis())) askApIdlePolicy();
// The glass's asks are served first, on the task that owns the driver. The
// starts live here rather than where they were asked so that no other task
// ever calls into the driver: a display-task scanNetworks()/begin() raced
// the convergence below — tick() could observe nothing in flight, then
// strip with WiFi.mode() the STA bit the display task was raising at that
// very moment, aborting its scan in the driver. With the starts on this
// task there is no second task left to race.
if (_joinReq) {
_joinReq = false;
if (wifiEnabled()) {
_joinDeadline = millis() + 20000; // WPA against a present AP settles well inside this
_joining = true;
WiFi.disconnect(); // whatever the station was doing before
staConnect(_joinSsid, _joinPass);
log_i("station: joining \"%s\" (asked from the glass)", _joinSsid);
} else {
// Wi-Fi went off between the ask and this pass. Starting the join
// would start the very driver the switch keeps down, so the ask fails
// instead; the glass reads the verdict off its usual shelf.
_joinVerdict = 2;
}
}
// The join completes here too, still on the one task — connect, timeout,
// persist and fallback all in one place, so an abandoned screen can no
// longer strand any of them.
if (_joining) {
if (WiFi.status() == WL_CONNECTED) {
WifiSettings w = settings.wifi();
strlcpy(w.staSsid, _joinSsid, sizeof(w.staSsid));
strlcpy(w.staPassword, _joinPass, sizeof(w.staPassword));
char why[96];
// Proven on air, then written down — through the same rules the
// funnel holds, so no side door into the store.
if (SettingsRules::validateWifi(w, why, sizeof(why))) {
settings.saveWifi(w);
// The switch follows the proof. A join asked for at the glass on a
// node whose station switch was off has just run a station anyway,
// so the settings say so too — or the re-assert below would drop
// the network the person just joined, and a reboot would keep the
// credentials but never the link. Straight to saveLinks(), not
// through applyLinks(): that funnel restarts the node for shapes
// the running radio cannot take, and this is the one it already
// holds.
if (!settings.links().wifiStaEnabled) {
LinkSettings l = settings.links();
l.wifiStaEnabled = true;
settings.saveLinks(l);
log_i("station: the station switch was off — the join turned it on");
}
log_i("station: joined \"%s\", IP %s — saved", w.staSsid,
WiFi.localIP().toString().c_str());
} else {
log_w("station: joined \"%s\" but not saved: %s", _joinSsid, why);
}
_staRetryAt = millis() + 30000;
_joining = false;
_joinVerdict = 1;
_modeSyncReq = true; // the verdict ends at the re-assert below
} else if ((int32_t)(millis() - _joinDeadline) >= 0) {
log_w("station: could not join \"%s\"", _joinSsid);
const WifiSettings& w = settings.wifi();
WiFi.disconnect();
bool wantAp, wantSta;
settingsWifiMode(wantAp, wantSta);
if (wantSta) {
// The settings ask for the stored station (switch on, network
// stored) — fall back to it and let the watchdog take over. The
// re-assert below is a no-op on this path: the shape it computes
// keeps the STA bit this begin() needs.
staConnect(w.staSsid, w.staPassword);
_staRetryAt = millis() + 30000;
}
// Nothing to fall back to — none stored, or the switch off — is no
// longer a branch of its own: the join's staConnect() was the only
// thing keeping the station interface up, and the re-assert below
// takes it down with everything else the settings do not ask for.
_joining = false;
_joinVerdict = 2;
_modeSyncReq = true;
} else {
return; // still trying; the watchdog waits its turn
}
}
// The scan the glass asked for starts here, with no join resolving — the
// join block above returns while one is still trying, so a scan request
// raised mid-join is simply parked (staScanCount() answers "still
// scanning" for it) and served on the pass after the verdict.
if (_scanReq) {
if (wifiEnabled()) {
_scanActive = true; // claimed before the start, as before
if (_scanReq) { // re-checked after the claim: a screen
WiFi.scanNetworks(true /* async */); // closed right now cancels an
_scanReq = false; // unserved request (staScanDone())
} else {
_scanActive = false; // cancelled between the check and the claim
}
} else {
_scanReq = false; // racing Wi-Fi-off: see the join above
}
}
// The one place the radio's shape is decided while the node runs. Both
// verdicts above land here, and so do the glass's endings on the display
// task (a scan's results freed, a network forgotten): whatever the STA bit
// was raised for is over, so the radio goes back to the shape the settings
// ask — computed after the saves above, which is what lets a join that has
// just stored its network and its switch keep what it proved. Held while a
// scan is out: the core raised the STA bit for it inside scanNetworks(),
// and stripping the bit aborts the scan in the driver — staScanDone()
// raises the request again, and it is served then. Held just the same
// while an ask is pending but unserved: the requests above are served
// earlier in this very pass, so a pending flag here means it arrived
// moments ago from the display task, and it must reach its serve whole
// rather than have the mode settled across it. The flag drops before the
// settings are read, so a request racing in from the display task is kept
// whole for the next pass rather than half-served by this one. The body
// lives in syncRadioShape(): it is no longer a bare mode assertion, because
// the shape can now change while the node runs — the idle policy and the
// live links.wifi_ap switch take the AP down and up — and a transition
// carries services with it and stages a grace before any teardown.
// A task that stopped itself — rebuildDiscovery lost its socket and could
// not rebind — has no convergence of its own to ride: nothing else raises
// the sync when the radio's shape already matches the settings, so the
// restart the flag exists for waited on some unrelated change (perhaps for
// ever). Raised here instead — one relaxed atomic read on the common path
// — and consumed by the need-based restart at the end of syncRadioShape.
// Gated on a link actually running: with the radio down the restart must
// wait for whatever convergence brings a link back anyway, and a raise
// nothing can consume would re-run the convergence every pass for good.
if (AutoInterface::stoppedUnexpectedly() && AutoInterface::wanted() &&
WiFi.getMode() != WIFI_MODE_NULL)
_modeSyncReq = true;
if (_modeSyncReq && !_scanActive && !_scanReq && !_joinReq) {
_modeSyncReq = false;
syncRadioShape();
}
// The AP config patch a runtime re-up could not land: the driver refused
// esp_wifi_set_config(AP) with ESP_ERR_WIFI_STATE because a station
// connect was in flight (on a LAN-down node the 30 s watchdog keeps one
// going much of the time). Re-run just the RMW — not the whole bring-up —
// until it lands or the attempts run out.
if (_apPatchRetries && _apPatchGate.due(millis())) {
if (!apUp()) {
_apPatchRetries = 0; // the AP left; its next bring-up patches afresh
} else {
bool inFlight = false;
if (applyApConfigPatch(inFlight)) {
_apPatchRetries = 0;
log_i("AP config patch landed on retry: %s, %u TU beacons", _securityName,
(unsigned)WIFI_AP_BEACON_TU);
} else if (!inFlight) {
_apPatchRetries = 0; // a final rejection; the helper logged it
} else if (--_apPatchRetries == 0) {
log_w("AP config patch still refused after %u attempts — a station connect never "
"settled; beacons stay at 100 TU until the next AP cycle",
(unsigned)kApPatchAttempts);
}
}
}
// Station watchdog: log transitions, kick a reconnect if auto-reconnect
// gave up (e.g. the LAN was down at boot).
if (wifiEnabled() && stationConfigured()) {
static bool wasConnected = false;
bool now = stationConnected();
if (now != wasConnected) {
wasConnected = now;
if (now) log_i("station: connected to \"%s\", IP %s, RSSI %d dBm", settings.wifi().staSsid, WiFi.localIP().toString().c_str(), WiFi.RSSI());
else log_w("station: disconnected from \"%s\"", settings.wifi().staSsid);
}
if (!now && (int32_t)(millis() - _staRetryAt) >= 0) {
_staRetryAt = millis() + 30000;
WiFi.reconnect();
}
}
}
// ---------------------------------------------------------------------------
// The convergence body: the one place the running radio is taken toward the
// shape the settings and the idle policy ask for. On the loop task only.
//
// Transition rules, in the order they are applied:
// - A teardown that removes the AP waits out AP_STOP_GRACE_MS first, so
// the reply to whatever asked (an HTTP 200 riding the AP itself, a
// console OK) leaves before its link does — the same reasoning as
// Bootloader::reboot's folded delay. A wake or a switch flip during the
// grace simply changes what the next pass computes, and the stage drops.
// The grace also outlasts the idle gate's cadence (the invariant lives
// with the two constants: Config.h and the gate in WifiManager.h), so a
// station that associates during it is seen by at least one policy ask,
// which lifts the suppression and drops the stage the same way.
// - The AP coming up goes through startAccessPoint(), the one bring-up,
// so the beacon read-modify-write, WPA3, and IPv6 reapply by
// construction; the Wi-Fi sleep and TX-power settings reapply from the
// AP_START/STA_START event hook begin() installs. Captive DNS and (on a
// node that booted with Wi-Fi off) mDNS follow the AP's OBSERVED state,
// reconciled once at the end of every pass (the body's last statement
// says why observed, never intended).
// - Dependent services ride the same pass as the driver change:
// AutoInterface::end() before the netifs go (its sockets hold the