Skip to content

Commit fac37f6

Browse files
committed
feat: implement WiFi AP roaming support and add these metrics to device health reporting
1 parent 3f64c2d commit fac37f6

8 files changed

Lines changed: 202 additions & 20 deletions

ESP32-P4-Allsky-Display.ino

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1823,7 +1823,10 @@ void loop() {
18231823
}
18241824

18251825
// Process background retry tasks (handles network, MQTT, and image download failures)
1826-
taskRetryHandler.process();
1826+
// Skip during OTA to free network bandwidth and reduce PSRAM contention
1827+
if (!webConfig.isOTAInProgress()) {
1828+
taskRetryHandler.process();
1829+
}
18271830
systemMonitor.forceResetWatchdog();
18281831

18291832
// Handle web server first for maximum responsiveness
@@ -1889,8 +1892,8 @@ void loop() {
18891892
if (webHandleTime > 5000) {
18901893
Serial.printf("WARNING: Web client handling took %lu ms\n", webHandleTime);
18911894
}
1892-
} else {
1893-
// Try to start web server if not running
1895+
} else if (!webConfig.isOTAInProgress()) {
1896+
// Try to start web server if not running (skip during OTA)
18941897
Serial.println("DEBUG: Web server not running, attempting to restart...");
18951898
systemMonitor.forceResetWatchdog(); // Reset before webConfig.begin
18961899
if (webConfig.begin(8080)) {
@@ -1904,19 +1907,20 @@ void loop() {
19041907
}
19051908

19061909
// Update MQTT manager (connect to MQTT after WiFi is established) with protection
1907-
if (wifiManager.isConnected()) {
1910+
// Skip during OTA to free network bandwidth
1911+
if (wifiManager.isConnected() && !webConfig.isOTAInProgress()) {
19081912
unsigned long mqttStartTime = millis();
19091913
mqttManager.update();
1910-
1914+
19111915
// Check if MQTT update took too long
19121916
if (millis() - mqttStartTime > 2000) {
19131917
Serial.printf("WARNING: MQTT update took %lu ms\n", millis() - mqttStartTime);
19141918
}
19151919
systemMonitor.forceResetWatchdog();
19161920
}
1917-
1918-
// Handle WebSocket events
1919-
if (wifiManager.isConnected() && webConfig.isRunning()) {
1921+
1922+
// Handle WebSocket events — skip during OTA to free network bandwidth
1923+
if (wifiManager.isConnected() && webConfig.isRunning() && !webConfig.isOTAInProgress()) {
19201924
webConfig.loopWebSocket();
19211925
}
19221926

@@ -2016,8 +2020,9 @@ void loop() {
20162020
}
20172021

20182022
// Only auto-cycle if in automatic mode, not paused, and multiple images available
2023+
// Skip during OTA — no point cycling images while firmware is being written
20192024
int imageUpdateMode = cachedImageUpdateMode;
2020-
if (imageUpdateMode == 0 && cyclingEnabled && imageSourceCount > 1 && !imageProcessing && !singleImageRefreshMode && !cyclingPausedForEditing) {
2025+
if (imageUpdateMode == 0 && cyclingEnabled && imageSourceCount > 1 && !imageProcessing && !singleImageRefreshMode && !cyclingPausedForEditing && !webConfig.isOTAInProgress()) {
20212026
// Use per-image duration instead of global cycle interval (from cached NVS value)
20222027
unsigned long currentImageDuration = cachedImageDuration * 1000; // Convert seconds to milliseconds
20232028
if (currentTime - lastCycleTime >= currentImageDuration || lastCycleTime == 0) {

build_info.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
#define BUILD_TIME __TIME__
1010

1111
// Git information (updated by compile script)
12-
#define GIT_COMMIT_HASH "3c63259"
13-
#define GIT_COMMIT_FULL "3c632593726059ee497de5aeb289ceb69c05c406"
12+
#define GIT_COMMIT_HASH "3f64c2d"
13+
#define GIT_COMMIT_FULL "3f64c2d636ef43cfc0102beac1db150c38ce586a"
1414
#define GIT_BRANCH "snd"
1515

1616
#endif // BUILD_INFO_H

config.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ extern const char* WIFI_PASSWORD;
5858
#define WIFI_MAX_WAIT_TIME 12000 // 12 seconds maximum wait time
5959
#define WIFI_RETRY_DELAY 400 // Delay between connection attempts
6060

61+
// WiFi AP roaming (mesh network support)
62+
#define WIFI_ROAM_CHECK_INTERVAL 60000 // ms between roam scans (60 seconds)
63+
#define WIFI_ROAM_RSSI_THRESHOLD 8 // minimum dB improvement to trigger AP switch
64+
6165
// =============================================================================
6266
// MQTT CONFIGURATION
6367
// =============================================================================

device_health.cpp

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ DeviceHealthAnalyzer deviceHealth;
1515
unsigned long DeviceHealthAnalyzer::networkDisconnectCount = 0;
1616
unsigned long DeviceHealthAnalyzer::mqttReconnectCount = 0;
1717
unsigned long DeviceHealthAnalyzer::watchdogResetCount = 0;
18+
unsigned long DeviceHealthAnalyzer::roamCount = 0;
19+
unsigned long DeviceHealthAnalyzer::roamScanCount = 0;
20+
unsigned long DeviceHealthAnalyzer::lastRoamTime = 0;
1821

1922
DeviceHealthAnalyzer::DeviceHealthAnalyzer() {
2023
}
@@ -104,7 +107,11 @@ NetworkHealth DeviceHealthAnalyzer::analyzeNetwork() {
104107
health.rssi = WiFi.RSSI();
105108
health.uptime = millis();
106109
health.disconnectCount = networkDisconnectCount;
107-
110+
health.bssid = WiFi.BSSIDstr();
111+
health.roamCount = roamCount;
112+
health.roamScanCount = roamScanCount;
113+
health.lastRoamTime = lastRoamTime;
114+
108115
if (!health.connected) {
109116
health.status = HEALTH_FAILING;
110117
health.message = "Critical: WiFi disconnected";
@@ -365,7 +372,9 @@ void DeviceHealthAnalyzer::printReport(const DeviceHealthReport& report) {
365372
LOG_INFO_F(" Connected: %s | RSSI: %d dBm | Disconnects: %lu\n",
366373
report.network.connected ? "Yes" : "No",
367374
report.network.rssi, report.network.disconnectCount);
368-
375+
LOG_INFO_F(" BSSID: %s | Roams: %lu | Roam Scans: %lu\n",
376+
report.network.bssid.c_str(), report.network.roamCount, report.network.roamScanCount);
377+
369378
// MQTT
370379
LOG_INFO_F("[MQTT] %s - %s\n", healthStatusToString(report.mqtt.status), report.mqtt.message.c_str());
371380
LOG_INFO_F(" Connected: %s | Reconnects: %lu\n",
@@ -429,9 +438,13 @@ String DeviceHealthAnalyzer::getReportJSON(const DeviceHealthReport& report) {
429438
json += "\"message\":\"" + escapeJsonString(report.network.message) + "\",";
430439
json += "\"connected\":" + String(report.network.connected ? "true" : "false") + ",";
431440
json += "\"rssi\":" + String(report.network.rssi) + ",";
432-
json += "\"disconnect_count\":" + String(report.network.disconnectCount);
441+
json += "\"disconnect_count\":" + String(report.network.disconnectCount) + ",";
442+
json += "\"bssid\":\"" + escapeJsonString(report.network.bssid) + "\",";
443+
json += "\"roam_count\":" + String(report.network.roamCount) + ",";
444+
json += "\"roam_scan_count\":" + String(report.network.roamScanCount) + ",";
445+
json += "\"last_roam_time_ms\":" + String(report.network.lastRoamTime);
433446
json += "},";
434-
447+
435448
// MQTT health
436449
json += "\"mqtt\":{";
437450
json += "\"status\":\"" + String(healthStatusToString(report.mqtt.status)) + "\",";
@@ -488,6 +501,16 @@ void DeviceHealthAnalyzer::recordWatchdogReset() {
488501
LOG_DEBUG_F("[HealthTracker] Watchdog reset count: %lu\n", watchdogResetCount);
489502
}
490503

504+
void DeviceHealthAnalyzer::recordRoam() {
505+
roamCount++;
506+
lastRoamTime = millis();
507+
LOG_DEBUG_F("[HealthTracker] Roam count: %lu\n", roamCount);
508+
}
509+
510+
void DeviceHealthAnalyzer::recordRoamScan() {
511+
roamScanCount++;
512+
}
513+
491514
bool DeviceHealthAnalyzer::isMemoryHealthy() {
492515
MemoryHealth health = analyzeMemory();
493516
return health.status <= HEALTH_GOOD;

device_health.h

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ struct NetworkHealth {
3232
int rssi;
3333
unsigned long uptime;
3434
unsigned long disconnectCount;
35+
String bssid;
36+
unsigned long roamCount;
37+
unsigned long roamScanCount;
38+
unsigned long lastRoamTime;
3539
HealthStatus status;
3640
String message;
3741
};
@@ -88,7 +92,10 @@ class DeviceHealthAnalyzer {
8892
static unsigned long networkDisconnectCount;
8993
static unsigned long mqttReconnectCount;
9094
static unsigned long watchdogResetCount;
91-
95+
static unsigned long roamCount;
96+
static unsigned long roamScanCount;
97+
static unsigned long lastRoamTime;
98+
9299
// Analysis methods
93100
MemoryHealth analyzeMemory();
94101
NetworkHealth analyzeNetwork();
@@ -118,7 +125,9 @@ class DeviceHealthAnalyzer {
118125
static void recordNetworkDisconnect();
119126
static void recordMQTTReconnect();
120127
static void recordWatchdogReset();
121-
128+
static void recordRoam();
129+
static void recordRoamScan();
130+
122131
// Quick health checks
123132
bool isMemoryHealthy();
124133
bool isSystemHealthy();

network_manager.cpp

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,14 @@ WiFiManager::WiFiManager() :
3030
ntpRetries(0),
3131
debugPrintFunc(nullptr),
3232
debugPrintfFunc(nullptr),
33-
firstImageLoaded(false)
33+
firstImageLoaded(false),
34+
roamScanPending(false),
35+
roamInProgress(false),
36+
lastRoamScanTime(0),
37+
roamStartTime(0),
38+
roamTargetChannel(0)
3439
{
40+
memset(roamTargetBSSID, 0, sizeof(roamTargetBSSID));
3541
}
3642

3743
bool WiFiManager::begin() {
@@ -166,11 +172,135 @@ void WiFiManager::setDebugFunctions(void (*debugPrint)(const char*, uint16_t),
166172
}
167173
}
168174

175+
void WiFiManager::checkForBetterAP() {
176+
unsigned long now = millis();
177+
178+
// --- State: Roam in progress (polling reconnection to target AP) ---
179+
if (roamInProgress) {
180+
if (WiFi.status() == WL_CONNECTED) {
181+
// Roam succeeded
182+
roamInProgress = false;
183+
wifiConnected = true;
184+
LOG_INFO_F("[WiFi] Roam successful - connected to %s (RSSI: %d dBm, ch: %d) in %lu ms\n",
185+
WiFi.BSSIDstr().c_str(), WiFi.RSSI(), WiFi.channel(),
186+
now - roamStartTime);
187+
DeviceHealthAnalyzer::recordRoam();
188+
syncNTPTime();
189+
} else if (now - roamStartTime > WIFI_MAX_WAIT_TIME) {
190+
// Roam timed out — fall back to normal reconnection
191+
LOG_WARNING_F("[WiFi] Roam failed (timeout after %lu ms) - falling back to auto-connect\n",
192+
now - roamStartTime);
193+
roamInProgress = false;
194+
wifiConnected = false;
195+
WiFi.disconnect();
196+
// Next update() cycle will call connectToWiFi() without BSSID lock
197+
} else if (WiFi.status() == WL_CONNECT_FAILED || WiFi.status() == WL_NO_SSID_AVAIL) {
198+
LOG_WARNING_F("[WiFi] Roam failed (status: %d) - falling back to auto-connect\n",
199+
WiFi.status());
200+
roamInProgress = false;
201+
wifiConnected = false;
202+
WiFi.disconnect();
203+
}
204+
return;
205+
}
206+
207+
// Don't start roam logic if not connected or if NTP sync is in progress
208+
if (!isConnected() || ntpSyncInProgress) {
209+
return;
210+
}
211+
212+
// --- State: Scan pending (poll for async scan completion) ---
213+
if (roamScanPending) {
214+
int scanResult = WiFi.scanComplete();
215+
216+
if (scanResult == WIFI_SCAN_RUNNING) {
217+
return; // Still scanning
218+
}
219+
220+
roamScanPending = false;
221+
222+
if (scanResult == WIFI_SCAN_FAILED || scanResult < 0) {
223+
LOG_WARNING("[WiFi] Roam scan failed");
224+
WiFi.scanDelete();
225+
return;
226+
}
227+
228+
// Get current connection info for comparison
229+
int currentRSSI = WiFi.RSSI();
230+
String currentBSSID = WiFi.BSSIDstr();
231+
String currentSSID = String(WIFI_SSID);
232+
233+
// Find the strongest AP with the same SSID but different BSSID
234+
int bestIndex = -1;
235+
int bestRSSI = currentRSSI;
236+
237+
for (int i = 0; i < scanResult; i++) {
238+
String scanSSID = WiFi.SSID(i);
239+
String scanBSSID = WiFi.BSSIDstr(i);
240+
int scanRSSI = WiFi.RSSI(i);
241+
242+
if (scanSSID == currentSSID && scanBSSID != currentBSSID) {
243+
if (scanRSSI > bestRSSI + WIFI_ROAM_RSSI_THRESHOLD) {
244+
bestRSSI = scanRSSI;
245+
bestIndex = i;
246+
}
247+
}
248+
}
249+
250+
if (bestIndex >= 0) {
251+
// Found a significantly stronger AP — initiate roam
252+
LOG_INFO_F("[WiFi] Roaming from %s (%d dBm) to %s (%d dBm, ch: %d)\n",
253+
currentBSSID.c_str(), currentRSSI,
254+
WiFi.BSSIDstr(bestIndex).c_str(), bestRSSI, WiFi.channel(bestIndex));
255+
256+
// Save target BSSID and channel before scanDelete clears them
257+
memcpy(roamTargetBSSID, WiFi.BSSID(bestIndex), 6);
258+
roamTargetChannel = WiFi.channel(bestIndex);
259+
260+
WiFi.scanDelete();
261+
262+
// Disconnect and reconnect to the stronger AP
263+
WiFi.disconnect();
264+
roamInProgress = true;
265+
roamStartTime = now;
266+
WiFi.begin(WIFI_SSID, WIFI_PASSWORD, roamTargetChannel, roamTargetBSSID);
267+
} else {
268+
LOG_DEBUG_F("[WiFi] Roam scan: no better AP found (current: %s, %d dBm, %d candidates)\n",
269+
currentBSSID.c_str(), currentRSSI, scanResult);
270+
WiFi.scanDelete();
271+
}
272+
273+
return;
274+
}
275+
276+
// --- State: Idle (check if it's time to start a new roam scan) ---
277+
if (now - lastRoamScanTime >= WIFI_ROAM_CHECK_INTERVAL) {
278+
// Don't start a scan if one is already running (e.g., web API scan)
279+
if (WiFi.scanComplete() == WIFI_SCAN_RUNNING) {
280+
return;
281+
}
282+
283+
lastRoamScanTime = now;
284+
roamScanPending = true;
285+
DeviceHealthAnalyzer::recordRoamScan();
286+
WiFi.scanNetworks(true, false); // async=true, show_hidden=false
287+
LOG_DEBUG("[WiFi] Roam scan started (async)");
288+
}
289+
}
290+
169291
void WiFiManager::update() {
292+
// During a roam, skip checkConnection/connectToWiFi to prevent interference
293+
if (roamInProgress) {
294+
checkForBetterAP(); // Poll roam connection status
295+
return;
296+
}
297+
170298
checkConnection();
171299

172-
// Attempt reconnection if disconnected (non-blocking)
173-
if (!isConnected()) {
300+
if (isConnected()) {
301+
checkForBetterAP(); // Scan for better APs / evaluate results
302+
} else {
303+
// Attempt reconnection if disconnected (non-blocking)
174304
connectToWiFi();
175305
}
176306

network_manager.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ class WiFiManager {
1919
static constexpr unsigned long RECONNECT_BACKOFF_MIN = 2000;
2020
static constexpr unsigned long RECONNECT_BACKOFF_MAX = 60000;
2121

22+
// AP roaming state (mesh WiFi support)
23+
bool roamScanPending;
24+
bool roamInProgress;
25+
unsigned long lastRoamScanTime;
26+
unsigned long roamStartTime;
27+
uint8_t roamTargetBSSID[6];
28+
int roamTargetChannel;
29+
2230
// Non-blocking NTP state
2331
bool ntpSyncInProgress;
2432
unsigned long ntpSyncStartTime;
@@ -29,6 +37,7 @@ class WiFiManager {
2937
// Debug display function pointer
3038
void (*debugPrintFunc)(const char* message, uint16_t color);
3139
void (*debugPrintfFunc)(uint16_t color, const char* format, ...);
40+
void checkForBetterAP();
3241
bool firstImageLoaded;
3342

3443
public:

web_config_api.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,7 @@ void WebConfig::handleGetAllInfo() {
949949
json += "\"dns\":\"" + WiFi.dnsIP().toString() + "\",";
950950
json += "\"mac\":\"" + WiFi.macAddress() + "\",";
951951
json += "\"rssi\":" + String(WiFi.RSSI()) + ",";
952+
json += "\"bssid\":\"" + WiFi.BSSIDstr() + "\",";
952953
json += "\"hostname\":\"" + String(WiFi.getHostname()) + "\"";
953954
} else {
954955
json += "\"ssid\":null,";
@@ -957,6 +958,7 @@ void WebConfig::handleGetAllInfo() {
957958
json += "\"dns\":null,";
958959
json += "\"mac\":\"" + WiFi.macAddress() + "\",";
959960
json += "\"rssi\":0,";
961+
json += "\"bssid\":null,";
960962
json += "\"hostname\":null";
961963
}
962964
json += "},";

0 commit comments

Comments
 (0)