diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 425a3353..a907a707 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -524,6 +524,7 @@ jobs: run: | python3 -B test/test_wireless_control.py -v python3 -B test/test_serial_wifi_sessions.py -v + python3 -B test/test_companion_wifi_listener.py -v python3 -B test/test_serial_ethernet_sessions.py -v python3 -B test/test_companion_wifi_session.py -v python3 -B test/test_espnow_radio_lifecycle.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 0eb878b3..6e96fcd7 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -422,6 +422,26 @@ credentials alone do not turn it on. ESP32 simultaneous-transport builds apply the change from the main loop; exclusive-transport builds and Pico W apply it after reboot. This does not disable a primary ESP-NOW mesh radio or add Bluetooth to a WiFi-only build. + +ESP32 WiFi Companions also accept `get wifi.tcp` over USB, BLE, or the browser +CLI. This checks the app's TCP listener (normally port `5000`), not just the +WiFi association. `listening` means the socket is open; `waiting-ip` means no +usable station or setup-AP address yet; `retrying` means listener startup +failed and will be retried automatically; `off` means no enabled listener +request. `reg=1` confirms the interface is registered for Companion commands. +`client=1` means a TCP client is connected. `acc`, `rx`, and `tx` count accepted +clients, complete received frames, and complete transmitted frames since boot; +the value after `/` is the last frame's hexadecimal opcode. `fail`, `err`, and +`retry` show listener failures, the last socket error, and seconds until retry. +`drop` gives the last client-disconnect reason. These counters contain no +message contents or credentials. An open listener does not prove a router or +guest-network firewall permits access from the phone. + +Listener retries wait 1, 2, 4, 8, 16, then at most 30 seconds. They do not +reconnect WiFi, interrupt BLE/WebConfig/OTA, replace a healthy app connection, +or re-enable explicitly disabled WiFi. `get display.wifi` includes `tcp=1` +only when the listener is actually open; `services=1` alone is not proof. + See [local maintenance commands](terminal_chat_cli.md#local-maintenance-commands) for the complete access rules. WiFi-only Companions accept all three modes. A Full Companion that runs BLE and infrastructure WiFi diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 6547a57a..71167202 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -314,6 +314,11 @@ WiFi store, and enablement is saved in node preferences. WiFi/BLE builds apply enablement from the main loop; exclusive WiFi/BLE builds apply it after reboot. `get wifi.status` reports the connection state and `get wifi.ip` reports the station address, the setup AP address, or `(not connected)`. +`get wifi.tcp` reports the actual app listener, interface registration, client +connection and completed frame counters. It is read-only and available through +the same binary CLI command over BLE or USB when TCP is unreachable. Listener +startup failures recover automatically without resetting a healthy client or +the WiFi connection; see [the CLI diagnostics](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server). `set freq ` works through command `0x42` and authorized LoRa CLI, like `set radio`; it saves the frequency and requires reboot to apply. See [local maintenance commands](terminal_chat_cli.md#local-maintenance-commands) diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 1b6b8057..cc260aba 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -525,6 +525,17 @@ their views. Companion session state is device-wide, so use one active Companion application at a time. On nRF52, BLE remains available while USB is in terminal or mOTA mode. +The Companion TCP listener starts only after a usable WiFi station or setup-AP +address exists. If opening the socket fails, it closes any failed-start socket +and retries with a delay capped at 30 seconds, without restarting the WiFi +connection or disturbing BLE, WebConfig, OTA, or a healthy accepted TCP client. +Turning WiFi off or suspending it cancels the listener request; retry service +cannot turn it back on. Use `get wifi.tcp` through BLE, USB, or the browser CLI +to inspect listener state, registration, accepted clients, completed RX/TX +frames, and errors without a PC port probe. `get display.wifi` also shows the +actual `tcp` listener flag independently of the network-services flag. A +listening socket alone cannot detect LAN firewall or guest-network isolation. + On WiFi TCP port 5000, reconnecting from the same IP retains queued outgoing frames. A different IP takes over immediately, but never receives the previous IP's replies: up to four queued frames are parked separately for 30 seconds diff --git a/examples/companion_radio/CompanionWiFi.h b/examples/companion_radio/CompanionWiFi.h index 7c52dbc0..cbea5cc0 100644 --- a/examples/companion_radio/CompanionWiFi.h +++ b/examples/companion_radio/CompanionWiFi.h @@ -66,6 +66,9 @@ enum class CompanionWiFiDisplayState : uint8_t { // distinguish network state from a stale or incorrect LCD frame. void noteCompanionWiFiDisplayState(CompanionWiFiDisplayState state); void formatCompanionWiFiDisplayStatus(char* reply, size_t reply_size); +// Inspect the actual Companion TCP listener and completed protocol traffic; +// reading this diagnostic never opens a client or restarts a live session. +void formatCompanionWiFiTcpStatus(char* reply, size_t reply_size); #ifdef WITH_WEBCONFIG // Use Companion ownership state when formatting an otherwise ambiguous idle or // disconnected station; WebConfig being stopped does not mean WiFi is off. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c426a78e..7bf1ae2d 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -4308,6 +4308,10 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, snprintf(reply, reply_size, "> %u", (unsigned)_prefs.wifi_enabled); return true; } + if (strcmp(command, "get wifi.tcp") == 0) { + formatCompanionWiFiTcpStatus(reply, reply_size); + return true; + } if (strncmp(command, "set wifi.enabled ", 17) == 0) { int32_t enabled; if (!mesh::cli::parseIntegerStrict(command + 17, enabled) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index f1ceb761..a9c7624d 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -115,6 +115,7 @@ MultiSerialInterface interface_manager; #include #include SerialWifiInterface wifi_interface; + static bool companion_wifi_interface_registered = false; #ifndef WIFI_PWD #define WIFI_PWD "" #endif @@ -1903,14 +1904,35 @@ void halt() { esp_wifi_sta_get_ap_info(&access_point) == ESP_OK; snprintf(reply, reply_size, "> state=%s, frames=%lu, display=%u/%02X, requested=%u, services=%u, " - "wl=%d, idf=%u, IP=%s", + "wl=%d, idf=%u, IP=%s, tcp=%u", names[state], (unsigned long)companion_wifi_display_frames, companion_display_available ? 1U : 0U, (unsigned int)companion_display_begin_status, companion_wifi_requested ? 1U : 0U, companion_wifi_active ? 1U : 0U, (int)WiFi.status(), idf_associated ? 1U : 0U, - WiFi.localIP().toString().c_str()); + WiFi.localIP().toString().c_str(), + wifi_interface.getStatus().listening ? 1U : 0U); + } + + void formatCompanionWiFiTcpStatus(char* reply, size_t reply_size) { + if (reply == nullptr || reply_size == 0) return; + const auto status = wifi_interface.getStatus(); + // Fit even full-width counters in the 160-byte Companion CLI reply. No + // self-probe connection: it would displace the user's single TCP session. + snprintf(reply, reply_size, + "> tcp=%u %s reg=%u ip=%u client=%u fail=%lu err=%d retry=%lus " + "acc=%lu rx=%lu/%02X tx=%lu/%02X drop=%s", + (unsigned int)(status.port ? status.port : TCP_PORT), + SerialWifiInterface::stateName(status.state), + companion_wifi_interface_registered ? 1U : 0U, + status.addressReady ? 1U : 0U, status.clientConnected ? 1U : 0U, + (unsigned long)status.beginFailures, status.lastErrno, + (unsigned long)((status.retryInMs + 999U) / 1000U), + (unsigned long)status.acceptedClients, + (unsigned long)status.validFrames, (unsigned int)status.lastOpcode, + (unsigned long)status.txFrames, (unsigned int)status.lastTxOpcode, + SerialWifiInterface::disconnectReasonName(status.lastDisconnectReason)); } #ifdef WITH_WEBCONFIG @@ -2402,8 +2424,10 @@ void halt() { #endif applyCompanionWiFiPowerSave(); - wifi_interface.begin(TCP_PORT); - wifi_interface.enable(); + if (companion_wifi_interface_registered) { + wifi_interface.begin(TCP_PORT); + wifi_interface.enable(); + } #ifdef ENABLE_OTA ota_console_start(); #endif @@ -2501,8 +2525,10 @@ void halt() { mesh::wifi::beginStation(configured_wifi_ssid, configured_wifi_password); } applyCompanionWiFiPowerSave(); - wifi_interface.begin(TCP_PORT); - wifi_interface.enable(); + if (companion_wifi_interface_registered) { + wifi_interface.begin(TCP_PORT); + wifi_interface.enable(); + } #ifdef ENABLE_OTA ota_console_start(); #endif @@ -3612,12 +3638,17 @@ void setup() { "Failed to restore ESP-NOW/WiFi protocol coexistence"); } #endif - WIFI_DEBUG_PRINTLN("connected! IP %s (companion app on :%d)", + WIFI_DEBUG_PRINTLN("connected! IP %s (companion TCP port :%d requested; get wifi.tcp for listener status)", WiFi.localIP().toString().c_str(), TCP_PORT); } }); - interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface); + companion_wifi_interface_registered = + interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface); + if (!companion_wifi_interface_registered) { + wifi_interface.disable(); + WIFI_DEBUG_PRINTLN("Companion WiFi interface registration failed"); + } #if !defined(COMPANION_EXCLUSIVE_WIFI_BLE) companion_wifi_requested = the_mesh.getNodePrefs()->wifi_enabled != 0; #endif @@ -3672,8 +3703,9 @@ void setup() { WiFi.setTimeout(WIFI_RETRY_TIMEOUT); WiFi.beginNoBlock(pico_wifi_ssid, pico_wifi_pwd); last_wifi_reconnect_attempt = millis(); - wifi_interface.begin(TCP_PORT); - interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface); + if (interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface)) { + wifi_interface.begin(TCP_PORT); + } } #endif #if defined(ENABLE_USB_INTERFACE) diff --git a/src/helpers/wifi/SerialWifiInterface.cpp b/src/helpers/wifi/SerialWifiInterface.cpp index a6a48ae7..f827f78f 100644 --- a/src/helpers/wifi/SerialWifiInterface.cpp +++ b/src/helpers/wifi/SerialWifiInterface.cpp @@ -1,6 +1,7 @@ #include "SerialWifiInterface.h" #include "../CompanionFrameQueue.h" #include +#include static constexpr uint32_t WIFI_FRAME_TIMEOUT_MS = 5000; @@ -23,18 +24,28 @@ void SerialWifiInterface::expireHeldQueue(uint32_t now) { void SerialWifiInterface::loop() { const uint32_t now = (uint32_t)millis(); expireHeldQueue(now); - if (deviceConnected && (!client.connected() - || (receive_pending && (uint32_t)(now - receive_started) >= WIFI_FRAME_TIMEOUT_MS))) { - disconnectClient(true); + // Use enable intent, not isEnabled(): an unavailable listener must recover + // even though MultiSerialInterface correctly declines to dispatch into it. + if (_isEnabled && listener_requested) serviceListener(now); + if (deviceConnected && !client.connected()) { + disconnectClient(true, DisconnectReason::PeerClosed); + } else if (deviceConnected && receive_pending + && (uint32_t)(now - receive_started) >= WIFI_FRAME_TIMEOUT_MS) { + disconnectClient(true, DisconnectReason::FrameTimeout); } } -void SerialWifiInterface::disconnectClient(bool cancel_session) { +void SerialWifiInterface::disconnectClient(bool cancel_session, + DisconnectReason reason) { const bool was_connected = deviceConnected; deviceConnected = false; if (client) client.stop(); resetReceivedFrameHeader(); send_offset = 0; // A reconnect must replay the whole frame, never its suffix. + if (was_connected) { + ++disconnect_count; + last_disconnect_reason = reason; + } if (cancel_session && was_connected && session_changed != nullptr) { session_changed(session_context); } @@ -74,18 +85,99 @@ void SerialWifiInterface::selectClient(const IPAddress& ip, uint32_t now) { queue_has_ip = true; } -void SerialWifiInterface::begin(int port) { - // wifi setup is handled outside of this class, only starts the server - server.begin(port); +bool SerialWifiInterface::addressReady() { + if (WiFi.status() == WL_CONNECTED + && static_cast(WiFi.localIP()) != 0) return true; +#if defined(ESP32) + // A setup/OTA AP is usable even when STA has no association. Do not start a + // listener for an AP's default address when the AP interface is disabled. + if ((WiFi.getMode() & WIFI_AP) + && static_cast(WiFi.softAPIP()) != 0) return true; +#endif + return false; +} + +bool SerialWifiInterface::serviceListener(uint32_t now) { + if (!listener_requested) return false; + if (static_cast(server)) { + listener_was_healthy = true; + return true; // Pico W begin() would otherwise replace a healthy listener. + } + if (listener_was_healthy) { + // Release a stale listening descriptor, not the separately accepted + // client or its response queue. Link changes alone do not take this path. + server.end(); + listener_was_healthy = false; + listener_attempted = false; + listener_retry_ms = 0; + consecutive_start_failures = 0; + } + if (!addressReady()) return false; + if (listener_attempted + && (uint32_t)(now - listener_last_attempt) < listener_retry_ms) return false; + + listener_last_attempt = now; + listener_attempted = true; + ++listener_begin_attempts; + // The ESP32 SDK silently returns from begin() after socket/bind/listen + // errors. Capture errno before end() can replace it during descriptor cleanup. + errno = 0; + server.begin(listener_port); + if (!static_cast(server)) { + listener_last_errno = errno; + listener_last_error = StartError::StartFailed; + ++listener_begin_failures; + server.end(); // Also releases a socket left behind by a failed bind/listen. + if (consecutive_start_failures < 6) ++consecutive_start_failures; + listener_retry_ms = consecutive_start_failures < 6 + ? (1000U << (consecutive_start_failures - 1)) : 30000U; + return false; + } + listener_was_healthy = true; + consecutive_start_failures = 0; + listener_retry_ms = 0; + listener_last_error = StartError::None; + listener_last_errno = 0; + return true; +} + +bool SerialWifiInterface::begin(int port) { + if (port <= 0 || port > 65535) { + listener_last_error = StartError::InvalidPort; + listener_last_errno = 0; + return false; + } + const bool new_request = !listener_requested || listener_port != port; + if (listener_port != 0 && listener_port != port) { + server.end(); // Explicit listener reconfiguration leaves an accepted client intact. + listener_was_healthy = false; + } + listener_port = static_cast(port); + listener_requested = true; + if (new_request) { + listener_attempted = false; + listener_retry_ms = 0; + consecutive_start_failures = 0; + } + // Existing callers begin before enable. This explicit request may start + // immediately, but background retries never implicitly re-enable it. + return serviceListener((uint32_t)millis()); +} + +void SerialWifiInterface::stopListener(DisconnectReason reason) { + _isEnabled = false; + listener_requested = false; + server.end(); + listener_was_healthy = false; + listener_attempted = false; + listener_retry_ms = 0; + consecutive_start_failures = 0; + disconnectClient(true, reason); + clearBuffers(); } void SerialWifiInterface::end() { - disable(); - if (client) client.stop(); - server.end(); - deviceConnected = false; - resetReceivedFrameHeader(); - clearBuffers(); + stopListener(DisconnectReason::Ended); } // ---------- public methods @@ -98,9 +190,61 @@ void SerialWifiInterface::enable() { } void SerialWifiInterface::disable() { - _isEnabled = false; - disconnectClient(true); - clearBuffers(); + stopListener(DisconnectReason::Disabled); +} + +SerialWifiInterface::Status SerialWifiInterface::getStatus() { + const bool ready = addressReady(); + const bool listening = static_cast(server); + State state = State::Off; + if (_isEnabled && listener_requested) { + state = !ready ? State::WaitingIp + : listening ? State::Listening : State::Retrying; + } + const uint32_t elapsed = (uint32_t)((uint32_t)millis() - listener_last_attempt); + const uint32_t retry = listener_requested && listener_attempted + && !listening && elapsed < listener_retry_ms + ? listener_retry_ms - elapsed : 0; + return {state, _isEnabled, listener_requested, ready, listening, + deviceConnected && client.connected(), listener_port, + listener_begin_attempts, listener_begin_failures, retry, + listener_last_error, listener_last_errno, accepted_clients, + valid_frames, last_opcode, tx_frames, last_tx_opcode, + disconnect_count, last_disconnect_reason}; +} + +const char* SerialWifiInterface::stateName(State state) { + switch (state) { + case State::Off: return "off"; + case State::WaitingIp: return "waiting-ip"; + case State::Retrying: return "retrying"; + case State::Listening: return "listening"; + } + return "unknown"; +} + +const char* SerialWifiInterface::startErrorName(StartError error) { + switch (error) { + case StartError::None: return "none"; + case StartError::InvalidPort: return "invalid-port"; + case StartError::StartFailed: return "start-failed"; + } + return "unknown"; +} + +const char* SerialWifiInterface::disconnectReasonName(DisconnectReason reason) { + switch (reason) { + case DisconnectReason::None: return "none"; + case DisconnectReason::PeerClosed: return "peer-closed"; + case DisconnectReason::FrameTimeout: return "frame-timeout"; + case DisconnectReason::InvalidHeader: return "invalid-header"; + case DisconnectReason::ShortHeader: return "short-header"; + case DisconnectReason::ShortPayload: return "short-payload"; + case DisconnectReason::Replaced: return "replaced"; + case DisconnectReason::Disabled: return "disabled"; + case DisconnectReason::Ended: return "ended"; + } + return "unknown"; } size_t SerialWifiInterface::writeFrame(const uint8_t src[], size_t len) { @@ -146,14 +290,17 @@ void SerialWifiInterface::resetReceivedFrameHeader() { size_t SerialWifiInterface::checkRecvFrame(uint8_t dest[]) { loop(); - if (!_isEnabled) return 0; + if (!isEnabled()) return 0; // check if new client connected - auto newClient = server.available(); + // Continue an accepted client's RX/TX during listener repair, but never + // call the SDK accept path while its listening descriptor is unavailable. + auto newClient = static_cast(server) ? server.available() : WiFiClient(); if (newClient) { const IPAddress new_ip = newClient.remoteIP(); // disconnect existing client - disconnectClient(!queue_has_ip || queue_ip != new_ip); + disconnectClient(!queue_has_ip || queue_ip != new_ip, + DisconnectReason::Replaced); // Partition queues and cancel the displaced session before exposing the // new connection to response producers or consuming its first command. @@ -161,6 +308,7 @@ size_t SerialWifiInterface::checkRecvFrame(uint8_t dest[]) { // switch active connection to new client client = newClient; + ++accepted_clients; // forget received frame header resetReceivedFrameHeader(); @@ -174,7 +322,7 @@ size_t SerialWifiInterface::checkRecvFrame(uint8_t dest[]) { } } else { if (deviceConnected) { - disconnectClient(true); + disconnectClient(true, DisconnectReason::PeerClosed); WIFI_DEBUG_PRINTLN("Disconnected"); } } @@ -196,6 +344,8 @@ size_t SerialWifiInterface::checkRecvFrame(uint8_t dest[]) { send_offset += written < remaining ? written : remaining; if (send_offset < total) return 0; send_offset = 0; + ++tx_frames; + last_tx_opcode = send_queue[0].buf[0]; send_queue_len--; for (int i = 0; i < send_queue_len; i++) { // delete top item from queue send_queue[i] = send_queue[i + 1]; @@ -211,7 +361,7 @@ size_t SerialWifiInterface::checkRecvFrame(uint8_t dest[]) { if (client.available() < 3) return 0; uint8_t header[3]; if (client.readBytes(header, sizeof(header)) != sizeof(header)) { - disconnectClient(true); + disconnectClient(true, DisconnectReason::ShortHeader); return 0; } received_frame_header.type = header[0]; @@ -220,17 +370,19 @@ size_t SerialWifiInterface::checkRecvFrame(uint8_t dest[]) { // avoids unbounded discard loops and interpreting its tail as commands. if (received_frame_header.type != '<' || received_frame_header.length == 0 || received_frame_header.length > MAX_FRAME_SIZE) { - disconnectClient(true); + disconnectClient(true, DisconnectReason::InvalidHeader); return 0; } } const size_t frame_length = received_frame_header.length; if (client.available() < (int)frame_length) return 0; if (dest == nullptr || client.readBytes(dest, frame_length) != frame_length) { - disconnectClient(true); + disconnectClient(true, DisconnectReason::ShortPayload); return 0; // Never dispatch a truncated command, even after a short read. } resetReceivedFrameHeader(); + ++valid_frames; + last_opcode = dest[0]; return frame_length; } diff --git a/src/helpers/wifi/SerialWifiInterface.h b/src/helpers/wifi/SerialWifiInterface.h index 2f6d299c..9b03b1f7 100644 --- a/src/helpers/wifi/SerialWifiInterface.h +++ b/src/helpers/wifi/SerialWifiInterface.h @@ -5,12 +5,63 @@ #include class SerialWifiInterface : public BaseSerialInterface { +public: + enum class State : uint8_t { Off, WaitingIp, Retrying, Listening }; + enum class StartError : uint8_t { None, InvalidPort, StartFailed }; + enum class DisconnectReason : uint8_t { + None, PeerClosed, FrameTimeout, InvalidHeader, ShortHeader, ShortPayload, + Replaced, Disabled, Ended + }; + struct Status { + State state; + bool enabled; // Logical enable intent, including a listener awaiting retry. + bool requested; + bool addressReady; + bool listening; // Actual SDK listener state, not merely an assigned IP. + bool clientConnected; + uint16_t port; + uint32_t beginAttempts; + uint32_t beginFailures; + uint32_t retryInMs; + StartError lastStartError; + int lastErrno; + uint32_t acceptedClients; + uint32_t validFrames; + uint8_t lastOpcode; + uint32_t txFrames; + uint8_t lastTxOpcode; + uint32_t disconnects; + DisconnectReason lastDisconnectReason; + }; + +private: bool deviceConnected; + // Keep enable intent separate from actual protocol availability. A failed + // cold listener needs loop service; a healthy accepted socket remains usable. bool _isEnabled; unsigned long _last_write; - WiFiServer server; - WiFiClient client; + // Arduino's listener conversion and client health accessor are not const. + mutable WiFiServer server; + mutable WiFiClient client; + bool listener_requested = false; + bool listener_was_healthy = false; + bool listener_attempted = false; + uint16_t listener_port = 0; + uint8_t consecutive_start_failures = 0; + uint32_t listener_last_attempt = 0; + uint32_t listener_retry_ms = 0; + uint32_t listener_begin_attempts = 0; + uint32_t listener_begin_failures = 0; + StartError listener_last_error = StartError::None; + int listener_last_errno = 0; + uint32_t accepted_clients = 0; + uint32_t valid_frames = 0; + uint8_t last_opcode = 0; + uint32_t tx_frames = 0; + uint8_t last_tx_opcode = 0; + uint32_t disconnect_count = 0; + DisconnectReason last_disconnect_reason = DisconnectReason::None; struct FrameHeader { uint8_t type; @@ -44,7 +95,10 @@ class SerialWifiInterface : public BaseSerialInterface { void clearBuffers(); void expireHeldQueue(uint32_t now); void selectClient(const IPAddress& ip, uint32_t now); - void disconnectClient(bool cancel_session); + void disconnectClient(bool cancel_session, DisconnectReason reason); + bool addressReady(); + bool serviceListener(uint32_t now); + void stopListener(DisconnectReason reason); protected: @@ -58,8 +112,14 @@ public: received_frame_header.length = 0; } - void begin(int port); + // Explicitly request a listener; does not enable the protocol interface. + // Failure or an unready network is retried by loop() only while enabled. + bool begin(int port); void end(); + Status getStatus(); + static const char* stateName(State state); + static const char* startErrorName(StartError error); + static const char* disconnectReasonName(DisconnectReason reason); // Called synchronously while disconnected, on teardown or before a different // IP can send commands/receive replies. Same-IP live replacement preserves // the existing owner; actual disconnects cancel its pending operations. @@ -71,7 +131,12 @@ public: // BaseSerialInterface methods void enable() override; void disable() override; - bool isEnabled() const override { return _isEnabled; } + bool isEnabled() const override { + // Losing the accept listener must not cancel a stream whose separately + // accepted socket is still healthy. Failed cold starts remain unavailable. + return _isEnabled && listener_requested + && (static_cast(server) || (deviceConnected && client.connected())); + } void loop() override; bool isConnected() const override; diff --git a/test/fixtures/companion_ap_ownership.cpp b/test/fixtures/companion_ap_ownership.cpp index 02692881..20d757ae 100644 --- a/test/fixtures/companion_ap_ownership.cpp +++ b/test/fixtures/companion_ap_ownership.cpp @@ -59,6 +59,7 @@ Portal& wifiSetupPortal(){return portal;} void saveCompanionWiFi(){} char configured_wifi_ssid[8]="mesh",configured_wifi_password[8]="secret"; bool companion_wifi_has_credentials=true,companion_wifi_requested=true,companion_wifi_active=true; +bool companion_wifi_interface_registered=true; bool companion_wifi_services_stopped=true; bool companion_wifi_background_stopped=false,companion_wifi_espnow_restore_pending=true; bool companion_wifi_webconfig_start_pending=false; @@ -71,7 +72,11 @@ void loadCompanionWiFiCredentials(){++credential_loads;} void resetCompanionWiFiRecoveryState(){wifi_reconnect_tracker=WiFiReconnectPolicy::Tracker();} void applyCompanionWiFiPowerSave(){++power_writes;} constexpr int TCP_PORT=1234; -struct Interface{void begin(int){++socket_starts;}void enable(){}}wifi_interface; +struct Interface{ + bool begin(int){++socket_starts;return true;} + void enable(){} + bool isListening()const{return socket_starts>0;} +}wifi_interface; void ota_console_start(){++console_starts;} @POWER_GUARDS@ #ifdef WITH_WEBCONFIG @@ -136,6 +141,12 @@ int main(int argc,char** argv){ assert(companion_wifi_active&&!companion_wifi_services_stopped&&board.inhibit_sleep); assert(mode_writes==1&&joins==1&&socket_starts==1&&console_starts==1&&power_writes==1); startCompanionWiFi();assert(mode_writes==1&&socket_starts==1); + }else if(scenario=="registration"){ + companion_wifi_interface_registered=false; + companion_wifi_active=false;board.ota=false;sdk_mode=WIFI_STA; + startCompanionWiFi(); + assert(companion_wifi_active&&board.inhibit_sleep&&!companion_wifi_services_stopped); + assert(socket_starts==0&&console_starts==1&&joins==1); }else if(scenario=="suspended"){ auto& power=mesh::wifi::networkPower(); power.setManaged(true);power.setSuspended(true); diff --git a/test/fixtures/companion_wifi_listener/diagnostics.cpp b/test/fixtures/companion_wifi_listener/diagnostics.cpp new file mode 100644 index 00000000..123a937f --- /dev/null +++ b/test/fixtures/companion_wifi_listener/diagnostics.cpp @@ -0,0 +1,97 @@ +#include +#include +#include +#include +#include + +uint32_t mock_millis = 100; +std::deque WiFiServer::incoming; +constexpr int TCP_PORT = 5000; +constexpr int ESP_OK = 0; +struct wifi_ap_record_t {}; +int esp_wifi_sta_get_ap_info(wifi_ap_record_t*) { return ESP_OK; } + +struct DiagnosticInterface { + SerialWifiInterface::Status snapshot{}; + int reads = 0; + SerialWifiInterface::Status getStatus() { ++reads; return snapshot; } +} wifi_interface; +bool companion_wifi_interface_registered = true; +uint8_t companion_wifi_display_state = 3; +uint32_t companion_wifi_display_frames = 99; +bool companion_display_available = true; +uint8_t companion_display_begin_status = 0x3c; +bool companion_wifi_requested = true; +bool companion_wifi_active = true; + +@METHODS@ + +int main() { + using Transport = SerialWifiInterface; + auto& status = wifi_interface.snapshot; + status.state = Transport::State::Retrying; + status.addressReady = true; + status.port = 5000; + status.beginFailures = 1; + status.lastErrno = EADDRINUSE; + status.retryInMs = 1; + status.lastDisconnectReason = Transport::DisconnectReason::InvalidHeader; + char reply[160] = {}; + formatCompanionWiFiTcpStatus(reply, sizeof(reply)); + assert(strstr(reply, "tcp=5000 retrying reg=1 ip=1 client=0 fail=1")); + assert(strstr(reply, "retry=1s") && strstr(reply, "drop=invalid-header")); + formatCompanionWiFiDisplayStatus(reply, sizeof(reply)); + assert(strstr(reply, "requested=1, services=1") && strstr(reply, "tcp=0")); + status.listening = true; + status.clientConnected = true; + status.state = Transport::State::Listening; + formatCompanionWiFiDisplayStatus(reply, sizeof(reply)); + assert(strstr(reply, "tcp=1")); + formatCompanionWiFiTcpStatus(reply, sizeof(reply)); + assert(strstr(reply, "listening") && strstr(reply, "client=1")); + + // Full-width counters and errno must leave the final drop reason intact in + // the actual 160-byte Companion command reply, not silently truncate it. + status.state = Transport::State::WaitingIp; + status.port = 65535; + status.beginFailures = UINT32_MAX; + status.acceptedClients = UINT32_MAX; + status.validFrames = UINT32_MAX; + status.txFrames = UINT32_MAX; + status.lastOpcode = status.lastTxOpcode = 0xff; + status.lastErrno = INT32_MIN; + status.retryInMs = 30000; + struct Guarded { char text[160]; char guard[8]; } guarded; + memset(&guarded, 0x5a, sizeof(guarded)); + formatCompanionWiFiTcpStatus(guarded.text, sizeof(guarded.text)); + assert(strlen(guarded.text) < sizeof(guarded.text)); + assert(strstr(guarded.text, "fail=4294967295 err=-2147483648 retry=30s")); + assert(strstr(guarded.text, "acc=4294967295 rx=4294967295/FF tx=4294967295/FF")); + assert(strstr(guarded.text, "drop=invalid-header")); + for (char byte : guarded.guard) assert(byte == 0x5a); + // Correlated binary CLI replies reserve three of the 160 bytes for a tag. + formatCompanionWiFiTcpStatus(guarded.text, 157); + assert(strlen(guarded.text) < 157); + assert(strstr(guarded.text, "drop=invalid-header")); + for (char byte : guarded.guard) assert(byte == 0x5a); + + // Small/null buffers are normal API contracts, not a reason to touch the + // transport. Status polling must not consume a command or open a client. + const int reads = wifi_interface.reads; + formatCompanionWiFiTcpStatus(nullptr, 160); + formatCompanionWiFiTcpStatus(reply, 0); + formatCompanionWiFiDisplayStatus(nullptr, 160); + formatCompanionWiFiDisplayStatus(reply, 0); + assert(wifi_interface.reads == reads); + memset(&guarded, 0x5a, sizeof(guarded)); + formatCompanionWiFiTcpStatus(guarded.text, 1); + assert(guarded.text[0] == 0 && guarded.text[1] == 0x5a); + formatCompanionWiFiTcpStatus(guarded.text, 8); + assert(guarded.text[7] == 0 && guarded.text[8] == 0x5a); + companion_wifi_interface_registered = false; + status.port = 0; + formatCompanionWiFiTcpStatus(reply, sizeof(reply)); + assert(strstr(reply, "tcp=5000") && strstr(reply, "reg=0")); + assert(WiFiServer::begin_calls == 0 && WiFiServer::incoming.empty()); + std::puts("PASS: production Wi-Fi diagnostic formatters"); +} diff --git a/test/fixtures/companion_wifi_listener/test.cpp b/test/fixtures/companion_wifi_listener/test.cpp new file mode 100644 index 00000000..0e66b28a --- /dev/null +++ b/test/fixtures/companion_wifi_listener/test.cpp @@ -0,0 +1,348 @@ +#include +#include +#include +#include +#include +#include +#include + +uint32_t mock_millis = 100; +std::deque WiFiServer::incoming; + +using Socket = std::shared_ptr; +using Bytes = std::vector; +static const IPAddress A(192, 168, 1, 20); +static const IPAddress B(192, 168, 1, 30); + +struct Fixture { + SerialWifiInterface transport; + uint8_t input[MAX_FRAME_SIZE] = {}; + int cancellations = 0; + Fixture() { + transport.setSessionChangedCallback([](void* context) { + ++static_cast(context)->cancellations; + }, this); + } + ~Fixture() { transport.end(); } + bool start() { const bool result = transport.begin(5000); transport.enable(); return result; } + size_t tick() { return transport.checkRecvFrame(input); } + Socket connect(IPAddress ip, const Bytes& data = {}) { + auto socket = std::make_shared(ip); + socket->received.insert(socket->received.end(), data.begin(), data.end()); + WiFiServer::incoming.push_back(WiFiClient(socket)); + tick(); + return socket; + } +}; + +int main(int argc, char** argv) { + assert(argc == 2); + const int scenario = std::atoi(argv[1]); + WiFiServer::resetDiagnostics(); + mock_millis = 100; + Fixture f; + if (scenario == 0) { + WiFi.link_status = WL_DISCONNECTED; + WiFi.station_ip = IPAddress(); + assert(!f.start()); + for (int i = 0; i < 100; ++i) { mock_millis += 1000; f.tick(); } + auto status = f.transport.getStatus(); + assert(status.enabled && status.requested && !status.addressReady); + assert(!status.listening && status.beginAttempts == 0 && status.beginFailures == 0); + assert(WiFiServer::begin_calls == 0); + WiFi.link_status = WL_CONNECTED; + WiFi.station_ip = IPAddress(192, 168, 1, 10); + f.tick(); + assert(f.transport.getStatus().listening && WiFiServer::begin_calls == 1); + } else if (scenario == 1) { + assert(f.start()); + const int ends = WiFiServer::end_calls; + for (int i = 0; i < 100; ++i) { mock_millis += 1000; f.tick(); } + assert(f.transport.begin(5000)); + assert(WiFiServer::begin_calls == 1 && WiFiServer::end_calls == ends); + assert(WiFiServer::open_sockets == 1); + } else if (scenario >= 2 && scenario <= 4) { + WiFiServer::begin_fault = scenario == 2 ? WiFiServer::BeginFault::Socket + : scenario == 3 ? WiFiServer::BeginFault::Bind : WiFiServer::BeginFault::Listen; + assert(!f.start()); + auto failed = f.transport.getStatus(); + assert(!failed.listening && failed.beginAttempts == 1 && failed.beginFailures == 1); + assert(!f.transport.isEnabled()); + assert(failed.retryInMs == 1000 && static_cast(failed.lastStartError) != 0); + assert(failed.lastErrno == (scenario == 2 ? ENOMEM : scenario == 3 ? EADDRINUSE : EIO)); + assert(WiFiServer::open_sockets == 0); + for (int i = 0; i < 50; ++i) f.tick(); + assert(WiFiServer::begin_calls == 1 && WiFiServer::open_sockets == 0); + mock_millis += 999; f.tick(); + assert(WiFiServer::begin_calls == 1); + WiFiServer::begin_fault = WiFiServer::BeginFault::None; + ++mock_millis; f.tick(); + auto healed = f.transport.getStatus(); + assert(healed.listening && healed.beginAttempts == 2 && healed.beginFailures == 1); + assert(healed.retryInMs == 0 && WiFiServer::open_sockets == 1); + assert(healed.lastStartError == SerialWifiInterface::StartError::None && healed.lastErrno == 0); + assert(f.transport.isEnabled()); + } else if (scenario == 5) { + WiFiServer::begin_fault = WiFiServer::BeginFault::Listen; + assert(!f.start()); + for (uint32_t delay : {1000U, 2000U, 4000U, 8000U, 16000U, 30000U, 30000U}) { + auto before = f.transport.getStatus(); + assert(before.retryInMs == delay && WiFiServer::open_sockets == 0); + mock_millis += delay - 1; + for (int i = 0; i < 20; ++i) f.tick(); + assert(f.transport.getStatus().beginAttempts == before.beginAttempts); + ++mock_millis; f.tick(); + assert(f.transport.getStatus().beginAttempts == before.beginAttempts + 1); + assert(WiFiServer::open_sockets == 0); + } + WiFiServer::begin_fault = WiFiServer::BeginFault::None; + mock_millis += 30000; f.tick(); + assert(f.transport.getStatus().listening); + } else if (scenario == 6) { + mock_millis = UINT32_MAX - 500; + WiFiServer::begin_fault = WiFiServer::BeginFault::Bind; + assert(!f.start()); + mock_millis += 999; f.tick(); + assert(WiFiServer::begin_calls == 1 && f.transport.getStatus().retryInMs == 1); + WiFiServer::begin_fault = WiFiServer::BeginFault::None; + ++mock_millis; f.tick(); + assert(f.transport.getStatus().listening && WiFiServer::begin_calls == 2); + } else if (scenario == 7 || scenario == 8) { + WiFiServer::begin_fault = WiFiServer::BeginFault::Bind; + assert(!f.start()); + if (scenario == 7) f.transport.disable(); + else f.transport.end(); // The main loop uses end() for network suspension. + assert(!f.transport.getStatus().requested && !f.transport.getStatus().listening); + WiFiServer::begin_fault = WiFiServer::BeginFault::None; + mock_millis += 30000; + f.transport.enable(); + for (int i = 0; i < 20; ++i) f.tick(); + assert(WiFiServer::begin_calls == 1 && WiFiServer::open_sockets == 0); + assert(!f.transport.getStatus().requested); + assert(f.transport.begin(5000)); + assert(f.transport.getStatus().listening); + } else if (scenario == 9) { + assert(f.start()); + auto socket = f.connect(A); + const Bytes reply{0x01, 0x23, 0x45}; + assert(f.transport.writeFrame(reply.data(), reply.size()) == reply.size()); + WiFiServer::listener_lost = true; + f.transport.loop(); + assert(f.transport.getStatus().listening); + assert(f.transport.isConnected() && socket->connected && f.cancellations == 0); + socket->write_limit = std::numeric_limits::max(); + for (int i = 0; i < 5; ++i) f.tick(); + assert((socket->sent == Bytes{'>', 3, 0, 0x01, 0x23, 0x45})); + } else if (scenario == 10) { + assert(f.start()); + WiFi.link_status = WL_DISCONNECTED; + WiFi.station_ip = IPAddress(); + f.tick(); + const int begins = WiFiServer::begin_calls; + assert(f.transport.getStatus().requested && !f.transport.getStatus().addressReady); + for (int i = 0; i < 20; ++i) { mock_millis += 1000; f.tick(); } + assert(WiFiServer::begin_calls == begins); + WiFi.link_status = WL_CONNECTED; + WiFi.station_ip = IPAddress(192, 168, 1, 11); + f.tick(); + assert(f.transport.getStatus().listening); + auto socket = f.connect(A, Bytes{'<', 1, 0, 0x16}); + assert(socket->connected && f.input[0] == 0x16); + } else if (scenario == 11) { + WiFi.station_ip = IPAddress(); + assert(!f.start()); + f.tick(); + assert(!f.transport.getStatus().addressReady && WiFiServer::begin_calls == 0); + WiFi.station_ip = IPAddress(192, 168, 1, 11); + f.tick(); + assert(f.transport.getStatus().listening); + } else if (scenario == 12 || scenario == 13) { + WiFi.link_status = WL_DISCONNECTED; + WiFi.station_ip = IPAddress(); + WiFi.mode_value = WIFI_AP; + if (scenario == 12) WiFi.access_point_ip = IPAddress(192, 168, 4, 1); + assert(f.start() == (scenario == 12)); + f.tick(); + assert(f.transport.getStatus().addressReady == (scenario == 12)); + assert(f.transport.getStatus().listening == (scenario == 12)); + assert(WiFiServer::begin_calls == (scenario == 12 ? 1 : 0)); + } else if (scenario == 14) { + assert(f.start()); + auto old = f.connect(A, Bytes{'<', 1, 0, 0x11}); + auto next = f.connect(B, Bytes{'<', 1, 0, 0x12}); + assert(!old->connected && next->connected && f.cancellations == 1); + auto status = f.transport.getStatus(); + assert(status.acceptedClients == 2 && status.validFrames == 2); + assert(status.lastOpcode == 0x12 && status.clientConnected && status.listening); + assert(status.disconnects == 1); + assert(status.lastDisconnectReason == SerialWifiInterface::DisconnectReason::Replaced); + } else if (scenario == 15) { + assert(f.start()); + auto socket = f.connect(A); + auto accepted = f.transport.getStatus(); + assert(accepted.acceptedClients == 1 && accepted.validFrames == 0); + socket->received = {'<', 1, 0, 0x16}; + assert(f.tick() == 1); + auto parsed = f.transport.getStatus(); + assert(parsed.validFrames == 1 && parsed.lastOpcode == 0x16); + socket->received = {'<', 2, 0, 0x42, 0x17}; + assert(f.tick() == 2); + assert(f.transport.getStatus().validFrames == 2); + assert(f.transport.getStatus().lastOpcode == 0x42); + socket->connected = false; f.tick(); f.tick(); + assert(f.transport.getStatus().disconnects == 1); + assert(f.transport.getStatus().lastDisconnectReason == SerialWifiInterface::DisconnectReason::PeerClosed); + assert(f.cancellations == 1 && !f.transport.getStatus().clientConnected); + } else if (scenario == 16) { + assert(f.start()); + auto socket = f.connect(A, Bytes{'?', 1, 0, '<', 1, 0, 0x16}); + auto status = f.transport.getStatus(); + assert(!socket->connected && !status.clientConnected); + assert(status.acceptedClients == 1 && status.validFrames == 0 && status.disconnects == 1); + assert(status.lastDisconnectReason == SerialWifiInterface::DisconnectReason::InvalidHeader); + assert(socket->read_count == 3 && status.listening); + for (int i = 0; i < 20; ++i) assert(f.tick() == 0); + assert(f.transport.getStatus().disconnects == 1); + f.connect(A, Bytes{'<', 1, 0, 0x16}); + assert(f.transport.getStatus().validFrames == 1); + } else if (scenario == 17) { + assert(f.start()); + auto socket = f.connect(A, Bytes{'<', 2, 0, 0x16}); + mock_millis += 4999; f.tick(); + assert(socket->connected && f.transport.getStatus().disconnects == 0); + ++mock_millis; f.tick(); + auto status = f.transport.getStatus(); + assert(!socket->connected && status.disconnects == 1 && status.validFrames == 0); + assert(status.lastDisconnectReason == SerialWifiInterface::DisconnectReason::FrameTimeout); + } else if (scenario == 18) { + assert(f.start()); + auto socket = f.connect(A); + socket->received = {'<', 1, 0, 0x16}; + const int begins = WiFiServer::begin_calls, ends = WiFiServer::end_calls; + for (int i = 0; i < 100; ++i) { + auto status = f.transport.getStatus(); + assert(status.listening && status.validFrames == 0); + } + assert(socket->read_count == 0 && WiFiServer::begin_calls == begins && WiFiServer::end_calls == ends); + assert(f.tick() == 1 && f.transport.getStatus().validFrames == 1); + } else if (scenario == 19) { + WiFiServer::begin_fault = WiFiServer::BeginFault::Listen; + assert(!f.transport.begin(5000)); + assert(!f.transport.getStatus().enabled); + WiFiServer::begin_fault = WiFiServer::BeginFault::None; + mock_millis += 30000; + for (int i = 0; i < 20; ++i) f.tick(); + assert(WiFiServer::begin_calls == 1 && WiFiServer::open_sockets == 0); + f.transport.enable(); f.tick(); + assert(f.transport.getStatus().listening && WiFiServer::begin_calls == 2); + } else if (scenario == 20) { + assert(f.start()); + auto socket = f.connect(A); + const Bytes reply{0x05, 0x23, 0x45}; + assert(f.transport.writeFrame(reply.data(), reply.size()) == reply.size()); + assert(f.transport.getStatus().txFrames == 0); + f.tick(); // A zero-byte write is neither a sent packet nor a failure. + assert(f.transport.getStatus().txFrames == 0 && socket->sent.empty()); + socket->write_limit = 2; + f.tick(); f.tick(); + assert(socket->sent.size() == 4 && f.transport.getStatus().txFrames == 0); + f.tick(); + auto complete = f.transport.getStatus(); + assert(complete.txFrames == 1 && complete.lastTxOpcode == 0x05); + for (int i = 0; i < 10; ++i) f.tick(); + assert(f.transport.getStatus().txFrames == 1); + } else if (scenario == 21) { + assert(!f.transport.begin(0)); + f.transport.enable(); + for (int i = 0; i < 20; ++i) { mock_millis += 1000; f.tick(); } + auto status = f.transport.getStatus(); + assert(!status.requested && !status.listening && WiFiServer::begin_calls == 0); + assert(status.beginAttempts == 0 && status.beginFailures == 0); + assert(status.lastStartError == SerialWifiInterface::StartError::InvalidPort); + assert(!f.transport.begin(65536)); + assert(f.transport.begin(5000)); + assert(f.transport.getStatus().listening); + } else if (scenario == 22 || scenario == 23) { + assert(f.start()); + auto socket = f.connect(A, scenario == 23 ? Bytes{'<', 2, 0} : Bytes{}); + socket->read_limit = 1; + const Bytes remaining = scenario == 23 ? Bytes{0x16, 0x17} : Bytes{'<', 2, 0, 0x16, 0x17}; + socket->received.assign(remaining.begin(), remaining.end()); + assert(f.tick() == 0); + auto status = f.transport.getStatus(); + assert(!socket->connected && status.validFrames == 0 && status.disconnects == 1); + const auto expected = scenario == 22 ? SerialWifiInterface::DisconnectReason::ShortHeader + : SerialWifiInterface::DisconnectReason::ShortPayload; + assert(status.lastDisconnectReason == expected); + assert(status.listening); + } else if (scenario == 24) { + assert(f.start()); + auto socket = f.connect(A, Bytes{'<', 1, 0, 0x16}); + const int begins = WiFiServer::begin_calls, ends = WiFiServer::end_calls; + WiFi.station_ip = IPAddress(192, 168, 1, 99); + f.tick(); + assert(f.transport.getStatus().listening && socket->connected && f.cancellations == 0); + assert(WiFiServer::begin_calls == begins && WiFiServer::end_calls == ends); + } else if (scenario == 25) { + WiFiServer::begin_fault = WiFiServer::BeginFault::Socket; + assert(!f.transport.begin(5000)); + MultiSerialInterface manager; + assert(manager.addInterface(InterfaceType::WiFi, &f.transport)); + manager.enable(); + assert(!f.transport.isEnabled()); + assert(manager.checkRecvFrame(f.input) == 0); + mock_millis += 1000; + WiFiServer::begin_fault = WiFiServer::BeginFault::None; + manager.loop(); // Must service failed transports despite unavailable dispatch. + assert(f.transport.isEnabled() && f.transport.getStatus().listening); + auto socket = std::make_shared(A); + socket->received = {'<', 1, 0, 0x16}; + WiFiServer::incoming.push_back(WiFiClient(socket)); + assert(manager.checkRecvFrame(f.input) == 1 && f.input[0] == 0x16); + } else if (scenario == 26) { + assert(f.start()); + auto socket = f.connect(A); + MultiSerialInterface manager; + assert(manager.addInterface(InterfaceType::WiFi, &f.transport)); + manager.enable(); + socket->received = {'<', 1, 0, 0x04}; + assert(manager.checkRecvFrame(f.input) == 1 && f.input[0] == 0x04); + manager.lockReplyRoute(); + assert(manager.isReplyRouteAvailable(&f.transport)); + const Bytes reply{0x05, 0x23, 0x45}; + assert(manager.writeFrame(reply.data(), reply.size()) == reply.size()); + WiFiServer::listener_lost = true; + WiFiServer::begin_fault = WiFiServer::BeginFault::Bind; + manager.loop(); + assert(!f.transport.getStatus().listening && socket->connected && f.cancellations == 0); + assert(f.transport.hasPendingIO() && WiFiServer::open_sockets == 0); + assert(f.transport.isEnabled() && manager.isConnected()); + assert(manager.isReplyRouteAvailable(&f.transport)); + // A separately accepted socket must continue this locked response stream + // before listener repair succeeds; a listener-only readiness test breaks it. + socket->write_limit = std::numeric_limits::max(); + assert(manager.checkRecvFrame(f.input) == 0); + assert((socket->sent == Bytes{'>', 3, 0, 0x05, 0x23, 0x45})); + assert(f.transport.getStatus().txFrames == 1); + socket->received = {'<', 1, 0, 0x16}; + assert(manager.checkRecvFrame(f.input) == 1 && f.input[0] == 0x16); + assert(f.transport.getStatus().validFrames == 2); + assert(!f.transport.getStatus().listening && WiFiServer::begin_calls == 2); + mock_millis += 1000; + WiFiServer::begin_fault = WiFiServer::BeginFault::None; + manager.loop(); + assert(f.transport.getStatus().listening && socket->connected && f.cancellations == 0); + assert(manager.isReplyRouteAvailable(&f.transport)); + } else if (scenario == 27) { + WiFi.mode_value = WIFI_STA; + WiFi.link_status = WL_DISCONNECTED; + WiFi.station_ip = IPAddress(); + WiFi.access_point_ip = IPAddress(192, 168, 4, 1); + assert(!f.start()); + f.tick(); + assert(!f.transport.getStatus().addressReady && WiFiServer::begin_calls == 0); + } else { + assert(false && "unknown scenario"); + } + std::printf("PASS: Wi-Fi listener scenario %d\n", scenario); +} diff --git a/test/fixtures/serial_wifi_sessions/mocks/WiFi.h b/test/fixtures/serial_wifi_sessions/mocks/WiFi.h index c3eb8233..988ee61d 100644 --- a/test/fixtures/serial_wifi_sessions/mocks/WiFi.h +++ b/test/fixtures/serial_wifi_sessions/mocks/WiFi.h @@ -1,11 +1,13 @@ #pragma once #include +#include #include #include #include #include #include +#include #include class IPAddress { @@ -18,8 +20,34 @@ public: operator uint32_t() const { return _address; } bool operator==(const IPAddress& other) const { return _address == other._address; } bool operator!=(const IPAddress& other) const { return !(*this == other); } + std::string toString() const { + return std::to_string((_address >> 24) & 255) + "." + + std::to_string((_address >> 16) & 255) + "." + + std::to_string((_address >> 8) & 255) + "." + + std::to_string(_address & 255); + } }; +constexpr int WL_CONNECTED = 3; +constexpr int WL_DISCONNECTED = 6; +constexpr int WIFI_OFF = 0; +constexpr int WIFI_STA = 1; +constexpr int WIFI_AP = 2; +constexpr int WIFI_AP_STA = 3; + +struct WiFiClass { + int link_status = WL_CONNECTED; + int mode_value = WIFI_STA; + IPAddress station_ip{192, 168, 1, 10}; + IPAddress access_point_ip; + int status() const { return link_status; } + int getMode() const { return mode_value; } + IPAddress localIP() const { return station_ip; } + IPAddress softAPIP() const { return access_point_ip; } +}; + +inline WiFiClass WiFi; + struct MockSocket { IPAddress address; bool connected = true; @@ -36,11 +64,13 @@ class WiFiClient { public: WiFiClient() = default; explicit WiFiClient(std::shared_ptr socket) : _socket(socket) {} - explicit operator bool() const { return _socket && _socket->connected; } - bool connected() const { return _socket && _socket->connected; } + explicit operator bool() { return _socket && _socket->connected; } + // Match the pinned ESP32 SDK's non-const connected() signature. This catches + // const transport accessors that accidentally rely on a const-only fake. + bool connected() { return _socket && _socket->connected; } void stop() { if (_socket) _socket->connected = false; } IPAddress remoteIP() const { return _socket ? _socket->address : IPAddress(); } - int available() const { return connected() ? int(_socket->received.size()) : 0; } + int available() { return connected() ? int(_socket->received.size()) : 0; } size_t read(uint8_t* dest, size_t size) { if (_socket) size = std::min(size, _socket->read_limit); size_t read = 0; @@ -61,12 +91,51 @@ public: }; class WiFiServer { + bool _listening = false; + bool _owns_socket = false; public: + enum class BeginFault { None, Socket, Bind, Listen }; static std::deque incoming; - void begin(int) {} - void end() { incoming.clear(); } + inline static BeginFault begin_fault = BeginFault::None; + inline static int begin_calls = 0; + inline static int end_calls = 0; + inline static int open_sockets = 0; + inline static int last_port = 0; + inline static bool listener_lost = false; + static void resetDiagnostics() { + begin_fault = BeginFault::None; + begin_calls = end_calls = open_sockets = last_port = 0; + listener_lost = false; + incoming.clear(); + WiFi = WiFiClass{}; + } + void begin(int port) { + ++begin_calls; + last_port = port; + if (_listening) return; + if (begin_fault == BeginFault::Socket) { errno = ENOMEM; return; } + // The pinned Arduino ESP32 WiFiServer leaves its descriptor open when + // bind/listen fails. A wrapper must call end() before a bounded retry. + _owns_socket = true; + ++open_sockets; + if (begin_fault == BeginFault::Bind) { errno = EADDRINUSE; return; } + if (begin_fault == BeginFault::Listen) { errno = EIO; return; } + _listening = true; + listener_lost = false; + errno = 0; + } + void end() { + ++end_calls; + if (_owns_socket) --open_sockets; + _owns_socket = _listening = false; + listener_lost = false; + incoming.clear(); + // Cleanup may overwrite errno; diagnostics must preserve the start error. + errno = 0; + } + operator bool() { return _listening && !listener_lost; } WiFiClient available() { - if (incoming.empty()) return WiFiClient(); + if (!static_cast(*this) || incoming.empty()) return WiFiClient(); WiFiClient client = incoming.front(); incoming.pop_front(); return client; diff --git a/test/test_cli_settings_contract.py b/test/test_cli_settings_contract.py index f249f8a1..127655bd 100644 --- a/test/test_cli_settings_contract.py +++ b/test/test_cli_settings_contract.py @@ -203,6 +203,7 @@ class CLISettingsContractTest(unittest.TestCase): self.assertEqual(gets - sets, { 'contact.cache', 'contact.cache.timing', # Contact-storage diagnostics 'display.wifi', # Connection/display status + 'wifi.tcp', # Read-only app listener/traffic diagnostic 'dm.held', # Read-only pending-DM count 'flash.health', # Read-only flash health diagnostics 'key.backup.transport', # Identity-backup transport capability diff --git a/test/test_companion_ap_ownership.py b/test/test_companion_ap_ownership.py index 7e2fbd61..4fa789dc 100644 --- a/test/test_companion_ap_ownership.py +++ b/test/test_companion_ap_ownership.py @@ -66,6 +66,10 @@ class CompanionAccessPointOwnershipTest(unittest.TestCase): self.run_fixture("start_wifi") self.run_fixture("start_wifi", webconfig=False) + def test_failed_transport_registration_does_not_start_unserviced_socket(self): + self.run_fixture("registration") + self.run_fixture("registration", webconfig=False) + def test_deliberate_recovery_setup_handoff_remains_available(self): self.run_fixture("setup_handoff") self.run_fixture("setup_handoff", webconfig=False) diff --git a/test/test_companion_wifi_listener.py b/test/test_companion_wifi_listener.py new file mode 100644 index 00000000..45bd66e1 --- /dev/null +++ b/test/test_companion_wifi_listener.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Fault-inject the production Companion TCP listener, without a radio or PC probe.""" + +from pathlib import Path +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/companion_wifi_listener/test.cpp" +MOCKS = ROOT / "test/fixtures/serial_wifi_sessions/mocks" +SANITIZERS = ( + ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + if sys.platform.startswith("linux") else [] +) +SCENARIOS = ( + "no listener attempt before station association and a real IP", + "healthy listener is not restarted by loop or repeated begin", + "failed socket creation is counted and retried without leaking", + "failed bind closes the retained SDK descriptor before retry", + "failed listen closes the retained SDK descriptor before retry", + "retry delay grows and caps at 30 seconds without a tight loop", + "retry deadline is correct across millis rollover", + "disable cancels automatic repair even after enable", + "end and background suspension cannot resurrect the listener", + "lost listener recovers without dropping a healthy client or its replies", + "station reconnect preserves requested listener and restarts service", + "station association alone with zero address is not ready", + "a ready setup AP supports TCP without station association", + "AP mode without an AP IP is not ready", + "single-client takeover retains existing session ownership guarantees", + "accepted sockets and valid command frames have separate diagnostics", + "invalid protocol frame records a rejection and never dispatches its tail", + "incomplete frame records timeout only at its bounded deadline", + "diagnostic reads cannot start listeners or consume client commands", + "begin without enable is bounded and cannot auto-retry while disabled", + "transmit diagnostics count only completely written framed packets", + "invalid port requests never start or automatically retry", + "short header read is distinguished from invalid protocol and timeout", + "short payload read is distinguished from invalid protocol and timeout", + "healthy address changes never restart a listener or displace its client", + "MultiSerial services unavailable transport so it can recover before dispatch", + "failed listener repair retains an accepted client and queued data", + "stale AP address never starts a listener while AP mode is disabled", +) + + +class CompanionWiFiListenerTests(unittest.TestCase): + def test_production_listener_faults(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + with tempfile.TemporaryDirectory(prefix="meshcore-wifi-listener-") as directory: + binary = Path(directory) / "wifi-listener.exe" + compiled = subprocess.run( + [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + *SANITIZERS, "-DESP32=1", f"-I{MOCKS}", f"-I{ROOT / 'src'}", + str(FIXTURE), str(ROOT / "src/helpers/wifi/SerialWifiInterface.cpp"), + "-o", str(binary)], + capture_output=True, text=True, timeout=60, + ) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + for scenario, description in enumerate(SCENARIOS): + with self.subTest(scenario=description): + checked = subprocess.run( + [str(binary), str(scenario)], capture_output=True, + text=True, timeout=10, + ) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn(f"PASS: Wi-Fi listener scenario {scenario}", checked.stdout) + + def test_production_cli_and_display_formatter_are_bounded_and_observable(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + main = (ROOT / "examples/companion_radio/main.cpp").read_text(encoding="utf-8") + methods = extract_braced(main, "void formatCompanionWiFiTcpStatus(") + methods += "\n" + extract_braced(main, "void formatCompanionWiFiDisplayStatus(") + fixture = (ROOT / "test/fixtures/companion_wifi_listener/diagnostics.cpp").read_text() + with tempfile.TemporaryDirectory(prefix="meshcore-wifi-diagnostics-") as directory: + work = Path(directory) + source = work / "diagnostics.cpp" + source.write_text(fixture.replace("@METHODS@", methods), encoding="utf-8") + binary = work / "diagnostics.exe" + compiled = subprocess.run( + [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + *SANITIZERS, "-DESP32=1", f"-I{MOCKS}", f"-I{ROOT / 'src'}", + str(source), str(ROOT / "src/helpers/wifi/SerialWifiInterface.cpp"), + "-o", str(binary)], capture_output=True, text=True, timeout=60, + ) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn("PASS: production Wi-Fi diagnostic formatters", checked.stdout) + + def test_cli_routes_and_registration_gate_use_real_listener(self): + main = (ROOT / "examples/companion_radio/main.cpp").read_text(encoding="utf-8") + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="utf-8") + command = extract_braced(mesh, 'if (strcmp(command, "get wifi.tcp") == 0)') + self.assertIn("formatCompanionWiFiTcpStatus(reply, reply_size);", command) + self.assertIn("return true;", command) + self.assertRegex(main, r"companion_wifi_interface_registered\s*=\s*" + r"interface_manager\.addInterface\(InterfaceType::WiFi") + for signature in ("static void startCompanionWiFi(", + "static void serviceCompanionWiFiBackgroundPower("): + method = extract_braced(main, signature) + gate = extract_braced(method, "if (companion_wifi_interface_registered)") + self.assertIn("wifi_interface.begin(TCP_PORT)", gate) + self.assertIn("wifi_interface.enable()", gate) + diagnostic = extract_braced(main, "void formatCompanionWiFiTcpStatus(") + self.assertNotRegex(diagnostic, r"(?:\.connect\(|WiFiClient\s+|server\.available)") + display = extract_braced(main, "void formatCompanionWiFiDisplayStatus(") + self.assertIn("wifi_interface.getStatus().listening", display) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_wifi_network_power.py b/test/test_companion_wifi_network_power.py index 85ee389c..ca2af7a9 100644 --- a/test/test_companion_wifi_network_power.py +++ b/test/test_companion_wifi_network_power.py @@ -47,8 +47,9 @@ struct Interface { int ends = 0, begins = 0; bool isConnected() const { return connected; } void end() { enabled = false; ++ends; } - void begin(int) { ++begins; } + bool begin(int) { ++begins; return true; } void enable() { enabled = true; } + bool isListening() const { return enabled && begins > ends; } } wifi_interface; struct ConsoleClient { bool active = false; @@ -71,6 +72,7 @@ void mesh::wifi::setStationAutoReconnect(bool value) { WiFi.setAutoReconnect(val struct Tracker { void noteDisconnected(uint32_t) {} } wifi_reconnect_tracker; bool companion_wifi_active = true, companion_wifi_requested = true; +bool companion_wifi_interface_registered = true; bool companion_wifi_background_stopped = false; bool companion_wifi_espnow_restore_pending = false; uint32_t companion_wifi_espnow_restore_at = 0; @@ -116,6 +118,7 @@ void reset() { test_now = 10000; board = {}; wifi_interface = {}; ota_console_client = {}; WiFi = {}; companion_wifi_active = companion_wifi_requested = true; + companion_wifi_interface_registered = true; companion_wifi_background_stopped = false; companion_wifi_espnow_restore_pending = false; companion_wifi_espnow_restore_at = 0; @@ -345,6 +348,15 @@ int main() { assert(!companion_wifi_webconfig_start_pending); mesh::wifi::networkPower().setManaged(false); assert(companionWiFiAutomaticRecoveryAllowed()); + + // Recovery cannot publish a listener that no dispatcher registered. + reset(); + companion_wifi_interface_registered = false; + mesh::wifi::networkPower().setSuspended(true); + serviceCompanionWiFiBackgroundPower(); + mesh::wifi::networkPower().setSuspended(false); + serviceCompanionWiFiBackgroundPower(); + assert(!companion_wifi_background_stopped && wifi_interface.begins == 0); } '''