From 03c0ad81feaebdff3217a2216f83d46401bf3c4e Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 1 Sep 2026 16:31:28 -0700 Subject: [PATCH 01/16] feat: abstract MQTT network transport --- examples/simple_repeater/MyMesh.cpp | 26 +- examples/simple_room_server/MyMesh.cpp | 26 +- src/helpers/AlertFaultPolicy.h | 53 +++- src/helpers/AlertReporter.cpp | 13 +- src/helpers/CommonCLI_Observer.cpp | 85 +++-- src/helpers/ESP32Board.cpp | 17 +- src/helpers/NetworkInterface.cpp | 296 ++++++++++++++++++ src/helpers/NetworkInterface.h | 44 +++ src/helpers/NetworkPolicy.h | 32 ++ src/helpers/SNMPAgent.cpp | 9 +- src/helpers/SNMPAgent.h | 3 +- src/helpers/bridges/MQTTBridge.cpp | 250 ++++----------- src/helpers/bridges/MQTTBridge.h | 39 +-- src/helpers/ethernet/ch390/CH390Config.h | 31 ++ .../ethernet/ch390/CH390EthernetInterface.cpp | 19 +- test/README.md | 1 + .../test_alert_fault_policy.cpp | 11 + .../test_network_policy.cpp | 36 +++ variants/thinknode_m7/platformio.ini | 46 ++- 19 files changed, 731 insertions(+), 306 deletions(-) create mode 100644 src/helpers/NetworkInterface.cpp create mode 100644 src/helpers/NetworkInterface.h create mode 100644 src/helpers/NetworkPolicy.h create mode 100644 src/helpers/ethernet/ch390/CH390Config.h create mode 100644 test/test_network_policy/test_network_policy.cpp diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 75d9fb4ee..48322c6ad 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,7 +1,12 @@ #include "MyMesh.h" #include +#include #include // for qsort() #include +#include +#if defined(ESP_PLATFORM) && !defined(NETWORK_USE_ETHERNET) +#include +#endif #if defined(WITH_MQTT_NEIGHBORS) #include // kSyncedClockEpoch #endif @@ -1218,11 +1223,13 @@ void MyMesh::begin(FILESYSTEM *fs) { #if defined(WITH_WEBCONFIG) && !defined(WEBCONFIG_NO_AUTO_AP) // First-boot setup portal: raised only when no WiFi has ever been configured, // so an OTA onto a deployed (configured) node can never open an AP. + #if !defined(NETWORK_USE_ETHERNET) if (_cli.getObserverPrefs()->wifi_ssid[0] == 0) { char wc_reply[160]; startWebConfig(false, wc_reply); Serial.println(wc_reply); } + #endif #endif radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); @@ -1467,6 +1474,11 @@ void MyMesh::clearStats() { #ifdef WITH_WEBCONFIG bool MyMesh::startWebConfig(bool force_ap, char* reply) { +#if defined(NETWORK_USE_ETHERNET) + (void)force_ap; + strcpy(reply, "Err: webconfig unavailable on Ethernet observer; use serial CLI"); + return true; +#else if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) { strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly" : "Err: webconfig already running"); @@ -1490,6 +1502,7 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { _webconfig->startLanMode(reply); // reports "WiFi not connected" if down } return true; +#endif } bool MyMesh::stopWebConfig(char* reply) { @@ -1523,12 +1536,17 @@ void MyMesh::onConfigBatchEnd() { void MyMesh::buildStatsJson(char* buf, size_t buf_size) { char ip[20] = ""; int wifi_rssi = 0; - if (WiFi.status() == WL_CONNECTED) { - strncpy(ip, WiFi.localIP().toString().c_str(), sizeof(ip) - 1); - wifi_rssi = WiFi.RSSI(); - } else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) { + NetworkInterface& network = activeNetworkInterface(); + if (network.isConnected()) { + strncpy(ip, network.localIP().toString().c_str(), sizeof(ip) - 1); + const int signal = network.rssi(); + wifi_rssi = signal == INT_MIN ? 0 : signal; + } +#if !defined(NETWORK_USE_ETHERNET) + else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) { strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1); } +#endif int pos = snprintf(buf, buf_size, "{\"uptime_s\":%lu,\"batt_mv\":%u," "\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu," diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index e6bed7d60..90d5ea4bc 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,6 +1,11 @@ #include "MyMesh.h" #include +#include #include +#include +#if defined(ESP_PLATFORM) && !defined(NETWORK_USE_ETHERNET) +#include +#endif #if defined(WITH_MQTT_NEIGHBORS) #include // kSyncedClockEpoch #endif @@ -1020,11 +1025,13 @@ void MyMesh::begin(FILESYSTEM *fs) { #if defined(WITH_WEBCONFIG) && !defined(WEBCONFIG_NO_AUTO_AP) // First-boot setup portal: raised only when no WiFi has ever been configured, // so an OTA onto a deployed (configured) node can never open an AP. + #if !defined(NETWORK_USE_ETHERNET) if (_cli.getObserverPrefs()->wifi_ssid[0] == 0) { char wc_reply[160]; startWebConfig(false, wc_reply); Serial.println(wc_reply); } + #endif #endif } @@ -1269,6 +1276,11 @@ void MyMesh::formatPacketStatsReply(char *reply) { #ifdef WITH_WEBCONFIG bool MyMesh::startWebConfig(bool force_ap, char* reply) { +#if defined(NETWORK_USE_ETHERNET) + (void)force_ap; + strcpy(reply, "Err: webconfig unavailable on Ethernet observer; use serial CLI"); + return true; +#else if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) { strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly" : "Err: webconfig already running"); @@ -1292,6 +1304,7 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { _webconfig->startLanMode(reply); // reports "WiFi not connected" if down } return true; +#endif } bool MyMesh::stopWebConfig(char* reply) { @@ -1325,12 +1338,17 @@ void MyMesh::onConfigBatchEnd() { void MyMesh::buildStatsJson(char* buf, size_t buf_size) { char ip[20] = ""; int wifi_rssi = 0; - if (WiFi.status() == WL_CONNECTED) { - strncpy(ip, WiFi.localIP().toString().c_str(), sizeof(ip) - 1); - wifi_rssi = WiFi.RSSI(); - } else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) { + NetworkInterface& network = activeNetworkInterface(); + if (network.isConnected()) { + strncpy(ip, network.localIP().toString().c_str(), sizeof(ip) - 1); + const int signal = network.rssi(); + wifi_rssi = signal == INT_MIN ? 0 : signal; + } +#if !defined(NETWORK_USE_ETHERNET) + else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) { strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1); } +#endif int pos = snprintf(buf, buf_size, "{\"uptime_s\":%lu,\"batt_mv\":%u," "\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu," diff --git a/src/helpers/AlertFaultPolicy.h b/src/helpers/AlertFaultPolicy.h index d15aaf1f6..1dbbac492 100644 --- a/src/helpers/AlertFaultPolicy.h +++ b/src/helpers/AlertFaultPolicy.h @@ -220,39 +220,58 @@ static inline void formatAge(uint32_t age_ms, char* out, size_t out_size) { } } -static inline void formatWifiDown(char* out, size_t out_size, uint32_t duration_ms, - uint8_t reason) { +static inline void formatNetworkDown(char* out, size_t out_size, const char* medium, + uint32_t duration_ms, uint8_t reason) { if (!out || out_size == 0) return; char age[16]; formatAge(duration_ms, age, sizeof(age)); if (reason != 0) { - snprintf(out, out_size, "WiFi down %s (reason %u)", age, (unsigned)reason); + snprintf(out, out_size, "%s down %s (reason %u)", medium, age, (unsigned)reason); } else { - snprintf(out, out_size, "WiFi down %s", age); + snprintf(out, out_size, "%s down %s", medium, age); } } +static inline void formatNetworkRecovered(char* out, size_t out_size, + const char* medium, + uint32_t duration_ms) { + if (!out || out_size == 0) return; + char age[16]; + formatAge(duration_ms, age, sizeof(age)); + snprintf(out, out_size, "%s recovered after %s", medium, age); +} + +// Compatibility helpers retained for existing callers and native tests. +static inline void formatWifiDown(char* out, size_t out_size, + uint32_t duration_ms, uint8_t reason) { + formatNetworkDown(out, out_size, "WiFi", duration_ms, reason); +} + static inline void formatWifiRecovered(char* out, size_t out_size, uint32_t duration_ms) { - if (!out || out_size == 0) return; - char age[16]; - formatAge(duration_ms, age, sizeof(age)); - snprintf(out, out_size, "WiFi recovered after %s", age); + formatNetworkRecovered(out, out_size, "WiFi", duration_ms); +} + +static inline bool formatNetworkAlert(char* out, size_t out_size, + const char* medium, const TickResult& r, + const OutageSnapshot& snap) { + const char* label = (medium && *medium) ? medium : "Network"; + if (r.action == Action::FireDown) { + formatNetworkDown(out, out_size, label, r.duration_ms, snap.reason); + return true; + } + if (r.action == Action::FireRecovered) { + formatNetworkRecovered(out, out_size, label, r.duration_ms); + return true; + } + return false; } // Production formatting entry: the same (TickResult, OutageSnapshot) pair // AlertReporter feeds after tick(). Returns false when there is no message. static inline bool formatWifiAlert(char* out, size_t out_size, const TickResult& r, const OutageSnapshot& snap) { - if (r.action == Action::FireDown) { - formatWifiDown(out, out_size, r.duration_ms, snap.reason); - return true; - } - if (r.action == Action::FireRecovered) { - formatWifiRecovered(out, out_size, r.duration_ms); - return true; - } - return false; + return formatNetworkAlert(out, out_size, "WiFi", r, snap); } static inline void formatMqttDown(char* out, size_t out_size, int slot_1based, diff --git a/src/helpers/AlertReporter.cpp b/src/helpers/AlertReporter.cpp index b55ac90c8..171d81c88 100644 --- a/src/helpers/AlertReporter.cpp +++ b/src/helpers/AlertReporter.cpp @@ -6,6 +6,7 @@ #include #ifdef WITH_MQTT_BRIDGE #include "AlertFaultPolicy.h" +#include "NetworkInterface.h" #endif // Header layout for PAYLOAD_TYPE_GRP_TXT before encryption: @@ -217,13 +218,21 @@ void AlertReporter::onLoop(unsigned long now_ms) { min_interval_ms); if (r.action == AlertFaultPolicy::Action::FireDown) { char text[80]; - AlertFaultPolicy::formatWifiAlert(text, sizeof(text), r, snap); + AlertFaultPolicy::formatNetworkAlert( + text, sizeof(text), + strcmp(activeNetworkInterface().mediumName(), "ethernet") == 0 + ? "Ethernet" : "WiFi", + r, snap); if (sendChannel(text)) { AlertFaultPolicy::commitDown(_wifi, now, snap.started_ms); } } else if (r.action == AlertFaultPolicy::Action::FireRecovered) { char text[80]; - AlertFaultPolicy::formatWifiAlert(text, sizeof(text), r, snap); + AlertFaultPolicy::formatNetworkAlert( + text, sizeof(text), + strcmp(activeNetworkInterface().mediumName(), "ethernet") == 0 + ? "Ethernet" : "WiFi", + r, snap); sendChannel(text); AlertFaultPolicy::commitRecovered(_wifi); } diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 9b2b2d302..28ac768bf 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -15,7 +15,9 @@ #include "TxtDataHelpers.h" #include "AlertReporter.h" // for alertReporterBannedChannelMatch[Hex]() #include "MQTTObserverValidation.h" // pure input validators (host-testable) +#include "NetworkInterface.h" #include +#include #include #ifdef ESP_PLATFORM #include @@ -414,8 +416,9 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf // runs on the Arduino loop task, shared with mesh/radio processing and the // web config batch, so a synchronous wait of up to 30 s would stall the // node. The sync runs in the background; verify with `get mqtt.ntp.diag`. - if (WiFi.status() != WL_CONNECTED) { - strcpy(reply, "OK - saved (WiFi not connected; NTP sync pending)"); + if (!activeNetworkInterface().isConnected()) { + snprintf(reply, 160, "OK - saved (%s not connected; NTP sync pending)", + activeNetworkInterface().mediumName()); } else if (!_callbacks->isMqttBridgeRunning()) { strcpy(reply, "OK - saved (MQTT bridge not running)"); } else if (_callbacks->syncMqttNtp()) { @@ -457,7 +460,8 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf _mqtt_prefs.wifi_power_save = ps_value; if (!persistObserverPrefs(reply)) return true; #ifdef ESP_PLATFORM - if (WiFi.status() == WL_CONNECTED) { + if (strcmp(activeNetworkInterface().mediumName(), "wifi") == 0 && + activeNetworkInterface().isConnected()) { wifi_ps_type_t ps_mode = (ps_value == 1) ? WIFI_PS_NONE : (ps_value == 2) ? WIFI_PS_MAX_MODEM : WIFI_PS_MIN_MODEM; esp_err_t ps_result = esp_wifi_set_ps(ps_mode); @@ -958,8 +962,9 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf #ifdef ESP_PLATFORM // Connectivity probe across all configured NTP servers; never updates the clock. // Serial console (sender_timestamp == 0) gets a detailed table; LoRa gets a compact list. - if (WiFi.status() != WL_CONNECTED) { - strcpy(reply, "Error: WiFi not connected"); + if (!activeNetworkInterface().isConnected()) { + snprintf(reply, 160, "Error: %s not connected", + activeNetworkInterface().mediumName()); } else if (!_callbacks->isMqttBridgeRunning()) { strcpy(reply, "Error: MQTT bridge not running"); } else if (!_callbacks->runMqttNtpDiag(reply, 160, sender_timestamp == 0)) { @@ -1033,20 +1038,28 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf } else { strcpy(reply, _mqtt_prefs.wifi_password[0] ? "> ******** (serial only)" : "> (not set)"); } - } else if (memcmp(config, "wifi.status", 11) == 0) { - wl_status_t status = WiFi.status(); - const char* status_str; - switch (status) { - case WL_CONNECTED: status_str = "connected"; break; - case WL_NO_SSID_AVAIL: status_str = "no_ssid"; break; - case WL_CONNECT_FAILED: status_str = "connect_failed"; break; - case WL_CONNECTION_LOST: status_str = "connection_lost"; break; - case WL_DISCONNECTED: status_str = "disconnected"; break; - case 255: status_str = "not_started"; break; - default: status_str = "unknown"; break; + } else if (memcmp(config, "link.status", 11) == 0 || + memcmp(config, "wifi.status", 11) == 0) { + NetworkInterface& network = activeNetworkInterface(); + const bool wifi_alias = config[0] == 'w'; + if (wifi_alias && strcmp(network.mediumName(), "wifi") != 0) { + snprintf(reply, 160, "> n/a (%s selected; use get link.status)", + network.mediumName()); + return true; } - if (status == WL_CONNECTED) { - sprintf(reply, "> %s, IP: %s, RSSI: %d dBm", status_str, WiFi.localIP().toString().c_str(), WiFi.RSSI()); + const bool connected = network.isConnected(); + if (connected) { + const int signal = network.rssi(); + if (wifi_alias) { + snprintf(reply, 160, "> %s, IP: %s, RSSI: %d dBm", + network.statusName(), network.localIP().toString().c_str(), signal); + } else if (signal == INT_MIN) { + snprintf(reply, 160, "> %s: connected, IP: %s", network.mediumName(), + network.localIP().toString().c_str()); + } else { + snprintf(reply, 160, "> %s: connected, IP: %s, RSSI: %d dBm", + network.mediumName(), network.localIP().toString().c_str(), signal); + } #ifdef WITH_MQTT_BRIDGE unsigned long connect_at = MQTTBridge::getWifiConnectedAtMillis(); if (connect_at != 0) { @@ -1074,19 +1087,35 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf #endif } else { #ifdef WITH_MQTT_BRIDGE - uint8_t reason = MQTTBridge::getLastWifiDisconnectReason(); + uint8_t reason = network.lastDisconnectReason(); if (reason != 0) { const char* desc = MQTTBridge::wifiReasonStr(reason); if (desc) { - sprintf(reply, "> %s: %s (reason: %d)", status_str, desc, reason); + if (wifi_alias) { + sprintf(reply, "> %s: %s (reason: %d)", network.statusName(), desc, reason); + } else { + sprintf(reply, "> %s: %s (reason: %d)", network.mediumName(), desc, reason); + } } else { - sprintf(reply, "> %s: reason %d", status_str, reason); + if (wifi_alias) { + sprintf(reply, "> %s: reason %d", network.statusName(), reason); + } else { + sprintf(reply, "> %s: reason %d", network.mediumName(), reason); + } } } else { - sprintf(reply, "> %s (code: %d)", status_str, status); + if (wifi_alias) { + sprintf(reply, "> %s (code: %d)", network.statusName(), network.statusCode()); + } else { + sprintf(reply, "> %s: %s", network.mediumName(), network.statusName()); + } } #else - sprintf(reply, "> %s (code: %d)", status_str, status); + if (wifi_alias) { + sprintf(reply, "> %s (code: %d)", network.statusName(), network.statusCode()); + } else { + sprintf(reply, "> %s: %s", network.mediumName(), network.statusName()); + } #endif } } else if (memcmp(config, "wifi.powersave", 14) == 0) { @@ -1150,8 +1179,9 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, #ifdef WITH_MQTT_BRIDGE if (memcmp(command, "tls.bundletest ", 15) == 0) { #ifdef ESP_PLATFORM - if (WiFi.status() != WL_CONNECTED) { - strcpy(reply, "ERR: WiFi not connected"); + if (!activeNetworkInterface().isConnected()) { + snprintf(reply, 160, "ERR: %s not connected", + activeNetworkInterface().mediumName()); } else { size_t bundle_len = 0; if (rootca_crt_bundle_start != nullptr && @@ -1196,8 +1226,9 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, // ota check -> report available build, do not flash // ota update -> download and flash, then reboot #if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_BASE) - if (WiFi.status() != WL_CONNECTED) { - strcpy(reply, "ERR: WiFi not connected"); + if (!activeNetworkInterface().isConnected()) { + snprintf(reply, 160, "ERR: %s not connected", + activeNetworkInterface().mediumName()); } else if (memcmp(command, "ota check", 9) == 0) { // Check is synchronous so its result lands in this reply, and runs with the // MQTT bridge UP: the slim per-variant manifest is tiny, so the fetch only diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 120203ab0..5452c50cf 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -1,6 +1,7 @@ #ifdef ESP_PLATFORM #include "ESP32Board.h" +#include "NetworkInterface.h" #include #if defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA) // Repeater or Room Server only @@ -14,15 +15,16 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { inhibit_sleep = true; // prevent sleep during OTA - // If the device is already on a WiFi network (e.g. an observer joined in STA - // mode), serve ElegantOTA on the station IP so it's reachable from the LAN - // without joining a separate AP. Otherwise raise the MeshCore-OTA SoftAP. + // If the device is already on its selected network, serve ElegantOTA on that + // address so it is reachable without joining a separate AP. Otherwise raise + // the MeshCore-OTA SoftAP. // force_ap ("start ota ap") always raises the SoftAP, so the OTA UI stays // reachable even when the joined network applies client isolation and the // station IP can't be reached. IPAddress ip; - if (!force_ap && WiFi.status() == WL_CONNECTED) { - ip = WiFi.localIP(); + if (NetworkPolicy::startOtaUsesSelectedNetwork( + force_ap, activeNetworkInterface().isConnected())) { + ip = activeNetworkInterface().localIP(); } else { WiFi.softAP("MeshCore-OTA", NULL); ip = WiFi.softAPIP(); @@ -200,8 +202,9 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char strcpy(reply, "ERR: OTA not configured (build via build.sh)"); return false; #else - if (WiFi.status() != WL_CONNECTED) { - strcpy(reply, "ERR: WiFi not connected"); + if (!activeNetworkInterface().isConnected()) { + snprintf(reply, 160, "ERR: %s not connected", + activeNetworkInterface().mediumName()); return false; } diff --git a/src/helpers/NetworkInterface.cpp b/src/helpers/NetworkInterface.cpp new file mode 100644 index 000000000..1d4d2d39f --- /dev/null +++ b/src/helpers/NetworkInterface.cpp @@ -0,0 +1,296 @@ +#include "NetworkInterface.h" + +#if defined(ESP_PLATFORM) + +#include "MQTTConnectionPolicy.h" + +#include +#include +#include + +#include +#include + +#if defined(NETWORK_USE_ETHERNET) +#include "ethernet/ch390/CH390Config.h" +#endif + +namespace { + +class NetworkInterfaceBase : public NetworkInterface { + protected: + std::atomic _outage_bits{AlertFaultPolicy::packOutageSnapshot({false, 0, 0})}; + std::atomic _connected_at{0}; + std::atomic _last_disconnect_time{0}; + std::atomic _last_disconnect_reason{0}; + bool _status_initialized = false; + bool _last_connected = false; + unsigned long _last_status_check = 0; + + AlertFaultPolicy::OutageSnapshot outage() const { + return AlertFaultPolicy::unpackOutageSnapshot( + _outage_bits.load(std::memory_order_acquire)); + } + + void setOutage(AlertFaultPolicy::OutageSnapshot snapshot) { + _outage_bits.store(AlertFaultPolicy::packOutageSnapshot(snapshot), + std::memory_order_release); + } + + void noteConnected(unsigned long now_ms) { + if (_connected_at.load(std::memory_order_relaxed) == 0) { + _connected_at.store(now_ms, std::memory_order_relaxed); + } + setOutage(AlertFaultPolicy::applyWifiGotIp(outage())); + } + + void noteDisconnected(unsigned long now_ms, uint8_t reason) { + _last_disconnect_reason.store(reason, std::memory_order_relaxed); + _last_disconnect_time.store(now_ms, std::memory_order_relaxed); + setOutage(AlertFaultPolicy::applyWifiDisconnectEvent( + (uint32_t)now_ms, reason, outage())); + } + + public: + unsigned long connectedAtMillis() const override { + return _connected_at.load(std::memory_order_relaxed); + } + + uint8_t lastDisconnectReason() const override { + return _last_disconnect_reason.load(std::memory_order_relaxed); + } + + unsigned long lastDisconnectTime() const override { + return _last_disconnect_time.load(std::memory_order_relaxed); + } + + AlertFaultPolicy::OutageSnapshot outageSnapshot() const override { + return outage(); + } +}; + +class WiFiNetworkInterface final : public NetworkInterfaceBase { + bool _event_registered = false; + char _ssid[33] = {}; + char _password[65] = {}; + unsigned long _last_reconnect_attempt = 0; + uint8_t _reconnect_backoff_attempt = 0; + + void applyPowerPrefs(uint8_t wifi_power_save) { + wifi_ps_type_t ps_mode = wifi_power_save == 2 ? WIFI_PS_MAX_MODEM : WIFI_PS_NONE; + esp_wifi_set_ps(ps_mode); +#ifdef MQTT_WIFI_TX_POWER + WiFi.setTxPower(MQTT_WIFI_TX_POWER); +#else + WiFi.setTxPower(WIFI_POWER_11dBm); +#endif + } + + public: + const char* mediumName() const override { return "wifi"; } + const char* statusName() const override { + switch (WiFi.status()) { + case WL_CONNECTED: return "connected"; + case WL_NO_SSID_AVAIL: return "no_ssid"; + case WL_CONNECT_FAILED: return "connect_failed"; + case WL_CONNECTION_LOST: return "connection_lost"; + case WL_DISCONNECTED: return "disconnected"; + case 255: return "not_started"; + default: return "unknown"; + } + } + int statusCode() const override { return (int)WiFi.status(); } + + bool configValid(const char* wifi_ssid) const override { + return wifi_ssid && wifi_ssid[0] != '\0'; + } + + bool begin(const char* wifi_ssid, const char* wifi_password) override { + if (!configValid(wifi_ssid)) return false; + strncpy(_ssid, wifi_ssid, sizeof(_ssid) - 1); + _ssid[sizeof(_ssid) - 1] = '\0'; + strncpy(_password, wifi_password ? wifi_password : "", sizeof(_password) - 1); + _password[sizeof(_password) - 1] = '\0'; + + WiFi.mode(WIFI_STA); + WiFi.setAutoReconnect(true); + WiFi.setAutoConnect(true); + + if (!_event_registered) { + WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) { + switch (event) { + case ARDUINO_EVENT_WIFI_STA_GOT_IP: + noteConnected(millis()); + _reconnect_backoff_attempt = 0; + break; + case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: + noteDisconnected(millis(), info.wifi_sta_disconnected.reason); + break; + default: + break; + } + }); + _event_registered = true; + } + + // Preserve the existing restart behavior: MQTT stop leaves the station up, + // and begin() must not force a disconnect that races the first DNS lookup. + if (!isConnected()) { + WiFi.begin(_ssid, _password); + } else { + noteConnected(millis()); + } + return true; + } + + NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) override { + const bool connected = isConnected(); + if (connected && connectedAtMillis() == 0) noteConnected(now_ms); + + if (!_status_initialized) { + _last_connected = connected; + _status_initialized = true; + setOutage(AlertFaultPolicy::applyWifiStatus( + now_ms, connected, outage(), false)); + } + + if ((uint32_t)(now_ms - _last_status_check) <= 10000) { + if (connected && outage().down) noteConnected(now_ms); + return NetworkTransition::None; + } + _last_status_check = now_ms; + + if (connected) { + const bool transitioned = !_last_connected; + if (transitioned) { + setOutage(AlertFaultPolicy::applyWifiStatus( + now_ms, true, outage(), true)); + _connected_at.store(now_ms, std::memory_order_relaxed); + _reconnect_backoff_attempt = 0; + applyPowerPrefs(wifi_power_save); + } + _last_connected = true; + return transitioned ? NetworkTransition::Up : NetworkTransition::None; + } + + AlertFaultPolicy::OutageSnapshot snapshot = AlertFaultPolicy::applyWifiStatus( + now_ms, false, outage(), true); + setOutage(snapshot); + const bool transitioned = _last_connected; + if (transitioned) { + _connected_at.store(0, std::memory_order_relaxed); + } else if (snapshot.down && MQTTConnectionPolicy::wifiReconnectDue( + now_ms, snapshot.started_ms, (uint32_t)_last_reconnect_attempt, + _reconnect_backoff_attempt)) { + _last_reconnect_attempt = now_ms; + _reconnect_backoff_attempt = + MQTTConnectionPolicy::nextWifiBackoffAttempt(_reconnect_backoff_attempt); + WiFi.disconnect(); + WiFi.begin(_ssid, _password); + } + _last_connected = false; + return transitioned ? NetworkTransition::Down : NetworkTransition::None; + } + + bool isConnected() const override { return WiFi.status() == WL_CONNECTED; } + IPAddress localIP() const override { return WiFi.localIP(); } + int rssi() const override { return isConnected() ? WiFi.RSSI() : INT_MIN; } + bool resolveHost(const char* hostname, IPAddress& address) const override { + return WiFi.hostByName(hostname, address); + } +}; + +#if defined(NETWORK_USE_ETHERNET) +class EthernetNetworkInterface final : public NetworkInterfaceBase { + bool _started = false; + bool _event_registered = false; + + public: + const char* mediumName() const override { return "ethernet"; } + const char* statusName() const override { + return isConnected() ? "connected" : "disconnected"; + } + int statusCode() const override { return isConnected() ? 1 : 0; } + bool configValid(const char*) const override { return true; } + + bool begin(const char*, const char*) override { + if (_started) return true; + if (!_event_registered) { + WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t) { + switch (event) { + case ARDUINO_EVENT_ETH_GOT_IP: + noteConnected(millis()); + break; + case ARDUINO_EVENT_ETH_DISCONNECTED: + // Ethernet has no 802.11 reason code; zero means unavailable. + noteDisconnected(millis(), 0); + _connected_at.store(0, std::memory_order_relaxed); + break; + default: + break; + } + }); + _event_registered = true; + } + _started = beginConfiguredCH390(); + if (_started && isConnected()) noteConnected(millis()); + return _started; + } + + NetworkTransition maintain(uint32_t now_ms, uint8_t) override { + const bool connected = isConnected(); + if (!_status_initialized) { + _last_connected = connected; + _status_initialized = true; + setOutage(AlertFaultPolicy::applyWifiStatus( + now_ms, connected, outage(), false)); + if (connected) noteConnected(now_ms); + return NetworkTransition::None; + } + + if (connected == _last_connected) { + if (connected && outage().down) noteConnected(now_ms); + return NetworkTransition::None; + } + + _last_connected = connected; + if (connected) { + _connected_at.store(now_ms, std::memory_order_relaxed); + setOutage(AlertFaultPolicy::applyWifiStatus( + now_ms, true, outage(), true)); + return NetworkTransition::Up; + } + + const bool outage_was_down = outage().down; + _connected_at.store(0, std::memory_order_relaxed); + AlertFaultPolicy::OutageSnapshot snapshot = AlertFaultPolicy::applyWifiStatus( + now_ms, false, outage(), true); + setOutage(snapshot); + if (!outage_was_down) { + _last_disconnect_time.store(now_ms, std::memory_order_relaxed); + } + return NetworkTransition::Down; + } + + bool isConnected() const override { return _started && CH390.isConnected(); } + IPAddress localIP() const override { return CH390.localIP(); } + int rssi() const override { return INT_MIN; } + bool resolveHost(const char* hostname, IPAddress& address) const override { + // Arduino's hostByName is a thin wrapper over the process-wide lwIP resolver; + // DNS follows the selected esp_netif even though this entry point is named WiFi. + return WiFi.hostByName(hostname, address); + } +}; +#endif + +} // namespace + +NetworkInterface& activeNetworkInterface() { +#if defined(NETWORK_USE_ETHERNET) + static EthernetNetworkInterface network; +#else + static WiFiNetworkInterface network; +#endif + return network; +} +#endif diff --git a/src/helpers/NetworkInterface.h b/src/helpers/NetworkInterface.h new file mode 100644 index 000000000..a4e631a1c --- /dev/null +++ b/src/helpers/NetworkInterface.h @@ -0,0 +1,44 @@ +#pragma once + +#include "NetworkPolicy.h" + +#if defined(ESP_PLATFORM) + +#include +#include +#include "AlertFaultPolicy.h" + +/** + * Physical network selected for IP-based services. + * + * The interface owns link bring-up and medium-specific maintenance. MQTT, NTP, + * OTA, and other socket users only consume connectivity and addressing. Normal + * MQTT shutdown deliberately does not stop this interface because an OTA + * download runs after the broker clients have been released. + */ +class NetworkInterface { + public: + virtual ~NetworkInterface() = default; + + virtual const char* mediumName() const = 0; + virtual const char* statusName() const = 0; + virtual int statusCode() const = 0; + virtual bool configValid(const char* wifi_ssid) const = 0; + virtual bool begin(const char* wifi_ssid, const char* wifi_password) = 0; + virtual NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) = 0; + + virtual bool isConnected() const = 0; + virtual IPAddress localIP() const = 0; + virtual int rssi() const = 0; // INT_MIN when the selected medium has no RSSI. + virtual bool resolveHost(const char* hostname, IPAddress& address) const = 0; + + virtual unsigned long connectedAtMillis() const = 0; + virtual uint8_t lastDisconnectReason() const = 0; + virtual unsigned long lastDisconnectTime() const = 0; + virtual AlertFaultPolicy::OutageSnapshot outageSnapshot() const = 0; +}; + +/** Build-selected singleton. Wi-Fi is the compatibility default. */ +NetworkInterface& activeNetworkInterface(); + +#endif diff --git a/src/helpers/NetworkPolicy.h b/src/helpers/NetworkPolicy.h new file mode 100644 index 000000000..731ed349c --- /dev/null +++ b/src/helpers/NetworkPolicy.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +enum class NetworkTransition : uint8_t { + None, + Up, + Down, +}; + +namespace NetworkPolicy { + +struct MQTTTransitionActions { + bool disconnect_slots; + bool retry_disconnected_slots_now; +}; + +static constexpr MQTTTransitionActions mqttActions(NetworkTransition transition) { + return { + transition == NetworkTransition::Down, + transition == NetworkTransition::Up, + }; +} + +// `start ota` uses the selected LAN only when reachable and not explicitly +// forced to SoftAP. Manifest OTA has no fallback and checks connectivity itself. +static constexpr bool startOtaUsesSelectedNetwork(bool force_ap, + bool network_connected) { + return !force_ap && network_connected; +} + +} // namespace NetworkPolicy diff --git a/src/helpers/SNMPAgent.cpp b/src/helpers/SNMPAgent.cpp index a70716f5b..ddfb2fe76 100644 --- a/src/helpers/SNMPAgent.cpp +++ b/src/helpers/SNMPAgent.cpp @@ -1,7 +1,9 @@ #ifdef WITH_SNMP #include "SNMPAgent.h" +#include "NetworkInterface.h" #include +#include #define SNMP_PORT 161 @@ -67,7 +69,7 @@ void MeshSNMPAgent::begin(const char* community) { void MeshSNMPAgent::loop() { if (!_running) return; - // Update memory and network stats locally (we're on Core 0 with WiFi) + // Update memory and selected-network stats locally on Core 0. _free_heap = (int)ESP.getFreeHeap(); _max_alloc = (int)ESP.getMaxAllocHeap(); _internal_free = (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL); @@ -77,9 +79,8 @@ void MeshSNMPAgent::loop() { _psram_free = 0; #endif - if (WiFi.isConnected()) { - _wifi_rssi = (int)WiFi.RSSI(); - } + const int signal = activeNetworkInterface().rssi(); + _wifi_rssi = signal == INT_MIN ? 0 : signal; _snmp.loop(); } diff --git a/src/helpers/SNMPAgent.h b/src/helpers/SNMPAgent.h index c1bfe0f5b..2f792634d 100644 --- a/src/helpers/SNMPAgent.h +++ b/src/helpers/SNMPAgent.h @@ -2,7 +2,6 @@ #ifdef WITH_SNMP -#include #include #include @@ -15,7 +14,7 @@ // .2.x.0 = radio (packets, RSSI, SNR, noise floor, air time) // .3.x.0 = mqtt (connected slots, queue depth, skipped publishes) // .4.x.0 = memory (free heap, max alloc, internal free, PSRAM free) -// .5.x.0 = network (WiFi RSSI) +// .5.x.0 = network (RSSI, or 0 when the selected medium has no RSSI) class MeshSNMPAgent { public: diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 39c8f05b7..8b48401f9 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -100,16 +100,8 @@ void MQTTBridge::getEffectiveMqttOrigin(const NodePrefs* np, const MQTTPrefs* ob applyEffectiveOrigin(np, obs, buf, buf_size); } -// Helper function to check if WiFi credentials are valid -static bool isWiFiConfigValid(const MQTTPrefs* obs) { - // Check if WiFi SSID is configured (not empty) - if (!obs || strlen(obs->wifi_ssid) == 0) { - return false; - } - - // WiFi password can be empty for open networks, so we don't check it - - return true; +static bool isNetworkConfigValid(const MQTTPrefs* obs) { + return obs && activeNetworkInterface().configValid(obs->wifi_ssid); } #ifdef WITH_MQTT_BRIDGE @@ -122,7 +114,7 @@ static bool customEndpointComplete(const char* host, uint16_t port) { } bool MQTTBridge::isConfigValid(const MQTTPrefs* obs) { - if (!obs || !isWiFiConfigValid(obs)) return false; + if (!obs || !isNetworkConfigValid(obs)) return false; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { const char* preset_name = obs->mqtt_slot_preset[i]; if (preset_name[0] == '\0' || strcmp(preset_name, MQTT_PRESET_NONE) == 0) continue; @@ -210,13 +202,6 @@ void* MQTTBridge::JsonScratchAllocator::reallocate(void* ptr, size_t new_size) { return psram_realloc(ptr, new_size); } -// Time (millis()) when WiFi was last seen connected; 0 when disconnected. Used for get wifi.status uptime. -static unsigned long s_wifi_connected_at = 0; - -// Last WiFi disconnect reason (from ESP-IDF event). Used for get wifi.status diagnostics. -static uint8_t s_wifi_disconnect_reason = 0; -static unsigned long s_wifi_disconnect_time = 0; - #ifdef MQTT_MEMORY_DEBUG // #region agent log static void agentLogHeap(const char* location, const char* message, const char* hypothesisId, @@ -236,7 +221,7 @@ static void agentLogHeap(const char* location, const char* message, const char* static MQTTBridge* s_mqtt_bridge_instance = nullptr; unsigned long MQTTBridge::getWifiConnectedAtMillis() { - return s_wifi_connected_at; + return activeNetworkInterface().connectedAtMillis(); } #if defined(WITH_MQTT_NEIGHBORS) @@ -427,8 +412,12 @@ int MQTTBridge::getMaxActiveSlots() { #endif } -uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; } -unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; } +uint8_t MQTTBridge::getLastWifiDisconnectReason() { + return activeNetworkInterface().lastDisconnectReason(); +} +unsigned long MQTTBridge::getLastWifiDisconnectTime() { + return activeNetworkInterface().lastDisconnectTime(); +} unsigned long MQTTBridge::getSlotCurrentOutageStartMs(int slot_index) const { if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return 0; @@ -635,6 +624,7 @@ static inline uint32_t mqttStopTimeoutForSlots(int slots) { MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity) : BridgeBase(prefs, mgr, rtc), _obs(obs), + _network(&activeNetworkInterface()), _queue_count(0), _last_status_publish(0), _last_status_retry(0), _status_interval(300000), _ntp_client(_ntp_udp, effectiveNtpPrimary(obs), 0, 60000), _last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS), @@ -661,8 +651,6 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mg #ifdef WITH_SNMP _snmp_agent(nullptr), #endif - _last_wifi_check(0), _last_wifi_status(WL_DISCONNECTED), _wifi_status_initialized(false), - _wifi_outage_bits{0}, _last_wifi_reconnect_attempt(0), _wifi_reconnect_backoff_attempt(0), _last_slot_reconnect_ms(0) #ifdef ESP_PLATFORM , _packet_queue_handle(nullptr), _mqtt_task_handle(nullptr), @@ -857,9 +845,10 @@ void MQTTBridge::begin() { _max_active_slots = getMaxActiveSlots(); MQTT_DEBUG_PRINTLN("Max active slots: %d", _max_active_slots); - // Check if WiFi credentials are configured first - if (!isWiFiConfigValid(_obs)) { - MQTT_DEBUG_PRINTLN("MQTT Bridge initialization skipped - WiFi credentials not configured"); + // Ethernet needs no credentials; Wi-Fi preserves the existing SSID gate. + if (!isNetworkConfigValid(_obs)) { + MQTT_DEBUG_PRINTLN("MQTT Bridge initialization skipped - %s is not configured", + _network->mediumName()); return; } @@ -1043,11 +1032,8 @@ void MQTTBridge::begin() { MQTT_DEBUG_PRINTLN("MQTT task created on Core %d", MQTT_TASK_CORE); #else - // Non-ESP32: Initialize WiFi directly (no task) - WiFi.mode(WIFI_STA); - WiFi.setAutoReconnect(true); - WiFi.setAutoConnect(true); - WiFi.begin(_obs->wifi_ssid, _obs->wifi_password); + // Non-ESP32: initialize the selected network directly (no task). + _network->begin(_obs->wifi_ssid, _obs->wifi_password); // NOTE: Slot setup deferred until after NTP sync in loop() #endif @@ -1237,75 +1223,31 @@ void MQTTBridge::mqttTask(void* parameter) { vTaskDelete(nullptr); } -void MQTTBridge::initializeWiFiInTask() { - MQTT_DEBUG_PRINTLN("Initializing WiFi in MQTT task..."); +void MQTTBridge::initializeNetworkInTask() { + MQTT_DEBUG_PRINTLN("Initializing %s network in MQTT task...", _network->mediumName()); - // Initialize WiFi - WiFi.mode(WIFI_STA); - - // Enable automatic reconnection - ESP32 will handle reconnection automatically - WiFi.setAutoReconnect(true); - WiFi.setAutoConnect(true); - - // Set up WiFi event handlers for better diagnostics and immediate disconnection - // detection. Register ONCE — the bridge is reused across restarts (e.g. stopped - // for `ota check`/`ota update`, or `set mqtt…` reconfigure) and WiFi.onEvent() - // never removes prior callbacks, so re-registering leaks handlers and duplicates - // every log line. - if (!_wifi_event_registered) { - WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) { - switch(event) { - case ARDUINO_EVENT_WIFI_STA_GOT_IP: - MQTT_DEBUG_PRINTLN("WiFi connected: %s", IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str()); - setWifiOutage(AlertFaultPolicy::applyWifiGotIp(wifiOutage())); - _wifi_reconnect_backoff_attempt = 0; - // Set flag to trigger NTP sync from loop() instead of doing it here - if (!_ntp_synced && !_ntp_sync_pending) { - _ntp_sync_pending = true; - } - break; - case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: { - const uint8_t reason = info.wifi_sta_disconnected.reason; - const unsigned long t = millis(); - s_wifi_disconnect_reason = reason; - s_wifi_disconnect_time = t; - setWifiOutage(AlertFaultPolicy::applyWifiDisconnectEvent( - (uint32_t)t, reason, wifiOutage())); - MQTT_DEBUG_PRINTLN("WiFi disconnected: reason %d", s_wifi_disconnect_reason); - break; - } - default: - break; - } - }); - _wifi_event_registered = true; - } - - // Only (re)start the WiFi association if it isn't already up. end() leaves the - // STA link connected, so on a restart (e.g. after `ota check`) calling - // WiFi.begin() again forces a needless disconnect/reconnect — which also races - // the MQTT task's first DNS lookup (getaddrinfo fails until WiFi/DNS recovers). - // When already connected, the deferred slot setup still fires in mqttTaskLoop() - // because _ntp_synced persists across end() (only _slots_setup_done is reset). - if (WiFi.status() != WL_CONNECTED) { - WiFi.begin(_obs->wifi_ssid, _obs->wifi_password); - } else if (!_ntp_synced && !_ntp_sync_pending) { - _ntp_sync_pending = true; // already connected but never synced — kick NTP now + // begin() is idempotent and deliberately leaves an already-up link alone. + // MQTT end()/begin() cycles therefore keep the transport alive for OTA and do + // not race the first DNS lookup after a bridge restart. + if (!_network->begin(_obs->wifi_ssid, _obs->wifi_password)) { + MQTT_DEBUG_PRINTLN("%s network initialization failed", _network->mediumName()); + } else if (_network->isConnected() && !_ntp_synced && !_ntp_sync_pending) { + _ntp_sync_pending = true; } // NOTE: Slot setup is deferred until after NTP sync in mqttTaskLoop(). // JWT-auth slots need valid timestamps for token creation, and connecting // before NTP sync just wastes heap on TLS handshakes that will be rejected. - MQTT_DEBUG_PRINTLN("WiFi initialization started in task"); + MQTT_DEBUG_PRINTLN("%s network initialization started in task", _network->mediumName()); } // --------------------------------------------------------------------------- // mqttTaskLoop() - main loop running on Core 0 // --------------------------------------------------------------------------- void MQTTBridge::mqttTaskLoop() { - // Initialize WiFi first - initializeWiFiInTask(); + // Initialize the selected physical network first. + initializeNetworkInTask(); // Wait a bit for WiFi to start connecting vTaskDelay(pdMS_TO_TICKS(1000)); @@ -1362,9 +1304,9 @@ void MQTTBridge::mqttTaskLoop() { } #endif - bool wifi_just_connected = handleWiFiConnection(now); - if (wifi_just_connected) { - // WiFi recovered — reset last_reconnect_attempt for disconnected slots so they + bool network_just_connected = handleNetworkConnection(now); + if (network_just_connected) { + // The uplink recovered — reset last_reconnect_attempt for disconnected slots so they // retry immediately rather than waiting up to 5 min for backoff timers to expire. for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].enabled && _slots[i].initial_connect_done && !_slots[i].connected) { @@ -1373,14 +1315,18 @@ void MQTTBridge::mqttTaskLoop() { } } - // Check for pending NTP sync (triggered from WiFi event handler) - if (_ntp_sync_pending && WiFi.status() == WL_CONNECTED) { + // A connected observation is enough to schedule NTP; physical event callbacks + // stay encapsulated in the selected network adapter. + if (!_ntp_synced && _network->isConnected() && !_ntp_sync_pending) { + _ntp_sync_pending = true; + } + if (_ntp_sync_pending && _network->isConnected()) { _ntp_sync_pending = false; syncTimeWithNTP(); } // Retry NTP every 30s if initial sync failed (slots can't start without valid time) - if (!_ntp_synced && WiFi.status() == WL_CONNECTED) { + if (!_ntp_synced && _network->isConnected()) { static unsigned long last_ntp_retry = 0; if (now - last_ntp_retry >= 30000) { last_ntp_retry = now; @@ -1495,7 +1441,7 @@ void MQTTBridge::mqttTaskLoop() { #ifdef WITH_SNMP // SNMP agent loop — process incoming UDP requests if (_snmp_agent) { - if (!_snmp_agent->isRunning() && WiFi.isConnected() && _obs->snmp_enabled) { + if (!_snmp_agent->isRunning() && _network->isConnected() && _obs->snmp_enabled) { _snmp_agent->begin(_obs->snmp_community); MQTT_DEBUG_PRINTLN("SNMP agent started on port 161 (community: %s)", _obs->snmp_community); } @@ -1517,7 +1463,7 @@ void MQTTBridge::mqttTaskLoop() { // Periodic NTP refresh (every hour) — lightweight, non-blocking. // Uses async SNTP instead of the heavy syncTimeWithNTP() which blocks Core 0 // for up to 20+ seconds with DNS lookups, UDP sockets, and retry loops. - if (WiFi.status() == WL_CONNECTED && now - _last_ntp_sync > 3600000) { + if (_network->isConnected() && now - _last_ntp_sync > 3600000) { refreshNTP(); } @@ -2062,7 +2008,7 @@ void MQTTBridge::maintainSlotConnections() { if (!_identity) return; // Check WiFi status first - if (WiFi.status() != WL_CONNECTED) return; + if (!_network->isConnected()) return; unsigned long now_millis = millis(); unsigned long current_time = time(nullptr); @@ -2792,98 +2738,26 @@ void MQTTBridge::checkConfigurationMismatch() { } } -bool MQTTBridge::handleWiFiConnection(unsigned long now) { - wl_status_t current_wifi_status = WiFi.status(); - bool transitioned_to_connected = false; - - if (current_wifi_status == WL_CONNECTED && s_wifi_connected_at == 0) { - s_wifi_connected_at = now; - } - if (!_wifi_status_initialized) { - _last_wifi_status = current_wifi_status; - _wifi_status_initialized = true; - setWifiOutage(AlertFaultPolicy::applyWifiStatus( - (uint32_t)now, current_wifi_status == WL_CONNECTED, wifiOutage(), false)); - } - if (now - _last_wifi_check <= 10000) { - // Events own the snapshot between 10 s polls. If STA is associated again - // and GOT_IP was missed, still close the outage so a flap contained - // between polls does not look like one continuous downtime. - if (current_wifi_status == WL_CONNECTED) { - AlertFaultPolicy::OutageSnapshot snap = wifiOutage(); - if (snap.down) { - setWifiOutage(AlertFaultPolicy::applyWifiGotIp(snap)); +bool MQTTBridge::handleNetworkConnection(unsigned long now) { + const NetworkTransition transition = + _network->maintain((uint32_t)now, _obs->wifi_power_save); + const NetworkPolicy::MQTTTransitionActions actions = + NetworkPolicy::mqttActions(transition); + if (actions.disconnect_slots) { + // Broker ownership stays in the bridge. The physical adapter reports the + // edge; the bridge explicitly closes every slot instead of waiting for + // eventual socket timeouts. + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].client && _slots[i].connected) { + _slots[i].client->disconnect(); } } - return false; } - _last_wifi_check = now; - - if (current_wifi_status == WL_CONNECTED) { - if (_last_wifi_status != WL_CONNECTED) { - transitioned_to_connected = true; - setWifiOutage(AlertFaultPolicy::applyWifiStatus( - (uint32_t)now, true, wifiOutage(), true)); - s_wifi_connected_at = now; - _wifi_reconnect_backoff_attempt = 0; - #ifdef ESP_PLATFORM - wifi_ps_type_t ps_mode; - uint8_t ps_pref = _obs->wifi_power_save; - if (ps_pref == 1) { - ps_mode = WIFI_PS_NONE; - } else if (ps_pref == 2) { - ps_mode = WIFI_PS_MAX_MODEM; - } else { - ps_mode = WIFI_PS_NONE; // default: no power save; eliminates DTIM wake latency on mains-powered bridges - } - esp_wifi_set_ps(ps_mode); - #ifdef MQTT_WIFI_TX_POWER - WiFi.setTxPower(MQTT_WIFI_TX_POWER); - #else - WiFi.setTxPower(WIFI_POWER_11dBm); - #endif - #endif - } - if (s_wifi_connected_at == 0) { - s_wifi_connected_at = now; - } - _last_wifi_status = WL_CONNECTED; - } else { - const bool last_connected = (_last_wifi_status == WL_CONNECTED); - AlertFaultPolicy::OutageSnapshot snap = AlertFaultPolicy::applyWifiStatus( - (uint32_t)now, false, wifiOutage(), true); - setWifiOutage(snap); - if (last_connected) { - s_wifi_connected_at = 0; - // Disconnect all slot clients when WiFi drops - for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - if (_slots[i].client && _slots[i].connected) { - _slots[i].client->disconnect(); - } - } - } else if (snap.down) { - // Backoff ladder + wrap-safe timing live in MQTTConnectionPolicy (Phase 6), - // exercised by host tests. Behavior is unchanged: both the link-down - // duration and the since-last-attempt interval must clear the current rung - // (elapsedMs is the wrap-safe form of the old ULONG_MAX branch). - if (MQTTConnectionPolicy::wifiReconnectDue( - (uint32_t)now, snap.started_ms, - (uint32_t)_last_wifi_reconnect_attempt, - _wifi_reconnect_backoff_attempt)) { - _last_wifi_reconnect_attempt = now; - _wifi_reconnect_backoff_attempt = - MQTTConnectionPolicy::nextWifiBackoffAttempt(_wifi_reconnect_backoff_attempt); - WiFi.disconnect(); - WiFi.begin(_obs->wifi_ssid, _obs->wifi_password); - } - } - _last_wifi_status = current_wifi_status; - } - return transitioned_to_connected; + return actions.retry_disconnected_slots_now; } bool MQTTBridge::isReady() const { - return _initialized && isWiFiConfigValid(_obs); + return _initialized && isNetworkConfigValid(_obs); } bool MQTTBridge::isIATAValid() const { @@ -2943,10 +2817,10 @@ void MQTTBridge::loop() { return; #else unsigned long now = millis(); - if (handleWiFiConnection(now) && !_ntp_synced) { + if (handleNetworkConnection(now) && !_ntp_synced) { syncTimeWithNTP(); } - if (_ntp_sync_pending && WiFi.status() == WL_CONNECTED) { + if (_ntp_sync_pending && _network->isConnected()) { _ntp_sync_pending = false; syncTimeWithNTP(); } @@ -2986,7 +2860,7 @@ void MQTTBridge::loop() { checkConfigurationMismatch(); // Periodic NTP refresh (every hour) — lightweight, non-blocking. - if (WiFi.status() == WL_CONNECTED && millis() - _last_ntp_sync > 3600000) { + if (_network->isConnected() && millis() - _last_ntp_sync > 3600000) { refreshNTP(); } @@ -3977,8 +3851,8 @@ void MQTTBridge::refreshNTP() { } bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { - if (!WiFi.isConnected()) { - MQTT_DEBUG_PRINTLN("Cannot sync time - WiFi not connected"); + if (!_network->isConnected()) { + MQTT_DEBUG_PRINTLN("Cannot sync time - %s not connected", _network->mediumName()); return false; } @@ -4026,7 +3900,7 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { // Skipping is what keeps the credit honest; the name that answered is the name // recorded. IPAddress resolved_ip; - if (!WiFi.hostByName(server, resolved_ip)) { + if (!_network->resolveHost(server, resolved_ip)) { MQTT_DEBUG_PRINTLN("NTP: %s does not resolve — skipping, not attempting a send", server); continue; } diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index e2d272538..5038db187 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -2,8 +2,8 @@ #include "MeshCore.h" #include "helpers/bridges/BridgeBase.h" +#include "helpers/NetworkInterface.h" #include -#include #include #include #include @@ -219,11 +219,6 @@ private: bool _ntp_synced; bool _ntp_sync_pending; // Flag to trigger NTP sync from loop() instead of event handler bool _slots_setup_done; // Deferred: slots set up after NTP sync - // WiFi.onEvent() handler registered once and never removed by end(); the bridge - // object is reused across restarts, so re-registering would leak handlers and - // duplicate every connect/disconnect log line. Inline-initialised so it survives - // construction and is NOT reset by end(). - bool _wifi_event_registered = false; int _max_active_slots; // Runtime limit: 5 with PSRAM, 2 without // Pending slot reconfigure: set from CLI (Core 1), processed by MQTT task (Core 0) @@ -406,25 +401,8 @@ private: unsigned long _last_config_warning; // Throttle configuration mismatch warnings static const unsigned long CONFIG_WARNING_INTERVAL = 300000; // Log every 5 minutes max - // WiFi connection state and exponential backoff - unsigned long _last_wifi_check; - wl_status_t _last_wifi_status; - bool _wifi_status_initialized; - // Packed OutageSnapshot; Core 0 (event + MQTT task) stores, Core 1 loads. - std::atomic _wifi_outage_bits; - unsigned long _last_wifi_reconnect_attempt; - uint8_t _wifi_reconnect_backoff_attempt; // 0..5 → 15s, 30s, 60s, 120s, 300s; reset on connect unsigned long _last_slot_reconnect_ms; // guards against concurrent TLS handshakes (15 s inter-slot gap) - AlertFaultPolicy::OutageSnapshot wifiOutage() const { - return AlertFaultPolicy::unpackOutageSnapshot( - _wifi_outage_bits.load(std::memory_order_acquire)); - } - void setWifiOutage(AlertFaultPolicy::OutageSnapshot snap) { - _wifi_outage_bits.store(AlertFaultPolicy::packOutageSnapshot(snap), - std::memory_order_release); - } - // Optional pointers for collecting stats internally (set by mesh if available) mesh::Dispatcher* _dispatcher; // For air times and errors mesh::Radio* _radio; // For noise floor @@ -483,13 +461,13 @@ private: void processPacketQueue(); bool publishStatus(); // Returns true if status was successfully published - bool handleWiFiConnection(unsigned long now); + bool handleNetworkConnection(unsigned long now); // FreeRTOS task function (runs on Core 0) #ifdef ESP_PLATFORM static void mqttTask(void* parameter); void mqttTaskLoop(); // Main loop for MQTT task - void initializeWiFiInTask(); // WiFi initialization moved to task + void initializeNetworkInTask(); // Selected-link initialization moved to task #endif bool publishPacket(mesh::Packet* packet, bool is_tx, bool& has_eligible_target, const uint8_t* raw_data = nullptr, int raw_len = 0, @@ -547,6 +525,7 @@ private: // Observer config (MQTT/WiFi/timezone/SNMP/alert), persisted to /mqtt.json. // _prefs (held by BridgeBase) still provides upstream fields (freq/sf/node_name…). MQTTPrefs* _obs = nullptr; + NetworkInterface* _network = nullptr; public: MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity); @@ -655,13 +634,17 @@ public: static unsigned long getWifiConnectedAtMillis(); /** - * Current WiFi outage snapshot for AlertReporter: down, started_ms, and the + * Current selected-network outage snapshot for AlertReporter: down, + * started_ms, and the * initiating disconnect reason. Distinct from getLastWifiDisconnectTime() / * getLastWifiDisconnectReason(), which follow the most recent ESP-IDF * DISCONNECTED event and are overwritten by STA-backoff WiFi.disconnect() * (reason 8 / ASSOC_LEAVE). */ - AlertFaultPolicy::OutageSnapshot getWifiOutageSnapshot() const { return wifiOutage(); } + AlertFaultPolicy::OutageSnapshot getWifiOutageSnapshot() const { + return _network ? _network->outageSnapshot() + : AlertFaultPolicy::OutageSnapshot{false, 0, 0}; + } /** * Per-slot outage accessors used by AlertReporter to detect prolonged @@ -723,7 +706,7 @@ public: uint16_t filter_mask; }; static bool getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out); - /** True when WiFi is set and at least one MQTT slot can run (preset + custom host if needed). */ + /** True when the selected network is configured and at least one MQTT slot can run. */ static bool isConfigValid(const MQTTPrefs* obs); static void formatSlotDiagReply(char* buf, size_t bufsize, int slot_index); static uint8_t getLastWifiDisconnectReason(); diff --git a/src/helpers/ethernet/ch390/CH390Config.h b/src/helpers/ethernet/ch390/CH390Config.h new file mode 100644 index 000000000..1b3a5bd9e --- /dev/null +++ b/src/helpers/ethernet/ch390/CH390Config.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +/** + * Bring up the repository's CH390 lwIP interface from the board build flags. + * Shared by the companion transport wrapper and the observer network adapter so + * pin and static-IP behavior cannot drift between the two paths. + */ +static inline bool beginConfiguredCH390() { + ch390_config_t config = CH390_DEFAULT_CONFIG(); + config.spi_miso_gpio = ETH_MISO_PIN; + config.spi_mosi_gpio = ETH_MOSI_PIN; + config.spi_sck_gpio = ETH_SCLK_PIN; + config.spi_cs_gpio = ETH_CS_PIN; + config.int_gpio = ETH_INT_PIN; + if (!CH390.begin(config)) return false; + +#if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET) + IPAddress ip(ETHERNET_STATIC_IP); + IPAddress gateway(ETHERNET_STATIC_GATEWAY); + IPAddress subnet(ETHERNET_STATIC_SUBNET); + #if defined(ETHERNET_STATIC_DNS) + IPAddress dns(ETHERNET_STATIC_DNS); + CH390.config(ip, gateway, subnet, dns); + #else + CH390.config(ip, gateway, subnet); + #endif +#endif + return true; +} diff --git a/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp b/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp index 7f6962453..ed262bfbd 100644 --- a/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp +++ b/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp @@ -1,4 +1,5 @@ #include "CH390EthernetInterface.h" +#include "CH390Config.h" void onWiFiEvent(WiFiEvent_t event) { switch(event){ @@ -29,26 +30,12 @@ bool CH390EthernetInterface::begin() { // listen to ethernet events WiFi.onEvent(onWiFiEvent); - // Init CH390 - ch390_config_t config = CH390_DEFAULT_CONFIG(); - config.spi_miso_gpio = ETH_MISO_PIN; - config.spi_mosi_gpio = ETH_MOSI_PIN; - config.spi_sck_gpio = ETH_SCLK_PIN; - config.spi_cs_gpio = ETH_CS_PIN; - config.int_gpio = ETH_INT_PIN; - if (!CH390.begin(config)) { + // Init CH390 using the same board configuration as the observer uplink. + if (!beginConfiguredCH390()) { ETHERNET_DEBUG_PRINTLN("Failed to initialize CH390 hardware."); return false; } - // Setup Static IP if build flags are present - #if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET) - IPAddress ip(ETHERNET_STATIC_IP); - IPAddress gw(ETHERNET_STATIC_GATEWAY); - IPAddress sn(ETHERNET_STATIC_SUBNET); - CH390.config(ip, gw, sn); - #endif - // Start Server server.begin(ETHERNET_TCP_PORT); ETHERNET_DEBUG_PRINTLN("listening on TCP port: %d", ETHERNET_TCP_PORT); diff --git a/test/README.md b/test/README.md index 647b6daf1..0cba8fff5 100644 --- a/test/README.md +++ b/test/README.md @@ -29,6 +29,7 @@ does not reflect the GoogleTest count — run the built binary directly | `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 all types except raw; 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; WiFi current-outage start sticky across STA reconnect attempts | +| `test_network_policy` | `src/helpers/NetworkPolicy.h` | transport-neutral MQTT link-transition actions and `start ota` selected-LAN versus forced/fallback SoftAP choice | | `test_alert_fault_policy` | `src/helpers/AlertFaultPolicy.h` | WiFi/MQTT fault edge detector; `OutageSnapshot` (down / started_ms / initiating reason) fed to tick and `formatWifiAlert`; reason-8 reconnects change neither duration nor initiating reason; flap between status polls; down at `millis()==0`; packed 64-bit cross-task word; rate-limit floor and first-fire; 5 s poll cadence and `millis()` rollover | | `test_display_viewport` | `src/helpers/ui/DisplayViewport.h`, `src/helpers/ui/DisplayFrameSignature.h` | logical-to-physical portrait mapping; fractional span coverage; fitted-width conversion; preferred/fallback text scaling; stable visible-frame change detection | | `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 | diff --git a/test/test_alert_fault_policy/test_alert_fault_policy.cpp b/test/test_alert_fault_policy/test_alert_fault_policy.cpp index 42ba27d7f..77bdecbd8 100644 --- a/test/test_alert_fault_policy/test_alert_fault_policy.cpp +++ b/test/test_alert_fault_policy/test_alert_fault_policy.cpp @@ -350,6 +350,17 @@ TEST(AlertFaultPolicy, FormatWifiAlertUsesSnapshotReason) { EXPECT_STREQ("WiFi down 47m", text); } +TEST(AlertFaultPolicy, FormatNetworkAlertUsesSelectedMediumLabel) { + Alert::Fault f = OkFault(); + const Alert::OutageSnapshot snap = Down(1000, 0); + const Alert::TickResult r = Alert::tick( + f, 1000 + kWifiThresh, snap, kWifiThresh, kMinInterval); + char text[80]; + ASSERT_TRUE(Alert::formatNetworkAlert( + text, sizeof(text), "Ethernet", r, snap)); + EXPECT_STREQ("Ethernet down 30m", text); +} + TEST(AlertFaultPolicy, FormatMqttSlotMessages) { char text[100]; Alert::formatMqttDown(text, sizeof(text), 1, "analyzer-us", 30U * 60000U); diff --git a/test/test_network_policy/test_network_policy.cpp b/test/test_network_policy/test_network_policy.cpp new file mode 100644 index 000000000..201c7451f --- /dev/null +++ b/test/test_network_policy/test_network_policy.cpp @@ -0,0 +1,36 @@ +#include + +#include "helpers/NetworkPolicy.h" + +TEST(NetworkPolicy, MqttDownDisconnectsSlotsWithoutRequestingImmediateRetry) { + const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Down); + EXPECT_TRUE(actions.disconnect_slots); + EXPECT_FALSE(actions.retry_disconnected_slots_now); +} + +TEST(NetworkPolicy, MqttUpRequestsImmediateRetryWithoutDisconnectingSlots) { + const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Up); + EXPECT_FALSE(actions.disconnect_slots); + EXPECT_TRUE(actions.retry_disconnected_slots_now); +} + +TEST(NetworkPolicy, NoTransitionHasNoMqttSideEffects) { + const auto actions = NetworkPolicy::mqttActions(NetworkTransition::None); + EXPECT_FALSE(actions.disconnect_slots); + EXPECT_FALSE(actions.retry_disconnected_slots_now); +} + +TEST(NetworkPolicy, StartOtaUsesReachableSelectedNetworkByDefault) { + EXPECT_TRUE(NetworkPolicy::startOtaUsesSelectedNetwork(false, true)); + EXPECT_FALSE(NetworkPolicy::startOtaUsesSelectedNetwork(false, false)); +} + +TEST(NetworkPolicy, StartOtaForceApOverridesAReachableSelectedNetwork) { + EXPECT_FALSE(NetworkPolicy::startOtaUsesSelectedNetwork(true, true)); + EXPECT_FALSE(NetworkPolicy::startOtaUsesSelectedNetwork(true, false)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/thinknode_m7/platformio.ini b/variants/thinknode_m7/platformio.ini index 7da141120..2ccf6f5c6 100644 --- a/variants/thinknode_m7/platformio.ini +++ b/variants/thinknode_m7/platformio.ini @@ -37,22 +37,31 @@ build_src_filter = ${esp32_base.build_src_filter} lib_deps = ${esp32_base.lib_deps} stevemarple/MicroNMEA @ ^2.0.6 -[ThinkNode_M7_ethernet] +[ThinkNode_M7_ch390] build_flags = - -D ETHERNET_ENABLED -D ETHERNET_USE_CH390 - -D ETHERNET_CLASS=CH390EthernetInterface -D ETH_MISO_PIN=14 -D ETH_MOSI_PIN=48 -D ETH_SCLK_PIN=47 -D ETH_CS_PIN=21 -D ETH_INT_PIN=45 -D ETHERNET_DEBUG_LOGGING=1 +lib_deps = + https://github.com/liamcottle/ESP32-CH390.git#47b401f1de546118b03c18b5689dedec45871f2d + +; Existing companion/CLI transport overlay. MQTT Ethernet observers consume the +; CH390 fragment above without ETHERNET_ENABLED, whose meaning in the simple +; repeater/room entry points is the legacy nRF52 Ethernet CLI. +[ThinkNode_M7_ethernet] +build_flags = + ${ThinkNode_M7_ch390.build_flags} + -D ETHERNET_ENABLED + -D ETHERNET_CLASS=CH390EthernetInterface build_src_filter = + + lib_deps = - https://github.com/liamcottle/ESP32-CH390.git#47b401f1de546118b03c18b5689dedec45871f2d + ${ThinkNode_M7_ch390.lib_deps} [env:ThinkNode_M7_repeater] extends = ThinkNode_M7 @@ -180,9 +189,10 @@ extends = ThinkNode_M7 build_src_filter = ${ThinkNode_M7.build_src_filter} +<../examples/kiss_modem/> -; MQTT observer envs are WiFi-only: the bridge's link management is bound to the -; WiFi station API, so the onboard CH390 cannot carry MQTT yet. The board has -; PSRAM, so MAX_NEIGHBOURS enables WITH_MQTT_NEIGHBORS (see MQTTBridge.h). +; Wi-Fi remains the default observer transport. Ethernet twins below select the +; onboard CH390 through NETWORK_USE_ETHERNET without enabling the legacy CLI +; transport. The board has PSRAM, so MAX_NEIGHBOURS enables +; WITH_MQTT_NEIGHBORS (see MQTTBridge.h). [env:ThinkNode_M7_repeater_observer_mqtt] extends = ThinkNode_M7 extra_scripts = @@ -227,6 +237,17 @@ lib_deps = paulstoffregen/Time@1.6.1 0neblock/SNMP_Agent +[env:ThinkNode_M7_repeater_observer_mqtt_ethernet] +extends = env:ThinkNode_M7_repeater_observer_mqtt +build_flags = + ${env:ThinkNode_M7_repeater_observer_mqtt.build_flags} + ${ThinkNode_M7_ch390.build_flags} + -D NETWORK_USE_ETHERNET=1 +build_src_filter = ${env:ThinkNode_M7_repeater_observer_mqtt.build_src_filter} +lib_deps = + ${env:ThinkNode_M7_repeater_observer_mqtt.lib_deps} + ${ThinkNode_M7_ch390.lib_deps} + [env:ThinkNode_M7_room_server_observer_mqtt] extends = ThinkNode_M7 extra_scripts = @@ -271,3 +292,14 @@ lib_deps = JChristensen/Timezone paulstoffregen/Time@1.6.1 0neblock/SNMP_Agent + +[env:ThinkNode_M7_room_server_observer_mqtt_ethernet] +extends = env:ThinkNode_M7_room_server_observer_mqtt +build_flags = + ${env:ThinkNode_M7_room_server_observer_mqtt.build_flags} + ${ThinkNode_M7_ch390.build_flags} + -D NETWORK_USE_ETHERNET=1 +build_src_filter = ${env:ThinkNode_M7_room_server_observer_mqtt.build_src_filter} +lib_deps = + ${env:ThinkNode_M7_room_server_observer_mqtt.lib_deps} + ${ThinkNode_M7_ch390.lib_deps} From 366b0867135f9dc8691d4a92bfeb01acd1aa7765 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 1 Sep 2026 21:18:22 -0700 Subject: [PATCH 02/16] feat: add automatic network failover --- MQTT_IMPLEMENTATION.md | 7 +- examples/simple_repeater/MyMesh.cpp | 41 ++-- examples/simple_repeater/MyMesh.h | 7 + examples/simple_repeater/UITask.cpp | 5 +- examples/simple_room_server/MyMesh.cpp | 41 ++-- examples/simple_room_server/MyMesh.h | 7 + examples/simple_room_server/UITask.cpp | 5 +- scripts/webconfig_mock_server.py | 3 + src/helpers/AlertReporter.cpp | 4 +- src/helpers/ESP32Board.cpp | 7 + src/helpers/MQTTDefaults.h | 1 + src/helpers/MQTTPrefsSerializer.h | 13 +- src/helpers/MQTTPrefsStorage.h | 10 + src/helpers/NetworkInterface.cpp | 231 +++++++++++++++++- src/helpers/NetworkInterface.h | 19 ++ src/helpers/NetworkPolicy.h | 72 +++++- src/helpers/bridges/MQTTBridge.cpp | 30 ++- src/helpers/esp32/WebConfigServer.cpp | 92 +++++-- src/helpers/esp32/WebConfigServer.h | 14 +- .../test_mqtt_prefs_serializer.cpp | 14 ++ .../test_network_policy.cpp | 56 +++++ variants/thinknode_m7/platformio.ini | 6 +- webui/index.html | 1 + 23 files changed, 600 insertions(+), 86 deletions(-) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index ac74485d6..774ff63a0 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -80,7 +80,7 @@ reboot **9. Verify configuration** ```bash get wifi.ssid -get wifi.status +get link.status get bridge.enabled get mqtt.rx get mqtt.tx @@ -541,12 +541,13 @@ These settings apply across all MQTT slots: - `set mqtt.owner <64-hex-char-public-key>` - Set owner public key - `set mqtt.email ` - Set owner email address -### WiFi Commands +### Network and WiFi Commands #### Get Commands - `get wifi.ssid` - Get WiFi SSID - `get wifi.pwd` - Get WiFi password -- `get wifi.status` - Get WiFi connection status, IP, RSSI, and uptime +- `get link.status` - Get the selected network medium, connection status, IP, signal when available, and uptime +- `get wifi.status` - WiFi-only compatibility alias; reports n/a when another medium is selected - `get wifi.powersave` - Get WiFi power save mode (none/min/max) #### Set Commands diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 48322c6ad..cfbe35d8b 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -4,7 +4,7 @@ #include // for qsort() #include #include -#if defined(ESP_PLATFORM) && !defined(NETWORK_USE_ETHERNET) +#if defined(ESP_PLATFORM) #include #endif #if defined(WITH_MQTT_NEIGHBORS) @@ -1147,6 +1147,17 @@ void MyMesh::begin(FILESYSTEM *fs) { } #endif + NetworkInterface& boot_network = activeNetworkInterface(); + if (boot_network.isAutomatic()) { + MQTTPrefs* obs = _cli.getObserverPrefs(); + Serial.printf("Network: probing Ethernet for up to %lums\n", + (unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS); + boot_network.bootstrap(obs->wifi_ssid, obs->wifi_password, + NETWORK_ETHERNET_BOOT_WAIT_MS); + Serial.printf("Network: selected %s (%s)\n", boot_network.mediumName(), + boot_network.statusName()); + } + acl.load(_fs, self_id); // TODO: key_store.begin(); region_map.load(_fs); @@ -1221,15 +1232,13 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif #if defined(WITH_WEBCONFIG) && !defined(WEBCONFIG_NO_AUTO_AP) - // First-boot setup portal: raised only when no WiFi has ever been configured, - // so an OTA onto a deployed (configured) node can never open an AP. - #if !defined(NETWORK_USE_ETHERNET) - if (_cli.getObserverPrefs()->wifi_ssid[0] == 0) { + // Existing Wi-Fi installs are complete by virtue of their stored SSID. An + // Ethernet-only install uses the explicit completion marker instead. + if (!mqttNetworkSetupComplete(_cli.getObserverPrefs())) { char wc_reply[160]; startWebConfig(false, wc_reply); Serial.println(wc_reply); } - #endif #endif radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); @@ -1474,11 +1483,6 @@ void MyMesh::clearStats() { #ifdef WITH_WEBCONFIG bool MyMesh::startWebConfig(bool force_ap, char* reply) { -#if defined(NETWORK_USE_ETHERNET) - (void)force_ap; - strcpy(reply, "Err: webconfig unavailable on Ethernet observer; use serial CLI"); - return true; -#else if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) { strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly" : "Err: webconfig already running"); @@ -1491,18 +1495,21 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { } if (force_ap) { // The setup AP owns WiFi outright; refuse while the bridge holds the STA. - if (bridge && bridge->isRunning()) { + if (bridge && bridge->isRunning() && + activeNetworkInterface().medium() != NetworkMedium::Ethernet) { strcpy(reply, "Err: MQTT bridge is running - 'set bridge off' first"); return true; } _webconfig->startSetupMode(reply); - } else if (_cli.getObserverPrefs()->wifi_ssid[0] == 0) { - _webconfig->startSetupMode(reply); // unconfigured: same portal as first boot + } else if (activeNetworkInterface().isConnected()) { + _webconfig->startLanMode(activeNetworkInterface().localIP(), + !mqttNetworkSetupComplete(_cli.getObserverPrefs()), reply); + } else if (!mqttNetworkSetupComplete(_cli.getObserverPrefs())) { + _webconfig->startSetupMode(reply); } else { - _webconfig->startLanMode(reply); // reports "WiFi not connected" if down + strcpy(reply, "Err: selected network not connected"); } return true; -#endif } bool MyMesh::stopWebConfig(char* reply) { @@ -1542,11 +1549,9 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) { const int signal = network.rssi(); wifi_rssi = signal == INT_MIN ? 0 : signal; } -#if !defined(NETWORK_USE_ETHERNET) else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) { strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1); } -#endif int pos = snprintf(buf, buf_size, "{\"uptime_s\":%lu,\"batt_mv\":%u," "\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu," diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 539a17154..6cc179d89 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -507,6 +507,13 @@ public: _wc_slot_restart_mask = 0; } void onConfigBatchEnd() override; + bool onInitialSetupComplete() override { + MQTTPrefs* obs = _cli.getObserverPrefs(); + obs->network_setup_complete = 1; + if (_cli.saveObserverPrefs(_fs)) return true; + obs->network_setup_complete = 0; + return false; + } void buildStatsJson(char* buf, size_t buf_size) override; #endif diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index ee66520f4..506aef648 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -174,15 +174,16 @@ void UITask::renderCurrScreen() { } char wc_ssid[33], wc_ip[16]; if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) { + const bool lan_setup = WebConfigServer::isLanSetup(); // setup portal active: show join instructions instead of the home screen _display->setTextSize(1); _display->setColor(UIColor::corp_blue); _display->setCursor(0, 0); - _display->print("Observer WiFi Setup"); + _display->print(lan_setup ? "Observer Ethernet Setup" : "Observer WiFi Setup"); _display->setColor(UIColor::primary_txt); _display->setCursor(0, 14); - _display->print("Join WiFi:"); + _display->print(lan_setup ? "Login code:" : "Join WiFi:"); _display->setColor(UIColor::warning_txt); _display->setCursor(6, 24); _display->print(wc_ssid); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 90d5ea4bc..01b157ccc 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -3,7 +3,7 @@ #include #include #include -#if defined(ESP_PLATFORM) && !defined(NETWORK_USE_ETHERNET) +#if defined(ESP_PLATFORM) #include #endif #if defined(WITH_MQTT_NEIGHBORS) @@ -948,6 +948,17 @@ void MyMesh::begin(FILESYSTEM *fs) { // load persisted prefs _cli.loadPrefs(_fs); + NetworkInterface& boot_network = activeNetworkInterface(); + if (boot_network.isAutomatic()) { + MQTTPrefs* obs = _cli.getObserverPrefs(); + Serial.printf("Network: probing Ethernet for up to %lums\n", + (unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS); + boot_network.bootstrap(obs->wifi_ssid, obs->wifi_password, + NETWORK_ETHERNET_BOOT_WAIT_MS); + Serial.printf("Network: selected %s (%s)\n", boot_network.mediumName(), + boot_network.statusName()); + } + acl.load(_fs, self_id); region_map.load(_fs); @@ -1023,15 +1034,13 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif #if defined(WITH_WEBCONFIG) && !defined(WEBCONFIG_NO_AUTO_AP) - // First-boot setup portal: raised only when no WiFi has ever been configured, - // so an OTA onto a deployed (configured) node can never open an AP. - #if !defined(NETWORK_USE_ETHERNET) - if (_cli.getObserverPrefs()->wifi_ssid[0] == 0) { + // Existing Wi-Fi installs are complete by virtue of their stored SSID. An + // Ethernet-only install uses the explicit completion marker instead. + if (!mqttNetworkSetupComplete(_cli.getObserverPrefs())) { char wc_reply[160]; startWebConfig(false, wc_reply); Serial.println(wc_reply); } - #endif #endif } @@ -1276,11 +1285,6 @@ void MyMesh::formatPacketStatsReply(char *reply) { #ifdef WITH_WEBCONFIG bool MyMesh::startWebConfig(bool force_ap, char* reply) { -#if defined(NETWORK_USE_ETHERNET) - (void)force_ap; - strcpy(reply, "Err: webconfig unavailable on Ethernet observer; use serial CLI"); - return true; -#else if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) { strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly" : "Err: webconfig already running"); @@ -1293,18 +1297,21 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { } if (force_ap) { // The setup AP owns WiFi outright; refuse while the bridge holds the STA. - if (bridge && bridge->isRunning()) { + if (bridge && bridge->isRunning() && + activeNetworkInterface().medium() != NetworkMedium::Ethernet) { strcpy(reply, "Err: MQTT bridge is running - 'set bridge off' first"); return true; } _webconfig->startSetupMode(reply); - } else if (_cli.getObserverPrefs()->wifi_ssid[0] == 0) { - _webconfig->startSetupMode(reply); // unconfigured: same portal as first boot + } else if (activeNetworkInterface().isConnected()) { + _webconfig->startLanMode(activeNetworkInterface().localIP(), + !mqttNetworkSetupComplete(_cli.getObserverPrefs()), reply); + } else if (!mqttNetworkSetupComplete(_cli.getObserverPrefs())) { + _webconfig->startSetupMode(reply); } else { - _webconfig->startLanMode(reply); // reports "WiFi not connected" if down + strcpy(reply, "Err: selected network not connected"); } return true; -#endif } bool MyMesh::stopWebConfig(char* reply) { @@ -1344,11 +1351,9 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) { const int signal = network.rssi(); wifi_rssi = signal == INT_MIN ? 0 : signal; } -#if !defined(NETWORK_USE_ETHERNET) else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) { strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1); } -#endif int pos = snprintf(buf, buf_size, "{\"uptime_s\":%lu,\"batt_mv\":%u," "\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu," diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 47f2f68d7..f5e0be7e0 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -497,6 +497,13 @@ public: _wc_slot_restart_mask = 0; } void onConfigBatchEnd() override; + bool onInitialSetupComplete() override { + MQTTPrefs* obs = _cli.getObserverPrefs(); + obs->network_setup_complete = 1; + if (_cli.saveObserverPrefs(_fs)) return true; + obs->network_setup_complete = 0; + return false; + } void buildStatsJson(char* buf, size_t buf_size) override; #endif }; diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index 8dab1cdc2..475d20a3c 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -155,15 +155,16 @@ void UITask::renderCurrScreen() { } char wc_ssid[33], wc_ip[16]; if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) { + const bool lan_setup = WebConfigServer::isLanSetup(); // setup portal active: show join instructions instead of the home screen _display->setTextSize(1); _display->setColor(UIColor::corp_blue); _display->setCursor(0, 0); - _display->print("Observer WiFi Setup"); + _display->print(lan_setup ? "Observer Ethernet Setup" : "Observer WiFi Setup"); _display->setColor(UIColor::primary_txt); _display->setCursor(0, 14); - _display->print("Join WiFi:"); + _display->print(lan_setup ? "Login code:" : "Join WiFi:"); _display->setColor(UIColor::warning_txt); _display->setCursor(6, 24); _display->print(wc_ssid); diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index c38d804e3..547889a12 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -497,6 +497,9 @@ GETTERS = { "wifi.status": lambda c: ( "SSID: %s\nIP: 192.168.1.42\nRSSI: -58 dBm\nUptime: %dm" % (c["wifi"]["ssid"] or "(not set)", int(time.time() - ST.start) // 60)), + "link.status": lambda c: ( + "wifi: connected, IP: 192.168.1.42, RSSI: -58 dBm, Uptime: %dm" + % (int(time.time() - ST.start) // 60)), "mqtt.status": lambda c: cli_mqtt_status(c), "mqtt.presets": lambda c: "\n".join( "%2d. %s%s" % (i + 1, n, "" if nd == "none" else " (needs %s)" % nd) diff --git a/src/helpers/AlertReporter.cpp b/src/helpers/AlertReporter.cpp index 171d81c88..d9617ca72 100644 --- a/src/helpers/AlertReporter.cpp +++ b/src/helpers/AlertReporter.cpp @@ -220,7 +220,7 @@ void AlertReporter::onLoop(unsigned long now_ms) { char text[80]; AlertFaultPolicy::formatNetworkAlert( text, sizeof(text), - strcmp(activeNetworkInterface().mediumName(), "ethernet") == 0 + activeNetworkInterface().medium() == NetworkMedium::Ethernet ? "Ethernet" : "WiFi", r, snap); if (sendChannel(text)) { @@ -230,7 +230,7 @@ void AlertReporter::onLoop(unsigned long now_ms) { char text[80]; AlertFaultPolicy::formatNetworkAlert( text, sizeof(text), - strcmp(activeNetworkInterface().mediumName(), "ethernet") == 0 + activeNetworkInterface().medium() == NetworkMedium::Ethernet ? "Ethernet" : "WiFi", r, snap); sendChannel(text); diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 5452c50cf..19cdce777 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -14,6 +14,9 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { inhibit_sleep = true; // prevent sleep during OTA + // Manual ElegantOTA owns port 80 and remains active until a successful upload + // reboots the device, so its route lock is intentionally reboot-scoped. + activeNetworkInterface().lockSwitching(); // If the device is already on its selected network, serve ElegantOTA on that // address so it is reachable without joining a separate AP. Otherwise raise @@ -184,16 +187,20 @@ bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char rep // mesh-receive call chain (it overflows the loopTask canary). Run the work in a // dedicated 24 KB-stack task and block here until it finishes. The big stack is // freed when the task exits; on a successful update the chip reboots inside it. + NetworkInterface& network = activeNetworkInterface(); + network.lockSwitching(); OtaTaskArgs args = { this, current_ver, dry_run, reply, false, false }; TaskHandle_t handle = nullptr; BaseType_t ok = xTaskCreatePinnedToCore(ota_task_entry, "ota", 24576, &args, 5, &handle, 1); if (ok != pdPASS) { + network.unlockSwitching(); strcpy(reply, "ERR: OTA task spawn failed"); return false; } while (!args.done) { delay(50); // Arduino delay() yields to other tasks } + network.unlockSwitching(); return args.result; } diff --git a/src/helpers/MQTTDefaults.h b/src/helpers/MQTTDefaults.h index c390da0b4..ecee15459 100644 --- a/src/helpers/MQTTDefaults.h +++ b/src/helpers/MQTTDefaults.h @@ -112,6 +112,7 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) { prefs->display_timeout_secs = DISPLAY_TIMEOUT_DEFAULT_SECS; prefs->display_flip = 0; + prefs->network_setup_complete = 0; } #endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTPrefsSerializer.h b/src/helpers/MQTTPrefsSerializer.h index f783b52c4..b3b968cc2 100644 --- a/src/helpers/MQTTPrefsSerializer.h +++ b/src/helpers/MQTTPrefsSerializer.h @@ -39,22 +39,31 @@ class MQTTPrefsSerializer : public ConfigSerializer { class WifiPrefs : public ConfigSerializer { MQTTPrefs* _prefs; - int32_t _power_save; + int32_t _power_save, _setup_complete; bool _seen_ssid = false, _seen_password = false, _seen_power_save = false; + bool _seen_setup_complete = false; protected: void structure() override { defStrict("ssid", _prefs->wifi_ssid, sizeof(_prefs->wifi_ssid), _seen_ssid); defStrict("password", _prefs->wifi_password, sizeof(_prefs->wifi_password), _seen_password); defStrict("power_save", _power_save, _seen_power_save); + defStrict("setup_complete", _setup_complete, _seen_setup_complete); } public: - explicit WifiPrefs(MQTTPrefs* prefs) : _prefs(prefs), _power_save(prefs->wifi_power_save) {} + explicit WifiPrefs(MQTTPrefs* prefs) + : _prefs(prefs), _power_save(prefs->wifi_power_save), + _setup_complete(prefs->network_setup_complete) {} bool apply(bool* repaired) { if (_power_save < 0 || _power_save > 2) { _power_save = 1; *repaired = true; } + if (_setup_complete < 0 || _setup_complete > 1) { + _setup_complete = 0; + *repaired = true; + } _prefs->wifi_power_save = static_cast(_power_save); + _prefs->network_setup_complete = static_cast(_setup_complete); return true; } }; diff --git a/src/helpers/MQTTPrefsStorage.h b/src/helpers/MQTTPrefsStorage.h index 7401825b8..c5072f15c 100644 --- a/src/helpers/MQTTPrefsStorage.h +++ b/src/helpers/MQTTPrefsStorage.h @@ -126,8 +126,18 @@ struct MQTTPrefs { // Rotate the panel 180 degrees from its compiled DISPLAY_ROTATION, for boards // mounted the other way up. Runtime only, like display_timeout_secs. uint8_t display_flip; + + // Explicitly records completion of first-run network onboarding when a node + // is configured over Ethernet and therefore may intentionally have no Wi-Fi + // SSID. Existing Wi-Fi installations remain complete by the helper below. + uint8_t network_setup_complete; }; +static inline bool mqttNetworkSetupComplete(const MQTTPrefs* prefs) { + return prefs != nullptr && + (prefs->network_setup_complete != 0 || prefs->wifi_ssid[0] != '\0'); +} + static const uint16_t DISPLAY_TIMEOUT_DEFAULT_SECS = 60; static const uint16_t DISPLAY_TIMEOUT_MAX_SECS = 3600; diff --git a/src/helpers/NetworkInterface.cpp b/src/helpers/NetworkInterface.cpp index 1d4d2d39f..ae5273e3d 100644 --- a/src/helpers/NetworkInterface.cpp +++ b/src/helpers/NetworkInterface.cpp @@ -11,7 +11,7 @@ #include #include -#if defined(NETWORK_USE_ETHERNET) +#if defined(NETWORK_PREFER_ETHERNET) #include "ethernet/ch390/CH390Config.h" #endif @@ -88,6 +88,7 @@ class WiFiNetworkInterface final : public NetworkInterfaceBase { public: const char* mediumName() const override { return "wifi"; } + NetworkMedium medium() const override { return NetworkMedium::WiFi; } const char* statusName() const override { switch (WiFi.status()) { case WL_CONNECTED: return "connected"; @@ -200,13 +201,14 @@ class WiFiNetworkInterface final : public NetworkInterfaceBase { } }; -#if defined(NETWORK_USE_ETHERNET) +#if defined(NETWORK_PREFER_ETHERNET) class EthernetNetworkInterface final : public NetworkInterfaceBase { bool _started = false; bool _event_registered = false; public: const char* mediumName() const override { return "ethernet"; } + NetworkMedium medium() const override { return NetworkMedium::Ethernet; } const char* statusName() const override { return isConnected() ? "connected" : "disconnected"; } @@ -281,13 +283,234 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { return WiFi.hostByName(hostname, address); } }; + +class AutomaticNetworkInterface final : public NetworkInterface { + EthernetNetworkInterface _ethernet; + WiFiNetworkInterface _wifi; + NetworkMedium _selected = NetworkMedium::None; + bool _ethernet_started = false; + bool _wifi_started = false; + char _wifi_ssid[33] = {}; + char _wifi_password[65] = {}; + uint32_t _ethernet_stable_since = 0; + uint32_t _selected_down_since = 0; + std::atomic _switch_locks{0}; + + NetworkInterface& selectedInterface() { + return _selected == NetworkMedium::Ethernet + ? static_cast(_ethernet) + : static_cast(_wifi); + } + const NetworkInterface& selectedInterface() const { + return _selected == NetworkMedium::Ethernet + ? static_cast(_ethernet) + : static_cast(_wifi); + } + + bool wifiConfigured() const { return _wifi_ssid[0] != '\0'; } + + void rememberWifi(const char* ssid, const char* password) { + strncpy(_wifi_ssid, ssid ? ssid : "", sizeof(_wifi_ssid) - 1); + _wifi_ssid[sizeof(_wifi_ssid) - 1] = '\0'; + strncpy(_wifi_password, password ? password : "", sizeof(_wifi_password) - 1); + _wifi_password[sizeof(_wifi_password) - 1] = '\0'; + } + + void startWifiFallback() { + if (_wifi_started || !wifiConfigured()) return; + _wifi_started = _wifi.begin(_wifi_ssid, _wifi_password); + } + + void select(NetworkMedium medium) { + if (medium == NetworkMedium::Ethernet) { + // ESP-IDF gives Wi-Fi a higher default-route priority than Ethernet. + // Keep only the selected STA associated so sockets cannot silently stay + // on Wi-Fi after the manager has declared Ethernet active. + WiFi.setAutoReconnect(false); + WiFi.disconnect(false, false); + _wifi_started = false; + } + _selected = medium; + _selected_down_since = 0; + } + + public: + const char* mediumName() const override { + if (_selected == NetworkMedium::Ethernet) return "ethernet"; + if (_selected == NetworkMedium::WiFi) return "wifi"; + return "none"; + } + NetworkMedium medium() const override { return _selected; } + const char* statusName() const override { + return _selected == NetworkMedium::None ? "not_selected" + : selectedInterface().statusName(); + } + int statusCode() const override { + return _selected == NetworkMedium::None ? 0 : selectedInterface().statusCode(); + } + bool configValid(const char* wifi_ssid) const override { + // Hardware availability and stored credentials are configuration. Current + // link/DHCP state is runtime state and must not permanently suppress the + // MQTT task that monitors for a late cable or lease. + return _ethernet_started || (wifi_ssid && wifi_ssid[0] != '\0'); + } + bool isAutomatic() const override { return true; } + + bool begin(const char* wifi_ssid, const char* wifi_password) override { + rememberWifi(wifi_ssid, wifi_password); + if (!_ethernet_started) { + _ethernet_started = _ethernet.begin(nullptr, nullptr); + } + // bootstrap() owns the initial choice. MQTT begin() is intentionally + // idempotent and cannot demote a boot-selected Ethernet link because of a + // momentary status sample between tasks. + if (_selected == NetworkMedium::None) { + if (_ethernet.isConnected()) { + select(NetworkMedium::Ethernet); + } else if (wifiConfigured()) { + startWifiFallback(); + _selected = NetworkMedium::WiFi; + } + } else if (_selected == NetworkMedium::WiFi) { + startWifiFallback(); + } + return _ethernet_started || _wifi_started; + } + + bool bootstrap(const char* wifi_ssid, const char* wifi_password, + uint32_t wait_ms) override { + rememberWifi(wifi_ssid, wifi_password); + if (!_ethernet_started) { + _ethernet_started = _ethernet.begin(nullptr, nullptr); + } + + const uint32_t started_at = millis(); + const uint32_t link_wait_ms = wait_ms < 1500 ? wait_ms : 1500; + while (_ethernet_started && !CH390.linkUp() && + (uint32_t)(millis() - started_at) < link_wait_ms) { + delay(25); + } + while (_ethernet_started && CH390.linkUp() && !_ethernet.isConnected() && + (uint32_t)(millis() - started_at) < wait_ms) { + delay(25); + } + + const NetworkMedium initial = NetworkPolicy::bootSelection( + _ethernet.isConnected(), wifiConfigured()); + if (initial == NetworkMedium::Ethernet) { + select(initial); + } else { + startWifiFallback(); + _selected = initial; + } + return initial != NetworkMedium::None; + } + + NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) override { + const NetworkTransition ethernet_transition = + _ethernet.maintain(now_ms, wifi_power_save); + const NetworkTransition wifi_transition = _wifi_started + ? _wifi.maintain(now_ms, wifi_power_save) + : NetworkTransition::None; + + // Ethernet may recover while a Wi-Fi fallback is still associating. In + // that sequence the selected enum never changes, but Wi-Fi would win + // ESP-IDF's default-route priority once it came up. Tear the unused STA + // down even without a selection edge, and force MQTT to reconnect if it + // had already become reachable. + if (_selected == NetworkMedium::Ethernet && _ethernet.isConnected() && + _wifi_started) { + const bool wifi_had_route = _wifi.isConnected(); + select(NetworkMedium::Ethernet); + if (wifi_had_route) return NetworkTransition::Switched; + } + + if (_ethernet.isConnected()) { + if (_ethernet_stable_since == 0) _ethernet_stable_since = now_ms; + } else { + _ethernet_stable_since = 0; + } + + const bool selected_connected = isConnected(); + if (!selected_connected) { + if (_selected_down_since == 0) _selected_down_since = now_ms; + } else { + _selected_down_since = 0; + } + + const uint32_t selected_down_ms = _selected_down_since == 0 + ? 0 : (uint32_t)(now_ms - _selected_down_since); + if (_selected == NetworkMedium::Ethernet && !_ethernet.isConnected() && + selected_down_ms >= NetworkPolicy::kEthernetDownGraceMs) { + startWifiFallback(); + } + + const uint32_t ethernet_stable_ms = _ethernet_stable_since == 0 + ? 0 : (uint32_t)(now_ms - _ethernet_stable_since); + const NetworkPolicy::AutomaticSelectionInput input = { + _selected, _ethernet.isConnected(), + _wifi_started && _wifi.isConnected(), wifiConfigured(), + _switch_locks.load(std::memory_order_relaxed) != 0, + ethernet_stable_ms, selected_down_ms}; + const NetworkMedium next = NetworkPolicy::automaticSelection(input); + + if (next != _selected) { + const NetworkMedium previous = _selected; + select(next); + return previous == NetworkMedium::None ? NetworkTransition::Up + : NetworkTransition::Switched; + } + + if (_selected == NetworkMedium::Ethernet) return ethernet_transition; + if (_selected == NetworkMedium::WiFi) return wifi_transition; + return NetworkTransition::None; + } + + void lockSwitching() override { + _switch_locks.fetch_add(1, std::memory_order_relaxed); + } + void unlockSwitching() override { + uint8_t value = _switch_locks.load(std::memory_order_relaxed); + while (value != 0 && !_switch_locks.compare_exchange_weak( + value, static_cast(value - 1), + std::memory_order_relaxed, std::memory_order_relaxed)) {} + } + + bool isConnected() const override { + return _selected != NetworkMedium::None && selectedInterface().isConnected(); + } + IPAddress localIP() const override { + return _selected == NetworkMedium::None ? IPAddress() : selectedInterface().localIP(); + } + int rssi() const override { + return _selected == NetworkMedium::None ? INT_MIN : selectedInterface().rssi(); + } + bool resolveHost(const char* hostname, IPAddress& address) const override { + return _selected != NetworkMedium::None && + selectedInterface().resolveHost(hostname, address); + } + unsigned long connectedAtMillis() const override { + return _selected == NetworkMedium::None ? 0 : selectedInterface().connectedAtMillis(); + } + uint8_t lastDisconnectReason() const override { + return _selected == NetworkMedium::None ? 0 : selectedInterface().lastDisconnectReason(); + } + unsigned long lastDisconnectTime() const override { + return _selected == NetworkMedium::None ? 0 : selectedInterface().lastDisconnectTime(); + } + AlertFaultPolicy::OutageSnapshot outageSnapshot() const override { + return _selected == NetworkMedium::None + ? AlertFaultPolicy::OutageSnapshot{false, 0, 0} + : selectedInterface().outageSnapshot(); + } +}; #endif } // namespace NetworkInterface& activeNetworkInterface() { -#if defined(NETWORK_USE_ETHERNET) - static EthernetNetworkInterface network; +#if defined(NETWORK_PREFER_ETHERNET) + static AutomaticNetworkInterface network; #else static WiFiNetworkInterface network; #endif diff --git a/src/helpers/NetworkInterface.h b/src/helpers/NetworkInterface.h index a4e631a1c..a5e152748 100644 --- a/src/helpers/NetworkInterface.h +++ b/src/helpers/NetworkInterface.h @@ -8,6 +8,10 @@ #include #include "AlertFaultPolicy.h" +#ifndef NETWORK_ETHERNET_BOOT_WAIT_MS +#define NETWORK_ETHERNET_BOOT_WAIT_MS 8000UL +#endif + /** * Physical network selected for IP-based services. * @@ -21,12 +25,27 @@ class NetworkInterface { virtual ~NetworkInterface() = default; virtual const char* mediumName() const = 0; + virtual NetworkMedium medium() const = 0; virtual const char* statusName() const = 0; virtual int statusCode() const = 0; virtual bool configValid(const char* wifi_ssid) const = 0; virtual bool begin(const char* wifi_ssid, const char* wifi_password) = 0; virtual NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) = 0; + // Automatic selectors are boot-owned so first-run services can use the + // chosen link before MQTT starts. Compatibility Wi-Fi builds keep the + // original MQTT-task-owned begin() path. + virtual bool isAutomatic() const { return false; } + virtual bool bootstrap(const char* wifi_ssid, const char* wifi_password, + uint32_t wait_ms) { + (void)wait_ms; + return begin(wifi_ssid, wifi_password); + } + + // WebConfig and OTA pin the current route for the lifetime of their session. + virtual void lockSwitching() {} + virtual void unlockSwitching() {} + virtual bool isConnected() const = 0; virtual IPAddress localIP() const = 0; virtual int rssi() const = 0; // INT_MIN when the selected medium has no RSSI. diff --git a/src/helpers/NetworkPolicy.h b/src/helpers/NetworkPolicy.h index 731ed349c..fe434a031 100644 --- a/src/helpers/NetworkPolicy.h +++ b/src/helpers/NetworkPolicy.h @@ -6,6 +6,13 @@ enum class NetworkTransition : uint8_t { None, Up, Down, + Switched, +}; + +enum class NetworkMedium : uint8_t { + None, + Ethernet, + WiFi, }; namespace NetworkPolicy { @@ -17,11 +24,72 @@ struct MQTTTransitionActions { static constexpr MQTTTransitionActions mqttActions(NetworkTransition transition) { return { - transition == NetworkTransition::Down, - transition == NetworkTransition::Up, + transition == NetworkTransition::Down || transition == NetworkTransition::Switched, + transition == NetworkTransition::Up || transition == NetworkTransition::Switched, }; } +struct AutomaticSelectionInput { + NetworkMedium selected; + bool ethernet_connected; + bool wifi_connected; + bool wifi_configured; + bool switching_locked; + uint32_t ethernet_stable_ms; + uint32_t selected_down_ms; +}; + +// Keep short link flaps from bouncing the default route. Ethernet is allowed +// to recover before Wi-Fi is started, and must then remain usable before it can +// preempt a working Wi-Fi fallback. +static constexpr uint32_t kEthernetDownGraceMs = 3000; +static constexpr uint32_t kEthernetFailbackStableMs = 10000; +static constexpr uint32_t kNtpRetryMs = 30000; + +static constexpr bool ntpPendingAfterConnectivitySample( + bool synced, bool pending, bool was_connected, bool connected) { + return pending || (!synced && connected && !was_connected); +} + +static constexpr bool ntpRetryDue(bool synced, bool connected, + uint32_t now_ms, uint32_t last_attempt_ms) { + return !synced && connected && + (uint32_t)(now_ms - last_attempt_ms) >= kNtpRetryMs; +} + +static constexpr NetworkMedium bootSelection(bool ethernet_connected, + bool wifi_configured) { + return ethernet_connected ? NetworkMedium::Ethernet + : wifi_configured ? NetworkMedium::WiFi + : NetworkMedium::None; +} + +static inline NetworkMedium automaticSelection( + const AutomaticSelectionInput& input) { + if (input.switching_locked) return input.selected; + + if (input.selected == NetworkMedium::Ethernet) { + if (input.ethernet_connected) return NetworkMedium::Ethernet; + if (input.wifi_configured && input.wifi_connected && + input.selected_down_ms >= kEthernetDownGraceMs) { + return NetworkMedium::WiFi; + } + return NetworkMedium::Ethernet; + } + + if (input.selected == NetworkMedium::WiFi) { + if (input.ethernet_connected && + input.ethernet_stable_ms >= kEthernetFailbackStableMs) { + return NetworkMedium::Ethernet; + } + return NetworkMedium::WiFi; + } + + if (input.ethernet_connected) return NetworkMedium::Ethernet; + if (input.wifi_configured && input.wifi_connected) return NetworkMedium::WiFi; + return NetworkMedium::None; +} + // `start ota` uses the selected LAN only when reachable and not explicitly // forced to SoftAP. Manifest OTA has no fallback and checks connectivity itself. static constexpr bool startOtaUsesSelectedNetwork(bool force_ap, diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 8b48401f9..12bf9a30e 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1252,6 +1252,9 @@ void MQTTBridge::mqttTaskLoop() { // Wait a bit for WiFi to start connecting vTaskDelay(pdMS_TO_TICKS(1000)); + bool network_was_connected = _network->isConnected(); + unsigned long last_ntp_attempt = 0; + // Main task loop #ifdef MQTT_MEMORY_DEBUG static unsigned long last_agent_log = 0; @@ -1315,23 +1318,26 @@ void MQTTBridge::mqttTaskLoop() { } } - // A connected observation is enough to schedule NTP; physical event callbacks - // stay encapsulated in the selected network adapter. - if (!_ntp_synced && _network->isConnected() && !_ntp_sync_pending) { - _ntp_sync_pending = true; - } - if (_ntp_sync_pending && _network->isConnected()) { + // Schedule once per link-up edge. Failed syncs are owned by the 30-second + // retry below; re-arming here on every loop would make a blocked NTP path + // run the multi-second sync sequence back-to-back forever. + const bool network_connected = _network->isConnected(); + _ntp_sync_pending = NetworkPolicy::ntpPendingAfterConnectivitySample( + _ntp_synced, _ntp_sync_pending, network_was_connected, + network_connected); + network_was_connected = network_connected; + if (_ntp_sync_pending && network_connected) { _ntp_sync_pending = false; + last_ntp_attempt = now; syncTimeWithNTP(); } // Retry NTP every 30s if initial sync failed (slots can't start without valid time) - if (!_ntp_synced && _network->isConnected()) { - static unsigned long last_ntp_retry = 0; - if (now - last_ntp_retry >= 30000) { - last_ntp_retry = now; - syncTimeWithNTP(); - } + if (NetworkPolicy::ntpRetryDue( + _ntp_synced, network_connected, (uint32_t)now, + (uint32_t)last_ntp_attempt)) { + last_ntp_attempt = now; + syncTimeWithNTP(); } // Process a CLI-requested forced NTP sync (queued from Core 1). Running it here diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 34bdca4b7..37740e8c3 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -172,17 +173,28 @@ bool WebConfigServer::isRebootPending() { bool WebConfigServer::getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len) { WebConfigServer* w = _active; - if (w == NULL || w->_mode != MODE_SETUP || w->_stopping) return false; + if (w == NULL || w->_stopping || + (w->_mode != MODE_SETUP && !(w->_mode == MODE_LAN && w->_initial_setup))) { + return false; + } if (ssid && ssid_len > 0) { - strncpy(ssid, w->_ap_ssid, ssid_len - 1); + const char* label = w->_mode == MODE_LAN ? w->_setup_code : w->_ap_ssid; + strncpy(ssid, label, ssid_len - 1); ssid[ssid_len - 1] = 0; } if (ip && ip_len > 0) { - snprintf(ip, ip_len, "%s", WiFi.softAPIP().toString().c_str()); + const IPAddress address = w->_mode == MODE_LAN + ? activeNetworkInterface().localIP() : WiFi.softAPIP(); + snprintf(ip, ip_len, "%s", address.toString().c_str()); } return true; } +bool WebConfigServer::isLanSetup() { + WebConfigServer* w = _active; + return w != NULL && !w->_stopping && w->_initial_setup && w->_mode == MODE_LAN; +} + // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- @@ -223,8 +235,10 @@ bool WebConfigServer::startSetupMode(char reply[]) { _dns->start(53, "*", ip); // captive portal: every name resolves to us _mode = MODE_SETUP; - _initial_setup = (_obs->wifi_ssid[0] == 0); + _initial_setup = !mqttNetworkSetupComplete(_obs); createServer(); + activeNetworkInterface().lockSwitching(); + _network_locked = true; _was_setup_ap = true; _last_activity = millis(); WiFi.scanNetworks(true); // pre-populate the SSID picker @@ -233,21 +247,42 @@ bool WebConfigServer::startSetupMode(char reply[]) { return true; } -bool WebConfigServer::startLanMode(char reply[]) { +bool WebConfigServer::startLanMode(IPAddress ip, bool initial_setup, char reply[]) { if (_mode != MODE_OFF || _stopping) { strcpy(reply, "Err: webconfig busy"); return false; } - if (WiFi.status() != WL_CONNECTED) { - strcpy(reply, "Err: WiFi not connected"); + activeNetworkInterface().lockSwitching(); + _network_locked = true; + if (!activeNetworkInterface().isConnected() || ip == IPAddress()) { + activeNetworkInterface().unlockSwitching(); + _network_locked = false; + strcpy(reply, "Err: selected network not connected"); return false; } + _initial_setup = initial_setup; + if (_initial_setup) { + for (int i = 0; i < 3; ++i) { + sprintf(&_setup_code[i * 4], "%04X", (unsigned)(esp_random() & 0xffff)); + } + _setup_code[12] = 0; + } else { + _setup_code[0] = 0; + } _mode = MODE_LAN; createServer(); _last_activity = millis(); - int pos = sprintf(reply, "WebConfig started: http://%s/ (admin password login)", - WiFi.localIP().toString().c_str()); + int pos; + if (_initial_setup) { + pos = sprintf(reply, "WebConfig Ethernet setup: http://%s/ code %s", + ip.toString().c_str(), _setup_code); + _setup_reminder_at = millis() + 60000; + if (_setup_reminder_at == 0) _setup_reminder_at = 1; + } else { + pos = sprintf(reply, "WebConfig started: http://%s/ (admin password login)", + ip.toString().c_str()); + } if (heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL) < 60 * 1024) { sprintf(reply + pos, " WARN: low heap"); } @@ -317,8 +352,8 @@ void WebConfigServer::finalizeTeardown() { _dns = NULL; if (_was_setup_ap) { WiFi.softAPdisconnect(true); - // Nothing else owns WiFi when we raised the AP: either the node is - // unconfigured, or `start webconfig ap` required the bridge stopped. + // A Wi-Fi-selected bridge must be stopped before a forced AP. An + // Ethernet-selected bridge can remain up because it does not own the STA. if (_obs->wifi_ssid[0] == 0) { WiFi.mode(WIFI_OFF); } else { @@ -336,6 +371,12 @@ void WebConfigServer::finalizeTeardown() { _batch_reboot_armed = false; _session_token[0] = 0; _stats_json[0] = 0; + _setup_code[0] = 0; + _setup_reminder_at = 0; + if (_network_locked) { + activeNetworkInterface().unlockSwitching(); + _network_locked = false; + } if (_cb) _cb->onWebConfigStopped(); } @@ -358,6 +399,15 @@ void WebConfigServer::tick(uint32_t now) { } if (_mode == MODE_OFF) return; + if (_mode == MODE_LAN && _initial_setup && _setup_reminder_at != 0 && + (int32_t)(now - _setup_reminder_at) >= 0) { + Serial.printf("WC: Ethernet setup http://%s/ code %s\n", + activeNetworkInterface().localIP().toString().c_str(), + _setup_code); + _setup_reminder_at = now + 60000; + if (_setup_reminder_at == 0) _setup_reminder_at = 1; + } + if (_dns) _dns->processNextRequest(); if (_batch_state == BATCH_PENDING) drainBatch(now); @@ -459,6 +509,15 @@ void WebConfigServer::drainBatch(uint32_t now) { return; // more commands next tick } } + if (_initial_setup && _admin_pwd_set && _batch_all_ok && + (_mode == MODE_LAN || _obs->wifi_ssid[0] != '\0')) { + if (_cb->onInitialSetupComplete()) { + _initial_setup = false; + _setup_code[0] = 0; + } else { + _batch_all_ok = false; + } + } _cb->onConfigBatchEnd(); WCLock lock(_mux); _batch_state = BATCH_DONE; @@ -611,7 +670,7 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) { DynamicJsonDocument doc(512); doc["mode"] = (_mode == MODE_SETUP) ? "setup" : "lan"; doc["auth"] = authed; - doc["needs_setup"] = (_obs->wifi_ssid[0] == 0); + doc["needs_setup"] = !mqttNetworkSetupComplete(_obs); doc["name"] = (const char*)_prefs->node_name; char node_id[17]; for (int i = 0; i < 8; i++) sprintf(&node_id[i * 2], "%02x", _pub_key[i]); @@ -657,7 +716,10 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) { return; } const char* pwd = doc["password"] | ""; - if (!fixedTimeEquals(pwd, _prefs->password, sizeof(_prefs->password))) { + const char* expected = _initial_setup ? _setup_code : _prefs->password; + const size_t expected_size = _initial_setup ? sizeof(_setup_code) + : sizeof(_prefs->password); + if (!fixedTimeEquals(pwd, expected, expected_size)) { if (++_login_fails >= 5) { _login_lock_until = now + 30000; if (_login_lock_until == 0) _login_lock_until = 1; @@ -836,7 +898,7 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) { // First onboarding is not complete until the known factory password has // been replaced. Enforce this server-side so the Advanced editor or a crafted // request cannot save WiFi and strand the node with the default password. - if (_mode == MODE_SETUP && _initial_setup && !set.containsKey("password") && + if (_initial_setup && !set.containsKey("password") && (reboot_after || set.containsKey("wifi.ssid"))) { req->send(400, "application/json", "{\"error\":\"admin password required for initial setup\"}"); return; @@ -1148,7 +1210,7 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) { // LAN still holding the factory password is a known credential on someone // else's network. The terminal warned about this client-side, which is a // reminder, not a rule — a pasted script or a direct POST ignored it. - if (_mode == MODE_SETUP && _initial_setup && !seq_sets_pwd && !_admin_pwd_set && + if (_initial_setup && !seq_sets_pwd && !_admin_pwd_set && (defer_reboot || seq_sets_ssid)) { req->send(400, "application/json", "{\"error\":\"admin password required for initial setup — " diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index 0dcebebd4..79cb167bd 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -6,8 +6,9 @@ // - 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. -// - LAN: bound to the existing STA connection (owned by the MQTT bridge -// task), started via `start webconfig`, admin-password login required. +// - LAN: bound to the selected network, started via `start webconfig`, +// admin-password login required. First-run Ethernet uses a random one-time +// login code and requires the operator to replace the admin password. // // Concurrency model: AsyncWebServer handlers run on the async_tcp task and // must never touch the CLI, prefs persistence, or the radio. Config writes @@ -57,6 +58,9 @@ public: // `set` handlers can be coalesced into one. virtual void onConfigBatchStart() {} virtual void onConfigBatchEnd() {} + // Persist completion of Ethernet onboarding after its password-setting + // batch succeeds. Called from the loop task. + virtual bool onInitialSetupComplete() { return false; } // Fill buf with the stats JSON snapshot. Called from tick() (loop task). virtual void buildStatsJson(char* buf, size_t buf_size) = 0; // Teardown finished (session + DNS freed, WiFi mode restored). @@ -72,6 +76,7 @@ public: // Fills the AP SSID and portal IP; either buffer may be NULL to just poll. // Call from the loop task only (same task that changes the mode). static bool getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len); + static bool isLanSetup(); // For the device display: true once a config save completed and the node is // about to reboot — ground truth for the user even if the browser lost its @@ -79,7 +84,7 @@ public: static bool isRebootPending(); bool startSetupMode(char reply[]); // open SoftAP + DNS captive portal - bool startLanMode(char reply[]); // bind to existing STA connection + bool startLanMode(IPAddress ip, bool initial_setup, char reply[]); void requestStop(); // stop listening and detach this session void tick(uint32_t now); // call every loop iteration @@ -136,11 +141,13 @@ private: bool _stopping = false; bool _was_setup_ap = false; bool _initial_setup = false; + bool _network_locked = false; // A `password` command has succeeded this session. Lets the CLI satisfy the // initial-setup invariant across separate submissions; the form batch always // sends the password with the rest, so it never needed the memory. bool _admin_pwd_set = false; char _ap_ssid[33] = {0}; + char _setup_code[13] = {0}; // Currently attached session, also used by the display's setup-info poll. static WebConfigServer* _active; @@ -166,6 +173,7 @@ private: // LAN-mode session (single slot; new login evicts the old session) char _session_token[33] = {0}; uint32_t _session_last_seen = 0; + uint32_t _setup_reminder_at = 0; uint8_t _login_fails = 0; uint32_t _login_lock_until = 0; diff --git a/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp b/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp index 421ea52b0..3427bb600 100644 --- a/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp +++ b/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp @@ -113,6 +113,7 @@ TEST(MQTTPrefsSerializer, RoundTripsEveryGroupAndNumericSlotKeys) { source.snmp_enabled = 1; source.radio_watchdog_minutes = 120; source.alert_enabled = 1; + source.network_setup_complete = 1; strcpy(source.alert_psk_hex, "0123456789abcdef0123456789abcdef"); strcpy(source.alert_hashtag, "#ops"); strcpy(source.alert_region, "PNW"); @@ -137,6 +138,19 @@ TEST(MQTTPrefsSerializer, RoundTripsEveryGroupAndNumericSlotKeys) { EXPECT_EQ(0x8001, loaded.mqtt_slot_packet_filter[5]); EXPECT_EQ(MQTT_NEIGHBORS_MAX_INTERVAL_MS, loaded.mqtt_neighbors_interval); EXPECT_STREQ("PNW", loaded.alert_region); + EXPECT_EQ(1, loaded.network_setup_complete); +} + +TEST(MQTTPrefsSerializer, NetworkOnboardingSupportsWifiAndEthernetOnlyInstalls) { + MQTTPrefs prefs = defaults(); + EXPECT_FALSE(mqttNetworkSetupComplete(&prefs)); + + strcpy(prefs.wifi_ssid, "existing-install"); + EXPECT_TRUE(mqttNetworkSetupComplete(&prefs)); + + prefs.wifi_ssid[0] = 0; + prefs.network_setup_complete = 1; + EXPECT_TRUE(mqttNetworkSetupComplete(&prefs)); } TEST(MQTTPrefsSerializer, MissingOptionalKeysKeepDefaults) { diff --git a/test/test_network_policy/test_network_policy.cpp b/test/test_network_policy/test_network_policy.cpp index 201c7451f..93e0a1f2a 100644 --- a/test/test_network_policy/test_network_policy.cpp +++ b/test/test_network_policy/test_network_policy.cpp @@ -20,6 +20,62 @@ TEST(NetworkPolicy, NoTransitionHasNoMqttSideEffects) { EXPECT_FALSE(actions.retry_disconnected_slots_now); } +TEST(NetworkPolicy, LinkSwitchReconnectsMqttSlotsImmediately) { + const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Switched); + EXPECT_TRUE(actions.disconnect_slots); + EXPECT_TRUE(actions.retry_disconnected_slots_now); +} + +TEST(NetworkPolicy, BootPrefersEthernetAndOtherwiseUsesConfiguredWifi) { + EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::bootSelection(true, true)); + EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::bootSelection(false, true)); + EXPECT_EQ(NetworkMedium::None, NetworkPolicy::bootSelection(false, false)); +} + +TEST(NetworkPolicy, EthernetFailureWaitsForGraceAndConnectedWifi) { + NetworkPolicy::AutomaticSelectionInput input = { + NetworkMedium::Ethernet, false, true, true, false, 0, + NetworkPolicy::kEthernetDownGraceMs - 1}; + EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::automaticSelection(input)); + input.selected_down_ms = NetworkPolicy::kEthernetDownGraceMs; + EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::automaticSelection(input)); +} + +TEST(NetworkPolicy, FailbackRequiresStableEthernet) { + NetworkPolicy::AutomaticSelectionInput input = { + NetworkMedium::WiFi, true, true, true, false, + NetworkPolicy::kEthernetFailbackStableMs - 1, 0}; + EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::automaticSelection(input)); + input.ethernet_stable_ms = NetworkPolicy::kEthernetFailbackStableMs; + EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::automaticSelection(input)); +} + +TEST(NetworkPolicy, SwitchingLockPinsTheCurrentMedium) { + const NetworkPolicy::AutomaticSelectionInput input = { + NetworkMedium::WiFi, true, true, true, true, + NetworkPolicy::kEthernetFailbackStableMs, 0}; + EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::automaticSelection(input)); +} + +TEST(NetworkPolicy, NtpIsScheduledOncePerConnectivityEdge) { + EXPECT_TRUE(NetworkPolicy::ntpPendingAfterConnectivitySample( + false, false, false, true)); + EXPECT_FALSE(NetworkPolicy::ntpPendingAfterConnectivitySample( + false, false, true, true)); + EXPECT_TRUE(NetworkPolicy::ntpPendingAfterConnectivitySample( + false, true, true, true)); + EXPECT_FALSE(NetworkPolicy::ntpPendingAfterConnectivitySample( + true, false, false, true)); +} + +TEST(NetworkPolicy, FailedNtpSyncWaitsForThirtySecondRetryBoundary) { + EXPECT_FALSE(NetworkPolicy::ntpRetryDue(false, true, 29999, 0)); + EXPECT_TRUE(NetworkPolicy::ntpRetryDue(false, true, 30000, 0)); + EXPECT_FALSE(NetworkPolicy::ntpRetryDue(true, true, 60000, 0)); + EXPECT_FALSE(NetworkPolicy::ntpRetryDue(false, false, 60000, 0)); + EXPECT_TRUE(NetworkPolicy::ntpRetryDue(false, true, 10, 0xffff8000u)); +} + TEST(NetworkPolicy, StartOtaUsesReachableSelectedNetworkByDefault) { EXPECT_TRUE(NetworkPolicy::startOtaUsesSelectedNetwork(false, true)); EXPECT_FALSE(NetworkPolicy::startOtaUsesSelectedNetwork(false, false)); diff --git a/variants/thinknode_m7/platformio.ini b/variants/thinknode_m7/platformio.ini index 2ccf6f5c6..890faee25 100644 --- a/variants/thinknode_m7/platformio.ini +++ b/variants/thinknode_m7/platformio.ini @@ -190,7 +190,7 @@ build_src_filter = ${ThinkNode_M7.build_src_filter} +<../examples/kiss_modem/> ; Wi-Fi remains the default observer transport. Ethernet twins below select the -; onboard CH390 through NETWORK_USE_ETHERNET without enabling the legacy CLI +; onboard CH390 through the Ethernet-preferred network manager without enabling the legacy CLI ; transport. The board has PSRAM, so MAX_NEIGHBOURS enables ; WITH_MQTT_NEIGHBORS (see MQTTBridge.h). [env:ThinkNode_M7_repeater_observer_mqtt] @@ -242,7 +242,7 @@ extends = env:ThinkNode_M7_repeater_observer_mqtt build_flags = ${env:ThinkNode_M7_repeater_observer_mqtt.build_flags} ${ThinkNode_M7_ch390.build_flags} - -D NETWORK_USE_ETHERNET=1 + -D NETWORK_PREFER_ETHERNET=1 build_src_filter = ${env:ThinkNode_M7_repeater_observer_mqtt.build_src_filter} lib_deps = ${env:ThinkNode_M7_repeater_observer_mqtt.lib_deps} @@ -298,7 +298,7 @@ extends = env:ThinkNode_M7_room_server_observer_mqtt build_flags = ${env:ThinkNode_M7_room_server_observer_mqtt.build_flags} ${ThinkNode_M7_ch390.build_flags} - -D NETWORK_USE_ETHERNET=1 + -D NETWORK_PREFER_ETHERNET=1 build_src_filter = ${env:ThinkNode_M7_room_server_observer_mqtt.build_src_filter} lib_deps = ${env:ThinkNode_M7_room_server_observer_mqtt.lib_deps} diff --git a/webui/index.html b/webui/index.html index ab7b43d6a..a71bd1e4c 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1546,6 +1546,7 @@ var CLI_KEYS=[ ["wifi.ssid","WiFi network name",0], ["wifi.pwd","WiFi password",0], ["wifi.powersave","WiFi power-save mode",0,"none|min|max"], + ["link.status","Selected network, IP, signal and uptime",1], ["wifi.status","WiFi connection, IP, RSSI and uptime",1], ["mqtt.origin","Observer name in published messages",0], ["mqtt.iata","IATA region code used in topic paths",0], From 3e3eec8d5dc4fea55079edc90ce60215246eaa4e Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 2 Sep 2026 21:37:30 -0700 Subject: [PATCH 03/16] feat(mqtt): add link diagnostics and improve network logging --- MQTT_IMPLEMENTATION.md | 8 ++ examples/simple_repeater/MyMesh.cpp | 6 +- examples/simple_room_server/MyMesh.cpp | 6 +- scripts/webconfig_mock_server.py | 3 + src/helpers/CommonCLI_Observer.cpp | 2 + src/helpers/NetworkInterface.cpp | 117 ++++++++++++++++-- src/helpers/NetworkInterface.h | 1 + src/helpers/NetworkPolicy.h | 83 ++++++++++++- src/helpers/bridges/MQTTBridge.cpp | 58 ++++++--- .../test_network_policy.cpp | 62 +++++++++- webui/index.html | 1 + 11 files changed, 317 insertions(+), 30 deletions(-) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 774ff63a0..69fd7b582 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -81,6 +81,7 @@ reboot ```bash get wifi.ssid get link.status +get link.diag get bridge.enabled get mqtt.rx get mqtt.tx @@ -547,6 +548,7 @@ These settings apply across all MQTT slots: - `get wifi.ssid` - Get WiFi SSID - `get wifi.pwd` - Get WiFi password - `get link.status` - Get the selected network medium, connection status, IP, signal when available, and uptime +- `get link.diag` - Explain automatic Ethernet selection using controller initialization, link event, IP, WiFi fallback, route-lock, and reason state - `get wifi.status` - WiFi-only compatibility alias; reports n/a when another medium is selected - `get wifi.powersave` - Get WiFi power save mode (none/min/max) @@ -859,6 +861,12 @@ the radio actually performs in that case. ### Connection Handling - Automatic reconnection with exponential backoff per slot; a slot that stays down through the full backoff ladder is retried on a slow periodic probe instead of hammering the broker +- Ethernet-preferred builds wait the full configured boot probe window for an Ethernet IP; + delayed or unavailable PHY carrier reporting does not shorten the DHCP deadline. The boot + selection log includes the measured probe duration. +- Ethernet/WiFi transitions are logged. A lost or changed route stops every started MQTT + client, including one whose disconnect callback arrived first; once a usable route returns, + route-caused backoff is cleared and one immediate reconnect is allowed - Packets are queued while a slot is disconnected and flushed when it recovers ### Raw Radio Data Capture diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index cfbe35d8b..1d16acccf 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1152,10 +1152,12 @@ void MyMesh::begin(FILESYSTEM *fs) { MQTTPrefs* obs = _cli.getObserverPrefs(); Serial.printf("Network: probing Ethernet for up to %lums\n", (unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS); + const uint32_t ethernet_probe_started_at = millis(); boot_network.bootstrap(obs->wifi_ssid, obs->wifi_password, NETWORK_ETHERNET_BOOT_WAIT_MS); - Serial.printf("Network: selected %s (%s)\n", boot_network.mediumName(), - boot_network.statusName()); + Serial.printf("Network: selected %s (%s) after %lums\n", + boot_network.mediumName(), boot_network.statusName(), + (unsigned long)(millis() - ethernet_probe_started_at)); } acl.load(_fs, self_id); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 01b157ccc..386c36b40 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -953,10 +953,12 @@ void MyMesh::begin(FILESYSTEM *fs) { MQTTPrefs* obs = _cli.getObserverPrefs(); Serial.printf("Network: probing Ethernet for up to %lums\n", (unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS); + const uint32_t ethernet_probe_started_at = millis(); boot_network.bootstrap(obs->wifi_ssid, obs->wifi_password, NETWORK_ETHERNET_BOOT_WAIT_MS); - Serial.printf("Network: selected %s (%s)\n", boot_network.mediumName(), - boot_network.statusName()); + Serial.printf("Network: selected %s (%s) after %lums\n", + boot_network.mediumName(), boot_network.statusName(), + (unsigned long)(millis() - ethernet_probe_started_at)); } acl.load(_fs, self_id); diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index 547889a12..74c6b7302 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -500,6 +500,9 @@ GETTERS = { "link.status": lambda c: ( "wifi: connected, IP: 192.168.1.42, RSSI: -58 dBm, Uptime: %dm" % (int(time.time() - ST.start) // 60)), + "link.diag": lambda c: ( + "why:ethernet-not-enabled selected:wifi\n" + "wifi:state:connected ip:192.168.1.42"), "mqtt.status": lambda c: cli_mqtt_status(c), "mqtt.presets": lambda c: "\n".join( "%2d. %s%s" % (i + 1, n, "" if nd == "none" else " (needs %s)" % nd) diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 28ac768bf..273666bef 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -1038,6 +1038,8 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf } else { strcpy(reply, _mqtt_prefs.wifi_password[0] ? "> ******** (serial only)" : "> (not set)"); } + } else if (strcmp(config, "link.diag") == 0) { + activeNetworkInterface().formatDiagnostics(reply, 160); } else if (memcmp(config, "link.status", 11) == 0 || memcmp(config, "wifi.status", 11) == 0) { NetworkInterface& network = activeNetworkInterface(); diff --git a/src/helpers/NetworkInterface.cpp b/src/helpers/NetworkInterface.cpp index ae5273e3d..c22399a1f 100644 --- a/src/helpers/NetworkInterface.cpp +++ b/src/helpers/NetworkInterface.cpp @@ -199,12 +199,31 @@ class WiFiNetworkInterface final : public NetworkInterfaceBase { bool resolveHost(const char* hostname, IPAddress& address) const override { return WiFi.hostByName(hostname, address); } + void formatDiagnostics(char* reply, size_t reply_size) const override { + snprintf(reply, reply_size, + "> why:ethernet-not-enabled selected:wifi\n" + "wifi:state:%s ip:%s", + statusName(), + localIP().toString().c_str()); + } }; #if defined(NETWORK_PREFER_ETHERNET) class EthernetNetworkInterface final : public NetworkInterfaceBase { + public: + enum class EventState : uint8_t { + None, + Started, + LinkDown, + LinkUp, + GotIp, + Stopped, + }; + + private: bool _started = false; bool _event_registered = false; + std::atomic _event_state{static_cast(EventState::None)}; public: const char* mediumName() const override { return "ethernet"; } @@ -220,14 +239,30 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { if (!_event_registered) { WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t) { switch (event) { + case ARDUINO_EVENT_ETH_START: + _event_state.store(static_cast(EventState::Started), + std::memory_order_relaxed); + break; + case ARDUINO_EVENT_ETH_CONNECTED: + _event_state.store(static_cast(EventState::LinkUp), + std::memory_order_relaxed); + break; case ARDUINO_EVENT_ETH_GOT_IP: + _event_state.store(static_cast(EventState::GotIp), + std::memory_order_relaxed); noteConnected(millis()); break; case ARDUINO_EVENT_ETH_DISCONNECTED: + _event_state.store(static_cast(EventState::LinkDown), + std::memory_order_relaxed); // Ethernet has no 802.11 reason code; zero means unavailable. noteDisconnected(millis(), 0); _connected_at.store(0, std::memory_order_relaxed); break; + case ARDUINO_EVENT_ETH_STOP: + _event_state.store(static_cast(EventState::Stopped), + std::memory_order_relaxed); + break; default: break; } @@ -282,6 +317,49 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { // DNS follows the selected esp_netif even though this entry point is named WiFi. return WiFi.hostByName(hostname, address); } + void formatDiagnostics(char* reply, size_t reply_size) const override { + snprintf(reply, reply_size, "> ethernet:%s ip=%s", + statusName(), localIP().toString().c_str()); + } + + EventState eventState() const { + return static_cast( + _event_state.load(std::memory_order_relaxed)); + } + bool sampleLink(bool& known) const { + known = false; + if (!_started) return false; + + // IEEE 802.3 BMSR link status is latch-low. Read it twice so the second + // value is the current carrier state rather than a remembered link flap. + (void)CH390.readPHY(0x01); + const uint32_t bmsr = CH390.readPHY(0x01) & 0xffffu; + if (bmsr != 0 && bmsr != 0xffffu) { + known = true; + return (bmsr & (1u << 2)) != 0; + } + + // A failed/unsupported direct PHY read can still use the driver's events. + const EventState state = eventState(); + known = state == EventState::LinkDown || state == EventState::LinkUp || + state == EventState::GotIp || state == EventState::Stopped; + return state == EventState::LinkUp || state == EventState::GotIp; + } + bool linkUp() const { + bool known = false; + return sampleLink(known); + } + const char* eventName() const { + switch (eventState()) { + case EventState::None: return "none"; + case EventState::Started: return "started"; + case EventState::LinkDown: return "link-down"; + case EventState::LinkUp: return "link-up"; + case EventState::GotIp: return "got-ip"; + case EventState::Stopped: return "stopped"; + } + return "unknown"; + } }; class AutomaticNetworkInterface final : public NetworkInterface { @@ -385,13 +463,9 @@ class AutomaticNetworkInterface final : public NetworkInterface { } const uint32_t started_at = millis(); - const uint32_t link_wait_ms = wait_ms < 1500 ? wait_ms : 1500; - while (_ethernet_started && !CH390.linkUp() && - (uint32_t)(millis() - started_at) < link_wait_ms) { - delay(25); - } - while (_ethernet_started && CH390.linkUp() && !_ethernet.isConnected() && - (uint32_t)(millis() - started_at) < wait_ms) { + while (NetworkPolicy::ethernetBootProbePending( + _ethernet_started, _ethernet.isConnected(), + (uint32_t)(millis() - started_at), wait_ms)) { delay(25); } @@ -489,6 +563,35 @@ class AutomaticNetworkInterface final : public NetworkInterface { return _selected != NetworkMedium::None && selectedInterface().resolveHost(hostname, address); } + void formatDiagnostics(char* reply, size_t reply_size) const override { + const bool ethernet_connected = _ethernet.isConnected(); + bool ethernet_link_known = false; + bool ethernet_link_up = _ethernet.sampleLink(ethernet_link_known); + ethernet_link_known = ethernet_link_known || ethernet_connected; + ethernet_link_up = ethernet_link_up || ethernet_connected; + const bool switching_locked = + _switch_locks.load(std::memory_order_relaxed) != 0; + const uint32_t now_ms = millis(); + const uint32_t ethernet_stable_ms = _ethernet_stable_since == 0 + ? 0 : (uint32_t)(now_ms - _ethernet_stable_since); + const NetworkDiagnosticReason reason = + NetworkPolicy::automaticDiagnosticReason( + _ethernet_started, ethernet_link_known, ethernet_link_up, + ethernet_connected, _selected, switching_locked, + ethernet_stable_ms); + snprintf(reply, reply_size, + "> why:%s selected:%s lock:%s\n" + "eth:init:%s evt:%s link:%s ip:%s\n" + "wifi:cfg:%s started:%s link:%s", + NetworkPolicy::diagnosticReasonName(reason), mediumName(), + switching_locked ? "yes" : "no", + _ethernet_started ? "ok" : "failed", _ethernet.eventName(), + ethernet_link_known ? (ethernet_link_up ? "up" : "down") + : "unknown", + _ethernet.localIP().toString().c_str(), + wifiConfigured() ? "yes" : "no", _wifi_started ? "yes" : "no", + (_wifi_started && _wifi.isConnected()) ? "up" : "down"); + } unsigned long connectedAtMillis() const override { return _selected == NetworkMedium::None ? 0 : selectedInterface().connectedAtMillis(); } diff --git a/src/helpers/NetworkInterface.h b/src/helpers/NetworkInterface.h index a5e152748..646a73cec 100644 --- a/src/helpers/NetworkInterface.h +++ b/src/helpers/NetworkInterface.h @@ -50,6 +50,7 @@ class NetworkInterface { virtual IPAddress localIP() const = 0; virtual int rssi() const = 0; // INT_MIN when the selected medium has no RSSI. virtual bool resolveHost(const char* hostname, IPAddress& address) const = 0; + virtual void formatDiagnostics(char* reply, size_t reply_size) const = 0; virtual unsigned long connectedAtMillis() const = 0; virtual uint8_t lastDisconnectReason() const = 0; diff --git a/src/helpers/NetworkPolicy.h b/src/helpers/NetworkPolicy.h index fe434a031..5d083eb44 100644 --- a/src/helpers/NetworkPolicy.h +++ b/src/helpers/NetworkPolicy.h @@ -15,20 +15,39 @@ enum class NetworkMedium : uint8_t { WiFi, }; +enum class NetworkDiagnosticReason : uint8_t { + EthernetActive, + EthernetInitFailed, + EthernetLinkUnknown, + EthernetLinkDown, + EthernetAwaitingIp, + EthernetStabilizing, + SwitchingLocked, + EthernetReady, +}; + namespace NetworkPolicy { struct MQTTTransitionActions { - bool disconnect_slots; + bool stop_started_slots; bool retry_disconnected_slots_now; + bool reset_reconnect_backoff; }; static constexpr MQTTTransitionActions mqttActions(NetworkTransition transition) { return { transition == NetworkTransition::Down || transition == NetworkTransition::Switched, transition == NetworkTransition::Up || transition == NetworkTransition::Switched, + transition == NetworkTransition::Up || transition == NetworkTransition::Switched, }; } +static constexpr const char* mediumName(NetworkMedium medium) { + return medium == NetworkMedium::Ethernet ? "ethernet" + : medium == NetworkMedium::WiFi ? "wifi" + : "none"; +} + struct AutomaticSelectionInput { NetworkMedium selected; bool ethernet_connected; @@ -46,6 +65,58 @@ static constexpr uint32_t kEthernetDownGraceMs = 3000; static constexpr uint32_t kEthernetFailbackStableMs = 10000; static constexpr uint32_t kNtpRetryMs = 30000; +static inline NetworkDiagnosticReason automaticDiagnosticReason( + bool ethernet_initialized, bool ethernet_link_known, + bool ethernet_link_up, bool ethernet_connected, NetworkMedium selected, + bool switching_locked, uint32_t ethernet_stable_ms) { + if (!ethernet_initialized) { + return NetworkDiagnosticReason::EthernetInitFailed; + } + if (!ethernet_link_known) { + return NetworkDiagnosticReason::EthernetLinkUnknown; + } + if (!ethernet_link_up) { + return NetworkDiagnosticReason::EthernetLinkDown; + } + if (!ethernet_connected) { + return NetworkDiagnosticReason::EthernetAwaitingIp; + } + if (selected == NetworkMedium::Ethernet) { + return NetworkDiagnosticReason::EthernetActive; + } + if (switching_locked) { + return NetworkDiagnosticReason::SwitchingLocked; + } + if (selected == NetworkMedium::WiFi && + ethernet_stable_ms < kEthernetFailbackStableMs) { + return NetworkDiagnosticReason::EthernetStabilizing; + } + return NetworkDiagnosticReason::EthernetReady; +} + +static inline const char* diagnosticReasonName( + NetworkDiagnosticReason reason) { + switch (reason) { + case NetworkDiagnosticReason::EthernetActive: + return "ethernet-active"; + case NetworkDiagnosticReason::EthernetInitFailed: + return "ethernet-init-failed"; + case NetworkDiagnosticReason::EthernetLinkUnknown: + return "ethernet-link-unknown"; + case NetworkDiagnosticReason::EthernetLinkDown: + return "ethernet-link-down"; + case NetworkDiagnosticReason::EthernetAwaitingIp: + return "ethernet-awaiting-ip"; + case NetworkDiagnosticReason::EthernetStabilizing: + return "ethernet-stabilizing"; + case NetworkDiagnosticReason::SwitchingLocked: + return "switching-locked"; + case NetworkDiagnosticReason::EthernetReady: + return "ethernet-ready"; + } + return "unknown"; +} + static constexpr bool ntpPendingAfterConnectivitySample( bool synced, bool pending, bool was_connected, bool connected) { return pending || (!synced && connected && !was_connected); @@ -64,6 +135,16 @@ static constexpr NetworkMedium bootSelection(bool ethernet_connected, : NetworkMedium::None; } +// Once the Ethernet controller has initialized, allow the entire boot probe +// window for link negotiation and DHCP. PHY carrier is useful diagnostic data, +// but it must not shorten the advertised deadline when carrier reporting lags. +static constexpr bool ethernetBootProbePending(bool ethernet_initialized, + bool ethernet_connected, + uint32_t elapsed_ms, + uint32_t wait_ms) { + return ethernet_initialized && !ethernet_connected && elapsed_ms < wait_ms; +} + static inline NetworkMedium automaticSelection( const AutomaticSelectionInput& input) { if (input.switching_locked) return input.selected; diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 12bf9a30e..cfbe3ad51 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1307,16 +1307,7 @@ void MQTTBridge::mqttTaskLoop() { } #endif - bool network_just_connected = handleNetworkConnection(now); - if (network_just_connected) { - // The uplink recovered — reset last_reconnect_attempt for disconnected slots so they - // retry immediately rather than waiting up to 5 min for backoff timers to expire. - for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - if (_slots[i].enabled && _slots[i].initial_connect_done && !_slots[i].connected) { - _slots[i].last_reconnect_attempt = 0; - } - } - } + handleNetworkConnection(now); // Schedule once per link-up edge. Failed syncs are owned by the 30-second // retry below; re-arming here on every loop would make a blocked NTP path @@ -2745,19 +2736,58 @@ void MQTTBridge::checkConfigurationMismatch() { } bool MQTTBridge::handleNetworkConnection(unsigned long now) { + const NetworkMedium previous_medium = _network->medium(); const NetworkTransition transition = _network->maintain((uint32_t)now, _obs->wifi_power_save); + const NetworkMedium selected_medium = _network->medium(); + + if (transition == NetworkTransition::Down) { + MQTT_DEBUG_PRINTLN("Network: %s link down", NetworkPolicy::mediumName(previous_medium)); + } else if (transition == NetworkTransition::Up) { + MQTT_DEBUG_PRINTLN("Network: %s link up (%s, IP %s)", + NetworkPolicy::mediumName(selected_medium), + _network->statusName(), _network->localIP().toString().c_str()); + } else if (transition == NetworkTransition::Switched) { + MQTT_DEBUG_PRINTLN("Network: switched %s -> %s (%s, IP %s)", + NetworkPolicy::mediumName(previous_medium), + NetworkPolicy::mediumName(selected_medium), + _network->statusName(), _network->localIP().toString().c_str()); + } + const NetworkPolicy::MQTTTransitionActions actions = NetworkPolicy::mqttActions(transition); - if (actions.disconnect_slots) { + if (actions.stop_started_slots) { // Broker ownership stays in the bridge. The physical adapter reports the - // edge; the bridge explicitly closes every slot instead of waiting for - // eventual socket timeouts. + // edge; the bridge explicitly stops every started slot instead of waiting + // for eventual socket timeouts. Do not gate this on slot.connected: the + // ESP-MQTT disconnect callback can clear that flag before the network edge + // reaches this task, but its client task and transport can still be alive. for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - if (_slots[i].client && _slots[i].connected) { + if (_slots[i].client && _slots[i].client->isStarted()) { + MQTT_DEBUG_PRINTLN("MQTT%d stopping for network transition", i + 1); _slots[i].client->disconnect(); } + _slots[i].connected = false; + _slots[i].connected_at_ms = 0; } + updateCachedConnectionStatus(); + } + + if (actions.reset_reconnect_backoff) { + // A usable route is a new connection epoch. Failures earned on the old + // route must not strand the replacement route on the 5-minute rung or at + // the circuit breaker. Preserve JWTs and slot configuration, but give each + // disconnected active slot one immediate, freshly guarded attempt. + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].initial_connect_done && !_slots[i].connected) { + _slots[i].reconnect_backoff = 0; + _slots[i].max_backoff_failures = 0; + _slots[i].circuit_breaker_tripped = false; + _slots[i].last_reconnect_attempt = + now - MQTTConnectionPolicy::reconnectDelayMs(0, static_cast(i)); + } + } + _last_slot_reconnect_ms = now - MQTTConnectionPolicy::kReconnectGuardMs; } return actions.retry_disconnected_slots_now; } diff --git a/test/test_network_policy/test_network_policy.cpp b/test/test_network_policy/test_network_policy.cpp index 93e0a1f2a..b1131a14e 100644 --- a/test/test_network_policy/test_network_policy.cpp +++ b/test/test_network_policy/test_network_policy.cpp @@ -4,26 +4,36 @@ TEST(NetworkPolicy, MqttDownDisconnectsSlotsWithoutRequestingImmediateRetry) { const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Down); - EXPECT_TRUE(actions.disconnect_slots); + EXPECT_TRUE(actions.stop_started_slots); EXPECT_FALSE(actions.retry_disconnected_slots_now); + EXPECT_FALSE(actions.reset_reconnect_backoff); } TEST(NetworkPolicy, MqttUpRequestsImmediateRetryWithoutDisconnectingSlots) { const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Up); - EXPECT_FALSE(actions.disconnect_slots); + EXPECT_FALSE(actions.stop_started_slots); EXPECT_TRUE(actions.retry_disconnected_slots_now); + EXPECT_TRUE(actions.reset_reconnect_backoff); } TEST(NetworkPolicy, NoTransitionHasNoMqttSideEffects) { const auto actions = NetworkPolicy::mqttActions(NetworkTransition::None); - EXPECT_FALSE(actions.disconnect_slots); + EXPECT_FALSE(actions.stop_started_slots); EXPECT_FALSE(actions.retry_disconnected_slots_now); + EXPECT_FALSE(actions.reset_reconnect_backoff); } TEST(NetworkPolicy, LinkSwitchReconnectsMqttSlotsImmediately) { const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Switched); - EXPECT_TRUE(actions.disconnect_slots); + EXPECT_TRUE(actions.stop_started_slots); EXPECT_TRUE(actions.retry_disconnected_slots_now); + EXPECT_TRUE(actions.reset_reconnect_backoff); +} + +TEST(NetworkPolicy, MediumNamesAreStableForTransitionLogs) { + EXPECT_STREQ("none", NetworkPolicy::mediumName(NetworkMedium::None)); + EXPECT_STREQ("ethernet", NetworkPolicy::mediumName(NetworkMedium::Ethernet)); + EXPECT_STREQ("wifi", NetworkPolicy::mediumName(NetworkMedium::WiFi)); } TEST(NetworkPolicy, BootPrefersEthernetAndOtherwiseUsesConfiguredWifi) { @@ -32,6 +42,15 @@ TEST(NetworkPolicy, BootPrefersEthernetAndOtherwiseUsesConfiguredWifi) { EXPECT_EQ(NetworkMedium::None, NetworkPolicy::bootSelection(false, false)); } +TEST(NetworkPolicy, EthernetBootProbeHonorsFullDeadlineUntilConnected) { + EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(true, false, 0, 8000)); + EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(true, false, 1500, 8000)); + EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(true, false, 7999, 8000)); + EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(true, false, 8000, 8000)); + EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(true, true, 100, 8000)); + EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(false, false, 100, 8000)); +} + TEST(NetworkPolicy, EthernetFailureWaitsForGraceAndConnectedWifi) { NetworkPolicy::AutomaticSelectionInput input = { NetworkMedium::Ethernet, false, true, true, false, 0, @@ -57,6 +76,41 @@ TEST(NetworkPolicy, SwitchingLockPinsTheCurrentMedium) { EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::automaticSelection(input)); } +TEST(NetworkPolicy, DiagnosticsIdentifyEthernetFallbackCause) { + using Reason = NetworkDiagnosticReason; + EXPECT_EQ(Reason::EthernetInitFailed, + NetworkPolicy::automaticDiagnosticReason( + false, false, false, false, NetworkMedium::WiFi, false, 0)); + EXPECT_EQ(Reason::EthernetLinkUnknown, + NetworkPolicy::automaticDiagnosticReason( + true, false, false, false, NetworkMedium::WiFi, false, 0)); + EXPECT_EQ(Reason::EthernetLinkDown, + NetworkPolicy::automaticDiagnosticReason( + true, true, false, false, NetworkMedium::WiFi, false, 0)); + EXPECT_EQ(Reason::EthernetAwaitingIp, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, false, NetworkMedium::WiFi, false, 0)); +} + +TEST(NetworkPolicy, DiagnosticsExplainWhyReadyEthernetHasNotBeenSelected) { + using Reason = NetworkDiagnosticReason; + EXPECT_EQ(Reason::SwitchingLocked, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, true, NetworkMedium::WiFi, true, + NetworkPolicy::kEthernetFailbackStableMs)); + EXPECT_EQ(Reason::EthernetStabilizing, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, true, NetworkMedium::WiFi, false, + NetworkPolicy::kEthernetFailbackStableMs - 1)); + EXPECT_EQ(Reason::EthernetReady, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, true, NetworkMedium::WiFi, false, + NetworkPolicy::kEthernetFailbackStableMs)); + EXPECT_EQ(Reason::EthernetActive, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, true, NetworkMedium::Ethernet, false, 0)); +} + TEST(NetworkPolicy, NtpIsScheduledOncePerConnectivityEdge) { EXPECT_TRUE(NetworkPolicy::ntpPendingAfterConnectivitySample( false, false, false, true)); diff --git a/webui/index.html b/webui/index.html index a71bd1e4c..6a851c1f2 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1547,6 +1547,7 @@ var CLI_KEYS=[ ["wifi.pwd","WiFi password",0], ["wifi.powersave","WiFi power-save mode",0,"none|min|max"], ["link.status","Selected network, IP, signal and uptime",1], + ["link.diag","Ethernet selection and fallback diagnostics",1], ["wifi.status","WiFi connection, IP, RSSI and uptime",1], ["mqtt.origin","Observer name in published messages",0], ["mqtt.iata","IATA region code used in topic paths",0], From 88b57db2c63ee56ad6778ef573443faadbf3b525 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 3 Sep 2026 15:38:13 -0700 Subject: [PATCH 04/16] feat(mqtt): initialize shared network event runtime for Ethernet --- MQTT_IMPLEMENTATION.md | 2 ++ src/helpers/NetworkInterface.cpp | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 69fd7b582..f5a5f01e8 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -864,6 +864,8 @@ the radio actually performs in that case. - Ethernet-preferred builds wait the full configured boot probe window for an Ethernet IP; delayed or unavailable PHY carrier reporting does not shorten the DHCP deadline. The boot selection log includes the measured probe duration. +- CH390 startup initializes Arduino's shared network event runtime without associating WiFi; + this keeps the framework's DNS and TLS hostname paths safe when Ethernet wins directly. - Ethernet/WiFi transitions are logged. A lost or changed route stops every started MQTT client, including one whose disconnect callback arrived first; once a usable route returns, route-caused backoff is cleared and one immediate reconnect is allowed diff --git a/src/helpers/NetworkInterface.cpp b/src/helpers/NetworkInterface.cpp index c22399a1f..c8953aacb 100644 --- a/src/helpers/NetworkInterface.cpp +++ b/src/helpers/NetworkInterface.cpp @@ -13,6 +13,12 @@ #if defined(NETWORK_PREFER_ETHERNET) #include "ethernet/ch390/CH390Config.h" + +// Arduino-ESP32's built-in ETH implementation calls this before bringing up +// Ethernet. It creates the shared Arduino network event group/task used by +// WiFiGenericClass::hostByName(), even when no Wi-Fi interface is started. +// ESP32-CH390 initializes esp_netif directly and omits this Arduino layer. +extern void tcpipInit(); #endif namespace { @@ -236,6 +242,10 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { bool begin(const char*, const char*) override { if (_started) return true; + // DNS and WiFiClientSecure are transport-neutral sockets in this Arduino + // core, but their hostname path still uses WiFiGeneric's event group. + // Initialize that shared runtime without enabling or associating Wi-Fi. + tcpipInit(); if (!_event_registered) { WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t) { switch (event) { From 0c65fc79dbf6be03f9e174ae049939563abe4038 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 3 Sep 2026 17:16:30 -0700 Subject: [PATCH 05/16] feat(network): implement hostname configuration for automatic interfaces --- examples/simple_repeater/MyMesh.cpp | 6 ++ examples/simple_room_server/MyMesh.cpp | 6 ++ src/helpers/NetworkHostname.h | 101 ++++++++++++++++++ src/helpers/NetworkInterface.cpp | 24 ++++- src/helpers/NetworkInterface.h | 3 + src/helpers/ethernet/ch390/CH390Config.h | 24 ++++- test/README.md | 1 + .../test_network_hostname.cpp | 95 ++++++++++++++++ 8 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 src/helpers/NetworkHostname.h create mode 100644 test/test_network_hostname/test_network_hostname.cpp diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 1d16acccf..121183c46 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -4,6 +4,7 @@ #include // for qsort() #include #include +#include #if defined(ESP_PLATFORM) #include #endif @@ -1149,6 +1150,11 @@ void MyMesh::begin(FILESYSTEM *fs) { NetworkInterface& boot_network = activeNetworkInterface(); if (boot_network.isAutomatic()) { + char network_hostname[NetworkHostname::kBufferSize]; + NetworkHostname::build(network_hostname, sizeof(network_hostname), + _prefs.node_name, self_id.pub_key, PUB_KEY_SIZE); + boot_network.setHostname(network_hostname); + Serial.printf("Network: hostname %s\n", network_hostname); MQTTPrefs* obs = _cli.getObserverPrefs(); Serial.printf("Network: probing Ethernet for up to %lums\n", (unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 386c36b40..c0bd70158 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #if defined(ESP_PLATFORM) #include #endif @@ -950,6 +951,11 @@ void MyMesh::begin(FILESYSTEM *fs) { NetworkInterface& boot_network = activeNetworkInterface(); if (boot_network.isAutomatic()) { + char network_hostname[NetworkHostname::kBufferSize]; + NetworkHostname::build(network_hostname, sizeof(network_hostname), + _prefs.node_name, self_id.pub_key, PUB_KEY_SIZE); + boot_network.setHostname(network_hostname); + Serial.printf("Network: hostname %s\n", network_hostname); MQTTPrefs* obs = _cli.getObserverPrefs(); Serial.printf("Network: probing Ethernet for up to %lums\n", (unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS); diff --git a/src/helpers/NetworkHostname.h b/src/helpers/NetworkHostname.h new file mode 100644 index 000000000..d4dfe7c44 --- /dev/null +++ b/src/helpers/NetworkHostname.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include + +namespace NetworkHostname { + +// ESP-IDF documents a 32-byte hostname limit. Keep one byte for the trailing +// NUL so the same buffer is safe through Arduino and lwIP APIs. +static constexpr size_t kMaxLength = 31; +static constexpr size_t kBufferSize = kMaxLength + 1; + +static inline bool isAsciiAlphaNumeric(uint8_t ch) { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9'); +} + +static inline char asciiLower(uint8_t ch) { + return ch >= 'A' && ch <= 'Z' ? static_cast(ch + ('a' - 'A')) + : static_cast(ch); +} + +/** + * Build a DHCP-safe hostname from the MeshCore node name. + * + * The result is lowercase, starts with "meshcore-", contains only letters, + * digits and hyphens, and never exceeds ESP-IDF's practical 31-character + * payload limit. If the readable name must be shortened, six hex digits from + * the stable node identity are retained to reduce truncation collisions. + */ +static inline bool build(char* dest, size_t dest_size, const char* node_name, + const uint8_t* stable_id, size_t stable_id_size) { + if (!dest || dest_size == 0) return false; + dest[0] = '\0'; + + static constexpr char kPrefix[] = "meshcore-"; + static constexpr char kFallback[] = "node"; + static constexpr char kHex[] = "0123456789abcdef"; + static constexpr size_t kPrefixLength = sizeof(kPrefix) - 1; + static constexpr size_t kSuffixLength = 7; // '-' plus six hex digits. + + // NodePrefs::node_name currently holds at most 31 bytes. A larger scratch + // buffer keeps this helper safe and independently testable with other input. + char slug[64]; + size_t slug_length = 0; + bool separator_pending = false; + if (node_name) { + for (size_t i = 0; node_name[i] != '\0' && slug_length < sizeof(slug) - 1; + ++i) { + const uint8_t ch = static_cast(node_name[i]); + if (isAsciiAlphaNumeric(ch)) { + if (separator_pending && slug_length > 0 && + slug_length < sizeof(slug) - 1) { + slug[slug_length++] = '-'; + } + if (slug_length < sizeof(slug) - 1) { + slug[slug_length++] = asciiLower(ch); + } + separator_pending = false; + } else if (slug_length > 0) { + separator_pending = true; + } + } + } + + if (slug_length == 0) { + for (size_t i = 0; i < sizeof(kFallback) - 1; ++i) { + slug[slug_length++] = kFallback[i]; + } + } + slug[slug_length] = '\0'; + + size_t max_length = dest_size - 1; + if (max_length > kMaxLength) max_length = kMaxLength; + if (max_length == 0) return false; + + const bool needs_truncation = kPrefixLength + slug_length > max_length; + const bool can_add_identity = needs_truncation && stable_id && + stable_id_size >= 3 && max_length > kPrefixLength + kSuffixLength; + const size_t suffix_length = can_add_identity ? kSuffixLength : 0; + const size_t prefix_length = kPrefixLength < max_length + ? kPrefixLength : max_length; + size_t slug_budget = max_length - prefix_length - suffix_length; + if (slug_budget > slug_length) slug_budget = slug_length; + while (slug_budget > 0 && slug[slug_budget - 1] == '-') --slug_budget; + + size_t out = 0; + for (size_t i = 0; i < prefix_length; ++i) dest[out++] = kPrefix[i]; + for (size_t i = 0; i < slug_budget; ++i) dest[out++] = slug[i]; + if (can_add_identity) { + dest[out++] = '-'; + for (size_t i = 0; i < 3; ++i) { + dest[out++] = kHex[(stable_id[i] >> 4) & 0x0f]; + dest[out++] = kHex[stable_id[i] & 0x0f]; + } + } + dest[out] = '\0'; + return true; +} + +} // namespace NetworkHostname diff --git a/src/helpers/NetworkInterface.cpp b/src/helpers/NetworkInterface.cpp index c8953aacb..dc0be519c 100644 --- a/src/helpers/NetworkInterface.cpp +++ b/src/helpers/NetworkInterface.cpp @@ -77,6 +77,7 @@ class NetworkInterfaceBase : public NetworkInterface { class WiFiNetworkInterface final : public NetworkInterfaceBase { bool _event_registered = false; + char _hostname[32] = {}; char _ssid[33] = {}; char _password[65] = {}; unsigned long _last_reconnect_attempt = 0; @@ -112,6 +113,11 @@ class WiFiNetworkInterface final : public NetworkInterfaceBase { return wifi_ssid && wifi_ssid[0] != '\0'; } + void setHostname(const char* hostname) override { + strncpy(_hostname, hostname ? hostname : "", sizeof(_hostname) - 1); + _hostname[sizeof(_hostname) - 1] = '\0'; + } + bool begin(const char* wifi_ssid, const char* wifi_password) override { if (!configValid(wifi_ssid)) return false; strncpy(_ssid, wifi_ssid, sizeof(_ssid) - 1); @@ -119,6 +125,9 @@ class WiFiNetworkInterface final : public NetworkInterfaceBase { strncpy(_password, wifi_password ? wifi_password : "", sizeof(_password) - 1); _password[sizeof(_password) - 1] = '\0'; + // Arduino-ESP32 applies this stored value when it creates the STA netif. + // It must be set before WiFi.mode()/begin() for the first DHCP exchange. + if (_hostname[0] != '\0') WiFi.setHostname(_hostname); WiFi.mode(WIFI_STA); WiFi.setAutoReconnect(true); WiFi.setAutoConnect(true); @@ -229,6 +238,7 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { private: bool _started = false; bool _event_registered = false; + char _hostname[32] = {}; std::atomic _event_state{static_cast(EventState::None)}; public: @@ -240,6 +250,11 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { int statusCode() const override { return isConnected() ? 1 : 0; } bool configValid(const char*) const override { return true; } + void setHostname(const char* hostname) override { + strncpy(_hostname, hostname ? hostname : "", sizeof(_hostname) - 1); + _hostname[sizeof(_hostname) - 1] = '\0'; + } + bool begin(const char*, const char*) override { if (_started) return true; // DNS and WiFiClientSecure are transport-neutral sockets in this Arduino @@ -279,7 +294,7 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { }); _event_registered = true; } - _started = beginConfiguredCH390(); + _started = beginConfiguredCH390(_hostname); if (_started && isConnected()) noteConnected(millis()); return _started; } @@ -444,6 +459,13 @@ class AutomaticNetworkInterface final : public NetworkInterface { } bool isAutomatic() const override { return true; } + void setHostname(const char* hostname) override { + // Whichever medium wins now or during a later failover presents the same + // stable DHCP identity to the LAN. + _ethernet.setHostname(hostname); + _wifi.setHostname(hostname); + } + bool begin(const char* wifi_ssid, const char* wifi_password) override { rememberWifi(wifi_ssid, wifi_password); if (!_ethernet_started) { diff --git a/src/helpers/NetworkInterface.h b/src/helpers/NetworkInterface.h index 646a73cec..5ded0ff98 100644 --- a/src/helpers/NetworkInterface.h +++ b/src/helpers/NetworkInterface.h @@ -29,6 +29,9 @@ class NetworkInterface { virtual const char* statusName() const = 0; virtual int statusCode() const = 0; virtual bool configValid(const char* wifi_ssid) const = 0; + // Configure the DHCP hostname before begin()/bootstrap(). Implementations + // retain it for any later fallback interface start. + virtual void setHostname(const char* hostname) = 0; virtual bool begin(const char* wifi_ssid, const char* wifi_password) = 0; virtual NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) = 0; diff --git a/src/helpers/ethernet/ch390/CH390Config.h b/src/helpers/ethernet/ch390/CH390Config.h index 1b3a5bd9e..30f110bf9 100644 --- a/src/helpers/ethernet/ch390/CH390Config.h +++ b/src/helpers/ethernet/ch390/CH390Config.h @@ -7,7 +7,7 @@ * Shared by the companion transport wrapper and the observer network adapter so * pin and static-IP behavior cannot drift between the two paths. */ -static inline bool beginConfiguredCH390() { +static inline bool beginConfiguredCH390(const char* hostname = nullptr) { ch390_config_t config = CH390_DEFAULT_CONFIG(); config.spi_miso_gpio = ETH_MISO_PIN; config.spi_mosi_gpio = ETH_MOSI_PIN; @@ -16,6 +16,28 @@ static inline bool beginConfiguredCH390() { config.int_gpio = ETH_INT_PIN; if (!CH390.begin(config)) return false; + if (hostname && hostname[0] != '\0') { +#if !(defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET)) + // ESP32-CH390 creates and starts its esp_netif inside begin(), so its + // setHostname() cannot be called earlier. Stop the just-created DHCP + // client, apply the name to that exact Ethernet netif, then start a fresh + // DHCP transaction. This prevents a fast wired link from retaining the + // ESP-IDF default hostname for its initial lease. + const bool dhcp_stopped = CH390.disableDHCP(); + const bool hostname_set = CH390.setHostname(hostname); + const bool dhcp_started = CH390.enableDHCP(); + if (!dhcp_stopped || !hostname_set || !dhcp_started) { + Serial.printf("Network: could not fully apply Ethernet hostname %s\n", + hostname); + } +#else + if (!CH390.setHostname(hostname)) { + Serial.printf("Network: could not apply Ethernet hostname %s\n", + hostname); + } +#endif + } + #if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET) IPAddress ip(ETHERNET_STATIC_IP); IPAddress gateway(ETHERNET_STATIC_GATEWAY); diff --git a/test/README.md b/test/README.md index 0cba8fff5..ee33b4c5b 100644 --- a/test/README.md +++ b/test/README.md @@ -30,6 +30,7 @@ does not reflect the GoogleTest count — run the built binary directly | `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank all types except raw; 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; WiFi current-outage start sticky across STA reconnect attempts | | `test_network_policy` | `src/helpers/NetworkPolicy.h` | transport-neutral MQTT link-transition actions and `start ota` selected-LAN versus forced/fallback SoftAP choice | +| `test_network_hostname` | `src/helpers/NetworkHostname.h` | DHCP-safe `meshcore-` hostname generation; invalid-character collapse; empty fallback; exact length boundary; stable identity suffix on truncation | | `test_alert_fault_policy` | `src/helpers/AlertFaultPolicy.h` | WiFi/MQTT fault edge detector; `OutageSnapshot` (down / started_ms / initiating reason) fed to tick and `formatWifiAlert`; reason-8 reconnects change neither duration nor initiating reason; flap between status polls; down at `millis()==0`; packed 64-bit cross-task word; rate-limit floor and first-fire; 5 s poll cadence and `millis()` rollover | | `test_display_viewport` | `src/helpers/ui/DisplayViewport.h`, `src/helpers/ui/DisplayFrameSignature.h` | logical-to-physical portrait mapping; fractional span coverage; fitted-width conversion; preferred/fallback text scaling; stable visible-frame change detection | | `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 | diff --git a/test/test_network_hostname/test_network_hostname.cpp b/test/test_network_hostname/test_network_hostname.cpp new file mode 100644 index 000000000..8835c7d96 --- /dev/null +++ b/test/test_network_hostname/test_network_hostname.cpp @@ -0,0 +1,95 @@ +#include + +#include + +#include "helpers/NetworkHostname.h" + +namespace { + +const uint8_t kStableId[] = {0xab, 0xcd, 0x01}; + +void expectDhcpSafe(const char* hostname) { + ASSERT_NE(nullptr, hostname); + const size_t length = strlen(hostname); + EXPECT_GT(length, 0u); + EXPECT_LE(length, NetworkHostname::kMaxLength); + EXPECT_NE('-', hostname[0]); + EXPECT_NE('-', hostname[length - 1]); + for (size_t i = 0; i < length; ++i) { + const char ch = hostname[i]; + EXPECT_TRUE((ch >= 'a' && ch <= 'z') || + (ch >= '0' && ch <= '9') || ch == '-') + << "invalid byte at " << i; + } +} + +} // namespace + +TEST(NetworkHostname, PrefixesAndLowercasesReadableNodeName) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), + "Hill Repeater 2", kStableId, + sizeof(kStableId))); + EXPECT_STREQ("meshcore-hill-repeater-2", hostname); + expectDhcpSafe(hostname); +} + +TEST(NetworkHostname, CollapsesInvalidRunsAndTrimsEdges) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), + " Adam's___Hill / Repeater! ", + kStableId, sizeof(kStableId))); + EXPECT_STREQ("meshcore-adam-s-hill-repeater", hostname); + expectDhcpSafe(hostname); +} + +TEST(NetworkHostname, UsesDocumentedFallbackForEmptySanitizedName) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), "___ !!!", + kStableId, sizeof(kStableId))); + EXPECT_STREQ("meshcore-node", hostname); + expectDhcpSafe(hostname); +} + +TEST(NetworkHostname, KeepsExactBoundaryWithoutIdentitySuffix) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), + "abcdefghijklmnopqrstuv", kStableId, + sizeof(kStableId))); + EXPECT_STREQ("meshcore-abcdefghijklmnopqrstuv", hostname); + EXPECT_EQ(NetworkHostname::kMaxLength, strlen(hostname)); +} + +TEST(NetworkHostname, TruncatesReadablePartAndAddsStableIdentitySuffix) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), + "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + kStableId, sizeof(kStableId))); + EXPECT_STREQ("meshcore-abcdefghijklmno-abcd01", hostname); + EXPECT_EQ(NetworkHostname::kMaxLength, strlen(hostname)); + expectDhcpSafe(hostname); +} + +TEST(NetworkHostname, IdentitySuffixSeparatesOtherwiseCollidingNames) { + const uint8_t other_id[] = {0x12, 0x34, 0x56}; + char first[NetworkHostname::kBufferSize]; + char second[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(first, sizeof(first), + "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + kStableId, sizeof(kStableId))); + ASSERT_TRUE(NetworkHostname::build(second, sizeof(second), + "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + other_id, sizeof(other_id))); + EXPECT_STRNE(first, second); + EXPECT_STREQ("meshcore-abcdefghijklmno-123456", second); +} + +TEST(NetworkHostname, RejectsMissingOutputBuffer) { + EXPECT_FALSE(NetworkHostname::build(nullptr, 0, "node", kStableId, + sizeof(kStableId))); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From fc528ae640cca637a48cd01405d0d17e4ded8897 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 3 Sep 2026 18:36:43 -0700 Subject: [PATCH 06/16] fix(network): set ethernet hostname before startup --- src/helpers/ethernet/ch390/CH390Config.h | 22 +++------------------- variants/thinknode_m7/platformio.ini | 2 +- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/src/helpers/ethernet/ch390/CH390Config.h b/src/helpers/ethernet/ch390/CH390Config.h index 30f110bf9..656603ba9 100644 --- a/src/helpers/ethernet/ch390/CH390Config.h +++ b/src/helpers/ethernet/ch390/CH390Config.h @@ -14,29 +14,13 @@ static inline bool beginConfiguredCH390(const char* hostname = nullptr) { config.spi_sck_gpio = ETH_SCLK_PIN; config.spi_cs_gpio = ETH_CS_PIN; config.int_gpio = ETH_INT_PIN; - if (!CH390.begin(config)) return false; - if (hostname && hostname[0] != '\0') { -#if !(defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET)) - // ESP32-CH390 creates and starts its esp_netif inside begin(), so its - // setHostname() cannot be called earlier. Stop the just-created DHCP - // client, apply the name to that exact Ethernet netif, then start a fresh - // DHCP transaction. This prevents a fast wired link from retaining the - // ESP-IDF default hostname for its initial lease. - const bool dhcp_stopped = CH390.disableDHCP(); - const bool hostname_set = CH390.setHostname(hostname); - const bool dhcp_started = CH390.enableDHCP(); - if (!dhcp_stopped || !hostname_set || !dhcp_started) { - Serial.printf("Network: could not fully apply Ethernet hostname %s\n", - hostname); - } -#else if (!CH390.setHostname(hostname)) { - Serial.printf("Network: could not apply Ethernet hostname %s\n", - hostname); + Serial.printf("Network: invalid Ethernet hostname %s\n", hostname); + return false; } -#endif } + if (!CH390.begin(config)) return false; #if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET) IPAddress ip(ETHERNET_STATIC_IP); diff --git a/variants/thinknode_m7/platformio.ini b/variants/thinknode_m7/platformio.ini index 890faee25..fa9a01402 100644 --- a/variants/thinknode_m7/platformio.ini +++ b/variants/thinknode_m7/platformio.ini @@ -47,7 +47,7 @@ build_flags = -D ETH_INT_PIN=45 -D ETHERNET_DEBUG_LOGGING=1 lib_deps = - https://github.com/liamcottle/ESP32-CH390.git#47b401f1de546118b03c18b5689dedec45871f2d + https://github.com/agessaman/ESP32-CH390.git#55c425705d9b21df11f95782ae6408707b2a91a9 ; Existing companion/CLI transport overlay. MQTT Ethernet observers consume the ; CH390 fragment above without ETHERNET_ENABLED, whose meaning in the simple From d9db3e8e6a971b434f7c742910f866ab688c4f82 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 3 Sep 2026 20:47:11 -0700 Subject: [PATCH 07/16] fix(network): harden automatic link transitions --- src/helpers/NetworkInterface.cpp | 3 +- src/helpers/NetworkPolicy.h | 5 +-- src/helpers/bridges/MQTTBridge.cpp | 5 ++- src/helpers/esp32/WebConfigServer.cpp | 34 +++++++++++++++++-- src/helpers/ethernet/ch390/CH390Config.h | 1 - .../test_network_policy.cpp | 10 ++++-- 6 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/helpers/NetworkInterface.cpp b/src/helpers/NetworkInterface.cpp index dc0be519c..ecf105adf 100644 --- a/src/helpers/NetworkInterface.cpp +++ b/src/helpers/NetworkInterface.cpp @@ -455,7 +455,8 @@ class AutomaticNetworkInterface final : public NetworkInterface { // Hardware availability and stored credentials are configuration. Current // link/DHCP state is runtime state and must not permanently suppress the // MQTT task that monitors for a late cable or lease. - return _ethernet_started || (wifi_ssid && wifi_ssid[0] != '\0'); + (void)wifi_ssid; + return true; } bool isAutomatic() const override { return true; } diff --git a/src/helpers/NetworkPolicy.h b/src/helpers/NetworkPolicy.h index 5d083eb44..fc16ab2d8 100644 --- a/src/helpers/NetworkPolicy.h +++ b/src/helpers/NetworkPolicy.h @@ -38,7 +38,7 @@ static constexpr MQTTTransitionActions mqttActions(NetworkTransition transition) return { transition == NetworkTransition::Down || transition == NetworkTransition::Switched, transition == NetworkTransition::Up || transition == NetworkTransition::Switched, - transition == NetworkTransition::Up || transition == NetworkTransition::Switched, + transition == NetworkTransition::Switched, }; } @@ -160,7 +160,8 @@ static inline NetworkMedium automaticSelection( if (input.selected == NetworkMedium::WiFi) { if (input.ethernet_connected && - input.ethernet_stable_ms >= kEthernetFailbackStableMs) { + (!input.wifi_connected || + input.ethernet_stable_ms >= kEthernetFailbackStableMs)) { return NetworkMedium::Ethernet; } return NetworkMedium::WiFi; diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index cfbe3ad51..427106fa0 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1252,7 +1252,10 @@ void MQTTBridge::mqttTaskLoop() { // Wait a bit for WiFi to start connecting vTaskDelay(pdMS_TO_TICKS(1000)); - bool network_was_connected = _network->isConnected(); + // Treat the first post-settle sample as an edge. A fast Wi-Fi association + // may complete during the delay above, before the task has sampled state; + // initializing from the live value would then defer NTP until its retry. + bool network_was_connected = false; unsigned long last_ntp_attempt = 0; // Main task loop diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 37740e8c3..1aa638d0b 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -23,6 +24,24 @@ // so an untouched password field never overwrites the stored value. static const char SECRET_SENTINEL[] = "********"; +// esp_random() has a true entropy source only while RF is active. Ethernet +// LAN mode deliberately keeps Wi-Fi and Bluetooth down, so temporarily enable +// the bootloader RNG source while generating authentication secrets there. +class WebConfigEntropyGuard { + bool _enabled; + + public: + WebConfigEntropyGuard() + : _enabled(activeNetworkInterface().medium() == + NetworkMedium::Ethernet) { + if (_enabled) bootloader_random_enable(); + } + + ~WebConfigEntropyGuard() { + if (_enabled) bootloader_random_disable(); + } +}; + // Key classification (allowlist, secret detection, slot-prefix parsing) lives in // helpers/WebConfigKeys.h so it can be unit-tested on the host. Thin aliases keep // the call sites below readable. @@ -262,8 +281,11 @@ bool WebConfigServer::startLanMode(IPAddress ip, bool initial_setup, char reply[ } _initial_setup = initial_setup; if (_initial_setup) { - for (int i = 0; i < 3; ++i) { - sprintf(&_setup_code[i * 4], "%04X", (unsigned)(esp_random() & 0xffff)); + { + WebConfigEntropyGuard entropy; + for (int i = 0; i < 3; ++i) { + sprintf(&_setup_code[i * 4], "%04X", (unsigned)(esp_random() & 0xffff)); + } } _setup_code[12] = 0; } else { @@ -730,7 +752,13 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) { } _login_fails = 0; _login_lock_until = 0; - for (int i = 0; i < 4; i++) sprintf(&_session_token[i * 8], "%08lx", (unsigned long)esp_random()); + { + WebConfigEntropyGuard entropy; + for (int i = 0; i < 4; i++) { + sprintf(&_session_token[i * 8], "%08lx", + (unsigned long)esp_random()); + } + } _session_last_seen = now; AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}"); diff --git a/src/helpers/ethernet/ch390/CH390Config.h b/src/helpers/ethernet/ch390/CH390Config.h index 656603ba9..057230677 100644 --- a/src/helpers/ethernet/ch390/CH390Config.h +++ b/src/helpers/ethernet/ch390/CH390Config.h @@ -17,7 +17,6 @@ static inline bool beginConfiguredCH390(const char* hostname = nullptr) { if (hostname && hostname[0] != '\0') { if (!CH390.setHostname(hostname)) { Serial.printf("Network: invalid Ethernet hostname %s\n", hostname); - return false; } } if (!CH390.begin(config)) return false; diff --git a/test/test_network_policy/test_network_policy.cpp b/test/test_network_policy/test_network_policy.cpp index b1131a14e..6d5f1f365 100644 --- a/test/test_network_policy/test_network_policy.cpp +++ b/test/test_network_policy/test_network_policy.cpp @@ -9,11 +9,11 @@ TEST(NetworkPolicy, MqttDownDisconnectsSlotsWithoutRequestingImmediateRetry) { EXPECT_FALSE(actions.reset_reconnect_backoff); } -TEST(NetworkPolicy, MqttUpRequestsImmediateRetryWithoutDisconnectingSlots) { +TEST(NetworkPolicy, MqttUpRetriesWithoutClearingBrokerCircuitBreaker) { const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Up); EXPECT_FALSE(actions.stop_started_slots); EXPECT_TRUE(actions.retry_disconnected_slots_now); - EXPECT_TRUE(actions.reset_reconnect_backoff); + EXPECT_FALSE(actions.reset_reconnect_backoff); } TEST(NetworkPolicy, NoTransitionHasNoMqttSideEffects) { @@ -69,6 +69,12 @@ TEST(NetworkPolicy, FailbackRequiresStableEthernet) { EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::automaticSelection(input)); } +TEST(NetworkPolicy, DeadWifiFailsBackToReadyEthernetImmediately) { + const NetworkPolicy::AutomaticSelectionInput input = { + NetworkMedium::WiFi, true, false, true, false, 0, 0}; + EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::automaticSelection(input)); +} + TEST(NetworkPolicy, SwitchingLockPinsTheCurrentMedium) { const NetworkPolicy::AutomaticSelectionInput input = { NetworkMedium::WiFi, true, true, true, true, From a0188bd1745a32f819df50fdcabcaad439366597 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 4 Sep 2026 08:42:46 -0700 Subject: [PATCH 08/16] fix(network): harden Ethernet-preferred lifecycle --- docs/cli_commands.md | 3 +- examples/simple_repeater/MyMesh.cpp | 11 +- examples/simple_repeater/main.cpp | 2 + examples/simple_room_server/MyMesh.cpp | 11 +- examples/simple_room_server/main.cpp | 2 + src/MeshCore.h | 3 + src/helpers/AlertReporter.cpp | 18 +- src/helpers/CommonCLI.cpp | 23 +- src/helpers/ESP32Board.cpp | 147 ++++++-- src/helpers/ESP32Board.h | 3 + src/helpers/MQTTConnectionPolicy.h | 14 + src/helpers/NetworkHostname.h | 16 +- src/helpers/NetworkInterface.cpp | 355 +++++++++++++----- src/helpers/NetworkInterface.h | 9 + src/helpers/NetworkPolicy.h | 51 ++- src/helpers/SNMPAgent.cpp | 4 +- src/helpers/bridges/MQTTBridge.cpp | 22 +- src/helpers/bridges/MQTTBridge.h | 14 +- src/helpers/esp32/HttpPort80Lease.h | 40 ++ src/helpers/esp32/WebConfigServer.cpp | 117 ++++-- src/helpers/esp32/WebConfigServer.h | 2 + .../test_mqtt_connection_policy.cpp | 13 + .../test_network_hostname.cpp | 46 ++- .../test_network_policy.cpp | 64 +++- variants/thinknode_m7/platformio.ini | 2 +- webui/index.html | 4 +- 26 files changed, 803 insertions(+), 193 deletions(-) create mode 100644 src/helpers/esp32/HttpPort80Lease.h diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 0c4ddf6ac..6f9fc715b 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -85,8 +85,9 @@ This document provides an overview of CLI commands that can be sent to MeshCore ### Start an Over-The-Air (OTA) firmware update **Usage:** -- `start ota` — serves the ElegantOTA web upload page on the station IP if joined to a Wi-Fi network, otherwise raises the `MeshCore-OTA` Wi-Fi hotspot. +- `start ota` — serves the ElegantOTA web upload page on the selected Ethernet or Wi-Fi IP when connected, otherwise raises the `MeshCore-OTA` Wi-Fi hotspot. The manual-OTA session stops after 15 minutes unless an upload is in progress. - `start ota ap` — always raises the `MeshCore-OTA` Wi-Fi hotspot, even when joined to a network. Use this when the network applies client isolation and the station IP isn't reachable. +- `stop ota` — stops an idle manual-OTA web server, releases port 80, and re-enables automatic network switching. It refuses while a firmware upload is in progress. --- diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 121183c46..9ab4bd999 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1550,15 +1550,17 @@ void MyMesh::onConfigBatchEnd() { // same sources as the REQ_TYPE_GET_STATUS reply and `get mqtt.stats`. void MyMesh::buildStatsJson(char* buf, size_t buf_size) { char ip[20] = ""; - int wifi_rssi = 0; + char wifi_rssi[12] = "null"; NetworkInterface& network = activeNetworkInterface(); + const char* network_medium = network.mediumName(); if (network.isConnected()) { strncpy(ip, network.localIP().toString().c_str(), sizeof(ip) - 1); const int signal = network.rssi(); - wifi_rssi = signal == INT_MIN ? 0 : signal; + if (signal != INT_MIN) snprintf(wifi_rssi, sizeof(wifi_rssi), "%d", signal); } else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) { strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1); + network_medium = "wifi-ap"; } int pos = snprintf(buf, buf_size, "{\"uptime_s\":%lu,\"batt_mv\":%u," @@ -1567,7 +1569,8 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) { "\"airtime_s\":%lu,\"rx_airtime_s\":%lu," "\"recv\":%lu,\"sent\":%lu,\"rx_err\":%lu," "\"sent_flood\":%lu,\"sent_direct\":%lu,\"recv_flood\":%lu,\"recv_direct\":%lu," - "\"tx_queue\":%d,\"wifi_rssi\":%d,\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[", + "\"tx_queue\":%d,\"wifi_rssi\":%s,\"network_medium\":\"%s\"," + "\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[", (unsigned long)(uptime_millis / 1000), (unsigned)board.getBattMilliVolts(), (unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getMinFreeHeap(), (unsigned long)ESP.getMaxAllocHeap(), @@ -1578,7 +1581,7 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) { (unsigned long)radio_driver.getPacketsRecvErrors(), (unsigned long)getNumSentFlood(), (unsigned long)getNumSentDirect(), (unsigned long)getNumRecvFlood(), (unsigned long)getNumRecvDirect(), - (int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, ip, + (int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, network_medium, ip, bridge ? bridge->getQueueSize() : 0); if (pos < 0 || pos >= (int)buf_size - 3) return; // truncated; snprintf terminated it bool first = true; diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index fa56772aa..13b0828c5 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -138,6 +138,8 @@ void setup() { } void loop() { + board.maintainOTAUpdate(millis()); + // Handle Serial CLI int len = strlen(command); while (Serial.available() && len < sizeof(command)-1) { diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index c0bd70158..64c61a2ef 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1352,15 +1352,17 @@ void MyMesh::onConfigBatchEnd() { // same sources as the stats CLI replies and `get mqtt.stats`. void MyMesh::buildStatsJson(char* buf, size_t buf_size) { char ip[20] = ""; - int wifi_rssi = 0; + char wifi_rssi[12] = "null"; NetworkInterface& network = activeNetworkInterface(); + const char* network_medium = network.mediumName(); if (network.isConnected()) { strncpy(ip, network.localIP().toString().c_str(), sizeof(ip) - 1); const int signal = network.rssi(); - wifi_rssi = signal == INT_MIN ? 0 : signal; + if (signal != INT_MIN) snprintf(wifi_rssi, sizeof(wifi_rssi), "%d", signal); } else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) { strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1); + network_medium = "wifi-ap"; } int pos = snprintf(buf, buf_size, "{\"uptime_s\":%lu,\"batt_mv\":%u," @@ -1369,7 +1371,8 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) { "\"airtime_s\":%lu,\"rx_airtime_s\":%lu," "\"recv\":%lu,\"sent\":%lu,\"rx_err\":%lu," "\"sent_flood\":%lu,\"sent_direct\":%lu,\"recv_flood\":%lu,\"recv_direct\":%lu," - "\"tx_queue\":%d,\"wifi_rssi\":%d,\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[", + "\"tx_queue\":%d,\"wifi_rssi\":%s,\"network_medium\":\"%s\"," + "\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[", (unsigned long)(uptime_millis / 1000), (unsigned)board.getBattMilliVolts(), (unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getMinFreeHeap(), (unsigned long)ESP.getMaxAllocHeap(), @@ -1380,7 +1383,7 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) { (unsigned long)radio_driver.getPacketsRecvErrors(), (unsigned long)getNumSentFlood(), (unsigned long)getNumSentDirect(), (unsigned long)getNumRecvFlood(), (unsigned long)getNumRecvDirect(), - (int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, ip, + (int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, network_medium, ip, bridge ? bridge->getQueueSize() : 0); if (pos < 0 || pos >= (int)buf_size - 3) return; // truncated; snprintf terminated it bool first = true; diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 48f487990..fe4c17a98 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -114,6 +114,8 @@ void setup() { } void loop() { + board.maintainOTAUpdate(millis()); + int len = strlen(command); while (Serial.available() && len < sizeof(command)-1) { char c = Serial.read(); diff --git a/src/MeshCore.h b/src/MeshCore.h index adfc1c9eb..f7995c810 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -69,6 +69,9 @@ public: virtual bool startOTAUpdate(const char* id, char reply[], bool force_ap) { return startOTAUpdate(id, reply); } + virtual bool stopOTAUpdate(char* reply) { (void)reply; return false; } + virtual bool isOTAUpdateInProgress() const { return false; } + virtual void maintainOTAUpdate(uint32_t now_ms) { (void)now_ms; } // Pull-based OTA: fetch the firmware build for this variant from a baked-in manifest and flash it. // current_ver is the running firmware version string (used to skip if already up to date); when // dry_run is true the build is only reported, not flashed. Observer (ESP32+WiFi) builds only. diff --git a/src/helpers/AlertReporter.cpp b/src/helpers/AlertReporter.cpp index d9617ca72..c473b274a 100644 --- a/src/helpers/AlertReporter.cpp +++ b/src/helpers/AlertReporter.cpp @@ -208,10 +208,14 @@ void AlertReporter::onLoop(unsigned long now_ms) { const uint32_t min_interval_ms = AlertFaultPolicy::minIntervalMs(_obs->alert_min_interval_min); - // -------- WiFi fault -------- + // -------- Primary network fault -------- if (_obs->alert_wifi_minutes > 0) { if (_bridge != nullptr) { - const AlertFaultPolicy::OutageSnapshot snap = _bridge->getWifiOutageSnapshot(); + const AlertFaultPolicy::OutageSnapshot snap = + _bridge->getNetworkOutageSnapshot(); + const NetworkMedium alert_medium = _bridge->getNetworkAlertMedium(); + const char* alert_label = alert_medium == NetworkMedium::Ethernet + ? "Ethernet" : "WiFi"; AlertFaultPolicy::TickResult r = AlertFaultPolicy::tick( _wifi, now, snap, AlertFaultPolicy::thresholdMs(_obs->alert_wifi_minutes), @@ -219,20 +223,14 @@ void AlertReporter::onLoop(unsigned long now_ms) { if (r.action == AlertFaultPolicy::Action::FireDown) { char text[80]; AlertFaultPolicy::formatNetworkAlert( - text, sizeof(text), - activeNetworkInterface().medium() == NetworkMedium::Ethernet - ? "Ethernet" : "WiFi", - r, snap); + text, sizeof(text), alert_label, r, snap); if (sendChannel(text)) { AlertFaultPolicy::commitDown(_wifi, now, snap.started_ms); } } else if (r.action == AlertFaultPolicy::Action::FireRecovered) { char text[80]; AlertFaultPolicy::formatNetworkAlert( - text, sizeof(text), - activeNetworkInterface().medium() == NetworkMedium::Ethernet - ? "Ethernet" : "WiFi", - r, snap); + text, sizeof(text), alert_label, r, snap); sendChannel(text); AlertFaultPolicy::commitRecovered(_wifi); } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 35d1822f2..244db25bf 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1276,16 +1276,27 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re (int)mi.fordblks, (int)mi.uordblks, (int)mi.arena, (int)mi.ordblks, _callbacks->getQueueSize()); #endif - } else if (memcmp(command, "start ota", 9) == 0) { + } else if (memcmp(command, "start ota", 9) == 0 && + (command[9] == 0 || command[9] == ' ')) { // Manual OTA: bring up the ElegantOTA web UI for a hand-uploaded binary. // Plain "start ota" serves on the station IP when joined to WiFi, else // raises the MeshCore-OTA SoftAP. "start ota ap" forces the SoftAP even // when connected, so the UI is reachable when the network applies client - // isolation and the station IP can't be reached. (&& short-circuits keep - // the [10]/[11] reads in-bounds when command == "start ota".) - bool force_ap = (command[9] == ' ' && command[10] == 'a' && command[11] == 'p'); - if (!_board->startOTAUpdate(_prefs->node_name, reply, force_ap)) { - strcpy(reply, "Error"); + // isolation and the station IP can't be reached. + bool force_ap = command[9] == ' ' && strcmp(&command[10], "ap") == 0; + if (command[9] == ' ' && !force_ap) { + strcpy(reply, "ERR: usage start ota [ap]"); + } else { + reply[0] = 0; + if (!_board->startOTAUpdate(_prefs->node_name, reply, force_ap) && + reply[0] == 0) { + strcpy(reply, "Error"); + } + } + } else if (strcmp(command, "stop ota") == 0) { + reply[0] = 0; + if (!_board->stopOTAUpdate(reply) && reply[0] == 0) { + strcpy(reply, "ERR: OTA web server not running"); } } else if (memcmp(command, "clock", 5) == 0) { uint32_t now = getRTCClock()->getCurrentTime(); diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 19cdce777..20e079b0a 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -9,14 +9,66 @@ #include #include #include +#include #include +#include + +#include "esp32/HttpPort80Lease.h" + +namespace { + +AsyncWebServer* ota_server = nullptr; +bool ota_routes_registered = false; +bool ota_server_running = false; +bool ota_raised_ap = false; +bool ota_network_locked = false; +uint32_t ota_started_at = 0; +char ota_id_buf[60]; +char ota_home_buf[90]; +char ota_url_buf[80]; + +void otaRegisterRoutes() { + ota_server->on("/", HTTP_GET, [](AsyncWebServerRequest* request) { + request->send(200, "text/html", ota_home_buf); + }); + ota_server->on("/log", HTTP_GET, [](AsyncWebServerRequest* request) { + request->send(SPIFFS, "/packet_log", "text/plain"); + }); + AsyncElegantOTA.begin(ota_server); + ota_routes_registered = true; +} + +void otaReleaseTransport() { + if (ota_server) ota_server->end(); + if (ota_raised_ap) WiFi.softAPdisconnect(true); + ota_raised_ap = false; + ota_server_running = false; + ota_started_at = 0; + if (ota_network_locked) { + activeNetworkInterface().unlockSwitching(); + ota_network_locked = false; + } + HttpPort80Lease::release(HttpPort80Lease::Owner::Ota); +} + +} // namespace bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { + if (ota_server_running) { + ota_started_at = millis(); + snprintf(reply, 160, "Started: %s", ota_url_buf); + return true; + } + if (!HttpPort80Lease::acquire(HttpPort80Lease::Owner::Ota)) { + snprintf(reply, 160, "Error: port 80 is in use by %s", + HttpPort80Lease::ownerName()); + return false; + } + inhibit_sleep = true; // prevent sleep during OTA - // Manual ElegantOTA owns port 80 and remains active until a successful upload - // reboots the device, so its route lock is intentionally reboot-scoped. activeNetworkInterface().lockSwitching(); + ota_network_locked = true; // If the device is already on its selected network, serve ElegantOTA on that // address so it is reachable without joining a separate AP. Otherwise raise @@ -29,38 +81,89 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { force_ap, activeNetworkInterface().isConnected())) { ip = activeNetworkInterface().localIP(); } else { - WiFi.softAP("MeshCore-OTA", NULL); + ota_raised_ap = WiFi.softAP("MeshCore-OTA", NULL); + if (!ota_raised_ap) { + otaReleaseTransport(); + inhibit_sleep = false; + strcpy(reply, "Error: failed to start OTA AP"); + return false; + } ip = WiFi.softAPIP(); } - sprintf(reply, "Started: http://%s/update", ip.toString().c_str()); + snprintf(ota_id_buf, sizeof(ota_id_buf), "%s (%s)", id, + getManufacturerName()); + snprintf(ota_home_buf, sizeof(ota_home_buf), + "

Hi! I am a MeshCore Repeater. ID: %s

", id); + snprintf(ota_url_buf, sizeof(ota_url_buf), "http://%s/update", + ip.toString().c_str()); + + if (!ota_server) { + ota_server = new (std::nothrow) AsyncWebServer(80); + if (!ota_server) { + otaReleaseTransport(); + inhibit_sleep = false; + strcpy(reply, "Error: insufficient memory for OTA server"); + return false; + } + } + AsyncElegantOTA.setID(ota_id_buf); + if (!ota_routes_registered) otaRegisterRoutes(); + ota_server->begin(); + if (ota_server->state() != LISTEN) { + otaReleaseTransport(); + inhibit_sleep = false; + strcpy(reply, "Error: failed to bind OTA server to port 80"); + return false; + } + + ota_server_running = true; + ota_started_at = millis(); + snprintf(reply, 160, "Started: %s", ota_url_buf); MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply); - static char id_buf[60]; - sprintf(id_buf, "%s (%s)", id, getManufacturerName()); - static char home_buf[90]; - sprintf(home_buf, "

Hi! I am a MeshCore Repeater. ID: %s

", id); - - AsyncWebServer* server = new AsyncWebServer(80); - - server->on("/", HTTP_GET, [](AsyncWebServerRequest *request) { - request->send(200, "text/html", home_buf); - }); - server->on("/log", HTTP_GET, [](AsyncWebServerRequest *request) { - request->send(SPIFFS, "/packet_log", "text/plain"); - }); - - AsyncElegantOTA.setID(id_buf); - AsyncElegantOTA.begin(server); // Start ElegantOTA - server->begin(); - return true; } +bool ESP32Board::stopOTAUpdate(char reply[]) { + if (!ota_server_running) return false; + if (Update.isRunning()) { + strcpy(reply, "Error: firmware upload is in progress"); + return false; + } + + otaReleaseTransport(); + inhibit_sleep = false; + strcpy(reply, "OK - OTA web server stopped"); + return true; +} + +bool ESP32Board::isOTAUpdateInProgress() const { + return ota_server_running && Update.isRunning(); +} + +void ESP32Board::maintainOTAUpdate(uint32_t now_ms) { + if (!ota_server_running || Update.isRunning()) return; + if (!NetworkPolicy::manualOtaTimeoutDue(now_ms, ota_started_at, false)) return; + char reply[160]; + if (stopOTAUpdate(reply)) MESH_DEBUG_PRINTLN("OTA: %s", reply); +} + #else bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { + (void)id; (void)reply; (void)force_ap; return false; // not supported } +bool ESP32Board::stopOTAUpdate(char reply[]) { + (void)reply; + return false; +} +bool ESP32Board::isOTAUpdateInProgress() const { + return false; +} +void ESP32Board::maintainOTAUpdate(uint32_t now_ms) { + (void)now_ms; +} #endif // --------------------------------------------------------------------------- diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 08e264bc0..02d0ac4bf 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -158,6 +158,9 @@ public: } bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) override; + bool stopOTAUpdate(char* reply) override; + bool isOTAUpdateInProgress() const override; + void maintainOTAUpdate(uint32_t now_ms) override; bool otaFromManifest(const char* current_ver, bool dry_run, char reply[]) override; // Heavy body (TLS + JSON / HTTPUpdate). Runs in a dedicated large-stack task // spawned by otaFromManifest() — public only so that task entry point can call diff --git a/src/helpers/MQTTConnectionPolicy.h b/src/helpers/MQTTConnectionPolicy.h index cf459459f..6a463ebec 100644 --- a/src/helpers/MQTTConnectionPolicy.h +++ b/src/helpers/MQTTConnectionPolicy.h @@ -91,6 +91,20 @@ static inline bool circuitBreakerProbeDue(uint32_t now, uint32_t last_attempt) { return elapsedMs(now, last_attempt) >= kCircuitBreakerProbeMs; } +// Move a disconnected slot's deadline to "due now" after the physical link +// returns. Preserve its earned backoff and breaker state: a normal reconnect +// gets one immediate attempt at its current rung, while a tripped breaker gets +// one immediate probe. Unsigned subtraction keeps this wrap-safe. +static inline uint32_t immediateRetryLastAttempt(uint32_t now, + bool circuit_breaker_tripped, + uint8_t reconnect_backoff, + uint8_t slot_index) { + const uint32_t delay = circuit_breaker_tripped + ? kCircuitBreakerProbeMs + : reconnectDelayMs(reconnect_backoff, slot_index); + return now - delay; +} + // WiFi station reconnect backoff. The bridge drives its own STA reconnect loop // separate from the per-slot MQTT reconnects, with a slightly longer first rung // (15 s vs the slot ladder's 10 s). Extracted from handleWiFiConnection() so the diff --git a/src/helpers/NetworkHostname.h b/src/helpers/NetworkHostname.h index d4dfe7c44..caf6396b7 100644 --- a/src/helpers/NetworkHostname.h +++ b/src/helpers/NetworkHostname.h @@ -25,8 +25,10 @@ static inline char asciiLower(uint8_t ch) { * * The result is lowercase, starts with "meshcore-", contains only letters, * digits and hyphens, and never exceeds ESP-IDF's practical 31-character - * payload limit. If the readable name must be shortened, six hex digits from - * the stable node identity are retained to reduce truncation collisions. + * payload limit. Six hex digits from the stable node identity are retained + * whenever non-ASCII bytes are removed, a fallback is needed, or the readable + * name must be shortened. This keeps lossy sanitization from assigning the + * same DHCP identity to unrelated nodes. */ static inline bool build(char* dest, size_t dest_size, const char* node_name, const uint8_t* stable_id, size_t stable_id_size) { @@ -44,6 +46,7 @@ static inline bool build(char* dest, size_t dest_size, const char* node_name, char slug[64]; size_t slug_length = 0; bool separator_pending = false; + bool removed_non_ascii = false; if (node_name) { for (size_t i = 0; node_name[i] != '\0' && slug_length < sizeof(slug) - 1; ++i) { @@ -57,12 +60,14 @@ static inline bool build(char* dest, size_t dest_size, const char* node_name, slug[slug_length++] = asciiLower(ch); } separator_pending = false; - } else if (slug_length > 0) { - separator_pending = true; + } else { + if (ch >= 0x80) removed_non_ascii = true; + if (slug_length > 0) separator_pending = true; } } } + const bool used_fallback = slug_length == 0; if (slug_length == 0) { for (size_t i = 0; i < sizeof(kFallback) - 1; ++i) { slug[slug_length++] = kFallback[i]; @@ -75,7 +80,8 @@ static inline bool build(char* dest, size_t dest_size, const char* node_name, if (max_length == 0) return false; const bool needs_truncation = kPrefixLength + slug_length > max_length; - const bool can_add_identity = needs_truncation && stable_id && + const bool needs_identity = needs_truncation || removed_non_ascii || used_fallback; + const bool can_add_identity = needs_identity && stable_id && stable_id_size >= 3 && max_length > kPrefixLength + kSuffixLength; const size_t suffix_length = can_add_identity ? kSuffixLength : 0; const size_t prefix_length = kPrefixLength < max_length diff --git a/src/helpers/NetworkInterface.cpp b/src/helpers/NetworkInterface.cpp index ecf105adf..73f2411d4 100644 --- a/src/helpers/NetworkInterface.cpp +++ b/src/helpers/NetworkInterface.cpp @@ -236,7 +236,7 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { }; private: - bool _started = false; + std::atomic _started{false}; bool _event_registered = false; char _hostname[32] = {}; std::atomic _event_state{static_cast(EventState::None)}; @@ -256,7 +256,7 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { } bool begin(const char*, const char*) override { - if (_started) return true; + if (_started.load(std::memory_order_acquire)) return true; // DNS and WiFiClientSecure are transport-neutral sockets in this Arduino // core, but their hostname path still uses WiFiGeneric's event group. // Initialize that shared runtime without enabling or associating Wi-Fi. @@ -294,9 +294,21 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { }); _event_registered = true; } - _started = beginConfiguredCH390(_hostname); - if (_started && isConnected()) noteConnected(millis()); - return _started; + _event_state.store(static_cast(EventState::None), + std::memory_order_relaxed); + const bool started = beginConfiguredCH390(_hostname); + _started.store(started, std::memory_order_release); + if (started && isConnected()) noteConnected(millis()); + return started; + } + + bool restart() { + CH390.end(); + _started.store(false, std::memory_order_release); + // Preserve status/outage history across attempts. maintain() observes the + // resulting edge in this same task iteration, so a retry cannot reset a + // prolonged-down alert timer or hide a previously connected transition. + return begin(nullptr, nullptr); } NetworkTransition maintain(uint32_t now_ms, uint8_t) override { @@ -334,7 +346,9 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { return NetworkTransition::Down; } - bool isConnected() const override { return _started && CH390.isConnected(); } + bool isConnected() const override { + return _started.load(std::memory_order_acquire) && CH390.isConnected(); + } IPAddress localIP() const override { return CH390.localIP(); } int rssi() const override { return INT_MIN; } bool resolveHost(const char* hostname, IPAddress& address) const override { @@ -351,9 +365,10 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { return static_cast( _event_state.load(std::memory_order_relaxed)); } + bool started() const { return _started.load(std::memory_order_acquire); } bool sampleLink(bool& known) const { known = false; - if (!_started) return false; + if (!_started.load(std::memory_order_acquire)) return false; // IEEE 802.3 BMSR link status is latch-low. Read it twice so the second // value is the current carrier state rather than a remembered link flap. @@ -390,22 +405,26 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { class AutomaticNetworkInterface final : public NetworkInterface { EthernetNetworkInterface _ethernet; WiFiNetworkInterface _wifi; - NetworkMedium _selected = NetworkMedium::None; - bool _ethernet_started = false; - bool _wifi_started = false; + std::atomic _selected{NetworkMedium::None}; + std::atomic _ethernet_started{false}; + std::atomic _wifi_started{false}; char _wifi_ssid[33] = {}; char _wifi_password[65] = {}; - uint32_t _ethernet_stable_since = 0; - uint32_t _selected_down_since = 0; + std::atomic _ethernet_stable_since{0}; + std::atomic _selected_down_since{0}; + std::atomic _last_ethernet_init_attempt{0}; + std::atomic _ethernet_retry_attempt{0}; + std::atomic _ethernet_no_ip_since{0}; std::atomic _switch_locks{0}; + std::atomic _switch_in_progress{false}; - NetworkInterface& selectedInterface() { - return _selected == NetworkMedium::Ethernet + NetworkInterface& selectedInterface(NetworkMedium selected) { + return selected == NetworkMedium::Ethernet ? static_cast(_ethernet) : static_cast(_wifi); } - const NetworkInterface& selectedInterface() const { - return _selected == NetworkMedium::Ethernet + const NetworkInterface& selectedInterface(NetworkMedium selected) const { + return selected == NetworkMedium::Ethernet ? static_cast(_ethernet) : static_cast(_wifi); } @@ -420,8 +439,40 @@ class AutomaticNetworkInterface final : public NetworkInterface { } void startWifiFallback() { - if (_wifi_started || !wifiConfigured()) return; - _wifi_started = _wifi.begin(_wifi_ssid, _wifi_password); + if (_wifi_started.load(std::memory_order_acquire) || !wifiConfigured()) return; + _wifi_started.store(_wifi.begin(_wifi_ssid, _wifi_password), + std::memory_order_release); + } + + bool beginUnlockedMutation() { + bool expected = false; + if (!_switch_in_progress.compare_exchange_strong( + expected, true, std::memory_order_acq_rel, + std::memory_order_relaxed)) { + return false; + } + if (_switch_locks.load(std::memory_order_acquire) != 0) { + _switch_in_progress.store(false, std::memory_order_release); + return false; + } + return true; + } + + void endUnlockedMutation() { + _switch_in_progress.store(false, std::memory_order_release); + } + + bool selectIfUnlocked(NetworkMedium medium) { + if (!beginUnlockedMutation()) return false; + select(medium); + endUnlockedMutation(); + return true; + } + + void startWifiFallbackIfUnlocked() { + if (!beginUnlockedMutation()) return; + startWifiFallback(); + endUnlockedMutation(); } void select(NetworkMedium medium) { @@ -431,25 +482,46 @@ class AutomaticNetworkInterface final : public NetworkInterface { // on Wi-Fi after the manager has declared Ethernet active. WiFi.setAutoReconnect(false); WiFi.disconnect(false, false); - _wifi_started = false; + _wifi_started.store(false, std::memory_order_release); } - _selected = medium; - _selected_down_since = 0; + _selected_down_since.store(0, std::memory_order_relaxed); + _selected.store(medium, std::memory_order_release); + } + + bool startOrRetryEthernet(uint32_t now_ms, bool restart) { + const bool started = restart ? _ethernet.restart() + : _ethernet.begin(nullptr, nullptr); + _last_ethernet_init_attempt.store(now_ms, std::memory_order_relaxed); + _ethernet_started.store(started, std::memory_order_release); + if (started) { + _ethernet_retry_attempt.store(0, std::memory_order_relaxed); + } else { + uint8_t attempt = _ethernet_retry_attempt.load(std::memory_order_relaxed); + if (attempt != UINT8_MAX) ++attempt; + _ethernet_retry_attempt.store(attempt, std::memory_order_relaxed); + } + return started; } public: const char* mediumName() const override { - if (_selected == NetworkMedium::Ethernet) return "ethernet"; - if (_selected == NetworkMedium::WiFi) return "wifi"; + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + if (selected == NetworkMedium::Ethernet) return "ethernet"; + if (selected == NetworkMedium::WiFi) return "wifi"; return "none"; } - NetworkMedium medium() const override { return _selected; } + NetworkMedium medium() const override { + return _selected.load(std::memory_order_acquire); + } const char* statusName() const override { - return _selected == NetworkMedium::None ? "not_selected" - : selectedInterface().statusName(); + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected == NetworkMedium::None ? "not_selected" + : selectedInterface(selected).statusName(); } int statusCode() const override { - return _selected == NetworkMedium::None ? 0 : selectedInterface().statusCode(); + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected == NetworkMedium::None ? 0 + : selectedInterface(selected).statusCode(); } bool configValid(const char* wifi_ssid) const override { // Hardware availability and stored credentials are configuration. Current @@ -469,36 +541,51 @@ class AutomaticNetworkInterface final : public NetworkInterface { bool begin(const char* wifi_ssid, const char* wifi_password) override { rememberWifi(wifi_ssid, wifi_password); - if (!_ethernet_started) { - _ethernet_started = _ethernet.begin(nullptr, nullptr); + if (!_ethernet_started.load(std::memory_order_acquire)) { + const uint32_t now_ms = millis(); + const uint8_t attempt = + _ethernet_retry_attempt.load(std::memory_order_relaxed); + if (NetworkPolicy::ethernetInitRetryDue( + attempt, now_ms, + _last_ethernet_init_attempt.load(std::memory_order_relaxed))) { + startOrRetryEthernet(now_ms, attempt != 0); + } } // bootstrap() owns the initial choice. MQTT begin() is intentionally // idempotent and cannot demote a boot-selected Ethernet link because of a // momentary status sample between tasks. - if (_selected == NetworkMedium::None) { + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + if (selected == NetworkMedium::None) { if (_ethernet.isConnected()) { select(NetworkMedium::Ethernet); } else if (wifiConfigured()) { startWifiFallback(); - _selected = NetworkMedium::WiFi; + _selected.store(NetworkMedium::WiFi, std::memory_order_release); } - } else if (_selected == NetworkMedium::WiFi) { + } else if (selected == NetworkMedium::WiFi) { startWifiFallback(); } - return _ethernet_started || _wifi_started; + return _ethernet_started.load(std::memory_order_acquire) || + _wifi_started.load(std::memory_order_acquire); } bool bootstrap(const char* wifi_ssid, const char* wifi_password, uint32_t wait_ms) override { rememberWifi(wifi_ssid, wifi_password); - if (!_ethernet_started) { - _ethernet_started = _ethernet.begin(nullptr, nullptr); + if (!_ethernet_started.load(std::memory_order_acquire)) { + startOrRetryEthernet(millis(), false); } const uint32_t started_at = millis(); - while (NetworkPolicy::ethernetBootProbePending( - _ethernet_started, _ethernet.isConnected(), - (uint32_t)(millis() - started_at), wait_ms)) { + for (;;) { + bool link_known = false; + const bool link_up = _ethernet.sampleLink(link_known); + if (!NetworkPolicy::ethernetBootProbePending( + _ethernet_started.load(std::memory_order_acquire), + _ethernet.isConnected(), link_known, link_up, + (uint32_t)(millis() - started_at), wait_ms)) { + break; + } delay(25); } @@ -508,15 +595,60 @@ class AutomaticNetworkInterface final : public NetworkInterface { select(initial); } else { startWifiFallback(); - _selected = initial; + _selected.store(initial, std::memory_order_release); } return initial != NetworkMedium::None; } NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) override { + bool ethernet_started = _ethernet_started.load(std::memory_order_acquire); + const bool switching_locked = + _switch_locks.load(std::memory_order_acquire) != 0; + const bool ethernet_stopped = + ethernet_started && + _ethernet.eventState() == EthernetNetworkInterface::EventState::Stopped; + if (ethernet_stopped) { + _ethernet_started.store(false, std::memory_order_release); + ethernet_started = false; + if (_ethernet_retry_attempt.load(std::memory_order_relaxed) == 0) { + _ethernet_retry_attempt.store(1, std::memory_order_relaxed); + _last_ethernet_init_attempt.store(now_ms, std::memory_order_relaxed); + } + } + const EthernetNetworkInterface::EventState ethernet_event = + _ethernet.eventState(); + const bool ethernet_link_up = + ethernet_event == EthernetNetworkInterface::EventState::LinkUp || + ethernet_event == EthernetNetworkInterface::EventState::GotIp; + if (ethernet_started && ethernet_link_up && !_ethernet.isConnected()) { + if (_ethernet_no_ip_since.load(std::memory_order_relaxed) == 0) { + uint32_t started_at = now_ms; + if (started_at == 0) started_at = 1; + _ethernet_no_ip_since.store(started_at, std::memory_order_relaxed); + } + } else { + _ethernet_no_ip_since.store(0, std::memory_order_relaxed); + } + if (!switching_locked && NetworkPolicy::ethernetNoIpRecoveryDue( + ethernet_started, ethernet_link_up, _ethernet.isConnected(), + now_ms, _ethernet_no_ip_since.load(std::memory_order_relaxed))) { + ethernet_started = startOrRetryEthernet(now_ms, true); + _ethernet_no_ip_since.store(0, std::memory_order_relaxed); + } + if (!ethernet_started && !switching_locked) { + const uint8_t attempt = + _ethernet_retry_attempt.load(std::memory_order_relaxed); + if (NetworkPolicy::ethernetInitRetryDue( + attempt, now_ms, + _last_ethernet_init_attempt.load(std::memory_order_relaxed))) { + ethernet_started = startOrRetryEthernet(now_ms, true); + } + } + const NetworkTransition ethernet_transition = _ethernet.maintain(now_ms, wifi_power_save); - const NetworkTransition wifi_transition = _wifi_started + const bool wifi_started = _wifi_started.load(std::memory_order_acquire); + const NetworkTransition wifi_transition = wifi_started ? _wifi.maintain(now_ms, wifi_power_save) : NetworkTransition::None; @@ -525,56 +657,73 @@ class AutomaticNetworkInterface final : public NetworkInterface { // ESP-IDF's default-route priority once it came up. Tear the unused STA // down even without a selection edge, and force MQTT to reconnect if it // had already become reachable. - if (_selected == NetworkMedium::Ethernet && _ethernet.isConnected() && - _wifi_started) { + NetworkMedium selected = _selected.load(std::memory_order_acquire); + if (selected == NetworkMedium::Ethernet && _ethernet.isConnected() && + wifi_started) { const bool wifi_had_route = _wifi.isConnected(); - select(NetworkMedium::Ethernet); - if (wifi_had_route) return NetworkTransition::Switched; + if (selectIfUnlocked(NetworkMedium::Ethernet) && wifi_had_route) { + return NetworkTransition::Switched; + } } if (_ethernet.isConnected()) { - if (_ethernet_stable_since == 0) _ethernet_stable_since = now_ms; + if (_ethernet_stable_since.load(std::memory_order_relaxed) == 0) { + _ethernet_stable_since.store(now_ms, std::memory_order_relaxed); + } } else { - _ethernet_stable_since = 0; + _ethernet_stable_since.store(0, std::memory_order_relaxed); } const bool selected_connected = isConnected(); if (!selected_connected) { - if (_selected_down_since == 0) _selected_down_since = now_ms; + if (_selected_down_since.load(std::memory_order_relaxed) == 0) { + _selected_down_since.store(now_ms, std::memory_order_relaxed); + } } else { - _selected_down_since = 0; + _selected_down_since.store(0, std::memory_order_relaxed); } - const uint32_t selected_down_ms = _selected_down_since == 0 - ? 0 : (uint32_t)(now_ms - _selected_down_since); - if (_selected == NetworkMedium::Ethernet && !_ethernet.isConnected() && + const uint32_t selected_down_since = + _selected_down_since.load(std::memory_order_relaxed); + const uint32_t selected_down_ms = selected_down_since == 0 + ? 0 : (uint32_t)(now_ms - selected_down_since); + selected = _selected.load(std::memory_order_acquire); + if (selected == NetworkMedium::Ethernet && !_ethernet.isConnected() && selected_down_ms >= NetworkPolicy::kEthernetDownGraceMs) { - startWifiFallback(); + startWifiFallbackIfUnlocked(); } - const uint32_t ethernet_stable_ms = _ethernet_stable_since == 0 - ? 0 : (uint32_t)(now_ms - _ethernet_stable_since); + const uint32_t ethernet_stable_since = + _ethernet_stable_since.load(std::memory_order_relaxed); + const uint32_t ethernet_stable_ms = ethernet_stable_since == 0 + ? 0 : (uint32_t)(now_ms - ethernet_stable_since); + const bool selection_locked = + _switch_locks.load(std::memory_order_acquire) != 0; const NetworkPolicy::AutomaticSelectionInput input = { - _selected, _ethernet.isConnected(), - _wifi_started && _wifi.isConnected(), wifiConfigured(), - _switch_locks.load(std::memory_order_relaxed) != 0, + selected, _ethernet.isConnected(), + _wifi_started.load(std::memory_order_acquire) && _wifi.isConnected(), + wifiConfigured(), selection_locked, ethernet_stable_ms, selected_down_ms}; const NetworkMedium next = NetworkPolicy::automaticSelection(input); - if (next != _selected) { - const NetworkMedium previous = _selected; - select(next); - return previous == NetworkMedium::None ? NetworkTransition::Up - : NetworkTransition::Switched; + if (next != selected) { + if (selectIfUnlocked(next)) { + return selected == NetworkMedium::None ? NetworkTransition::Up + : NetworkTransition::Switched; + } } - if (_selected == NetworkMedium::Ethernet) return ethernet_transition; - if (_selected == NetworkMedium::WiFi) return wifi_transition; + if (selected == NetworkMedium::Ethernet) return ethernet_transition; + if (selected == NetworkMedium::WiFi) return wifi_transition; return NetworkTransition::None; } void lockSwitching() override { - _switch_locks.fetch_add(1, std::memory_order_relaxed); + uint8_t value = _switch_locks.load(std::memory_order_relaxed); + while (value != UINT8_MAX && !_switch_locks.compare_exchange_weak( + value, static_cast(value + 1), + std::memory_order_acq_rel, std::memory_order_relaxed)) {} + while (_switch_in_progress.load(std::memory_order_acquire)) delay(1); } void unlockSwitching() override { uint8_t value = _switch_locks.load(std::memory_order_relaxed); @@ -584,60 +733,96 @@ class AutomaticNetworkInterface final : public NetworkInterface { } bool isConnected() const override { - return _selected != NetworkMedium::None && selectedInterface().isConnected(); + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected != NetworkMedium::None && + selectedInterface(selected).isConnected(); } IPAddress localIP() const override { - return _selected == NetworkMedium::None ? IPAddress() : selectedInterface().localIP(); + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected == NetworkMedium::None ? IPAddress() + : selectedInterface(selected).localIP(); } int rssi() const override { - return _selected == NetworkMedium::None ? INT_MIN : selectedInterface().rssi(); + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected == NetworkMedium::None ? INT_MIN + : selectedInterface(selected).rssi(); } bool resolveHost(const char* hostname, IPAddress& address) const override { - return _selected != NetworkMedium::None && - selectedInterface().resolveHost(hostname, address); + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected != NetworkMedium::None && + selectedInterface(selected).resolveHost(hostname, address); } void formatDiagnostics(char* reply, size_t reply_size) const override { + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + const bool ethernet_started = + _ethernet_started.load(std::memory_order_acquire); + const bool wifi_started = _wifi_started.load(std::memory_order_acquire); const bool ethernet_connected = _ethernet.isConnected(); bool ethernet_link_known = false; bool ethernet_link_up = _ethernet.sampleLink(ethernet_link_known); ethernet_link_known = ethernet_link_known || ethernet_connected; ethernet_link_up = ethernet_link_up || ethernet_connected; const bool switching_locked = - _switch_locks.load(std::memory_order_relaxed) != 0; + _switch_locks.load(std::memory_order_acquire) != 0; const uint32_t now_ms = millis(); - const uint32_t ethernet_stable_ms = _ethernet_stable_since == 0 - ? 0 : (uint32_t)(now_ms - _ethernet_stable_since); + const uint32_t ethernet_stable_since = + _ethernet_stable_since.load(std::memory_order_relaxed); + const uint32_t ethernet_stable_ms = ethernet_stable_since == 0 + ? 0 : (uint32_t)(now_ms - ethernet_stable_since); + const uint8_t retry_attempt = + _ethernet_retry_attempt.load(std::memory_order_relaxed); + const uint32_t retry_delay = + NetworkPolicy::ethernetInitRetryDelayMs(retry_attempt); + const uint32_t since_attempt = (uint32_t)( + now_ms - _last_ethernet_init_attempt.load(std::memory_order_relaxed)); + const uint32_t retry_in = !ethernet_started && retry_delay > since_attempt + ? retry_delay - since_attempt : 0; const NetworkDiagnosticReason reason = NetworkPolicy::automaticDiagnosticReason( - _ethernet_started, ethernet_link_known, ethernet_link_up, - ethernet_connected, _selected, switching_locked, + ethernet_started, ethernet_link_known, ethernet_link_up, + ethernet_connected, selected, switching_locked, ethernet_stable_ms); snprintf(reply, reply_size, "> why:%s selected:%s lock:%s\n" - "eth:init:%s evt:%s link:%s ip:%s\n" + "eth:init:%s retry:%u/%lums evt:%s link:%s ip:%s\n" "wifi:cfg:%s started:%s link:%s", - NetworkPolicy::diagnosticReasonName(reason), mediumName(), + NetworkPolicy::diagnosticReasonName(reason), + NetworkPolicy::mediumName(selected), switching_locked ? "yes" : "no", - _ethernet_started ? "ok" : "failed", _ethernet.eventName(), + ethernet_started ? "ok" : "failed", retry_attempt, + (unsigned long)retry_in, _ethernet.eventName(), ethernet_link_known ? (ethernet_link_up ? "up" : "down") : "unknown", _ethernet.localIP().toString().c_str(), - wifiConfigured() ? "yes" : "no", _wifi_started ? "yes" : "no", - (_wifi_started && _wifi.isConnected()) ? "up" : "down"); + wifiConfigured() ? "yes" : "no", wifi_started ? "yes" : "no", + (wifi_started && _wifi.isConnected()) ? "up" : "down"); } unsigned long connectedAtMillis() const override { - return _selected == NetworkMedium::None ? 0 : selectedInterface().connectedAtMillis(); + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected == NetworkMedium::None + ? 0 : selectedInterface(selected).connectedAtMillis(); } uint8_t lastDisconnectReason() const override { - return _selected == NetworkMedium::None ? 0 : selectedInterface().lastDisconnectReason(); + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected == NetworkMedium::None + ? 0 : selectedInterface(selected).lastDisconnectReason(); } unsigned long lastDisconnectTime() const override { - return _selected == NetworkMedium::None ? 0 : selectedInterface().lastDisconnectTime(); + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected == NetworkMedium::None + ? 0 : selectedInterface(selected).lastDisconnectTime(); } AlertFaultPolicy::OutageSnapshot outageSnapshot() const override { - return _selected == NetworkMedium::None + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected == NetworkMedium::None ? AlertFaultPolicy::OutageSnapshot{false, 0, 0} - : selectedInterface().outageSnapshot(); + : selectedInterface(selected).outageSnapshot(); + } + NetworkMedium alertMedium() const override { + return NetworkMedium::Ethernet; + } + AlertFaultPolicy::OutageSnapshot alertOutageSnapshot() const override { + return _ethernet.outageSnapshot(); } }; #endif diff --git a/src/helpers/NetworkInterface.h b/src/helpers/NetworkInterface.h index 5ded0ff98..339c14707 100644 --- a/src/helpers/NetworkInterface.h +++ b/src/helpers/NetworkInterface.h @@ -59,6 +59,15 @@ class NetworkInterface { virtual uint8_t lastDisconnectReason() const = 0; virtual unsigned long lastDisconnectTime() const = 0; virtual AlertFaultPolicy::OutageSnapshot outageSnapshot() const = 0; + + // Fault reporting normally follows the selected interface. Automatic + // Ethernet-preferred builds instead keep reporting the primary Ethernet + // outage while healthy Wi-Fi carries traffic, so prolonged degradation is + // not hidden by a successful fallback. + virtual NetworkMedium alertMedium() const { return medium(); } + virtual AlertFaultPolicy::OutageSnapshot alertOutageSnapshot() const { + return outageSnapshot(); + } }; /** Build-selected singleton. Wi-Fi is the compatibility default. */ diff --git a/src/helpers/NetworkPolicy.h b/src/helpers/NetworkPolicy.h index fc16ab2d8..10d7da697 100644 --- a/src/helpers/NetworkPolicy.h +++ b/src/helpers/NetworkPolicy.h @@ -63,7 +63,40 @@ struct AutomaticSelectionInput { // preempt a working Wi-Fi fallback. static constexpr uint32_t kEthernetDownGraceMs = 3000; static constexpr uint32_t kEthernetFailbackStableMs = 10000; +static constexpr uint32_t kEthernetNoLinkBootGraceMs = 750; +static constexpr uint32_t kEthernetNoIpRecoveryMs = 120000; +static constexpr uint32_t kEthernetInitRetryMinMs = 5000; +static constexpr uint32_t kEthernetInitRetryMaxMs = 300000; static constexpr uint32_t kNtpRetryMs = 30000; +static constexpr uint32_t kManualOtaSessionTimeoutMs = + 15UL * 60UL * 1000UL; + +static inline uint32_t ethernetInitRetryDelayMs(uint8_t attempt) { + if (attempt == 0) return 0; + uint32_t delay_ms = kEthernetInitRetryMinMs; + for (uint8_t i = 1; i < attempt && delay_ms < kEthernetInitRetryMaxMs; ++i) { + delay_ms = delay_ms > kEthernetInitRetryMaxMs / 2 + ? kEthernetInitRetryMaxMs : delay_ms * 2; + } + return delay_ms; +} + +static inline bool ethernetInitRetryDue(uint8_t attempt, + uint32_t now_ms, + uint32_t last_attempt_ms) { + return attempt == 0 || + (uint32_t)(now_ms - last_attempt_ms) >= + ethernetInitRetryDelayMs(attempt); +} + +static constexpr bool ethernetNoIpRecoveryDue(bool initialized, + bool link_up, + bool connected, + uint32_t now_ms, + uint32_t no_ip_since_ms) { + return initialized && link_up && !connected && no_ip_since_ms != 0 && + (uint32_t)(now_ms - no_ip_since_ms) >= kEthernetNoIpRecoveryMs; +} static inline NetworkDiagnosticReason automaticDiagnosticReason( bool ethernet_initialized, bool ethernet_link_known, @@ -135,14 +168,17 @@ static constexpr NetworkMedium bootSelection(bool ethernet_connected, : NetworkMedium::None; } -// Once the Ethernet controller has initialized, allow the entire boot probe -// window for link negotiation and DHCP. PHY carrier is useful diagnostic data, -// but it must not shorten the advertised deadline when carrier reporting lags. +// Give the PHY a short window to report carrier. Once a definitive link-down +// sample arrives, do not stall mesh startup for the full DHCP deadline. A +// present link (or an unknown PHY state) still receives the entire probe. static constexpr bool ethernetBootProbePending(bool ethernet_initialized, bool ethernet_connected, + bool link_known, + bool link_up, uint32_t elapsed_ms, uint32_t wait_ms) { - return ethernet_initialized && !ethernet_connected && elapsed_ms < wait_ms; + return ethernet_initialized && !ethernet_connected && elapsed_ms < wait_ms && + (!link_known || link_up || elapsed_ms < kEthernetNoLinkBootGraceMs); } static inline NetworkMedium automaticSelection( @@ -179,4 +215,11 @@ static constexpr bool startOtaUsesSelectedNetwork(bool force_ap, return !force_ap && network_connected; } +static constexpr bool manualOtaTimeoutDue(uint32_t now_ms, + uint32_t started_ms, + bool upload_in_progress) { + return !upload_in_progress && + (uint32_t)(now_ms - started_ms) >= kManualOtaSessionTimeoutMs; +} + } // namespace NetworkPolicy diff --git a/src/helpers/SNMPAgent.cpp b/src/helpers/SNMPAgent.cpp index ddfb2fe76..8394a5884 100644 --- a/src/helpers/SNMPAgent.cpp +++ b/src/helpers/SNMPAgent.cpp @@ -17,7 +17,7 @@ MeshSNMPAgent::MeshSNMPAgent() _total_air_time_secs(0), _mqtt_connected_slots(0), _mqtt_queue_depth(0), _mqtt_skipped_publishes(0), _free_heap(0), _max_alloc(0), _internal_free(0), _psram_free(0), - _wifi_rssi(0) + _wifi_rssi(-127) { _firmware_version[0] = '\0'; _node_name[0] = '\0'; @@ -80,7 +80,7 @@ void MeshSNMPAgent::loop() { #endif const int signal = activeNetworkInterface().rssi(); - _wifi_rssi = signal == INT_MIN ? 0 : signal; + _wifi_rssi = signal == INT_MIN ? -127 : signal; _snmp.loop(); } diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 427106fa0..78273d3ab 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -2776,18 +2776,20 @@ bool MQTTBridge::handleNetworkConnection(unsigned long now) { updateCachedConnectionStatus(); } - if (actions.reset_reconnect_backoff) { - // A usable route is a new connection epoch. Failures earned on the old - // route must not strand the replacement route on the 5-minute rung or at - // the circuit breaker. Preserve JWTs and slot configuration, but give each - // disconnected active slot one immediate, freshly guarded attempt. + if (actions.retry_disconnected_slots_now) { + // Link recovery gets one immediate attempt without forgiving broker + // failures earned on the same route. A medium switch is a new connection + // epoch, so only that transition clears the old route's ladder/breaker. for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].enabled && _slots[i].initial_connect_done && !_slots[i].connected) { - _slots[i].reconnect_backoff = 0; - _slots[i].max_backoff_failures = 0; - _slots[i].circuit_breaker_tripped = false; - _slots[i].last_reconnect_attempt = - now - MQTTConnectionPolicy::reconnectDelayMs(0, static_cast(i)); + if (actions.reset_reconnect_backoff) { + _slots[i].reconnect_backoff = 0; + _slots[i].max_backoff_failures = 0; + _slots[i].circuit_breaker_tripped = false; + } + _slots[i].last_reconnect_attempt = MQTTConnectionPolicy::immediateRetryLastAttempt( + static_cast(now), _slots[i].circuit_breaker_tripped, + _slots[i].reconnect_backoff, static_cast(i)); } } _last_slot_reconnect_ms = now - MQTTConnectionPolicy::kReconnectGuardMs; diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 5038db187..04e276498 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -634,17 +634,21 @@ public: static unsigned long getWifiConnectedAtMillis(); /** - * Current selected-network outage snapshot for AlertReporter: down, - * started_ms, and the - * initiating disconnect reason. Distinct from getLastWifiDisconnectTime() / + * Current alert-network outage snapshot for AlertReporter: down, started_ms, + * and the initiating disconnect reason. Ethernet-preferred builds continue + * tracking Ethernet after a Wi-Fi fallback. Distinct from + * getLastWifiDisconnectTime() / * getLastWifiDisconnectReason(), which follow the most recent ESP-IDF * DISCONNECTED event and are overwritten by STA-backoff WiFi.disconnect() * (reason 8 / ASSOC_LEAVE). */ - AlertFaultPolicy::OutageSnapshot getWifiOutageSnapshot() const { - return _network ? _network->outageSnapshot() + AlertFaultPolicy::OutageSnapshot getNetworkOutageSnapshot() const { + return _network ? _network->alertOutageSnapshot() : AlertFaultPolicy::OutageSnapshot{false, 0, 0}; } + NetworkMedium getNetworkAlertMedium() const { + return _network ? _network->alertMedium() : NetworkMedium::None; + } /** * Per-slot outage accessors used by AlertReporter to detect prolonged diff --git a/src/helpers/esp32/HttpPort80Lease.h b/src/helpers/esp32/HttpPort80Lease.h new file mode 100644 index 000000000..464cf39ae --- /dev/null +++ b/src/helpers/esp32/HttpPort80Lease.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +// Ownership guard for the two AsyncWebServer users. It prevents `start ota` +// from claiming port 80 while WebConfig is listening (and vice versa), even +// when the requests arrive from different tasks. +namespace HttpPort80Lease { + +enum class Owner : uint8_t { None = 0, WebConfig, Ota }; + +inline std::atomic& current() { + static std::atomic owner{Owner::None}; + return owner; +} + +inline bool acquire(Owner requested) { + if (requested == Owner::None) return false; + Owner expected = Owner::None; + return current().compare_exchange_strong( + expected, requested, std::memory_order_acq_rel, + std::memory_order_relaxed); +} + +inline void release(Owner expected) { + current().compare_exchange_strong( + expected, Owner::None, std::memory_order_acq_rel, + std::memory_order_relaxed); +} + +inline const char* ownerName() { + switch (current().load(std::memory_order_acquire)) { + case Owner::WebConfig: return "webconfig"; + case Owner::Ota: return "ota"; + default: return "none"; + } +} + +} // namespace HttpPort80Lease diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 1aa638d0b..5c75a1f12 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -7,9 +7,10 @@ #include #include #include -#include +#include #include #include +#include #include #include @@ -18,30 +19,58 @@ #include #include +#include "HttpPort80Lease.h" + #include "WebConfigHtml.h" // Placeholder sent instead of stored secrets; POSTs carrying it are dropped // so an untouched password field never overwrites the stored value. static const char SECRET_SENTINEL[] = "********"; -// esp_random() has a true entropy source only while RF is active. Ethernet -// LAN mode deliberately keeps Wi-Fi and Bluetooth down, so temporarily enable -// the bootloader RNG source while generating authentication secrets there. +// The ESP hardware RNG has a continuous entropy source only while an RF block +// is running. Ethernet LAN mode deliberately keeps Wi-Fi and Bluetooth down, +// so briefly start an unassociated station while creating login secrets. The +// bootloader RNG helper is intentionally not used here: ESP-IDF limits it to +// early boot, before normal RF/ADC/I2S operation begins. class WebConfigEntropyGuard { - bool _enabled; + wifi_mode_t _previous_mode; + bool _started_wifi; + bool _ready; public: WebConfigEntropyGuard() - : _enabled(activeNetworkInterface().medium() == - NetworkMedium::Ethernet) { - if (_enabled) bootloader_random_enable(); + : _previous_mode(WiFi.getMode()), _started_wifi(false), _ready(true) { + if (_previous_mode == WIFI_MODE_NULL) { + _ready = WiFi.mode(WIFI_MODE_STA); + _started_wifi = _ready; + } } ~WebConfigEntropyGuard() { - if (_enabled) bootloader_random_disable(); + if (_started_wifi) WiFi.mode(_previous_mode); } + + bool ready() const { return _ready; } }; +static bool fillRandomBytes(uint8_t* output, size_t byte_count) { + if (!output || byte_count == 0) return false; + WebConfigEntropyGuard entropy; + if (!entropy.ready()) return false; + + esp_fill_random(output, byte_count); + return true; +} + +static void bytesToHex(char* output, const uint8_t* bytes, size_t byte_count) { + static const char HEX_DIGITS[] = "0123456789ABCDEF"; + for (size_t i = 0; i < byte_count; ++i) { + output[i * 2] = HEX_DIGITS[bytes[i] >> 4]; + output[i * 2 + 1] = HEX_DIGITS[bytes[i] & 0x0f]; + } + output[byte_count * 2] = '\0'; +} + // Key classification (allowlist, secret detection, slot-prefix parsing) lives in // helpers/WebConfigKeys.h so it can be unit-tested on the host. Thin aliases keep // the call sites below readable. @@ -223,6 +252,11 @@ bool WebConfigServer::startSetupMode(char reply[]) { strcpy(reply, "Err: webconfig busy"); return false; } + if (!HttpPort80Lease::acquire(HttpPort80Lease::Owner::WebConfig)) { + snprintf(reply, 160, "Err: port 80 is in use by %s", + HttpPort80Lease::ownerName()); + return false; + } // 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. @@ -244,6 +278,7 @@ bool WebConfigServer::startSetupMode(char reply[]) { #endif if (!ap_ok) { WiFi.mode(WIFI_OFF); + HttpPort80Lease::release(HttpPort80Lease::Owner::WebConfig); strcpy(reply, "Err: failed to start AP"); return false; } @@ -271,26 +306,38 @@ bool WebConfigServer::startLanMode(IPAddress ip, bool initial_setup, char reply[ strcpy(reply, "Err: webconfig busy"); return false; } + if (!HttpPort80Lease::acquire(HttpPort80Lease::Owner::WebConfig)) { + snprintf(reply, 160, "Err: port 80 is in use by %s", + HttpPort80Lease::ownerName()); + return false; + } activeNetworkInterface().lockSwitching(); _network_locked = true; if (!activeNetworkInterface().isConnected() || ip == IPAddress()) { activeNetworkInterface().unlockSwitching(); _network_locked = false; + HttpPort80Lease::release(HttpPort80Lease::Owner::WebConfig); strcpy(reply, "Err: selected network not connected"); return false; } _initial_setup = initial_setup; + uint8_t session_entropy[sizeof(_session_secret) + 6]; + const size_t entropy_size = sizeof(_session_secret) + (_initial_setup ? 6 : 0); + if (!fillRandomBytes(session_entropy, entropy_size)) { + activeNetworkInterface().unlockSwitching(); + _network_locked = false; + HttpPort80Lease::release(HttpPort80Lease::Owner::WebConfig); + strcpy(reply, "Err: secure random source unavailable"); + return false; + } + memcpy(_session_secret, session_entropy, sizeof(_session_secret)); + _session_generation = 0; if (_initial_setup) { - { - WebConfigEntropyGuard entropy; - for (int i = 0; i < 3; ++i) { - sprintf(&_setup_code[i * 4], "%04X", (unsigned)(esp_random() & 0xffff)); - } - } - _setup_code[12] = 0; + bytesToHex(_setup_code, session_entropy + sizeof(_session_secret), 6); } else { _setup_code[0] = 0; } + memset(session_entropy, 0, sizeof(session_entropy)); _mode = MODE_LAN; createServer(); _last_activity = millis(); @@ -391,6 +438,8 @@ void WebConfigServer::finalizeTeardown() { _batch_state = BATCH_IDLE; _batch_next = 0; _batch_reboot_armed = false; + memset(_session_secret, 0, sizeof(_session_secret)); + _session_generation = 0; _session_token[0] = 0; _stats_json[0] = 0; _setup_code[0] = 0; @@ -399,6 +448,7 @@ void WebConfigServer::finalizeTeardown() { activeNetworkInterface().unlockSwitching(); _network_locked = false; } + HttpPort80Lease::release(HttpPort80Lease::Owner::WebConfig); if (_cb) _cb->onWebConfigStopped(); } @@ -653,13 +703,14 @@ bool WebConfigServer::checkAuth(AsyncWebServerRequest* req) { _last_activity = millis(); if (_mode == MODE_SETUP) return true; // physical proximity implied, nothing configured if (_mode != MODE_LAN) return false; - if (_session_token[0] == 0) return false; if (!req->hasHeader("Cookie")) return false; const String& cookies = req->getHeader("Cookie")->value(); int idx = cookies.indexOf("wcs="); if (idx < 0 || (int)cookies.length() < idx + 4 + 32) return false; String token = cookies.substring(idx + 4, idx + 4 + 32); uint32_t now = millis(); + WCLock lock(_mux); + if (_session_token[0] == 0) return false; if ((uint32_t)(now - _session_last_seen) > WEBCONFIG_SESSION_TTL_MS) return false; if (!fixedTimeEquals(token.c_str(), _session_token, 32)) return false; _session_last_seen = now; // sliding expiry @@ -752,25 +803,41 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) { } _login_fails = 0; _login_lock_until = 0; + uint32_t generation; { - WebConfigEntropyGuard entropy; - for (int i = 0; i < 4; i++) { - sprintf(&_session_token[i * 8], "%08lx", - (unsigned long)esp_random()); - } + WCLock lock(_mux); + generation = ++_session_generation; + if (generation == 0) generation = ++_session_generation; + } + uint8_t token_bytes[16]; + SHA256 token_hmac; + token_hmac.resetHMAC(_session_secret, sizeof(_session_secret)); + token_hmac.update(reinterpret_cast(&generation), + sizeof(generation)); + token_hmac.finalizeHMAC(_session_secret, sizeof(_session_secret), + token_bytes, sizeof(token_bytes)); + char session_token[33]; + bytesToHex(session_token, token_bytes, sizeof(token_bytes)); + memset(token_bytes, 0, sizeof(token_bytes)); + { + WCLock lock(_mux); + memcpy(_session_token, session_token, sizeof(_session_token)); + _session_last_seen = now; } - _session_last_seen = now; AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}"); char cookie[80]; - sprintf(cookie, "wcs=%s; HttpOnly; SameSite=Lax; Path=/", _session_token); + sprintf(cookie, "wcs=%s; HttpOnly; SameSite=Lax; Path=/", session_token); res->addHeader("Set-Cookie", cookie); req->send(res); } void WebConfigServer::handleLogout(AsyncWebServerRequest* req) { if (_mode == MODE_OFF) { req->send(503); return; } - _session_token[0] = 0; + { + WCLock lock(_mux); + _session_token[0] = 0; + } AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}"); res->addHeader("Set-Cookie", "wcs=; Max-Age=0; Path=/"); req->send(res); diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index 79cb167bd..b19ca8103 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -171,6 +171,8 @@ private: BatchEntry _batch[MAX_BATCH]; // LAN-mode session (single slot; new login evicts the old session) + uint8_t _session_secret[32] = {0}; + uint32_t _session_generation = 0; char _session_token[33] = {0}; uint32_t _session_last_seen = 0; uint32_t _setup_reminder_at = 0; diff --git a/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp b/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp index e5fef6d0f..e9d1ff224 100644 --- a/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp +++ b/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp @@ -96,6 +96,19 @@ TEST(MQTTConnectionPolicy, CircuitBreakerProbeHasExactThirtyMinuteBoundary) { EXPECT_TRUE(Policy::circuitBreakerProbeDue(900000U, last)); } +TEST(MQTTConnectionPolicy, LinkRecoveryMakesCurrentBackoffDueImmediately) { + const uint32_t now = 1234U; + const uint32_t last = Policy::immediateRetryLastAttempt(now, false, 4, 2); + EXPECT_TRUE(Policy::reconnectDue(now, last, 4, 2)); + EXPECT_FALSE(Policy::circuitBreakerProbeDue(now, last)); +} + +TEST(MQTTConnectionPolicy, LinkRecoveryMakesBreakerProbeDueWithoutClearingIt) { + const uint32_t now = 1234U; + const uint32_t last = Policy::immediateRetryLastAttempt(now, true, 5, 2); + EXPECT_TRUE(Policy::circuitBreakerProbeDue(now, last)); +} + TEST(MQTTConnectionPolicy, JwtLifetimeUsesCappedPerSlotStagger) { EXPECT_EQ(86400U, Policy::jwtLifetimeSecs(86400U, 0)); EXPECT_EQ(86100U, Policy::jwtLifetimeSecs(86400U, 1)); diff --git a/test/test_network_hostname/test_network_hostname.cpp b/test/test_network_hostname/test_network_hostname.cpp index 8835c7d96..378148610 100644 --- a/test/test_network_hostname/test_network_hostname.cpp +++ b/test/test_network_hostname/test_network_hostname.cpp @@ -47,7 +47,51 @@ TEST(NetworkHostname, UsesDocumentedFallbackForEmptySanitizedName) { char hostname[NetworkHostname::kBufferSize]; ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), "___ !!!", kStableId, sizeof(kStableId))); - EXPECT_STREQ("meshcore-node", hostname); + EXPECT_STREQ("meshcore-node-abcd01", hostname); + expectDhcpSafe(hostname); +} + +TEST(NetworkHostname, RemovesUtf8AndRetainsStableIdentity) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), + "M\xC3\xBCnchen", kStableId, + sizeof(kStableId))); + EXPECT_STREQ("meshcore-m-nchen-abcd01", hostname); + expectDhcpSafe(hostname); +} + +TEST(NetworkHostname, MarksCombiningUnicodeAsLossy) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), + "Cafe\xCC\x81", kStableId, + sizeof(kStableId))); + EXPECT_STREQ("meshcore-cafe-abcd01", hostname); + expectDhcpSafe(hostname); +} + +TEST(NetworkHostname, UnicodeOnlyNamesUseDistinctIdentityFallbacks) { + const uint8_t other_id[] = {0x12, 0x34, 0x56}; + const char unicode_only[] = "\xE7\xBD\x91\xE6\xA0\xBC\xF0\x9F\x8C\x90"; + char first[NetworkHostname::kBufferSize]; + char second[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(first, sizeof(first), unicode_only, + kStableId, sizeof(kStableId))); + ASSERT_TRUE(NetworkHostname::build(second, sizeof(second), unicode_only, + other_id, sizeof(other_id))); + EXPECT_STREQ("meshcore-node-abcd01", first); + EXPECT_STREQ("meshcore-node-123456", second); + EXPECT_STRNE(first, second); + expectDhcpSafe(first); + expectDhcpSafe(second); +} + +TEST(NetworkHostname, MalformedUtf8CannotReachDhcpHostname) { + char hostname[NetworkHostname::kBufferSize]; + const char malformed[] = {'b', 'a', 'd', static_cast(0xff), + 'n', 'a', 'm', 'e', '\0'}; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), malformed, + kStableId, sizeof(kStableId))); + EXPECT_STREQ("meshcore-bad-name-abcd01", hostname); expectDhcpSafe(hostname); } diff --git a/test/test_network_policy/test_network_policy.cpp b/test/test_network_policy/test_network_policy.cpp index 6d5f1f365..7a057918f 100644 --- a/test/test_network_policy/test_network_policy.cpp +++ b/test/test_network_policy/test_network_policy.cpp @@ -42,13 +42,54 @@ TEST(NetworkPolicy, BootPrefersEthernetAndOtherwiseUsesConfiguredWifi) { EXPECT_EQ(NetworkMedium::None, NetworkPolicy::bootSelection(false, false)); } -TEST(NetworkPolicy, EthernetBootProbeHonorsFullDeadlineUntilConnected) { - EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(true, false, 0, 8000)); - EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(true, false, 1500, 8000)); - EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(true, false, 7999, 8000)); - EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(true, false, 8000, 8000)); - EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(true, true, 100, 8000)); - EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(false, false, 100, 8000)); +TEST(NetworkPolicy, EthernetBootProbeUsesFullDeadlineForLinkOrUnknownState) { + EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending( + true, false, false, false, 1500, 8000)); + EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending( + true, false, true, true, 7999, 8000)); + EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending( + true, false, true, true, 8000, 8000)); + EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending( + true, true, true, true, 100, 8000)); + EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending( + false, false, false, false, 100, 8000)); +} + +TEST(NetworkPolicy, EthernetBootProbeStopsEarlyOnKnownCableDown) { + EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending( + true, false, true, false, + NetworkPolicy::kEthernetNoLinkBootGraceMs - 1, 8000)); + EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending( + true, false, true, false, + NetworkPolicy::kEthernetNoLinkBootGraceMs, 8000)); +} + +TEST(NetworkPolicy, EthernetInitRetryUsesBoundedExponentialBackoff) { + EXPECT_EQ(0u, NetworkPolicy::ethernetInitRetryDelayMs(0)); + EXPECT_EQ(5000u, NetworkPolicy::ethernetInitRetryDelayMs(1)); + EXPECT_EQ(10000u, NetworkPolicy::ethernetInitRetryDelayMs(2)); + EXPECT_EQ(300000u, NetworkPolicy::ethernetInitRetryDelayMs(8)); + EXPECT_EQ(300000u, NetworkPolicy::ethernetInitRetryDelayMs(255)); + + EXPECT_FALSE(NetworkPolicy::ethernetInitRetryDue(1, 4999, 0)); + EXPECT_TRUE(NetworkPolicy::ethernetInitRetryDue(1, 5000, 0)); + EXPECT_TRUE(NetworkPolicy::ethernetInitRetryDue(1, 10, 10u - 5000u)); +} + +TEST(NetworkPolicy, EthernetNoIpRecoveryRequiresSustainedCarrier) { + const uint32_t timeout = NetworkPolicy::kEthernetNoIpRecoveryMs; + EXPECT_FALSE(NetworkPolicy::ethernetNoIpRecoveryDue( + true, true, false, timeout, 0)); + EXPECT_FALSE(NetworkPolicy::ethernetNoIpRecoveryDue( + true, true, false, timeout, 1)); + EXPECT_TRUE(NetworkPolicy::ethernetNoIpRecoveryDue( + true, true, false, timeout + 1, 1)); + EXPECT_FALSE(NetworkPolicy::ethernetNoIpRecoveryDue( + true, false, false, timeout + 1, 1)); + EXPECT_FALSE(NetworkPolicy::ethernetNoIpRecoveryDue( + true, true, true, timeout + 1, 1)); + EXPECT_TRUE(NetworkPolicy::ethernetNoIpRecoveryDue( + true, true, false, 100, 100u - timeout)); } TEST(NetworkPolicy, EthernetFailureWaitsForGraceAndConnectedWifi) { @@ -146,6 +187,15 @@ TEST(NetworkPolicy, StartOtaForceApOverridesAReachableSelectedNetwork) { EXPECT_FALSE(NetworkPolicy::startOtaUsesSelectedNetwork(true, false)); } +TEST(NetworkPolicy, ManualOtaTimeoutIsUploadSafeAndWrapSafe) { + const uint32_t timeout = NetworkPolicy::kManualOtaSessionTimeoutMs; + EXPECT_FALSE(NetworkPolicy::manualOtaTimeoutDue(timeout - 1, 0, false)); + EXPECT_TRUE(NetworkPolicy::manualOtaTimeoutDue(timeout, 0, false)); + EXPECT_FALSE(NetworkPolicy::manualOtaTimeoutDue(timeout, 0, true)); + EXPECT_TRUE(NetworkPolicy::manualOtaTimeoutDue( + 100, 100 - timeout, false)); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/variants/thinknode_m7/platformio.ini b/variants/thinknode_m7/platformio.ini index fa9a01402..2888f5854 100644 --- a/variants/thinknode_m7/platformio.ini +++ b/variants/thinknode_m7/platformio.ini @@ -47,7 +47,7 @@ build_flags = -D ETH_INT_PIN=45 -D ETHERNET_DEBUG_LOGGING=1 lib_deps = - https://github.com/agessaman/ESP32-CH390.git#55c425705d9b21df11f95782ae6408707b2a91a9 + https://github.com/agessaman/ESP32-CH390.git#a9367ff8ba400880270615b54da9c239cfd68eca ; Existing companion/CLI transport overlay. MQTT Ethernet observers consume the ; CH390 fragment above without ETHERNET_ENABLED, whose meaning in the simple diff --git a/webui/index.html b/webui/index.html index 6a851c1f2..9452b2417 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1156,7 +1156,9 @@ function pollStats(){ tile("Direct RX/TX",s.recv_direct+" / "+s.sent_direct)+ tile("TX queue",s.tx_queue)+ tile("MQTT queue",s.mqtt_queue)+ - tile("WiFi RSSI",s.wifi_rssi,"dBm")+ + (s.wifi_rssi==null + ? tile("Network",s.network_medium||"—","RSSI unavailable") + : tile("WiFi RSSI",s.wifi_rssi,"dBm"))+ tile("IP",s.ip||"—"); push(st.hist.heap,kb);push(st.hist.noise,s.noise); spark($("#spark-heap"),st.hist.heap);spark($("#spark-noise"),st.hist.noise); From a129420804dc8c9eae64480968274f0ceba5faab Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 19 Sep 2026 10:14:16 -0700 Subject: [PATCH 09/16] fix(network): pre-merge review fixes - Guard the boot-time link bootstrap to observer ESP32 builds. It ran unconditionally in MyMesh::begin(), breaking every non-observer repeater/room server build (ESP32 and nRF52). - Rename NetworkInterface -> NetworkLink (class, accessor, files). Arduino-ESP32 3.x ships its own NetworkInterface class and header, which broke the ESP32-C6 builds. Drop WiFi.setAutoConnect(), a no-op on 2.x and removed in 3.x. - Refresh stored Wi-Fi credentials every bridge tick so the STA reconnect loop picks up `set wifi.ssid` / `set wifi.pwd` without a reboot, as the bridge did before the link moved out of it. Skip reconnects while the SSID is empty. - Restore the "WiFi connected: " / "WiFi disconnected: reason N" debug lines the bridge used to print. - Alert on Ethernet only once it has held a lease this boot or when no Wi-Fi is configured; Wi-Fi-only installs of an Ethernet-preferred image keep Wi-Fi alerts instead of reporting "Ethernet down". - Record wifi.setup_complete only for Ethernet LAN onboarding, so Wi-Fi builds keep the SSID-based first-boot portal rule. - Use seq_cst for the route-switch lock/mutation flag handshake. - Docs: SNMP RSSI sentinel is -127; describe link-return vs medium-switch reconnect behavior accurately; note runtime credential pickup. - Test: unknown keys inside a known /mqtt.json group are ignored, which keeps wifi.setup_complete downgrade-safe. --- MQTT_IMPLEMENTATION.md | 7 +- examples/simple_repeater/MyMesh.cpp | 14 +-- examples/simple_room_server/MyMesh.cpp | 14 +-- src/helpers/AlertReporter.cpp | 2 +- src/helpers/CommonCLI_Observer.cpp | 26 ++--- src/helpers/ESP32Board.cpp | 16 +-- .../{NetworkInterface.cpp => NetworkLink.cpp} | 97 ++++++++++++------- .../{NetworkInterface.h => NetworkLink.h} | 11 ++- src/helpers/SNMPAgent.cpp | 4 +- src/helpers/SNMPAgent.h | 2 +- src/helpers/bridges/MQTTBridge.cpp | 11 ++- src/helpers/bridges/MQTTBridge.h | 4 +- src/helpers/esp32/WebConfigServer.cpp | 22 ++--- .../test_mqtt_prefs_serializer.cpp | 14 +++ 14 files changed, 149 insertions(+), 95 deletions(-) rename src/helpers/{NetworkInterface.cpp => NetworkLink.cpp} (90%) rename src/helpers/{NetworkInterface.h => NetworkLink.h} (89%) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 60e96fc91..550513ec1 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -872,8 +872,11 @@ the radio actually performs in that case. - CH390 startup initializes Arduino's shared network event runtime without associating WiFi; this keeps the framework's DNS and TLS hostname paths safe when Ethernet wins directly. - Ethernet/WiFi transitions are logged. A lost or changed route stops every started MQTT - client, including one whose disconnect callback arrived first; once a usable route returns, - route-caused backoff is cleared and one immediate reconnect is allowed + client, including one whose disconnect callback arrived first. When the same link returns, + each slot gets one immediate attempt at its current backoff rung (a tripped circuit breaker + gets one immediate probe); a switch to the other medium also clears backoff and breakers +- WiFi credentials changed at runtime (`set wifi.ssid` / `set wifi.pwd`) are used on the next + reconnect attempt without a reboot - Packets are queued while a slot is disconnected and flushed when it recovers ### Raw Radio Data Capture diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 9ab4bd999..295186943 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -3,7 +3,7 @@ #include #include // for qsort() #include -#include +#include #include #if defined(ESP_PLATFORM) #include @@ -1148,7 +1148,8 @@ void MyMesh::begin(FILESYSTEM *fs) { } #endif - NetworkInterface& boot_network = activeNetworkInterface(); +#if defined(WITH_MQTT_BRIDGE) && defined(ESP_PLATFORM) + NetworkLink& boot_network = activeNetworkLink(); if (boot_network.isAutomatic()) { char network_hostname[NetworkHostname::kBufferSize]; NetworkHostname::build(network_hostname, sizeof(network_hostname), @@ -1165,6 +1166,7 @@ void MyMesh::begin(FILESYSTEM *fs) { boot_network.mediumName(), boot_network.statusName(), (unsigned long)(millis() - ethernet_probe_started_at)); } +#endif acl.load(_fs, self_id); // TODO: key_store.begin(); @@ -1504,13 +1506,13 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { if (force_ap) { // The setup AP owns WiFi outright; refuse while the bridge holds the STA. if (bridge && bridge->isRunning() && - activeNetworkInterface().medium() != NetworkMedium::Ethernet) { + activeNetworkLink().medium() != NetworkMedium::Ethernet) { strcpy(reply, "Err: MQTT bridge is running - 'set bridge off' first"); return true; } _webconfig->startSetupMode(reply); - } else if (activeNetworkInterface().isConnected()) { - _webconfig->startLanMode(activeNetworkInterface().localIP(), + } else if (activeNetworkLink().isConnected()) { + _webconfig->startLanMode(activeNetworkLink().localIP(), !mqttNetworkSetupComplete(_cli.getObserverPrefs()), reply); } else if (!mqttNetworkSetupComplete(_cli.getObserverPrefs())) { _webconfig->startSetupMode(reply); @@ -1551,7 +1553,7 @@ void MyMesh::onConfigBatchEnd() { void MyMesh::buildStatsJson(char* buf, size_t buf_size) { char ip[20] = ""; char wifi_rssi[12] = "null"; - NetworkInterface& network = activeNetworkInterface(); + NetworkLink& network = activeNetworkLink(); const char* network_medium = network.mediumName(); if (network.isConnected()) { strncpy(ip, network.localIP().toString().c_str(), sizeof(ip) - 1); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 64c61a2ef..f5b41860e 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -2,7 +2,7 @@ #include #include #include -#include +#include #include #if defined(ESP_PLATFORM) #include @@ -949,7 +949,8 @@ void MyMesh::begin(FILESYSTEM *fs) { // load persisted prefs _cli.loadPrefs(_fs); - NetworkInterface& boot_network = activeNetworkInterface(); +#if defined(WITH_MQTT_BRIDGE) && defined(ESP_PLATFORM) + NetworkLink& boot_network = activeNetworkLink(); if (boot_network.isAutomatic()) { char network_hostname[NetworkHostname::kBufferSize]; NetworkHostname::build(network_hostname, sizeof(network_hostname), @@ -966,6 +967,7 @@ void MyMesh::begin(FILESYSTEM *fs) { boot_network.mediumName(), boot_network.statusName(), (unsigned long)(millis() - ethernet_probe_started_at)); } +#endif acl.load(_fs, self_id); region_map.load(_fs); @@ -1306,13 +1308,13 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { if (force_ap) { // The setup AP owns WiFi outright; refuse while the bridge holds the STA. if (bridge && bridge->isRunning() && - activeNetworkInterface().medium() != NetworkMedium::Ethernet) { + activeNetworkLink().medium() != NetworkMedium::Ethernet) { strcpy(reply, "Err: MQTT bridge is running - 'set bridge off' first"); return true; } _webconfig->startSetupMode(reply); - } else if (activeNetworkInterface().isConnected()) { - _webconfig->startLanMode(activeNetworkInterface().localIP(), + } else if (activeNetworkLink().isConnected()) { + _webconfig->startLanMode(activeNetworkLink().localIP(), !mqttNetworkSetupComplete(_cli.getObserverPrefs()), reply); } else if (!mqttNetworkSetupComplete(_cli.getObserverPrefs())) { _webconfig->startSetupMode(reply); @@ -1353,7 +1355,7 @@ void MyMesh::onConfigBatchEnd() { void MyMesh::buildStatsJson(char* buf, size_t buf_size) { char ip[20] = ""; char wifi_rssi[12] = "null"; - NetworkInterface& network = activeNetworkInterface(); + NetworkLink& network = activeNetworkLink(); const char* network_medium = network.mediumName(); if (network.isConnected()) { strncpy(ip, network.localIP().toString().c_str(), sizeof(ip) - 1); diff --git a/src/helpers/AlertReporter.cpp b/src/helpers/AlertReporter.cpp index c473b274a..4a58ad612 100644 --- a/src/helpers/AlertReporter.cpp +++ b/src/helpers/AlertReporter.cpp @@ -6,7 +6,7 @@ #include #ifdef WITH_MQTT_BRIDGE #include "AlertFaultPolicy.h" -#include "NetworkInterface.h" +#include "NetworkLink.h" #endif // Header layout for PAYLOAD_TYPE_GRP_TXT before encryption: diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 273666bef..f1d757f88 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -15,7 +15,7 @@ #include "TxtDataHelpers.h" #include "AlertReporter.h" // for alertReporterBannedChannelMatch[Hex]() #include "MQTTObserverValidation.h" // pure input validators (host-testable) -#include "NetworkInterface.h" +#include "NetworkLink.h" #include #include #include @@ -416,9 +416,9 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf // runs on the Arduino loop task, shared with mesh/radio processing and the // web config batch, so a synchronous wait of up to 30 s would stall the // node. The sync runs in the background; verify with `get mqtt.ntp.diag`. - if (!activeNetworkInterface().isConnected()) { + if (!activeNetworkLink().isConnected()) { snprintf(reply, 160, "OK - saved (%s not connected; NTP sync pending)", - activeNetworkInterface().mediumName()); + activeNetworkLink().mediumName()); } else if (!_callbacks->isMqttBridgeRunning()) { strcpy(reply, "OK - saved (MQTT bridge not running)"); } else if (_callbacks->syncMqttNtp()) { @@ -460,8 +460,8 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf _mqtt_prefs.wifi_power_save = ps_value; if (!persistObserverPrefs(reply)) return true; #ifdef ESP_PLATFORM - if (strcmp(activeNetworkInterface().mediumName(), "wifi") == 0 && - activeNetworkInterface().isConnected()) { + if (strcmp(activeNetworkLink().mediumName(), "wifi") == 0 && + activeNetworkLink().isConnected()) { wifi_ps_type_t ps_mode = (ps_value == 1) ? WIFI_PS_NONE : (ps_value == 2) ? WIFI_PS_MAX_MODEM : WIFI_PS_MIN_MODEM; esp_err_t ps_result = esp_wifi_set_ps(ps_mode); @@ -962,9 +962,9 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf #ifdef ESP_PLATFORM // Connectivity probe across all configured NTP servers; never updates the clock. // Serial console (sender_timestamp == 0) gets a detailed table; LoRa gets a compact list. - if (!activeNetworkInterface().isConnected()) { + if (!activeNetworkLink().isConnected()) { snprintf(reply, 160, "Error: %s not connected", - activeNetworkInterface().mediumName()); + activeNetworkLink().mediumName()); } else if (!_callbacks->isMqttBridgeRunning()) { strcpy(reply, "Error: MQTT bridge not running"); } else if (!_callbacks->runMqttNtpDiag(reply, 160, sender_timestamp == 0)) { @@ -1039,10 +1039,10 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf strcpy(reply, _mqtt_prefs.wifi_password[0] ? "> ******** (serial only)" : "> (not set)"); } } else if (strcmp(config, "link.diag") == 0) { - activeNetworkInterface().formatDiagnostics(reply, 160); + activeNetworkLink().formatDiagnostics(reply, 160); } else if (memcmp(config, "link.status", 11) == 0 || memcmp(config, "wifi.status", 11) == 0) { - NetworkInterface& network = activeNetworkInterface(); + NetworkLink& network = activeNetworkLink(); const bool wifi_alias = config[0] == 'w'; if (wifi_alias && strcmp(network.mediumName(), "wifi") != 0) { snprintf(reply, 160, "> n/a (%s selected; use get link.status)", @@ -1181,9 +1181,9 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, #ifdef WITH_MQTT_BRIDGE if (memcmp(command, "tls.bundletest ", 15) == 0) { #ifdef ESP_PLATFORM - if (!activeNetworkInterface().isConnected()) { + if (!activeNetworkLink().isConnected()) { snprintf(reply, 160, "ERR: %s not connected", - activeNetworkInterface().mediumName()); + activeNetworkLink().mediumName()); } else { size_t bundle_len = 0; if (rootca_crt_bundle_start != nullptr && @@ -1228,9 +1228,9 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, // ota check -> report available build, do not flash // ota update -> download and flash, then reboot #if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_BASE) - if (!activeNetworkInterface().isConnected()) { + if (!activeNetworkLink().isConnected()) { snprintf(reply, 160, "ERR: %s not connected", - activeNetworkInterface().mediumName()); + activeNetworkLink().mediumName()); } else if (memcmp(command, "ota check", 9) == 0) { // Check is synchronous so its result lands in this reply, and runs with the // MQTT bridge UP: the slim per-variant manifest is tiny, so the fetch only diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 20e079b0a..19c3e5cc9 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -1,7 +1,7 @@ #ifdef ESP_PLATFORM #include "ESP32Board.h" -#include "NetworkInterface.h" +#include "NetworkLink.h" #include #if defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA) // Repeater or Room Server only @@ -46,7 +46,7 @@ void otaReleaseTransport() { ota_server_running = false; ota_started_at = 0; if (ota_network_locked) { - activeNetworkInterface().unlockSwitching(); + activeNetworkLink().unlockSwitching(); ota_network_locked = false; } HttpPort80Lease::release(HttpPort80Lease::Owner::Ota); @@ -67,7 +67,7 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { } inhibit_sleep = true; // prevent sleep during OTA - activeNetworkInterface().lockSwitching(); + activeNetworkLink().lockSwitching(); ota_network_locked = true; // If the device is already on its selected network, serve ElegantOTA on that @@ -78,8 +78,8 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { // station IP can't be reached. IPAddress ip; if (NetworkPolicy::startOtaUsesSelectedNetwork( - force_ap, activeNetworkInterface().isConnected())) { - ip = activeNetworkInterface().localIP(); + force_ap, activeNetworkLink().isConnected())) { + ip = activeNetworkLink().localIP(); } else { ota_raised_ap = WiFi.softAP("MeshCore-OTA", NULL); if (!ota_raised_ap) { @@ -290,7 +290,7 @@ bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char rep // mesh-receive call chain (it overflows the loopTask canary). Run the work in a // dedicated 24 KB-stack task and block here until it finishes. The big stack is // freed when the task exits; on a successful update the chip reboots inside it. - NetworkInterface& network = activeNetworkInterface(); + NetworkLink& network = activeNetworkLink(); network.lockSwitching(); OtaTaskArgs args = { this, current_ver, dry_run, reply, false, false }; TaskHandle_t handle = nullptr; @@ -312,9 +312,9 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char strcpy(reply, "ERR: OTA not configured (build via build.sh)"); return false; #else - if (!activeNetworkInterface().isConnected()) { + if (!activeNetworkLink().isConnected()) { snprintf(reply, 160, "ERR: %s not connected", - activeNetworkInterface().mediumName()); + activeNetworkLink().mediumName()); return false; } diff --git a/src/helpers/NetworkInterface.cpp b/src/helpers/NetworkLink.cpp similarity index 90% rename from src/helpers/NetworkInterface.cpp rename to src/helpers/NetworkLink.cpp index 73f2411d4..10dfb23a5 100644 --- a/src/helpers/NetworkInterface.cpp +++ b/src/helpers/NetworkLink.cpp @@ -1,4 +1,4 @@ -#include "NetworkInterface.h" +#include "NetworkLink.h" #if defined(ESP_PLATFORM) @@ -21,9 +21,16 @@ extern void tcpipInit(); #endif +// Same "MQTT: " prefix as MQTT_DEBUG_PRINTLN so existing log greps keep matching. +#if defined(MQTT_DEBUG) + #define NETWORK_DEBUG_PRINTLN(F, ...) do { if (Serial.availableForWrite() > 0) { Serial.printf("MQTT: " F "\n", ##__VA_ARGS__); } } while (0) +#else + #define NETWORK_DEBUG_PRINTLN(...) do {} while (0) +#endif + namespace { -class NetworkInterfaceBase : public NetworkInterface { +class NetworkLinkBase : public NetworkLink { protected: std::atomic _outage_bits{AlertFaultPolicy::packOutageSnapshot({false, 0, 0})}; std::atomic _connected_at{0}; @@ -75,7 +82,7 @@ class NetworkInterfaceBase : public NetworkInterface { } }; -class WiFiNetworkInterface final : public NetworkInterfaceBase { +class WiFiNetworkLink final : public NetworkLinkBase { bool _event_registered = false; char _hostname[32] = {}; char _ssid[33] = {}; @@ -118,29 +125,36 @@ class WiFiNetworkInterface final : public NetworkInterfaceBase { _hostname[sizeof(_hostname) - 1] = '\0'; } - bool begin(const char* wifi_ssid, const char* wifi_password) override { - if (!configValid(wifi_ssid)) return false; - strncpy(_ssid, wifi_ssid, sizeof(_ssid) - 1); + void updateWifiCredentials(const char* wifi_ssid, const char* wifi_password) override { + strncpy(_ssid, wifi_ssid ? wifi_ssid : "", sizeof(_ssid) - 1); _ssid[sizeof(_ssid) - 1] = '\0'; strncpy(_password, wifi_password ? wifi_password : "", sizeof(_password) - 1); _password[sizeof(_password) - 1] = '\0'; + } + + bool begin(const char* wifi_ssid, const char* wifi_password) override { + if (!configValid(wifi_ssid)) return false; + updateWifiCredentials(wifi_ssid, wifi_password); // Arduino-ESP32 applies this stored value when it creates the STA netif. // It must be set before WiFi.mode()/begin() for the first DHCP exchange. if (_hostname[0] != '\0') WiFi.setHostname(_hostname); WiFi.mode(WIFI_STA); WiFi.setAutoReconnect(true); - WiFi.setAutoConnect(true); if (!_event_registered) { WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) { switch (event) { case ARDUINO_EVENT_WIFI_STA_GOT_IP: + NETWORK_DEBUG_PRINTLN("WiFi connected: %s", + IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str()); noteConnected(millis()); _reconnect_backoff_attempt = 0; break; case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: noteDisconnected(millis(), info.wifi_sta_disconnected.reason); + NETWORK_DEBUG_PRINTLN("WiFi disconnected: reason %d", + info.wifi_sta_disconnected.reason); break; default: break; @@ -195,7 +209,7 @@ class WiFiNetworkInterface final : public NetworkInterfaceBase { const bool transitioned = _last_connected; if (transitioned) { _connected_at.store(0, std::memory_order_relaxed); - } else if (snapshot.down && MQTTConnectionPolicy::wifiReconnectDue( + } else if (snapshot.down && _ssid[0] != '\0' && MQTTConnectionPolicy::wifiReconnectDue( now_ms, snapshot.started_ms, (uint32_t)_last_reconnect_attempt, _reconnect_backoff_attempt)) { _last_reconnect_attempt = now_ms; @@ -224,7 +238,7 @@ class WiFiNetworkInterface final : public NetworkInterfaceBase { }; #if defined(NETWORK_PREFER_ETHERNET) -class EthernetNetworkInterface final : public NetworkInterfaceBase { +class EthernetNetworkLink final : public NetworkLinkBase { public: enum class EventState : uint8_t { None, @@ -402,9 +416,9 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { } }; -class AutomaticNetworkInterface final : public NetworkInterface { - EthernetNetworkInterface _ethernet; - WiFiNetworkInterface _wifi; +class AutomaticNetworkLink final : public NetworkLink { + EthernetNetworkLink _ethernet; + WiFiNetworkLink _wifi; std::atomic _selected{NetworkMedium::None}; std::atomic _ethernet_started{false}; std::atomic _wifi_started{false}; @@ -417,16 +431,23 @@ class AutomaticNetworkInterface final : public NetworkInterface { std::atomic _ethernet_no_ip_since{0}; std::atomic _switch_locks{0}; std::atomic _switch_in_progress{false}; + std::atomic _ethernet_seen{false}; // held a lease at least once this boot - NetworkInterface& selectedInterface(NetworkMedium selected) { - return selected == NetworkMedium::Ethernet - ? static_cast(_ethernet) - : static_cast(_wifi); + // Ethernet is the primary for alerts once it has carried traffic, or when it + // is the only configured medium; a Wi-Fi-only install keeps Wi-Fi alerts. + bool ethernetIsAlertPrimary() const { + return _ethernet_seen.load(std::memory_order_acquire) || !wifiConfigured(); } - const NetworkInterface& selectedInterface(NetworkMedium selected) const { + + NetworkLink& selectedInterface(NetworkMedium selected) { return selected == NetworkMedium::Ethernet - ? static_cast(_ethernet) - : static_cast(_wifi); + ? static_cast(_ethernet) + : static_cast(_wifi); + } + const NetworkLink& selectedInterface(NetworkMedium selected) const { + return selected == NetworkMedium::Ethernet + ? static_cast(_ethernet) + : static_cast(_wifi); } bool wifiConfigured() const { return _wifi_ssid[0] != '\0'; } @@ -444,14 +465,14 @@ class AutomaticNetworkInterface final : public NetworkInterface { std::memory_order_release); } + // Lock and mutation each publish one flag then read the other; seq_cst keeps + // both sides from missing each other. bool beginUnlockedMutation() { bool expected = false; - if (!_switch_in_progress.compare_exchange_strong( - expected, true, std::memory_order_acq_rel, - std::memory_order_relaxed)) { + if (!_switch_in_progress.compare_exchange_strong(expected, true)) { return false; } - if (_switch_locks.load(std::memory_order_acquire) != 0) { + if (_switch_locks.load() != 0) { _switch_in_progress.store(false, std::memory_order_release); return false; } @@ -532,6 +553,11 @@ class AutomaticNetworkInterface final : public NetworkInterface { } bool isAutomatic() const override { return true; } + void updateWifiCredentials(const char* wifi_ssid, const char* wifi_password) override { + rememberWifi(wifi_ssid, wifi_password); + _wifi.updateWifiCredentials(_wifi_ssid, _wifi_password); + } + void setHostname(const char* hostname) override { // Whichever medium wins now or during a later failover presents the same // stable DHCP identity to the LAN. @@ -589,6 +615,7 @@ class AutomaticNetworkInterface final : public NetworkInterface { delay(25); } + if (_ethernet.isConnected()) _ethernet_seen.store(true, std::memory_order_release); const NetworkMedium initial = NetworkPolicy::bootSelection( _ethernet.isConnected(), wifiConfigured()); if (initial == NetworkMedium::Ethernet) { @@ -606,7 +633,7 @@ class AutomaticNetworkInterface final : public NetworkInterface { _switch_locks.load(std::memory_order_acquire) != 0; const bool ethernet_stopped = ethernet_started && - _ethernet.eventState() == EthernetNetworkInterface::EventState::Stopped; + _ethernet.eventState() == EthernetNetworkLink::EventState::Stopped; if (ethernet_stopped) { _ethernet_started.store(false, std::memory_order_release); ethernet_started = false; @@ -615,11 +642,11 @@ class AutomaticNetworkInterface final : public NetworkInterface { _last_ethernet_init_attempt.store(now_ms, std::memory_order_relaxed); } } - const EthernetNetworkInterface::EventState ethernet_event = + const EthernetNetworkLink::EventState ethernet_event = _ethernet.eventState(); const bool ethernet_link_up = - ethernet_event == EthernetNetworkInterface::EventState::LinkUp || - ethernet_event == EthernetNetworkInterface::EventState::GotIp; + ethernet_event == EthernetNetworkLink::EventState::LinkUp || + ethernet_event == EthernetNetworkLink::EventState::GotIp; if (ethernet_started && ethernet_link_up && !_ethernet.isConnected()) { if (_ethernet_no_ip_since.load(std::memory_order_relaxed) == 0) { uint32_t started_at = now_ms; @@ -647,6 +674,7 @@ class AutomaticNetworkInterface final : public NetworkInterface { const NetworkTransition ethernet_transition = _ethernet.maintain(now_ms, wifi_power_save); + if (_ethernet.isConnected()) _ethernet_seen.store(true, std::memory_order_release); const bool wifi_started = _wifi_started.load(std::memory_order_acquire); const NetworkTransition wifi_transition = wifi_started ? _wifi.maintain(now_ms, wifi_power_save) @@ -721,9 +749,8 @@ class AutomaticNetworkInterface final : public NetworkInterface { void lockSwitching() override { uint8_t value = _switch_locks.load(std::memory_order_relaxed); while (value != UINT8_MAX && !_switch_locks.compare_exchange_weak( - value, static_cast(value + 1), - std::memory_order_acq_rel, std::memory_order_relaxed)) {} - while (_switch_in_progress.load(std::memory_order_acquire)) delay(1); + value, static_cast(value + 1))) {} + while (_switch_in_progress.load()) delay(1); } void unlockSwitching() override { uint8_t value = _switch_locks.load(std::memory_order_relaxed); @@ -819,21 +846,21 @@ class AutomaticNetworkInterface final : public NetworkInterface { : selectedInterface(selected).outageSnapshot(); } NetworkMedium alertMedium() const override { - return NetworkMedium::Ethernet; + return ethernetIsAlertPrimary() ? NetworkMedium::Ethernet : NetworkMedium::WiFi; } AlertFaultPolicy::OutageSnapshot alertOutageSnapshot() const override { - return _ethernet.outageSnapshot(); + return ethernetIsAlertPrimary() ? _ethernet.outageSnapshot() : _wifi.outageSnapshot(); } }; #endif } // namespace -NetworkInterface& activeNetworkInterface() { +NetworkLink& activeNetworkLink() { #if defined(NETWORK_PREFER_ETHERNET) - static AutomaticNetworkInterface network; + static AutomaticNetworkLink network; #else - static WiFiNetworkInterface network; + static WiFiNetworkLink network; #endif return network; } diff --git a/src/helpers/NetworkInterface.h b/src/helpers/NetworkLink.h similarity index 89% rename from src/helpers/NetworkInterface.h rename to src/helpers/NetworkLink.h index 339c14707..afc125a96 100644 --- a/src/helpers/NetworkInterface.h +++ b/src/helpers/NetworkLink.h @@ -20,9 +20,9 @@ * MQTT shutdown deliberately does not stop this interface because an OTA * download runs after the broker clients have been released. */ -class NetworkInterface { +class NetworkLink { public: - virtual ~NetworkInterface() = default; + virtual ~NetworkLink() = default; virtual const char* mediumName() const = 0; virtual NetworkMedium medium() const = 0; @@ -33,6 +33,11 @@ class NetworkInterface { // retain it for any later fallback interface start. virtual void setHostname(const char* hostname) = 0; virtual bool begin(const char* wifi_ssid, const char* wifi_password) = 0; + // Latest stored credentials; the next reconnect attempt uses them without a reboot. + virtual void updateWifiCredentials(const char* wifi_ssid, const char* wifi_password) { + (void)wifi_ssid; + (void)wifi_password; + } virtual NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) = 0; // Automatic selectors are boot-owned so first-run services can use the @@ -71,6 +76,6 @@ class NetworkInterface { }; /** Build-selected singleton. Wi-Fi is the compatibility default. */ -NetworkInterface& activeNetworkInterface(); +NetworkLink& activeNetworkLink(); #endif diff --git a/src/helpers/SNMPAgent.cpp b/src/helpers/SNMPAgent.cpp index 8394a5884..eb29bd688 100644 --- a/src/helpers/SNMPAgent.cpp +++ b/src/helpers/SNMPAgent.cpp @@ -1,7 +1,7 @@ #ifdef WITH_SNMP #include "SNMPAgent.h" -#include "NetworkInterface.h" +#include "NetworkLink.h" #include #include @@ -79,7 +79,7 @@ void MeshSNMPAgent::loop() { _psram_free = 0; #endif - const int signal = activeNetworkInterface().rssi(); + const int signal = activeNetworkLink().rssi(); _wifi_rssi = signal == INT_MIN ? -127 : signal; _snmp.loop(); diff --git a/src/helpers/SNMPAgent.h b/src/helpers/SNMPAgent.h index 2f792634d..fe94b10fe 100644 --- a/src/helpers/SNMPAgent.h +++ b/src/helpers/SNMPAgent.h @@ -14,7 +14,7 @@ // .2.x.0 = radio (packets, RSSI, SNR, noise floor, air time) // .3.x.0 = mqtt (connected slots, queue depth, skipped publishes) // .4.x.0 = memory (free heap, max alloc, internal free, PSRAM free) -// .5.x.0 = network (RSSI, or 0 when the selected medium has no RSSI) +// .5.x.0 = network (RSSI, or -127 when disconnected or the medium has no RSSI) class MeshSNMPAgent { public: diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 78273d3ab..a9d727752 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -101,7 +101,7 @@ void MQTTBridge::getEffectiveMqttOrigin(const NodePrefs* np, const MQTTPrefs* ob } static bool isNetworkConfigValid(const MQTTPrefs* obs) { - return obs && activeNetworkInterface().configValid(obs->wifi_ssid); + return obs && activeNetworkLink().configValid(obs->wifi_ssid); } #ifdef WITH_MQTT_BRIDGE @@ -221,7 +221,7 @@ static void agentLogHeap(const char* location, const char* message, const char* static MQTTBridge* s_mqtt_bridge_instance = nullptr; unsigned long MQTTBridge::getWifiConnectedAtMillis() { - return activeNetworkInterface().connectedAtMillis(); + return activeNetworkLink().connectedAtMillis(); } #if defined(WITH_MQTT_NEIGHBORS) @@ -413,10 +413,10 @@ int MQTTBridge::getMaxActiveSlots() { } uint8_t MQTTBridge::getLastWifiDisconnectReason() { - return activeNetworkInterface().lastDisconnectReason(); + return activeNetworkLink().lastDisconnectReason(); } unsigned long MQTTBridge::getLastWifiDisconnectTime() { - return activeNetworkInterface().lastDisconnectTime(); + return activeNetworkLink().lastDisconnectTime(); } unsigned long MQTTBridge::getSlotCurrentOutageStartMs(int slot_index) const { @@ -624,7 +624,7 @@ static inline uint32_t mqttStopTimeoutForSlots(int slots) { MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity) : BridgeBase(prefs, mgr, rtc), _obs(obs), - _network(&activeNetworkInterface()), + _network(&activeNetworkLink()), _queue_count(0), _last_status_publish(0), _last_status_retry(0), _status_interval(300000), _ntp_client(_ntp_udp, effectiveNtpPrimary(obs), 0, 60000), _last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS), @@ -2740,6 +2740,7 @@ void MQTTBridge::checkConfigurationMismatch() { bool MQTTBridge::handleNetworkConnection(unsigned long now) { const NetworkMedium previous_medium = _network->medium(); + _network->updateWifiCredentials(_obs->wifi_ssid, _obs->wifi_password); const NetworkTransition transition = _network->maintain((uint32_t)now, _obs->wifi_power_save); const NetworkMedium selected_medium = _network->medium(); diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 04e276498..46ef18d42 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -2,7 +2,7 @@ #include "MeshCore.h" #include "helpers/bridges/BridgeBase.h" -#include "helpers/NetworkInterface.h" +#include "helpers/NetworkLink.h" #include #include #include @@ -525,7 +525,7 @@ private: // Observer config (MQTT/WiFi/timezone/SNMP/alert), persisted to /mqtt.json. // _prefs (held by BridgeBase) still provides upstream fields (freq/sf/node_name…). MQTTPrefs* _obs = nullptr; - NetworkInterface* _network = nullptr; + NetworkLink* _network = nullptr; public: MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity); diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 5c75a1f12..510d9b754 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -13,7 +13,7 @@ #include #include -#include +#include #include #include #include @@ -232,7 +232,7 @@ bool WebConfigServer::getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t } if (ip && ip_len > 0) { const IPAddress address = w->_mode == MODE_LAN - ? activeNetworkInterface().localIP() : WiFi.softAPIP(); + ? activeNetworkLink().localIP() : WiFi.softAPIP(); snprintf(ip, ip_len, "%s", address.toString().c_str()); } return true; @@ -291,7 +291,7 @@ bool WebConfigServer::startSetupMode(char reply[]) { _mode = MODE_SETUP; _initial_setup = !mqttNetworkSetupComplete(_obs); createServer(); - activeNetworkInterface().lockSwitching(); + activeNetworkLink().lockSwitching(); _network_locked = true; _was_setup_ap = true; _last_activity = millis(); @@ -311,10 +311,10 @@ bool WebConfigServer::startLanMode(IPAddress ip, bool initial_setup, char reply[ HttpPort80Lease::ownerName()); return false; } - activeNetworkInterface().lockSwitching(); + activeNetworkLink().lockSwitching(); _network_locked = true; - if (!activeNetworkInterface().isConnected() || ip == IPAddress()) { - activeNetworkInterface().unlockSwitching(); + if (!activeNetworkLink().isConnected() || ip == IPAddress()) { + activeNetworkLink().unlockSwitching(); _network_locked = false; HttpPort80Lease::release(HttpPort80Lease::Owner::WebConfig); strcpy(reply, "Err: selected network not connected"); @@ -324,7 +324,7 @@ bool WebConfigServer::startLanMode(IPAddress ip, bool initial_setup, char reply[ uint8_t session_entropy[sizeof(_session_secret) + 6]; const size_t entropy_size = sizeof(_session_secret) + (_initial_setup ? 6 : 0); if (!fillRandomBytes(session_entropy, entropy_size)) { - activeNetworkInterface().unlockSwitching(); + activeNetworkLink().unlockSwitching(); _network_locked = false; HttpPort80Lease::release(HttpPort80Lease::Owner::WebConfig); strcpy(reply, "Err: secure random source unavailable"); @@ -445,7 +445,7 @@ void WebConfigServer::finalizeTeardown() { _setup_code[0] = 0; _setup_reminder_at = 0; if (_network_locked) { - activeNetworkInterface().unlockSwitching(); + activeNetworkLink().unlockSwitching(); _network_locked = false; } HttpPort80Lease::release(HttpPort80Lease::Owner::WebConfig); @@ -474,7 +474,7 @@ void WebConfigServer::tick(uint32_t now) { if (_mode == MODE_LAN && _initial_setup && _setup_reminder_at != 0 && (int32_t)(now - _setup_reminder_at) >= 0) { Serial.printf("WC: Ethernet setup http://%s/ code %s\n", - activeNetworkInterface().localIP().toString().c_str(), + activeNetworkLink().localIP().toString().c_str(), _setup_code); _setup_reminder_at = now + 60000; if (_setup_reminder_at == 0) _setup_reminder_at = 1; @@ -581,8 +581,8 @@ void WebConfigServer::drainBatch(uint32_t now) { return; // more commands next tick } } - if (_initial_setup && _admin_pwd_set && _batch_all_ok && - (_mode == MODE_LAN || _obs->wifi_ssid[0] != '\0')) { + // Wi-Fi onboarding is recorded by the stored SSID; only Ethernet LAN setup needs the marker. + if (_initial_setup && _admin_pwd_set && _batch_all_ok && _mode == MODE_LAN) { if (_cb->onInitialSetupComplete()) { _initial_setup = false; _setup_code[0] = 0; diff --git a/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp b/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp index 3427bb600..d94c658cd 100644 --- a/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp +++ b/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp @@ -485,6 +485,20 @@ TEST(MQTTPrefsSerializer, UnknownGroupsAreIgnoredSoAppendedKeysAreDowngradeSafe) EXPECT_EQ(45, prefs.display_timeout_secs); } +TEST(MQTTPrefsSerializer, UnknownKeysInsideAKnownGroupAreDowngradeSafe) { + // Firmware that predates wifi.setup_complete sees it as an unknown key in a + // known group; the rest of the group must still load. + MQTTPrefs prefs = defaults(); + InputStream input( + "{version:1,wifi:{ssid:\"home\",future_key:1,power_save:2}}"); + MQTTPrefsSerializer serializer(&prefs); + ASSERT_TRUE(serializer.loadSerial(input)); + bool repaired = false; + ASSERT_TRUE(serializer.apply(&repaired)); + EXPECT_STREQ("home", prefs.wifi_ssid); + EXPECT_EQ(2, prefs.wifi_power_save); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From 854d4e2ec39825857429a852d4bfc31bffe42e55 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 19 Sep 2026 10:14:23 -0700 Subject: [PATCH 10/16] feat(thinknode-m7): make the MQTT observer envs Ethernet-preferred Fold the CH390 overlay and NETWORK_PREFER_ETHERNET into ThinkNode_M7_{repeater,room_server}_observer_mqtt and drop the separate *_observer_mqtt_ethernet twins. The env names (and so the OTA manifests) are unchanged, so existing M7 observers OTA into the Ethernet-preferred image; without a cable they select stored Wi-Fi after a brief boot probe. --- MQTT_IMPLEMENTATION.md | 13 +++++++---- variants/thinknode_m7/platformio.ini | 35 +++++++--------------------- 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 550513ec1..aeb4ebc32 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -225,11 +225,14 @@ pio run -e ThinkNode_M7_repeater_observer_mqtt pio run -e ThinkNode_M7_room_server_observer_mqtt ``` -**ThinkNode M7 — WiFi only:** the M7 has an onboard CH390 Ethernet controller, and -`ThinkNode_M7_companion_radio_ethernet` uses it, but the MQTT bridge's link -management is bound to the WiFi station API, so the observer envs uplink over WiFi. -See `UPSTREAM_BUGS.md` for the Ethernet gap. The board has PSRAM, so these builds -get neighbors publication (`WITH_MQTT_NEIGHBORS`) automatically. +**ThinkNode M7 — Ethernet preferred, WiFi fallback:** the observer envs use the onboard +CH390 Ethernet when it has a DHCP lease at boot, otherwise the stored WiFi network, and +switch between the two at runtime (`get link.status`, `get link.diag`). Nodes without a +cable behave like WiFi observers after a brief (about 1 s) Ethernet probe at boot. The DHCP hostname is +`meshcore-` on either medium. A first boot on Ethernet with no WiFi configured +opens WebConfig on the LAN with a one-time login code printed on serial (and shown on +the display), and requires replacing the admin password. The board has PSRAM, so these +builds get neighbors publication (`WITH_MQTT_NEIGHBORS`) automatically. **TLora naming:** The env prefix `LilyGo_TLora_V2_1_1_6` is LilyGo’s **T-LoRa V2.1–1.6** board (SX1276); PlatformIO selects **`ttgo-lora32-v1`** (TTGO LoRa32 V1.0). **MQTT observer** envs extend a slim base **without** `sensor_base` so the image fits `min_spiffs`; **all other** `LilyGo_TLora_V2_1_1_6_*` targets still use optional I2C environmental sensors as before. The **`lilygo_tlora_c6`** variant is separate hardware (ESP32-C6). diff --git a/variants/thinknode_m7/platformio.ini b/variants/thinknode_m7/platformio.ini index 620fe390d..60945e041 100644 --- a/variants/thinknode_m7/platformio.ini +++ b/variants/thinknode_m7/platformio.ini @@ -191,10 +191,9 @@ extends = ThinkNode_M7 build_src_filter = ${ThinkNode_M7.build_src_filter} +<../examples/kiss_modem/> -; Wi-Fi remains the default observer transport. Ethernet twins below select the -; onboard CH390 through the Ethernet-preferred network manager without enabling the legacy CLI -; transport. The board has PSRAM, so MAX_NEIGHBOURS enables -; WITH_MQTT_NEIGHBORS (see MQTTBridge.h). +; MQTT observers prefer the onboard CH390 Ethernet and fall back to Wi-Fi, without +; enabling the legacy Ethernet CLI transport (no ETHERNET_ENABLED). The board has +; PSRAM, so MAX_NEIGHBOURS enables WITH_MQTT_NEIGHBORS (see MQTTBridge.h). [env:ThinkNode_M7_repeater_observer_mqtt] extends = ThinkNode_M7 extra_scripts = @@ -204,6 +203,8 @@ board_ssl_cert_source = adafruit-full board_build.embed_files = src/certs/x509_crt_bundle.bin build_flags = ${ThinkNode_M7.build_flags} + ${ThinkNode_M7_ch390.build_flags} + -D NETWORK_PREFER_ETHERNET=1 -D ADVERT_NAME='"MQTT Observer"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 @@ -231,6 +232,7 @@ build_src_filter = ${ThinkNode_M7.build_src_filter} +<../examples/simple_repeater> lib_deps = ${ThinkNode_M7.lib_deps} + ${ThinkNode_M7_ch390.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 bblanchon/ArduinoJson @ 7.4.3 @@ -239,17 +241,6 @@ lib_deps = paulstoffregen/Time@1.6.1 0neblock/SNMP_Agent -[env:ThinkNode_M7_repeater_observer_mqtt_ethernet] -extends = env:ThinkNode_M7_repeater_observer_mqtt -build_flags = - ${env:ThinkNode_M7_repeater_observer_mqtt.build_flags} - ${ThinkNode_M7_ch390.build_flags} - -D NETWORK_PREFER_ETHERNET=1 -build_src_filter = ${env:ThinkNode_M7_repeater_observer_mqtt.build_src_filter} -lib_deps = - ${env:ThinkNode_M7_repeater_observer_mqtt.lib_deps} - ${ThinkNode_M7_ch390.lib_deps} - [env:ThinkNode_M7_room_server_observer_mqtt] extends = ThinkNode_M7 extra_scripts = @@ -259,6 +250,8 @@ board_ssl_cert_source = adafruit-full board_build.embed_files = src/certs/x509_crt_bundle.bin build_flags = ${ThinkNode_M7.build_flags} + ${ThinkNode_M7_ch390.build_flags} + -D NETWORK_PREFER_ETHERNET=1 -D ADVERT_NAME='"ThinkNode M7 Room Observer"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 @@ -287,6 +280,7 @@ build_src_filter = ${ThinkNode_M7.build_src_filter} +<../examples/simple_room_server> lib_deps = ${ThinkNode_M7.lib_deps} + ${ThinkNode_M7_ch390.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 bblanchon/ArduinoJson @ 7.4.3 @@ -294,14 +288,3 @@ lib_deps = JChristensen/Timezone paulstoffregen/Time@1.6.1 0neblock/SNMP_Agent - -[env:ThinkNode_M7_room_server_observer_mqtt_ethernet] -extends = env:ThinkNode_M7_room_server_observer_mqtt -build_flags = - ${env:ThinkNode_M7_room_server_observer_mqtt.build_flags} - ${ThinkNode_M7_ch390.build_flags} - -D NETWORK_PREFER_ETHERNET=1 -build_src_filter = ${env:ThinkNode_M7_room_server_observer_mqtt.build_src_filter} -lib_deps = - ${env:ThinkNode_M7_room_server_observer_mqtt.lib_deps} - ${ThinkNode_M7_ch390.lib_deps} From 46f639cb1da72cdf00626710224fde334fe21a05 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 19 Sep 2026 10:23:12 -0700 Subject: [PATCH 11/16] fix(network): bound link-transition teardown and gate Ethernet restarts - Link down/switch edges now softDisconnect() every started slot instead of disconnect(). The full stop could wait forever for a DISCONNECTED event on the Core 0 MQTT task, and it destroyed and recreated every slot's esp-mqtt task on each Wi-Fi drop, where the bridge previously stopped none (measured: 62 s deauth, five slots, zero stops). softDisconnect() is bounded and keeps the task; reconnectSlotClient() then calls reconnect() on the new route. Rename the transition action to disconnect_started_slots to match. - Ethernet no-IP recovery and init retries rebuild the CH390 netif, so they now run inside the route-switch mutation gate. An OTA/WebConfig lock taken after maintain() samples the lock count can no longer have the interface torn down underneath it. --- MQTT_IMPLEMENTATION.md | 4 ++-- src/helpers/NetworkLink.cpp | 14 ++++++++++--- src/helpers/NetworkPolicy.h | 2 +- src/helpers/bridges/MQTTBridge.cpp | 20 ++++++++----------- .../test_network_policy.cpp | 8 ++++---- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index aeb4ebc32..67ecebeac 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -874,8 +874,8 @@ the radio actually performs in that case. selection log includes the measured probe duration. - CH390 startup initializes Arduino's shared network event runtime without associating WiFi; this keeps the framework's DNS and TLS hostname paths safe when Ethernet wins directly. -- Ethernet/WiFi transitions are logged. A lost or changed route stops every started MQTT - client, including one whose disconnect callback arrived first. When the same link returns, +- Ethernet/WiFi transitions are logged. A lost or changed route closes every live MQTT + transport with a bounded disconnect that keeps the client task. When the same link returns, each slot gets one immediate attempt at its current backoff rung (a tripped circuit breaker gets one immediate probe); a switch to the other medium also clears backoff and breakers - WiFi credentials changed at runtime (`set wifi.ssid` / `set wifi.pwd`) are used on the next diff --git a/src/helpers/NetworkLink.cpp b/src/helpers/NetworkLink.cpp index 10dfb23a5..6314d1813 100644 --- a/src/helpers/NetworkLink.cpp +++ b/src/helpers/NetworkLink.cpp @@ -573,8 +573,10 @@ class AutomaticNetworkLink final : public NetworkLink { _ethernet_retry_attempt.load(std::memory_order_relaxed); if (NetworkPolicy::ethernetInitRetryDue( attempt, now_ms, - _last_ethernet_init_attempt.load(std::memory_order_relaxed))) { + _last_ethernet_init_attempt.load(std::memory_order_relaxed)) && + beginUnlockedMutation()) { startOrRetryEthernet(now_ms, attempt != 0); + endUnlockedMutation(); } } // bootstrap() owns the initial choice. MQTT begin() is intentionally @@ -656,19 +658,25 @@ class AutomaticNetworkLink final : public NetworkLink { } else { _ethernet_no_ip_since.store(0, std::memory_order_relaxed); } + // Restarts rebuild the CH390 netif, so they take the same mutation gate as + // a route switch: an OTA/WebConfig lock taken after the sample above wins. if (!switching_locked && NetworkPolicy::ethernetNoIpRecoveryDue( ethernet_started, ethernet_link_up, _ethernet.isConnected(), - now_ms, _ethernet_no_ip_since.load(std::memory_order_relaxed))) { + now_ms, _ethernet_no_ip_since.load(std::memory_order_relaxed)) && + beginUnlockedMutation()) { ethernet_started = startOrRetryEthernet(now_ms, true); _ethernet_no_ip_since.store(0, std::memory_order_relaxed); + endUnlockedMutation(); } if (!ethernet_started && !switching_locked) { const uint8_t attempt = _ethernet_retry_attempt.load(std::memory_order_relaxed); if (NetworkPolicy::ethernetInitRetryDue( attempt, now_ms, - _last_ethernet_init_attempt.load(std::memory_order_relaxed))) { + _last_ethernet_init_attempt.load(std::memory_order_relaxed)) && + beginUnlockedMutation()) { ethernet_started = startOrRetryEthernet(now_ms, true); + endUnlockedMutation(); } } diff --git a/src/helpers/NetworkPolicy.h b/src/helpers/NetworkPolicy.h index 10d7da697..a4e7953aa 100644 --- a/src/helpers/NetworkPolicy.h +++ b/src/helpers/NetworkPolicy.h @@ -29,7 +29,7 @@ enum class NetworkDiagnosticReason : uint8_t { namespace NetworkPolicy { struct MQTTTransitionActions { - bool stop_started_slots; + bool disconnect_started_slots; bool retry_disconnected_slots_now; bool reset_reconnect_backoff; }; diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index a9d727752..0f3c67cca 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1986,10 +1986,8 @@ void MQTTBridge::teardownSlot(int index, bool force) { // A stopped client needs connect(): reconnect() is a documented no-op on one, so reaching // it here would strand the slot. The producer is a failed esp_mqtt_client_start(), which -// leaves _started false while initial_connect_done stays set. Not the WiFi-drop teardown, -// which only stops slots still marked connected: a publishing slot's socket fails first, so -// the guard skips it — measured across a 62 s deauth, five slots, zero stops. An idle slot -// with no traffic to fail on is the one case that could still reach here that way. +// leaves _started false while initial_connect_done stays set. Not a network transition, +// which only softDisconnect()s and keeps the client task. void MQTTBridge::reconnectSlotClient(int index) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return; MQTTSlot& slot = _slots[index]; @@ -2760,16 +2758,14 @@ bool MQTTBridge::handleNetworkConnection(unsigned long now) { const NetworkPolicy::MQTTTransitionActions actions = NetworkPolicy::mqttActions(transition); - if (actions.stop_started_slots) { - // Broker ownership stays in the bridge. The physical adapter reports the - // edge; the bridge explicitly stops every started slot instead of waiting - // for eventual socket timeouts. Do not gate this on slot.connected: the - // ESP-MQTT disconnect callback can clear that flag before the network edge - // reaches this task, but its client task and transport can still be alive. + if (actions.disconnect_started_slots) { + // Close each live transport now instead of waiting for socket timeouts, but + // keep the esp-mqtt task: softDisconnect() is bounded where a full stop can + // wait forever, and reconnectSlotClient() then reconnects on the new route. for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].client && _slots[i].client->isStarted()) { - MQTT_DEBUG_PRINTLN("MQTT%d stopping for network transition", i + 1); - _slots[i].client->disconnect(); + MQTT_DEBUG_PRINTLN("MQTT%d disconnecting for network transition", i + 1); + _slots[i].client->softDisconnect(); } _slots[i].connected = false; _slots[i].connected_at_ms = 0; diff --git a/test/test_network_policy/test_network_policy.cpp b/test/test_network_policy/test_network_policy.cpp index 7a057918f..3558e1e3c 100644 --- a/test/test_network_policy/test_network_policy.cpp +++ b/test/test_network_policy/test_network_policy.cpp @@ -4,28 +4,28 @@ TEST(NetworkPolicy, MqttDownDisconnectsSlotsWithoutRequestingImmediateRetry) { const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Down); - EXPECT_TRUE(actions.stop_started_slots); + EXPECT_TRUE(actions.disconnect_started_slots); EXPECT_FALSE(actions.retry_disconnected_slots_now); EXPECT_FALSE(actions.reset_reconnect_backoff); } TEST(NetworkPolicy, MqttUpRetriesWithoutClearingBrokerCircuitBreaker) { const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Up); - EXPECT_FALSE(actions.stop_started_slots); + EXPECT_FALSE(actions.disconnect_started_slots); EXPECT_TRUE(actions.retry_disconnected_slots_now); EXPECT_FALSE(actions.reset_reconnect_backoff); } TEST(NetworkPolicy, NoTransitionHasNoMqttSideEffects) { const auto actions = NetworkPolicy::mqttActions(NetworkTransition::None); - EXPECT_FALSE(actions.stop_started_slots); + EXPECT_FALSE(actions.disconnect_started_slots); EXPECT_FALSE(actions.retry_disconnected_slots_now); EXPECT_FALSE(actions.reset_reconnect_backoff); } TEST(NetworkPolicy, LinkSwitchReconnectsMqttSlotsImmediately) { const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Switched); - EXPECT_TRUE(actions.stop_started_slots); + EXPECT_TRUE(actions.disconnect_started_slots); EXPECT_TRUE(actions.retry_disconnected_slots_now); EXPECT_TRUE(actions.reset_reconnect_backoff); } From 9faa239a5b388e445b662bca3edef909f99e3a91 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 19 Sep 2026 10:28:10 -0700 Subject: [PATCH 12/16] fix(network): cap the per-slot transition disconnect wait at 2 s The old route is already down or switched away when a transition fires, so waiting the default 5 s per slot for a DISCONNECTED event only delays recovery (up to 25 s across five slots). A client whose event is late is aborted by keepalive and retried by the normal backoff loop, the same as the token-renewal path's softDisconnect() + reconnect(). --- src/helpers/bridges/MQTTBridge.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 0f3c67cca..d530cc8a7 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -2736,6 +2736,8 @@ void MQTTBridge::checkConfigurationMismatch() { } } +static constexpr unsigned long kNetworkTransitionDisconnectMs = 2000; + bool MQTTBridge::handleNetworkConnection(unsigned long now) { const NetworkMedium previous_medium = _network->medium(); _network->updateWifiCredentials(_obs->wifi_ssid, _obs->wifi_password); @@ -2762,10 +2764,12 @@ bool MQTTBridge::handleNetworkConnection(unsigned long now) { // Close each live transport now instead of waiting for socket timeouts, but // keep the esp-mqtt task: softDisconnect() is bounded where a full stop can // wait forever, and reconnectSlotClient() then reconnects on the new route. + // The old route is gone, so a short wait suffices; a client whose event is + // late is aborted by keepalive and picked up by the normal backoff retry. for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].client && _slots[i].client->isStarted()) { MQTT_DEBUG_PRINTLN("MQTT%d disconnecting for network transition", i + 1); - _slots[i].client->softDisconnect(); + _slots[i].client->softDisconnect(kNetworkTransitionDisconnectMs); } _slots[i].connected = false; _slots[i].connected_at_ms = 0; From b443aabe6be4ca8d83acaad4c17930ab8abd6e52 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 19 Sep 2026 10:35:49 -0700 Subject: [PATCH 13/16] fix(webconfig): persist the admin password before completing Ethernet onboarding The upstream `password` command saves /prefs.json without reporting the result, and WebConfig overwrites its reply with "OK". If that write failed while the later /mqtt.json write succeeded, wifi.setup_complete was set and the factory password came back after reboot with first-run setup suppressed. onInitialSetupComplete() now re-saves /prefs.json and records completion only when it succeeds; otherwise the batch fails, setup stays open, and no reboot is queued. --- examples/simple_repeater/MyMesh.h | 2 ++ examples/simple_room_server/MyMesh.h | 2 ++ 2 files changed, 4 insertions(+) diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 6cc179d89..99394a473 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -508,6 +508,8 @@ public: } void onConfigBatchEnd() override; bool onInitialSetupComplete() override { + // The password command does not report its save; make it durable first. + if (!_cli.savePrefs(_fs)) return false; MQTTPrefs* obs = _cli.getObserverPrefs(); obs->network_setup_complete = 1; if (_cli.saveObserverPrefs(_fs)) return true; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index f5e0be7e0..594450f8c 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -498,6 +498,8 @@ public: } void onConfigBatchEnd() override; bool onInitialSetupComplete() override { + // The password command does not report its save; make it durable first. + if (!_cli.savePrefs(_fs)) return false; MQTTPrefs* obs = _cli.getObserverPrefs(); obs->network_setup_complete = 1; if (_cli.saveObserverPrefs(_fs)) return true; From 44c46ab67fba8d984ab2701b59dbed032dd58378 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 19 Sep 2026 10:53:39 -0700 Subject: [PATCH 14/16] fix(webconfig): read the LAN address after pinning the route startLanMode() took the caller's IP, read before the route lock. A Wi-Fi/Ethernet switch in between left WebConfig locked to the new link while advertising the old link's address. It now reads the selected link's address after lockSwitching(), as startOTAUpdate() already does. --- examples/simple_repeater/MyMesh.cpp | 3 +-- examples/simple_room_server/MyMesh.cpp | 3 +-- src/helpers/esp32/WebConfigServer.cpp | 4 +++- src/helpers/esp32/WebConfigServer.h | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 295186943..ff70df6fe 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1512,8 +1512,7 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { } _webconfig->startSetupMode(reply); } else if (activeNetworkLink().isConnected()) { - _webconfig->startLanMode(activeNetworkLink().localIP(), - !mqttNetworkSetupComplete(_cli.getObserverPrefs()), reply); + _webconfig->startLanMode(!mqttNetworkSetupComplete(_cli.getObserverPrefs()), reply); } else if (!mqttNetworkSetupComplete(_cli.getObserverPrefs())) { _webconfig->startSetupMode(reply); } else { diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index f5b41860e..35bbd6323 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1314,8 +1314,7 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { } _webconfig->startSetupMode(reply); } else if (activeNetworkLink().isConnected()) { - _webconfig->startLanMode(activeNetworkLink().localIP(), - !mqttNetworkSetupComplete(_cli.getObserverPrefs()), reply); + _webconfig->startLanMode(!mqttNetworkSetupComplete(_cli.getObserverPrefs()), reply); } else if (!mqttNetworkSetupComplete(_cli.getObserverPrefs())) { _webconfig->startSetupMode(reply); } else { diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 510d9b754..bb9bdc362 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -301,7 +301,7 @@ bool WebConfigServer::startSetupMode(char reply[]) { return true; } -bool WebConfigServer::startLanMode(IPAddress ip, bool initial_setup, char reply[]) { +bool WebConfigServer::startLanMode(bool initial_setup, char reply[]) { if (_mode != MODE_OFF || _stopping) { strcpy(reply, "Err: webconfig busy"); return false; @@ -313,6 +313,8 @@ bool WebConfigServer::startLanMode(IPAddress ip, bool initial_setup, char reply[ } activeNetworkLink().lockSwitching(); _network_locked = true; + // Read the address only once the route is pinned, so it names the locked link. + const IPAddress ip = activeNetworkLink().localIP(); if (!activeNetworkLink().isConnected() || ip == IPAddress()) { activeNetworkLink().unlockSwitching(); _network_locked = false; diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index b19ca8103..16ff47bab 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -84,7 +84,7 @@ public: static bool isRebootPending(); bool startSetupMode(char reply[]); // open SoftAP + DNS captive portal - bool startLanMode(IPAddress ip, bool initial_setup, char reply[]); + bool startLanMode(bool initial_setup, char reply[]); // bind to the selected network void requestStop(); // stop listening and detach this session void tick(uint32_t now); // call every loop iteration From ee41da85ec1855e60ec68533023da4a666a77e7b Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 19 Sep 2026 15:31:51 -0700 Subject: [PATCH 15/16] fix(network): bound link transitions and restore each medium's DNS Two defects found by an adversarial review of the merge. A link transition no longer stops a slot whose connect attempt is in flight. Stopping one means esp_mqtt_client_stop(), which waits on the SDK's API mutex and its task's stopped event with no bound; the client task notices only when it returns from whatever transport call it is in. During teardown the bridge's StopUnproven timeout contains that, but during a transition nothing does, so a routine WiFi flap could freeze the sole MQTT worker for far longer than the 2 s this path advertises, with the bridge's own stop handshake queued behind it. The attempt is left to resolve instead: unlike a reconfigure, a transition changes neither endpoint nor credentials, so an attempt that completes is credited to the broker it actually reached and drops with the old route. Failing back to Ethernet left the node using the WiFi network's DNS server. lwIP keeps one global server list and IDF 4.4 has no per-interface retention, so the medium that leased last owns DNS for every socket. Each link now remembers the resolver its own DHCP lease installed and puts it back when it is selected again; a medium that never held a lease leaves the current resolver alone. Where the two networks are on different subnets this was a silent outage: the link read as connected while every broker and NTP hostname failed to resolve, until Ethernet's own DHCP renewal happened to fix it. --- MQTT_IMPLEMENTATION.md | 3 + src/helpers/NetworkLink.cpp | 60 ++++++++++++++++++- src/helpers/bridges/MQTTBridge.cpp | 39 +++++++----- .../test_mqtt_client_state.cpp | 31 ++++++---- 4 files changed, 102 insertions(+), 31 deletions(-) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 2184177f6..d5eeb8bc1 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -880,6 +880,9 @@ the radio actually performs in that case. report a connection that no longer exists. When the same link returns, each slot gets one immediate attempt at its current backoff rung (a tripped circuit breaker gets one immediate probe); a switch to the other medium also clears backoff and breakers +- Each medium's DHCP DNS servers are remembered when it gets its lease and restored when it is + selected again. lwIP keeps one global resolver list, so without this a node that fell back to + WiFi and then failed back to Ethernet would keep asking the WiFi network's DNS server - WiFi credentials changed at runtime (`set wifi.ssid` / `set wifi.pwd`) are used on the next reconnect attempt without a reboot - Packets are queued while a slot is disconnected and flushed when it recovers diff --git a/src/helpers/NetworkLink.cpp b/src/helpers/NetworkLink.cpp index 3b3b981fc..3590ffa80 100644 --- a/src/helpers/NetworkLink.cpp +++ b/src/helpers/NetworkLink.cpp @@ -11,6 +11,7 @@ #include #include +#include #if defined(NETWORK_PREFER_ETHERNET) #include "ethernet/ch390/CH390Config.h" @@ -40,6 +41,8 @@ class NetworkLinkBase : public NetworkLink { bool _status_initialized = false; bool _last_connected = false; unsigned long _last_status_check = 0; + static constexpr int kDnsServers = 2; + std::atomic _dns_snapshot[kDnsServers] = {{0}, {0}}; AlertFaultPolicy::OutageSnapshot outage() const { return AlertFaultPolicy::unpackOutageSnapshot( @@ -65,6 +68,7 @@ class NetworkLinkBase : public NetworkLink { (uint32_t)now_ms, reason, outage())); } + public: unsigned long connectedAtMillis() const override { return _connected_at.load(std::memory_order_relaxed); @@ -81,6 +85,39 @@ class NetworkLinkBase : public NetworkLink { AlertFaultPolicy::OutageSnapshot outageSnapshot() const override { return outage(); } + + // Remember the resolver this link's DHCP lease installed, so switching back + // to it can put that resolver back. + // + // lwIP keeps ONE global server list, not one per interface: a DHCP lease on + // any medium calls dns_setserver() and overwrites whatever the other medium + // had. IDF 4.4 has no per-interface retention either — esp_netif_get_dns_info() + // reads the same globals — so the medium that leased last owns DNS for every + // socket. Without this, an Ethernet node that falls back to Wi-Fi and then + // fails back keeps asking the Wi-Fi network's DNS server. When the two are on + // different subnets that server is unreachable from Ethernet, and the node + // reads as connected while every broker and NTP hostname fails to resolve, + // until Ethernet's own DHCP renewal happens to fix it hours later. + void snapshotDns() { + for (int i = 0; i < kDnsServers; i++) { + const ip_addr_t* server = dns_getserver(i); + _dns_snapshot[i].store( + (server != nullptr && !ip_addr_isany(server)) + ? ip4_addr_get_u32(ip_2_ip4(server)) : 0, + std::memory_order_relaxed); + } + } + + // Only servers this link actually leased are restored; a medium that has + // never held a lease leaves the current resolver alone rather than blanking it. + void restoreDns() const { + for (int i = 0; i < kDnsServers; i++) { + const uint32_t addr = _dns_snapshot[i].load(std::memory_order_relaxed); + if (addr == 0) continue; + const ip_addr_t server = IPADDR4_INIT(addr); + dns_setserver(i, &server); + } + } }; class WiFiNetworkLink final : public NetworkLinkBase { @@ -199,7 +236,10 @@ class WiFiNetworkLink final : public NetworkLinkBase { // Already associated when the link started (end()/begin() leaves STA up): // there is no connect transition below to carry the setting, so apply it // here or the node keeps running whatever mode was set before. - if (connected) applyPowerPrefs(wifi_power_save); + if (connected) { + applyPowerPrefs(wifi_power_save); + snapshotDns(); + } } if ((uint32_t)(now_ms - _last_status_check) <= 10000) { @@ -216,6 +256,7 @@ class WiFiNetworkLink final : public NetworkLinkBase { _connected_at.store(now_ms, std::memory_order_relaxed); _reconnect_backoff_attempt = 0; applyPowerPrefs(wifi_power_save); + snapshotDns(); } _last_connected = true; return transitioned ? NetworkTransition::Up : NetworkTransition::None; @@ -308,6 +349,10 @@ class EthernetNetworkLink final : public NetworkLinkBase { _event_state.store(static_cast(EventState::GotIp), std::memory_order_relaxed); noteConnected(millis()); + // Taken here rather than at the maintain() edge: this is the moment + // the lease installed the resolver, before a Wi-Fi fallback lease + // can overwrite it. + snapshotDns(); break; case ARDUINO_EVENT_ETH_DISCONNECTED: _event_state.store(static_cast(EventState::LinkDown), @@ -350,7 +395,10 @@ class EthernetNetworkLink final : public NetworkLinkBase { _status_initialized = true; setOutage(AlertFaultPolicy::applyWifiStatus( now_ms, connected, outage(), false)); - if (connected) noteConnected(now_ms); + if (connected) { + noteConnected(now_ms); + snapshotDns(); + } return NetworkTransition::None; } @@ -364,6 +412,7 @@ class EthernetNetworkLink final : public NetworkLinkBase { _connected_at.store(now_ms, std::memory_order_relaxed); setOutage(AlertFaultPolicy::applyWifiStatus( now_ms, true, outage(), true)); + snapshotDns(); return NetworkTransition::Up; } @@ -525,6 +574,13 @@ class AutomaticNetworkLink final : public NetworkLink { } _selected_down_since.store(0, std::memory_order_relaxed); _selected.store(medium, std::memory_order_release); + // Put back the resolver this medium leased. lwIP's server list is global, + // so whichever medium leased last still owns it here (see snapshotDns()). + if (medium == NetworkMedium::Ethernet) { + _ethernet.restoreDns(); + } else if (medium == NetworkMedium::WiFi) { + _wifi.restoreDns(); + } } bool startOrRetryEthernet(uint32_t now_ms, bool restart) { diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index d0ce8c029..29edc89e7 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -2335,31 +2335,38 @@ static constexpr unsigned long kNetworkTransitionDisconnectMs = 2000; // Close a slot's transport because the route under it changed. // -// Same shape as closeLiveClientForReconfigure(), and for the same F04 reason: a -// client that is still Starting has an attempt aimed at the OLD route, and -// softDisconnect() returns immediately on one that is not yet connected, so the -// attempt would run to completion and deliver a CONNECTED event that is -// indistinguishable from the new route's. On a medium switch the old route can -// still be briefly usable, so that event is not hypothetical. Cancelling it -// needs a real stop; reconnectSlotClient() starts a stopped client again. +// Deliberately NOT the reconfigure path, even though both close a live client. +// A reconfigure gives the slot a new endpoint and new credentials, so an +// in-flight attempt that completes afterwards reports a connection to the OLD +// broker under the new configuration, and cancelling it is worth a real stop +// (F04). A link transition changes neither: the attempt is aimed at the same +// broker with the same credentials, so if it completes it is credited to the +// endpoint it actually reached. It is stale only in that its socket sits on a +// route that is going away, and the ordinary DISCONNECTED path handles that. // -// A connected client takes the cheap path instead — bounded, and the esp-mqtt -// task stays, which is what keeps a Wi-Fi flap from recreating every slot's -// task and refragmenting internal heap. The wait is short because the old route -// is already gone; a client whose event is late is aborted by keepalive and -// picked up by the normal backoff retry. +// So an attempt in flight is left alone here. Stopping it would mean calling +// esp_mqtt_client_stop(), which waits on the SDK's API mutex and its task's +// stopped event with no bound (see stopSlotClient()) — the client task notices +// only when it returns from whatever transport call it is in, up to +// network_timeout_ms for a TLS connect. During teardown that is contained by +// the bridge's StopUnproven timeout; during a transition nothing contains it, +// so paying it serially for up to five slots would freeze the sole MQTT worker +// far past the bound this path advertises, and would block the bridge's own +// stop handshake behind it. A routine Wi-Fi flap must not cost that. // -// Nothing here touches a client that is not live, so a Quarantined one (its SDK -// task was never joined) is left alone, as it must be for the rest of the boot. +// A connected client takes the cheap bounded path, and the esp-mqtt task stays, +// which is what keeps a flap from recreating every slot's task and +// refragmenting internal heap. Nothing here touches a client that is not live, +// so a Quarantined one (its SDK task was never joined) is left alone, as it +// must be for the rest of the boot. void MQTTBridge::closeLiveClientForLinkTransition(int index) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return; MQTTSlot& slot = _slots[index]; if (slot.client == nullptr || !clientStateIsLive(slot.client_state)) return; if (mqttClientStateHasAttemptInFlight(slot.client_state)) { - MQTT_DEBUG_PRINTLN("MQTT%d link transition during connect - stopping to cancel the attempt", + MQTT_DEBUG_PRINTLN("MQTT%d link transition during connect - leaving the attempt to resolve", index + 1); - stopSlotClient(index); return; } diff --git a/test/test_mqtt_client_state/test_mqtt_client_state.cpp b/test/test_mqtt_client_state/test_mqtt_client_state.cpp index 76cfba731..1ff2b97ff 100644 --- a/test/test_mqtt_client_state/test_mqtt_client_state.cpp +++ b/test/test_mqtt_client_state/test_mqtt_client_state.cpp @@ -76,26 +76,31 @@ TEST(MqttClientState, StopIsAcknowledgedOnlyWhenEveryClientIsProvenStopped) { } // A link transition closes each slot's transport because the route under it -// changed. The two predicates above are what decide how, and getting the -// Starting case wrong is not cosmetic: softDisconnect() returns immediately on -// a client that is not yet connected, so that attempt would run to completion -// against the OLD route and deliver a CONNECTED event the new attempt cannot be -// told apart from. -TEST(MqttClientState, LinkTransitionStopsOnlyClientsWithAnAttemptInFlight) { +// changed. These two predicates are what decide which clients it may touch. +// +// The distinction from a reconfigure matters: a reconfigure must cancel an +// in-flight attempt, because that attempt would report the OLD endpoint under +// the new configuration. A transition changes no configuration, so the attempt +// is left to resolve — cancelling it would mean an unbounded +// esp_mqtt_client_stop() on the sole MQTT worker during a routine link flap. +TEST(MqttClientState, LinkTransitionOnlyDisconnectsClientsWithNoAttemptInFlight) { for (MqttClientState s : kAllStates) { - const bool touched = mqttClientStateIsLive(s); - const bool needs_stop = touched && mqttClientStateHasAttemptInFlight(s); + const bool live = mqttClientStateIsLive(s); + const bool disconnected_here = live && !mqttClientStateHasAttemptInFlight(s); // Quarantined is never touched: its SDK task was not joined, so it stays // out of service for the rest of the boot. - if (s == MqttClientState::Quarantined) EXPECT_FALSE(touched) << mqttClientStateName(s); + if (s == MqttClientState::Quarantined) EXPECT_FALSE(live) << mqttClientStateName(s); // Nothing that is already proven stopped is worth a disconnect. - if (mqttClientStateIsProvenStopped(s)) EXPECT_FALSE(touched) << mqttClientStateName(s); + if (mqttClientStateIsProvenStopped(s)) EXPECT_FALSE(live) << mqttClientStateName(s); - EXPECT_EQ(s == MqttClientState::Starting, needs_stop) << mqttClientStateName(s); - // The cheap bounded path, which keeps the esp-mqtt task alive. EXPECT_EQ(s == MqttClientState::Connected || s == MqttClientState::Disconnected, - touched && !needs_stop) << mqttClientStateName(s); + disconnected_here) << mqttClientStateName(s); + // Starting is live, and still must not be disconnected here. + if (s == MqttClientState::Starting) { + EXPECT_TRUE(live); + EXPECT_FALSE(disconnected_here); + } } } From 4dd438f4992af5c3de567655ba197c92592594fb Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 19 Sep 2026 15:45:39 -0700 Subject: [PATCH 16/16] fix(network): keep the DNS snapshot typed and on the lwIP thread Follow-up to the DNS restore, from a second review round. lwIP's DNS functions belong to the TCP/IP thread, and both halves were called from elsewhere: the Ethernet snapshot from the Arduino event task, the Wi-Fi snapshot and every restore from the MQTT task. Both now go through esp_netif_tcpip_exec(), which is also what serializes the snapshot itself, so it no longer needs atomics. Neither caller is the TCP/IP thread, so the call cannot deadlock on itself. The snapshot keeps the whole ip_addr_t instead of an IPv4 word. These builds compile lwIP with IPv6 enabled, so reinterpreting a v6 server as v4 would have installed four meaningless bytes as a resolver. RDNSS is disabled here, so a v6 server should not arise today; storing the tagged value means it stays correct if that changes. Restore now writes every slot, empty ones included, so returning to a one-server network cannot leave the other medium's second server behind as a fallback that only fails slowly. A lease that carried no resolver at all is not recorded, so a medium that has never had DNS leaves the current resolver alone instead of wiping it. --- src/helpers/NetworkLink.cpp | 56 ++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/src/helpers/NetworkLink.cpp b/src/helpers/NetworkLink.cpp index 3590ffa80..ba87b35a5 100644 --- a/src/helpers/NetworkLink.cpp +++ b/src/helpers/NetworkLink.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #if defined(NETWORK_PREFER_ETHERNET) @@ -42,7 +43,9 @@ class NetworkLinkBase : public NetworkLink { bool _last_connected = false; unsigned long _last_status_check = 0; static constexpr int kDnsServers = 2; - std::atomic _dns_snapshot[kDnsServers] = {{0}, {0}}; + // Only ever touched on the lwIP thread (see snapshotDns()), so no atomics. + ip_addr_t _dns_snapshot[kDnsServers] = {}; + bool _dns_captured = false; AlertFaultPolicy::OutageSnapshot outage() const { return AlertFaultPolicy::unpackOutageSnapshot( @@ -86,6 +89,7 @@ class NetworkLinkBase : public NetworkLink { return outage(); } + private: // Remember the resolver this link's DHCP lease installed, so switching back // to it can put that resolver back. // @@ -98,26 +102,52 @@ class NetworkLinkBase : public NetworkLink { // different subnets that server is unreachable from Ethernet, and the node // reads as connected while every broker and NTP hostname fails to resolve, // until Ethernet's own DHCP renewal happens to fix it hours later. - void snapshotDns() { + // + // Both halves run through esp_netif_tcpip_exec() because lwIP's DNS functions + // belong to the TCP/IP thread, and these are called from the Arduino event + // task (Ethernet GOT_IP) and the MQTT task (Wi-Fi edge, and select()). That + // also serializes the snapshot itself, so it needs no atomics. Neither caller + // is the TCP/IP thread, so the call cannot deadlock on itself. + // + // The whole ip_addr_t is kept, not an IPv4 word: these builds compile lwIP + // with IPv6 on, and reinterpreting a v6 server as v4 would install four + // meaningless bytes as a resolver. (RDNSS is disabled here, so a v6 server + // should never appear — storing the tagged value means it stays correct if + // that ever changes.) + static esp_err_t snapshotDnsOnTcpipThread(void* ctx) { + NetworkLinkBase* self = static_cast(ctx); + bool any = false; for (int i = 0; i < kDnsServers; i++) { const ip_addr_t* server = dns_getserver(i); - _dns_snapshot[i].store( - (server != nullptr && !ip_addr_isany(server)) - ? ip4_addr_get_u32(ip_2_ip4(server)) : 0, - std::memory_order_relaxed); + self->_dns_snapshot[i] = (server != nullptr) ? *server : *IP_ADDR_ANY; + if (!ip_addr_isany_val(self->_dns_snapshot[i])) any = true; } + // A lease that carried no resolver at all (static configuration, or a + // server that offered none) must not be remembered as "this medium's DNS is + // nothing" — restoring that would wipe a working resolver for no gain. + if (any) self->_dns_captured = true; + return ESP_OK; } - // Only servers this link actually leased are restored; a medium that has - // never held a lease leaves the current resolver alone rather than blanking it. - void restoreDns() const { + static esp_err_t restoreDnsOnTcpipThread(void* ctx) { + NetworkLinkBase* self = static_cast(ctx); + if (!self->_dns_captured) return ESP_OK; + // Every slot is written, empty ones included: returning to a network with + // one server must not leave the other medium's second server behind as a + // fallback that only fails slowly. for (int i = 0; i < kDnsServers; i++) { - const uint32_t addr = _dns_snapshot[i].load(std::memory_order_relaxed); - if (addr == 0) continue; - const ip_addr_t server = IPADDR4_INIT(addr); - dns_setserver(i, &server); + dns_setserver(i, &self->_dns_snapshot[i]); } + return ESP_OK; } + + public: + void snapshotDns() { esp_netif_tcpip_exec(snapshotDnsOnTcpipThread, this); } + + // A medium that has never held a lease with a resolver leaves the current one + // alone rather than blanking it. + void restoreDns() { esp_netif_tcpip_exec(restoreDnsOnTcpipThread, this); } + }; class WiFiNetworkLink final : public NetworkLinkBase {