Recover Companion WiFi TCP listener failures and expose diagnostics
This commit is contained in:
1 parent
a2f51253d8
commit
6693dba8cf
17 files changed
+1004
-46
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
Reference in new issue
Block a user