diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index b46714877..d5eeb8bc1 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -80,7 +80,8 @@ reboot **9. Verify configuration** ```bash get wifi.ssid -get wifi.status +get link.status +get link.diag get bridge.enabled get mqtt.rx get mqtt.tx @@ -224,11 +225,14 @@ pio run -e ThinkNode_M7_repeater_observer_mqtt pio run -e ThinkNode_M7_room_server_observer_mqtt ``` -**ThinkNode M7 — WiFi only:** the M7 has an onboard CH390 Ethernet controller, and -`ThinkNode_M7_companion_radio_ethernet` uses it, but the MQTT bridge's link -management is bound to the WiFi station API, so the observer envs uplink over WiFi. -See `UPSTREAM_BUGS.md` for the Ethernet gap. The board has PSRAM, so these builds -get neighbors publication (`WITH_MQTT_NEIGHBORS`) automatically. +**ThinkNode M7 — Ethernet preferred, WiFi fallback:** the observer envs use the onboard +CH390 Ethernet when it has a DHCP lease at boot, otherwise the stored WiFi network, and +switch between the two at runtime (`get link.status`, `get link.diag`). Nodes without a +cable behave like WiFi observers after a brief (about 1 s) Ethernet probe at boot. The DHCP hostname is +`meshcore-` on either medium. A first boot on Ethernet with no WiFi configured +opens WebConfig on the LAN with a one-time login code printed on serial (and shown on +the display), and requires replacing the admin password. The board has PSRAM, so these +builds get neighbors publication (`WITH_MQTT_NEIGHBORS`) automatically. **TLora naming:** The env prefix `LilyGo_TLora_V2_1_1_6` is LilyGo’s **T-LoRa V2.1–1.6** board (SX1276); PlatformIO selects **`ttgo-lora32-v1`** (TTGO LoRa32 V1.0). **MQTT observer** envs extend a slim base **without** `sensor_base` so the image fits `min_spiffs`; **all other** `LilyGo_TLora_V2_1_1_6_*` targets still use optional I2C environmental sensors as before. The **`lilygo_tlora_c6`** variant is separate hardware (ESP32-C6). @@ -546,12 +550,14 @@ 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 link.diag` - Explain automatic Ethernet selection using controller initialization, link event, IP, WiFi fallback, route-lock, and reason state +- `get wifi.status` - WiFi-only compatibility alias; reports n/a when another medium is selected - `get wifi.powersave` - Get WiFi power save mode (none/min/max) #### Set Commands @@ -863,6 +869,22 @@ the radio actually performs in that case. ### Connection Handling - Automatic reconnection with exponential backoff per slot; a slot that stays down through the full backoff ladder is retried on a slow periodic probe instead of hammering the broker +- Ethernet-preferred builds wait the full configured boot probe window for an Ethernet IP; + delayed or unavailable PHY carrier reporting does not shorten the DHCP deadline. The boot + selection log includes the measured probe duration. +- CH390 startup initializes Arduino's shared network event runtime without associating WiFi; + this keeps the framework's DNS and TLS hostname paths safe when Ethernet wins directly. +- Ethernet/WiFi transitions are logged. A lost or changed route closes every live MQTT + transport with a bounded disconnect that keeps the client task; a slot that was still + connecting is stopped instead, so its attempt cannot complete against the old route and + report a connection that no longer exists. When the same link returns, each slot gets one + immediate attempt at its current backoff rung (a tripped circuit breaker gets one immediate + probe); a switch to the other medium also clears backoff and breakers +- Each medium's DHCP DNS servers are remembered when it gets its lease and restored when it is + selected again. lwIP keeps one global resolver list, so without this a node that fell back to + WiFi and then failed back to Ethernet would keep asking the WiFi network's DNS server +- WiFi credentials changed at runtime (`set wifi.ssid` / `set wifi.pwd`) are used on the next + reconnect attempt without a reboot - Packets are queued while a slot is disconnected and flushed when it recovers ### Raw Radio Data Capture diff --git a/docs/cli_commands.md b/docs/cli_commands.md index b7f3f47ca..aec487208 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 a8a0b2018..d6a9554dd 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,7 +1,13 @@ #include "MyMesh.h" #include +#include #include // for qsort() #include +#include +#include +#if defined(ESP_PLATFORM) +#include +#endif #if defined(WITH_MQTT_NEIGHBORS) #include // kSyncedClockEpoch #endif @@ -1142,6 +1148,26 @@ void MyMesh::begin(FILESYSTEM *fs) { } #endif +#if defined(WITH_MQTT_BRIDGE) && defined(ESP_PLATFORM) + NetworkLink& boot_network = activeNetworkLink(); + if (boot_network.isAutomatic()) { + char network_hostname[NetworkHostname::kBufferSize]; + NetworkHostname::build(network_hostname, sizeof(network_hostname), + _prefs.node_name, self_id.pub_key, PUB_KEY_SIZE); + boot_network.setHostname(network_hostname); + Serial.printf("Network: hostname %s\n", network_hostname); + MQTTPrefs* obs = _cli.getObserverPrefs(); + Serial.printf("Network: probing Ethernet for up to %lums\n", + (unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS); + const uint32_t ethernet_probe_started_at = millis(); + boot_network.bootstrap(obs->wifi_ssid, obs->wifi_password, + NETWORK_ETHERNET_BOOT_WAIT_MS); + Serial.printf("Network: selected %s (%s) after %lums\n", + boot_network.mediumName(), boot_network.statusName(), + (unsigned long)(millis() - ethernet_probe_started_at)); + } +#endif + acl.load(_fs, self_id); // TODO: key_store.begin(); region_map.load(_fs); @@ -1216,9 +1242,9 @@ 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 (_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); @@ -1479,15 +1505,18 @@ 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() && + activeNetworkLink().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 (activeNetworkLink().isConnected()) { + _webconfig->startLanMode(!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; } @@ -1522,12 +1551,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; - if (WiFi.status() == WL_CONNECTED) { - strncpy(ip, WiFi.localIP().toString().c_str(), sizeof(ip) - 1); - wifi_rssi = WiFi.RSSI(); - } else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) { + char wifi_rssi[12] = "null"; + NetworkLink& network = activeNetworkLink(); + const char* network_medium = network.mediumName(); + if (network.isConnected()) { + strncpy(ip, network.localIP().toString().c_str(), sizeof(ip) - 1); + const int signal = network.rssi(); + 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," @@ -1536,7 +1570,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(), @@ -1547,7 +1582,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/MyMesh.h b/examples/simple_repeater/MyMesh.h index 4b12f6d0c..aebfa2e6c 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -517,6 +517,15 @@ public: _wc_slot_restart_mask = 0; } void onConfigBatchEnd() override; + bool onInitialSetupComplete() override { + // The password command does not report its save; make it durable first. + if (!_cli.savePrefs(_fs)) return false; + MQTTPrefs* obs = _cli.getObserverPrefs(); + obs->network_setup_complete = 1; + if (_cli.saveObserverPrefs(_fs)) return true; + obs->network_setup_complete = 0; + return false; + } void buildStatsJson(char* buf, size_t buf_size) override; #endif diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index ee66520f4..506aef648 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -174,15 +174,16 @@ void UITask::renderCurrScreen() { } char wc_ssid[33], wc_ip[16]; if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) { + const bool lan_setup = WebConfigServer::isLanSetup(); // setup portal active: show join instructions instead of the home screen _display->setTextSize(1); _display->setColor(UIColor::corp_blue); _display->setCursor(0, 0); - _display->print("Observer WiFi Setup"); + _display->print(lan_setup ? "Observer Ethernet Setup" : "Observer WiFi Setup"); _display->setColor(UIColor::primary_txt); _display->setCursor(0, 14); - _display->print("Join WiFi:"); + _display->print(lan_setup ? "Login code:" : "Join WiFi:"); _display->setColor(UIColor::warning_txt); _display->setCursor(6, 24); _display->print(wc_ssid); diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 3ad160fdc..13d643ffa 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); // `command` must stay NUL-terminated within its bounds. If it ever isn't, diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 7c13018c8..146c547e7 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,6 +1,12 @@ #include "MyMesh.h" #include +#include #include +#include +#include +#if defined(ESP_PLATFORM) +#include +#endif #if defined(WITH_MQTT_NEIGHBORS) #include // kSyncedClockEpoch #endif @@ -943,6 +949,26 @@ void MyMesh::begin(FILESYSTEM *fs) { // load persisted prefs _cli.loadPrefs(_fs); +#if defined(WITH_MQTT_BRIDGE) && defined(ESP_PLATFORM) + NetworkLink& boot_network = activeNetworkLink(); + if (boot_network.isAutomatic()) { + char network_hostname[NetworkHostname::kBufferSize]; + NetworkHostname::build(network_hostname, sizeof(network_hostname), + _prefs.node_name, self_id.pub_key, PUB_KEY_SIZE); + boot_network.setHostname(network_hostname); + Serial.printf("Network: hostname %s\n", network_hostname); + MQTTPrefs* obs = _cli.getObserverPrefs(); + Serial.printf("Network: probing Ethernet for up to %lums\n", + (unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS); + const uint32_t ethernet_probe_started_at = millis(); + boot_network.bootstrap(obs->wifi_ssid, obs->wifi_password, + NETWORK_ETHERNET_BOOT_WAIT_MS); + Serial.printf("Network: selected %s (%s) after %lums\n", + boot_network.mediumName(), boot_network.statusName(), + (unsigned long)(millis() - ethernet_probe_started_at)); + } +#endif + acl.load(_fs, self_id); region_map.load(_fs); @@ -1018,9 +1044,9 @@ 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 (_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); @@ -1281,15 +1307,18 @@ 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() && + activeNetworkLink().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 (activeNetworkLink().isConnected()) { + _webconfig->startLanMode(!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; } @@ -1324,12 +1353,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; - if (WiFi.status() == WL_CONNECTED) { - strncpy(ip, WiFi.localIP().toString().c_str(), sizeof(ip) - 1); - wifi_rssi = WiFi.RSSI(); - } else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) { + char wifi_rssi[12] = "null"; + NetworkLink& network = activeNetworkLink(); + const char* network_medium = network.mediumName(); + if (network.isConnected()) { + strncpy(ip, network.localIP().toString().c_str(), sizeof(ip) - 1); + const int signal = network.rssi(); + 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," @@ -1338,7 +1372,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(), @@ -1349,7 +1384,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/MyMesh.h b/examples/simple_room_server/MyMesh.h index 34cb29591..bfcfe3624 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -507,6 +507,15 @@ public: _wc_slot_restart_mask = 0; } void onConfigBatchEnd() override; + bool onInitialSetupComplete() override { + // The password command does not report its save; make it durable first. + if (!_cli.savePrefs(_fs)) return false; + MQTTPrefs* obs = _cli.getObserverPrefs(); + obs->network_setup_complete = 1; + if (_cli.saveObserverPrefs(_fs)) return true; + obs->network_setup_complete = 0; + return false; + } void buildStatsJson(char* buf, size_t buf_size) override; #endif }; diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index 8dab1cdc2..475d20a3c 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -155,15 +155,16 @@ void UITask::renderCurrScreen() { } char wc_ssid[33], wc_ip[16]; if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) { + const bool lan_setup = WebConfigServer::isLanSetup(); // setup portal active: show join instructions instead of the home screen _display->setTextSize(1); _display->setColor(UIColor::corp_blue); _display->setCursor(0, 0); - _display->print("Observer WiFi Setup"); + _display->print(lan_setup ? "Observer Ethernet Setup" : "Observer WiFi Setup"); _display->setColor(UIColor::primary_txt); _display->setCursor(0, 14); - _display->print("Join WiFi:"); + _display->print(lan_setup ? "Login code:" : "Join WiFi:"); _display->setColor(UIColor::warning_txt); _display->setCursor(6, 24); _display->print(wc_ssid); diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index df2de9725..8308825aa 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); // `command` must stay NUL-terminated within its bounds. If it ever isn't, // strlen() above can return >= sizeof(command) and the loop below would then diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index c38d804e3..74c6b7302 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -497,6 +497,12 @@ 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)), + "link.diag": lambda c: ( + "why:ethernet-not-enabled selected:wifi\n" + "wifi:state:connected ip:192.168.1.42"), "mqtt.status": lambda c: cli_mqtt_status(c), "mqtt.presets": lambda c: "\n".join( "%2d. %s%s" % (i + 1, n, "" if nd == "none" else " (needs %s)" % nd) diff --git a/src/MeshCore.h b/src/MeshCore.h index 3b1d1c059..9ce7b1026 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/AlertFaultPolicy.h b/src/helpers/AlertFaultPolicy.h index d15aaf1f6..1dbbac492 100644 --- a/src/helpers/AlertFaultPolicy.h +++ b/src/helpers/AlertFaultPolicy.h @@ -220,39 +220,58 @@ static inline void formatAge(uint32_t age_ms, char* out, size_t out_size) { } } -static inline void formatWifiDown(char* out, size_t out_size, uint32_t duration_ms, - uint8_t reason) { +static inline void formatNetworkDown(char* out, size_t out_size, const char* medium, + uint32_t duration_ms, uint8_t reason) { if (!out || out_size == 0) return; char age[16]; formatAge(duration_ms, age, sizeof(age)); if (reason != 0) { - snprintf(out, out_size, "WiFi down %s (reason %u)", age, (unsigned)reason); + snprintf(out, out_size, "%s down %s (reason %u)", medium, age, (unsigned)reason); } else { - snprintf(out, out_size, "WiFi down %s", age); + snprintf(out, out_size, "%s down %s", medium, age); } } +static inline void formatNetworkRecovered(char* out, size_t out_size, + const char* medium, + uint32_t duration_ms) { + if (!out || out_size == 0) return; + char age[16]; + formatAge(duration_ms, age, sizeof(age)); + snprintf(out, out_size, "%s recovered after %s", medium, age); +} + +// Compatibility helpers retained for existing callers and native tests. +static inline void formatWifiDown(char* out, size_t out_size, + uint32_t duration_ms, uint8_t reason) { + formatNetworkDown(out, out_size, "WiFi", duration_ms, reason); +} + static inline void formatWifiRecovered(char* out, size_t out_size, uint32_t duration_ms) { - if (!out || out_size == 0) return; - char age[16]; - formatAge(duration_ms, age, sizeof(age)); - snprintf(out, out_size, "WiFi recovered after %s", age); + formatNetworkRecovered(out, out_size, "WiFi", duration_ms); +} + +static inline bool formatNetworkAlert(char* out, size_t out_size, + const char* medium, const TickResult& r, + const OutageSnapshot& snap) { + const char* label = (medium && *medium) ? medium : "Network"; + if (r.action == Action::FireDown) { + formatNetworkDown(out, out_size, label, r.duration_ms, snap.reason); + return true; + } + if (r.action == Action::FireRecovered) { + formatNetworkRecovered(out, out_size, label, r.duration_ms); + return true; + } + return false; } // Production formatting entry: the same (TickResult, OutageSnapshot) pair // AlertReporter feeds after tick(). Returns false when there is no message. static inline bool formatWifiAlert(char* out, size_t out_size, const TickResult& r, const OutageSnapshot& snap) { - if (r.action == Action::FireDown) { - formatWifiDown(out, out_size, r.duration_ms, snap.reason); - return true; - } - if (r.action == Action::FireRecovered) { - formatWifiRecovered(out, out_size, r.duration_ms); - return true; - } - return false; + return formatNetworkAlert(out, out_size, "WiFi", r, snap); } static inline void formatMqttDown(char* out, size_t out_size, int slot_1based, diff --git a/src/helpers/AlertReporter.cpp b/src/helpers/AlertReporter.cpp index b55ac90c8..4a58ad612 100644 --- a/src/helpers/AlertReporter.cpp +++ b/src/helpers/AlertReporter.cpp @@ -6,6 +6,7 @@ #include #ifdef WITH_MQTT_BRIDGE #include "AlertFaultPolicy.h" +#include "NetworkLink.h" #endif // Header layout for PAYLOAD_TYPE_GRP_TXT before encryption: @@ -207,23 +208,29 @@ 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), min_interval_ms); if (r.action == AlertFaultPolicy::Action::FireDown) { char text[80]; - AlertFaultPolicy::formatWifiAlert(text, sizeof(text), r, snap); + AlertFaultPolicy::formatNetworkAlert( + 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::formatWifiAlert(text, sizeof(text), r, snap); + AlertFaultPolicy::formatNetworkAlert( + 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/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index dbb6e0214..2696b8363 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -15,8 +15,10 @@ #include "TxtDataHelpers.h" #include "AlertReporter.h" // for alertReporterBannedChannelMatch[Hex]() #include "MQTTObserverValidation.h" // pure input validators (host-testable) +#include "NetworkLink.h" #include "WifiPowerSavePolicy.h" // one powersave value->mode/name mapping #include +#include #include #ifdef ESP_PLATFORM #include @@ -415,8 +417,9 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf // runs on the Arduino loop task, shared with mesh/radio processing and the // web config batch, so a synchronous wait of up to 30 s would stall the // node. The sync runs in the background; verify with `get mqtt.ntp.diag`. - if (WiFi.status() != WL_CONNECTED) { - strcpy(reply, "OK - saved (WiFi not connected; NTP sync pending)"); + if (!activeNetworkLink().isConnected()) { + snprintf(reply, 160, "OK - saved (%s not connected; NTP sync pending)", + activeNetworkLink().mediumName()); } else if (!_callbacks->isMqttBridgeRunning()) { strcpy(reply, "OK - saved (MQTT bridge not running)"); } else if (_callbacks->syncMqttNtp()) { @@ -447,8 +450,9 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf if (!persistObserverPrefs(reply)) return true; const char* ps_name = WifiPowerSavePolicy::nameFor(ps_value); #ifdef ESP_PLATFORM - if (WiFi.status() == WL_CONNECTED) { - // Same mapping the bridge applies on every association, so this cannot + if (strcmp(activeNetworkLink().mediumName(), "wifi") == 0 && + activeNetworkLink().isConnected()) { + // Same mapping the link applies on every association, so this cannot // drift back apart (see WifiPowerSavePolicy). esp_err_t ps_result = esp_wifi_set_ps((wifi_ps_type_t)WifiPowerSavePolicy::modeFor(ps_value)); @@ -946,8 +950,9 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf #ifdef ESP_PLATFORM // Connectivity probe across all configured NTP servers; never updates the clock. // Serial console (sender_timestamp == 0) gets a detailed table; LoRa gets a compact list. - if (WiFi.status() != WL_CONNECTED) { - strcpy(reply, "Error: WiFi not connected"); + if (!activeNetworkLink().isConnected()) { + snprintf(reply, 160, "Error: %s not connected", + activeNetworkLink().mediumName()); } else if (!_callbacks->isMqttBridgeRunning()) { strcpy(reply, "Error: MQTT bridge not running"); } else if (!_callbacks->runMqttNtpDiag(reply, 160, sender_timestamp == 0)) { @@ -1021,20 +1026,30 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf } else { strcpy(reply, _mqtt_prefs.wifi_password[0] ? "> ******** (serial only)" : "> (not set)"); } - } else if (memcmp(config, "wifi.status", 11) == 0) { - wl_status_t status = WiFi.status(); - const char* status_str; - switch (status) { - case WL_CONNECTED: status_str = "connected"; break; - case WL_NO_SSID_AVAIL: status_str = "no_ssid"; break; - case WL_CONNECT_FAILED: status_str = "connect_failed"; break; - case WL_CONNECTION_LOST: status_str = "connection_lost"; break; - case WL_DISCONNECTED: status_str = "disconnected"; break; - case 255: status_str = "not_started"; break; - default: status_str = "unknown"; break; + } else if (strcmp(config, "link.diag") == 0) { + activeNetworkLink().formatDiagnostics(reply, 160); + } else if (memcmp(config, "link.status", 11) == 0 || + memcmp(config, "wifi.status", 11) == 0) { + NetworkLink& network = activeNetworkLink(); + const bool wifi_alias = config[0] == 'w'; + if (wifi_alias && strcmp(network.mediumName(), "wifi") != 0) { + snprintf(reply, 160, "> n/a (%s selected; use get link.status)", + network.mediumName()); + return true; } - if (status == WL_CONNECTED) { - sprintf(reply, "> %s, IP: %s, RSSI: %d dBm", status_str, WiFi.localIP().toString().c_str(), WiFi.RSSI()); + const bool connected = network.isConnected(); + if (connected) { + const int signal = network.rssi(); + if (wifi_alias) { + snprintf(reply, 160, "> %s, IP: %s, RSSI: %d dBm", + network.statusName(), network.localIP().toString().c_str(), signal); + } else if (signal == INT_MIN) { + snprintf(reply, 160, "> %s: connected, IP: %s", network.mediumName(), + network.localIP().toString().c_str()); + } else { + snprintf(reply, 160, "> %s: connected, IP: %s, RSSI: %d dBm", + network.mediumName(), network.localIP().toString().c_str(), signal); + } #ifdef WITH_MQTT_BRIDGE unsigned long connect_at = MQTTBridge::getWifiConnectedAtMillis(); if (connect_at != 0) { @@ -1062,19 +1077,35 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf #endif } else { #ifdef WITH_MQTT_BRIDGE - uint8_t reason = MQTTBridge::getLastWifiDisconnectReason(); + uint8_t reason = network.lastDisconnectReason(); if (reason != 0) { const char* desc = MQTTBridge::wifiReasonStr(reason); if (desc) { - sprintf(reply, "> %s: %s (reason: %d)", status_str, desc, reason); + if (wifi_alias) { + sprintf(reply, "> %s: %s (reason: %d)", network.statusName(), desc, reason); + } else { + sprintf(reply, "> %s: %s (reason: %d)", network.mediumName(), desc, reason); + } } else { - sprintf(reply, "> %s: reason %d", status_str, reason); + if (wifi_alias) { + sprintf(reply, "> %s: reason %d", network.statusName(), reason); + } else { + sprintf(reply, "> %s: reason %d", network.mediumName(), reason); + } } } else { - sprintf(reply, "> %s (code: %d)", status_str, status); + if (wifi_alias) { + sprintf(reply, "> %s (code: %d)", network.statusName(), network.statusCode()); + } else { + sprintf(reply, "> %s: %s", network.mediumName(), network.statusName()); + } } #else - sprintf(reply, "> %s (code: %d)", status_str, status); + if (wifi_alias) { + sprintf(reply, "> %s (code: %d)", network.statusName(), network.statusCode()); + } else { + sprintf(reply, "> %s: %s", network.mediumName(), network.statusName()); + } #endif } } else if (memcmp(config, "wifi.powersave", 14) == 0) { @@ -1136,8 +1167,9 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, #ifdef WITH_MQTT_BRIDGE if (memcmp(command, "tls.bundletest ", 15) == 0) { #ifdef ESP_PLATFORM - if (WiFi.status() != WL_CONNECTED) { - strcpy(reply, "ERR: WiFi not connected"); + if (!activeNetworkLink().isConnected()) { + snprintf(reply, 160, "ERR: %s not connected", + activeNetworkLink().mediumName()); } else { size_t bundle_len = 0; if (rootca_crt_bundle_start != nullptr && @@ -1182,8 +1214,9 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, // ota check -> report available build, do not flash // ota update -> download and flash, then reboot #if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_BASE) - if (WiFi.status() != WL_CONNECTED) { - strcpy(reply, "ERR: WiFi not connected"); + if (!activeNetworkLink().isConnected()) { + snprintf(reply, 160, "ERR: %s not connected", + activeNetworkLink().mediumName()); } else if (memcmp(command, "ota check", 9) == 0) { // Check is synchronous so its result lands in this reply, and runs with the // MQTT bridge UP: the slim per-variant manifest is tiny, so the fetch only diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 120203ab0..19c3e5cc9 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -1,6 +1,7 @@ #ifdef ESP_PLATFORM #include "ESP32Board.h" +#include "NetworkLink.h" #include #if defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA) // Repeater or Room Server only @@ -8,54 +9,161 @@ #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) { + activeNetworkLink().unlockSwitching(); + ota_network_locked = false; + } + HttpPort80Lease::release(HttpPort80Lease::Owner::Ota); +} + +} // namespace bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { - inhibit_sleep = true; // prevent sleep during OTA + 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; + } - // If the device is already on a WiFi network (e.g. an observer joined in STA - // mode), serve ElegantOTA on the station IP so it's reachable from the LAN - // without joining a separate AP. Otherwise raise the MeshCore-OTA SoftAP. + inhibit_sleep = true; // prevent sleep during OTA + activeNetworkLink().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 + // the MeshCore-OTA SoftAP. // force_ap ("start ota ap") always raises the SoftAP, so the OTA UI stays // reachable even when the joined network applies client isolation and the // station IP can't be reached. IPAddress ip; - if (!force_ap && WiFi.status() == WL_CONNECTED) { - ip = WiFi.localIP(); + if (NetworkPolicy::startOtaUsesSelectedNetwork( + force_ap, activeNetworkLink().isConnected())) { + ip = activeNetworkLink().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 // --------------------------------------------------------------------------- @@ -182,16 +290,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. + NetworkLink& network = activeNetworkLink(); + 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; } @@ -200,8 +312,9 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char strcpy(reply, "ERR: OTA not configured (build via build.sh)"); return false; #else - if (WiFi.status() != WL_CONNECTED) { - strcpy(reply, "ERR: WiFi not connected"); + if (!activeNetworkLink().isConnected()) { + snprintf(reply, 160, "ERR: %s not connected", + activeNetworkLink().mediumName()); return false; } 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/MQTTDefaults.h b/src/helpers/MQTTDefaults.h index c390da0b4..ecee15459 100644 --- a/src/helpers/MQTTDefaults.h +++ b/src/helpers/MQTTDefaults.h @@ -112,6 +112,7 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) { prefs->display_timeout_secs = DISPLAY_TIMEOUT_DEFAULT_SECS; prefs->display_flip = 0; + prefs->network_setup_complete = 0; } #endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTPrefsSerializer.h b/src/helpers/MQTTPrefsSerializer.h index e256e6a9d..7a772c269 100644 --- a/src/helpers/MQTTPrefsSerializer.h +++ b/src/helpers/MQTTPrefsSerializer.h @@ -40,22 +40,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 > WifiPowerSavePolicy::kMaxStoredValue) { _power_save = 1; *repaired = true; } + if (_setup_complete < 0 || _setup_complete > 1) { + _setup_complete = 0; + *repaired = true; + } _prefs->wifi_power_save = static_cast(_power_save); + _prefs->network_setup_complete = static_cast(_setup_complete); return true; } }; diff --git a/src/helpers/MQTTPrefsStorage.h b/src/helpers/MQTTPrefsStorage.h index 7401825b8..c5072f15c 100644 --- a/src/helpers/MQTTPrefsStorage.h +++ b/src/helpers/MQTTPrefsStorage.h @@ -126,8 +126,18 @@ struct MQTTPrefs { // Rotate the panel 180 degrees from its compiled DISPLAY_ROTATION, for boards // mounted the other way up. Runtime only, like display_timeout_secs. uint8_t display_flip; + + // Explicitly records completion of first-run network onboarding when a node + // is configured over Ethernet and therefore may intentionally have no Wi-Fi + // SSID. Existing Wi-Fi installations remain complete by the helper below. + uint8_t network_setup_complete; }; +static inline bool mqttNetworkSetupComplete(const MQTTPrefs* prefs) { + return prefs != nullptr && + (prefs->network_setup_complete != 0 || prefs->wifi_ssid[0] != '\0'); +} + static const uint16_t DISPLAY_TIMEOUT_DEFAULT_SECS = 60; static const uint16_t DISPLAY_TIMEOUT_MAX_SECS = 3600; diff --git a/src/helpers/NetworkHostname.h b/src/helpers/NetworkHostname.h new file mode 100644 index 000000000..caf6396b7 --- /dev/null +++ b/src/helpers/NetworkHostname.h @@ -0,0 +1,107 @@ +#pragma once + +#include +#include + +namespace NetworkHostname { + +// ESP-IDF documents a 32-byte hostname limit. Keep one byte for the trailing +// NUL so the same buffer is safe through Arduino and lwIP APIs. +static constexpr size_t kMaxLength = 31; +static constexpr size_t kBufferSize = kMaxLength + 1; + +static inline bool isAsciiAlphaNumeric(uint8_t ch) { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9'); +} + +static inline char asciiLower(uint8_t ch) { + return ch >= 'A' && ch <= 'Z' ? static_cast(ch + ('a' - 'A')) + : static_cast(ch); +} + +/** + * Build a DHCP-safe hostname from the MeshCore node name. + * + * The result is lowercase, starts with "meshcore-", contains only letters, + * digits and hyphens, and never exceeds ESP-IDF's practical 31-character + * payload limit. 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) { + if (!dest || dest_size == 0) return false; + dest[0] = '\0'; + + static constexpr char kPrefix[] = "meshcore-"; + static constexpr char kFallback[] = "node"; + static constexpr char kHex[] = "0123456789abcdef"; + static constexpr size_t kPrefixLength = sizeof(kPrefix) - 1; + static constexpr size_t kSuffixLength = 7; // '-' plus six hex digits. + + // NodePrefs::node_name currently holds at most 31 bytes. A larger scratch + // buffer keeps this helper safe and independently testable with other input. + char slug[64]; + size_t slug_length = 0; + bool separator_pending = false; + bool removed_non_ascii = false; + if (node_name) { + for (size_t i = 0; node_name[i] != '\0' && slug_length < sizeof(slug) - 1; + ++i) { + const uint8_t ch = static_cast(node_name[i]); + if (isAsciiAlphaNumeric(ch)) { + if (separator_pending && slug_length > 0 && + slug_length < sizeof(slug) - 1) { + slug[slug_length++] = '-'; + } + if (slug_length < sizeof(slug) - 1) { + slug[slug_length++] = asciiLower(ch); + } + separator_pending = false; + } else { + if (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]; + } + } + slug[slug_length] = '\0'; + + size_t max_length = dest_size - 1; + if (max_length > kMaxLength) max_length = kMaxLength; + if (max_length == 0) return false; + + const bool needs_truncation = kPrefixLength + slug_length > max_length; + const bool 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 + ? kPrefixLength : max_length; + size_t slug_budget = max_length - prefix_length - suffix_length; + if (slug_budget > slug_length) slug_budget = slug_length; + while (slug_budget > 0 && slug[slug_budget - 1] == '-') --slug_budget; + + size_t out = 0; + for (size_t i = 0; i < prefix_length; ++i) dest[out++] = kPrefix[i]; + for (size_t i = 0; i < slug_budget; ++i) dest[out++] = slug[i]; + if (can_add_identity) { + dest[out++] = '-'; + for (size_t i = 0; i < 3; ++i) { + dest[out++] = kHex[(stable_id[i] >> 4) & 0x0f]; + dest[out++] = kHex[stable_id[i] & 0x0f]; + } + } + dest[out] = '\0'; + return true; +} + +} // namespace NetworkHostname diff --git a/src/helpers/NetworkLink.cpp b/src/helpers/NetworkLink.cpp new file mode 100644 index 000000000..ba87b35a5 --- /dev/null +++ b/src/helpers/NetworkLink.cpp @@ -0,0 +1,979 @@ +#include "NetworkLink.h" + +#if defined(ESP_PLATFORM) + +#include "MQTTConnectionPolicy.h" +#include "WifiPowerSavePolicy.h" + +#include +#include +#include + +#include +#include +#include +#include + +#if defined(NETWORK_PREFER_ETHERNET) +#include "ethernet/ch390/CH390Config.h" + +// Arduino-ESP32's built-in ETH implementation calls this before bringing up +// Ethernet. It creates the shared Arduino network event group/task used by +// WiFiGenericClass::hostByName(), even when no Wi-Fi interface is started. +// ESP32-CH390 initializes esp_netif directly and omits this Arduino layer. +extern void tcpipInit(); +#endif + +// Same "MQTT: " prefix as MQTT_DEBUG_PRINTLN so existing log greps keep matching. +#if defined(MQTT_DEBUG) + #define NETWORK_DEBUG_PRINTLN(F, ...) do { if (Serial.availableForWrite() > 0) { Serial.printf("MQTT: " F "\n", ##__VA_ARGS__); } } while (0) +#else + #define NETWORK_DEBUG_PRINTLN(...) do {} while (0) +#endif + +namespace { + +class NetworkLinkBase : public NetworkLink { + protected: + std::atomic _outage_bits{AlertFaultPolicy::packOutageSnapshot({false, 0, 0})}; + std::atomic _connected_at{0}; + std::atomic _last_disconnect_time{0}; + std::atomic _last_disconnect_reason{0}; + bool _status_initialized = false; + bool _last_connected = false; + unsigned long _last_status_check = 0; + static constexpr int kDnsServers = 2; + // Only ever touched on the lwIP thread (see snapshotDns()), so no atomics. + ip_addr_t _dns_snapshot[kDnsServers] = {}; + bool _dns_captured = false; + + AlertFaultPolicy::OutageSnapshot outage() const { + return AlertFaultPolicy::unpackOutageSnapshot( + _outage_bits.load(std::memory_order_acquire)); + } + + void setOutage(AlertFaultPolicy::OutageSnapshot snapshot) { + _outage_bits.store(AlertFaultPolicy::packOutageSnapshot(snapshot), + std::memory_order_release); + } + + void noteConnected(unsigned long now_ms) { + if (_connected_at.load(std::memory_order_relaxed) == 0) { + _connected_at.store(now_ms, std::memory_order_relaxed); + } + setOutage(AlertFaultPolicy::applyWifiGotIp(outage())); + } + + void noteDisconnected(unsigned long now_ms, uint8_t reason) { + _last_disconnect_reason.store(reason, std::memory_order_relaxed); + _last_disconnect_time.store(now_ms, std::memory_order_relaxed); + setOutage(AlertFaultPolicy::applyWifiDisconnectEvent( + (uint32_t)now_ms, reason, outage())); + } + + + public: + unsigned long connectedAtMillis() const override { + return _connected_at.load(std::memory_order_relaxed); + } + + uint8_t lastDisconnectReason() const override { + return _last_disconnect_reason.load(std::memory_order_relaxed); + } + + unsigned long lastDisconnectTime() const override { + return _last_disconnect_time.load(std::memory_order_relaxed); + } + + AlertFaultPolicy::OutageSnapshot outageSnapshot() const override { + return outage(); + } + + private: + // Remember the resolver this link's DHCP lease installed, so switching back + // to it can put that resolver back. + // + // lwIP keeps ONE global server list, not one per interface: a DHCP lease on + // any medium calls dns_setserver() and overwrites whatever the other medium + // had. IDF 4.4 has no per-interface retention either — esp_netif_get_dns_info() + // reads the same globals — so the medium that leased last owns DNS for every + // socket. Without this, an Ethernet node that falls back to Wi-Fi and then + // fails back keeps asking the Wi-Fi network's DNS server. When the two are on + // different subnets that server is unreachable from Ethernet, and the node + // reads as connected while every broker and NTP hostname fails to resolve, + // until Ethernet's own DHCP renewal happens to fix it hours later. + // + // Both halves run through esp_netif_tcpip_exec() because lwIP's DNS functions + // belong to the TCP/IP thread, and these are called from the Arduino event + // task (Ethernet GOT_IP) and the MQTT task (Wi-Fi edge, and select()). That + // also serializes the snapshot itself, so it needs no atomics. Neither caller + // is the TCP/IP thread, so the call cannot deadlock on itself. + // + // The whole ip_addr_t is kept, not an IPv4 word: these builds compile lwIP + // with IPv6 on, and reinterpreting a v6 server as v4 would install four + // meaningless bytes as a resolver. (RDNSS is disabled here, so a v6 server + // should never appear — storing the tagged value means it stays correct if + // that ever changes.) + static esp_err_t snapshotDnsOnTcpipThread(void* ctx) { + NetworkLinkBase* self = static_cast(ctx); + bool any = false; + for (int i = 0; i < kDnsServers; i++) { + const ip_addr_t* server = dns_getserver(i); + self->_dns_snapshot[i] = (server != nullptr) ? *server : *IP_ADDR_ANY; + if (!ip_addr_isany_val(self->_dns_snapshot[i])) any = true; + } + // A lease that carried no resolver at all (static configuration, or a + // server that offered none) must not be remembered as "this medium's DNS is + // nothing" — restoring that would wipe a working resolver for no gain. + if (any) self->_dns_captured = true; + return ESP_OK; + } + + static esp_err_t restoreDnsOnTcpipThread(void* ctx) { + NetworkLinkBase* self = static_cast(ctx); + if (!self->_dns_captured) return ESP_OK; + // Every slot is written, empty ones included: returning to a network with + // one server must not leave the other medium's second server behind as a + // fallback that only fails slowly. + for (int i = 0; i < kDnsServers; i++) { + dns_setserver(i, &self->_dns_snapshot[i]); + } + return ESP_OK; + } + + public: + void snapshotDns() { esp_netif_tcpip_exec(snapshotDnsOnTcpipThread, this); } + + // A medium that has never held a lease with a resolver leaves the current one + // alone rather than blanking it. + void restoreDns() { esp_netif_tcpip_exec(restoreDnsOnTcpipThread, this); } + +}; + +class WiFiNetworkLink final : public NetworkLinkBase { + bool _event_registered = false; + char _hostname[32] = {}; + char _ssid[33] = {}; + char _password[65] = {}; + unsigned long _last_reconnect_attempt = 0; + uint8_t _reconnect_backoff_attempt = 0; + + // One mapping for startup, reconnect and CLI (see WifiPowerSavePolicy). The + // stored default is `none`; `min` means MIN_MODEM here exactly as the CLI + // says it does. Mapping the stored value locally is what let a node set to + // `min` silently run with power save off after its first reconnect. + void applyPowerPrefs(uint8_t wifi_power_save) { + static_assert((int)WifiPowerSavePolicy::kModeNone == (int)WIFI_PS_NONE, "wifi_ps_type_t drift"); + static_assert((int)WifiPowerSavePolicy::kModeMinModem == (int)WIFI_PS_MIN_MODEM, "wifi_ps_type_t drift"); + static_assert((int)WifiPowerSavePolicy::kModeMaxModem == (int)WIFI_PS_MAX_MODEM, "wifi_ps_type_t drift"); + esp_wifi_set_ps((wifi_ps_type_t)WifiPowerSavePolicy::modeFor(wifi_power_save)); + // Read back rather than logging what we asked for: this is the only place + // the mode is observable on a running node, and the setting used to change + // meaning between the CLI and this path. + wifi_ps_type_t applied = WIFI_PS_NONE; + esp_wifi_get_ps(&applied); + NETWORK_DEBUG_PRINTLN("WiFi power save: %s (mode=%d)", + WifiPowerSavePolicy::nameFor(wifi_power_save), (int)applied); +#ifdef MQTT_WIFI_TX_POWER + WiFi.setTxPower(MQTT_WIFI_TX_POWER); +#else + WiFi.setTxPower(WIFI_POWER_11dBm); +#endif + } + + public: + const char* mediumName() const override { return "wifi"; } + NetworkMedium medium() const override { return NetworkMedium::WiFi; } + const char* statusName() const override { + switch (WiFi.status()) { + case WL_CONNECTED: return "connected"; + case WL_NO_SSID_AVAIL: return "no_ssid"; + case WL_CONNECT_FAILED: return "connect_failed"; + case WL_CONNECTION_LOST: return "connection_lost"; + case WL_DISCONNECTED: return "disconnected"; + case 255: return "not_started"; + default: return "unknown"; + } + } + int statusCode() const override { return (int)WiFi.status(); } + + bool configValid(const char* wifi_ssid) const override { + return wifi_ssid && wifi_ssid[0] != '\0'; + } + + void setHostname(const char* hostname) override { + strncpy(_hostname, hostname ? hostname : "", sizeof(_hostname) - 1); + _hostname[sizeof(_hostname) - 1] = '\0'; + } + + void updateWifiCredentials(const char* wifi_ssid, const char* wifi_password) override { + strncpy(_ssid, wifi_ssid ? wifi_ssid : "", sizeof(_ssid) - 1); + _ssid[sizeof(_ssid) - 1] = '\0'; + strncpy(_password, wifi_password ? wifi_password : "", sizeof(_password) - 1); + _password[sizeof(_password) - 1] = '\0'; + } + + bool begin(const char* wifi_ssid, const char* wifi_password) override { + if (!configValid(wifi_ssid)) return false; + updateWifiCredentials(wifi_ssid, wifi_password); + + // Arduino-ESP32 applies this stored value when it creates the STA netif. + // It must be set before WiFi.mode()/begin() for the first DHCP exchange. + if (_hostname[0] != '\0') WiFi.setHostname(_hostname); + WiFi.mode(WIFI_STA); + WiFi.setAutoReconnect(true); + + if (!_event_registered) { + WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) { + switch (event) { + case ARDUINO_EVENT_WIFI_STA_GOT_IP: + NETWORK_DEBUG_PRINTLN("WiFi connected: %s", + IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str()); + noteConnected(millis()); + _reconnect_backoff_attempt = 0; + break; + case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: + noteDisconnected(millis(), info.wifi_sta_disconnected.reason); + NETWORK_DEBUG_PRINTLN("WiFi disconnected: reason %d", + info.wifi_sta_disconnected.reason); + break; + default: + break; + } + }); + _event_registered = true; + } + + // Preserve the existing restart behavior: MQTT stop leaves the station up, + // and begin() must not force a disconnect that races the first DNS lookup. + if (!isConnected()) { + WiFi.begin(_ssid, _password); + } else { + noteConnected(millis()); + } + return true; + } + + NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) override { + const bool connected = isConnected(); + if (connected && connectedAtMillis() == 0) noteConnected(now_ms); + + if (!_status_initialized) { + _last_connected = connected; + _status_initialized = true; + setOutage(AlertFaultPolicy::applyWifiStatus( + now_ms, connected, outage(), false)); + // Already associated when the link started (end()/begin() leaves STA up): + // there is no connect transition below to carry the setting, so apply it + // here or the node keeps running whatever mode was set before. + if (connected) { + applyPowerPrefs(wifi_power_save); + snapshotDns(); + } + } + + if ((uint32_t)(now_ms - _last_status_check) <= 10000) { + if (connected && outage().down) noteConnected(now_ms); + return NetworkTransition::None; + } + _last_status_check = now_ms; + + if (connected) { + const bool transitioned = !_last_connected; + if (transitioned) { + setOutage(AlertFaultPolicy::applyWifiStatus( + now_ms, true, outage(), true)); + _connected_at.store(now_ms, std::memory_order_relaxed); + _reconnect_backoff_attempt = 0; + applyPowerPrefs(wifi_power_save); + snapshotDns(); + } + _last_connected = true; + return transitioned ? NetworkTransition::Up : NetworkTransition::None; + } + + AlertFaultPolicy::OutageSnapshot snapshot = AlertFaultPolicy::applyWifiStatus( + now_ms, false, outage(), true); + setOutage(snapshot); + const bool transitioned = _last_connected; + if (transitioned) { + _connected_at.store(0, std::memory_order_relaxed); + } else if (snapshot.down && _ssid[0] != '\0' && MQTTConnectionPolicy::wifiReconnectDue( + now_ms, snapshot.started_ms, (uint32_t)_last_reconnect_attempt, + _reconnect_backoff_attempt)) { + _last_reconnect_attempt = now_ms; + _reconnect_backoff_attempt = + MQTTConnectionPolicy::nextWifiBackoffAttempt(_reconnect_backoff_attempt); + WiFi.disconnect(); + WiFi.begin(_ssid, _password); + } + _last_connected = false; + return transitioned ? NetworkTransition::Down : NetworkTransition::None; + } + + bool isConnected() const override { return WiFi.status() == WL_CONNECTED; } + IPAddress localIP() const override { return WiFi.localIP(); } + int rssi() const override { return isConnected() ? WiFi.RSSI() : INT_MIN; } + bool resolveHost(const char* hostname, IPAddress& address) const override { + return WiFi.hostByName(hostname, address); + } + void formatDiagnostics(char* reply, size_t reply_size) const override { + snprintf(reply, reply_size, + "> why:ethernet-not-enabled selected:wifi\n" + "wifi:state:%s ip:%s", + statusName(), + localIP().toString().c_str()); + } +}; + +#if defined(NETWORK_PREFER_ETHERNET) +class EthernetNetworkLink final : public NetworkLinkBase { + public: + enum class EventState : uint8_t { + None, + Started, + LinkDown, + LinkUp, + GotIp, + Stopped, + }; + + private: + std::atomic _started{false}; + bool _event_registered = false; + char _hostname[32] = {}; + std::atomic _event_state{static_cast(EventState::None)}; + + public: + const char* mediumName() const override { return "ethernet"; } + NetworkMedium medium() const override { return NetworkMedium::Ethernet; } + const char* statusName() const override { + return isConnected() ? "connected" : "disconnected"; + } + int statusCode() const override { return isConnected() ? 1 : 0; } + bool configValid(const char*) const override { return true; } + + void setHostname(const char* hostname) override { + strncpy(_hostname, hostname ? hostname : "", sizeof(_hostname) - 1); + _hostname[sizeof(_hostname) - 1] = '\0'; + } + + bool begin(const char*, const char*) override { + if (_started.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. + tcpipInit(); + if (!_event_registered) { + WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t) { + switch (event) { + case ARDUINO_EVENT_ETH_START: + _event_state.store(static_cast(EventState::Started), + std::memory_order_relaxed); + break; + case ARDUINO_EVENT_ETH_CONNECTED: + _event_state.store(static_cast(EventState::LinkUp), + std::memory_order_relaxed); + break; + case ARDUINO_EVENT_ETH_GOT_IP: + _event_state.store(static_cast(EventState::GotIp), + std::memory_order_relaxed); + noteConnected(millis()); + // Taken here rather than at the maintain() edge: this is the moment + // the lease installed the resolver, before a Wi-Fi fallback lease + // can overwrite it. + snapshotDns(); + break; + case ARDUINO_EVENT_ETH_DISCONNECTED: + _event_state.store(static_cast(EventState::LinkDown), + std::memory_order_relaxed); + // Ethernet has no 802.11 reason code; zero means unavailable. + noteDisconnected(millis(), 0); + _connected_at.store(0, std::memory_order_relaxed); + break; + case ARDUINO_EVENT_ETH_STOP: + _event_state.store(static_cast(EventState::Stopped), + std::memory_order_relaxed); + break; + default: + break; + } + }); + _event_registered = true; + } + _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 { + const bool connected = isConnected(); + if (!_status_initialized) { + _last_connected = connected; + _status_initialized = true; + setOutage(AlertFaultPolicy::applyWifiStatus( + now_ms, connected, outage(), false)); + if (connected) { + noteConnected(now_ms); + snapshotDns(); + } + return NetworkTransition::None; + } + + if (connected == _last_connected) { + if (connected && outage().down) noteConnected(now_ms); + return NetworkTransition::None; + } + + _last_connected = connected; + if (connected) { + _connected_at.store(now_ms, std::memory_order_relaxed); + setOutage(AlertFaultPolicy::applyWifiStatus( + now_ms, true, outage(), true)); + snapshotDns(); + return NetworkTransition::Up; + } + + const bool outage_was_down = outage().down; + _connected_at.store(0, std::memory_order_relaxed); + AlertFaultPolicy::OutageSnapshot snapshot = AlertFaultPolicy::applyWifiStatus( + now_ms, false, outage(), true); + setOutage(snapshot); + if (!outage_was_down) { + _last_disconnect_time.store(now_ms, std::memory_order_relaxed); + } + return NetworkTransition::Down; + } + + bool isConnected() const override { + return _started.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 { + // Arduino's hostByName is a thin wrapper over the process-wide lwIP resolver; + // DNS follows the selected esp_netif even though this entry point is named WiFi. + return WiFi.hostByName(hostname, address); + } + void formatDiagnostics(char* reply, size_t reply_size) const override { + snprintf(reply, reply_size, "> ethernet:%s ip=%s", + statusName(), localIP().toString().c_str()); + } + + EventState eventState() const { + return static_cast( + _event_state.load(std::memory_order_relaxed)); + } + bool started() const { return _started.load(std::memory_order_acquire); } + bool sampleLink(bool& known) const { + known = 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. + (void)CH390.readPHY(0x01); + const uint32_t bmsr = CH390.readPHY(0x01) & 0xffffu; + if (bmsr != 0 && bmsr != 0xffffu) { + known = true; + return (bmsr & (1u << 2)) != 0; + } + + // A failed/unsupported direct PHY read can still use the driver's events. + const EventState state = eventState(); + known = state == EventState::LinkDown || state == EventState::LinkUp || + state == EventState::GotIp || state == EventState::Stopped; + return state == EventState::LinkUp || state == EventState::GotIp; + } + bool linkUp() const { + bool known = false; + return sampleLink(known); + } + const char* eventName() const { + switch (eventState()) { + case EventState::None: return "none"; + case EventState::Started: return "started"; + case EventState::LinkDown: return "link-down"; + case EventState::LinkUp: return "link-up"; + case EventState::GotIp: return "got-ip"; + case EventState::Stopped: return "stopped"; + } + return "unknown"; + } +}; + +class AutomaticNetworkLink final : public NetworkLink { + EthernetNetworkLink _ethernet; + WiFiNetworkLink _wifi; + std::atomic _selected{NetworkMedium::None}; + std::atomic _ethernet_started{false}; + std::atomic _wifi_started{false}; + char _wifi_ssid[33] = {}; + char _wifi_password[65] = {}; + 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}; + std::atomic _ethernet_seen{false}; // held a lease at least once this boot + + // Ethernet is the primary for alerts once it has carried traffic, or when it + // is the only configured medium; a Wi-Fi-only install keeps Wi-Fi alerts. + bool ethernetIsAlertPrimary() const { + return _ethernet_seen.load(std::memory_order_acquire) || !wifiConfigured(); + } + + NetworkLink& selectedInterface(NetworkMedium selected) { + return selected == NetworkMedium::Ethernet + ? static_cast(_ethernet) + : static_cast(_wifi); + } + const NetworkLink& selectedInterface(NetworkMedium selected) const { + return selected == NetworkMedium::Ethernet + ? static_cast(_ethernet) + : static_cast(_wifi); + } + + bool wifiConfigured() const { return _wifi_ssid[0] != '\0'; } + + 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.load(std::memory_order_acquire) || !wifiConfigured()) return; + _wifi_started.store(_wifi.begin(_wifi_ssid, _wifi_password), + std::memory_order_release); + } + + // Lock and mutation each publish one flag then read the other; seq_cst keeps + // both sides from missing each other. + bool beginUnlockedMutation() { + bool expected = false; + if (!_switch_in_progress.compare_exchange_strong(expected, true)) { + return false; + } + if (_switch_locks.load() != 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) { + 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.store(false, std::memory_order_release); + } + _selected_down_since.store(0, std::memory_order_relaxed); + _selected.store(medium, std::memory_order_release); + // Put back the resolver this medium leased. lwIP's server list is global, + // so whichever medium leased last still owns it here (see snapshotDns()). + if (medium == NetworkMedium::Ethernet) { + _ethernet.restoreDns(); + } else if (medium == NetworkMedium::WiFi) { + _wifi.restoreDns(); + } + } + + bool startOrRetryEthernet(uint32_t now_ms, bool restart) { + 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 { + 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.load(std::memory_order_acquire); + } + const char* statusName() const override { + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected == NetworkMedium::None ? "not_selected" + : selectedInterface(selected).statusName(); + } + int statusCode() const override { + 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 + // link/DHCP state is runtime state and must not permanently suppress the + // MQTT task that monitors for a late cable or lease. + (void)wifi_ssid; + return true; + } + bool isAutomatic() const override { return true; } + + void updateWifiCredentials(const char* wifi_ssid, const char* wifi_password) override { + rememberWifi(wifi_ssid, wifi_password); + _wifi.updateWifiCredentials(_wifi_ssid, _wifi_password); + } + + void setHostname(const char* hostname) override { + // Whichever medium wins now or during a later failover presents the same + // stable DHCP identity to the LAN. + _ethernet.setHostname(hostname); + _wifi.setHostname(hostname); + } + + bool begin(const char* wifi_ssid, const char* wifi_password) override { + rememberWifi(wifi_ssid, wifi_password); + if (!_ethernet_started.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)) && + beginUnlockedMutation()) { + startOrRetryEthernet(now_ms, attempt != 0); + endUnlockedMutation(); + } + } + // 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. + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + if (selected == NetworkMedium::None) { + if (_ethernet.isConnected()) { + select(NetworkMedium::Ethernet); + } else if (wifiConfigured()) { + startWifiFallback(); + _selected.store(NetworkMedium::WiFi, std::memory_order_release); + } + } else if (selected == NetworkMedium::WiFi) { + startWifiFallback(); + } + 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.load(std::memory_order_acquire)) { + startOrRetryEthernet(millis(), false); + } + + const uint32_t started_at = millis(); + 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); + } + + if (_ethernet.isConnected()) _ethernet_seen.store(true, std::memory_order_release); + const NetworkMedium initial = NetworkPolicy::bootSelection( + _ethernet.isConnected(), wifiConfigured()); + if (initial == NetworkMedium::Ethernet) { + select(initial); + } else { + startWifiFallback(); + _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() == EthernetNetworkLink::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 EthernetNetworkLink::EventState ethernet_event = + _ethernet.eventState(); + const bool ethernet_link_up = + ethernet_event == EthernetNetworkLink::EventState::LinkUp || + ethernet_event == EthernetNetworkLink::EventState::GotIp; + if (ethernet_started && ethernet_link_up && !_ethernet.isConnected()) { + if (_ethernet_no_ip_since.load(std::memory_order_relaxed) == 0) { + uint32_t started_at = now_ms; + 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); + } + // Restarts rebuild the CH390 netif, so they take the same mutation gate as + // a route switch: an OTA/WebConfig lock taken after the sample above wins. + if (!switching_locked && NetworkPolicy::ethernetNoIpRecoveryDue( + ethernet_started, ethernet_link_up, _ethernet.isConnected(), + now_ms, _ethernet_no_ip_since.load(std::memory_order_relaxed)) && + beginUnlockedMutation()) { + ethernet_started = startOrRetryEthernet(now_ms, true); + _ethernet_no_ip_since.store(0, std::memory_order_relaxed); + endUnlockedMutation(); + } + if (!ethernet_started && !switching_locked) { + const uint8_t attempt = + _ethernet_retry_attempt.load(std::memory_order_relaxed); + if (NetworkPolicy::ethernetInitRetryDue( + attempt, now_ms, + _last_ethernet_init_attempt.load(std::memory_order_relaxed)) && + beginUnlockedMutation()) { + ethernet_started = startOrRetryEthernet(now_ms, true); + endUnlockedMutation(); + } + } + + const NetworkTransition ethernet_transition = + _ethernet.maintain(now_ms, wifi_power_save); + if (_ethernet.isConnected()) _ethernet_seen.store(true, std::memory_order_release); + const bool wifi_started = _wifi_started.load(std::memory_order_acquire); + const NetworkTransition wifi_transition = wifi_started + ? _wifi.maintain(now_ms, wifi_power_save) + : 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. + NetworkMedium selected = _selected.load(std::memory_order_acquire); + if (selected == NetworkMedium::Ethernet && _ethernet.isConnected() && + wifi_started) { + const bool wifi_had_route = _wifi.isConnected(); + if (selectIfUnlocked(NetworkMedium::Ethernet) && wifi_had_route) { + return NetworkTransition::Switched; + } + } + + if (_ethernet.isConnected()) { + if (_ethernet_stable_since.load(std::memory_order_relaxed) == 0) { + _ethernet_stable_since.store(now_ms, std::memory_order_relaxed); + } + } else { + _ethernet_stable_since.store(0, std::memory_order_relaxed); + } + + const bool selected_connected = isConnected(); + if (!selected_connected) { + if (_selected_down_since.load(std::memory_order_relaxed) == 0) { + _selected_down_since.store(now_ms, std::memory_order_relaxed); + } + } else { + _selected_down_since.store(0, std::memory_order_relaxed); + } + + 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) { + startWifiFallbackIfUnlocked(); + } + + 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.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) { + 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; + return NetworkTransition::None; + } + + void lockSwitching() override { + uint8_t value = _switch_locks.load(std::memory_order_relaxed); + while (value != UINT8_MAX && !_switch_locks.compare_exchange_weak( + value, static_cast(value + 1))) {} + while (_switch_in_progress.load()) delay(1); + } + 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 { + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected != NetworkMedium::None && + selectedInterface(selected).isConnected(); + } + IPAddress localIP() const override { + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected == NetworkMedium::None ? IPAddress() + : selectedInterface(selected).localIP(); + } + int rssi() const override { + 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 { + 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_acquire) != 0; + const uint32_t now_ms = millis(); + 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_stable_ms); + snprintf(reply, reply_size, + "> why:%s selected:%s lock:%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), + NetworkPolicy::mediumName(selected), + switching_locked ? "yes" : "no", + 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"); + } + unsigned long connectedAtMillis() const override { + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected == NetworkMedium::None + ? 0 : selectedInterface(selected).connectedAtMillis(); + } + uint8_t lastDisconnectReason() const override { + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected == NetworkMedium::None + ? 0 : selectedInterface(selected).lastDisconnectReason(); + } + unsigned long lastDisconnectTime() const override { + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected == NetworkMedium::None + ? 0 : selectedInterface(selected).lastDisconnectTime(); + } + AlertFaultPolicy::OutageSnapshot outageSnapshot() const override { + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + return selected == NetworkMedium::None + ? AlertFaultPolicy::OutageSnapshot{false, 0, 0} + : selectedInterface(selected).outageSnapshot(); + } + NetworkMedium alertMedium() const override { + return ethernetIsAlertPrimary() ? NetworkMedium::Ethernet : NetworkMedium::WiFi; + } + AlertFaultPolicy::OutageSnapshot alertOutageSnapshot() const override { + return ethernetIsAlertPrimary() ? _ethernet.outageSnapshot() : _wifi.outageSnapshot(); + } +}; +#endif + +} // namespace + +NetworkLink& activeNetworkLink() { +#if defined(NETWORK_PREFER_ETHERNET) + static AutomaticNetworkLink network; +#else + static WiFiNetworkLink network; +#endif + return network; +} +#endif diff --git a/src/helpers/NetworkLink.h b/src/helpers/NetworkLink.h new file mode 100644 index 000000000..afc125a96 --- /dev/null +++ b/src/helpers/NetworkLink.h @@ -0,0 +1,81 @@ +#pragma once + +#include "NetworkPolicy.h" + +#if defined(ESP_PLATFORM) + +#include +#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. + * + * The interface owns link bring-up and medium-specific maintenance. MQTT, NTP, + * OTA, and other socket users only consume connectivity and addressing. Normal + * MQTT shutdown deliberately does not stop this interface because an OTA + * download runs after the broker clients have been released. + */ +class NetworkLink { + public: + virtual ~NetworkLink() = 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; + // Configure the DHCP hostname before begin()/bootstrap(). Implementations + // retain it for any later fallback interface start. + virtual void setHostname(const char* hostname) = 0; + virtual bool begin(const char* wifi_ssid, const char* wifi_password) = 0; + // Latest stored credentials; the next reconnect attempt uses them without a reboot. + virtual void updateWifiCredentials(const char* wifi_ssid, const char* wifi_password) { + (void)wifi_ssid; + (void)wifi_password; + } + virtual NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) = 0; + + // Automatic selectors are boot-owned so first-run services can use the + // 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. + virtual bool resolveHost(const char* hostname, IPAddress& address) const = 0; + virtual void formatDiagnostics(char* reply, size_t reply_size) const = 0; + + virtual unsigned long connectedAtMillis() const = 0; + virtual uint8_t lastDisconnectReason() const = 0; + 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. */ +NetworkLink& activeNetworkLink(); + +#endif diff --git a/src/helpers/NetworkPolicy.h b/src/helpers/NetworkPolicy.h new file mode 100644 index 000000000..a4e7953aa --- /dev/null +++ b/src/helpers/NetworkPolicy.h @@ -0,0 +1,225 @@ +#pragma once + +#include + +enum class NetworkTransition : uint8_t { + None, + Up, + Down, + Switched, +}; + +enum class NetworkMedium : uint8_t { + None, + Ethernet, + WiFi, +}; + +enum class NetworkDiagnosticReason : uint8_t { + EthernetActive, + EthernetInitFailed, + EthernetLinkUnknown, + EthernetLinkDown, + EthernetAwaitingIp, + EthernetStabilizing, + SwitchingLocked, + EthernetReady, +}; + +namespace NetworkPolicy { + +struct MQTTTransitionActions { + bool disconnect_started_slots; + bool retry_disconnected_slots_now; + bool reset_reconnect_backoff; +}; + +static constexpr MQTTTransitionActions mqttActions(NetworkTransition transition) { + return { + transition == NetworkTransition::Down || transition == NetworkTransition::Switched, + transition == NetworkTransition::Up || transition == NetworkTransition::Switched, + transition == NetworkTransition::Switched, + }; +} + +static constexpr const char* mediumName(NetworkMedium medium) { + return medium == NetworkMedium::Ethernet ? "ethernet" + : medium == NetworkMedium::WiFi ? "wifi" + : "none"; +} + +struct AutomaticSelectionInput { + NetworkMedium selected; + bool ethernet_connected; + 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 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, + bool ethernet_link_up, bool ethernet_connected, NetworkMedium selected, + bool switching_locked, uint32_t ethernet_stable_ms) { + if (!ethernet_initialized) { + return NetworkDiagnosticReason::EthernetInitFailed; + } + if (!ethernet_link_known) { + return NetworkDiagnosticReason::EthernetLinkUnknown; + } + if (!ethernet_link_up) { + return NetworkDiagnosticReason::EthernetLinkDown; + } + if (!ethernet_connected) { + return NetworkDiagnosticReason::EthernetAwaitingIp; + } + if (selected == NetworkMedium::Ethernet) { + return NetworkDiagnosticReason::EthernetActive; + } + if (switching_locked) { + return NetworkDiagnosticReason::SwitchingLocked; + } + if (selected == NetworkMedium::WiFi && + ethernet_stable_ms < kEthernetFailbackStableMs) { + return NetworkDiagnosticReason::EthernetStabilizing; + } + return NetworkDiagnosticReason::EthernetReady; +} + +static inline const char* diagnosticReasonName( + NetworkDiagnosticReason reason) { + switch (reason) { + case NetworkDiagnosticReason::EthernetActive: + return "ethernet-active"; + case NetworkDiagnosticReason::EthernetInitFailed: + return "ethernet-init-failed"; + case NetworkDiagnosticReason::EthernetLinkUnknown: + return "ethernet-link-unknown"; + case NetworkDiagnosticReason::EthernetLinkDown: + return "ethernet-link-down"; + case NetworkDiagnosticReason::EthernetAwaitingIp: + return "ethernet-awaiting-ip"; + case NetworkDiagnosticReason::EthernetStabilizing: + return "ethernet-stabilizing"; + case NetworkDiagnosticReason::SwitchingLocked: + return "switching-locked"; + case NetworkDiagnosticReason::EthernetReady: + return "ethernet-ready"; + } + return "unknown"; +} + +static constexpr bool ntpPendingAfterConnectivitySample( + bool synced, bool pending, bool was_connected, bool connected) { + return pending || (!synced && connected && !was_connected); +} + +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; +} + +// 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 && + (!link_known || link_up || elapsed_ms < kEthernetNoLinkBootGraceMs); +} + +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.wifi_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, + bool network_connected) { + 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 a70716f5b..eb29bd688 100644 --- a/src/helpers/SNMPAgent.cpp +++ b/src/helpers/SNMPAgent.cpp @@ -1,7 +1,9 @@ #ifdef WITH_SNMP #include "SNMPAgent.h" +#include "NetworkLink.h" #include +#include #define SNMP_PORT 161 @@ -15,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'; @@ -67,7 +69,7 @@ void MeshSNMPAgent::begin(const char* community) { void MeshSNMPAgent::loop() { if (!_running) return; - // Update memory and network stats locally (we're on Core 0 with WiFi) + // Update memory and selected-network stats locally on Core 0. _free_heap = (int)ESP.getFreeHeap(); _max_alloc = (int)ESP.getMaxAllocHeap(); _internal_free = (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL); @@ -77,9 +79,8 @@ void MeshSNMPAgent::loop() { _psram_free = 0; #endif - if (WiFi.isConnected()) { - _wifi_rssi = (int)WiFi.RSSI(); - } + const int signal = activeNetworkLink().rssi(); + _wifi_rssi = signal == INT_MIN ? -127 : signal; _snmp.loop(); } diff --git a/src/helpers/SNMPAgent.h b/src/helpers/SNMPAgent.h index c1bfe0f5b..fe94b10fe 100644 --- a/src/helpers/SNMPAgent.h +++ b/src/helpers/SNMPAgent.h @@ -2,7 +2,6 @@ #ifdef WITH_SNMP -#include #include #include @@ -15,7 +14,7 @@ // .2.x.0 = radio (packets, RSSI, SNR, noise floor, air time) // .3.x.0 = mqtt (connected slots, queue depth, skipped publishes) // .4.x.0 = memory (free heap, max alloc, internal free, PSRAM free) -// .5.x.0 = network (WiFi RSSI) +// .5.x.0 = network (RSSI, or -127 when disconnected or the medium has no RSSI) class MeshSNMPAgent { public: diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index a21a75c6c..29edc89e7 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -107,16 +107,8 @@ void MQTTBridge::getEffectiveMqttOrigin(const NodePrefs* np, const MQTTPrefs* ob applyEffectiveOrigin(np, obs, buf, buf_size); } -// Helper function to check if WiFi credentials are valid -static bool isWiFiConfigValid(const MQTTPrefs* obs) { - // Check if WiFi SSID is configured (not empty) - if (!obs || strlen(obs->wifi_ssid) == 0) { - return false; - } - - // WiFi password can be empty for open networks, so we don't check it - - return true; +static bool isNetworkConfigValid(const MQTTPrefs* obs) { + return obs && activeNetworkLink().configValid(obs->wifi_ssid); } #ifdef WITH_MQTT_BRIDGE @@ -129,7 +121,7 @@ static bool customEndpointComplete(const char* host, uint16_t port) { } bool MQTTBridge::isConfigValid(const MQTTPrefs* obs) { - if (!obs || !isWiFiConfigValid(obs)) return false; + if (!obs || !isNetworkConfigValid(obs)) return false; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { const char* preset_name = obs->mqtt_slot_preset[i]; if (preset_name[0] == '\0' || strcmp(preset_name, MQTT_PRESET_NONE) == 0) continue; @@ -217,17 +209,11 @@ void* MQTTBridge::JsonScratchAllocator::reallocate(void* ptr, size_t new_size) { return psram_realloc(ptr, new_size); } -// Time (millis()) when WiFi was last seen connected; 0 when disconnected. Used for get wifi.status uptime. -static unsigned long s_wifi_connected_at = 0; - -// Last WiFi disconnect reason (from ESP-IDF event). Used for get wifi.status diagnostics. -static uint8_t s_wifi_disconnect_reason = 0; // Latched by end() when a stop passes its deadline unacknowledged. end() clears // the diagnostic singleton, so without this `get mqtt.status` could only say // "not running" and an operator would have no way to learn that the bridge is // down for the rest of the boot and why. static bool s_stop_unproven = false; -static unsigned long s_wifi_disconnect_time = 0; #ifdef MQTT_MEMORY_DEBUG // #region agent log @@ -248,7 +234,7 @@ static void agentLogHeap(const char* location, const char* message, const char* static MQTTBridge* s_mqtt_bridge_instance = nullptr; unsigned long MQTTBridge::getWifiConnectedAtMillis() { - return s_wifi_connected_at; + return activeNetworkLink().connectedAtMillis(); } #if defined(WITH_MQTT_NEIGHBORS) @@ -441,27 +427,6 @@ int MQTTBridge::getMaxActiveSlots() { #endif } -// One mapping for startup, reconnect and CLI (see WifiPowerSavePolicy). The -// stored default is `none`; `min` means MIN_MODEM here exactly as the CLI says -// it does. -void MQTTBridge::applyWifiPowerSave() { - #ifdef ESP_PLATFORM - static_assert((int)WifiPowerSavePolicy::kModeNone == (int)WIFI_PS_NONE, "wifi_ps_type_t drift"); - static_assert((int)WifiPowerSavePolicy::kModeMinModem == (int)WIFI_PS_MIN_MODEM, "wifi_ps_type_t drift"); - static_assert((int)WifiPowerSavePolicy::kModeMaxModem == (int)WIFI_PS_MAX_MODEM, "wifi_ps_type_t drift"); - if (!_obs) return; - const uint8_t stored = _obs->wifi_power_save; - esp_wifi_set_ps((wifi_ps_type_t)WifiPowerSavePolicy::modeFor(stored)); - // Read back rather than logging what we asked for: this is the only place the - // mode is observable on a running node, and the setting used to change - // meaning between the CLI and this path. - wifi_ps_type_t applied = WIFI_PS_NONE; - esp_wifi_get_ps(&applied); - MQTT_DEBUG_PRINTLN("WiFi power save: %s (mode=%d)", - WifiPowerSavePolicy::nameFor(stored), (int)applied); - #endif -} - bool MQTTBridge::stopUnprovenLatched() { return s_stop_unproven; } bool MQTTBridge::stopAcknowledgedLate() const { @@ -472,8 +437,12 @@ bool MQTTBridge::stopAcknowledgedLate() const { #endif } -uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; } -unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; } +uint8_t MQTTBridge::getLastWifiDisconnectReason() { + return activeNetworkLink().lastDisconnectReason(); +} +unsigned long MQTTBridge::getLastWifiDisconnectTime() { + return activeNetworkLink().lastDisconnectTime(); +} unsigned long MQTTBridge::getSlotCurrentOutageStartMs(int slot_index) const { if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return 0; @@ -706,6 +675,7 @@ static inline uint32_t mqttStopTimeoutForSlots(int slots) { MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity) : BridgeBase(prefs, mgr, rtc), _obs(obs), + _network(&activeNetworkLink()), _queue_count(0), _last_status_publish(0), _last_status_retry(0), _status_interval(300000), _last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS), @@ -732,8 +702,6 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mg #ifdef WITH_SNMP _snmp_agent(nullptr), #endif - _last_wifi_check(0), _last_wifi_status(WL_DISCONNECTED), _wifi_status_initialized(false), - _wifi_outage_bits{0}, _last_wifi_reconnect_attempt(0), _wifi_reconnect_backoff_attempt(0), _last_slot_reconnect_ms(0) #ifdef ESP_PLATFORM , _packet_queue_handle(nullptr), _mqtt_task_handle(nullptr), @@ -944,9 +912,10 @@ void MQTTBridge::begin() { _max_active_slots = getMaxActiveSlots(); MQTT_DEBUG_PRINTLN("Max active slots: %d", _max_active_slots); - // Check if WiFi credentials are configured first - if (!isWiFiConfigValid(_obs)) { - MQTT_DEBUG_PRINTLN("MQTT Bridge initialization skipped - WiFi credentials not configured"); + // Ethernet needs no credentials; Wi-Fi preserves the existing SSID gate. + if (!isNetworkConfigValid(_obs)) { + MQTT_DEBUG_PRINTLN("MQTT Bridge initialization skipped - %s is not configured", + _network->mediumName()); return; } @@ -1131,11 +1100,8 @@ void MQTTBridge::begin() { MQTT_DEBUG_PRINTLN("MQTT task created on Core %d", MQTT_TASK_CORE); #else - // Non-ESP32: Initialize WiFi directly (no task) - WiFi.mode(WIFI_STA); - WiFi.setAutoReconnect(true); - WiFi.setAutoConnect(true); - WiFi.begin(_obs->wifi_ssid, _obs->wifi_password); + // Non-ESP32: initialize the selected network directly (no task). + _network->begin(_obs->wifi_ssid, _obs->wifi_password); // NOTE: Slot setup deferred until after NTP sync in loop() #endif @@ -1348,79 +1314,41 @@ void MQTTBridge::mqttTask(void* parameter) { vTaskDelete(nullptr); } -void MQTTBridge::initializeWiFiInTask() { - MQTT_DEBUG_PRINTLN("Initializing WiFi in MQTT task..."); +void MQTTBridge::initializeNetworkInTask() { + MQTT_DEBUG_PRINTLN("Initializing %s network in MQTT task...", _network->mediumName()); - // Initialize WiFi - WiFi.mode(WIFI_STA); - - // Enable automatic reconnection - ESP32 will handle reconnection automatically - WiFi.setAutoReconnect(true); - WiFi.setAutoConnect(true); - - // Set up WiFi event handlers for better diagnostics and immediate disconnection - // detection. Register ONCE — the bridge is reused across restarts (e.g. stopped - // for `ota check`/`ota update`, or `set mqtt…` reconfigure) and WiFi.onEvent() - // never removes prior callbacks, so re-registering leaks handlers and duplicates - // every log line. - if (!_wifi_event_registered) { - WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) { - switch(event) { - case ARDUINO_EVENT_WIFI_STA_GOT_IP: - MQTT_DEBUG_PRINTLN("WiFi connected: %s", IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str()); - setWifiOutage(AlertFaultPolicy::applyWifiGotIp(wifiOutage())); - _wifi_reconnect_backoff_attempt = 0; - // Set flag to trigger NTP sync from loop() instead of doing it here - if (!_ntp_synced && !_ntp_sync_pending) { - _ntp_sync_pending = true; - } - break; - case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: { - const uint8_t reason = info.wifi_sta_disconnected.reason; - const unsigned long t = millis(); - s_wifi_disconnect_reason = reason; - s_wifi_disconnect_time = t; - setWifiOutage(AlertFaultPolicy::applyWifiDisconnectEvent( - (uint32_t)t, reason, wifiOutage())); - MQTT_DEBUG_PRINTLN("WiFi disconnected: reason %d", s_wifi_disconnect_reason); - break; - } - default: - break; - } - }); - _wifi_event_registered = true; - } - - // Only (re)start the WiFi association if it isn't already up. end() leaves the - // STA link connected, so on a restart (e.g. after `ota check`) calling - // WiFi.begin() again forces a needless disconnect/reconnect — which also races - // the MQTT task's first DNS lookup (getaddrinfo fails until WiFi/DNS recovers). - // When already connected, the deferred slot setup still fires in mqttTaskLoop() - // because _ntp_synced persists across end() (only _slots_setup_done is reset). - if (WiFi.status() != WL_CONNECTED) { - WiFi.begin(_obs->wifi_ssid, _obs->wifi_password); - } else if (!_ntp_synced && !_ntp_sync_pending) { - _ntp_sync_pending = true; // already connected but never synced — kick NTP now + // begin() is idempotent and deliberately leaves an already-up link alone. + // MQTT end()/begin() cycles therefore keep the transport alive for OTA and do + // not race the first DNS lookup after a bridge restart. + if (!_network->begin(_obs->wifi_ssid, _obs->wifi_password)) { + MQTT_DEBUG_PRINTLN("%s network initialization failed", _network->mediumName()); + } else if (_network->isConnected() && !_ntp_synced && !_ntp_sync_pending) { + _ntp_sync_pending = true; } // NOTE: Slot setup is deferred until after NTP sync in mqttTaskLoop(). // JWT-auth slots need valid timestamps for token creation, and connecting // before NTP sync just wastes heap on TLS handshakes that will be rejected. - MQTT_DEBUG_PRINTLN("WiFi initialization started in task"); + MQTT_DEBUG_PRINTLN("%s network initialization started in task", _network->mediumName()); } // --------------------------------------------------------------------------- // mqttTaskLoop() - main loop running on Core 0 // --------------------------------------------------------------------------- void MQTTBridge::mqttTaskLoop() { - // Initialize WiFi first - initializeWiFiInTask(); + // Initialize the selected physical network first. + initializeNetworkInTask(); // Wait a bit for WiFi to start connecting vTaskDelay(pdMS_TO_TICKS(1000)); + // Treat the first post-settle sample as an edge. A fast Wi-Fi association + // may complete during the delay above, before the task has sampled state; + // initializing from the live value would then defer NTP until its retry. + bool network_was_connected = false; + unsigned long last_ntp_attempt = 0; + // Main task loop #ifdef MQTT_MEMORY_DEBUG static unsigned long last_agent_log = 0; @@ -1500,30 +1428,28 @@ void MQTTBridge::mqttTaskLoop() { } #endif - bool wifi_just_connected = handleWiFiConnection(now); - if (wifi_just_connected) { - // WiFi recovered — reset last_reconnect_attempt for disconnected slots so they - // retry immediately rather than waiting up to 5 min for backoff timers to expire. - for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - if (_slots[i].enabled && _slots[i].initial_connect_done && !_slots[i].connected) { - _slots[i].last_reconnect_attempt = 0; - } - } - } + handleNetworkConnection(now); - // Check for pending NTP sync (triggered from WiFi event handler) - if (_ntp_sync_pending && WiFi.status() == WL_CONNECTED) { + // 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 && WiFi.status() == WL_CONNECTED) { - 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 @@ -1633,7 +1559,7 @@ void MQTTBridge::mqttTaskLoop() { #ifdef WITH_SNMP // SNMP agent loop — process incoming UDP requests if (_snmp_agent) { - if (!_snmp_agent->isRunning() && WiFi.isConnected() && _obs->snmp_enabled) { + if (!_snmp_agent->isRunning() && _network->isConnected() && _obs->snmp_enabled) { _snmp_agent->begin(_obs->snmp_community); MQTT_DEBUG_PRINTLN("SNMP agent started on port 161 (community: %s)", _obs->snmp_community); } @@ -1655,7 +1581,7 @@ void MQTTBridge::mqttTaskLoop() { // Periodic NTP refresh (every hour): a validated probe, one attempt per // server. Blocks this task for about one probe timeout per server that does // not answer (see refreshNTP()). - if (WiFi.status() == WL_CONNECTED && now - _last_ntp_sync > 3600000) { + if (_network->isConnected() && now - _last_ntp_sync > 3600000) { refreshNTP(); } @@ -2403,6 +2329,56 @@ void MQTTBridge::closeLiveClientForReconfigure(int index) { slot.client_state = ClientState::Disconnected; } +// The old route is already gone, so a link transition waits far less for the +// DISCONNECTED event than an ordinary teardown does. +static constexpr unsigned long kNetworkTransitionDisconnectMs = 2000; + +// Close a slot's transport because the route under it changed. +// +// Deliberately NOT the reconfigure path, even though both close a live client. +// A reconfigure gives the slot a new endpoint and new credentials, so an +// in-flight attempt that completes afterwards reports a connection to the OLD +// broker under the new configuration, and cancelling it is worth a real stop +// (F04). A link transition changes neither: the attempt is aimed at the same +// broker with the same credentials, so if it completes it is credited to the +// endpoint it actually reached. It is stale only in that its socket sits on a +// route that is going away, and the ordinary DISCONNECTED path handles that. +// +// So an attempt in flight is left alone here. Stopping it would mean calling +// esp_mqtt_client_stop(), which waits on the SDK's API mutex and its task's +// stopped event with no bound (see stopSlotClient()) — the client task notices +// only when it returns from whatever transport call it is in, up to +// network_timeout_ms for a TLS connect. During teardown that is contained by +// the bridge's StopUnproven timeout; during a transition nothing contains it, +// so paying it serially for up to five slots would freeze the sole MQTT worker +// far past the bound this path advertises, and would block the bridge's own +// stop handshake behind it. A routine Wi-Fi flap must not cost that. +// +// A connected client takes the cheap bounded path, and the esp-mqtt task stays, +// which is what keeps a flap from recreating every slot's task and +// refragmenting internal heap. Nothing here touches a client that is not live, +// so a Quarantined one (its SDK task was never joined) is left alone, as it +// must be for the rest of the boot. +void MQTTBridge::closeLiveClientForLinkTransition(int index) { + if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return; + MQTTSlot& slot = _slots[index]; + if (slot.client == nullptr || !clientStateIsLive(slot.client_state)) return; + + if (mqttClientStateHasAttemptInFlight(slot.client_state)) { + MQTT_DEBUG_PRINTLN("MQTT%d link transition during connect - leaving the attempt to resolve", + index + 1); + return; + } + + MQTT_DEBUG_PRINTLN("MQTT%d disconnecting for link transition", index + 1); + const esp_err_t r = slot.client->softDisconnect(kNetworkTransitionDisconnectMs); + if (r != ESP_OK) { + MQTT_DEBUG_PRINTLN("MQTT%d link transition: transport did not close cleanly (%s)", + index + 1, esp_err_to_name(r)); + } + slot.client_state = ClientState::Disconnected; +} + // Stop a live client and record whether the stop was proven. ESP_ERR_TIMEOUT // means no DISCONNECTED event arrived but esp_mqtt_client_stop() itself // returned OK, so the SDK task is joined and the object is safe to reuse; any @@ -2490,10 +2466,8 @@ void MQTTBridge::teardownSlot(int index, TeardownReason reason) { // A stopped client needs connect(): reconnect() is a documented no-op on one, so reaching // it here would strand the slot. The producer is a failed esp_mqtt_client_start(), which -// leaves _started false while initial_connect_done stays set. Not the WiFi-drop teardown, -// which only stops slots still marked connected: a publishing slot's socket fails first, so -// the guard skips it — measured across a 62 s deauth, five slots, zero stops. An idle slot -// with no traffic to fail on is the one case that could still reach here that way. +// leaves _started false while initial_connect_done stays set. Not a link transition, +// which only disconnects the session and keeps the client task. esp_err_t MQTTBridge::reconnectSlotClient(int index) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return ESP_ERR_INVALID_ARG; MQTTSlot& slot = _slots[index]; @@ -2527,7 +2501,7 @@ void MQTTBridge::maintainSlotConnections() { if (!_identity) return; // Check WiFi status first - if (WiFi.status() != WL_CONNECTED) return; + if (!_network->isConnected()) return; unsigned long now_millis = millis(); unsigned long current_time = time(nullptr); @@ -3322,95 +3296,60 @@ void MQTTBridge::checkConfigurationMismatch() { } } -bool MQTTBridge::handleWiFiConnection(unsigned long now) { - wl_status_t current_wifi_status = WiFi.status(); - bool transitioned_to_connected = false; +bool MQTTBridge::handleNetworkConnection(unsigned long now) { + const NetworkMedium previous_medium = _network->medium(); + _network->updateWifiCredentials(_obs->wifi_ssid, _obs->wifi_password); + const NetworkTransition transition = + _network->maintain((uint32_t)now, _obs->wifi_power_save); + const NetworkMedium selected_medium = _network->medium(); - if (current_wifi_status == WL_CONNECTED && s_wifi_connected_at == 0) { - s_wifi_connected_at = now; + if (transition == NetworkTransition::Down) { + MQTT_DEBUG_PRINTLN("Network: %s link down", NetworkPolicy::mediumName(previous_medium)); + } else if (transition == NetworkTransition::Up) { + MQTT_DEBUG_PRINTLN("Network: %s link up (%s, IP %s)", + NetworkPolicy::mediumName(selected_medium), + _network->statusName(), _network->localIP().toString().c_str()); + } else if (transition == NetworkTransition::Switched) { + MQTT_DEBUG_PRINTLN("Network: switched %s -> %s (%s, IP %s)", + NetworkPolicy::mediumName(previous_medium), + NetworkPolicy::mediumName(selected_medium), + _network->statusName(), _network->localIP().toString().c_str()); } - if (!_wifi_status_initialized) { - _last_wifi_status = current_wifi_status; - _wifi_status_initialized = true; - setWifiOutage(AlertFaultPolicy::applyWifiStatus( - (uint32_t)now, current_wifi_status == WL_CONNECTED, wifiOutage(), false)); - #ifdef ESP_PLATFORM - // Already associated at bridge start (end()/begin() leaves STA up): there is - // no connect transition below to carry the setting, so apply it here or the - // node runs on whatever the previous mode was. - if (current_wifi_status == WL_CONNECTED) applyWifiPowerSave(); - #endif - } - if (now - _last_wifi_check <= 10000) { - // Events own the snapshot between 10 s polls. If STA is associated again - // and GOT_IP was missed, still close the outage so a flap contained - // between polls does not look like one continuous downtime. - if (current_wifi_status == WL_CONNECTED) { - AlertFaultPolicy::OutageSnapshot snap = wifiOutage(); - if (snap.down) { - setWifiOutage(AlertFaultPolicy::applyWifiGotIp(snap)); - } - } - return false; - } - _last_wifi_check = now; - if (current_wifi_status == WL_CONNECTED) { - if (_last_wifi_status != WL_CONNECTED) { - transitioned_to_connected = true; - setWifiOutage(AlertFaultPolicy::applyWifiStatus( - (uint32_t)now, true, wifiOutage(), true)); - s_wifi_connected_at = now; - _wifi_reconnect_backoff_attempt = 0; - #ifdef ESP_PLATFORM - applyWifiPowerSave(); - #ifdef MQTT_WIFI_TX_POWER - WiFi.setTxPower(MQTT_WIFI_TX_POWER); - #else - WiFi.setTxPower(WIFI_POWER_11dBm); - #endif - #endif + const NetworkPolicy::MQTTTransitionActions actions = + NetworkPolicy::mqttActions(transition); + if (actions.disconnect_started_slots) { + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + closeLiveClientForLinkTransition(i); + _slots[i].connected = false; + _slots[i].connected_at_ms = 0; } - if (s_wifi_connected_at == 0) { - s_wifi_connected_at = now; - } - _last_wifi_status = WL_CONNECTED; - } else { - const bool last_connected = (_last_wifi_status == WL_CONNECTED); - AlertFaultPolicy::OutageSnapshot snap = AlertFaultPolicy::applyWifiStatus( - (uint32_t)now, false, wifiOutage(), true); - setWifiOutage(snap); - if (last_connected) { - s_wifi_connected_at = 0; - // Disconnect all slot clients when WiFi drops - for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - if (_slots[i].client && _slots[i].connected) { - _slots[i].client->disconnect(); + updateCachedConnectionStatus(); + } + + 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) { + if (actions.reset_reconnect_backoff) { + _slots[i].reconnect_backoff = 0; + _slots[i].max_backoff_failures = 0; + _slots[i].circuit_breaker_tripped = false; } - } - } else if (snap.down) { - // Backoff ladder + wrap-safe timing live in MQTTConnectionPolicy (Phase 6), - // exercised by host tests. Behavior is unchanged: both the link-down - // duration and the since-last-attempt interval must clear the current rung - // (elapsedMs is the wrap-safe form of the old ULONG_MAX branch). - if (MQTTConnectionPolicy::wifiReconnectDue( - (uint32_t)now, snap.started_ms, - (uint32_t)_last_wifi_reconnect_attempt, - _wifi_reconnect_backoff_attempt)) { - _last_wifi_reconnect_attempt = now; - _wifi_reconnect_backoff_attempt = - MQTTConnectionPolicy::nextWifiBackoffAttempt(_wifi_reconnect_backoff_attempt); - WiFi.disconnect(); - WiFi.begin(_obs->wifi_ssid, _obs->wifi_password); + _slots[i].last_reconnect_attempt = MQTTConnectionPolicy::immediateRetryLastAttempt( + static_cast(now), _slots[i].circuit_breaker_tripped, + _slots[i].reconnect_backoff, static_cast(i)); } } - _last_wifi_status = current_wifi_status; + _last_slot_reconnect_ms = now - MQTTConnectionPolicy::kReconnectGuardMs; } - return transitioned_to_connected; + return actions.retry_disconnected_slots_now; } bool MQTTBridge::isReady() const { - return _initialized && isWiFiConfigValid(_obs); + return _initialized && isNetworkConfigValid(_obs); } bool MQTTBridge::isIATAValid() const { @@ -3470,10 +3409,10 @@ void MQTTBridge::loop() { return; #else unsigned long now = millis(); - if (handleWiFiConnection(now) && !_ntp_synced) { + if (handleNetworkConnection(now) && !_ntp_synced) { syncTimeWithNTP(); } - if (_ntp_sync_pending && WiFi.status() == WL_CONNECTED) { + if (_ntp_sync_pending && _network->isConnected()) { _ntp_sync_pending = false; syncTimeWithNTP(); } @@ -3513,7 +3452,7 @@ void MQTTBridge::loop() { checkConfigurationMismatch(); // Periodic NTP refresh (every hour) — lightweight, non-blocking. - if (WiFi.status() == WL_CONNECTED && millis() - _last_ntp_sync > 3600000) { + if (_network->isConnected() && millis() - _last_ntp_sync > 3600000) { refreshNTP(); } @@ -4531,8 +4470,9 @@ bool MQTTBridge::probeNtpServer(const char* server, uint32_t min_epoch, // previous destination when a name fails to resolve, so sending anyway asks // whichever server resolved last and credits its genuine reply to this name. // Observed on d4: `set mqtt.ntp bogus.invalid` reported success with a correct - // epoch, answered by the pool address left over from boot. - if (!WiFi.hostByName(server, expected_ip)) { + // epoch, answered by the pool address left over from boot. Resolution goes + // through the link so it follows whichever esp_netif is selected. + if (!_network->resolveHost(server, expected_ip)) { if (why) *why = kNtpDnsFailedReason; return false; } @@ -4606,8 +4546,8 @@ bool MQTTBridge::probeNtpServer(const char* server, uint32_t min_epoch, } bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only, int attempts_per_server) { - if (!WiFi.isConnected()) { - MQTT_DEBUG_PRINTLN("Cannot sync time - WiFi not connected"); + if (!_network->isConnected()) { + MQTT_DEBUG_PRINTLN("Cannot sync time - %s not connected", _network->mediumName()); return false; } diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index b88b35a63..7b662da64 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -2,6 +2,7 @@ #include "MeshCore.h" #include "helpers/bridges/BridgeBase.h" +#include "helpers/NetworkLink.h" #include #include #include @@ -262,11 +263,6 @@ private: bool _ntp_synced; bool _ntp_sync_pending; // Flag to trigger NTP sync from loop() instead of event handler bool _slots_setup_done; // Deferred: slots set up after NTP sync - // WiFi.onEvent() handler registered once and never removed by end(); the bridge - // object is reused across restarts, so re-registering would leak handlers and - // duplicate every connect/disconnect log line. Inline-initialised so it survives - // construction and is NOT reset by end(). - bool _wifi_event_registered = false; int _max_active_slots; // Runtime limit: 5 with PSRAM, 2 without // Pending slot reconfigure: set from CLI (Core 1), processed by MQTT task (Core 0) @@ -460,25 +456,8 @@ private: unsigned long _last_config_warning; // Throttle configuration mismatch warnings static const unsigned long CONFIG_WARNING_INTERVAL = 300000; // Log every 5 minutes max - // WiFi connection state and exponential backoff - unsigned long _last_wifi_check; - wl_status_t _last_wifi_status; - bool _wifi_status_initialized; - // Packed OutageSnapshot; Core 0 (event + MQTT task) stores, Core 1 loads. - std::atomic _wifi_outage_bits; - unsigned long _last_wifi_reconnect_attempt; - uint8_t _wifi_reconnect_backoff_attempt; // 0..5 → 15s, 30s, 60s, 120s, 300s; reset on connect unsigned long _last_slot_reconnect_ms; // guards against concurrent TLS handshakes (15 s inter-slot gap) - AlertFaultPolicy::OutageSnapshot wifiOutage() const { - return AlertFaultPolicy::unpackOutageSnapshot( - _wifi_outage_bits.load(std::memory_order_acquire)); - } - void setWifiOutage(AlertFaultPolicy::OutageSnapshot snap) { - _wifi_outage_bits.store(AlertFaultPolicy::packOutageSnapshot(snap), - std::memory_order_release); - } - // Optional pointers for collecting stats internally (set by mesh if available) mesh::Dispatcher* _dispatcher; // For air times and errors mesh::Radio* _radio; // For noise floor @@ -541,6 +520,9 @@ private: // Close a live client for a reconfigure: softDisconnect where that is enough, // a real stop where an in-flight connection attempt has to be cancelled. void closeLiveClientForReconfigure(int index); + // Close a live client because the route changed under it: bounded where that + // is enough, a real stop where an in-flight attempt has to be cancelled. + void closeLiveClientForLinkTransition(int index); // Stop a live client, recording Stopped (proven) or Quarantined (not joined). void stopSlotClient(int index); // Reconnect a slot, starting it instead when the client is stopped (reconnect() is a @@ -563,13 +545,13 @@ private: void processPacketQueue(); bool publishStatus(); // Returns true if status was successfully published - bool handleWiFiConnection(unsigned long now); + bool handleNetworkConnection(unsigned long now); // FreeRTOS task function (runs on Core 0) #ifdef ESP_PLATFORM static void mqttTask(void* parameter); void mqttTaskLoop(); // Main loop for MQTT task - void initializeWiFiInTask(); // WiFi initialization moved to task + void initializeNetworkInTask(); // Selected-link initialization moved to task #endif bool publishPacket(mesh::Packet* packet, bool is_tx, bool& has_eligible_target, const uint8_t* raw_data = nullptr, int raw_len = 0, @@ -601,7 +583,6 @@ private: void getClientVersion(char* buffer, size_t buffer_size) const; void logMemoryStatus(); void refreshOriginFromPrefs(); - void applyWifiPowerSave(); // one mapping, applied on every association // begin()/end()-scoped PSRAM buffers. Each allocation is independent so a // transient heap shortage degrades to the existing stack fallback instead // of making the bridge unusable. @@ -632,6 +613,7 @@ private: // Observer config (MQTT/WiFi/timezone/SNMP/alert), persisted to /mqtt.json. // _prefs (held by BridgeBase) still provides upstream fields (freq/sf/node_name…). MQTTPrefs* _obs = nullptr; + NetworkLink* _network = nullptr; public: MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity); @@ -753,13 +735,21 @@ public: static unsigned long getWifiConnectedAtMillis(); /** - * Current WiFi 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 wifiOutage(); } + 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 @@ -821,7 +811,7 @@ public: uint16_t filter_mask; }; static bool getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out); - /** True when WiFi is set and at least one MQTT slot can run (preset + custom host if needed). */ + /** True when the selected network is configured and at least one MQTT slot can run. */ static bool isConfigValid(const MQTTPrefs* obs); static void formatSlotDiagReply(char* buf, size_t bufsize, int slot_index); static uint8_t getLastWifiDisconnectReason(); diff --git a/src/helpers/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 d62a07f18..6aab8da04 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -7,22 +7,71 @@ #include #include #include +#include #include #include +#include #include +#include #include #include #include #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[] = "********"; +// 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 { + wifi_mode_t _previous_mode; + bool _started_wifi; + bool _ready; + + public: + WebConfigEntropyGuard() + : _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 (_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. @@ -173,17 +222,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 + ? activeNetworkLink().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 // --------------------------------------------------------------------------- @@ -193,6 +253,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. @@ -214,6 +279,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; } @@ -224,8 +290,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(); + activeNetworkLink().lockSwitching(); + _network_locked = true; _was_setup_ap = true; _last_activity = millis(); WiFi.scanNetworks(true); // pre-populate the SSID picker @@ -234,21 +302,59 @@ bool WebConfigServer::startSetupMode(char reply[]) { return true; } -bool WebConfigServer::startLanMode(char reply[]) { +bool WebConfigServer::startLanMode(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"); + if (!HttpPort80Lease::acquire(HttpPort80Lease::Owner::WebConfig)) { + snprintf(reply, 160, "Err: port 80 is in use by %s", + HttpPort80Lease::ownerName()); return false; } + activeNetworkLink().lockSwitching(); + _network_locked = true; + // Read the address only once the route is pinned, so it names the locked link. + const IPAddress ip = activeNetworkLink().localIP(); + if (!activeNetworkLink().isConnected() || ip == IPAddress()) { + activeNetworkLink().unlockSwitching(); + _network_locked = false; + 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)) { + activeNetworkLink().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) { + 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(); - 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"); } @@ -318,8 +424,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 { @@ -335,8 +441,17 @@ 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; + _setup_reminder_at = 0; + if (_network_locked) { + activeNetworkLink().unlockSwitching(); + _network_locked = false; + } + HttpPort80Lease::release(HttpPort80Lease::Owner::WebConfig); if (_cb) _cb->onWebConfigStopped(); } @@ -359,6 +474,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", + activeNetworkLink().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); @@ -460,6 +584,15 @@ void WebConfigServer::drainBatch(uint32_t now) { return; // more commands next tick } } + // Wi-Fi onboarding is recorded by the stored SSID; only Ethernet LAN setup needs the marker. + if (_initial_setup && _admin_pwd_set && _batch_all_ok && _mode == MODE_LAN) { + if (_cb->onInitialSetupComplete()) { + _initial_setup = false; + _setup_code[0] = 0; + } else { + _batch_all_ok = false; + } + } _cb->onConfigBatchEnd(); WCLock lock(_mux); _batch_state = BATCH_DONE; @@ -573,13 +706,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 @@ -612,7 +746,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]); @@ -658,7 +792,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; @@ -669,19 +806,41 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) { } _login_fails = 0; _login_lock_until = 0; - for (int i = 0; i < 4; i++) sprintf(&_session_token[i * 8], "%08lx", (unsigned long)esp_random()); - _session_last_seen = now; + uint32_t generation; + { + 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; + } 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); @@ -836,7 +995,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 +1307,7 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) { // LAN still holding the factory password is a known credential on someone // else's network. The terminal warned about this client-side, which is a // reminder, not a rule — a pasted script or a direct POST ignored it. - if (_mode == MODE_SETUP && _initial_setup && !seq_sets_pwd && !_admin_pwd_set && + if (_initial_setup && !seq_sets_pwd && !_admin_pwd_set && (defer_reboot || seq_sets_ssid)) { req->send(400, "application/json", "{\"error\":\"admin password required for initial setup — " diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index 0dcebebd4..16ff47bab 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(bool initial_setup, char reply[]); // bind to the selected network void requestStop(); // stop listening and detach this session void tick(uint32_t now); // call every loop iteration @@ -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; @@ -164,8 +171,11 @@ 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; uint8_t _login_fails = 0; uint32_t _login_lock_until = 0; diff --git a/src/helpers/ethernet/ch390/CH390Config.h b/src/helpers/ethernet/ch390/CH390Config.h new file mode 100644 index 000000000..057230677 --- /dev/null +++ b/src/helpers/ethernet/ch390/CH390Config.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +/** + * Bring up the repository's CH390 lwIP interface from the board build flags. + * Shared by the companion transport wrapper and the observer network adapter so + * pin and static-IP behavior cannot drift between the two paths. + */ +static inline bool beginConfiguredCH390(const char* hostname = nullptr) { + ch390_config_t config = CH390_DEFAULT_CONFIG(); + config.spi_miso_gpio = ETH_MISO_PIN; + config.spi_mosi_gpio = ETH_MOSI_PIN; + config.spi_sck_gpio = ETH_SCLK_PIN; + config.spi_cs_gpio = ETH_CS_PIN; + config.int_gpio = ETH_INT_PIN; + if (hostname && hostname[0] != '\0') { + if (!CH390.setHostname(hostname)) { + Serial.printf("Network: invalid Ethernet hostname %s\n", hostname); + } + } + if (!CH390.begin(config)) return false; + +#if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET) + IPAddress ip(ETHERNET_STATIC_IP); + IPAddress gateway(ETHERNET_STATIC_GATEWAY); + IPAddress subnet(ETHERNET_STATIC_SUBNET); + #if defined(ETHERNET_STATIC_DNS) + IPAddress dns(ETHERNET_STATIC_DNS); + CH390.config(ip, gateway, subnet, dns); + #else + CH390.config(ip, gateway, subnet); + #endif +#endif + return true; +} diff --git a/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp b/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp index 7f6962453..ed262bfbd 100644 --- a/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp +++ b/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp @@ -1,4 +1,5 @@ #include "CH390EthernetInterface.h" +#include "CH390Config.h" void onWiFiEvent(WiFiEvent_t event) { switch(event){ @@ -29,26 +30,12 @@ bool CH390EthernetInterface::begin() { // listen to ethernet events WiFi.onEvent(onWiFiEvent); - // Init CH390 - ch390_config_t config = CH390_DEFAULT_CONFIG(); - config.spi_miso_gpio = ETH_MISO_PIN; - config.spi_mosi_gpio = ETH_MOSI_PIN; - config.spi_sck_gpio = ETH_SCLK_PIN; - config.spi_cs_gpio = ETH_CS_PIN; - config.int_gpio = ETH_INT_PIN; - if (!CH390.begin(config)) { + // Init CH390 using the same board configuration as the observer uplink. + if (!beginConfiguredCH390()) { ETHERNET_DEBUG_PRINTLN("Failed to initialize CH390 hardware."); return false; } - // Setup Static IP if build flags are present - #if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET) - IPAddress ip(ETHERNET_STATIC_IP); - IPAddress gw(ETHERNET_STATIC_GATEWAY); - IPAddress sn(ETHERNET_STATIC_SUBNET); - CH390.config(ip, gw, sn); - #endif - // Start Server server.begin(ETHERNET_TCP_PORT); ETHERNET_DEBUG_PRINTLN("listening on TCP port: %d", ETHERNET_TCP_PORT); diff --git a/test/README.md b/test/README.md index 647b6daf1..ee33b4c5b 100644 --- a/test/README.md +++ b/test/README.md @@ -29,6 +29,8 @@ does not reflect the GoogleTest count — run the built binary directly | `test_topic_template` | `src/helpers/MQTTTopicTemplate.h` | `{iata}/{device}/{token}/{type}` expansion, overflow/NUL-termination, and a buffer-size fuzz | | `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank all types except raw; required identifiers; invalid inputs/slots; exact buffer boundaries | | `test_mqtt_connection_policy` | `src/helpers/MQTTConnectionPolicy.h` | reconnect guard/backoff/stagger and breaker transitions; stable reset; JWT lifetime/renewal policy; exact timing boundaries and 32-bit `millis()` rollover; WiFi current-outage start sticky across STA reconnect attempts | +| `test_network_policy` | `src/helpers/NetworkPolicy.h` | transport-neutral MQTT link-transition actions and `start ota` selected-LAN versus forced/fallback SoftAP choice | +| `test_network_hostname` | `src/helpers/NetworkHostname.h` | DHCP-safe `meshcore-` hostname generation; invalid-character collapse; empty fallback; exact length boundary; stable identity suffix on truncation | | `test_alert_fault_policy` | `src/helpers/AlertFaultPolicy.h` | WiFi/MQTT fault edge detector; `OutageSnapshot` (down / started_ms / initiating reason) fed to tick and `formatWifiAlert`; reason-8 reconnects change neither duration nor initiating reason; flap between status polls; down at `millis()==0`; packed 64-bit cross-task word; rate-limit floor and first-fire; 5 s poll cadence and `millis()` rollover | | `test_display_viewport` | `src/helpers/ui/DisplayViewport.h`, `src/helpers/ui/DisplayFrameSignature.h` | logical-to-physical portrait mapping; fractional span coverage; fitted-width conversion; preferred/fallback text scaling; stable visible-frame change detection | | `test_mqtt_packet_queue_policy` | `src/helpers/MQTTPacketQueuePolicy.h` | queue-full eviction; stale-disconnect flush; adaptive drain limits; bounded QoS0 retries; exact timing boundaries and 32-bit `millis()` rollover | diff --git a/test/test_alert_fault_policy/test_alert_fault_policy.cpp b/test/test_alert_fault_policy/test_alert_fault_policy.cpp index 42ba27d7f..77bdecbd8 100644 --- a/test/test_alert_fault_policy/test_alert_fault_policy.cpp +++ b/test/test_alert_fault_policy/test_alert_fault_policy.cpp @@ -350,6 +350,17 @@ TEST(AlertFaultPolicy, FormatWifiAlertUsesSnapshotReason) { EXPECT_STREQ("WiFi down 47m", text); } +TEST(AlertFaultPolicy, FormatNetworkAlertUsesSelectedMediumLabel) { + Alert::Fault f = OkFault(); + const Alert::OutageSnapshot snap = Down(1000, 0); + const Alert::TickResult r = Alert::tick( + f, 1000 + kWifiThresh, snap, kWifiThresh, kMinInterval); + char text[80]; + ASSERT_TRUE(Alert::formatNetworkAlert( + text, sizeof(text), "Ethernet", r, snap)); + EXPECT_STREQ("Ethernet down 30m", text); +} + TEST(AlertFaultPolicy, FormatMqttSlotMessages) { char text[100]; Alert::formatMqttDown(text, sizeof(text), 1, "analyzer-us", 30U * 60000U); diff --git a/test/test_mqtt_client_state/test_mqtt_client_state.cpp b/test/test_mqtt_client_state/test_mqtt_client_state.cpp index 9b0f7c36c..1ff2b97ff 100644 --- a/test/test_mqtt_client_state/test_mqtt_client_state.cpp +++ b/test/test_mqtt_client_state/test_mqtt_client_state.cpp @@ -75,6 +75,35 @@ TEST(MqttClientState, StopIsAcknowledgedOnlyWhenEveryClientIsProvenStopped) { EXPECT_FALSE(mqttStopMayBeAcknowledged(nullptr, 3)); } +// A link transition closes each slot's transport because the route under it +// changed. These two predicates are what decide which clients it may touch. +// +// The distinction from a reconfigure matters: a reconfigure must cancel an +// in-flight attempt, because that attempt would report the OLD endpoint under +// the new configuration. A transition changes no configuration, so the attempt +// is left to resolve — cancelling it would mean an unbounded +// esp_mqtt_client_stop() on the sole MQTT worker during a routine link flap. +TEST(MqttClientState, LinkTransitionOnlyDisconnectsClientsWithNoAttemptInFlight) { + for (MqttClientState s : kAllStates) { + const bool live = mqttClientStateIsLive(s); + const bool disconnected_here = live && !mqttClientStateHasAttemptInFlight(s); + + // Quarantined is never touched: its SDK task was not joined, so it stays + // out of service for the rest of the boot. + if (s == MqttClientState::Quarantined) EXPECT_FALSE(live) << mqttClientStateName(s); + // Nothing that is already proven stopped is worth a disconnect. + if (mqttClientStateIsProvenStopped(s)) EXPECT_FALSE(live) << mqttClientStateName(s); + + EXPECT_EQ(s == MqttClientState::Connected || s == MqttClientState::Disconnected, + disconnected_here) << mqttClientStateName(s); + // Starting is live, and still must not be disconnected here. + if (s == MqttClientState::Starting) { + EXPECT_TRUE(live); + EXPECT_FALSE(disconnected_here); + } + } +} + TEST(MqttClientState, EveryStateHasAName) { for (MqttClientState s : kAllStates) { ASSERT_NE(nullptr, mqttClientStateName(s)); 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_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp b/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp index 421ea52b0..d94c658cd 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) { @@ -471,6 +485,20 @@ TEST(MQTTPrefsSerializer, UnknownGroupsAreIgnoredSoAppendedKeysAreDowngradeSafe) EXPECT_EQ(45, prefs.display_timeout_secs); } +TEST(MQTTPrefsSerializer, UnknownKeysInsideAKnownGroupAreDowngradeSafe) { + // Firmware that predates wifi.setup_complete sees it as an unknown key in a + // known group; the rest of the group must still load. + MQTTPrefs prefs = defaults(); + InputStream input( + "{version:1,wifi:{ssid:\"home\",future_key:1,power_save:2}}"); + MQTTPrefsSerializer serializer(&prefs); + ASSERT_TRUE(serializer.loadSerial(input)); + bool repaired = false; + ASSERT_TRUE(serializer.apply(&repaired)); + EXPECT_STREQ("home", prefs.wifi_ssid); + EXPECT_EQ(2, prefs.wifi_power_save); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_network_hostname/test_network_hostname.cpp b/test/test_network_hostname/test_network_hostname.cpp new file mode 100644 index 000000000..378148610 --- /dev/null +++ b/test/test_network_hostname/test_network_hostname.cpp @@ -0,0 +1,139 @@ +#include + +#include + +#include "helpers/NetworkHostname.h" + +namespace { + +const uint8_t kStableId[] = {0xab, 0xcd, 0x01}; + +void expectDhcpSafe(const char* hostname) { + ASSERT_NE(nullptr, hostname); + const size_t length = strlen(hostname); + EXPECT_GT(length, 0u); + EXPECT_LE(length, NetworkHostname::kMaxLength); + EXPECT_NE('-', hostname[0]); + EXPECT_NE('-', hostname[length - 1]); + for (size_t i = 0; i < length; ++i) { + const char ch = hostname[i]; + EXPECT_TRUE((ch >= 'a' && ch <= 'z') || + (ch >= '0' && ch <= '9') || ch == '-') + << "invalid byte at " << i; + } +} + +} // namespace + +TEST(NetworkHostname, PrefixesAndLowercasesReadableNodeName) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), + "Hill Repeater 2", kStableId, + sizeof(kStableId))); + EXPECT_STREQ("meshcore-hill-repeater-2", hostname); + expectDhcpSafe(hostname); +} + +TEST(NetworkHostname, CollapsesInvalidRunsAndTrimsEdges) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), + " Adam's___Hill / Repeater! ", + kStableId, sizeof(kStableId))); + EXPECT_STREQ("meshcore-adam-s-hill-repeater", hostname); + expectDhcpSafe(hostname); +} + +TEST(NetworkHostname, UsesDocumentedFallbackForEmptySanitizedName) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), "___ !!!", + kStableId, sizeof(kStableId))); + EXPECT_STREQ("meshcore-node-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); +} + +TEST(NetworkHostname, KeepsExactBoundaryWithoutIdentitySuffix) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), + "abcdefghijklmnopqrstuv", kStableId, + sizeof(kStableId))); + EXPECT_STREQ("meshcore-abcdefghijklmnopqrstuv", hostname); + EXPECT_EQ(NetworkHostname::kMaxLength, strlen(hostname)); +} + +TEST(NetworkHostname, TruncatesReadablePartAndAddsStableIdentitySuffix) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), + "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + kStableId, sizeof(kStableId))); + EXPECT_STREQ("meshcore-abcdefghijklmno-abcd01", hostname); + EXPECT_EQ(NetworkHostname::kMaxLength, strlen(hostname)); + expectDhcpSafe(hostname); +} + +TEST(NetworkHostname, IdentitySuffixSeparatesOtherwiseCollidingNames) { + const uint8_t other_id[] = {0x12, 0x34, 0x56}; + char first[NetworkHostname::kBufferSize]; + char second[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(first, sizeof(first), + "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + kStableId, sizeof(kStableId))); + ASSERT_TRUE(NetworkHostname::build(second, sizeof(second), + "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + other_id, sizeof(other_id))); + EXPECT_STRNE(first, second); + EXPECT_STREQ("meshcore-abcdefghijklmno-123456", second); +} + +TEST(NetworkHostname, RejectsMissingOutputBuffer) { + EXPECT_FALSE(NetworkHostname::build(nullptr, 0, "node", kStableId, + sizeof(kStableId))); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_network_policy/test_network_policy.cpp b/test/test_network_policy/test_network_policy.cpp new file mode 100644 index 000000000..3558e1e3c --- /dev/null +++ b/test/test_network_policy/test_network_policy.cpp @@ -0,0 +1,202 @@ +#include + +#include "helpers/NetworkPolicy.h" + +TEST(NetworkPolicy, MqttDownDisconnectsSlotsWithoutRequestingImmediateRetry) { + const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Down); + EXPECT_TRUE(actions.disconnect_started_slots); + EXPECT_FALSE(actions.retry_disconnected_slots_now); + EXPECT_FALSE(actions.reset_reconnect_backoff); +} + +TEST(NetworkPolicy, MqttUpRetriesWithoutClearingBrokerCircuitBreaker) { + const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Up); + EXPECT_FALSE(actions.disconnect_started_slots); + EXPECT_TRUE(actions.retry_disconnected_slots_now); + EXPECT_FALSE(actions.reset_reconnect_backoff); +} + +TEST(NetworkPolicy, NoTransitionHasNoMqttSideEffects) { + const auto actions = NetworkPolicy::mqttActions(NetworkTransition::None); + EXPECT_FALSE(actions.disconnect_started_slots); + EXPECT_FALSE(actions.retry_disconnected_slots_now); + EXPECT_FALSE(actions.reset_reconnect_backoff); +} + +TEST(NetworkPolicy, LinkSwitchReconnectsMqttSlotsImmediately) { + const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Switched); + EXPECT_TRUE(actions.disconnect_started_slots); + EXPECT_TRUE(actions.retry_disconnected_slots_now); + EXPECT_TRUE(actions.reset_reconnect_backoff); +} + +TEST(NetworkPolicy, MediumNamesAreStableForTransitionLogs) { + EXPECT_STREQ("none", NetworkPolicy::mediumName(NetworkMedium::None)); + EXPECT_STREQ("ethernet", NetworkPolicy::mediumName(NetworkMedium::Ethernet)); + EXPECT_STREQ("wifi", NetworkPolicy::mediumName(NetworkMedium::WiFi)); +} + +TEST(NetworkPolicy, BootPrefersEthernetAndOtherwiseUsesConfiguredWifi) { + 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, 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) { + 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, DeadWifiFailsBackToReadyEthernetImmediately) { + const NetworkPolicy::AutomaticSelectionInput input = { + NetworkMedium::WiFi, true, false, true, false, 0, 0}; + EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::automaticSelection(input)); +} + +TEST(NetworkPolicy, SwitchingLockPinsTheCurrentMedium) { + const NetworkPolicy::AutomaticSelectionInput input = { + NetworkMedium::WiFi, true, true, true, true, + NetworkPolicy::kEthernetFailbackStableMs, 0}; + EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::automaticSelection(input)); +} + +TEST(NetworkPolicy, DiagnosticsIdentifyEthernetFallbackCause) { + using Reason = NetworkDiagnosticReason; + EXPECT_EQ(Reason::EthernetInitFailed, + NetworkPolicy::automaticDiagnosticReason( + false, false, false, false, NetworkMedium::WiFi, false, 0)); + EXPECT_EQ(Reason::EthernetLinkUnknown, + NetworkPolicy::automaticDiagnosticReason( + true, false, false, false, NetworkMedium::WiFi, false, 0)); + EXPECT_EQ(Reason::EthernetLinkDown, + NetworkPolicy::automaticDiagnosticReason( + true, true, false, false, NetworkMedium::WiFi, false, 0)); + EXPECT_EQ(Reason::EthernetAwaitingIp, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, false, NetworkMedium::WiFi, false, 0)); +} + +TEST(NetworkPolicy, DiagnosticsExplainWhyReadyEthernetHasNotBeenSelected) { + using Reason = NetworkDiagnosticReason; + EXPECT_EQ(Reason::SwitchingLocked, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, true, NetworkMedium::WiFi, true, + NetworkPolicy::kEthernetFailbackStableMs)); + EXPECT_EQ(Reason::EthernetStabilizing, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, true, NetworkMedium::WiFi, false, + NetworkPolicy::kEthernetFailbackStableMs - 1)); + EXPECT_EQ(Reason::EthernetReady, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, true, NetworkMedium::WiFi, false, + NetworkPolicy::kEthernetFailbackStableMs)); + EXPECT_EQ(Reason::EthernetActive, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, true, NetworkMedium::Ethernet, false, 0)); +} + +TEST(NetworkPolicy, NtpIsScheduledOncePerConnectivityEdge) { + EXPECT_TRUE(NetworkPolicy::ntpPendingAfterConnectivitySample( + false, false, false, true)); + 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)); +} + +TEST(NetworkPolicy, StartOtaForceApOverridesAReachableSelectedNetwork) { + EXPECT_FALSE(NetworkPolicy::startOtaUsesSelectedNetwork(true, true)); + 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 482848de2..60945e041 100644 --- a/variants/thinknode_m7/platformio.ini +++ b/variants/thinknode_m7/platformio.ini @@ -37,22 +37,31 @@ build_src_filter = ${esp32_base.build_src_filter} lib_deps = ${esp32_base.lib_deps} stevemarple/MicroNMEA @ ^2.0.6 -[ThinkNode_M7_ethernet] +[ThinkNode_M7_ch390] build_flags = - -D ETHERNET_ENABLED -D ETHERNET_USE_CH390 - -D ETHERNET_CLASS=CH390EthernetInterface -D ETH_MISO_PIN=14 -D ETH_MOSI_PIN=48 -D ETH_SCLK_PIN=47 -D ETH_CS_PIN=21 -D ETH_INT_PIN=45 -D ETHERNET_DEBUG_LOGGING=1 +lib_deps = + https://github.com/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 +; repeater/room entry points is the legacy nRF52 Ethernet CLI. +[ThinkNode_M7_ethernet] +build_flags = + ${ThinkNode_M7_ch390.build_flags} + -D ETHERNET_ENABLED + -D ETHERNET_CLASS=CH390EthernetInterface build_src_filter = + + lib_deps = - https://github.com/liamcottle/ESP32-CH390.git#47b401f1de546118b03c18b5689dedec45871f2d + ${ThinkNode_M7_ch390.lib_deps} [env:ThinkNode_M7_repeater] extends = ThinkNode_M7 @@ -182,8 +191,8 @@ extends = ThinkNode_M7 build_src_filter = ${ThinkNode_M7.build_src_filter} +<../examples/kiss_modem/> -; MQTT observer envs are WiFi-only: the bridge's link management is bound to the -; WiFi station API, so the onboard CH390 cannot carry MQTT yet. The board has +; MQTT observers prefer the onboard CH390 Ethernet and fall back to Wi-Fi, without +; enabling the legacy Ethernet CLI transport (no ETHERNET_ENABLED). The board has ; PSRAM, so MAX_NEIGHBOURS enables WITH_MQTT_NEIGHBORS (see MQTTBridge.h). [env:ThinkNode_M7_repeater_observer_mqtt] extends = ThinkNode_M7 @@ -194,6 +203,8 @@ board_ssl_cert_source = adafruit-full board_build.embed_files = src/certs/x509_crt_bundle.bin build_flags = ${ThinkNode_M7.build_flags} + ${ThinkNode_M7_ch390.build_flags} + -D NETWORK_PREFER_ETHERNET=1 -D ADVERT_NAME='"MQTT Observer"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 @@ -221,6 +232,7 @@ build_src_filter = ${ThinkNode_M7.build_src_filter} +<../examples/simple_repeater> lib_deps = ${ThinkNode_M7.lib_deps} + ${ThinkNode_M7_ch390.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 bblanchon/ArduinoJson @ 7.4.3 @@ -238,6 +250,8 @@ board_ssl_cert_source = adafruit-full board_build.embed_files = src/certs/x509_crt_bundle.bin build_flags = ${ThinkNode_M7.build_flags} + ${ThinkNode_M7_ch390.build_flags} + -D NETWORK_PREFER_ETHERNET=1 -D ADVERT_NAME='"ThinkNode M7 Room Observer"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 @@ -266,6 +280,7 @@ build_src_filter = ${ThinkNode_M7.build_src_filter} +<../examples/simple_room_server> lib_deps = ${ThinkNode_M7.lib_deps} + ${ThinkNode_M7_ch390.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 bblanchon/ArduinoJson @ 7.4.3 diff --git a/webui/index.html b/webui/index.html index ab7b43d6a..3102d352c 100644 --- a/webui/index.html +++ b/webui/index.html @@ -476,7 +476,7 @@ body.tab-cli{padding-bottom:0}
-
+

Maintenance

@@ -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); @@ -1546,6 +1548,8 @@ 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], + ["link.diag","Ethernet selection and fallback diagnostics",1], ["wifi.status","WiFi connection, IP, RSSI and uptime",1], ["mqtt.origin","Observer name in published messages",0], ["mqtt.iata","IATA region code used in topic paths",0],