From cdde4bf673dbfba614fc72b4b61d7a5e08a1c9a7 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 2 Sep 2026 21:37:30 -0700 Subject: [PATCH] feat(mqtt): add link diagnostics and improve network logging --- MQTT_IMPLEMENTATION.md | 8 ++ examples/simple_repeater/MyMesh.cpp | 6 +- examples/simple_room_server/MyMesh.cpp | 6 +- scripts/webconfig_mock_server.py | 3 + src/helpers/CommonCLI_Observer.cpp | 2 + src/helpers/NetworkInterface.cpp | 117 ++++++++++++++++-- src/helpers/NetworkInterface.h | 1 + src/helpers/NetworkPolicy.h | 83 ++++++++++++- src/helpers/bridges/MQTTBridge.cpp | 58 ++++++--- .../test_network_policy.cpp | 62 +++++++++- webui/index.html | 1 + 11 files changed, 317 insertions(+), 30 deletions(-) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 774ff63a..69fd7b58 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -81,6 +81,7 @@ reboot ```bash get wifi.ssid get link.status +get link.diag get bridge.enabled get mqtt.rx get mqtt.tx @@ -547,6 +548,7 @@ These settings apply across all MQTT slots: - `get wifi.ssid` - Get WiFi SSID - `get wifi.pwd` - Get WiFi password - `get link.status` - Get the selected network medium, connection status, IP, signal when available, and uptime +- `get link.diag` - Explain automatic Ethernet selection using controller initialization, link event, IP, WiFi fallback, route-lock, and reason state - `get wifi.status` - WiFi-only compatibility alias; reports n/a when another medium is selected - `get wifi.powersave` - Get WiFi power save mode (none/min/max) @@ -859,6 +861,12 @@ the radio actually performs in that case. ### Connection Handling - Automatic reconnection with exponential backoff per slot; a slot that stays down through the full backoff ladder is retried on a slow periodic probe instead of hammering the broker +- Ethernet-preferred builds wait the full configured boot probe window for an Ethernet IP; + delayed or unavailable PHY carrier reporting does not shorten the DHCP deadline. The boot + selection log includes the measured probe duration. +- Ethernet/WiFi transitions are logged. A lost or changed route stops every started MQTT + client, including one whose disconnect callback arrived first; once a usable route returns, + route-caused backoff is cleared and one immediate reconnect is allowed - Packets are queued while a slot is disconnected and flushed when it recovers ### Raw Radio Data Capture diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index cfbe35d8..1d16accc 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1152,10 +1152,12 @@ void MyMesh::begin(FILESYSTEM *fs) { MQTTPrefs* obs = _cli.getObserverPrefs(); Serial.printf("Network: probing Ethernet for up to %lums\n", (unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS); + const uint32_t ethernet_probe_started_at = millis(); boot_network.bootstrap(obs->wifi_ssid, obs->wifi_password, NETWORK_ETHERNET_BOOT_WAIT_MS); - Serial.printf("Network: selected %s (%s)\n", boot_network.mediumName(), - boot_network.statusName()); + Serial.printf("Network: selected %s (%s) after %lums\n", + boot_network.mediumName(), boot_network.statusName(), + (unsigned long)(millis() - ethernet_probe_started_at)); } acl.load(_fs, self_id); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 01b157cc..386c36b4 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -953,10 +953,12 @@ void MyMesh::begin(FILESYSTEM *fs) { MQTTPrefs* obs = _cli.getObserverPrefs(); Serial.printf("Network: probing Ethernet for up to %lums\n", (unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS); + const uint32_t ethernet_probe_started_at = millis(); boot_network.bootstrap(obs->wifi_ssid, obs->wifi_password, NETWORK_ETHERNET_BOOT_WAIT_MS); - Serial.printf("Network: selected %s (%s)\n", boot_network.mediumName(), - boot_network.statusName()); + Serial.printf("Network: selected %s (%s) after %lums\n", + boot_network.mediumName(), boot_network.statusName(), + (unsigned long)(millis() - ethernet_probe_started_at)); } acl.load(_fs, self_id); diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index 547889a1..74c6b730 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -500,6 +500,9 @@ GETTERS = { "link.status": lambda c: ( "wifi: connected, IP: 192.168.1.42, RSSI: -58 dBm, Uptime: %dm" % (int(time.time() - ST.start) // 60)), + "link.diag": lambda c: ( + "why:ethernet-not-enabled selected:wifi\n" + "wifi:state:connected ip:192.168.1.42"), "mqtt.status": lambda c: cli_mqtt_status(c), "mqtt.presets": lambda c: "\n".join( "%2d. %s%s" % (i + 1, n, "" if nd == "none" else " (needs %s)" % nd) diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 28ac768b..273666be 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -1038,6 +1038,8 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf } else { strcpy(reply, _mqtt_prefs.wifi_password[0] ? "> ******** (serial only)" : "> (not set)"); } + } else if (strcmp(config, "link.diag") == 0) { + activeNetworkInterface().formatDiagnostics(reply, 160); } else if (memcmp(config, "link.status", 11) == 0 || memcmp(config, "wifi.status", 11) == 0) { NetworkInterface& network = activeNetworkInterface(); diff --git a/src/helpers/NetworkInterface.cpp b/src/helpers/NetworkInterface.cpp index ae5273e3..c22399a1 100644 --- a/src/helpers/NetworkInterface.cpp +++ b/src/helpers/NetworkInterface.cpp @@ -199,12 +199,31 @@ class WiFiNetworkInterface final : public NetworkInterfaceBase { bool resolveHost(const char* hostname, IPAddress& address) const override { return WiFi.hostByName(hostname, address); } + void formatDiagnostics(char* reply, size_t reply_size) const override { + snprintf(reply, reply_size, + "> why:ethernet-not-enabled selected:wifi\n" + "wifi:state:%s ip:%s", + statusName(), + localIP().toString().c_str()); + } }; #if defined(NETWORK_PREFER_ETHERNET) class EthernetNetworkInterface final : public NetworkInterfaceBase { + public: + enum class EventState : uint8_t { + None, + Started, + LinkDown, + LinkUp, + GotIp, + Stopped, + }; + + private: bool _started = false; bool _event_registered = false; + std::atomic _event_state{static_cast(EventState::None)}; public: const char* mediumName() const override { return "ethernet"; } @@ -220,14 +239,30 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { if (!_event_registered) { WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t) { switch (event) { + case ARDUINO_EVENT_ETH_START: + _event_state.store(static_cast(EventState::Started), + std::memory_order_relaxed); + break; + case ARDUINO_EVENT_ETH_CONNECTED: + _event_state.store(static_cast(EventState::LinkUp), + std::memory_order_relaxed); + break; case ARDUINO_EVENT_ETH_GOT_IP: + _event_state.store(static_cast(EventState::GotIp), + std::memory_order_relaxed); noteConnected(millis()); break; case ARDUINO_EVENT_ETH_DISCONNECTED: + _event_state.store(static_cast(EventState::LinkDown), + std::memory_order_relaxed); // Ethernet has no 802.11 reason code; zero means unavailable. noteDisconnected(millis(), 0); _connected_at.store(0, std::memory_order_relaxed); break; + case ARDUINO_EVENT_ETH_STOP: + _event_state.store(static_cast(EventState::Stopped), + std::memory_order_relaxed); + break; default: break; } @@ -282,6 +317,49 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { // DNS follows the selected esp_netif even though this entry point is named WiFi. return WiFi.hostByName(hostname, address); } + void formatDiagnostics(char* reply, size_t reply_size) const override { + snprintf(reply, reply_size, "> ethernet:%s ip=%s", + statusName(), localIP().toString().c_str()); + } + + EventState eventState() const { + return static_cast( + _event_state.load(std::memory_order_relaxed)); + } + bool sampleLink(bool& known) const { + known = false; + if (!_started) return false; + + // IEEE 802.3 BMSR link status is latch-low. Read it twice so the second + // value is the current carrier state rather than a remembered link flap. + (void)CH390.readPHY(0x01); + const uint32_t bmsr = CH390.readPHY(0x01) & 0xffffu; + if (bmsr != 0 && bmsr != 0xffffu) { + known = true; + return (bmsr & (1u << 2)) != 0; + } + + // A failed/unsupported direct PHY read can still use the driver's events. + const EventState state = eventState(); + known = state == EventState::LinkDown || state == EventState::LinkUp || + state == EventState::GotIp || state == EventState::Stopped; + return state == EventState::LinkUp || state == EventState::GotIp; + } + bool linkUp() const { + bool known = false; + return sampleLink(known); + } + const char* eventName() const { + switch (eventState()) { + case EventState::None: return "none"; + case EventState::Started: return "started"; + case EventState::LinkDown: return "link-down"; + case EventState::LinkUp: return "link-up"; + case EventState::GotIp: return "got-ip"; + case EventState::Stopped: return "stopped"; + } + return "unknown"; + } }; class AutomaticNetworkInterface final : public NetworkInterface { @@ -385,13 +463,9 @@ class AutomaticNetworkInterface final : public NetworkInterface { } const uint32_t started_at = millis(); - const uint32_t link_wait_ms = wait_ms < 1500 ? wait_ms : 1500; - while (_ethernet_started && !CH390.linkUp() && - (uint32_t)(millis() - started_at) < link_wait_ms) { - delay(25); - } - while (_ethernet_started && CH390.linkUp() && !_ethernet.isConnected() && - (uint32_t)(millis() - started_at) < wait_ms) { + while (NetworkPolicy::ethernetBootProbePending( + _ethernet_started, _ethernet.isConnected(), + (uint32_t)(millis() - started_at), wait_ms)) { delay(25); } @@ -489,6 +563,35 @@ class AutomaticNetworkInterface final : public NetworkInterface { return _selected != NetworkMedium::None && selectedInterface().resolveHost(hostname, address); } + void formatDiagnostics(char* reply, size_t reply_size) const override { + const bool ethernet_connected = _ethernet.isConnected(); + bool ethernet_link_known = false; + bool ethernet_link_up = _ethernet.sampleLink(ethernet_link_known); + ethernet_link_known = ethernet_link_known || ethernet_connected; + ethernet_link_up = ethernet_link_up || ethernet_connected; + const bool switching_locked = + _switch_locks.load(std::memory_order_relaxed) != 0; + const uint32_t now_ms = millis(); + const uint32_t ethernet_stable_ms = _ethernet_stable_since == 0 + ? 0 : (uint32_t)(now_ms - _ethernet_stable_since); + const NetworkDiagnosticReason reason = + NetworkPolicy::automaticDiagnosticReason( + _ethernet_started, ethernet_link_known, ethernet_link_up, + ethernet_connected, _selected, switching_locked, + ethernet_stable_ms); + snprintf(reply, reply_size, + "> why:%s selected:%s lock:%s\n" + "eth:init:%s evt:%s link:%s ip:%s\n" + "wifi:cfg:%s started:%s link:%s", + NetworkPolicy::diagnosticReasonName(reason), mediumName(), + switching_locked ? "yes" : "no", + _ethernet_started ? "ok" : "failed", _ethernet.eventName(), + ethernet_link_known ? (ethernet_link_up ? "up" : "down") + : "unknown", + _ethernet.localIP().toString().c_str(), + wifiConfigured() ? "yes" : "no", _wifi_started ? "yes" : "no", + (_wifi_started && _wifi.isConnected()) ? "up" : "down"); + } unsigned long connectedAtMillis() const override { return _selected == NetworkMedium::None ? 0 : selectedInterface().connectedAtMillis(); } diff --git a/src/helpers/NetworkInterface.h b/src/helpers/NetworkInterface.h index a5e15274..646a73ce 100644 --- a/src/helpers/NetworkInterface.h +++ b/src/helpers/NetworkInterface.h @@ -50,6 +50,7 @@ class NetworkInterface { virtual IPAddress localIP() const = 0; virtual int rssi() const = 0; // INT_MIN when the selected medium has no RSSI. virtual bool resolveHost(const char* hostname, IPAddress& address) const = 0; + virtual void formatDiagnostics(char* reply, size_t reply_size) const = 0; virtual unsigned long connectedAtMillis() const = 0; virtual uint8_t lastDisconnectReason() const = 0; diff --git a/src/helpers/NetworkPolicy.h b/src/helpers/NetworkPolicy.h index fe434a03..5d083eb4 100644 --- a/src/helpers/NetworkPolicy.h +++ b/src/helpers/NetworkPolicy.h @@ -15,20 +15,39 @@ enum class NetworkMedium : uint8_t { WiFi, }; +enum class NetworkDiagnosticReason : uint8_t { + EthernetActive, + EthernetInitFailed, + EthernetLinkUnknown, + EthernetLinkDown, + EthernetAwaitingIp, + EthernetStabilizing, + SwitchingLocked, + EthernetReady, +}; + namespace NetworkPolicy { struct MQTTTransitionActions { - bool disconnect_slots; + bool stop_started_slots; bool retry_disconnected_slots_now; + bool reset_reconnect_backoff; }; static constexpr MQTTTransitionActions mqttActions(NetworkTransition transition) { return { transition == NetworkTransition::Down || transition == NetworkTransition::Switched, transition == NetworkTransition::Up || transition == NetworkTransition::Switched, + transition == NetworkTransition::Up || transition == NetworkTransition::Switched, }; } +static constexpr const char* mediumName(NetworkMedium medium) { + return medium == NetworkMedium::Ethernet ? "ethernet" + : medium == NetworkMedium::WiFi ? "wifi" + : "none"; +} + struct AutomaticSelectionInput { NetworkMedium selected; bool ethernet_connected; @@ -46,6 +65,58 @@ static constexpr uint32_t kEthernetDownGraceMs = 3000; static constexpr uint32_t kEthernetFailbackStableMs = 10000; static constexpr uint32_t kNtpRetryMs = 30000; +static inline NetworkDiagnosticReason automaticDiagnosticReason( + bool ethernet_initialized, bool ethernet_link_known, + bool ethernet_link_up, bool ethernet_connected, NetworkMedium selected, + bool switching_locked, uint32_t ethernet_stable_ms) { + if (!ethernet_initialized) { + return NetworkDiagnosticReason::EthernetInitFailed; + } + if (!ethernet_link_known) { + return NetworkDiagnosticReason::EthernetLinkUnknown; + } + if (!ethernet_link_up) { + return NetworkDiagnosticReason::EthernetLinkDown; + } + if (!ethernet_connected) { + return NetworkDiagnosticReason::EthernetAwaitingIp; + } + if (selected == NetworkMedium::Ethernet) { + return NetworkDiagnosticReason::EthernetActive; + } + if (switching_locked) { + return NetworkDiagnosticReason::SwitchingLocked; + } + if (selected == NetworkMedium::WiFi && + ethernet_stable_ms < kEthernetFailbackStableMs) { + return NetworkDiagnosticReason::EthernetStabilizing; + } + return NetworkDiagnosticReason::EthernetReady; +} + +static inline const char* diagnosticReasonName( + NetworkDiagnosticReason reason) { + switch (reason) { + case NetworkDiagnosticReason::EthernetActive: + return "ethernet-active"; + case NetworkDiagnosticReason::EthernetInitFailed: + return "ethernet-init-failed"; + case NetworkDiagnosticReason::EthernetLinkUnknown: + return "ethernet-link-unknown"; + case NetworkDiagnosticReason::EthernetLinkDown: + return "ethernet-link-down"; + case NetworkDiagnosticReason::EthernetAwaitingIp: + return "ethernet-awaiting-ip"; + case NetworkDiagnosticReason::EthernetStabilizing: + return "ethernet-stabilizing"; + case NetworkDiagnosticReason::SwitchingLocked: + return "switching-locked"; + case NetworkDiagnosticReason::EthernetReady: + return "ethernet-ready"; + } + return "unknown"; +} + static constexpr bool ntpPendingAfterConnectivitySample( bool synced, bool pending, bool was_connected, bool connected) { return pending || (!synced && connected && !was_connected); @@ -64,6 +135,16 @@ static constexpr NetworkMedium bootSelection(bool ethernet_connected, : NetworkMedium::None; } +// Once the Ethernet controller has initialized, allow the entire boot probe +// window for link negotiation and DHCP. PHY carrier is useful diagnostic data, +// but it must not shorten the advertised deadline when carrier reporting lags. +static constexpr bool ethernetBootProbePending(bool ethernet_initialized, + bool ethernet_connected, + uint32_t elapsed_ms, + uint32_t wait_ms) { + return ethernet_initialized && !ethernet_connected && elapsed_ms < wait_ms; +} + static inline NetworkMedium automaticSelection( const AutomaticSelectionInput& input) { if (input.switching_locked) return input.selected; diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 12bf9a30..cfbe3ad5 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1307,16 +1307,7 @@ void MQTTBridge::mqttTaskLoop() { } #endif - bool network_just_connected = handleNetworkConnection(now); - if (network_just_connected) { - // The uplink recovered — reset last_reconnect_attempt for disconnected slots so they - // retry immediately rather than waiting up to 5 min for backoff timers to expire. - for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - if (_slots[i].enabled && _slots[i].initial_connect_done && !_slots[i].connected) { - _slots[i].last_reconnect_attempt = 0; - } - } - } + handleNetworkConnection(now); // Schedule once per link-up edge. Failed syncs are owned by the 30-second // retry below; re-arming here on every loop would make a blocked NTP path @@ -2745,19 +2736,58 @@ void MQTTBridge::checkConfigurationMismatch() { } bool MQTTBridge::handleNetworkConnection(unsigned long now) { + const NetworkMedium previous_medium = _network->medium(); const NetworkTransition transition = _network->maintain((uint32_t)now, _obs->wifi_power_save); + const NetworkMedium selected_medium = _network->medium(); + + if (transition == NetworkTransition::Down) { + MQTT_DEBUG_PRINTLN("Network: %s link down", NetworkPolicy::mediumName(previous_medium)); + } else if (transition == NetworkTransition::Up) { + MQTT_DEBUG_PRINTLN("Network: %s link up (%s, IP %s)", + NetworkPolicy::mediumName(selected_medium), + _network->statusName(), _network->localIP().toString().c_str()); + } else if (transition == NetworkTransition::Switched) { + MQTT_DEBUG_PRINTLN("Network: switched %s -> %s (%s, IP %s)", + NetworkPolicy::mediumName(previous_medium), + NetworkPolicy::mediumName(selected_medium), + _network->statusName(), _network->localIP().toString().c_str()); + } + const NetworkPolicy::MQTTTransitionActions actions = NetworkPolicy::mqttActions(transition); - if (actions.disconnect_slots) { + if (actions.stop_started_slots) { // Broker ownership stays in the bridge. The physical adapter reports the - // edge; the bridge explicitly closes every slot instead of waiting for - // eventual socket timeouts. + // edge; the bridge explicitly stops every started slot instead of waiting + // for eventual socket timeouts. Do not gate this on slot.connected: the + // ESP-MQTT disconnect callback can clear that flag before the network edge + // reaches this task, but its client task and transport can still be alive. for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - if (_slots[i].client && _slots[i].connected) { + if (_slots[i].client && _slots[i].client->isStarted()) { + MQTT_DEBUG_PRINTLN("MQTT%d stopping for network transition", i + 1); _slots[i].client->disconnect(); } + _slots[i].connected = false; + _slots[i].connected_at_ms = 0; } + updateCachedConnectionStatus(); + } + + if (actions.reset_reconnect_backoff) { + // A usable route is a new connection epoch. Failures earned on the old + // route must not strand the replacement route on the 5-minute rung or at + // the circuit breaker. Preserve JWTs and slot configuration, but give each + // disconnected active slot one immediate, freshly guarded attempt. + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].initial_connect_done && !_slots[i].connected) { + _slots[i].reconnect_backoff = 0; + _slots[i].max_backoff_failures = 0; + _slots[i].circuit_breaker_tripped = false; + _slots[i].last_reconnect_attempt = + now - MQTTConnectionPolicy::reconnectDelayMs(0, static_cast(i)); + } + } + _last_slot_reconnect_ms = now - MQTTConnectionPolicy::kReconnectGuardMs; } return actions.retry_disconnected_slots_now; } diff --git a/test/test_network_policy/test_network_policy.cpp b/test/test_network_policy/test_network_policy.cpp index 93e0a1f2..b1131a14 100644 --- a/test/test_network_policy/test_network_policy.cpp +++ b/test/test_network_policy/test_network_policy.cpp @@ -4,26 +4,36 @@ TEST(NetworkPolicy, MqttDownDisconnectsSlotsWithoutRequestingImmediateRetry) { const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Down); - EXPECT_TRUE(actions.disconnect_slots); + EXPECT_TRUE(actions.stop_started_slots); EXPECT_FALSE(actions.retry_disconnected_slots_now); + EXPECT_FALSE(actions.reset_reconnect_backoff); } TEST(NetworkPolicy, MqttUpRequestsImmediateRetryWithoutDisconnectingSlots) { const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Up); - EXPECT_FALSE(actions.disconnect_slots); + EXPECT_FALSE(actions.stop_started_slots); EXPECT_TRUE(actions.retry_disconnected_slots_now); + EXPECT_TRUE(actions.reset_reconnect_backoff); } TEST(NetworkPolicy, NoTransitionHasNoMqttSideEffects) { const auto actions = NetworkPolicy::mqttActions(NetworkTransition::None); - EXPECT_FALSE(actions.disconnect_slots); + EXPECT_FALSE(actions.stop_started_slots); EXPECT_FALSE(actions.retry_disconnected_slots_now); + EXPECT_FALSE(actions.reset_reconnect_backoff); } TEST(NetworkPolicy, LinkSwitchReconnectsMqttSlotsImmediately) { const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Switched); - EXPECT_TRUE(actions.disconnect_slots); + EXPECT_TRUE(actions.stop_started_slots); EXPECT_TRUE(actions.retry_disconnected_slots_now); + EXPECT_TRUE(actions.reset_reconnect_backoff); +} + +TEST(NetworkPolicy, MediumNamesAreStableForTransitionLogs) { + EXPECT_STREQ("none", NetworkPolicy::mediumName(NetworkMedium::None)); + EXPECT_STREQ("ethernet", NetworkPolicy::mediumName(NetworkMedium::Ethernet)); + EXPECT_STREQ("wifi", NetworkPolicy::mediumName(NetworkMedium::WiFi)); } TEST(NetworkPolicy, BootPrefersEthernetAndOtherwiseUsesConfiguredWifi) { @@ -32,6 +42,15 @@ TEST(NetworkPolicy, BootPrefersEthernetAndOtherwiseUsesConfiguredWifi) { EXPECT_EQ(NetworkMedium::None, NetworkPolicy::bootSelection(false, false)); } +TEST(NetworkPolicy, EthernetBootProbeHonorsFullDeadlineUntilConnected) { + EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(true, false, 0, 8000)); + EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(true, false, 1500, 8000)); + EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(true, false, 7999, 8000)); + EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(true, false, 8000, 8000)); + EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(true, true, 100, 8000)); + EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(false, false, 100, 8000)); +} + TEST(NetworkPolicy, EthernetFailureWaitsForGraceAndConnectedWifi) { NetworkPolicy::AutomaticSelectionInput input = { NetworkMedium::Ethernet, false, true, true, false, 0, @@ -57,6 +76,41 @@ TEST(NetworkPolicy, SwitchingLockPinsTheCurrentMedium) { EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::automaticSelection(input)); } +TEST(NetworkPolicy, DiagnosticsIdentifyEthernetFallbackCause) { + using Reason = NetworkDiagnosticReason; + EXPECT_EQ(Reason::EthernetInitFailed, + NetworkPolicy::automaticDiagnosticReason( + false, false, false, false, NetworkMedium::WiFi, false, 0)); + EXPECT_EQ(Reason::EthernetLinkUnknown, + NetworkPolicy::automaticDiagnosticReason( + true, false, false, false, NetworkMedium::WiFi, false, 0)); + EXPECT_EQ(Reason::EthernetLinkDown, + NetworkPolicy::automaticDiagnosticReason( + true, true, false, false, NetworkMedium::WiFi, false, 0)); + EXPECT_EQ(Reason::EthernetAwaitingIp, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, false, NetworkMedium::WiFi, false, 0)); +} + +TEST(NetworkPolicy, DiagnosticsExplainWhyReadyEthernetHasNotBeenSelected) { + using Reason = NetworkDiagnosticReason; + EXPECT_EQ(Reason::SwitchingLocked, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, true, NetworkMedium::WiFi, true, + NetworkPolicy::kEthernetFailbackStableMs)); + EXPECT_EQ(Reason::EthernetStabilizing, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, true, NetworkMedium::WiFi, false, + NetworkPolicy::kEthernetFailbackStableMs - 1)); + EXPECT_EQ(Reason::EthernetReady, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, true, NetworkMedium::WiFi, false, + NetworkPolicy::kEthernetFailbackStableMs)); + EXPECT_EQ(Reason::EthernetActive, + NetworkPolicy::automaticDiagnosticReason( + true, true, true, true, NetworkMedium::Ethernet, false, 0)); +} + TEST(NetworkPolicy, NtpIsScheduledOncePerConnectivityEdge) { EXPECT_TRUE(NetworkPolicy::ntpPendingAfterConnectivitySample( false, false, false, true)); diff --git a/webui/index.html b/webui/index.html index a71bd1e4..6a851c1f 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1547,6 +1547,7 @@ var CLI_KEYS=[ ["wifi.pwd","WiFi password",0], ["wifi.powersave","WiFi power-save mode",0,"none|min|max"], ["link.status","Selected network, IP, signal and uptime",1], + ["link.diag","Ethernet selection and fallback diagnostics",1], ["wifi.status","WiFi connection, IP, RSSI and uptime",1], ["mqtt.origin","Observer name in published messages",0], ["mqtt.iata","IATA region code used in topic paths",0],