Recover Companion WiFi TCP listener failures and expose diagnostics

This commit is contained in:
mikecarper committed 2026-10-13 16:24:34 -07:00
1 parent a2f51253d8
commit 6693dba8cf
17 files changed
+1004 -46

No files matched your search

+1
View File
@@ -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
+20
View File
@@ -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
+5
View File
@@ -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 <MHz>` 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)
+11
View File
@@ -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
+3
View File
@@ -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.
+4
View File
@@ -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)
+42 -10
View File
@@ -115,6 +115,7 @@ MultiSerialInterface interface_manager;
#include <esp_sntp.h>
#include <esp_wifi.h>
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)
+175 -23
View File
@@ -1,6 +1,7 @@
#include "SerialWifiInterface.h"
#include "../CompanionFrameQueue.h"
#include <WiFi.h>
#include <errno.h>
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<uint32_t>(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<uint32_t>(WiFi.softAPIP()) != 0) return true;
#endif
return false;
}
bool SerialWifiInterface::serviceListener(uint32_t now) {
if (!listener_requested) return false;
if (static_cast<bool>(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<bool>(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<uint16_t>(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<bool>(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<bool>(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;
}
+70 -5
View File
@@ -5,12 +5,63 @@
#include <WiFi.h>
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<bool>(server) || (deviceConnected && client.connected()));
}
void loop() override;
bool isConnected() const override;
+12 -1
View File
@@ -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);
+97
View File
@@ -0,0 +1,97 @@
#include <helpers/wifi/SerialWifiInterface.h>
#include <cassert>
#include <cstdio>
#include <cstring>
#include <climits>
uint32_t mock_millis = 100;
std::deque<WiFiClient> 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");
}
+348
View File
@@ -0,0 +1,348 @@
#include <helpers/wifi/SerialWifiInterface.h>
#include <helpers/MultiSerialInterface.h>
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <limits>
#include <vector>
uint32_t mock_millis = 100;
std::deque<WiFiClient> WiFiServer::incoming;
using Socket = std::shared_ptr<MockSocket>;
using Bytes = std::vector<uint8_t>;
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<Fixture*>(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<MockSocket>(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<int>(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<size_t>::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<MockSocket>(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<size_t>::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);
}
+75 -6
View File
@@ -1,11 +1,13 @@
#pragma once
#include <algorithm>
#include <cerrno>
#include <cstddef>
#include <cstdint>
#include <deque>
#include <limits>
#include <memory>
#include <string>
#include <vector>
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<MockSocket> 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<WiFiClient> 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<bool>(*this) || incoming.empty()) return WiFiClient();
WiFiClient client = incoming.front();
incoming.pop_front();
return client;
+1
View File
@@ -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
+4
View File
@@ -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)
+123
View File
@@ -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()
+13 -1
View File
@@ -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);
}
'''