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);