Harden WiFi and LR1110 recovery

This commit is contained in:
mikecarper committed 2026-08-03 16:23:23 -07:00
1 parent 9bec10b41a
commit 44ec918303
20 files changed
+1835 -1240

No files matched your search

+4
View File
@@ -1595,6 +1595,10 @@ bool MyMesh::isWebConfigSetupActive() const {
&& !_webconfig->isStopping();
}
bool MyMesh::isWebConfigWiFiRecoveryActive() const {
return _webconfig && _webconfig->isSavedWiFiRecoveryActive();
}
void MyMesh::rebootNow() {
board.reboot();
}
+1
View File
@@ -119,6 +119,7 @@ public:
void stopWebConfig();
void serviceWebConfig();
bool isWebConfigSetupActive() const;
bool isWebConfigWiFiRecoveryActive() const;
void getNodeSnapshot(WebConfigServer::NodeSnapshot& snapshot) override;
void execCommand(char* cmd, char* reply) override;
+53 -32
View File
@@ -45,6 +45,7 @@ MultiSerialInterface interface_manager;
// include esp32 wifi interface
#include <helpers/esp32/SerialWifiInterface.h>
#include <helpers/WiFiSetupPortal.h>
#include <helpers/WiFiReconnectPolicy.h>
SerialWifiInterface wifi_interface;
#ifndef WIFI_PWD
#define WIFI_PWD ""
@@ -120,9 +121,9 @@ void halt() {
#if defined(ESP32) && defined(WIFI_SSID)
static const unsigned long WIFI_SETUP_FALLBACK_MS = 120000UL;
static const char COMPANION_WIFI_SETUP_AP[] = "MeshCore-Setup";
bool wifi_needs_reconnect = false;
unsigned long last_wifi_reconnect_attempt = 0;
unsigned long wifi_disconnected_since = 0;
WiFiReconnectPolicy::Tracker wifi_reconnect_tracker;
bool wifi_setup_attempted = false;
unsigned long last_wifi_setup_attempt = 0;
bool wifi_setup_recovery_mode = false;
static char configured_wifi_ssid[32];
static char configured_wifi_password[64];
@@ -139,7 +140,7 @@ void halt() {
companion_setup_display->turnOn();
companion_setup_display->startFrame();
companion_setup_display->setTextSize(1);
companion_setup_display->setColor(DisplayDriver::LIGHT);
companion_setup_display->setColor(UIColor::primary_txt);
char setup_ssid[33] = {0};
char setup_ip[16] = {0};
@@ -335,16 +336,10 @@ void setup() {
WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info){
if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) {
WIFI_DEBUG_PRINTLN("WiFi disconnected. Flagging for reconnect...");
wifi_needs_reconnect = true;
if (wifi_disconnected_since == 0) {
wifi_disconnected_since = millis() ? millis() : 1;
}
WIFI_DEBUG_PRINTLN("WiFi disconnected; automatic recovery is active");
} else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) {
WIFI_DEBUG_PRINTLN("connected! IP %s (companion app on :%d)",
WiFi.localIP().toString().c_str(), TCP_PORT);
wifi_needs_reconnect = false;
wifi_disconnected_since = 0;
}
});
@@ -361,7 +356,7 @@ void setup() {
if (have_wifi) {
WiFi.mode(WIFI_STA);
wifi_disconnected_since = millis() ? millis() : 1;
wifi_reconnect_tracker.noteDisconnected(millis());
WiFi.begin(configured_wifi_ssid, configured_wifi_password);
}
#ifndef WITH_WEBCONFIG
@@ -512,10 +507,18 @@ void loop() {
#ifdef ENABLE_OTA
ota_console_loop(); // service the OTA text console (port 5002)
#endif
const unsigned long wifi_now = millis();
if (WiFi.status() == WL_CONNECTED) {
wifi_disconnected_since = 0;
wifi_reconnect_tracker.noteConnected();
wifi_setup_attempted = false;
#ifdef WITH_WEBCONFIG
if (wifi_setup_recovery_mode && the_mesh.isWebConfigSetupActive()) {
// startAutoMode() can raise the setup AP after its own short connection
// timeout, before the main-loop fallback marks recovery mode. A successful
// saved-SSID retry should close either kind of recovery AP.
if (configured_wifi_ssid[0] && the_mesh.isWebConfigSetupActive()
&& (wifi_setup_recovery_mode
|| the_mesh.isWebConfigWiFiRecoveryActive())) {
WiFi.setAutoReconnect(true);
the_mesh.stopWebConfig();
wifi_setup_recovery_mode = false;
}
@@ -530,51 +533,69 @@ void loop() {
wifi_setup_recovery_mode = false;
}
} else if (configured_wifi_ssid[0]) {
unsigned long now = millis();
if (wifi_disconnected_since == 0) wifi_disconnected_since = now ? now : 1;
wifi_reconnect_tracker.noteDisconnected(wifi_now);
if (!wifi_setup_recovery_mode
#ifdef WITH_WEBCONFIG
&& !the_mesh.isWebConfigSetupActive()
#else
&& !wifiSetupPortal().isActive()
#endif
&& now - wifi_disconnected_since >= WIFI_SETUP_FALLBACK_MS) {
&& wifi_reconnect_tracker.disconnectedFor(
wifi_now, WIFI_SETUP_FALLBACK_MS)
&& (!wifi_setup_attempted
|| WiFiReconnectPolicy::elapsedMs(
wifi_now, last_wifi_setup_attempt) >= WIFI_SETUP_FALLBACK_MS)) {
wifi_setup_attempted = true;
last_wifi_setup_attempt = wifi_now;
#ifdef WITH_WEBCONFIG
if (WebConfigServer::loadEnabled(true)) {
char web_reply[160];
if (the_mesh.startWebConfig(true, web_reply)) {
wifi_setup_recovery_mode = true;
WIFI_DEBUG_PRINTLN("WiFi unavailable for two minutes; %s", web_reply);
} else {
wifi_disconnected_since = now;
}
}
#else
if (wifiSetupPortal().begin(COMPANION_WIFI_SETUP_AP, saveCompanionWiFi, nullptr)) {
wifiSetupPortal().configureRecovery(
configured_wifi_ssid, configured_wifi_password, WIFI_SETUP_FALLBACK_MS);
configured_wifi_ssid, configured_wifi_password,
WiFiReconnectPolicy::kRetryIntervalMs,
WiFiReconnectPolicy::kRetryIntervalMs - WIFI_SETUP_FALLBACK_MS);
wifi_setup_recovery_mode = true;
WIFI_DEBUG_PRINTLN("WiFi unavailable for two minutes; setup AP started");
} else {
// Avoid retrying portal creation on every pass through loop().
wifi_disconnected_since = now;
}
#endif
}
}
// Safely attempt to reconnect every 10 seconds if flagged
if (
// The ESP stack normally reconnects by itself. If it gets wedged after an AP
// outage, reassert the saved credentials every five minutes. WebConfig's
// setup AP uses AP+STA mode, so the station retry can run without taking the
// recovery page down. The legacy portal owns its own identical retry timer.
const bool reconnect_owned_here =
#ifdef WITH_WEBCONFIG
!the_mesh.isWebConfigSetupActive()
|| wifi_setup_recovery_mode
|| the_mesh.isWebConfigWiFiRecoveryActive();
#else
!wifiSetupPortal().isActive()
!wifiSetupPortal().isActive();
#endif
&& wifi_needs_reconnect
&& (millis() - last_wifi_reconnect_attempt > 10000)) {
WIFI_DEBUG_PRINTLN("Attempting manual WiFi reconnect...");
WiFi.disconnect();
WiFi.reconnect();
last_wifi_reconnect_attempt = millis();
if (configured_wifi_ssid[0] && reconnect_owned_here
&& WiFi.status() != WL_CONNECTED
&& wifi_reconnect_tracker.retryDue(wifi_now)) {
WIFI_DEBUG_PRINTLN("WiFi still unavailable; retrying saved SSID");
wifi_reconnect_tracker.noteAttempt(wifi_now);
#ifdef WITH_WEBCONFIG
if (!the_mesh.isWebConfigSetupActive()) {
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
}
#else
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
#endif
WiFi.disconnect(false, false);
WiFi.begin(configured_wifi_ssid, configured_wifi_password);
}
#ifdef WITH_MQTT_BRIDGE
the_mesh.serviceMQTT(configured_wifi_ssid, configured_wifi_password);
+7 -7
View File
@@ -100,10 +100,10 @@ void UITask::renderCurrScreen() {
#ifdef WITH_WEBCONFIG
if (WebConfigServer::isRebootPending()) {
_display->setTextSize(1);
_display->setColor(DisplayDriver::GREEN);
_display->setColor(UIColor::corp_blue);
_display->setCursor(0, 14);
_display->print("Config saved!");
_display->setColor(DisplayDriver::LIGHT);
_display->setColor(UIColor::primary_txt);
_display->setCursor(0, 30);
_display->print("Rebooting...");
return;
@@ -111,19 +111,19 @@ void UITask::renderCurrScreen() {
char wc_ssid[33], wc_ip[16];
if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) {
_display->setTextSize(1);
_display->setColor(DisplayDriver::GREEN);
_display->setColor(UIColor::corp_blue);
_display->setCursor(0, 0);
_display->print("WebUI WiFi Setup");
_display->setColor(DisplayDriver::LIGHT);
_display->setColor(UIColor::primary_txt);
_display->setCursor(0, 14);
_display->print("Join WiFi:");
_display->setColor(DisplayDriver::YELLOW);
_display->setColor(UIColor::warning_txt);
_display->setCursor(6, 24);
_display->print(wc_ssid);
_display->setColor(DisplayDriver::LIGHT);
_display->setColor(UIColor::primary_txt);
_display->setCursor(0, 40);
_display->print("Then browse to:");
_display->setColor(DisplayDriver::YELLOW);
_display->setColor(UIColor::warning_txt);
_display->setCursor(6, 50);
_display->print(wc_ip);
return;
+8 -8
View File
@@ -81,10 +81,10 @@ void UITask::renderCurrScreen() {
#ifdef WITH_WEBCONFIG
if (WebConfigServer::isRebootPending()) {
_display->setTextSize(1);
_display->setColor(DisplayDriver::GREEN);
_display->setColor(UIColor::corp_blue);
_display->setCursor(0, 14);
_display->print("Config saved!");
_display->setColor(DisplayDriver::LIGHT);
_display->setColor(UIColor::primary_txt);
_display->setCursor(0, 30);
_display->print("Rebooting...");
return;
@@ -92,19 +92,19 @@ void UITask::renderCurrScreen() {
char wc_ssid[33], wc_ip[16];
if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) {
_display->setTextSize(1);
_display->setColor(DisplayDriver::GREEN);
_display->setColor(UIColor::corp_blue);
_display->setCursor(0, 0);
_display->print("WebUI WiFi Setup");
_display->setColor(DisplayDriver::LIGHT);
_display->setColor(UIColor::primary_txt);
_display->setCursor(0, 14);
_display->print("Join WiFi:");
_display->setColor(DisplayDriver::YELLOW);
_display->setColor(UIColor::warning_txt);
_display->setCursor(6, 24);
_display->print(wc_ssid);
_display->setColor(DisplayDriver::LIGHT);
_display->setColor(UIColor::primary_txt);
_display->setCursor(0, 40);
_display->print("Then browse to:");
_display->setColor(DisplayDriver::YELLOW);
_display->setColor(UIColor::warning_txt);
_display->setCursor(6, 50);
_display->print(wc_ip);
return;
@@ -131,7 +131,7 @@ void UITask::renderCurrScreen() {
if (WiFi.status() == WL_CONNECTED) {
IPAddress ip = WiFi.localIP();
_display->setCursor(0, 40);
_display->setColor(DisplayDriver::LIGHT);
_display->setColor(UIColor::primary_txt);
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
_display->print(tmp);
}
+25
View File
@@ -41,6 +41,8 @@ static const int kMaxBatch = 24; // .h:90 MAX_BATCH
static const uint32_t kDrainPacingMs = 25; // .cpp:296 inter-command gap
static const uint32_t kRebootFallbackMs = 30000; // .cpp:331 drain-finish fallback
static const uint32_t kRebootConfirmMs = 3000; // .cpp:784 first result-read arm
static const uint32_t kSetupWiFiConnectTimeoutMs = 20000;
static const uint32_t kSetupHandoffRebootConfirmMs = 1000;
static const uint32_t kStopWarnMs = 10000; // .h:95 STOP_WARN_MS
// The batch lifecycle. A fresh POST moves Idle->Pending; the drainer moves
@@ -134,6 +136,23 @@ static inline uint32_t finishRebootAt(bool batch_reboot, bool batch_all_ok, uint
return (batch_reboot && batch_all_ok) ? scheduleAt(now, kRebootFallbackMs) : 0u;
}
// A successful setup-portal save that changes station credentials is held in
// Pending while AP+STA joins the selected network. That makes the DHCP address
// available to the captive page before its setup AP disappears. Other saves
// keep the ordinary batch/reboot path.
static inline bool shouldStartSetupWiFiHandoff(bool setup_mode,
bool batch_reboot,
bool batch_all_ok,
bool wifi_credentials_changed,
bool has_wifi_ssid) {
return setup_mode && batch_reboot && batch_all_ok
&& wifi_credentials_changed && has_wifi_ssid;
}
static inline uint32_t setupWiFiConnectDeadline(uint32_t now) {
return scheduleAt(now, kSetupWiFiConnectTimeoutMs);
}
// --------------------------------------------------------------------------
// Result read (.cpp:738-786).
// --------------------------------------------------------------------------
@@ -168,6 +187,12 @@ static inline uint32_t confirmRebootAt(uint32_t now) {
return scheduleAt(now, kRebootConfirmMs);
}
// The browser retains the rendered handoff page after the AP disappears, so
// only a brief response-flush delay is needed before rebooting.
static inline uint32_t confirmSetupHandoffRebootAt(uint32_t now) {
return scheduleAt(now, kSetupHandoffRebootConfirmMs);
}
// --------------------------------------------------------------------------
// Reboot fire (.cpp:262-265) and isRebootPending (.cpp:70-74).
// --------------------------------------------------------------------------
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <stdint.h>
// Pure, platform-independent timing state for the explicit WiFi reconnect
// fallback. The platform's automatic reconnect remains enabled where
// appropriate; this tracker guarantees that a stuck station is kicked with
// the saved credentials every five minutes while the link remains down.
namespace WiFiReconnectPolicy {
static const uint32_t kRetryIntervalMs = 5UL * 60UL * 1000UL;
static inline uint32_t elapsedMs(uint32_t now, uint32_t then) {
return now - then;
}
class Tracker {
public:
Tracker() : _tracking(false), _disconnected_since(0), _last_attempt(0) {}
void noteConnected() {
_tracking = false;
_disconnected_since = 0;
_last_attempt = 0;
}
// The first observation of a disconnected station starts both timers. This
// treats the current/initial association as the first attempt and schedules
// the explicit fallback five minutes later.
void noteDisconnected(uint32_t now) {
if (_tracking) return;
_tracking = true;
_disconnected_since = now;
_last_attempt = now;
}
bool retryDue(uint32_t now) const {
return _tracking
&& elapsedMs(now, _disconnected_since) >= kRetryIntervalMs
&& elapsedMs(now, _last_attempt) >= kRetryIntervalMs;
}
void noteAttempt(uint32_t now) {
if (!_tracking) noteDisconnected(now);
_last_attempt = now;
}
bool disconnectedFor(uint32_t now, uint32_t duration_ms) const {
return _tracking && elapsedMs(now, _disconnected_since) >= duration_ms;
}
bool isTracking() const { return _tracking; }
private:
bool _tracking;
uint32_t _disconnected_since;
uint32_t _last_attempt;
};
} // namespace WiFiReconnectPolicy
+12 -6
View File
@@ -14,7 +14,9 @@ namespace {
static const IPAddress SETUP_IP(192, 168, 4, 1);
static const IPAddress SETUP_MASK(255, 255, 255, 0);
static const uint32_t CONNECT_TIMEOUT_MS = 20000;
static const uint32_t AP_CLOSE_DELAY_MS = 5000;
// The rendered success page remains in the browser after the setup SSID
// disappears; allow one second for the HTTP response to flush first.
static const uint32_t AP_CLOSE_DELAY_MS = 1000;
static const char SETUP_PAGE[] = R"HTML(<!doctype html>
<html><head><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -170,13 +172,15 @@ static void handleSave(PortalImpl* impl, WiFiClient& client, const char* body) {
"<p>MeshCore joined the selected network.</p><p>IP address: <strong>%s</strong></p>"
"<p>Reconnect this phone or computer to that WiFi network, then open "
"<strong>http://%s/</strong> to finish MQTT setup.</p>"
"<p>The temporary setup access point will close shortly.</p>",
"<p>The setup SSID will close now; this page and address will remain visible.</p>",
ip.c_str(), ip.c_str());
#else
snprintf(response, sizeof(response),
"<!doctype html><meta name=viewport content='width=device-width'><h2>Connected</h2>"
"<p>MeshCore joined the WiFi network.</p><p>IP address: <strong>%s</strong></p>"
"<p>The setup access point will close shortly.</p>", ip.c_str());
"<p>MeshCore joined the WiFi network.</p>"
"<p>New network address: <strong>http://%s/</strong></p>"
"<p>The setup SSID will close now; this page and address will remain visible.</p>",
ip.c_str());
#endif
sendResponse(client, 200, "OK", "text/html", response);
Serial.printf("WiFi setup: connected; IP %s\n", ip.c_str());
@@ -318,7 +322,8 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi
}
void WiFiSetupPortal::configureRecovery(const char* ssid, const char* password,
uint32_t interval_ms) {
uint32_t interval_ms,
uint32_t initial_delay_ms) {
PortalImpl* impl = static_cast<PortalImpl*>(_impl);
if (!impl) return;
impl->recovery_ssid[0] = 0;
@@ -326,7 +331,8 @@ void WiFiSetupPortal::configureRecovery(const char* ssid, const char* password,
impl->recovery_password[sizeof(impl->recovery_password) - 1] = 0;
impl->recovery_interval_ms = interval_ms;
impl->recovery_connecting = false;
impl->next_recovery_at = millis() + interval_ms;
impl->next_recovery_at = millis()
+ (initial_delay_ms ? initial_delay_ms : interval_ms);
if (ssid) {
strncpy(impl->recovery_ssid, ssid, sizeof(impl->recovery_ssid) - 1);
impl->recovery_ssid[sizeof(impl->recovery_ssid) - 1] = 0;
+5 -1
View File
@@ -14,7 +14,11 @@ public:
// Starts an open setup AP and captive HTTP form. The AP remains available
// after a failed association and shuts down shortly after a successful one.
bool begin(const char* ap_name, SaveCallback save_callback, void* context);
void configureRecovery(const char* ssid, const char* password, uint32_t interval_ms);
// Retry interval is the steady-state cadence. initial_delay_ms defaults to
// that interval, but callers that opened the portal partway through an
// outage can preserve the original reconnect schedule with a shorter delay.
void configureRecovery(const char* ssid, const char* password,
uint32_t interval_ms, uint32_t initial_delay_ms = 0);
bool isActive() const { return _active; }
IPAddress apIP() const { return IPAddress(192, 168, 4, 1); }
File diff suppressed because it is too large. Load diff
+234 -16
View File
@@ -458,6 +458,9 @@ bool WebConfigServer::startSetupMode(char reply[]) {
strcpy(reply, "Err: webconfig busy");
return false;
}
_retry_saved_wifi_in_setup = false;
_setup_reconnect_in_progress = false;
_setup_reconnect_deadline = 0;
// AP_STA (not pure AP) so the WiFi scan for the SSID picker works while
// the AP is up. STA stays unconnected - the bridge won't touch WiFi
// while wifi_ssid is empty, and `start webconfig ap` requires it stopped.
@@ -506,6 +509,10 @@ bool WebConfigServer::startLanMode(char reply[]) {
return false;
}
_mode = MODE_LAN;
_wifi_reconnect_tracker.noteConnected();
_retry_saved_wifi_in_setup = false;
_setup_reconnect_in_progress = false;
_setup_reconnect_deadline = 0;
createServer();
_last_activity = millis();
@@ -530,6 +537,10 @@ bool WebConfigServer::startAutoMode(char reply[]) {
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
_retry_saved_wifi_in_setup = false;
_setup_reconnect_in_progress = false;
_setup_reconnect_deadline = 0;
_wifi_reconnect_tracker.noteDisconnected(millis());
WiFi.begin(_wifi_ssid, _wifi_password);
wifi_ps_type_t ps_mode = _wifi_power_save == 1 ? WIFI_PS_NONE
: _wifi_power_save == 2 ? WIFI_PS_MAX_MODEM
@@ -602,10 +613,16 @@ void WebConfigServer::finalizeTeardown() {
_stop_warn_at = 0;
_stop_warned = false;
_connect_deadline = 0;
_retry_saved_wifi_in_setup = false;
_setup_reconnect_in_progress = false;
_setup_reconnect_deadline = 0;
_reboot_at = 0;
_batch_state = BATCH_IDLE;
_batch_next = 0;
_batch_reboot_armed = false;
_setup_wifi_handoff_pending = false;
_setup_wifi_handoff_deadline = 0;
_setup_wifi_handoff_ip[0] = 0;
_cli_state = CLI_IDLE;
_cli_reqid[0] = 0;
_cli_command[0] = 0;
@@ -636,6 +653,7 @@ void WebConfigServer::tick(uint32_t now) {
if (_mode == MODE_CONNECTING) {
if (WiFi.status() == WL_CONNECTED) {
_wifi_reconnect_tracker.noteConnected();
_mode = MODE_LAN;
createServer();
_connect_deadline = 0;
@@ -643,20 +661,91 @@ void WebConfigServer::tick(uint32_t now) {
Serial.printf("WebConfig ready: http://%s/\n", WiFi.localIP().toString().c_str());
} else if (_connect_deadline && (int32_t)(now - _connect_deadline) >= 0) {
Serial.printf("WebConfig: WiFi '%s' unavailable; opening setup AP\n", _wifi_ssid);
const bool retry_saved_wifi = _wifi_ssid[0] != 0;
WiFi.disconnect(true);
_mode = MODE_OFF;
_connect_deadline = 0;
char ignored[160];
startSetupMode(ignored);
if (startSetupMode(ignored)) {
_retry_saved_wifi_in_setup = retry_saved_wifi;
}
Serial.println(ignored);
}
return;
}
// MQTT and companion runtimes manage their own station connection. For
// standalone WiFi/WebUI builds, do not rely solely on ESP auto-reconnect:
// after five minutes offline, explicitly reassert the saved credentials and
// repeat at that interval until the router returns.
if (_mode == MODE_LAN && _owns_wifi && _wifi_ssid[0]) {
if (WiFi.status() == WL_CONNECTED) {
_wifi_reconnect_tracker.noteConnected();
} else {
_wifi_reconnect_tracker.noteDisconnected(now);
if (_wifi_reconnect_tracker.retryDue(now)) {
_wifi_reconnect_tracker.noteAttempt(now);
Serial.printf("WebConfig: WiFi still unavailable; retrying '%s'\n",
_wifi_ssid);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.disconnect(false, false);
WiFi.begin(_wifi_ssid, _wifi_password);
}
}
}
// If boot occurred while the configured router was down, startAutoMode()
// leaves a setup AP available instead of going dark. Keep that AP running,
// but retry the saved station credentials on the same five-minute cadence.
// A manually requested setup AP does not set this flag and is never closed
// by a background reconnect.
if (_mode == MODE_SETUP && _retry_saved_wifi_in_setup && _wifi_ssid[0]) {
// An external owner (MQTT or the companion runtime) can restore the link
// too. Promote as soon as any owner succeeds; only initiate retries here
// when WebConfig itself owns WiFi.
if (WiFi.status() == WL_CONNECTED) {
if (_dns) {
_dns->stop();
delete _dns;
_dns = NULL;
}
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
_was_setup_ap = false;
_initial_setup = false;
_retry_saved_wifi_in_setup = false;
_setup_reconnect_in_progress = false;
_setup_reconnect_deadline = 0;
_wifi_reconnect_tracker.noteConnected();
_mode = MODE_LAN;
_last_activity = now;
Serial.printf("WebConfig: saved WiFi recovered; ready at http://%s/\n",
WiFi.localIP().toString().c_str());
} else if (_owns_wifi && _setup_reconnect_in_progress
&& (int32_t)(now - _setup_reconnect_deadline) >= 0) {
WiFi.disconnect(false, false);
_setup_reconnect_in_progress = false;
_setup_reconnect_deadline = 0;
Serial.println("WebConfig: saved WiFi still unavailable; setup AP remains active");
} else if (_owns_wifi && !_setup_reconnect_in_progress
&& _wifi_reconnect_tracker.retryDue(now)) {
_wifi_reconnect_tracker.noteAttempt(now);
_setup_reconnect_in_progress = true;
_setup_reconnect_deadline = now + 20000UL;
Serial.printf("WebConfig: retrying saved WiFi '%s'\n", _wifi_ssid);
WiFi.begin(_wifi_ssid, _wifi_password);
}
}
if (_dns) _dns->processNextRequest();
if (_cli_state == CLI_PENDING) drainCliCommand();
if (_batch_state == BATCH_PENDING) drainBatch(now);
if (_setup_wifi_handoff_pending) serviceSetupWiFiHandoff(now);
if (_batch_state == BATCH_PENDING && !_setup_wifi_handoff_pending) {
drainBatch(now);
}
if (WebConfigBatch::rebootDue(_reboot_at, now)) {
Serial.printf("WC: rebooting now (%s)\n", _batch_reboot_armed ? "confirmed" : "fallback");
@@ -681,7 +770,29 @@ void WebConfigServer::tick(uint32_t now) {
// left broadcasting an open AP). LAN mode runs until `stop webconfig`.
if (_mode == MODE_SETUP && WiFi.softAPgetStationNum() == 0 &&
(now - _last_activity) > WEBCONFIG_AP_IDLE_TIMEOUT_MS) {
requestStop();
if (_retry_saved_wifi_in_setup && _wifi_ssid[0]) {
// Close the open AP, but keep the WebConfig object alive in LAN mode so
// its (or an external owner's) saved-network retries continue forever.
// The listener becomes reachable again as soon as the router returns.
if (_dns) {
_dns->stop();
delete _dns;
_dns = NULL;
}
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
_was_setup_ap = false;
_initial_setup = false;
_retry_saved_wifi_in_setup = false;
_setup_reconnect_in_progress = false;
_setup_reconnect_deadline = 0;
_mode = MODE_LAN;
_last_activity = now;
Serial.println("WebConfig: setup AP idle; saved WiFi recovery continues");
} else {
requestStop();
}
}
}
@@ -705,6 +816,67 @@ void WebConfigServer::drainCliCommand() {
_cli_state = CLI_DONE;
}
void WebConfigServer::finishBatch(uint32_t now) {
WCLock lock(_mux);
_batch_state = BATCH_DONE;
const uint32_t reboot_at =
WebConfigBatch::finishRebootAt(_batch_reboot, _batch_all_ok, now);
if (reboot_at != 0) {
// Fallback only: a successful result read replaces this with the shorter
// confirmation delay. Failed batches remain available for correction.
_reboot_at = reboot_at;
}
}
void WebConfigServer::serviceSetupWiFiHandoff(uint32_t now) {
if (!_setup_wifi_handoff_pending) return;
IPAddress station_ip = WiFi.localIP();
if (WiFi.status() == WL_CONNECTED && static_cast<uint32_t>(station_ip) != 0) {
char ip[16];
snprintf(ip, sizeof(ip), "%s", station_ip.toString().c_str());
{
WCLock lock(_mux);
if (!_setup_wifi_handoff_pending) return;
strncpy(_setup_wifi_handoff_ip, ip,
sizeof(_setup_wifi_handoff_ip) - 1);
_setup_wifi_handoff_ip[sizeof(_setup_wifi_handoff_ip) - 1] = 0;
_setup_wifi_handoff_pending = false;
_setup_wifi_handoff_deadline = 0;
}
Serial.printf("WebConfig: joined '%s' at %s; waiting for browser handoff\n",
_wifi_ssid, ip);
finishBatch(now);
return;
}
if (!WebConfigBatch::deadlineReached(now, _setup_wifi_handoff_deadline)) {
return;
}
WiFi.disconnect(false, false); // stop STA attempt; leave the setup AP up
{
WCLock lock(_mux);
if (!_setup_wifi_handoff_pending) return;
for (int i = _batch_count - 1; i >= 0; i--) {
if (strcmp(_batch[i].key, "wifi.ssid") == 0
|| strcmp(_batch[i].key, "wifi.pwd") == 0) {
snprintf(_batch[i].reply, sizeof(_batch[i].reply),
"Error: could not join WiFi '%s'; setup AP remains active",
_wifi_ssid);
break;
}
}
_batch_all_ok = false;
_setup_wifi_handoff_pending = false;
_setup_wifi_handoff_deadline = 0;
_setup_wifi_handoff_ip[0] = 0;
}
Serial.printf("WebConfig: could not join '%s'; setup AP remains active\n",
_wifi_ssid);
finishBatch(now);
}
void WebConfigServer::drainBatch(uint32_t now) {
// One command per call, spaced out. Each `set` persists prefs with a flash
// write, and flash writes stall the WiFi task (flash cache off); running a
@@ -798,15 +970,35 @@ void WebConfigServer::drainBatch(uint32_t now) {
WCLock prefs_lock(_mux);
_cb->onConfigBatchEnd();
}
WCLock lock(_mux);
_batch_state = BATCH_DONE;
const uint32_t reboot_at =
WebConfigBatch::finishRebootAt(_batch_reboot, _batch_all_ok, now);
if (reboot_at != 0) {
// Fallback only: a successful result read replaces this with the shorter
// confirmation delay. Failed batches remain available for correction.
_reboot_at = reboot_at;
bool wifi_credentials_changed = false;
for (int i = 0; i < _batch_count; i++) {
if (strcmp(_batch[i].key, "wifi.ssid") == 0
|| strcmp(_batch[i].key, "wifi.pwd") == 0) {
wifi_credentials_changed = true;
break;
}
}
if (WebConfigBatch::shouldStartSetupWiFiHandoff(
_mode == MODE_SETUP, _batch_reboot, _batch_all_ok,
wifi_credentials_changed, _wifi_ssid[0] != 0)) {
{
WCLock lock(_mux);
_setup_wifi_handoff_pending = true;
_setup_wifi_handoff_deadline =
WebConfigBatch::setupWiFiConnectDeadline(now);
_setup_wifi_handoff_ip[0] = 0;
}
// AP+STA keeps the captive page reachable while DHCP assigns the address
// that will be shown to the operator before the setup AP is shut down.
WiFi.mode(WIFI_AP_STA);
WiFi.setAutoReconnect(false);
Serial.printf("WebConfig: testing saved WiFi '%s' before reboot\n",
_wifi_ssid);
WiFi.begin(_wifi_ssid, _wifi_password);
return;
}
finishBatch(now);
}
// ---------------------------------------------------------------------------
@@ -932,7 +1124,13 @@ void WebConfigServer::collectBody(AsyncWebServerRequest* req, uint8_t* data, siz
bool WebConfigServer::checkAuth(AsyncWebServerRequest* req) {
_last_activity = millis();
if (_mode == MODE_SETUP) return true; // physical proximity implied, nothing configured
if (_mode == MODE_SETUP) {
// AP+STA is used for scans and saved-network recovery. Keep the unauthenticated
// setup API reachable only through the physical setup AP, never through a
// briefly recovered LAN interface before tick() promotes the mode.
return req && req->client()
&& req->client()->localIP() == WiFi.softAPIP();
}
if (_mode != MODE_LAN) return false;
NodeSnapshot node = {};
{
@@ -1272,6 +1470,9 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1);
_batch_reqid[sizeof(_batch_reqid) - 1] = 0;
_standalone_wifi_dirty = false;
_setup_wifi_handoff_pending = false;
_setup_wifi_handoff_deadline = 0;
_setup_wifi_handoff_ip[0] = 0;
_batch_state = BATCH_PENDING; // tick() picks it up on the loop task
uint32_t du = millis() + 60000;
if (du == 0) du = 1;
@@ -1339,6 +1540,9 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
WebConfigBatch::doneReportsReboot(_batch_reboot, _batch_all_ok);
doc["all_ok"] = _batch_all_ok;
doc["reqid"] = (const char*)_batch_reqid;
if (_setup_wifi_handoff_ip[0]) {
doc["wifi_ip"] = (const char*)_setup_wifi_handoff_ip;
}
JsonArray results = doc.createNestedArray("results");
for (int i = 0; i < _batch_count; i++) {
JsonObject r = results.createNestedObject();
@@ -1346,14 +1550,28 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
r["reply"] = (const char*)_batch[i].reply;
}
// State stays DONE (re-readable) until the next POST claims the slot.
const uint32_t result_now = millis();
if (WebConfigBatch::shouldArmConfirmReboot(
toSpecState(_batch_state), _batch_reboot,
_batch_all_ok, _batch_reboot_armed)) {
// Confirmation delivered - reboot 3 s from now (replaces the 30 s
// drain-time fallback) so the UI can show its countdown first. Armed
// once; re-reads must not keep pushing the deadline out.
// Confirmation delivered. The handoff page remains rendered in the
// browser after the AP disappears, so it only needs a one-second response
// flush before reboot. Armed once; re-reads cannot push the deadline out.
_batch_reboot_armed = true;
_reboot_at = WebConfigBatch::confirmRebootAt(millis());
const bool setup_handoff = _setup_wifi_handoff_ip[0] != 0;
const uint32_t reboot_delay = setup_handoff
? WebConfigBatch::kSetupHandoffRebootConfirmMs
: WebConfigBatch::kRebootConfirmMs;
doc["reboot_in_ms"] = reboot_delay;
_reboot_at = setup_handoff
? WebConfigBatch::confirmSetupHandoffRebootAt(result_now)
: WebConfigBatch::confirmRebootAt(result_now);
} else if (WebConfigBatch::doneReportsReboot(
_batch_reboot, _batch_all_ok) && _reboot_at != 0) {
// A retry after a lost HTTP response gets the same IP plus an accurate
// remaining countdown; reading again never postpones the reboot.
doc["reboot_in_ms"] = WebConfigBatch::deadlineReached(
result_now, _reboot_at) ? 1u : _reboot_at - result_now;
}
AsyncResponseStream* res = req->beginResponseStream("application/json");
+15 -1
View File
@@ -6,7 +6,8 @@
// Two modes:
// - SETUP: open SoftAP + captive portal, raised automatically on first boot
// when no WiFi is configured (wifi_ssid empty), or manually via
// `start webconfig ap`. Save -> reboot; auto-stops after an idle timeout.
// `start webconfig ap`. A credential save joins in AP+STA mode, reports the
// assigned LAN IP, then reboots; the AP auto-stops after an idle timeout.
// - LAN: bound to an existing or portal-owned STA connection. Infrastructure
// roles require the node admin password; companions use their trusted LAN.
//
@@ -27,6 +28,7 @@
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <helpers/WebConfigBatch.h>
#include <helpers/WiFiReconnectPolicy.h>
class AsyncWebServer;
class AsyncWebServerRequest;
@@ -168,6 +170,9 @@ public:
Mode mode() const { return _mode; }
bool isRunning() const { return _mode != MODE_OFF; }
bool isStopping() const { return _stopping; }
bool isSavedWiFiRecoveryActive() const {
return _mode == MODE_SETUP && _retry_saved_wifi_in_setup && !_stopping;
}
private:
static const int MAX_BATCH = WebConfigBatch::kMaxBatch;
@@ -209,6 +214,10 @@ private:
uint8_t _wifi_power_save = 1;
bool _cli_enabled = true;
char _ap_ssid[33] = {0};
WiFiReconnectPolicy::Tracker _wifi_reconnect_tracker;
bool _retry_saved_wifi_in_setup = false;
bool _setup_reconnect_in_progress = false;
uint32_t _setup_reconnect_deadline = 0;
// Currently attached session, also used by the display's setup-info poll.
static WebConfigServer* _active;
@@ -226,6 +235,9 @@ private:
bool _batch_all_ok = true;
char _batch_reqid[24] = {0};
bool _standalone_wifi_dirty = false;
bool _setup_wifi_handoff_pending = false;
uint32_t _setup_wifi_handoff_deadline = 0;
char _setup_wifi_handoff_ip[16] = {0};
BatchEntry _batch[MAX_BATCH];
// Browser terminal command: filled by async_tcp under _mux and executed by
@@ -268,6 +280,8 @@ private:
void detachRoutes();
uint32_t handlerRefCount() const;
void drainBatch(uint32_t now);
void serviceSetupWiFiHandoff(uint32_t now);
void finishBatch(uint32_t now);
void drainCliCommand();
void finalizeTeardown();
bool checkAuth(AsyncWebServerRequest* req);
+44 -13
View File
@@ -2,6 +2,7 @@
#include <RadioLib.h>
#include "MeshCore.h"
#include "LR1110RxRecovery.h"
class CustomLR1110 : public LR1110 {
uint32_t _preambleMillis = 66;
@@ -13,17 +14,45 @@ class CustomLR1110 : public LR1110 {
public:
CustomLR1110(Module *mod) : LR1110(mod) { }
int16_t recoverReceivePath() {
_activityAt = 0;
_headerSeen = false;
// Standby is the operation known to reset the LR1110's unreported
// four-byte RX-buffer displacement. Clear stale bytes and IRQ state too,
// then let RadioLibWrapper re-arm receive mode.
int16_t state = standby();
int16_t next = clearRxBuffer();
if (state == RADIOLIB_ERR_NONE) state = next;
next = clearIrqState(RADIOLIB_LR11X0_IRQ_ALL);
if (state == RADIOLIB_ERR_NONE) state = next;
return state;
}
size_t getPacketLength(bool update) override {
size_t len = LR1110::getPacketLength(update);
if (len == 0 && getIrqStatus() & RADIOLIB_LR11X0_IRQ_HEADER_ERR) {
// we've just received a corrupted packet
// this may have triggered a bug causing subsequent packets to be shifted
// call standby() to return radio to known-good state
// recvRaw will call startReceive() to restart rx
MESH_DEBUG_PRINTLN("LR1110: got header err, calling standby()");
standby();
// GetRxBufferStatus can report either zero or a stale/nonzero length for
// a header-error event. Recover based on the IRQ itself, never its length.
if (getIrqStatus() & RADIOLIB_LR11X0_IRQ_HEADER_ERR) {
MESH_DEBUG_PRINTLN("LR1110: header error, resetting RX path");
recoverReceivePath();
return 0;
}
return len;
return LR1110::getPacketLength(update);
}
int16_t readData(uint8_t* data, size_t len) override {
int16_t state = LR1110::readData(data, len);
if (state != RADIOLIB_ERR_NONE) return state;
// Defense in depth: if the trigger IRQ was missed, never pass the known
// shifted/truncated representation up to Dispatcher where it can look
// like a valid transport flood and be repeated by the mesh.
if (mesh::hasLR1110RxBufferShiftSignature(data, len)) {
MESH_DEBUG_PRINTLN("LR1110: four-byte RX shift detected, dropping packet");
recoverReceivePath();
return RADIOLIB_ERR_CRC_MISMATCH;
}
return RADIOLIB_ERR_NONE;
}
float getFreqMHz() const { return freqMHz; }
@@ -93,12 +122,12 @@ class CustomLR1110 : public LR1110 {
bool getRxBoostedGainMode() const { return _rx_boosted; }
int16_t startReceive() override {
// Make preamble detection visible to CAD while retaining RadioLib's
// normal RX-complete and error events.
// Keep preamble detection visible to CAD and route header errors to DIO1
// so the wrapper can reset the RX path before another packet is read.
return LR1110::startReceive(
RADIOLIB_LR11X0_RX_TIMEOUT_INF,
RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED),
RADIOLIB_IRQ_RX_DEFAULT_MASK, 0);
RADIOLIB_IRQ_RX_DEFAULT_MASK | (1UL << RADIOLIB_IRQ_HEADER_ERR), 0);
}
// BUSY high means the chip is asleep (RX duty-cycle sleep window) or mid
@@ -117,7 +146,9 @@ class CustomLR1110 : public LR1110 {
bool hdrErr = irq & RADIOLIB_LR11X0_IRQ_HEADER_ERR; // bit 6
uint32_t now = millis();
if (hdrErr) {
clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID | RADIOLIB_LR11X0_IRQ_HEADER_ERR);
// Do not clear this here. The receive wrapper owns header-error
// recovery and must see the IRQ in order to enter standby. Clearing it
// from this polling path recreates the four-byte RX-buffer shift bug.
_activityAt = 0;
_headerSeen = false;
return false;
@@ -58,6 +58,24 @@ public:
bool supportsRxPowerSaving() const override { return true; }
protected:
bool isPacketReady() override {
// Header errors are recovery events, not packets. Handle them before the
// generic duty-cycle readiness check can discard the IRQ or a stale buffer
// length can be mistaken for a newly received frame.
if (_radio->checkIrq(RADIOLIB_IRQ_HEADER_ERR) > 0) {
MESH_DEBUG_PRINTLN("CustomLR1110Wrapper: recovering from header error");
int16_t err = ((CustomLR1110 *)_radio)->recoverReceivePath();
if (err != RADIOLIB_ERR_NONE) {
MESH_DEBUG_PRINTLN("CustomLR1110Wrapper: RX recovery error (%d)", err);
}
_rx_ps_armed = false;
_rx_hold_continuous = false;
n_recv_errors++;
return false;
}
return RadioLibWrapper::isPacketReady();
}
int startReceiveMode() override {
if (_rx_ps_armed) {
// stop the previous duty-cycle sequence with an explicit standby before
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
namespace mesh {
// A damaged-header condition can make the LR1110 place every subsequent
// packet four bytes beyond the offset reported by GetRxBufferStatus. Reading
// from the reported offset then returns four zero bytes before the real frame.
//
// This helper is intentionally consumed only by CustomLR1110. On the MeshCore
// wire, these bytes would otherwise decode as a transport-flood request whose
// first transport code is the reserved value 0000.
static constexpr size_t LR1110_RX_BUFFER_SHIFT_BYTES = 4;
inline bool hasLR1110RxBufferShiftSignature(const uint8_t* data, size_t len) {
if (data == nullptr || len <= LR1110_RX_BUFFER_SHIFT_BYTES) return false;
for (size_t i = 0; i < LR1110_RX_BUFFER_SHIFT_BYTES; ++i) {
if (data[i] != 0) return false;
}
return true;
}
} // namespace mesh
+3
View File
@@ -26,9 +26,12 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_mqtt_presets` | `src/helpers/MQTTPresets.h` | preset lookup; table integrity (unique names, non-empty URLs, JWT-audience invariant, names fit the slot buffer); `mqttPresetNeedsSlotCredentials`; slot-count constants |
| `test_observer_validation` | `src/helpers/MQTTObserverValidation.h` | IATA (exactly 3 alphanumerics), owner key (64 hex), NTP hostname, and the buffer-fit check behind the #17 length validation -- including boundaries and nulls |
| `test_webconfig_keys` | `src/helpers/WebConfigKeys.h` | POST-key allowlist, secret detection, admin-password and browser-terminal command validation, slot-index bounds, and short-input guards |
| `test_webconfig_batch` | `src/helpers/WebConfigBatch.h` | config POST/replay/busy decisions; paced command drain; setup WiFi validation/IP handoff; reboot confirmation/fallback; stop/refcount gating; exact timing and rollover boundaries |
| `test_topic_template` | `src/helpers/MQTTTopicTemplate.h` | `{iata}/{device}/{token}/{type}` expansion, overflow/NUL-termination, and a buffer-size fuzz |
| `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank packets-only behavior; required identifiers; invalid inputs/slots; exact buffer boundaries |
| `test_mqtt_connection_policy` | `src/helpers/MQTTConnectionPolicy.h` | reconnect guard/backoff/stagger and breaker transitions; stable reset; JWT lifetime/renewal policy; exact timing boundaries and 32-bit `millis()` rollover |
| `test_wifi_reconnect_policy` | `src/helpers/WiFiReconnectPolicy.h` | five-minute forced reconnect cadence, connection resets, duplicate disconnect observations, and 32-bit `millis()` rollover |
| `test_lr1110_rx_recovery` | `src/helpers/radiolib/LR1110RxRecovery.h` | LR1110 four-byte RX-buffer shift signature; captured and accumulated shifts; ordinary/scoped packet exclusions |
| `test_mqtt_packet_queue_policy` | `src/helpers/MQTTPacketQueuePolicy.h` | queue-full eviction; stale-disconnect flush; adaptive drain limits; bounded QoS0 retries; exact timing boundaries and 32-bit `millis()` rollover |
| `test_mqtt_runtime_buffer_lifecycle` | `src/helpers/MQTTRuntimeBufferLifecycle.h` | idempotent allocation/release; partial-allocation degradation; retry of only missing buffers |
| `test_mqtt_prefs_codec` | `src/helpers/MQTTPrefsStorage.h`, `src/helpers/MQTTPrefsCodec.h` | binary pre-slot/3-slot/6-slot migration fixtures; v1 header integrity; downgrade preservation |
@@ -0,0 +1,48 @@
#include <gtest/gtest.h>
#include "helpers/radiolib/LR1110RxRecovery.h"
TEST(LR1110RxRecovery, RejectsNullAndShortBuffers) {
EXPECT_FALSE(mesh::hasLR1110RxBufferShiftSignature(nullptr, 32));
const uint8_t empty = 0;
EXPECT_FALSE(mesh::hasLR1110RxBufferShiftSignature(&empty, 0));
const uint8_t onlyPrefix[] = {0x00, 0x00, 0x00, 0x00};
EXPECT_FALSE(mesh::hasLR1110RxBufferShiftSignature(
onlyPrefix, sizeof(onlyPrefix)));
}
TEST(LR1110RxRecovery, DetectsCapturedFourByteShift) {
const uint8_t captured[] = {
0x00, 0x00, 0x00, 0x00, 0x15, 0x04, 0x1c, 0x7f, 0x7a, 0x56};
EXPECT_TRUE(mesh::hasLR1110RxBufferShiftSignature(
captured, sizeof(captured)));
const uint8_t accumulatedShift[] = {
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x15, 0x04};
EXPECT_TRUE(mesh::hasLR1110RxBufferShiftSignature(
accumulatedShift, sizeof(accumulatedShift)));
}
TEST(LR1110RxRecovery, AllowsOrdinaryAndValidScopedPrefixes) {
const uint8_t ordinaryFlood[] = {0x15, 0x04, 0x1c, 0x7f, 0x7a, 0x56};
EXPECT_FALSE(mesh::hasLR1110RxBufferShiftSignature(
ordinaryFlood, sizeof(ordinaryFlood)));
// Header 00 is legal for a transport-flood request, but a real scope code
// makes the prefix differ from the LR1110's four injected zero bytes.
const uint8_t scopedRequest[] = {
0x00, 0x34, 0x12, 0x78, 0x56, 0x01, 0xaa};
EXPECT_FALSE(mesh::hasLR1110RxBufferShiftSignature(
scopedRequest, sizeof(scopedRequest)));
const uint8_t threeZeros[] = {0x00, 0x00, 0x00, 0x15, 0x04, 0x1c};
EXPECT_FALSE(mesh::hasLR1110RxBufferShiftSignature(
threeZeros, sizeof(threeZeros)));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -109,6 +109,26 @@ TEST(WebConfigBatch, FinishArmsThirtySecondFallbackOnlyForAFullyOkRebootBatch) {
EXPECT_EQ(0u, Batch::finishRebootAt(false, true, now));
}
TEST(WebConfigBatch, SetupWiFiHandoffRequiresASuccessfulCredentialChangingReboot) {
EXPECT_TRUE(Batch::shouldStartSetupWiFiHandoff(
/*setup=*/true, /*reboot=*/true, /*all_ok=*/true,
/*wifi_changed=*/true, /*has_ssid=*/true));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(false, true, true, true, true));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(true, false, true, true, true));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(true, true, false, true, true));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(true, true, true, false, true));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(true, true, true, true, false));
}
TEST(WebConfigBatch, SetupWiFiHandoffUsesBoundedConnectAndBriefFlushDelays) {
const uint32_t now = 500000;
EXPECT_EQ(now + Batch::kSetupWiFiConnectTimeoutMs,
Batch::setupWiFiConnectDeadline(now));
EXPECT_EQ(now + Batch::kSetupHandoffRebootConfirmMs,
Batch::confirmSetupHandoffRebootAt(now));
EXPECT_EQ(1000U, Batch::kSetupHandoffRebootConfirmMs);
}
// --------------------------------------------------------------------------
// Result read
// --------------------------------------------------------------------------
@@ -0,0 +1,63 @@
#include <gtest/gtest.h>
#include <limits>
#include <stdint.h>
#include "helpers/WiFiReconnectPolicy.h"
namespace Policy = WiFiReconnectPolicy;
TEST(WiFiReconnectPolicy, RetriesAtFiveMinuteBoundaryAndEveryFiveMinutesAfter) {
Policy::Tracker tracker;
tracker.noteDisconnected(1000U);
EXPECT_FALSE(tracker.retryDue(1000U + Policy::kRetryIntervalMs - 1U));
EXPECT_TRUE(tracker.retryDue(1000U + Policy::kRetryIntervalMs));
tracker.noteAttempt(1000U + Policy::kRetryIntervalMs);
EXPECT_FALSE(tracker.retryDue(1000U + 2U * Policy::kRetryIntervalMs - 1U));
EXPECT_TRUE(tracker.retryDue(1000U + 2U * Policy::kRetryIntervalMs));
}
TEST(WiFiReconnectPolicy, ReconnectTimerResetsAfterAConnection) {
Policy::Tracker tracker;
tracker.noteDisconnected(1000U);
tracker.noteConnected();
EXPECT_FALSE(tracker.isTracking());
EXPECT_FALSE(tracker.retryDue(1000U + Policy::kRetryIntervalMs));
tracker.noteDisconnected(500000U);
EXPECT_FALSE(tracker.retryDue(500000U + Policy::kRetryIntervalMs - 1U));
EXPECT_TRUE(tracker.retryDue(500000U + Policy::kRetryIntervalMs));
}
TEST(WiFiReconnectPolicy, DuplicateDisconnectObservationsDoNotPostponeRetry) {
Policy::Tracker tracker;
tracker.noteDisconnected(1000U);
tracker.noteDisconnected(200000U);
EXPECT_TRUE(tracker.retryDue(1000U + Policy::kRetryIntervalMs));
}
TEST(WiFiReconnectPolicy, TimersSurviveMillisRollover) {
Policy::Tracker tracker;
const uint32_t start = std::numeric_limits<uint32_t>::max() - 999U;
tracker.noteDisconnected(start);
EXPECT_FALSE(tracker.retryDue(start + Policy::kRetryIntervalMs - 1U));
EXPECT_TRUE(tracker.retryDue(start + Policy::kRetryIntervalMs));
}
TEST(WiFiReconnectPolicy, DisconnectedDurationUsesExactWrapSafeBoundary) {
Policy::Tracker tracker;
const uint32_t start = std::numeric_limits<uint32_t>::max() - 99U;
tracker.noteDisconnected(start);
EXPECT_FALSE(tracker.disconnectedFor(start + 119999U, 120000U));
EXPECT_TRUE(tracker.disconnectedFor(start + 120000U, 120000U));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+23 -9
View File
@@ -228,7 +228,7 @@ canvas{width:100%;height:56px;display:block}
<h2 id="wz-review-title">Step 4 &middot; Review &amp; save</h2>
<div id="wz-review"></div>
</div>
<div class="note">Saving reboots the node into normal operation. To keep the page available later, run <b>set webui on</b>; use <b>get webui</b> to see its address.</div>
<div class="note">Saving tests the selected WiFi, shows the node's new network address, then reboots into normal operation. To keep the page available later, run <b>set webui on</b>.</div>
<div class="row">
<button class="btn sec" id="wz-review-back" onclick="wzGo(3)">Back</button>
<button class="btn" style="flex:1" id="wz-save" onclick="wizardSave()">Save &amp; reboot</button>
@@ -472,6 +472,7 @@ canvas{width:100%;height:56px;display:block}
<span class="spin" style="width:34px;height:34px"></span>
<h1 style="margin:16px 0 8px" id="reboot-title">Rebooting&hellip;</h1>
<p id="reboot-msg" style="color:var(--mut);max-width:420px"></p>
<p class="hide" id="reboot-ip" style="margin-top:14px">New network address:<br><a id="reboot-ip-link" href="#" style="font-size:20px;font-weight:650"></a></p>
<p style="margin-top:14px;font-size:26px;font-weight:650" id="reboot-count"></p>
</div>
</div>
@@ -816,7 +817,10 @@ function pollResult(cb,errs,idles,acked,reqid,submitted){
if(fails)toast(fails+" setting(s) rejected - see fields");
else if(r.reboot){
st.dirty={};
showReboot("The node is restarting with the saved WiFi settings. Reconnect to that network and open the node at its router-assigned address.",false,"Settings saved [OK] - rebooting");
showReboot(r.wifi_ip
? "The node joined the selected WiFi. This page and address stay visible while it reboots. Reconnect your phone or laptop to that network, then open the address."
: "The node is restarting with the saved WiFi settings. Reconnect to that network and open the node at its router-assigned address.",
false,"Settings saved [OK] - rebooting",r.wifi_ip,rebootSeconds(r));
return;
}else toast("Saved [OK]");
loadConfigSoft();
@@ -1264,9 +1268,9 @@ function wizardSave(){
if(st.hasMqtt&&!/^[A-Za-z]{3}$/.test(st.dirty["mqtt.iata"]||st.orig["mqtt.iata"]||"")){
$("#wz-err").textContent="Please enter a 3-letter IATA region code (MQTT step).";return}
$("#wz-err").textContent="";
var b=$("#wz-save");b.disabled=true;
var b=$("#wz-save"),blabel=b.textContent;b.disabled=true;b.textContent="Testing WiFi...";
saveChanges({reboot:true},function(r,acked){
b.disabled=false;
b.disabled=false;b.textContent=blabel;
if(!r){
if(acked){
// The node accepted the batch (202), but without its result we cannot
@@ -1284,25 +1288,35 @@ function wizardSave(){
$("#wz-err").textContent=fails.map(function(x){return x.key+": "+x.reply}).join(" * ");
return;
}
showReboot("The node is joining '"+eff("wifi.ssid")
+"'. Reconnect your phone or laptop to your normal WiFi. Find the node's IP on your router, or run 'get webui' over its CLI to see the address.",
false,"Settings saved [OK] - rebooting in a moment");
if(r.wifi_ip){
showReboot("The node joined '"+eff("wifi.ssid")
+"'. This page and address stay visible while it reboots. Reconnect your phone or laptop to that WiFi, then open the address.",
false,"WiFi connected [OK] - rebooting soon",r.wifi_ip,rebootSeconds(r));
}else{
showReboot("The settings were saved, but the node did not report a new network address. Reconnect to '"
+eff("wifi.ssid")+"' and use your router or 'get webui' to find it.",
false,"Settings saved [OK] - rebooting",null,rebootSeconds(r));
}
st.dirty={};
});
}
/* ---------- reboot ---------- */
function rebootSeconds(r){return r&&r.reboot_in_ms?Math.max(1,Math.ceil(r.reboot_in_ms/1000)):30}
function doReboot(){
if(!confirm("Reboot this node now?"))return;
post("/api/reboot",{}).then(function(){
showReboot("The node is restarting. This page will try to reconnect automatically.",true);
}).catch(function(e){if(e.message!=="auth")toast("Reboot failed: "+e.message)});
}
function showReboot(msg,reconnect,title){
function showReboot(msg,reconnect,title,ip,seconds){
$("#reboot-title").textContent=title||"Rebooting...";
$("#reboot-msg").textContent=msg;
var iprow=$("#reboot-ip"),iplink=$("#reboot-ip-link");
if(ip){var url="http://"+ip+"/";iplink.textContent=url;iplink.href=url;iprow.classList.remove("hide")}
else{iplink.textContent="";iplink.href="#";iprow.classList.add("hide")}
$("#reboot-ov").classList.add("show");
var n=30;$("#reboot-count").textContent=n;
var n=seconds||30;$("#reboot-count").textContent=n;
var t=setInterval(function(){
n--;$("#reboot-count").textContent=n>0?n:"";
if(n<=5&&reconnect){