diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index ac74485d..774ff63a 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 48322c6a..cfbe35d8 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 539a1715..6cc179d8 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 ee66520f..506aef64 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 90d5ea4b..01b157cc 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 47f2f68d..f5e0be7e 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 8dab1cdc..475d20a3 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 c38d804e..547889a1 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 171d81c8..d9617ca7 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 5452c50c..19cdce77 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 c390da0b..ecee1545 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 f783b52c..b3b968cc 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 7401825b..c5072f15 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 1d4d2d39..ae5273e3 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 a4e631a1..a5e15274 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 731ed349..fe434a03 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 8b48401f..12bf9a30 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 34bdca4b..37740e8c 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 0dcebebd..79cb167b 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 421ea52b..3427bb60 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 201c7451..93e0a1f2 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 2ccf6f5c..890faee2 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 ab7b43d6..a71bd1e4 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],