Improve companion setup and USB session recovery

This commit is contained in:
mikecarper committed 2026-09-02 03:44:28 -07:00
1 parent ecaa5ac2a0
commit cf70478c51
34 files changed
+6283 -2147

No files matched your search

+1
View File
@@ -62,6 +62,7 @@ over the normal binary USB, BLE, or TCP connection:
|---|---|
| [`board`](cli_commands.md#show-the-hardware-name) | Every Companion text terminal and command `0x42` |
| [`version`](cli_commands.md#get-the-version) | Every Companion text terminal and protocol-v14 command `0x42`; returns the untruncated build identity |
| [`get storage.layout`](cli_commands.md#show-the-storage-layout) | Every Companion text terminal and command `0x42`; reports the running platform's storage layout |
| [`memory`](#memory) | ESP32 Full Companion text terminal and command `0x42` |
| [`get/set radio.rxgain`](cli_commands.md#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141) | Companion with a supported radio |
| [`get/set radio.fem.rxgain`](cli_commands.md#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards) | Companion on a board with controllable LoRa FEM LNA |
+10 -3
View File
@@ -65,11 +65,18 @@ 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);
#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.
void formatCompanionWiFiStatus(char* reply, size_t reply_size);
#endif
// A display callback only queues this request. The main loop owns the actual
// WebConfig/WiFi transition so touch handling never tears down network services
// from the UI task.
// Display callbacks only queue these requests. The main loop owns the actual
// WebConfig/WiFi transitions so input handling never tears down network
// services from the UI task. Neither request changes the saved WebUI or WiFi
// preference; the setup AP is controlled only for the current boot session.
void requestCompanionWiFiSetup();
void requestCompanionWiFiSetupStop();
// Reload saved credentials after a text-terminal update. The reconnect is
// deferred so the command reply can leave USB/TCP before WiFi is restarted.
+25 -1
View File
@@ -5,6 +5,7 @@
#include <helpers/CompanionHardwareCommandCompat.h>
#include <helpers/CompanionStatusResponse.h>
#include <helpers/IdentityGeneration.h>
#include <helpers/StorageLayout.h>
#include <helpers/UsbAsciiBinarySwitch.h>
#include <helpers/UsbLogging.h>
#include "helpers/radiolib/RXPowerSaving.h"
@@ -2118,6 +2119,12 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
return true;
}
if (strncmp(command, "get ", 4) == 0 &&
mesh::cli::handleStorageLayoutGet(command + 4, board, reply,
reply_size)) {
return true;
}
if (strcmp(command, "get pwrmgt.bootreason") == 0
|| strcmp(command, "powerlog") == 0) {
if (!board.isPowerManagementInitialized()) {
@@ -2396,7 +2403,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
WebConfigServer::formatWiFiSSID(reply, reply_size);
return true;
case mesh::cli::StandaloneWiFiKey::Status:
WebConfigServer::formatWiFiStatus(reply, reply_size);
formatCompanionWiFiStatus(reply, reply_size);
return true;
case mesh::cli::StandaloneWiFiKey::CLI:
// Companion WebConfig deliberately has no browser command terminal:
@@ -3302,6 +3309,22 @@ bool MyMesh::hasPendingReqs() const {
|| pending_discovery != 0 || pending_req != 0;
}
bool MyMesh::hasFiniteDelayedReplyForRoute(BaseSerialInterface* route) const {
if (route == NULL) return false;
if (pending_serial_reply_route == route
|| (command_radio_apply_pending && command_radio_reply_route == route)
|| (binary_trace_pending && binary_trace_reply_route == route)) {
return true;
}
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; ++i) {
if (expected_ack_table[i].ack != 0
&& expected_ack_table[i].reply_route == route) {
return true;
}
}
return false;
}
void MyMesh::servicePendingSerialReply() {
if (pending_serial_reply_route == NULL) return;
if (!_serial->isReplyRouteAvailable(pending_serial_reply_route)
@@ -6331,6 +6354,7 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().print("Commands:\r\n");
terminalOutput().print(" board\r\n");
terminalOutput().print(" version\r\n");
terminalOutput().print(" get storage.layout\r\n");
terminalOutput().print(" get pwrmgt.bootreason\r\n");
#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS
terminalOutput().print(" memory\r\n");
+4 -1
View File
@@ -134,6 +134,7 @@ public:
void startInterface(BaseSerialInterface &serial);
void cancelSerialResponseStream();
void cancelSerialOperationsForRoute(BaseSerialInterface* route);
bool hasFiniteDelayedReplyForRoute(BaseSerialInterface* route) const;
void resetUsbHostSessionInput();
const char *getNodeName();
@@ -187,6 +188,9 @@ public:
#ifdef ENABLE_USB_INTERFACE
void enterTerminalMode();
void exitTerminalMode();
// Clear state owned by the current text-terminal host without changing the
// protocol owner or printing a new banner.
void resetTerminalSession();
bool isTerminalMode() const { return _terminal_mode; }
void handleTerminalCommand(char* command);
#if COMPANION_FEATURE_NETWORK_TERMINAL
@@ -362,7 +366,6 @@ private:
#ifdef ENABLE_USB_INTERFACE
Stream& terminalOutput();
bool hasTerminalOutput() const { return _terminal_output != NULL; }
void resetTerminalSession();
void printTerminalBanner(bool show_binary_stop);
ContactInfo* getTerminalRecipient();
void printTerminalPath(const ContactInfo& recipient);
+207 -50
View File
@@ -93,6 +93,7 @@ MultiSerialInterface interface_manager;
#include <helpers/ArduinoSerialInterface.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/UsbAsciiBinarySwitch.h>
#include <helpers/UsbLogging.h>
static const char USB_TERMINAL_START_TOKEN[] = "+++MESHCORE-TERM-START";
static const char USB_TERMINAL_STOP_TOKEN[] = "+++MESHCORE-TERM-STOP";
#if COMPANION_FEATURE_USB_MOTA_SOURCE
@@ -107,6 +108,32 @@ MultiSerialInterface interface_manager;
// for targets which cannot report DTR (see setConnectedCheck below)
#define USB_CLIENT_IDLE_TIMEOUT (10*60*1000UL)
#endif
#ifndef USB_FRAME_REPLY_GRACE_MS
// A complete command frame proves that a Binary Companion client is
// listening even if an ESP32 HWCDC host-presence flag briefly flaps while
// the response is being queued. Keep this much time to finish the reply.
#define USB_FRAME_REPLY_GRACE_MS 2000UL
#endif
#ifndef USB_HOST_LOSS_GRACE_MS
// The bundled ESP32 HWCDC SOF detector declares loss after only a few
// missed ticks. Debounce that advisory signal before ending a host session.
#define USB_HOST_LOSS_GRACE_MS 2000UL
#endif
#ifndef USB_HOST_LOSS_EDGE_MS
// HWCDC's framework filter declares loss after roughly 5 ms without SOFs.
// Require a longer continuous gap before using that signal as a fallback
// session boundary when the finite BUS_RESET event queue drops its event.
#define USB_HOST_LOSS_EDGE_MS 100UL
#endif
#ifndef USB_TRANSPORT_RESET_RETRY_MS
// A failed purge can spend up to roughly 150 ms waiting on the live HWCDC
// writer/ring. Back off enough to keep WiFi and mesh service responsive.
#define USB_TRANSPORT_RESET_RETRY_MS 1000UL
#endif
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
mesh::UsbHostPresenceDebouncer usb_hwcdc_host_presence;
#endif
#endif
#if COMPANION_FEATURE_NETWORK_TERMINAL
@@ -502,7 +529,10 @@ static void queueUsbTerminalControlReply(const char* reply) {
static void drainUsbTerminalOutputBeforeProtocolSwitch() {
const uint32_t started = millis();
while (mesh::hasPendingUsbTerminalOutput()
#if !(defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT)
&& isUsbTerminalDataConnected()
#endif
&& (uint32_t)(millis() - started)
< USB_TERMINAL_PROTOCOL_DRAIN_MS) {
mesh::serviceUsbTerminalPort();
@@ -605,6 +635,12 @@ static bool enterUsbMotaMode(mesh::UsbMotaEntryOrigin origin) {
usb_mota_line_len = 0;
usb_mota_line[0] = 0;
usb_mota_disconnect_armed = isUsbTerminalDataConnected();
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
// A valid attach command is stronger host proof than HWCDC's advisory SOF
// sample, which may be low in the same loop as the received command.
usb_hwcdc_host_presence.noteClientActivity();
#endif
char reply[160] = {0};
if (!the_mesh.handleLocalControlCommand("ota folder on", reply, sizeof(reply))
@@ -631,12 +667,15 @@ static bool enterUsbMotaMode(mesh::UsbMotaEntryOrigin origin) {
}
static void serviceUsbMota() {
#if !(defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT)
if (isUsbTerminalDataConnected()) {
usb_mota_disconnect_armed = true;
} else if (usb_mota_disconnect_armed) {
leaveUsbMotaMode(false);
return;
}
#endif
// SerialMotaSource consumes framed responses synchronously while serving a
// block. Bytes left here are host control text, notably motatool's automatic
@@ -669,51 +708,113 @@ static void serviceUsbMota() {
}
#endif
// DTR polling alone cannot distinguish a continuously open handle from a
// close/reopen that happened entirely between two loop iterations. Consume the
// TinyUSB owner-task edge before any protocol parser runs, so no line, login,
// recipient, queued text, or mOTA attachment survives into the next host.
// Consume an exact TinyUSB owner-task edge, or HWCDC's framework-debounced SOF
// loss edge, before any protocol parser runs. No line, login, recipient, queued
// text, or mOTA attachment may survive into the next host session.
static bool usb_terminal_host_reset_completion_pending = false;
static uint32_t usb_terminal_host_reset_retry_at = 0;
static void resetUsbTerminalHostSession(bool preserve_ascii_terminal) {
// Stop only work routed to the old USB host; a simultaneous BLE/WiFi
// transaction is independent and must continue. Clear the route after
// stopping its producer so no response can fall back to another transport.
cancelUsbSerialOperations();
the_mesh.resetUsbHostSessionInput();
bool protocol_owner_reset = false;
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (usb_mota_mode) {
leaveUsbMotaMode(false);
protocol_owner_reset = true;
}
#endif
if (!protocol_owner_reset && the_mesh.isTerminalMode()) {
if (preserve_ascii_terminal) {
// Full Companion's ordinary HWCDC terminal is the default protocol.
// Clear the old host's input/output without turning the next physical
// session into a silent Binary-only port.
the_mesh.resetTerminalSession();
mesh::discardUsbTerminalOutput();
clearUsbTerminalLine();
usb_terminal_discard_line = false;
usb_terminal_disconnect_armed = false;
#if defined(COMPANION_RADIO_FULL)
usb_binary_startup_probe.cancel();
#endif
} else {
leaveUsbTerminalMode(false);
}
protocol_owner_reset = true;
}
if (!protocol_owner_reset) {
mesh::discardUsbTerminalOutput();
usb_serial_interface.setPassthroughMode(false);
clearUsbTerminalLine();
usb_terminal_discard_line = false;
usb_terminal_disconnect_armed = false;
usb_logging_terminal_mode = false;
#if defined(COMPANION_RADIO_FULL)
usb_binary_startup_probe.cancel();
#endif
}
// setPassthroughMode() clears parser/queue state but frame ownership is a
// separate proof. Revoke all three explicitly at the session boundary.
usb_serial_interface.resetSessionState();
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
// Purge HWCDC's queues behind a temporary diagnostics gate. This keeps the
// core's shared RTOS objects alive while other firmware tasks are running.
if (!mesh::resetUsbCompanionTransport()) {
// Keep the producer gate and PHY quarantined, then retry from later loops.
// Rebooting on repeatable allocation/contention failures creates a host-
// enumeration restart loop and still does not prove stale bytes were gone.
usb_terminal_host_reset_completion_pending = true;
usb_terminal_host_reset_retry_at = millis() + USB_TRANSPORT_RESET_RETRY_MS;
return;
}
usb_terminal_host_reset_completion_pending = false;
usb_hwcdc_host_presence.reset();
#endif
}
static void serviceUsbTerminalHostSessionReset() {
#if defined(NRF52_PLATFORM)
if (mesh::takeUsbTerminalSessionReset()) {
usb_terminal_host_reset_completion_pending = true;
// A rapid reopen can make USB look connected again before this loop. Stop
// only work routed to the old USB host; a simultaneous BLE transaction is
// independent and must continue. Clear the route after stopping its
// producer so no remaining response can fall back to another transport.
cancelUsbSerialOperations();
the_mesh.resetUsbHostSessionInput();
bool protocol_owner_reset = false;
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (usb_mota_mode) {
leaveUsbMotaMode(false);
protocol_owner_reset = true;
}
#endif
if (!protocol_owner_reset && the_mesh.isTerminalMode()) {
leaveUsbTerminalMode(false);
protocol_owner_reset = true;
}
if (!protocol_owner_reset) {
mesh::discardUsbTerminalOutput();
usb_serial_interface.setPassthroughMode(false);
clearUsbTerminalLine();
usb_terminal_discard_line = false;
usb_terminal_disconnect_armed = false;
usb_logging_terminal_mode = false;
#if defined(COMPANION_RADIO_FULL)
usb_binary_startup_probe.cancel();
#endif
}
resetUsbTerminalHostSession(false);
}
if (usb_terminal_host_reset_completion_pending
&& mesh::tryCompleteUsbTerminalSessionReset()) {
usb_terminal_host_reset_completion_pending = false;
}
#elif defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
if (usb_terminal_host_reset_completion_pending) {
if ((int32_t)(millis() - usb_terminal_host_reset_retry_at) >= 0) {
if (mesh::resetUsbCompanionTransport()) {
usb_terminal_host_reset_completion_pending = false;
usb_hwcdc_host_presence.reset();
} else {
usb_terminal_host_reset_retry_at =
millis() + USB_TRANSPORT_RESET_RETRY_MS;
}
}
return;
}
const bool host_present = board.isUsbHostConnected();
(void)usb_hwcdc_host_presence.observeHost(
host_present, millis(), USB_HOST_LOSS_EDGE_MS,
USB_HOST_LOSS_GRACE_MS);
const bool hardware_bus_reset = mesh::takeUsbTerminalSessionReset();
const bool physical_host_loss =
usb_hwcdc_host_presence.takeHostLossEdge();
const bool sustained_host_loss =
usb_hwcdc_host_presence.takeSustainedHostLoss();
if (hardware_bus_reset || physical_host_loss || sustained_host_loss) {
resetUsbTerminalHostSession(true);
}
#endif
}
@@ -940,6 +1041,7 @@ void halt() {
static bool companion_wifi_disable_in_progress = false;
static bool companion_wifi_services_stopped = false;
static volatile bool companion_wifi_setup_requested = false;
static volatile bool companion_wifi_setup_stop_requested = false;
static bool companion_wifi_credential_reload_pending = false;
static unsigned long companion_wifi_credential_reload_at = 0;
static bool companion_wifi_power_save_loaded = false;
@@ -1313,10 +1415,27 @@ void halt() {
WiFi.localIP().toString().c_str());
}
#ifdef WITH_WEBCONFIG
void formatCompanionWiFiStatus(char* reply, size_t reply_size) {
const mesh::wifi::CompanionWiFiRuntimeState runtime = {
companion_wifi_requested,
companion_wifi_active,
companion_wifi_credential_reload_pending,
};
WebConfigServer::formatWiFiStatus(reply, reply_size, &runtime);
}
#endif
void requestCompanionWiFiSetup() {
companion_wifi_setup_stop_requested = false;
companion_wifi_setup_requested = true;
}
void requestCompanionWiFiSetupStop() {
companion_wifi_setup_requested = false;
companion_wifi_setup_stop_requested = true;
}
bool toggleCompanionWiFi() {
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
const CompanionTransportMode current = getCompanionTransportMode();
@@ -1847,7 +1966,37 @@ void halt() {
#endif
void setup() {
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \
&& defined(ENABLE_USB_INTERFACE)
// Configure HWCDC before begin() allocates its mutex and enables the USB ISR.
// The bundled setTxBufferSize() deletes/recreates its ring without taking the
// TX mutex or masking that ISR, so even an otherwise-quiet post-begin resize
// can race the interrupt handler.
// A failed resize deletes HWCDC's existing ring. Try progressively smaller
// queues while setup still has no producers, and tell reset cleanup the exact
// capacity it must observe before reopening a host session.
static const size_t usb_tx_sizes[] = {
MESH_ESP32_USB_TX_BUFFER_SIZE, 2048, 1024, 512, 256};
size_t usb_tx_capacity = 0;
for (size_t candidate : usb_tx_sizes) {
usb_tx_capacity = Serial.setTxBufferSize(candidate);
if (usb_tx_capacity == candidate) break;
}
Serial.setTxTimeoutMs(5);
#endif
Serial.begin(115200);
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \
&& defined(ENABLE_USB_INTERFACE)
// If every pre-begin allocation failed, begin() may have recovered by
// creating its built-in 256-byte ring. Discover that ring without replacing
// it after the ISR is live; a zero result leaves reset cleanup quarantined.
if (usb_tx_capacity == 0) {
usb_tx_capacity = Serial.availableForWrite();
}
mesh::setUsbCompanionTxBufferCapacity(usb_tx_capacity);
#endif
#if MESH_PACKET_LOGGING
mesh::serialLogBegin();
#endif
@@ -2115,19 +2264,25 @@ void setup() {
usb_serial_interface.enableFlowControl(true);
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
// a 256 byte TX buffer overflows during a contact sync, and write() blocks
// up to tx_timeout_ms per call against a stalled host (same reasoning as
// the kiss_modem tuning)
Serial.setTxBufferSize(4096);
Serial.setTxTimeoutMs(5);
// The ESP32 USB-Serial-JTAG peripheral (HWCDC) has no DTR concept at all,
// and its bool operator is only a transient RX/TX activity heuristic. A real
// Binary Companion client therefore has to prove ownership by sending
// frames; an idle physical cable must not claim the interface.
// The ESP32 USB-Serial-JTAG peripheral (HWCDC) has no DTR concept at all.
// Its bool operator is a transient RX/TX activity heuristic, and even the
// SOF-based host-presence flag can briefly flap around a bus reset. A
// complete command frame is stronger proof that a client is listening, so
// grant it a short lease in which the response can be queued and drained.
// Outside that lease, require the physical host signal as well as recent
// Binary Companion activity so an idle cable does not claim the interface.
// The idle cap stays absolute: an abandoned multi-frame response must not
// lock BLE or WiFi dispatch forever merely because SOFs continue.
usb_serial_interface.setConnectedCheck([]() {
uint32_t last = usb_serial_interface.getLastFrameMillis();
return (bool)Serial && usb_serial_interface.hasReceivedFrame()
&& (millis() - last) < USB_CLIENT_IDLE_TIMEOUT;
return usb_hwcdc_host_presence.isClientConnected(
board.isUsbHostConnected(), millis(),
usb_serial_interface.hasReceivedFrame(),
usb_serial_interface.getLastFrameMillis(),
usb_serial_interface.getCompletedFrameCount(),
USB_FRAME_REPLY_GRACE_MS, USB_CLIENT_IDLE_TIMEOUT,
USB_HOST_LOSS_EDGE_MS,
USB_HOST_LOSS_GRACE_MS,
the_mesh.hasFiniteDelayedReplyForRoute(&usb_serial_interface));
});
#elif (defined(ESP32) && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT) \
|| defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM)
@@ -2317,13 +2472,15 @@ void loop() {
#if defined(ESP32) && defined(WIFI_SSID)
#ifdef WITH_WEBCONFIG
the_mesh.serviceWebConfig();
if (companion_wifi_setup_stop_requested) {
companion_wifi_setup_stop_requested = false;
the_mesh.stopWebConfig();
}
if (companion_wifi_setup_requested) {
companion_wifi_setup_requested = false;
if (companion_wifi_requested) {
char web_reply[160];
if (!the_mesh.startWebConfig(true, web_reply)) {
WIFI_DEBUG_PRINTLN("Display WiFi setup request: %s", web_reply);
}
char web_reply[160];
if (!the_mesh.startWebConfig(true, web_reply)) {
WIFI_DEBUG_PRINTLN("Display WiFi setup request: %s", web_reply);
}
}
#endif
+62 -3
View File
@@ -118,6 +118,46 @@ static void drawCompanionTransportChoice(DisplayDriver& display,
#endif
#if UI_WIFI_SETUP_HOME_PAGE == 1
static bool drawCompactCompanionWiFiSetupPage(
DisplayDriver& display, bool setup_active, const char* setup_ssid,
const char* setup_ip, bool wifi_enabled, bool wifi_connected,
bool wifi_configured) {
if (display.width() > 128 || display.height() > 64) return false;
// Four small rows fit below the shared title bar and page dots. The OLED
// deliberately uses text instead of a QR: at this scale the SSID, portal IP,
// and physical-button action are more useful and remain readable.
display.setCompactText(true);
display.setTextSize(1);
display.setColor(UIColor::primary_txt);
display.drawTextCentered(
display.width() / 2, 20,
setup_active ? "SETUP AP ACTIVE" : "SETUP AP INACTIVE");
if (setup_active) {
display.drawTextEllipsized(0, 31, display.width(), setup_ssid);
char open_ip[22];
snprintf(open_ip, sizeof(open_ip), "OPEN %s", setup_ip);
display.setColor(UIColor::secondary_txt);
display.drawTextCentered(display.width() / 2, 42, open_ip);
} else {
const char* wifi_status = !wifi_enabled
? "WIFI OFF"
: wifi_connected
? "WIFI READY"
: wifi_configured ? "WIFI CONNECTING" : "WIFI NOT CONFIGURED";
display.setColor(UIColor::secondary_txt);
display.drawTextCentered(display.width() / 2, 37, wifi_status);
}
display.setColor(UIColor::warning_txt);
display.drawTextCentered(
display.width() / 2, 53,
setup_active ? "HOLD STOP AP" : "HOLD START AP");
display.setCompactText(false);
return true;
}
static void drawCompanionWiFiSetupPage(DisplayDriver& display) {
char setup_ssid[33] = {0};
char setup_ip[16] = {0};
@@ -147,6 +187,12 @@ static void drawCompanionWiFiSetupPage(DisplayDriver& display) {
}
noteCompanionWiFiDisplayState(state);
if (drawCompactCompanionWiFiSetupPage(
display, setup_active, setup_ssid, setup_ip, wifi_enabled,
wifi_connected, wifi_configured)) {
return;
}
// Keep the setup content below the shared title bar and page dots. Compact
// text makes its physical size identical on the 480 canvas and the
// 320-to-480 emergency canvas.
@@ -456,6 +502,10 @@ public:
#endif
}
#if UI_WIFI_SETUP_HOME_PAGE == 1
bool isWiFiSetupPage() const { return _page == HomePage::WIFI_SETUP; }
#endif
void poll() override {
#if UI_WIFI_SETUP_HOME_PAGE == 1
const bool wifi_setup_active = WebConfigServer::getSetupInfo(
@@ -1055,10 +1105,12 @@ public:
#endif
#if UI_WIFI_SETUP_HOME_PAGE == 1
if (c == KEY_ENTER && _page == HomePage::WIFI_SETUP) {
if (isCompanionWiFiEnabled() && !isCompanionWiFiConnected()
&& !WebConfigServer::getSetupInfo(nullptr, 0, nullptr, 0)) {
if (WebConfigServer::getSetupInfo(nullptr, 0, nullptr, 0)) {
requestCompanionWiFiSetupStop();
_task->showAlert("Stopping setup AP", 1000);
} else {
requestCompanionWiFiSetup();
_task->showAlert("Starting WiFi setup", 1000);
_task->showAlert("Starting setup AP", 1000);
}
return true;
}
@@ -1943,6 +1995,13 @@ char UITask::checkDisplayOn(char c) {
}
char UITask::handleLongPress(char c) {
#if UI_WIFI_SETUP_HOME_PAGE == 1
// A setup AP may focus this page immediately at boot. Its documented HOLD
// action must win over the otherwise-global early-boot CLI rescue gesture.
if (curr == home && static_cast<HomeScreen*>(home)->isWiFiSetupPage()) {
return c;
}
#endif
if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue
the_mesh.enterCLIRescue();
c = 0; // consume event
+11 -10
View File
@@ -158,6 +158,15 @@ void ArduinoSerialInterface::setPassthroughMode(bool enabled) {
resetTransmitState();
}
void ArduinoSerialInterface::resetSessionState() {
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
_has_received_frame = false;
_last_frame_ms = 0;
resetReceiveState();
resetTransmitState();
}
bool ArduinoSerialInterface::takeControlSequence() {
bool received = _controlSequenceReceived;
_controlSequenceReceived = false;
@@ -172,19 +181,11 @@ bool ArduinoSerialInterface::takeSecondaryControlSequence() {
void ArduinoSerialInterface::enable() {
_isEnabled = true;
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
_has_received_frame = false;
resetReceiveState();
resetTransmitState();
resetSessionState();
}
void ArduinoSerialInterface::disable() {
_isEnabled = false;
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
_has_received_frame = false;
resetReceiveState();
resetTransmitState();
resetSessionState();
}
bool ArduinoSerialInterface::isConnected() const {
+4
View File
@@ -95,6 +95,10 @@ public:
// Without it isConnected() assumes true, as a plain UART has no way of knowing.
void setConnectedCheck(ConnectedCheck fn) { _conn_check = fn; }
// Clear all protocol state owned by one host session while retaining the
// monotonic completed-frame counter used by startup handoff arbitration.
void resetSessionState();
// millis() of the last completely received frame, 0 if none since boot.
// Useful as an activity-based connection check where no DTR state exists.
uint32_t getLastFrameMillis() const { return _last_frame_ms; }
+4 -162
View File
@@ -1,6 +1,7 @@
#include <Arduino.h>
#include "CommonCLI.h"
#include "CLICommandUtils.h"
#include "StorageLayout.h"
#include "radiolib/RadioPowerLimits.h"
#include "radiolib/LR2021SideDetectorConfig.h"
#include "TxtDataHelpers.h"
@@ -9,7 +10,6 @@
#include "sensors/EnvironmentI2CConfig.h"
#if defined(NRF52_PLATFORM)
#include "AtomicFileWriter.h"
#include "ota/OtaFlashLayout_nrf52.h"
#endif
#include <RTClib.h>
#include <ctype.h>
@@ -54,8 +54,6 @@ static void resetToUf2Bootloader() {
#ifdef ESP_PLATFORM
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <esp_wifi.h>
#include <esp_heap_caps.h>
#else
@@ -64,9 +62,6 @@ static void resetToUf2Bootloader() {
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
#include <helpers/esp32/WiFiRadioPolicy.h>
#endif
#if defined(STM32_PLATFORM)
#include <InternalFileSystem.h>
#endif
#ifdef WITH_MQTT_BRIDGE
#include "bridges/MQTTBridge.h"
#include "CommonPrefsRecovery.h"
@@ -107,30 +102,6 @@ static void formatSdCardElapsed(char* out, size_t out_len, bool recorded,
}
}
static void formatSdCardBytes(char* out, size_t out_len, uint64_t bytes) {
static const char* const units[] = {"B", "KiB", "MiB", "GiB", "TiB"};
uint64_t unit_size = 1;
uint8_t unit = 0;
while (unit < 4 && bytes >= unit_size * 1024ULL) {
unit_size *= 1024ULL;
unit++;
}
if (unit == 0) {
snprintf(out, out_len, "%lu B", (unsigned long)bytes);
return;
}
uint64_t whole = bytes / unit_size;
uint32_t tenths = (uint32_t)(((bytes % unit_size) * 10ULL + unit_size / 2ULL) /
unit_size);
if (tenths == 10) {
whole++;
tenths = 0;
}
snprintf(out, out_len, "%lu.%lu %s", (unsigned long)whole,
(unsigned long)tenths, units[unit]);
}
#endif
#include <helpers/radiolib/RXPowerSaving.h>
@@ -3004,135 +2975,6 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
}
}
bool CommonCLI::handleStorageLayoutGetCmd(const char* config, char* reply) {
static const char key[] = "storage.layout";
const size_t key_len = sizeof(key) - 1;
if (strncmp(config, key, key_len) != 0 ||
(config[key_len] != 0 && config[key_len] != ' ')) {
return false;
}
if (config[key_len] != 0) {
strcpy(reply, "Error: use get storage.layout");
return true;
}
#if defined(ESP32_PLATFORM)
snprintf(reply, 160, "> int:esp32=%luK ext:none; ",
(unsigned long)(ESP.getFlashChipSize() / 1024UL));
const esp_partition_t* running = esp_ota_get_running_partition();
esp_partition_iterator_t iterator = esp_partition_find(
ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL);
bool first = true;
bool truncated = false;
while (iterator != NULL) {
const esp_partition_t* partition = esp_partition_get(iterator);
char entry[48];
snprintf(entry, sizeof(entry), "%s%s%s@0x%lX+%luK",
first ? "" : ",", partition->label,
running != NULL && partition->address == running->address ? "*" : "",
(unsigned long)partition->address,
(unsigned long)(partition->size / 1024UL));
const size_t used = strlen(reply);
const size_t entry_len = strlen(entry);
if (used + entry_len + 1 >= 160) {
truncated = true;
break;
}
memcpy(reply + used, entry, entry_len + 1);
first = false;
iterator = esp_partition_next(iterator);
}
if (iterator != NULL) esp_partition_iterator_release(iterator);
if (first) {
snprintf(reply + strlen(reply), 160 - strlen(reply), "partitions=none");
} else if (truncated) {
const size_t used = strlen(reply);
if (used + 4 < 160) memcpy(reply + used, ",...", 5);
}
#elif defined(NRF52_PLATFORM)
const uint32_t flash_bytes =
(uint32_t)NRF_FICR->CODEPAGESIZE * (uint32_t)NRF_FICR->CODESIZE;
const uint32_t app_start = mesh::ota::mota_nrf52_app_base();
const uint32_t app_end =
(uint32_t)(uintptr_t)mesh::ota::__flash_arduino_end;
#if defined(NRF52840_XXAA)
const uint32_t internal_fs_start = 0x000ED000UL;
#else
const uint32_t internal_fs_start = 0x0006D000UL;
#endif
const uint32_t internal_fs_size = 7UL * 4096UL;
snprintf(reply, 160,
"> int:nrf52=%luK app=0x%lX-0x%lX ifs=0x%lX+%luK",
(unsigned long)(flash_bytes / 1024UL),
(unsigned long)app_start, (unsigned long)app_end,
(unsigned long)internal_fs_start,
(unsigned long)(internal_fs_size / 1024UL));
#if defined(ENABLE_OTA) && defined(OTA_QSPI_STORE)
mesh::ota::OtaStoreQspiNrf52& store = mesh::ota::ota_ctx().fetch_store;
const uint32_t capacity = store.capacity();
if (capacity != 0) {
snprintf(reply + strlen(reply), 160 - strlen(reply),
"; ext:qspi=%luK owner=ota-raw id=%06lX",
(unsigned long)(capacity / 1024UL),
(unsigned long)store.jedec_id());
} else {
snprintf(reply + strlen(reply), 160 - strlen(reply),
"; ext:qspi=error(%s)", store.last_error());
}
#elif defined(ENABLE_OTA) && defined(OTA_SD_STORE)
uint64_t used_bytes = 0;
uint64_t free_bytes = 0;
mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().fetch_store;
if (store.getSpace(*_board, used_bytes, free_bytes)) {
char total[24];
char used[24];
char free[24];
formatSdCardBytes(total, sizeof(total), used_bytes + free_bytes);
formatSdCardBytes(used, sizeof(used), used_bytes);
formatSdCardBytes(free, sizeof(free), free_bytes);
snprintf(reply + strlen(reply), 160 - strlen(reply),
"; ext:sd=%s used=%s free=%s owner=ota",
total, used, free);
} else {
snprintf(reply + strlen(reply), 160 - strlen(reply),
"; ext:sd=error(%s)", store.last_error());
}
#elif defined(QSPIFLASH)
snprintf(reply + strlen(reply), 160 - strlen(reply),
"; ext:qspi=configured owner=littlefs");
#else
snprintf(reply + strlen(reply), 160 - strlen(reply), "; ext:none");
#endif
#elif defined(RP2040_PLATFORM)
#if defined(PICO_FLASH_SIZE_BYTES) && defined(FS_START) && defined(FS_END)
snprintf(reply, 160,
"> int:rp2040=%luK ifs=%luK layout=fixed; ext:none",
(unsigned long)(PICO_FLASH_SIZE_BYTES / 1024UL),
(unsigned long)(((uintptr_t)FS_END - (uintptr_t)FS_START) / 1024UL));
#elif defined(PICO_FLASH_SIZE_BYTES)
snprintf(reply, 160, "> int:rp2040=%luK layout=fixed; ext:none",
(unsigned long)(PICO_FLASH_SIZE_BYTES / 1024UL));
#else
strcpy(reply, "> int:rp2040 layout=fixed; ext:none");
#endif
#elif defined(STM32_PLATFORM)
snprintf(reply, 160,
"> int:stm32=%luK ifs=0x%lX+%luK layout=fixed; ext:none",
(unsigned long)((FLASH_END_ADDR - FLASH_BASE + 1UL) / 1024UL),
(unsigned long)LFS_FLASH_ADDR_BASE,
(unsigned long)(LFS_FLASH_TOTAL_SIZE / 1024UL));
#else
strcpy(reply, "Error: storage layout unsupported on this platform");
#endif
return true;
}
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
bool CommonCLI::handleSdCardSetCmd(const char* config, char* reply) {
if (strncmp(config, "sdcard", 6) != 0 ||
@@ -3267,8 +3109,8 @@ bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) {
} else {
char used[24];
char free[24];
formatSdCardBytes(used, sizeof(used), used_bytes);
formatSdCardBytes(free, sizeof(free), free_bytes);
mesh::cli::formatStorageBytes(used, sizeof(used), used_bytes);
mesh::cli::formatStorageBytes(free, sizeof(free), free_bytes);
snprintf(reply, 160, "> used: %s, free: %s", used, free);
}
} else if (strncmp(query, "ls", 2) == 0 || strncmp(query, "dir", 3) == 0) {
@@ -4566,7 +4408,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
#endif
// Observer/MQTT/WiFi/timezone/alert/SNMP commands live in CommonCLI_Observer.cpp.
if (handleObserverGetCmd(sender_timestamp, config, reply)) return;
if (handleStorageLayoutGetCmd(config, reply)) return;
if (mesh::cli::handleStorageLayoutGet(config, *_board, reply, 160)) return;
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
if (handleSdCardGetCmd(config, reply)) return;
#endif
-1
View File
@@ -754,7 +754,6 @@ class CommonCLI {
void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply);
void handleSetCmd(uint32_t sender_timestamp, char* command, char* reply);
void handleDelCmd(char* command, char* reply);
bool handleStorageLayoutGetCmd(const char* config, char* reply);
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
bool handleSdCardGetCmd(const char* config, char* reply);
bool handleSdCardSetCmd(const char* config, char* reply);
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <stdint.h>
namespace mesh {
namespace wifi {
// Runtime context supplied when shared WebConfig code describes a Companion-
// owned station. A stopped WebConfig session does not mean that station is off.
struct CompanionWiFiRuntimeState {
bool requested;
bool active;
bool credential_reload_pending;
};
enum class CompanionWiFiFallbackState : uint8_t {
OffDisabled,
Inactive,
ReconnectScheduled,
ConnectingOrRetrying,
};
inline CompanionWiFiFallbackState classifyCompanionWiFiFallback(
const CompanionWiFiRuntimeState& state) {
if (!state.requested) return CompanionWiFiFallbackState::OffDisabled;
if (!state.active) return CompanionWiFiFallbackState::Inactive;
if (state.credential_reload_pending) {
return CompanionWiFiFallbackState::ReconnectScheduled;
}
return CompanionWiFiFallbackState::ConnectingOrRetrying;
}
} // namespace wifi
} // namespace mesh
+162
View File
@@ -0,0 +1,162 @@
#include "StorageLayout.h"
#if defined(ESP32_PLATFORM)
#include <Arduino.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#elif defined(NRF52_PLATFORM)
#include <stdarg.h>
#include <nrf.h>
#include "ota/OtaFlashLayout_nrf52.h"
#elif defined(STM32_PLATFORM)
#include <InternalFileSystem.h>
#endif
#if defined(ENABLE_OTA)
#include "ota/OtaContext.h"
#endif
namespace mesh {
namespace cli {
#if defined(NRF52_PLATFORM)
static void appendStorageLayout(char* reply, size_t reply_size,
const char* format, ...) {
if (reply == nullptr || reply_size == 0) return;
const size_t used = strlen(reply);
if (used >= reply_size) return;
va_list args;
va_start(args, format);
vsnprintf(reply + used, reply_size - used, format, args);
va_end(args);
}
#endif
void formatStorageLayout(MainBoard& board, char* reply, size_t reply_size) {
if (reply == nullptr || reply_size == 0) return;
reply[0] = 0;
#if defined(ESP32_PLATFORM)
(void)board;
Esp32StorageLayoutWriter writer(reply, reply_size,
ESP.getFlashChipSize());
const esp_partition_t* running = esp_ota_get_running_partition();
esp_partition_iterator_t iterator = esp_partition_find(
ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL);
while (iterator != NULL) {
const esp_partition_t* partition = esp_partition_get(iterator);
if (!writer.append(
partition->label, partition->address, partition->size,
running != NULL && partition->address == running->address)) {
break;
}
iterator = esp_partition_next(iterator);
}
if (iterator != NULL) esp_partition_iterator_release(iterator);
writer.finish();
#elif defined(NRF52_PLATFORM)
const uint32_t flash_bytes =
(uint32_t)NRF_FICR->CODEPAGESIZE * (uint32_t)NRF_FICR->CODESIZE;
const uint32_t app_start = mesh::ota::mota_nrf52_app_base();
const uint32_t app_end =
(uint32_t)(uintptr_t)mesh::ota::__flash_arduino_end;
#if defined(NRF52840_XXAA)
const uint32_t internal_fs_start = 0x000ED000UL;
#else
const uint32_t internal_fs_start = 0x0006D000UL;
#endif
const uint32_t internal_fs_size = 7UL * 4096UL;
snprintf(reply, reply_size,
"> int:nrf52=%luK app=0x%lX-0x%lX ifs=0x%lX+%luK",
(unsigned long)(flash_bytes / 1024UL),
(unsigned long)app_start, (unsigned long)app_end,
(unsigned long)internal_fs_start,
(unsigned long)(internal_fs_size / 1024UL));
#if defined(ENABLE_OTA) && defined(OTA_QSPI_STORE)
(void)board;
mesh::ota::OtaStoreQspiNrf52& store = mesh::ota::ota_ctx().fetch_store;
const uint32_t capacity = store.capacity();
if (capacity != 0) {
appendStorageLayout(reply, reply_size,
"; ext:qspi=%luK owner=ota-raw id=%06lX",
(unsigned long)(capacity / 1024UL),
(unsigned long)store.jedec_id());
} else {
appendStorageLayout(reply, reply_size, "; ext:qspi=error(%s)",
store.last_error());
}
#elif defined(ENABLE_OTA) && defined(OTA_SD_STORE)
uint64_t used_bytes = 0;
uint64_t free_bytes = 0;
mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().fetch_store;
if (store.getSpace(board, used_bytes, free_bytes)) {
char total[24];
char used[24];
char free[24];
formatStorageBytes(total, sizeof(total), used_bytes + free_bytes);
formatStorageBytes(used, sizeof(used), used_bytes);
formatStorageBytes(free, sizeof(free), free_bytes);
appendStorageLayout(reply, reply_size,
"; ext:sd=%s used=%s free=%s owner=ota", total,
used, free);
} else {
appendStorageLayout(reply, reply_size, "; ext:sd=error(%s)",
store.last_error());
}
#elif defined(QSPIFLASH)
(void)board;
appendStorageLayout(reply, reply_size,
"; ext:qspi=configured owner=littlefs");
#else
(void)board;
appendStorageLayout(reply, reply_size, "; ext:none");
#endif
#elif defined(RP2040_PLATFORM)
(void)board;
#if defined(PICO_FLASH_SIZE_BYTES) && defined(FS_START) && defined(FS_END)
snprintf(reply, reply_size,
"> int:rp2040=%luK ifs=%luK layout=fixed; ext:none",
(unsigned long)(PICO_FLASH_SIZE_BYTES / 1024UL),
(unsigned long)(((uintptr_t)FS_END - (uintptr_t)FS_START) /
1024UL));
#elif defined(PICO_FLASH_SIZE_BYTES)
snprintf(reply, reply_size,
"> int:rp2040=%luK layout=fixed; ext:none",
(unsigned long)(PICO_FLASH_SIZE_BYTES / 1024UL));
#else
snprintf(reply, reply_size, "> int:rp2040 layout=fixed; ext:none");
#endif
#elif defined(STM32_PLATFORM)
(void)board;
snprintf(reply, reply_size,
"> int:stm32=%luK ifs=0x%lX+%luK layout=fixed; ext:none",
(unsigned long)((FLASH_END_ADDR - FLASH_BASE + 1UL) / 1024UL),
(unsigned long)LFS_FLASH_ADDR_BASE,
(unsigned long)(LFS_FLASH_TOTAL_SIZE / 1024UL));
#else
(void)board;
snprintf(reply, reply_size,
"Error: storage layout unsupported on this platform");
#endif
}
bool handleStorageLayoutGet(const char* config, MainBoard& board, char* reply,
size_t reply_size) {
const StorageLayoutGetMatch match = classifyStorageLayoutGet(config);
if (match == StorageLayoutGetMatch::NotMatched) return false;
if (reply == nullptr || reply_size == 0) return true;
if (match == StorageLayoutGetMatch::InvalidArguments) {
snprintf(reply, reply_size, "Error: use get storage.layout");
} else {
formatStorageLayout(board, reply, reply_size);
}
return true;
}
} // namespace cli
} // namespace mesh
+126
View File
@@ -0,0 +1,126 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
namespace mesh {
class MainBoard;
namespace cli {
enum class StorageLayoutGetMatch : uint8_t {
NotMatched = 0,
Valid,
InvalidArguments,
};
// `config` is the portion following "get ". Keep recognition independent of
// any role's CLI wrapper so CommonCLI and Companion command 0x42 expose the
// same diagnostic and the same malformed-command response.
inline StorageLayoutGetMatch classifyStorageLayoutGet(const char* config) {
static const char key[] = "storage.layout";
const size_t key_len = sizeof(key) - 1;
if (config == nullptr || strncmp(config, key, key_len) != 0 ||
(config[key_len] != 0 && config[key_len] != ' ' &&
config[key_len] != '\t')) {
return StorageLayoutGetMatch::NotMatched;
}
return config[key_len] == 0 ? StorageLayoutGetMatch::Valid
: StorageLayoutGetMatch::InvalidArguments;
}
inline void formatStorageBytes(char* out, size_t out_len, uint64_t bytes) {
if (out == nullptr || out_len == 0) return;
static const char* const units[] = {"B", "KiB", "MiB", "GiB", "TiB"};
uint64_t unit_size = 1;
uint8_t unit = 0;
while (unit < 4 && bytes >= unit_size * 1024ULL) {
unit_size *= 1024ULL;
unit++;
}
if (unit == 0) {
snprintf(out, out_len, "%lu B", (unsigned long)bytes);
return;
}
uint64_t whole = bytes / unit_size;
uint32_t tenths =
(uint32_t)(((bytes % unit_size) * 10ULL + unit_size / 2ULL) /
unit_size);
if (tenths == 10) {
whole++;
tenths = 0;
}
snprintf(out, out_len, "%lu.%lu %s", (unsigned long)whole,
(unsigned long)tenths, units[unit]);
}
// The hardware-facing formatter walks ESP-IDF's live partition iterator. This
// small writer keeps its bounded text policy host-testable without mocking IDF.
class Esp32StorageLayoutWriter {
public:
Esp32StorageLayoutWriter(char* out, size_t out_len, uint32_t flash_bytes)
: _out(out), _out_len(out_len), _has_partition(false),
_truncated(false) {
if (_out == nullptr || _out_len == 0) return;
snprintf(_out, _out_len, "> int:esp32=%luK ext:none; ",
(unsigned long)(flash_bytes / 1024UL));
}
bool append(const char* label, uint32_t address, uint32_t size,
bool running) {
if (_out == nullptr || _out_len == 0 || _truncated) return false;
char entry[48];
snprintf(entry, sizeof(entry), "%s%s%s@0x%lX+%luK",
_has_partition ? "," : "", label != nullptr ? label : "",
running ? "*" : "", (unsigned long)address,
(unsigned long)(size / 1024UL));
const size_t used = strlen(_out);
const size_t entry_len = strlen(entry);
// Preserve one spare byte beyond the terminator, matching the original
// CommonCLI response policy and leaving room for an ellipsis when possible.
if (used + entry_len + 1 >= _out_len) {
_truncated = true;
return false;
}
memcpy(_out + used, entry, entry_len + 1);
_has_partition = true;
return true;
}
void finish() {
if (_out == nullptr || _out_len == 0) return;
const size_t used = strlen(_out);
if (!_has_partition) {
if (used < _out_len) {
snprintf(_out + used, _out_len - used, "partitions=none");
}
} else if (_truncated && used + 4 < _out_len) {
memcpy(_out + used, ",...", 5);
}
}
bool truncated() const { return _truncated; }
private:
char* _out;
size_t _out_len;
bool _has_partition;
bool _truncated;
};
void formatStorageLayout(MainBoard& board, char* reply, size_t reply_size);
// Returns false only when `config` is not this command. Recognized malformed
// requests are consumed and receive the canonical usage response.
bool handleStorageLayoutGet(const char* config, MainBoard& board, char* reply,
size_t reply_size);
} // namespace cli
} // namespace mesh
+175
View File
@@ -1,5 +1,6 @@
#pragma once
#include <atomic>
#include <stdint.h>
#include <string.h>
@@ -40,6 +41,180 @@ inline bool isUsbMotaOwnerTransitionCommand(const char* command) {
&& isUsbMotaOwnerTransitionCommand(command, strlen(command));
}
// A deliberate HWCDC detach can produce a burst of BUS_RESET callbacks while
// the host enumerates the clean transport. Keep ignoring that whole burst until
// actual post-clean RX/TX activity proves that a new session has begun. Unlike
// a one-shot boolean exchange, repeated reset callbacks do not consume the
// clean-transport proof and cannot start a detach/reset loop.
class UsbSelfResetBurstGuard {
std::atomic<bool> _clean_transport_waiting_for_activity{false};
public:
void expectSelfResetBurst() {
_clean_transport_waiting_for_activity.store(
true, std::memory_order_release);
}
bool shouldIgnoreBusReset() const {
return _clean_transport_waiting_for_activity.load(
std::memory_order_acquire);
}
void notePostCleanActivity() {
_clean_transport_waiting_for_activity.store(
false, std::memory_order_release);
}
};
// HWCDC exposes a physical/SOF host signal but no DTR/open state. Its framework
// filter is only a few milliseconds, so apply a second, meaningful debounce
// before reporting a transport boundary. The response route retains its longer
// grace independently, and sustained loss remains a conservative fallback.
class UsbHostPresenceDebouncer {
bool _host_seen = false;
bool _physical_host_seen = false;
bool _loss_observed = false;
bool _host_loss_edge_reported = false;
bool _host_loss_edge_pending = false;
bool _sustained_loss_pending = false;
bool _completed_frame_seen = false;
bool _finite_reply_was_pending = false;
bool _finite_reply_drain_active = false;
uint32_t _loss_observed_at = 0;
uint32_t _last_completed_frame_count = 0;
uint32_t _finite_reply_drain_started_at = 0;
bool retainFiniteReplyLease(bool pending, uint32_t now,
uint32_t drain_grace_ms) {
if (pending) {
_finite_reply_was_pending = true;
_finite_reply_drain_active = false;
return true;
}
if (_finite_reply_was_pending) {
_finite_reply_was_pending = false;
_finite_reply_drain_active = true;
_finite_reply_drain_started_at = now;
}
if (!_finite_reply_drain_active) return false;
if ((uint32_t)(now - _finite_reply_drain_started_at) < drain_grace_ms) {
return true;
}
_finite_reply_drain_active = false;
return false;
}
public:
bool observeHost(bool host_present, uint32_t now,
uint32_t host_loss_edge_ms,
uint32_t host_loss_grace_ms) {
if (_sustained_loss_pending) return false;
if (host_present) {
_host_seen = true;
_physical_host_seen = true;
_loss_observed = false;
_host_loss_edge_reported = false;
return true;
}
if (!_host_seen) return false;
if (!_loss_observed) {
_loss_observed = true;
_loss_observed_at = now;
return true;
}
const uint32_t loss_age = (uint32_t)(now - _loss_observed_at);
if (!_host_loss_edge_reported && _physical_host_seen
&& loss_age >= host_loss_edge_ms) {
// Keep this shorter than the response-affinity grace, but long enough to
// reject the framework's transient five-millisecond SOF gaps. This still
// recovers an actual unplug/re-enumeration when its BUS_RESET event was
// dropped by the framework's finite event queue.
_host_loss_edge_reported = true;
_host_loss_edge_pending = true;
}
if (loss_age < host_loss_grace_ms) {
return true;
}
_host_seen = false;
_physical_host_seen = false;
_loss_observed = false;
_sustained_loss_pending = true;
return false;
}
void noteClientActivity() {
if (_sustained_loss_pending) return;
_host_seen = true;
_loss_observed = false;
}
bool isClientConnected(bool host_present, uint32_t now,
bool has_received_frame,
uint32_t last_frame_at,
uint32_t completed_frame_count,
uint32_t frame_reply_grace_ms,
uint32_t client_idle_timeout_ms,
uint32_t host_loss_edge_ms,
uint32_t host_loss_grace_ms,
bool finite_reply_pending) {
// A newly completed Binary frame proves a host session even when HWCDC's
// SOF signal was already false. Arm loss tracking once per frame count;
// doing this on every poll would continually restart the debounce timer.
if (has_received_frame
&& (!_completed_frame_seen
|| completed_frame_count != _last_completed_frame_count)) {
_completed_frame_seen = true;
_last_completed_frame_count = completed_frame_count;
noteClientActivity();
}
// Sample the physical signal even during the immediate-frame lease so a
// delayed operation has a fresh last-positive timestamp to rely on.
const bool host_recent =
observeHost(host_present, now, host_loss_edge_ms,
host_loss_grace_ms);
if (_sustained_loss_pending) return false;
if (!has_received_frame) return false;
const uint32_t frame_age = (uint32_t)(now - last_frame_at);
// Only a bounded, already-routed operation may extend the absolute idle
// cap. Its true->false transition grants one short drain window for the
// final frame which was queued just before the operation cleared itself.
const bool finite_reply_lease = retainFiniteReplyLease(
finite_reply_pending, now, frame_reply_grace_ms);
if (frame_age < frame_reply_grace_ms) return true;
return host_recent
&& (frame_age < client_idle_timeout_ms || finite_reply_lease);
}
bool takeSustainedHostLoss() {
const bool pending = _sustained_loss_pending;
_sustained_loss_pending = false;
return pending;
}
bool takeHostLossEdge() {
const bool pending = _host_loss_edge_pending;
_host_loss_edge_pending = false;
return pending;
}
void reset() {
_host_seen = false;
_physical_host_seen = false;
_loss_observed = false;
_host_loss_edge_reported = false;
_host_loss_edge_pending = false;
_sustained_loss_pending = false;
_completed_frame_seen = false;
_finite_reply_was_pending = false;
_finite_reply_drain_active = false;
_loss_observed_at = 0;
_last_completed_frame_count = 0;
_finite_reply_drain_started_at = 0;
}
};
// Coordinates the Full Companion USB startup handoff without consuming the
// '<' byte. ArduinoSerialInterface remains the only binary protocol parser.
class UsbBinaryStartupProbe {
+330 -6
View File
@@ -4,9 +4,22 @@
#include <Arduino.h>
#include <atomic>
#if defined(NRF52_PLATFORM)
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \
&& defined(ENABLE_USB_INTERFACE)
#define MESH_ESP32_HWCDC_SESSION_GUARD 1
#include "esp_idf_version.h"
#include "hal/usb_serial_jtag_ll.h"
#else
#define MESH_ESP32_HWCDC_SESSION_GUARD 0
#endif
#if defined(NRF52_PLATFORM) || MESH_ESP32_HWCDC_SESSION_GUARD
#include "NonBlockingWriteStream.h"
#endif
#if MESH_ESP32_HWCDC_SESSION_GUARD
#include "UsbAsciiBinarySwitch.h"
#endif
#if defined(NRF52_PLATFORM) && \
(defined(ENABLE_USB_INTERFACE) || defined(OTA_FOLDER_SERIAL))
@@ -59,6 +72,156 @@ class NullUsbLoggingStream : public Stream {
static NullUsbLoggingStream null_usb_logging_stream;
#if MESH_ESP32_HWCDC_SESSION_GUARD
// Every primary HWCDC role shares one producer gate. In particular, returning
// this facade from usbLoggingPort() means a task which cached its Stream& before
// a disconnect still checks the current session at the actual write boundary.
static std::atomic<uint32_t> esp32_hwcdc_access_generation{0};
static std::atomic<uint32_t> esp32_hwcdc_allowed_generation{0};
static std::atomic<uint32_t> esp32_hwcdc_bus_reset_generation{0};
static UsbSelfResetBurstGuard esp32_hwcdc_self_reset_guard;
// HWCDC::write() retains bytes in its software ring when the framework's raw
// five-millisecond SOF detector happens to report false. In that path the core
// does not leave SERIAL_IN_EMPTY enabled, so a valid Companion response can
// remain parked until a later request happens to kick TX. Keep requesting that
// interrupt until the framework reports actual TX progress.
static std::atomic<bool> esp32_hwcdc_tx_kick_pending{false};
static portMUX_TYPE esp32_hwcdc_session_mux = portMUX_INITIALIZER_UNLOCKED;
static uint32_t esp32_hwcdc_taken_bus_reset_generation = 0;
static bool esp32_hwcdc_event_handler_registered = false;
static std::atomic<size_t> esp32_hwcdc_tx_buffer_capacity{
MESH_ESP32_USB_TX_BUFFER_SIZE};
// A busy writer or temporary allocation failure may require more than one main
// loop to purge. Keep the original detach state/generation across retries so a
// failed attempt never reattaches stale bytes or turns into a reboot loop.
static bool esp32_hwcdc_cleanup_pending = false;
static bool esp32_hwcdc_restore_pad_enabled = false;
static uint32_t esp32_hwcdc_cleanup_generation = 0;
static bool canAccessEsp32Hwcdc(void*) {
return esp32_hwcdc_allowed_generation.load(std::memory_order_acquire)
== esp32_hwcdc_access_generation.load(std::memory_order_acquire);
}
static void handleEsp32HwcdcEvent(void*, esp_event_base_t, int32_t event_id,
void*) {
if (event_id == ARDUINO_HW_CDC_BUS_RESET_EVENT) {
// A host may issue several reset requests while enumerating the clean
// transport. Suppress the complete burst; post-clean traffic below ends
// the exemption before any later active-session reset can be ignored.
if (esp32_hwcdc_self_reset_guard.shouldIgnoreBusReset()) {
return;
}
// Quarantine reads and writes before publishing the boundary to the main
// loop. If this callback lands just after that loop sampled the generation,
// no old-session parser or producer can run during the intervening pass.
portENTER_CRITICAL(&esp32_hwcdc_session_mux);
esp32_hwcdc_access_generation.fetch_add(
1, std::memory_order_acq_rel);
esp32_hwcdc_tx_kick_pending.store(false, std::memory_order_release);
usb_serial_jtag_ll_disable_intr_mask(
USB_SERIAL_JTAG_INTR_SERIAL_IN_EMPTY);
portEXIT_CRITICAL(&esp32_hwcdc_session_mux);
Serial.setDebugOutput(false);
esp32_hwcdc_bus_reset_generation.fetch_add(
1, std::memory_order_acq_rel);
return;
}
if (event_id == ARDUINO_HW_CDC_RX_EVENT
|| event_id == ARDUINO_HW_CDC_TX_EVENT) {
// If no self-reset event was delivered, post-clean traffic proves that the
// expected enumeration is over. A subsequent reset must not be ignored.
esp32_hwcdc_self_reset_guard.notePostCleanActivity();
}
}
class Esp32HwcdcSessionStream : public Stream {
public:
int available() override {
const int count = Serial.available();
if (count > 0) noteActivity();
return count;
}
int read() override {
const int value = Serial.read();
if (value >= 0) noteActivity();
return value;
}
int peek() override {
const int value = Serial.peek();
if (value >= 0) noteActivity();
return value;
}
void flush() override { Serial.flush(); }
int availableForWrite() override { return Serial.availableForWrite(); }
size_t write(uint8_t value) override { return write(&value, 1); }
size_t write(const uint8_t* data, size_t size) override {
if (data == nullptr || size == 0) return 0;
esp32_hwcdc_tx_kick_pending.store(true, std::memory_order_release);
return Serial.write(data, size);
}
private:
static void noteActivity() {
esp32_hwcdc_self_reset_guard.notePostCleanActivity();
}
};
static Esp32HwcdcSessionStream esp32_hwcdc_session_stream;
static size_t writeEsp32HwcdcOnce(void*, const uint8_t* data, size_t size) {
return esp32_hwcdc_session_stream.write(data, size);
}
static SingleAttemptNonBlockingStream guarded_esp32_hwcdc_port(
esp32_hwcdc_session_stream, writeEsp32HwcdcOnce, nullptr,
canAccessEsp32Hwcdc);
static AtomicWholeRecordNonBlockingStream<11>
guarded_esp32_hwcdc_mota_port(guarded_esp32_hwcdc_port);
static void serviceEsp32HwcdcTxKickExclusive(void*) {
if (!esp32_hwcdc_tx_kick_pending.load(std::memory_order_acquire)
|| !canAccessEsp32Hwcdc(nullptr)) {
return;
}
// Unlike the asynchronous TX event queue, the ring's free capacity is
// direct proof that every tracked byte has left HWCDC's software queue.
// Sample it while the shared producer gate excludes every MeshCore writer.
const size_t tx_capacity = esp32_hwcdc_tx_buffer_capacity.load(
std::memory_order_acquire);
if (tx_capacity != 0 && Serial.availableForWrite() >= tx_capacity) {
esp32_hwcdc_tx_kick_pending.store(false, std::memory_order_release);
return;
}
if (!Serial.isPlugged()) return;
// Match the framework's normal connected-write kick. Serialize the final
// generation check and register writes with BUS_RESET quarantine so a stale
// service pass cannot re-enable TX after the session has been closed.
portENTER_CRITICAL(&esp32_hwcdc_session_mux);
if (esp32_hwcdc_tx_kick_pending.load(std::memory_order_acquire)
&& canAccessEsp32Hwcdc(nullptr) && Serial.isPlugged()) {
usb_serial_jtag_ll_txfifo_flush();
usb_serial_jtag_ll_ena_intr_mask(
USB_SERIAL_JTAG_INTR_SERIAL_IN_EMPTY);
}
portEXIT_CRITICAL(&esp32_hwcdc_session_mux);
}
static void serviceEsp32HwcdcTxKick() {
if (!esp32_hwcdc_tx_kick_pending.load(std::memory_order_acquire)) return;
(void)guarded_esp32_hwcdc_port.tryRunExclusive(
serviceEsp32HwcdcTxKickExclusive);
}
#endif
#if MESH_NRF52_PRIMARY_USB_NONBLOCKING
#if defined(ENABLE_USB_INTERFACE)
// Writers/readers may access CDC0 only when DTR is high and the application has
@@ -156,9 +319,17 @@ static uint32_t primary_usb_terminal_taken_reset_generation = 0;
static void setPlatformDebugOutputEnabled(bool enabled) {
#if defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE)
// Arduino-ESP32 log_e()/ESP-IDF diagnostics otherwise write straight to
// the same UART/CDC stream used by Binary Companion. Keep that low-level
// route under the same runtime gate as MeshCore's own diagnostics.
// the same UART/CDC stream used by Binary Companion.
#if MESH_ESP32_HWCDC_SESSION_GUARD
// HWCDC's framework putc hook bypasses the guarded Stream and cannot make
// its check-plus-write atomic with a BUS_RESET callback. Keep that raw route
// disabled on a shared protocol port; MeshCore diagnostics still use the
// guarded usbLoggingPort() below.
(void)enabled;
Serial.setDebugOutput(false);
#else
Serial.setDebugOutput(enabled);
#endif
#else
(void)enabled;
#endif
@@ -388,12 +559,26 @@ bool saveUsbLoggingBootPreference(bool enabled) {
return true;
}
void setUsbCompanionTxBufferCapacity(size_t capacity) {
#if MESH_ESP32_HWCDC_SESSION_GUARD
esp32_hwcdc_tx_buffer_capacity.store(capacity, std::memory_order_release);
#else
(void)capacity;
#endif
}
void beginUsbLoggingPort() {
// setup() calls this once before role preferences are loaded and again
// afterwards. The first call silences framework diagnostics on a protected
// ESP32 Companion stream; setUsbLoggingEnabled() restores them only when the
// saved setting explicitly enables logging.
setPlatformDebugOutputEnabled(isUsbLoggingEnabled());
#if MESH_ESP32_HWCDC_SESSION_GUARD
if (!esp32_hwcdc_event_handler_registered) {
Serial.onEvent(ARDUINO_HW_CDC_ANY_EVENT, handleEsp32HwcdcEvent);
esp32_hwcdc_event_handler_registered = true;
}
#endif
#if defined(MESH_DUAL_CDC_LOGGING)
if (dedicated_usb_logging_port_started
|| !usb_logging_preference_known.load(std::memory_order_relaxed)
@@ -459,6 +644,129 @@ void serviceUsbLoggingPort() {
#endif
}
struct Esp32HwcdcPurgeResult {
bool tx_empty = false;
};
#if MESH_ESP32_HWCDC_SESSION_GUARD
static bool detachEsp32HwcdcPads() {
#if ESP_IDF_VERSION_MAJOR >= 5
// IDF 5.x replaced the C3/S3 state-preserving light-sleep helper with the
// unified PHY query/setter pair also used by C6.
const bool enabled = usb_serial_jtag_ll_phy_is_pad_enabled();
usb_serial_jtag_ll_phy_enable_pad(false);
return enabled;
#else
// IDF 4.x (Arduino-ESP32 2.x C3/S3) supplies this combined helper.
return usb_serial_jtag_ll_pad_backup_and_disable();
#endif
}
static void restoreEsp32HwcdcPads(bool enabled) {
#if ESP_IDF_VERSION_MAJOR >= 5
usb_serial_jtag_ll_phy_enable_pad(enabled);
#else
usb_serial_jtag_ll_enable_pad(enabled);
#endif
}
static void purgeEsp32HwcdcQueues(void* opaque) {
Esp32HwcdcPurgeResult* result =
static_cast<Esp32HwcdcPurgeResult*>(opaque);
size_t tx_capacity = esp32_hwcdc_tx_buffer_capacity.load(
std::memory_order_acquire);
if (tx_capacity == 0) {
// Never replace HWCDC's ring after begin() has enabled its ISR. Without a
// known empty capacity we cannot prove the old session was purged, so keep
// the transport quarantined and let the caller retry at a bounded rate.
while (Serial.read() >= 0) {}
return;
}
const uint32_t purge_started = millis();
uint8_t flush_attempts = 0;
do {
// The bundled HWCDC flush removes only one contiguous BYTEBUF item, and a
// busy TX mutex makes it return without a result. Require at least two
// passes, then verify the configured empty capacity under a deadline.
Serial.flush();
++flush_attempts;
result->tx_empty = flush_attempts >= 2 && tx_capacity != 0
&& Serial.availableForWrite() >= tx_capacity;
if (!result->tx_empty) delay(1);
} while (!result->tx_empty
&& (uint32_t)(millis() - purge_started) < 100U);
while (Serial.read() >= 0) {}
}
#endif
bool resetUsbCompanionTransport() {
#if MESH_ESP32_HWCDC_SESSION_GUARD
// HWCDC owns RTOS queues, a TX mutex, an ISR, and an event task. Calling
// end()/begin() here can delete those objects while a WiFi/MQTT/diagnostic
// producer is writing. Close the independent transport gate first. Runtime
// logging changes remain preferences while the low-level debug route is
// forced off by that gate.
if (!esp32_hwcdc_cleanup_pending) {
portENTER_CRITICAL(&esp32_hwcdc_session_mux);
esp32_hwcdc_cleanup_generation =
esp32_hwcdc_access_generation.fetch_add(
1, std::memory_order_acq_rel) + 1U;
esp32_hwcdc_tx_kick_pending.store(false, std::memory_order_release);
usb_serial_jtag_ll_disable_intr_mask(
USB_SERIAL_JTAG_INTR_SERIAL_IN_EMPTY);
portEXIT_CRITICAL(&esp32_hwcdc_session_mux);
setPlatformDebugOutputEnabled(false);
esp32_hwcdc_restore_pad_enabled = detachEsp32HwcdcPads();
esp32_hwcdc_cleanup_pending = true;
// Always hold a real host-visible detach interval. The SOF tracker may
// already be false when a physical-loss edge reaches the main loop.
delay(10);
const uint32_t detached_at = millis();
while (Serial.isPlugged()
&& (uint32_t)(millis() - detached_at) < 50U) {
delay(1);
}
}
// tryRunExclusive() shares the same writer guard as all primary Stream
// facades. Retry briefly if a producer entered Serial.write() just before
// the access gate closed; HWCDC writes are capped at five milliseconds.
Esp32HwcdcPurgeResult result;
const uint32_t writer_deadline = millis();
bool ran_exclusive = false;
do {
ran_exclusive = guarded_esp32_hwcdc_port.tryRunExclusive(
purgeEsp32HwcdcQueues, &result);
if (!ran_exclusive) delay(1);
} while (!ran_exclusive
&& (uint32_t)(millis() - writer_deadline) < 50U);
if (!ran_exclusive || !result.tx_empty) {
// Leave the PHY and producer gate closed. The caller retries from a later
// loop; rebooting here can create an enumeration watchdog/restart cycle.
return false;
}
esp32_hwcdc_self_reset_guard.expectSelfResetBurst();
restoreEsp32HwcdcPads(esp32_hwcdc_restore_pad_enabled);
// Publish only the epoch actually purged. If another BUS_RESET arrived
// during cleanup its newer generation remains quarantined for the next pass.
portENTER_CRITICAL(&esp32_hwcdc_session_mux);
esp32_hwcdc_tx_kick_pending.store(false, std::memory_order_release);
esp32_hwcdc_allowed_generation.store(
esp32_hwcdc_cleanup_generation, std::memory_order_release);
portEXIT_CRITICAL(&esp32_hwcdc_session_mux);
esp32_hwcdc_cleanup_pending = false;
// A concurrent runtime preference change is authoritative; never restore a
// stale snapshot taken before the purge.
setPlatformDebugOutputEnabled(isUsbLoggingEnabled());
#endif
return true;
}
Stream& usbLoggingPort() {
#if defined(MESH_DUAL_CDC_LOGGING)
if (isUsbLoggingEnabled() && dedicated_usb_logging_port_started
@@ -468,7 +776,9 @@ Stream& usbLoggingPort() {
return null_usb_logging_stream;
#else
if (!isUsbLoggingEnabled()) return null_usb_logging_stream;
#if defined(NRF52_PLATFORM)
#if MESH_ESP32_HWCDC_SESSION_GUARD
return guarded_esp32_hwcdc_port;
#elif defined(NRF52_PLATFORM)
return nonblocking_primary_usb_logging_port;
#else
return Serial;
@@ -477,7 +787,9 @@ Stream& usbLoggingPort() {
}
Stream& usbCompanionPort() {
#if MESH_NRF52_PRIMARY_USB_NONBLOCKING
#if MESH_ESP32_HWCDC_SESSION_GUARD
return guarded_esp32_hwcdc_port;
#elif MESH_NRF52_PRIMARY_USB_NONBLOCKING
return nonblocking_primary_usb_companion_port;
#else
return Serial;
@@ -485,7 +797,9 @@ Stream& usbCompanionPort() {
}
Stream& usbMotaPort() {
#if MESH_NRF52_PRIMARY_USB_NONBLOCKING
#if MESH_ESP32_HWCDC_SESSION_GUARD
return guarded_esp32_hwcdc_mota_port;
#elif MESH_NRF52_PRIMARY_USB_NONBLOCKING
return nonblocking_primary_usb_mota_port;
#else
return usbCompanionPort();
@@ -509,6 +823,8 @@ void serviceUsbTerminalPort() {
primary_usb_terminal_seen_reset_generation = reset_generation;
}
buffered_primary_usb_terminal_port.service();
#elif MESH_ESP32_HWCDC_SESSION_GUARD
serviceEsp32HwcdcTxKick();
#endif
}
@@ -535,6 +851,14 @@ bool takeUsbTerminalSessionReset() {
}
primary_usb_terminal_taken_reset_generation = reset_generation;
return true;
#elif MESH_ESP32_HWCDC_SESSION_GUARD && defined(ENABLE_USB_INTERFACE)
const uint32_t reset_generation =
esp32_hwcdc_bus_reset_generation.load(std::memory_order_acquire);
if (reset_generation == esp32_hwcdc_taken_bus_reset_generation) {
return false;
}
esp32_hwcdc_taken_bus_reset_generation = reset_generation;
return true;
#else
return false;
#endif
+18 -3
View File
@@ -16,6 +16,10 @@
namespace mesh {
#ifndef MESH_ESP32_USB_TX_BUFFER_SIZE
#define MESH_ESP32_USB_TX_BUFFER_SIZE 4096
#endif
namespace detail {
// Keep the identity-marker reconnect rule pure so host tests can exercise the
@@ -40,6 +44,11 @@ void setUsbLoggingEnabled(bool enabled);
// the normal role preferences.
bool saveUsbLoggingBootPreference(bool enabled);
// Record the HWCDC TX ring size actually allocated during early setup. A zero
// value keeps reset cleanup quarantined and eligible for a minimum-size retry
// instead of mistaking an allocation failure for a permanently non-empty ring.
void setUsbCompanionTxBufferCapacity(size_t capacity);
// Start the optional dedicated USB logging interface. Ordinary and single-TTY
// builds use Serial; nRF52 Full Companion uses a second CDC ACM port.
void beginUsbLoggingPort();
@@ -66,14 +75,20 @@ Stream& usbTerminalPort();
void serviceUsbTerminalPort();
void discardUsbTerminalOutput();
bool hasPendingUsbTerminalOutput();
// Consume an exact CDC0 DTR-low edge captured in TinyUSB's owner task. This is
// separate from polling the current DTR value: Windows can close and reopen a
// COM handle between two application-loop iterations.
// Consume a primary-USB session boundary reported by the USB owner task:
// CDC0 DTR-low on nRF52, or a hardware CDC bus reset on ESP32. This is
// independent of polling current line/SOF state; ESP32 retains that poll as a
// fallback because its bundled framework event queue is finite.
bool takeUsbTerminalSessionReset();
// After the application has reset its protocol state, atomically purge any
// CDC0 writer that raced the close callback and reopen the producer gate. A
// false result is a zero-wait busy indication; retry it next loop.
bool tryCompleteUsbTerminalSessionReset();
// Purge platform USB-driver bytes at a confirmed host-session boundary. On
// ESP32 HWCDC this briefly detaches the PHY and gates diagnostics so stale RX
// or TX data cannot cross into a newly enumerated host session. A false result
// leaves that quarantine intact and should be retried from a later loop.
bool resetUsbCompanionTransport();
bool hasDedicatedUsbLoggingPort();
bool isDedicatedUsbLoggingPortConfigured();
bool usbLoggingInterfaceRestartRequired();
File diff suppressed because it is too large. Load diff
+30 -1
View File
@@ -656,7 +656,9 @@ bool WebConfigServer::formatWiFiCliStatus(char* reply, size_t reply_len) {
return true;
}
bool WebConfigServer::formatWiFiStatus(char* reply, size_t reply_len) {
bool WebConfigServer::formatWiFiStatus(
char* reply, size_t reply_len,
const mesh::wifi::CompanionWiFiRuntimeState* companion_runtime) {
if (!reply || reply_len == 0) return false;
char ssid[32] = "";
@@ -700,6 +702,31 @@ bool WebConfigServer::formatWiFiStatus(char* reply, size_t reply_len) {
return true;
}
// Companion owns its station independently of the WebConfig lifecycle. Once
// WebConfig stops, MODE_OFF cannot distinguish a disabled radio from a
// deferred or active Companion reconnect.
if (companion_runtime) {
switch (mesh::wifi::classifyCompanionWiFiFallback(*companion_runtime)) {
case mesh::wifi::CompanionWiFiFallbackState::OffDisabled:
snprintf(reply, reply_len,
"> off, WiFi disabled; configured SSID: %s", ssid);
appendOtaSeederStatus(reply, reply_len);
return true;
case mesh::wifi::CompanionWiFiFallbackState::Inactive:
snprintf(reply, reply_len,
"> inactive, WiFi enabled but services stopped; configured SSID: %s",
ssid);
appendOtaSeederStatus(reply, reply_len);
return true;
case mesh::wifi::CompanionWiFiFallbackState::ReconnectScheduled:
snprintf(reply, reply_len, "> reconnect scheduled, SSID: %s", ssid);
appendOtaSeederStatus(reply, reply_len);
return true;
case mesh::wifi::CompanionWiFiFallbackState::ConnectingOrRetrying:
break;
}
}
switch (status) {
case WL_NO_SSID_AVAIL:
snprintf(reply, reply_len, "> failed, SSID not found: %s", ssid);
@@ -713,6 +740,8 @@ bool WebConfigServer::formatWiFiStatus(char* reply, size_t reply_len) {
default:
if (active && active->_mode == MODE_LAN) {
snprintf(reply, reply_len, "> disconnected, SSID: %s", ssid);
} else if (companion_runtime) {
snprintf(reply, reply_len, "> connecting/retrying, SSID: %s", ssid);
} else {
snprintf(reply, reply_len,
"> off, configured SSID: %s; run 'start webconfig'", ssid);
+4 -1
View File
@@ -27,6 +27,7 @@
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <helpers/CompanionWiFiStatus.h>
#include <helpers/WebConfigBatch.h>
#include <helpers/WiFiPowerSave.h>
#include <helpers/WiFiReconnectPolicy.h>
@@ -183,7 +184,9 @@ public:
static bool setWiFiCliEnabled(const char* value, char* reply,
size_t reply_len);
static bool formatWiFiSSID(char* reply, size_t reply_len);
static bool formatWiFiStatus(char* reply, size_t reply_len);
static bool formatWiFiStatus(
char* reply, size_t reply_len,
const mesh::wifi::CompanionWiFiRuntimeState* companion_runtime = NULL);
static bool formatWiFiPowerSave(char* reply, size_t reply_len);
static bool formatWiFiCliStatus(char* reply, size_t reply_len);
+9 -4
View File
@@ -23,10 +23,15 @@
#if defined(OTA_FOLDER_SERIAL)
#include "MotaSourceSerial.h" // relay an external folder served by a host daemon over the USB serial
#ifndef OTA_FOLDER_SERIAL_STREAM
#if defined(NRF52_PLATFORM)
// Adafruit's CDC write waits while an open host stops reading. Every
// default nRF52 serial-folder request uses a whole-record facade over one
// TinyUSB attempt; dedicated-UART overrides retain their normal stream.
#if defined(NRF52_PLATFORM) \
|| (defined(ESP32) && defined(ARDUINO_USB_MODE) \
&& ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) \
&& ARDUINO_USB_CDC_ON_BOOT \
&& defined(ENABLE_USB_INTERFACE))
// Native USB serial-folder requests share the primary session facade.
// It prevents a host reset from racing a cached mOTA Stream reference;
// dedicated-UART overrides retain their normal stream.
#include "../UsbLogging.h"
#define OTA_FOLDER_SERIAL_STREAM ::mesh::usbMotaPort()
#define MESH_OTA_FOLDER_SERIAL_NONBLOCKING_USB 1
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class CompanionWiFiStatusTest(unittest.TestCase):
def test_runtime_fallback_classification(self):
source = r'''
#include <helpers/CompanionWiFiStatus.h>
using mesh::wifi::CompanionWiFiFallbackState;
using mesh::wifi::classifyCompanionWiFiFallback;
int main() {
if (classifyCompanionWiFiFallback({false, false, false}) !=
CompanionWiFiFallbackState::OffDisabled) return 1;
if (classifyCompanionWiFiFallback({true, false, false}) !=
CompanionWiFiFallbackState::Inactive) return 2;
if (classifyCompanionWiFiFallback({true, true, true}) !=
CompanionWiFiFallbackState::ReconnectScheduled) return 3;
if (classifyCompanionWiFiFallback({true, true, false}) !=
CompanionWiFiFallbackState::ConnectingOrRetrying) return 4;
return 0;
}
'''
with tempfile.TemporaryDirectory() as temp_dir:
executable = Path(temp_dir) / "companion_wifi_status"
compiled = subprocess.run(
[
"c++",
"-std=c++11",
f"-I{ROOT / 'src'}",
"-x",
"c++",
"-",
"-o",
str(executable),
],
input=source,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(compiled.returncode, 0, compiled.stderr)
result = subprocess.run(
[str(executable)], capture_output=True, text=True, check=False
)
self.assertEqual(result.returncode, 0, result.stderr)
def test_wifi_status_command_uses_runtime_formatter(self):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
local_start = mesh.index("bool MyMesh::handleLocalControlCommand(")
local_end = mesh.index(
"\n#if COMPANION_FEATURE_TEMP_RADIO\nvoid MyMesh::serviceTempRadio()",
local_start,
)
local = mesh[local_start:local_end]
status_case = re.search(
r"case mesh::cli::StandaloneWiFiKey::Status:(.*?)return true;",
local,
re.DOTALL,
)
self.assertIsNotNone(status_case)
self.assertIn(
"formatCompanionWiFiStatus(reply, reply_size);",
status_case.group(1),
)
self.assertNotIn(
"WebConfigServer::formatWiFiStatus", status_case.group(1)
)
def test_active_runtime_fallback_is_not_off(self):
server = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
start = server.index("bool WebConfigServer::formatWiFiStatus(")
end = server.index("\nbool WebConfigServer::startSetupMode", start)
formatter = server[start:end]
self.assertIn("classifyCompanionWiFiFallback", formatter)
self.assertIn(
'> off, WiFi disabled; configured SSID: %s', formatter
)
self.assertIn(
'> inactive, WiFi enabled but services stopped;', formatter
)
self.assertIn('> reconnect scheduled, SSID: %s', formatter)
self.assertIn('> connecting/retrying, SSID: %s', formatter)
# Existing rich setup/connecting and connected reports retain priority.
self.assertLess(
formatter.index("active->_mode == MODE_SETUP"),
formatter.index("classifyCompanionWiFiFallback"),
)
self.assertLess(
formatter.index("if (status == WL_CONNECTED)"),
formatter.index("classifyCompanionWiFiFallback"),
)
if __name__ == "__main__":
unittest.main()
+265
View File
@@ -45,6 +45,271 @@ class Esp32UsbSerialHygieneTest(unittest.TestCase):
self.assertIn('"-DARDUINO_USB_CDC_ON_BOOT=1"', board)
self.assertIn('"-DARDUINO_USB_MODE=1"', board)
def test_hwcdc_reply_route_has_completed_frame_grace(self):
main = source("examples/companion_radio/main.cpp")
start = main.index(
"// The ESP32 USB-Serial-JTAG peripheral (HWCDC)"
)
hwcdc = main[start : main.index("#elif", start)]
serial_begin = main.index("Serial.begin(115200);")
tx_buffer = main.index("Serial.setTxBufferSize(candidate);")
logging_begin = main.index("mesh::beginUsbLoggingPort();")
self.assertLess(tx_buffer, serial_begin)
self.assertLess(serial_begin, logging_begin)
self.assertIn("board.isUsbHostConnected()", hwcdc)
self.assertIn("usb_serial_interface.hasReceivedFrame()", hwcdc)
self.assertIn("USB_FRAME_REPLY_GRACE_MS", hwcdc)
self.assertIn("USB_HOST_LOSS_EDGE_MS", hwcdc)
self.assertIn("USB_HOST_LOSS_GRACE_MS", hwcdc)
self.assertIn("USB_CLIENT_IDLE_TIMEOUT", hwcdc)
self.assertIn("getCompletedFrameCount()", hwcdc)
self.assertIn(
"the_mesh.hasFiniteDelayedReplyForRoute(&usb_serial_interface)",
hwcdc,
)
self.assertNotIn("hasSerialOperationForRoute", hwcdc)
self.assertNotIn("isReplyRouteLockedFor", hwcdc)
self.assertNotIn("hasPendingWork()", hwcdc)
self.assertNotIn("_iter_started", hwcdc)
self.assertNotRegex(hwcdc, r"\(bool\)\s*Serial")
def test_finite_usb_lease_covers_only_bounded_reply_families(self):
mesh = source("examples/companion_radio/MyMesh.cpp")
start = mesh.index("bool MyMesh::hasFiniteDelayedReplyForRoute(")
body = mesh[start : mesh.index("void MyMesh::servicePendingSerialReply()", start)]
self.assertIn("pending_serial_reply_route == route", body)
self.assertIn("command_radio_reply_route == route", body)
self.assertIn("binary_trace_reply_route == route", body)
self.assertIn("expected_ack_table[i].reply_route == route", body)
self.assertNotIn("_iter_started", body)
self.assertNotIn("lockReplyRoute", body)
self.assertNotIn("hasPendingWork", body)
self.assertNotIn("sign_data_reply_route", body)
def test_hwcdc_sustained_loss_resets_session_state(self):
main = source("examples/companion_radio/main.cpp")
start = main.index("static void serviceUsbTerminalHostSessionReset()")
service = main[start : main.index("static void serviceUsbTerminal()", start)]
reset_start = main.index("static void resetUsbTerminalHostSession(")
reset = main[reset_start:start]
self.assertIn("takeSustainedHostLoss()", service)
self.assertIn("takeHostLossEdge()", service)
self.assertIn("mesh::takeUsbTerminalSessionReset()", service)
self.assertIn(
"hardware_bus_reset || physical_host_loss || sustained_host_loss",
service,
)
self.assertIn("usb_terminal_host_reset_completion_pending", service)
self.assertIn("USB_TRANSPORT_RESET_RETRY_MS", service)
self.assertIn("resetUsbTerminalHostSession(true);", service)
self.assertIn("cancelUsbSerialOperations();", reset)
self.assertIn("usb_serial_interface.resetSessionState();", reset)
self.assertIn("if (!mesh::resetUsbCompanionTransport())", reset)
self.assertIn("usb_terminal_host_reset_completion_pending = true", reset)
self.assertNotIn("board.reboot()", reset)
self.assertIn("if (preserve_ascii_terminal)", reset)
self.assertIn("the_mesh.resetTerminalSession();", reset)
logging = source("src/helpers/UsbLogging.cpp")
helper_start = logging.index("static bool detachEsp32HwcdcPads()")
purge_start = logging.index("bool resetUsbCompanionTransport()")
helper = logging[helper_start:purge_start]
purge = logging[purge_start : logging.index(
"Stream& usbLoggingPort()", purge_start
)]
self.assertIn(
"esp32_hwcdc_access_generation.fetch_add(", purge
)
self.assertIn("setPlatformDebugOutputEnabled(false);", purge)
self.assertIn("detachEsp32HwcdcPads()", purge)
self.assertIn("delay(10);", purge)
self.assertIn("tryRunExclusive(", purge)
self.assertIn("Serial.flush();", helper)
self.assertIn("flush_attempts >= 2", helper)
self.assertIn("Serial.availableForWrite()", helper)
self.assertIn("esp32_hwcdc_tx_buffer_capacity.load(", helper)
self.assertNotIn("Serial.setTxBufferSize(", helper)
self.assertIn("while (Serial.read() >= 0)", helper)
self.assertIn(
"setPlatformDebugOutputEnabled(isUsbLoggingEnabled());", purge
)
self.assertIn(
"esp32_hwcdc_self_reset_guard.expectSelfResetBurst()", purge
)
self.assertLess(
purge.index("esp32_hwcdc_self_reset_guard.expectSelfResetBurst()"),
purge.index("restoreEsp32HwcdcPads("),
)
self.assertIn(
"esp32_hwcdc_allowed_generation.store(", purge
)
self.assertNotIn("setUsbLoggingEnabled(false);", purge)
self.assertNotIn("Serial.end();", purge)
self.assertNotIn("Serial.begin(", purge)
setup = main[main.index("void setup()") : main.index("board.begin();")]
self.assertIn("static const size_t usb_tx_sizes[]", setup)
self.assertIn("Serial.setTxBufferSize(candidate)", setup)
self.assertIn("usb_tx_capacity = Serial.availableForWrite();", setup)
self.assertIn(
"mesh::setUsbCompanionTxBufferCapacity(usb_tx_capacity)", setup
)
debug_start = logging.index(
"static void setPlatformDebugOutputEnabled(bool enabled)"
)
debug = logging[debug_start : logging.index(
"#if defined(MESH_DUAL_CDC_LOGGING)", debug_start
)]
self.assertIn("MESH_ESP32_HWCDC_SESSION_GUARD", debug)
self.assertIn("Serial.setDebugOutput(false);", debug)
self.assertNotIn("&& canAccessEsp32Hwcdc", debug)
companion_start = logging.index("Stream& usbCompanionPort()")
companion = logging[companion_start : logging.index(
"Stream& usbMotaPort()", companion_start
)]
self.assertIn("return guarded_esp32_hwcdc_port;", companion)
mota_port_start = logging.index("Stream& usbMotaPort()")
mota_port = logging[mota_port_start : logging.index(
"Stream& usbTerminalPort()", mota_port_start
)]
self.assertIn(
"return guarded_esp32_hwcdc_mota_port;", mota_port
)
self.assertIn(
"AtomicWholeRecordNonBlockingStream<11>", logging
)
ota_context = source("src/helpers/ota/OtaContext.h")
ota_stream = ota_context[
ota_context.index("#ifndef OTA_FOLDER_SERIAL_STREAM") :
ota_context.index("#ifndef OTA_FOLDER_SERIAL_BAUD")
]
self.assertIn("defined(ESP32)", ota_stream)
self.assertIn("ARDUINO_USB_MODE == 1", ota_stream)
self.assertIn("defined(ENABLE_USB_INTERFACE)", ota_stream)
self.assertIn("::mesh::usbMotaPort()", ota_stream)
self.assertIn(
"Serial.onEvent(ARDUINO_HW_CDC_ANY_EVENT,", logging
)
self.assertIn(
"esp32_hwcdc_bus_reset_generation.fetch_add(", logging
)
self.assertIn(
"esp32_hwcdc_access_generation.fetch_add(", logging
)
self.assertIn(
"esp32_hwcdc_self_reset_guard.shouldIgnoreBusReset()", logging
)
self.assertNotIn("esp32_hwcdc_expected_self_reset.exchange(", logging)
self.assertIn(
"esp32_hwcdc_self_reset_guard.notePostCleanActivity()", logging
)
take_start = logging.index("bool takeUsbTerminalSessionReset()")
take = logging[take_start : logging.index(
"bool tryCompleteUsbTerminalSessionReset()", take_start
)]
self.assertIn("MESH_ESP32_HWCDC_SESSION_GUARD", take)
self.assertIn("esp32_hwcdc_bus_reset_generation.load(", take)
mota_start = main.index("static void serviceUsbMota()")
mota_end = main.index(
"static bool usb_terminal_host_reset_completion_pending", mota_start
)
mota = main[mota_start:mota_end]
self.assertIn("#if !(defined(ESP32)", mota)
self.assertIn("isUsbTerminalDataConnected()", mota)
def test_hwcdc_pad_detach_supports_idf4_and_idf5(self):
logging = source("src/helpers/UsbLogging.cpp")
wrappers = logging[
logging.index("static bool detachEsp32HwcdcPads()") :
logging.index("static void purgeEsp32HwcdcQueues(")
]
self.assertIn('#include "esp_idf_version.h"', logging)
self.assertEqual(wrappers.count("#if ESP_IDF_VERSION_MAJOR >= 5"), 2)
self.assertIn("usb_serial_jtag_ll_phy_is_pad_enabled()", wrappers)
self.assertIn("usb_serial_jtag_ll_phy_enable_pad(false)", wrappers)
self.assertIn("usb_serial_jtag_ll_phy_enable_pad(enabled)", wrappers)
self.assertIn("usb_serial_jtag_ll_pad_backup_and_disable()", wrappers)
self.assertIn("usb_serial_jtag_ll_enable_pad(enabled)", wrappers)
reset_start = logging.index("bool resetUsbCompanionTransport()")
reset = logging[reset_start : logging.index(
"Stream& usbLoggingPort()", reset_start
)]
self.assertNotIn("usb_serial_jtag_ll_pad_backup_and_disable()", reset)
self.assertNotIn("usb_serial_jtag_ll_phy_enable_pad(", reset)
c6 = source("variants/m5stack_unit_c6l/platformio.ini")
usb = c6[c6.index("[env:M5Stack_Unit_C6L_companion_radio_usb]") :]
self.assertIn("-D ARDUINO_USB_MODE=1", usb)
self.assertIn("-D ENABLE_USB_INTERFACE", usb)
def test_hwcdc_host_presence_uses_sof_signal(self):
board = source("src/helpers/ESP32Board.h")
start = board.index("bool isUsbHostConnected() override")
host_check = board[start : board.index("void setInhibitSleep", start)]
self.assertIn("ARDUINO_USB_MODE", host_check)
self.assertIn("return Serial.isPlugged();", host_check)
def test_hwcdc_retries_tx_kick_after_transient_sof_loss(self):
logging = source("src/helpers/UsbLogging.cpp")
self.assertIn(
"static std::atomic<bool> esp32_hwcdc_tx_kick_pending{false};",
logging,
)
write_start = logging.index(
"size_t write(const uint8_t* data, size_t size) override"
)
write_end = logging.index("private:", write_start)
guarded_write = logging[write_start:write_end]
self.assertIn(
"esp32_hwcdc_tx_kick_pending.store(true", guarded_write
)
self.assertIn("return Serial.write(data, size);", guarded_write)
kick_start = logging.index(
"static void serviceEsp32HwcdcTxKickExclusive"
)
kick_end = logging.index("#endif", kick_start)
kick = logging[kick_start:kick_end]
self.assertIn("Serial.availableForWrite()", kick)
self.assertIn("esp32_hwcdc_tx_buffer_capacity.load(", kick)
self.assertIn("canAccessEsp32Hwcdc(nullptr)", kick)
self.assertIn("Serial.isPlugged()", kick)
self.assertIn("usb_serial_jtag_ll_txfifo_flush();", kick)
self.assertIn("USB_SERIAL_JTAG_INTR_SERIAL_IN_EMPTY", kick)
self.assertIn("portENTER_CRITICAL(&esp32_hwcdc_session_mux)", kick)
self.assertIn("tryRunExclusive(", kick)
event_start = logging.index("static void handleEsp32HwcdcEvent")
event_end = logging.index(
"class Esp32HwcdcSessionStream", event_start
)
event = logging[event_start:event_end]
self.assertIn(
"esp32_hwcdc_tx_kick_pending.store(false", event
)
self.assertIn("usb_serial_jtag_ll_disable_intr_mask(", event)
self.assertNotIn(
"if (event_id == ARDUINO_HW_CDC_TX_EVENT) {", event
)
service_start = logging.index("void serviceUsbTerminalPort()")
service_end = logging.index(
"void discardUsbTerminalOutput()", service_start
)
service = logging[service_start:service_end]
self.assertIn("MESH_ESP32_HWCDC_SESSION_GUARD", service)
self.assertIn("serviceEsp32HwcdcTxKick();", service)
def test_mqtt_ntp_detail_never_bypasses_logging_mode(self):
text = source("src/helpers/bridges/MQTTBridge.cpp")
self.assertIn(
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
PROFILE = ROOT / "variants" / "heltec_v4" / "platformio.ini"
R8_PROFILE = ROOT / "variants" / "heltec_v4_r8" / "platformio.ini"
UI = ROOT / "examples" / "companion_radio" / "ui-new" / "UITask.cpp"
WIFI = ROOT / "examples" / "companion_radio" / "CompanionWiFi.h"
MAIN = ROOT / "examples" / "companion_radio" / "main.cpp"
def ini_section(source: str, name: str) -> str:
start = source.index(f"[env:{name}]")
end = source.find("\n[", start + 1)
return source[start:] if end < 0 else source[start:end]
class HeltecV4WiFiSetupPageTest(unittest.TestCase):
def test_only_main_v4_oled_full_profiles_enable_the_page(self):
profile = PROFILE.read_text(encoding="utf-8")
full_profiles = (
"heltec_v4_2_v4_3_companion_radio_full_femon",
"heltec_v4_3_companion_radio_full_femoff",
)
for name in full_profiles:
section = ini_section(profile, name)
self.assertIn("-D UI_WIFI_SETUP_HOME_PAGE=1", section)
self.assertIn("-D MESHCORE_REQUIRES_COMPANION_RADIO_FULL=1", section)
self.assertEqual(profile.count("-D UI_WIFI_SETUP_HOME_PAGE=1"), 2)
self.assertNotIn(
"UI_WIFI_SETUP_HOME_PAGE",
ini_section(profile, "heltec_v4_companion_radio_wifi_femon"),
)
self.assertNotIn(
"UI_WIFI_SETUP_HOME_PAGE",
ini_section(profile, "heltec_v4_tft_companion_radio_full_femon"),
)
self.assertNotIn("UI_WIFI_SETUP_HOME_PAGE", R8_PROFILE.read_text())
def test_compact_page_fits_128_by_64_without_qr_or_cli_copy(self):
ui = UI.read_text(encoding="utf-8")
start = ui.index("static bool drawCompactCompanionWiFiSetupPage")
end = ui.index("\nstatic void drawCompanionWiFiSetupPage", start)
compact = ui[start:end]
self.assertIn("display.width() > 128 || display.height() > 64", compact)
self.assertIn("display.setTextSize(1);", compact)
self.assertIn('"SETUP AP ACTIVE" : "SETUP AP INACTIVE"', compact)
self.assertIn('"HOLD STOP AP" : "HOLD START AP"', compact)
self.assertIn('snprintf(open_ip, sizeof(open_ip), "OPEN %s", setup_ip)', compact)
self.assertIn("display.drawTextEllipsized", compact)
self.assertNotIn("drawQrCode", compact)
self.assertNotIn("buildWiFiSetupQrPayload", compact)
self.assertNotIn("start webconfig", compact.lower())
# Default size-one glyphs are six by eight logical pixels.
for text in (
"SETUP AP INACTIVE",
"WIFI NOT CONFIGURED",
"OPEN 255.255.255.255",
"HOLD START AP",
):
self.assertLessEqual(len(text) * 6, 128)
for y in (20, 31, 42, 53):
self.assertLessEqual(y + 8, 64)
page_start = ui.index("static void drawCompanionWiFiSetupPage")
page_end = ui.index("\n}\n#endif\n\n#include \"icons.h\"", page_start)
page = ui[page_start:page_end]
self.assertLess(
page.index("drawCompactCompanionWiFiSetupPage("),
page.index("buildWiFiSetupQrPayload("),
)
def test_short_click_leaves_page_and_hold_toggles_session_ap(self):
ui = UI.read_text(encoding="utf-8")
home_handler_start = ui.index(" bool handleInput(char c) override {")
home_handler_end = ui.index("\n};", home_handler_start)
handler = ui[home_handler_start:home_handler_end]
next_page = handler.index("if (c == KEY_NEXT || c == KEY_RIGHT)")
setup_action = handler.index(
"if (c == KEY_ENTER && _page == HomePage::WIFI_SETUP)"
)
self.assertLess(next_page, setup_action)
self.assertIn("_page = (_page + 1) % HomePage::Count;", handler)
setup_handler = handler[setup_action:]
self.assertIn("WebConfigServer::getSetupInfo(nullptr, 0, nullptr, 0)", setup_handler)
self.assertIn("requestCompanionWiFiSetupStop();", setup_handler)
self.assertIn("requestCompanionWiFiSetup();", setup_handler)
self.assertNotIn("!isCompanionWiFiConnected()", setup_handler)
loop = ui[ui.index("void UITask::loop()") : ui.index(
"char UITask::checkDisplayOn", ui.index("void UITask::loop()")
)]
self.assertIn(
"if (ev == BUTTON_EVENT_CLICK) {\n"
" c = checkDisplayOn(KEY_NEXT);",
loop,
)
self.assertIn(": handleLongPress(KEY_ENTER);", loop)
long_press = ui[ui.index("char UITask::handleLongPress") :]
long_press = long_press[:long_press.index("\nchar UITask::handleDoubleClick")]
page_bypass = long_press.index("isWiFiSetupPage()")
cli_rescue = long_press.index("the_mesh.enterCLIRescue()")
self.assertLess(page_bypass, cli_rescue)
self.assertIn("return c;", long_press[page_bypass:cli_rescue])
def test_deferred_toggle_does_not_change_saved_preferences(self):
wifi = WIFI.read_text(encoding="utf-8")
self.assertIn("void requestCompanionWiFiSetupStop();", wifi)
self.assertIn("current boot session", wifi)
main = MAIN.read_text(encoding="utf-8")
requests_start = main.index("void requestCompanionWiFiSetup()")
requests_end = main.index("bool toggleCompanionWiFi()", requests_start)
requests = main[requests_start:requests_end]
self.assertIn("companion_wifi_setup_requested = true;", requests)
self.assertIn("companion_wifi_setup_stop_requested = true;", requests)
self.assertNotIn("savePrefs", requests)
self.assertNotIn("saveEnabled", requests)
loop = main[main.index("\nvoid loop()") :]
stop = loop.index("if (companion_wifi_setup_stop_requested)")
start = loop.index("if (companion_wifi_setup_requested)", stop)
state_service = loop.index("serviceCompanionWiFiState();", start)
self.assertLess(stop, start)
self.assertLess(start, state_service)
self.assertIn("the_mesh.stopWebConfig();", loop[stop:start])
self.assertIn("the_mesh.startWebConfig(true, web_reply)", loop[start:state_service])
self.assertNotIn("savePrefs", loop[stop:state_service])
self.assertNotIn("saveEnabled", loop[stop:state_service])
if __name__ == "__main__":
unittest.main()
@@ -88,8 +88,12 @@ struct SingleAttemptContext {
size_t nested_result = 99;
bool reenter = false;
bool accessible = true;
bool try_exclusive_during_write = false;
bool nested_exclusive_result = true;
};
void noOpExclusive(void*) {}
size_t tryWriteOnce(void* opaque, const uint8_t*, size_t size) {
SingleAttemptContext* context =
static_cast<SingleAttemptContext*>(opaque);
@@ -100,6 +104,10 @@ size_t tryWriteOnce(void* opaque, const uint8_t*, size_t size) {
const uint8_t nested = 'n';
context->nested_result = context->stream->write(&nested, 1);
}
if (context->try_exclusive_during_write && context->stream != nullptr) {
context->nested_exclusive_result =
context->stream->tryRunExclusive(noOpExclusive);
}
return context->result;
}
@@ -366,6 +374,21 @@ TEST_F(Nrf52DebugOutputTest, SingleAttemptFacadeDropsAReentrantWriter) {
EXPECT_EQ(context.nested_result, 0U);
}
TEST_F(Nrf52DebugOutputTest,
SingleAttemptFacadeExcludesAPurgeWhileAWriterIsActive) {
SingleAttemptContext context;
context.result = 1;
context.try_exclusive_during_write = true;
mesh::SingleAttemptNonBlockingStream port(logging_port, tryWriteOnce,
&context, canAccess);
context.stream = &port;
const uint8_t value = 'x';
EXPECT_EQ(port.write(&value, 1), 1U);
EXPECT_FALSE(context.nested_exclusive_result);
EXPECT_TRUE(port.tryRunExclusive(noOpExclusive));
}
TEST_F(Nrf52DebugOutputTest,
BufferedFacadeRetainsACompleteMultiLineReplyAcrossShortDrains) {
logging_port.reset(0);
+20 -11
View File
@@ -493,19 +493,28 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase):
self.assertIn("bool tryCompleteUsbTerminalSessionReset();", header)
self.assertIn("bool takeUsbTerminalSessionReset()", source)
self.assertIn("bool tryCompleteUsbTerminalSessionReset()", source)
reset = main[
reset_helper = main[
main.index("static void resetUsbTerminalHostSession(") :
main.index("static void serviceUsbTerminalHostSessionReset()")
]
reset_service = main[
main.index("static void serviceUsbTerminalHostSessionReset()") :
main.index("static void serviceUsbTerminal()")
]
self.assertIn("mesh::takeUsbTerminalSessionReset()", reset)
self.assertIn("mesh::tryCompleteUsbTerminalSessionReset()", reset)
self.assertIn("leaveUsbMotaMode(false);", reset)
self.assertIn("leaveUsbTerminalMode(false);", reset)
self.assertIn("usb_serial_interface.setPassthroughMode(false);", reset)
self.assertIn("clearUsbTerminalLine();", reset)
self.assertIn("usb_binary_startup_probe.cancel();", reset)
self.assertIn("usb_terminal_host_reset_completion_pending", reset)
self.assertIn("cancelUsbSerialOperations();", reset)
self.assertIn("mesh::takeUsbTerminalSessionReset()", reset_service)
self.assertIn("mesh::tryCompleteUsbTerminalSessionReset()", reset_service)
self.assertIn("resetUsbTerminalHostSession(false);", reset_service)
self.assertLess(
reset_service.index("resetUsbTerminalHostSession(false);"),
reset_service.index("mesh::tryCompleteUsbTerminalSessionReset()"),
)
self.assertIn("leaveUsbMotaMode(false);", reset_helper)
self.assertIn("leaveUsbTerminalMode(false);", reset_helper)
self.assertIn("usb_serial_interface.setPassthroughMode(false);", reset_helper)
self.assertIn("clearUsbTerminalLine();", reset_helper)
self.assertIn("usb_binary_startup_probe.cancel();", reset_helper)
self.assertIn("usb_terminal_host_reset_completion_pending", reset_service)
self.assertIn("cancelUsbSerialOperations();", reset_helper)
usb_cancel = main[
main.index("static void cancelUsbSerialOperations()"):
main.index("static void enterUsbTerminalMode()")
@@ -525,7 +534,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase):
self.assertLess(route_check, route_cancel)
self.assertLess(route_cancel, delayed_cancel)
self.assertLess(delayed_cancel, route_forget)
self.assertIn("the_mesh.resetUsbHostSessionInput();", reset)
self.assertIn("the_mesh.resetUsbHostSessionInput();", reset_helper)
self.assertNotRegex(main, r"Serial\.(?:available|read|peek)\(")
mesh_source = (
ROOT / "examples/companion_radio/MyMesh.cpp"
@@ -52,6 +52,206 @@ public:
static const char START_TOKEN[] = "+++MESHCORE-TERM-START";
static const char SEEDER_TOKEN[] = "ota folder on";
TEST(UsbHwcdcSession, IgnoresWholeExpectedSelfResetBurstUntilActivity) {
mesh::UsbSelfResetBurstGuard guard;
EXPECT_FALSE(guard.shouldIgnoreBusReset());
guard.expectSelfResetBurst();
EXPECT_TRUE(guard.shouldIgnoreBusReset());
EXPECT_TRUE(guard.shouldIgnoreBusReset());
guard.notePostCleanActivity();
EXPECT_FALSE(guard.shouldIgnoreBusReset());
}
TEST(UsbHwcdcSession, CompletedFrameGetsImmediateReplyLease) {
mesh::UsbHostPresenceDebouncer tracker;
EXPECT_TRUE(tracker.isClientConnected(
false, 100, true, 100, 1, 2000, 600000, 100, 2000, false));
EXPECT_TRUE(tracker.isClientConnected(
false, 2099, true, 100, 1, 2000, 600000, 100, 2000, false));
EXPECT_FALSE(tracker.isClientConnected(
false, 2100, true, 100, 1, 2000, 600000, 100, 2000, false));
EXPECT_TRUE(tracker.takeSustainedHostLoss());
}
TEST(UsbHwcdcSession, DebouncesTransientHostLossAndReportsSustainedLossOnce) {
mesh::UsbHostPresenceDebouncer tracker;
EXPECT_TRUE(tracker.observeHost(true, 100, 100, 2000));
// A long interval without sampling is not itself evidence of continuous
// loss. The debounce window starts at the first observed false sample.
EXPECT_TRUE(tracker.observeHost(false, 5000, 100, 2000));
EXPECT_TRUE(tracker.observeHost(false, 6999, 100, 2000));
EXPECT_FALSE(tracker.takeSustainedHostLoss());
EXPECT_FALSE(tracker.observeHost(false, 7000, 100, 2000));
EXPECT_TRUE(tracker.takeSustainedHostLoss());
EXPECT_FALSE(tracker.takeSustainedHostLoss());
}
TEST(UsbHwcdcSession, DebouncesAndReportsPhysicalLossEdgeOnce) {
mesh::UsbHostPresenceDebouncer tracker;
EXPECT_TRUE(tracker.observeHost(true, 100, 100, 2000));
EXPECT_TRUE(tracker.observeHost(false, 110, 100, 2000));
EXPECT_FALSE(tracker.takeHostLossEdge());
EXPECT_TRUE(tracker.observeHost(false, 209, 100, 2000));
EXPECT_FALSE(tracker.takeHostLossEdge());
EXPECT_TRUE(tracker.observeHost(false, 210, 100, 2000));
EXPECT_TRUE(tracker.takeHostLossEdge());
EXPECT_FALSE(tracker.takeHostLossEdge());
// Repeated false samples belong to the same edge.
EXPECT_TRUE(tracker.observeHost(false, 220, 100, 2000));
EXPECT_FALSE(tracker.takeHostLossEdge());
}
TEST(UsbHwcdcSession, IgnoresTransientPhysicalLossBeforeEdgeDebounce) {
mesh::UsbHostPresenceDebouncer tracker;
EXPECT_TRUE(tracker.observeHost(true, 100, 100, 2000));
EXPECT_TRUE(tracker.observeHost(false, 110, 100, 2000));
EXPECT_TRUE(tracker.observeHost(true, 150, 100, 2000));
EXPECT_FALSE(tracker.takeHostLossEdge());
}
TEST(UsbHwcdcSession, FrameProofAloneDoesNotInventPhysicalLossEdge) {
mesh::UsbHostPresenceDebouncer tracker;
EXPECT_TRUE(tracker.isClientConnected(
false, 100, true, 100, 1, 2000, 600000, 100, 2000, false));
EXPECT_FALSE(tracker.takeHostLossEdge());
}
TEST(UsbHwcdcSession, DebounceHandlesMillisRollover) {
mesh::UsbHostPresenceDebouncer tracker;
const uint32_t started = 0xFFFFFFF0u;
EXPECT_TRUE(tracker.observeHost(true, started, 100, 2000));
EXPECT_TRUE(tracker.observeHost(false, started + 100u, 100, 2000));
EXPECT_TRUE(tracker.observeHost(false, started + 2099u, 100, 2000));
EXPECT_FALSE(tracker.observeHost(false, started + 2100u, 100, 2000));
EXPECT_TRUE(tracker.takeSustainedHostLoss());
}
TEST(UsbHwcdcSession, IdleLeaseIsAbsoluteWhileHostRemains) {
mesh::UsbHostPresenceDebouncer tracker;
EXPECT_FALSE(tracker.isClientConnected(
true, 700100, true, 100, 1, 2000, 600000, 100, 2000, false));
}
TEST(UsbHwcdcSession, ReplugDoesNotErasePendingOldSessionLoss) {
mesh::UsbHostPresenceDebouncer tracker;
EXPECT_TRUE(tracker.observeHost(true, 10, 100, 2000));
EXPECT_TRUE(tracker.observeHost(false, 20, 100, 2000));
EXPECT_FALSE(tracker.observeHost(false, 2020, 100, 2000));
EXPECT_FALSE(tracker.observeHost(true, 2030, 100, 2000));
EXPECT_TRUE(tracker.takeSustainedHostLoss());
EXPECT_FALSE(tracker.takeSustainedHostLoss());
}
TEST(UsbHwcdcSession, SustainedLossOverridesFreshFrameUntilReset) {
mesh::UsbHostPresenceDebouncer tracker;
EXPECT_TRUE(tracker.observeHost(true, 10, 100, 2000));
EXPECT_TRUE(tracker.observeHost(false, 20, 100, 2000));
EXPECT_FALSE(tracker.observeHost(false, 2020, 100, 2000));
EXPECT_FALSE(tracker.isClientConnected(
false, 2030, true, 2030, 1, 2000, 600000, 100, 2000, false));
EXPECT_FALSE(tracker.isClientConnected(
true, 2040, true, 2040, 2, 2000, 600000, 100, 2000, false));
EXPECT_TRUE(tracker.takeSustainedHostLoss());
tracker.reset();
EXPECT_TRUE(tracker.isClientConnected(
true, 2050, true, 2050, 3, 2000, 600000, 100, 2000, false));
}
TEST(UsbHwcdcSession, FiniteReplyExtendsIdleAndGetsOneDrainWindow) {
mesh::UsbHostPresenceDebouncer tracker;
const uint32_t last_frame = 100;
const uint32_t idle_limit = 600000;
const uint32_t now = last_frame + idle_limit;
EXPECT_TRUE(tracker.isClientConnected(
true, now, true, last_frame, 1, 2000, idle_limit, 100, 2000, true));
EXPECT_TRUE(tracker.isClientConnected(
true, now + 1, true, last_frame, 1, 2000, idle_limit, 100, 2000, false));
EXPECT_TRUE(tracker.isClientConnected(
true, now + 2000, true, last_frame, 1, 2000, idle_limit, 100, 2000, false));
EXPECT_FALSE(tracker.isClientConnected(
true, now + 2001, true, last_frame, 1, 2000, idle_limit, 100, 2000, false));
EXPECT_FALSE(tracker.isClientConnected(
true, now + 3000, true, last_frame, 1, 2000, idle_limit, 100, 2000, false));
}
TEST(UsbHwcdcSession, NewFiniteReplyRearmsAfterDrain) {
mesh::UsbHostPresenceDebouncer tracker;
EXPECT_TRUE(tracker.isClientConnected(
true, 700000, true, 1, 1, 2000, 600000, 100, 2000, true));
EXPECT_TRUE(tracker.isClientConnected(
true, 700001, true, 1, 1, 2000, 600000, 100, 2000, false));
EXPECT_TRUE(tracker.isClientConnected(
true, 701000, true, 1, 1, 2000, 600000, 100, 2000, true));
EXPECT_TRUE(tracker.isClientConnected(
true, 701001, true, 1, 1, 2000, 600000, 100, 2000, false));
EXPECT_FALSE(tracker.isClientConnected(
true, 703001, true, 1, 1, 2000, 600000, 100, 2000, false));
}
TEST(UsbHwcdcSession, SustainedLossOverridesFiniteReplyAndResetClearsIt) {
mesh::UsbHostPresenceDebouncer tracker;
EXPECT_TRUE(tracker.observeHost(true, 10, 100, 2000));
EXPECT_TRUE(tracker.isClientConnected(
true, 700000, true, 1, 1, 2000, 600000, 100, 2000, true));
EXPECT_TRUE(tracker.observeHost(false, 700010, 100, 2000));
EXPECT_FALSE(tracker.observeHost(false, 702010, 100, 2000));
EXPECT_FALSE(tracker.isClientConnected(
false, 702011, true, 1, 1, 2000, 600000, 100, 2000, true));
tracker.reset();
EXPECT_FALSE(tracker.isClientConnected(
true, 702012, true, 1, 1, 2000, 600000, 100, 2000, false));
}
TEST(UsbHwcdcSession, FiniteReplyDrainHandlesMillisRollover) {
mesh::UsbHostPresenceDebouncer tracker;
const uint32_t started = 0xFFFFFFF0u;
EXPECT_TRUE(tracker.isClientConnected(
true, started, true, started - 600001u, 1,
2000, 600000, 100, 2000, true));
EXPECT_TRUE(tracker.isClientConnected(
true, started + 1u, true, started - 600001u, 1,
2000, 600000, 100, 2000, false));
EXPECT_TRUE(tracker.isClientConnected(
true, started + 2000u, true, started - 600001u, 1,
2000, 600000, 100, 2000, false));
EXPECT_FALSE(tracker.isClientConnected(
true, started + 2001u, true, started - 600001u, 1,
2000, 600000, 100, 2000, false));
}
TEST(UsbHwcdcSession, SessionResetClearsFrameProofAndPartialIo) {
BufferStream stream;
ArduinoSerialInterface interface;
interface.begin(stream, START_TOKEN);
interface.enable();
interface.enableFlowControl(true);
const uint8_t complete[] = {'<', 1, 0, 0x05};
stream.push(complete, sizeof(complete));
uint8_t frame[MAX_FRAME_SIZE] = {};
ASSERT_EQ(interface.checkRecvFrame(frame), 1u);
ASSERT_TRUE(interface.hasReceivedFrame());
const uint32_t completed = interface.getCompletedFrameCount();
stream.write_capacity = 0;
const uint8_t response[] = {0x06, 0x42};
ASSERT_EQ(interface.writeFrame(response, sizeof(response)),
sizeof(response));
const uint8_t partial[] = {'<', 2, 0, 0x16};
stream.push(partial, sizeof(partial));
EXPECT_EQ(interface.checkRecvFrame(frame), 0u);
ASSERT_TRUE(interface.hasPendingIO());
interface.resetSessionState();
EXPECT_FALSE(interface.hasReceivedFrame());
EXPECT_FALSE(interface.hasPendingIO());
EXPECT_EQ(interface.getCompletedFrameCount(), completed);
}
TEST(UsbMotaOwnerPolicy, MatchesEveryOtaCliAttachDetachSpelling) {
EXPECT_TRUE(mesh::isUsbMotaOwnerTransitionCommand("ota folder on"));
EXPECT_TRUE(mesh::isUsbMotaOwnerTransitionCommand("ota folder off"));
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class StorageLayoutCliTest(unittest.TestCase):
def test_shared_matcher_formatter_and_host_fallback(self):
source = r'''
#include <helpers/StorageLayout.h>
#include <string.h>
namespace mesh { class MainBoard {}; }
using mesh::cli::Esp32StorageLayoutWriter;
using mesh::cli::StorageLayoutGetMatch;
using mesh::cli::classifyStorageLayoutGet;
int main() {
if (classifyStorageLayoutGet(nullptr) !=
StorageLayoutGetMatch::NotMatched) return 1;
if (classifyStorageLayoutGet("storage.layout") !=
StorageLayoutGetMatch::Valid) return 2;
if (classifyStorageLayoutGet("storage.layout extra") !=
StorageLayoutGetMatch::InvalidArguments) return 3;
if (classifyStorageLayoutGet("storage.layout\textra") !=
StorageLayoutGetMatch::InvalidArguments) return 4;
if (classifyStorageLayoutGet("storage.layoutx") !=
StorageLayoutGetMatch::NotMatched) return 5;
char layout[160];
Esp32StorageLayoutWriter writer(layout, sizeof(layout), 8UL * 1024UL * 1024UL);
if (!writer.append("nvs", 0x9000, 20UL * 1024UL, false)) return 6;
if (!writer.append("app0", 0x10000, 1984UL * 1024UL, true)) return 7;
writer.finish();
if (strcmp(layout,
"> int:esp32=8192K ext:none; nvs@0x9000+20K,app0*@0x10000+1984K") != 0)
return 8;
char empty[80];
Esp32StorageLayoutWriter empty_writer(empty, sizeof(empty), 4UL * 1024UL * 1024UL);
empty_writer.finish();
if (strcmp(empty, "> int:esp32=4096K ext:none; partitions=none") != 0)
return 9;
char bounded[64];
Esp32StorageLayoutWriter bounded_writer(
bounded, sizeof(bounded), 4UL * 1024UL * 1024UL);
if (!bounded_writer.append("nvs", 0x9000, 20UL * 1024UL, false)) return 10;
if (bounded_writer.append("large_app", 0x10000, 1984UL * 1024UL, true)) return 11;
bounded_writer.finish();
if (!bounded_writer.truncated() || strstr(bounded, ",...") == nullptr) return 12;
if (strlen(bounded) >= sizeof(bounded)) return 13;
char bytes[24];
mesh::cli::formatStorageBytes(bytes, sizeof(bytes), 1536);
if (strcmp(bytes, "1.5 KiB") != 0) return 14;
mesh::MainBoard board;
char reply[80];
if (!mesh::cli::handleStorageLayoutGet(
"storage.layout", board, reply, sizeof(reply))) return 15;
if (strcmp(reply,
"Error: storage layout unsupported on this platform") != 0)
return 16;
if (!mesh::cli::handleStorageLayoutGet(
"storage.layout bad", board, reply, sizeof(reply))) return 17;
if (strcmp(reply, "Error: use get storage.layout") != 0) return 18;
if (mesh::cli::handleStorageLayoutGet(
"storage.layoutx", board, reply, sizeof(reply))) return 19;
return 0;
}
'''
with tempfile.TemporaryDirectory() as temp_dir:
executable = Path(temp_dir) / "storage_layout"
compiled = subprocess.run(
[
"c++",
"-std=c++11",
f"-I{ROOT / 'src'}",
"-x",
"c++",
"-",
str(ROOT / "src/helpers/StorageLayout.cpp"),
"-o",
str(executable),
],
input=source,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(compiled.returncode, 0, compiled.stderr)
result = subprocess.run(
[str(executable)], capture_output=True, text=True, check=False
)
self.assertEqual(result.returncode, 0, result.stderr)
def test_common_and_companion_use_one_platform_formatter(self):
common = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
common_header = (ROOT / "src/helpers/CommonCLI.h").read_text()
companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
formatter = (ROOT / "src/helpers/StorageLayout.cpp").read_text()
self.assertIn('#include "StorageLayout.h"', common)
self.assertIn(
"mesh::cli::handleStorageLayoutGet(config, *_board, reply, 160)",
common,
)
self.assertNotIn("handleStorageLayoutGetCmd", common)
self.assertNotIn("handleStorageLayoutGetCmd", common_header)
self.assertNotIn("esp_partition_find", common)
local_start = companion.index("bool MyMesh::handleLocalControlCommand(")
local_end = companion.index(
"\n#if COMPANION_FEATURE_TEMP_RADIO\nvoid MyMesh::serviceTempRadio()",
local_start,
)
local = companion[local_start:local_end]
self.assertRegex(
local,
re.compile(
r'if \(strncmp\(command, "get ", 4\) == 0 &&\s+'
r'mesh::cli::handleStorageLayoutGet\(command \+ 4, board, reply,\s+'
r'reply_size\)\)',
re.DOTALL,
),
)
self.assertIn(
'terminalOutput().print(" get storage.layout\\r\\n");',
companion,
)
# ESP-IDF discovery and every platform-specific result live only in the
# shared formatter; neither role owns a second copy.
self.assertEqual(formatter.count("esp_partition_find("), 1)
self.assertEqual(formatter.count('> int:esp32=%luK ext:none; '), 0)
writer = (ROOT / "src/helpers/StorageLayout.h").read_text()
self.assertEqual(writer.count('> int:esp32=%luK ext:none; '), 1)
def test_companion_availability_is_documented(self):
availability = (
ROOT / "docs/cli_command_availability.md"
).read_text()
companion_start = availability.index("## Companion framed CLI")
companion_end = availability.index("\n## nRF52", companion_start)
companion_section = availability[companion_start:companion_end]
self.assertIn("[`get storage.layout`]", companion_section)
self.assertIn("Every Companion text terminal and command `0x42`", companion_section)
if __name__ == "__main__":
unittest.main()
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
import gzip
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[1]
SERVER = ROOT / "src" / "helpers" / "esp32" / "WebConfigServer.cpp"
KEYS = ROOT / "src" / "helpers" / "WebConfigKeys.h"
UI = ROOT / "webui" / "index.html"
EMBEDDED_UI = ROOT / "src" / "helpers" / "esp32" / "WebConfigHtml.h"
def function_body(source: str, start: str, end: str) -> str:
return source[source.index(start) : source.index(end, source.index(start))]
def embedded_ui() -> str:
header = EMBEDDED_UI.read_text(encoding="utf-8")
length = int(re.search(r"WEBCONFIG_HTML_GZ_LEN = (\d+);", header).group(1))
array = header.split(
"const uint8_t WEBCONFIG_HTML_GZ[] PROGMEM = {", 1
)[1]
blob = bytes(
int(value, 16) for value in re.findall(r"0x([0-9a-f]{2})", array)
)[:length]
return gzip.decompress(blob).decode("utf-8")
class WebConfigSecretMaskingTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server = SERVER.read_text(encoding="utf-8")
cls.keys = KEYS.read_text(encoding="utf-8")
cls.ui = UI.read_text(encoding="utf-8")
match = re.search(
r'static const char SECRET_SENTINEL\[\]\s*=\s*"([^"]*)"\s*;',
cls.server,
)
if match is None:
raise AssertionError("WebConfigServer has no secret sentinel")
cls.sentinel = match.group(1)
def test_server_and_captive_portal_use_exactly_eight_stars(self):
self.assertEqual(self.sentinel, "********")
self.assertEqual(len(self.sentinel), 8)
browser = re.search(r'var SENTINEL\s*=\s*"([^"]*)"\s*;', self.ui)
self.assertIsNotNone(browser)
self.assertEqual(browser.group(1), self.sentinel)
captive_portal = re.search(
r'var SENTINEL\s*=\s*"([^"]*)"\s*;', embedded_ui()
)
self.assertIsNotNone(captive_portal)
self.assertEqual(captive_portal.group(1), self.sentinel)
def test_config_get_masks_only_nonempty_wifi_password(self):
get_handler = function_body(
self.server,
"void WebConfigServer::handleConfigGet(",
"void WebConfigServer::handleConfigPost(",
)
self.assertRegex(
get_handler,
r'wifi\["pwd"\]\s*=\s*_wifi_password\[0\]\s*'
r'\?\s*SECRET_SENTINEL\s*:\s*""\s*;',
)
self.assertEqual(get_handler.count('wifi["pwd"]'), 1)
self.assertNotRegex(
get_handler,
r'wifi\["pwd"\]\s*=\s*(?:\(const char\s*\*\)\s*)?'
r'_wifi_password\s*;',
)
def test_config_post_skips_only_exact_sentinel_for_secret_keys(self):
post_handler = function_body(
self.server,
"void WebConfigServer::handleConfigPost(",
"void WebConfigServer::handleConfigResult(",
)
skip = re.search(
r"if\s*\(\s*isSecretKey\(key\)\s*&&\s*"
r"strcmp\(val,\s*SECRET_SENTINEL\)\s*==\s*0\s*\)\s*continue\s*;",
post_handler,
)
self.assertIsNotNone(skip)
# The equality guard must run before a BatchEntry/CLI command is built;
# otherwise an unchanged masked field could persist the placeholder.
batch_entry = post_handler.index("BatchEntry& e")
pre_persistence = post_handler[
post_handler.index("int count = 0;") : batch_entry
]
self.assertEqual(pre_persistence.count("continue;"), 1)
self.assertLess(skip.start(), batch_entry)
self.assertLess(skip.start(), post_handler.index("count++;"))
# strcmp equality means close values remain real password updates, not
# wildcard matches. Keep representative boundary cases explicit.
def skipped(key: str, value: str) -> bool:
return key == "wifi.pwd" and value == self.sentinel
self.assertTrue(skipped("wifi.pwd", "********"))
for value in ("", "*******", "*********", "********x", "x********"):
self.assertFalse(skipped("wifi.pwd", value), value)
self.assertFalse(skipped("wifi.ssid", "********"))
def test_wifi_password_is_classified_as_a_secret_key(self):
secret_classifier = function_body(
self.keys,
"static inline bool wcIsSecretKey(",
"static inline bool wcIsSecretReadCommand(",
)
self.assertIn('strcmp(key, "wifi.pwd") == 0', secret_classifier)
if __name__ == "__main__":
unittest.main()
+162
View File
@@ -45,6 +45,163 @@ class WebConfigUiTest(unittest.TestCase):
self.assertIn('if(!st.skipRadio&&$("#wz-rp").value==="")', save)
self.assertIn("if(st.hasMqtt&&!st.skipMqtt&&", save)
def test_setup_wizard_opens_existing_wifi_scan_once(self):
page = SOURCE.read_text(encoding="utf-8")
self.assertIn("wizardScanOpened:false", page)
self.assertIn('onclick="openScan(\'wz-ssid\')">Scan</button>', page)
self.assertIn('onclick="startScan(true)">Rescan</button>', page)
open_scan = page[page.index("function openScan(target)"):
page.index("function closeScan()")]
self.assertIn('if(target==="wz-ssid")st.wizardScanOpened=true', open_scan)
auto_open = page[page.index("function openWizardScanOnce()"):
page.index("function wzGo(n)")]
self.assertIn("if(st.wizardScanOpened)return", auto_open)
self.assertIn('openScan("wz-ssid")', auto_open)
self.assertNotIn("startScan(true)", auto_open)
show_wizard = page[page.index("function showWizard()"):
page.index("function wzGo(n)")]
self.assertLess(show_wizard.index("loadConfig(capture)"),
show_wizard.index("openWizardScanOnce()"))
self.assertIn("wzRadioFill();openWizardScanOnce()", show_wizard)
# Scan completion may replace only the result list. Credentials change
# only in the existing explicit network-click handler below it.
poll_scan = page[page.index("function pollScan(url,epoch)"):
page.index("function clearInheritedWiFiPassword")]
self.assertNotIn(".value=", poll_scan)
self.assertNotIn("dispatchEvent", poll_scan)
def test_scan_epoch_invalidates_old_timers_and_responses(self):
page = SOURCE.read_text(encoding="utf-8")
self.assertIn("scanEpoch:0", page)
close_scan = page[page.index("function closeScan()"):
page.index("function startScan(force)")]
self.assertIn("clearTimeout(st.scanTimer);st.scanTimer=0", close_scan)
self.assertIn("st.scanEpoch++", close_scan)
start_scan = page[page.index("function startScan(force)"):
page.index("function bars(rssi)")]
self.assertIn("var epoch=++st.scanEpoch", start_scan)
self.assertIn('pollScan(force?"/api/scan?rescan=1":"/api/scan",epoch)',
start_scan)
poll_scan = page[page.index("function pollScan(url,epoch)"):
page.index("function clearInheritedWiFiPassword")]
# Check before issuing a request, after a response, and in the error
# path so neither a stale timer nor a stale reply can alter the panel.
self.assertEqual(poll_scan.count("if(epoch!==st.scanEpoch)return"), 3)
self.assertIn('pollScan("/api/scan",epoch)', poll_scan)
def test_late_config_load_replays_visible_form_edits(self):
page = SOURCE.read_text(encoding="utf-8")
self.assertIn("configLoadCapture:null", page)
self.assertIn("configEpoch:0", page)
listener = page[page.index('document.addEventListener("input"'):
page.index("function updateSaveBar()")]
self.assertIn("el.closest(capture.root)", listener)
self.assertIn("capture.values[el.dataset.k]=elVal(el)", listener)
self.assertIn("capture.confirms[el.dataset.cfm]=el.value", listener)
self.assertIn("capture.wifiPasswordAutoCleared=!!ev.meshAutoWiFiPassword", listener)
self.assertIn("capture.radio[el.dataset.rg]=elVal(el)", listener)
self.assertIn("capture.rxps[el.dataset.rxps]=elVal(el)", listener)
load_config = page[page.index("function loadConfig(loadCapture)"):
page.index("function markDirty")]
self.assertIn("var epoch=++st.configEpoch", load_config)
self.assertIn("loadCapture.loadEpoch=epoch", load_config)
self.assertIn("if(epoch!==st.configEpoch)return false", load_config)
self.assertIn("restoreConfigLoadEdits(loadCapture)", load_config)
self.assertIn("return true", load_config)
restore = page[page.index("function restoreConfigLoadEdits"):
page.index("function rxpsCfgValue")]
self.assertIn('owns.call(capture.values,"wifi.ssid")', restore)
self.assertIn('capture.values["wifi.ssid"]!==st.orig["wifi.ssid"]',
restore)
self.assertIn('!owns.call(capture.values,"wifi.pwd")', restore)
self.assertIn('st.orig["wifi.pwd"]===SENTINEL', restore)
self.assertIn('capture.values["wifi.pwd"]=""', restore)
self.assertIn("capture.wifiPasswordAutoCleared=true", restore)
self.assertIn("st.wifiPasswordAutoCleared=true", restore)
self.assertIn("v!==st.orig[k]", restore)
self.assertIn("if(dirty)st.dirty[k]=v;else delete st.dirty[k]", restore)
self.assertIn("capture.confirms[k]", restore)
self.assertIn("Object.keys(capture.radio)", restore)
self.assertIn("var restoredRadio=radioCombo()", restore)
self.assertIn("if(capture.rxps)", restore)
show_wizard = page[page.index("function showWizard()"):
page.index("function wzGo(n)")]
self.assertLess(show_wizard.index("st.configLoadCapture=capture"),
show_wizard.index("loadConfig(capture)"))
self.assertIn("if(st.configLoadCapture===capture)", show_wizard)
self.assertIn("if(!applied)return", show_wizard)
self.assertIn("st.configEpoch===capture.loadEpoch", show_wizard)
enter_app = page[page.index("function enterApp()"):
page.index("function enterConsole()")]
self.assertIn('root:"#v-app"', enter_app)
self.assertIn("st.configLoadCapture=capture", enter_app)
self.assertIn("loadConfig(capture)", enter_app)
def test_new_ssid_clears_only_inherited_masked_password(self):
page = SOURCE.read_text(encoding="utf-8")
clear_pwd = page[page.index("function clearInheritedWiFiPassword"):
page.index('$("#scan-list").addEventListener')]
self.assertIn('target==="wz-ssid"?"wz-wifi-pwd"', clear_pwd)
self.assertIn('target==="app-ssid"?"app-wifi-pwd"', clear_pwd)
self.assertIn('nextSsid===st.orig["wifi.ssid"]', clear_pwd)
self.assertIn("pwd.value!==SENTINEL", clear_pwd)
self.assertIn('pwd.value=""', clear_pwd)
self.assertIn("st.wifiPasswordAutoCleared=true", clear_pwd)
self.assertIn("st.wifiPasswordAutoCleared=false", clear_pwd)
self.assertIn("pwd.value=SENTINEL", clear_pwd)
self.assertIn("meshAutoWiFiPassword=true", clear_pwd)
choose = page[page.index('$("#scan-list").addEventListener'):
page.index("/* ---------- wizard ---------- */")]
self.assertLess(
choose.index("clearInheritedWiFiPassword(st.scanTarget,selected)"),
choose.index("el.value=selected"),
)
listener = page[page.index('document.addEventListener("input"'):
page.index("function updateSaveBar()")]
self.assertIn(
'if(el.dataset.k==="wifi.ssid")clearInheritedWiFiPassword(el.id,elVal(el))',
listener,
)
def test_password_review_distinguishes_explicit_clear(self):
page = SOURCE.read_text(encoding="utf-8")
review = page[page.index("function buildReview()"):
page.index("function wizardSave()")]
self.assertIn(
'Object.prototype.hasOwnProperty.call(d,"wifi.pwd")', review
)
self.assertIn(
'pwdChanged?(d["wifi.pwd"]?"******":"(open network)")', review
)
def test_scan_panel_exposes_automatic_updates_accessibly(self):
page = SOURCE.read_text(encoding="utf-8")
self.assertIn('id="wz-scan-btn"', page)
self.assertIn('id="app-scan-btn"', page)
self.assertEqual(page.count('aria-controls="scan-panel"'), 2)
self.assertIn('id="scan-panel" role="region"', page)
self.assertIn('id="scan-list" aria-live="polite" aria-busy="true"', page)
open_scan = page[page.index("function openScan(target)"):
page.index("function closeScan()")]
self.assertIn('setAttribute("aria-expanded"', open_scan)
close_scan = page[page.index("function closeScan()"):
page.index("function startScan(force)")]
self.assertIn('setAttribute("aria-hidden","true")', close_scan)
def test_generated_asset_contains_show_controls(self):
header = HEADER.read_text(encoding="utf-8")
length = int(re.search(r"WEBCONFIG_HTML_GZ_LEN = (\d+);", header).group(1))
@@ -59,6 +216,11 @@ class WebConfigUiTest(unittest.TestCase):
self.assertIn("function toggleWiFiPassword(btn)", page)
self.assertIn("function wzSkipRadio()", page)
self.assertIn("function wzSkipMqtt()", page)
self.assertIn("function openWizardScanOnce()", page)
self.assertIn('openScan("wz-ssid")', page)
self.assertIn("function restoreConfigLoadEdits(capture)", page)
self.assertIn("function pollScan(url,epoch)", page)
self.assertIn("function clearInheritedWiFiPassword(target,nextSsid)", page)
def test_skipped_mqtt_does_not_pin_display_in_setup_mode(self):
main = (ROOT / "examples" / "companion_radio" / "main.cpp").read_text(
+573
View File
@@ -0,0 +1,573 @@
#!/usr/bin/env python3
"""Optional real-browser checks for WebConfig's asynchronous setup races."""
import gzip
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
HEADER = ROOT / "src" / "helpers" / "esp32" / "WebConfigHtml.h"
def chromium_path():
candidates = [
shutil.which(name)
for name in ("google-chrome", "chromium", "chromium-browser", "chrome", "msedge")
]
for base, rest in (
(os.environ.get("ProgramFiles"), ("Google", "Chrome", "Application", "chrome.exe")),
(os.environ.get("ProgramFiles(x86)"), ("Microsoft", "Edge", "Application", "msedge.exe")),
(os.environ.get("LocalAppData"), ("Google", "Chrome", "Application", "chrome.exe")),
):
if base:
candidates.append(str(Path(base).joinpath(*rest)))
return next((path for path in candidates if path and Path(path).is_file()), None)
BROWSER = chromium_path()
def embedded_page():
header = HEADER.read_text(encoding="utf-8")
length = int(re.search(r"WEBCONFIG_HTML_GZ_LEN = (\d+);", header).group(1))
array = header.split("const uint8_t WEBCONFIG_HTML_GZ[] PROGMEM = {", 1)[1]
blob = bytes(
int(value, 16) for value in re.findall(r"0x([0-9a-f]{2})", array)
)[:length]
return gzip.decompress(blob).decode("utf-8")
class WebConfigUiRuntimeTest(unittest.TestCase):
def setup_values(self):
sys.path.insert(0, str(ROOT / "scripts"))
try:
import webconfig_mock_server as mock
finally:
sys.path.pop(0)
config = mock.default_config(True)
config["wifi"]["ssid"] = "StoredNet"
config["wifi"]["pwd"] = "********"
status = {
"mode": "setup",
"auth": True,
"mqtt": False,
"cli": False,
"wifi_psk64": True,
"password_supported": True,
"needs_password": False,
"radio_optional": True,
"needs_setup": False,
"capabilities": 0,
"max_cmds": 16,
"name": "runtime-test",
"role": "Companion",
"board": "mock",
"fw": "v1.17.1-test",
"build_date": "runtime",
}
return status, config
def run_page(self, prelude, virtual_time=1500):
if not BROWSER:
self.skipTest("Chromium-family browser is unavailable")
source = embedded_page()
markers = ('<script>\n"use strict";', '<script>"use strict";')
marker = next((candidate for candidate in markers if candidate in source), None)
self.assertIsNotNone(marker)
page = source.replace(marker, prelude + marker, 1)
with tempfile.TemporaryDirectory(prefix="meshcore-webconfig-runtime-") as tmp:
tmp_path = Path(tmp)
html = tmp_path / "index.html"
profile = tmp_path / "profile"
profile.mkdir()
html.write_text(page, encoding="utf-8")
args = [
BROWSER,
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--no-default-browser-check",
"--user-data-dir=" + str(profile),
"--virtual-time-budget=" + str(virtual_time),
"--dump-dom",
html.as_uri(),
]
if os.name != "nt" and hasattr(os, "geteuid") and os.geteuid() == 0:
args.insert(1, "--no-sandbox")
result = subprocess.run(args, capture_output=True, timeout=30)
stderr = result.stderr.decode("utf-8", "replace")
self.assertEqual(result.returncode, 0, stderr)
dom = result.stdout.decode("utf-8", "replace")
self.assertNotIn("data-test-error=", dom)
return dom
def run_early_selection_case(self, explicit_password=None):
status, config = self.setup_values()
prelude = """
<script>
(function(){
var status=%s,config=%s;
function response(value){
return {ok:true,status:200,json:function(){return Promise.resolve(value)}};
}
window.fetch=function(path){
if(path==="/api/status")return Promise.resolve(response(status));
if(path==="/api/config")return new Promise(function(resolve){
setTimeout(function(){resolve(response(config))},500);
});
if(path.indexOf("/api/scan")===0)return Promise.resolve(response({
state:"done",networks:[{ssid:"EarlyNet",rssi:-40,enc:true,channel:6}]
}));
return Promise.resolve({ok:false,status:404,json:function(){return Promise.resolve({})}});
};
window.addEventListener("load",function(){
setTimeout(function(){
openScan("wz-ssid");
setTimeout(function(){
var network=document.querySelector("#scan-list .net");
if(!network){document.body.setAttribute("data-test-error","missing-network");return}
network.click();
var explicit=%s;
if(explicit!==null){
var pwd=document.getElementById("wz-wifi-pwd");
pwd.value=explicit;
pwd.dispatchEvent(new Event("input",{bubbles:true}));
}
},50);
},50);
setTimeout(function(){
var body=document.body,owns=Object.prototype.hasOwnProperty;
body.setAttribute("data-test-ssid",document.getElementById("wz-ssid").value);
body.setAttribute("data-test-wz-pwd",document.getElementById("wz-wifi-pwd").value);
body.setAttribute("data-test-app-pwd",document.getElementById("app-wifi-pwd").value);
body.setAttribute("data-test-orig-pwd",st.orig["wifi.pwd"]);
body.setAttribute("data-test-dirty-ssid",st.dirty["wifi.ssid"]||"");
body.setAttribute("data-test-dirty-pwd-present",
owns.call(st.dirty,"wifi.pwd")?"yes":"no");
body.setAttribute("data-test-dirty-pwd",st.dirty["wifi.pwd"]||"");
},900);
});
})();
</script>
""" % (json.dumps(status), json.dumps(config), json.dumps(explicit_password))
return self.run_page(prelude)
def test_early_new_ssid_clears_password_loaded_later(self):
dom = self.run_early_selection_case()
self.assertIn('data-test-ssid="EarlyNet"', dom)
self.assertIn('data-test-orig-pwd="********"', dom)
self.assertIn('data-test-wz-pwd=""', dom)
self.assertIn('data-test-app-pwd=""', dom)
self.assertIn('data-test-dirty-ssid="EarlyNet"', dom)
self.assertIn('data-test-dirty-pwd-present="yes"', dom)
self.assertIn('data-test-dirty-pwd=""', dom)
def test_early_explicit_password_is_preserved(self):
dom = self.run_early_selection_case("new-secret")
self.assertIn('data-test-ssid="EarlyNet"', dom)
self.assertIn('data-test-wz-pwd="new-secret"', dom)
self.assertIn('data-test-app-pwd="new-secret"', dom)
self.assertIn('data-test-dirty-pwd-present="yes"', dom)
self.assertIn('data-test-dirty-pwd="new-secret"', dom)
def test_setup_automatically_opens_embedded_scan_picker(self):
status, config = self.setup_values()
prelude = """
<script>
(function(){
var status=%s,config=%s;
function response(value){
return {ok:true,status:200,json:function(){return Promise.resolve(value)}};
}
window.fetch=function(path){
if(path==="/api/status")return Promise.resolve(response(status));
if(path==="/api/config")return new Promise(function(resolve){
setTimeout(function(){resolve(response(config))},100);
});
if(path.indexOf("/api/scan")===0)return Promise.resolve(response({
state:"done",networks:[{ssid:"NearbyNet",rssi:-35,enc:true,channel:11}]
}));
return Promise.resolve({ok:false,status:404,json:function(){return Promise.resolve({})}});
};
window.addEventListener("load",function(){
setTimeout(function(){
var body=document.body,panel=document.getElementById("scan-panel");
body.setAttribute("data-test-scan-open",panel.classList.contains("hide")?"no":"yes");
body.setAttribute("data-test-scan-hidden",panel.getAttribute("aria-hidden"));
body.setAttribute("data-test-scan-expanded",document.getElementById("wz-scan-btn").getAttribute("aria-expanded"));
body.setAttribute("data-test-scan-busy",document.getElementById("scan-list").getAttribute("aria-busy"));
body.setAttribute("data-test-network-count",document.querySelectorAll("#scan-list .net").length);
body.setAttribute("data-test-ssid",document.getElementById("wz-ssid").value);
body.setAttribute("data-test-pwd",document.getElementById("wz-wifi-pwd").value);
body.setAttribute("data-test-dirty-count",Object.keys(st.dirty).length);
},700);
});
})();
</script>
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-scan-open="yes"', dom)
self.assertIn('data-test-scan-hidden="false"', dom)
self.assertIn('data-test-scan-expanded="true"', dom)
self.assertIn('data-test-scan-busy="false"', dom)
self.assertIn('data-test-network-count="1"', dom)
self.assertIn('data-test-ssid="StoredNet"', dom)
self.assertIn('data-test-pwd="********"', dom)
self.assertIn('data-test-dirty-count="0"', dom)
def test_typed_new_ssid_clears_inherited_password_and_review(self):
status, config = self.setup_values()
prelude = """
<script>
(function(){
var status=%s,config=%s;
function response(value){
return {ok:true,status:200,json:function(){return Promise.resolve(value)}};
}
window.fetch=function(path){
if(path==="/api/status")return Promise.resolve(response(status));
if(path==="/api/config")return Promise.resolve(response(config));
if(path.indexOf("/api/scan")===0)return Promise.resolve(response({state:"done",networks:[]}));
return Promise.resolve({ok:false,status:404,json:function(){return Promise.resolve({})}});
};
window.addEventListener("load",function(){
setTimeout(function(){
var ssid=document.getElementById("wz-ssid");
ssid.value="TypedNet";
ssid.dispatchEvent(new Event("input",{bubbles:true}));
buildReview();
var body=document.body,owns=Object.prototype.hasOwnProperty;
body.setAttribute("data-test-ssid",ssid.value);
body.setAttribute("data-test-pwd",document.getElementById("wz-wifi-pwd").value);
body.setAttribute("data-test-dirty-pwd",owns.call(st.dirty,"wifi.pwd")?"yes":"no");
body.setAttribute("data-test-review-open",document.getElementById("wz-review").textContent.indexOf("(open network)")>=0?"yes":"no");
},400);
});
})();
</script>
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-ssid="TypedNet"', dom)
self.assertIn('data-test-pwd=""', dom)
self.assertIn('data-test-dirty-pwd="yes"', dom)
self.assertIn('data-test-review-open="yes"', dom)
def test_returning_to_original_ssid_restores_masked_password(self):
status, config = self.setup_values()
prelude = """
<script>
(function(){
var status=%s,config=%s;
function response(value){
return {ok:true,status:200,json:function(){return Promise.resolve(value)}};
}
window.fetch=function(path){
if(path==="/api/status")return Promise.resolve(response(status));
if(path==="/api/config")return Promise.resolve(response(config));
if(path.indexOf("/api/scan")===0)return Promise.resolve(response({state:"done",networks:[]}));
return Promise.resolve({ok:false,status:404,json:function(){return Promise.resolve({})}});
};
window.addEventListener("load",function(){
setTimeout(function(){
var ssid=document.getElementById("wz-ssid");
ssid.value="OtherNet";
ssid.dispatchEvent(new Event("input",{bubbles:true}));
ssid.value="StoredNet";
ssid.dispatchEvent(new Event("input",{bubbles:true}));
var body=document.body,owns=Object.prototype.hasOwnProperty;
body.setAttribute("data-test-pwd",document.getElementById("wz-wifi-pwd").value);
body.setAttribute("data-test-dirty-ssid",owns.call(st.dirty,"wifi.ssid")?"yes":"no");
body.setAttribute("data-test-dirty-pwd",owns.call(st.dirty,"wifi.pwd")?"yes":"no");
body.setAttribute("data-test-auto-cleared",st.wifiPasswordAutoCleared?"yes":"no");
},400);
});
})();
</script>
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-pwd="********"', dom)
self.assertIn('data-test-dirty-ssid="no"', dom)
self.assertIn('data-test-dirty-pwd="no"', dom)
self.assertIn('data-test-auto-cleared="no"', dom)
def test_advanced_editor_edit_during_config_load_is_preserved(self):
status, config = self.setup_values()
status["mode"] = "lan"
prelude = """
<script>
(function(){
var status=%s,config=%s;
function response(value){
return {ok:true,status:200,json:function(){return Promise.resolve(value)}};
}
window.fetch=function(path){
if(path==="/api/status")return Promise.resolve(response(status));
if(path==="/api/config")return new Promise(function(resolve){
setTimeout(function(){resolve(response(config))},500);
});
return Promise.resolve({ok:false,status:404,json:function(){return Promise.resolve({})}});
};
window.addEventListener("load",function(){
setTimeout(function(){
var name=document.querySelector('#v-app [data-k="name"]');
name.value="Early App Edit";
name.dispatchEvent(new Event("input",{bubbles:true}));
},75);
setTimeout(function(){
var body=document.body,name=document.querySelector('#v-app [data-k="name"]');
body.setAttribute("data-test-name",name.value);
body.setAttribute("data-test-dirty-name",st.dirty.name||"");
body.setAttribute("data-test-capture",st.configLoadCapture===null?"clear":"set");
},850);
});
})();
</script>
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-name="Early App Edit"', dom)
self.assertIn('data-test-dirty-name="Early App Edit"', dom)
self.assertIn('data-test-capture="clear"', dom)
def test_second_load_auto_password_clear_remains_restorable(self):
status, config = self.setup_values()
prelude = """
<script>
(function(){
var status=%s,config=%s,configCalls=0;
function response(value){
return {ok:true,status:200,json:function(){return Promise.resolve(value)}};
}
window.fetch=function(path){
if(path==="/api/status")return Promise.resolve(response(status));
if(path==="/api/config"){
configCalls++;
if(configCalls===1)return Promise.resolve(response(config));
return new Promise(function(resolve){
setTimeout(function(){resolve(response(config))},400);
});
}
if(path.indexOf("/api/scan")===0)return Promise.resolve(response({state:"done",networks:[]}));
return Promise.resolve({ok:false,status:404,json:function(){return Promise.resolve({})}});
};
window.addEventListener("load",function(){
// The setup wizard's first load has populated both password fields with
// the stored-secret sentinel. Entering the advanced editor starts a second
// captured load; change its SSID while that request is still outstanding.
setTimeout(function(){enterApp()},75);
setTimeout(function(){
var ssid=document.getElementById("app-ssid");
ssid.value="OtherNet";
ssid.dispatchEvent(new Event("input",{bubbles:true}));
},125);
setTimeout(function(){
var body=document.body,owns=Object.prototype.hasOwnProperty;
var ssid=document.getElementById("app-ssid");
var pwd=document.getElementById("app-wifi-pwd");
ssid.value="StoredNet";
ssid.dispatchEvent(new Event("input",{bubbles:true}));
body.setAttribute("data-test-restored-pwd",pwd.value);
body.setAttribute("data-test-restored-dirty-ssid",
owns.call(st.dirty,"wifi.ssid")?"yes":"no");
body.setAttribute("data-test-restored-dirty-pwd",
owns.call(st.dirty,"wifi.pwd")?"yes":"no");
body.setAttribute("data-test-restored-auto",
st.wifiPasswordAutoCleared?"yes":"no");
// A real password edit after an automatic clear is authoritative. It
// must not be replaced with the sentinel when the SSID returns to the
// stored network.
ssid.value="OtherNet";
ssid.dispatchEvent(new Event("input",{bubbles:true}));
pwd.value="manual-secret";
pwd.dispatchEvent(new Event("input",{bubbles:true}));
ssid.value="StoredNet";
ssid.dispatchEvent(new Event("input",{bubbles:true}));
body.setAttribute("data-test-manual-pwd",pwd.value);
body.setAttribute("data-test-manual-dirty-ssid",
owns.call(st.dirty,"wifi.ssid")?"yes":"no");
body.setAttribute("data-test-manual-dirty-pwd",
owns.call(st.dirty,"wifi.pwd")?"yes":"no");
body.setAttribute("data-test-manual-auto",
st.wifiPasswordAutoCleared?"yes":"no");
body.setAttribute("data-test-config-calls",String(configCalls));
body.setAttribute("data-test-capture",
st.configLoadCapture===null?"clear":"set");
},700);
});
})();
</script>
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-restored-pwd="********"', dom)
self.assertIn('data-test-restored-dirty-ssid="no"', dom)
self.assertIn('data-test-restored-dirty-pwd="no"', dom)
self.assertIn('data-test-restored-auto="no"', dom)
self.assertIn('data-test-manual-pwd="manual-secret"', dom)
self.assertIn('data-test-manual-dirty-ssid="no"', dom)
self.assertIn('data-test-manual-dirty-pwd="yes"', dom)
self.assertIn('data-test-manual-auto="no"', dom)
self.assertIn('data-test-config-calls="2"', dom)
self.assertIn('data-test-capture="clear"', dom)
def test_delayed_app_load_preserves_only_edited_radio_field(self):
status, config = self.setup_values()
status["mode"] = "lan"
prelude = """
<script>
(function(){
var status=%s,config=%s;
function response(value){
return {ok:true,status:200,json:function(){return Promise.resolve(value)}};
}
window.fetch=function(path){
if(path==="/api/status")return Promise.resolve(response(status));
if(path==="/api/config")return new Promise(function(resolve){
setTimeout(function(){resolve(response(config))},500);
});
return Promise.resolve({ok:false,status:404,json:function(){return Promise.resolve({})}});
};
window.addEventListener("load",function(){
setTimeout(function(){
var sf=document.querySelector('#v-app [data-rg="sf"]');
sf.value="9";
sf.dispatchEvent(new Event("input",{bubbles:true}));
},75);
setTimeout(function(){
var body=document.body;
function radioValue(key){
return document.querySelector('#v-app [data-rg="'+key+'"]').value;
}
body.setAttribute("data-test-radio-freq",radioValue("freq"));
body.setAttribute("data-test-radio-bw",radioValue("bw"));
body.setAttribute("data-test-radio-sf",radioValue("sf"));
body.setAttribute("data-test-radio-cr",radioValue("cr"));
body.setAttribute("data-test-radio-orig",st.orig.radio);
body.setAttribute("data-test-radio-dirty",st.dirty.radio||"");
body.setAttribute("data-test-capture",
st.configLoadCapture===null?"clear":"set");
},850);
});
})();
</script>
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-radio-freq="910.525"', dom)
self.assertIn('data-test-radio-bw="62.5"', dom)
self.assertIn('data-test-radio-sf="9"', dom)
self.assertIn('data-test-radio-cr="5"', dom)
self.assertIn('data-test-radio-orig="910.525,62.5,7,5"', dom)
self.assertIn('data-test-radio-dirty="910.525,62.5,9,5"', dom)
self.assertIn('data-test-capture="clear"', dom)
def test_closed_picker_ignores_late_scan_response(self):
status, config = self.setup_values()
prelude = """
<script>
(function(){
var status=%s,config=%s;
function response(value){
return {ok:true,status:200,json:function(){return Promise.resolve(value)}};
}
window.fetch=function(path){
if(path==="/api/status")return Promise.resolve(response(status));
if(path==="/api/config")return Promise.resolve(response(config));
if(path.indexOf("/api/scan")===0)return new Promise(function(resolve){
setTimeout(function(){resolve(response({
state:"done",networks:[{ssid:"LateNet",rssi:-40,enc:true,channel:6}]
}))},450);
});
return Promise.resolve({ok:false,status:404,json:function(){return Promise.resolve({})}});
};
window.addEventListener("load",function(){
setTimeout(function(){
closeScan();
document.getElementById("scan-list").textContent="closed";
},100);
setTimeout(function(){
var body=document.body,panel=document.getElementById("scan-panel");
body.setAttribute("data-test-scan-hidden",panel.getAttribute("aria-hidden"));
body.setAttribute("data-test-scan-expanded",document.getElementById("wz-scan-btn").getAttribute("aria-expanded"));
body.setAttribute("data-test-network-count",document.querySelectorAll("#scan-list .net").length);
body.setAttribute("data-test-scan-text",document.getElementById("scan-list").textContent);
},800);
});
})();
</script>
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-scan-hidden="true"', dom)
self.assertIn('data-test-scan-expanded="false"', dom)
self.assertIn('data-test-network-count="0"', dom)
self.assertIn('data-test-scan-text="closed"', dom)
def test_newer_config_load_wins_when_older_reply_finishes_last(self):
status, old_config = self.setup_values()
new_config = json.loads(json.dumps(old_config))
new_config["wifi"]["ssid"] = "AppNet"
prelude = """
<script>
(function(){
var status=%s,oldConfig=%s,newConfig=%s,configCalls=0;
function response(value){
return {ok:true,status:200,json:function(){return Promise.resolve(value)}};
}
window.fetch=function(path){
if(path==="/api/status")return Promise.resolve(response(status));
if(path==="/api/config"){
configCalls++;
if(configCalls===1)return new Promise(function(resolve){
setTimeout(function(){resolve(response(oldConfig))},500);
});
return Promise.resolve(response(newConfig));
}
if(path.indexOf("/api/scan")===0)return Promise.resolve(response({state:"done",networks:[]}));
return Promise.resolve({ok:false,status:404,json:function(){return Promise.resolve({})}});
};
window.addEventListener("load",function(){
setTimeout(function(){enterApp()},75);
setTimeout(function(){
var body=document.body;
body.setAttribute("data-test-config-calls",configCalls);
body.setAttribute("data-test-orig-ssid",st.orig["wifi.ssid"]);
body.setAttribute("data-test-field-ssid",document.getElementById("app-ssid").value);
body.setAttribute("data-test-scan-hidden",document.getElementById("scan-panel").getAttribute("aria-hidden"));
body.setAttribute("data-test-capture",st.configLoadCapture===null?"clear":"set");
},800);
});
})();
</script>
""" % (json.dumps(status), json.dumps(old_config), json.dumps(new_config))
dom = self.run_page(prelude)
self.assertIn('data-test-config-calls="2"', dom)
self.assertIn('data-test-orig-ssid="AppNet"', dom)
self.assertIn('data-test-field-ssid="AppNet"', dom)
self.assertIn('data-test-scan-hidden="true"', dom)
self.assertIn('data-test-capture="clear"', dom)
if __name__ == "__main__":
unittest.main()
+847
View File
@@ -0,0 +1,847 @@
#!/usr/bin/env python3
"""Stress an ESP32 MeshCore Companion serial session without radio traffic.
This utility talks directly to the Companion framing protocol with pyserial. It
has two complementary modes:
* ``persistent`` opens one handle, performs APP_START, then cycles through
read-only local queries.
* ``reopen`` creates and synchronously closes a fresh handle for every
APP_START transaction.
The selected requests only inspect local device state. They do not send mesh
packets, advertisements, messages, or other LoRa traffic.
Example (run only when the intended board and COM port have been confirmed)::
python tools/hil/esp32_companion_serial_stress.py \
--port COM7 --mode both --count 100
Exactly one JSON summary is written to stdout. Diagnostics requested with
``--verbose`` go to stderr, and any failed transaction produces a nonzero exit.
"""
from __future__ import annotations
import argparse
from dataclasses import asdict, dataclass
import json
import struct
import sys
import time
from typing import Any, Callable, Dict, Optional, Sequence, Tuple
HOST_FRAME_MARKER = ord("<")
DEVICE_FRAME_MARKER = ord(">")
MAX_FRAME_SIZE = 176
CMD_APP_START = 1
CMD_GET_DEVICE_TIME = 5
CMD_GET_BATT_AND_STORAGE = 20
CMD_DEVICE_QUERY = 22
CMD_GET_STATS = 56
STATS_TYPE_CORE = 0
RESP_CODE_ERR = 1
RESP_CODE_SELF_INFO = 5
RESP_CODE_CURR_TIME = 9
RESP_CODE_BATT_AND_STORAGE = 12
RESP_CODE_DEVICE_INFO = 13
RESP_CODE_STATS = 24
ADV_TYPE_CHAT = 1
COMPANION_PROTOCOL_VERSION = 14
class HilError(RuntimeError):
"""Base class for an actionable HIL failure."""
class FrameTimeout(HilError):
"""Raised when no complete valid frame arrives before the deadline."""
class ProtocolError(HilError):
"""Raised when the peer returns a malformed or unexpected response."""
class DependencyError(HilError):
"""Raised when the runtime serial dependency is unavailable."""
@dataclass(frozen=True)
class StressConfig:
port: str
mode: str = "both"
count: int = 100
baudrate: int = 115200
app_name: str = "MeshCore-HIL-Stress"
queries: Tuple[str, ...] = (
"device_info",
"device_time",
"battery_storage",
"core_stats",
)
response_timeout: float = 2.0
read_poll_timeout: float = 0.05
write_timeout: float = 2.0
open_delay: float = 0.25
request_delay: float = 0.02
cycle_delay: float = 0.05
close_delay: float = 0.10
verbose: bool = False
@dataclass
class TransportCounters:
ports_opened: int = 0
ports_closed: int = 0
request_frames_sent: int = 0
response_frames_received: int = 0
request_bytes_sent: int = 0
response_payload_bytes: int = 0
discarded_prefix_bytes: int = 0
invalid_length_candidates: int = 0
ignored_push_frames: int = 0
@dataclass(frozen=True)
class RequestSpec:
name: str
payload: bytes
response_type: int
validator: Callable[[bytes], Dict[str, Any]]
def _require(condition: bool, message: str) -> None:
if not condition:
raise ProtocolError(message)
def _decode_fixed_text(field: bytes, field_name: str) -> str:
value, separator, padding = field.partition(b"\0")
if separator:
_require(
not any(padding),
f"{field_name} has nonzero bytes after its terminator",
)
try:
return value.decode("utf-8")
except UnicodeDecodeError as exc:
raise ProtocolError(f"{field_name} is not valid UTF-8") from exc
def validate_app_start_response(payload: bytes) -> Dict[str, Any]:
_require(payload and payload[0] == RESP_CODE_SELF_INFO,
"APP_START response type is not SELF_INFO")
_require(58 <= len(payload) <= 89,
f"SELF_INFO length {len(payload)} is outside 58..89")
_require(payload[1] == ADV_TYPE_CHAT,
f"SELF_INFO advert type is {payload[1]}, expected chat")
public_key = payload[4:36]
_require(len(public_key) == 32, "SELF_INFO public key is truncated")
_require(any(public_key) and any(value != 0xFF for value in public_key),
"SELF_INFO public key is an erased value")
latitude, longitude = struct.unpack_from("<ii", payload, 36)
_require(-90_000_000 <= latitude <= 90_000_000,
f"SELF_INFO latitude {latitude} is invalid")
_require(-180_000_000 <= longitude <= 180_000_000,
f"SELF_INFO longitude {longitude} is invalid")
frequency_khz, bandwidth_hz = struct.unpack_from("<II", payload, 48)
spreading_factor = payload[56]
coding_rate = payload[57]
_require(frequency_khz > 0, "SELF_INFO frequency is zero")
_require(bandwidth_hz > 0, "SELF_INFO bandwidth is zero")
_require(5 <= spreading_factor <= 12,
f"SELF_INFO spreading factor {spreading_factor} is invalid")
_require(5 <= coding_rate <= 8,
f"SELF_INFO coding rate {coding_rate} is invalid")
try:
node_name = payload[58:].decode("utf-8")
except UnicodeDecodeError as exc:
raise ProtocolError("SELF_INFO node name is not valid UTF-8") from exc
return {
"node_name": node_name,
"public_key_prefix": public_key[:6].hex(),
"latitude_e6": latitude,
"longitude_e6": longitude,
"frequency_khz": frequency_khz,
"bandwidth_hz": bandwidth_hz,
"spreading_factor": spreading_factor,
"coding_rate": coding_rate,
}
def validate_device_time_response(payload: bytes) -> Dict[str, Any]:
_require(len(payload) == 5,
f"CURR_TIME length is {len(payload)}, expected 5")
_require(payload[0] == RESP_CODE_CURR_TIME,
"device-time response type is not CURR_TIME")
return {"unix_time": struct.unpack_from("<I", payload, 1)[0]}
def validate_battery_storage_response(payload: bytes) -> Dict[str, Any]:
_require(len(payload) == 11,
f"BATT_AND_STORAGE length is {len(payload)}, expected 11")
_require(payload[0] == RESP_CODE_BATT_AND_STORAGE,
"battery/storage response type is not BATT_AND_STORAGE")
battery_mv, used_kib, total_kib = struct.unpack_from("<HII", payload, 1)
_require(total_kib == 0 or used_kib <= total_kib,
f"storage use {used_kib} KiB exceeds total {total_kib} KiB")
return {
"battery_mv": battery_mv,
"storage_used_kib": used_kib,
"storage_total_kib": total_kib,
}
def validate_device_info_response(payload: bytes) -> Dict[str, Any]:
_require(len(payload) == 82,
f"DEVICE_INFO length is {len(payload)}, expected 82")
_require(payload[0] == RESP_CODE_DEVICE_INFO,
"device-query response type is not DEVICE_INFO")
_require(payload[1] >= 1, "DEVICE_INFO protocol version is zero")
_require(payload[2] > 0, "DEVICE_INFO contact capacity is zero")
_require(payload[3] > 0, "DEVICE_INFO channel capacity is zero")
_require(payload[80] in (0, 1),
f"DEVICE_INFO repeat flag {payload[80]} is invalid")
_require(payload[81] <= 2,
f"DEVICE_INFO path-hash mode {payload[81]} is invalid")
build_date = _decode_fixed_text(payload[8:20], "build date")
manufacturer = _decode_fixed_text(payload[20:60], "manufacturer")
firmware = _decode_fixed_text(payload[60:80], "firmware version")
_require(bool(manufacturer), "DEVICE_INFO manufacturer is empty")
_require(bool(firmware), "DEVICE_INFO firmware version is empty")
return {
"protocol_version": payload[1],
"contact_capacity": payload[2] * 2,
"channel_capacity": payload[3],
"ble_pin": struct.unpack_from("<I", payload, 4)[0],
"build_date": build_date,
"manufacturer": manufacturer,
"firmware": firmware,
"repeat_enabled": bool(payload[80]),
"path_hash_mode": payload[81],
}
def validate_core_stats_response(payload: bytes) -> Dict[str, Any]:
_require(len(payload) == 11,
f"core STATS length is {len(payload)}, expected 11")
_require(payload[0] == RESP_CODE_STATS,
"core-stats response type is not STATS")
_require(payload[1] == STATS_TYPE_CORE,
f"STATS subtype is {payload[1]}, expected core")
battery_mv, uptime_seconds, error_flags, queue_length = struct.unpack_from(
"<HIHB", payload, 2
)
return {
"battery_mv": battery_mv,
"uptime_seconds": uptime_seconds,
"error_flags": error_flags,
"outbound_queue_length": queue_length,
}
SAFE_QUERY_SPECS: Dict[str, RequestSpec] = {
"device_info": RequestSpec(
"device_info",
bytes((CMD_DEVICE_QUERY, COMPANION_PROTOCOL_VERSION)),
RESP_CODE_DEVICE_INFO,
validate_device_info_response,
),
"device_time": RequestSpec(
"device_time",
bytes((CMD_GET_DEVICE_TIME,)),
RESP_CODE_CURR_TIME,
validate_device_time_response,
),
"battery_storage": RequestSpec(
"battery_storage",
bytes((CMD_GET_BATT_AND_STORAGE,)),
RESP_CODE_BATT_AND_STORAGE,
validate_battery_storage_response,
),
"core_stats": RequestSpec(
"core_stats",
bytes((CMD_GET_STATS, STATS_TYPE_CORE)),
RESP_CODE_STATS,
validate_core_stats_response,
),
}
def make_app_start_spec(app_name: str) -> RequestSpec:
try:
encoded_name = app_name.encode("utf-8")
except UnicodeEncodeError as exc:
raise ValueError("app name cannot be encoded as UTF-8") from exc
payload = bytes((CMD_APP_START,)) + (b"\0" * 7) + encoded_name
if len(payload) > MAX_FRAME_SIZE:
raise ValueError(
f"APP_START payload is {len(payload)} bytes; maximum is "
f"{MAX_FRAME_SIZE}"
)
return RequestSpec(
"app_start",
payload,
RESP_CODE_SELF_INFO,
validate_app_start_response,
)
def encode_host_frame(payload: bytes) -> bytes:
if not 1 <= len(payload) <= MAX_FRAME_SIZE:
raise ValueError(
f"Companion payload length must be 1..{MAX_FRAME_SIZE}, got "
f"{len(payload)}"
)
return bytes((HOST_FRAME_MARKER,)) + struct.pack("<H", len(payload)) + payload
class DeviceFrameReader:
"""Incrementally find validated device frames among optional ASCII noise."""
def __init__(self, counters: TransportCounters) -> None:
self._buffer = bytearray()
self._counters = counters
def _extract_frame(self) -> Optional[bytes]:
while True:
marker_index = self._buffer.find(bytes((DEVICE_FRAME_MARKER,)))
if marker_index < 0:
self._counters.discarded_prefix_bytes += len(self._buffer)
self._buffer.clear()
return None
if marker_index:
self._counters.discarded_prefix_bytes += marker_index
del self._buffer[:marker_index]
if len(self._buffer) < 3:
return None
payload_length = self._buffer[1] | (self._buffer[2] << 8)
if not 1 <= payload_length <= MAX_FRAME_SIZE:
# Drop only this marker. A later byte consumed as part of the
# invalid candidate may itself be the start of a valid frame.
self._counters.invalid_length_candidates += 1
del self._buffer[0]
continue
frame_length = 3 + payload_length
if len(self._buffer) < frame_length:
return None
payload = bytes(self._buffer[3:frame_length])
del self._buffer[:frame_length]
self._counters.response_frames_received += 1
self._counters.response_payload_bytes += len(payload)
return payload
def read_frame(
self,
port: Any,
timeout: float,
clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
) -> bytes:
deadline = clock() + timeout
while True:
payload = self._extract_frame()
if payload is not None:
return payload
remaining = deadline - clock()
if remaining <= 0:
raise FrameTimeout(
"timed out waiting for a valid '>' Companion frame "
f"({len(self._buffer)} buffered byte(s))"
)
waiting = max(0, int(getattr(port, "in_waiting", 0)))
data = port.read(min(max(waiting, 1), 512))
if data:
self._buffer.extend(data)
else:
sleep(min(0.001, remaining))
def _send_request(port: Any, spec: RequestSpec,
counters: TransportCounters) -> None:
frame = encode_host_frame(spec.payload)
written = port.write(frame)
if written != len(frame):
raise HilError(
f"{spec.name}: serial write accepted {written!r} of "
f"{len(frame)} bytes"
)
port.flush()
counters.request_frames_sent += 1
counters.request_bytes_sent += len(frame)
def transact(
port: Any,
reader: DeviceFrameReader,
spec: RequestSpec,
timeout: float,
counters: TransportCounters,
clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
) -> Dict[str, Any]:
_send_request(port, spec, counters)
deadline = clock() + timeout
while True:
remaining = deadline - clock()
if remaining <= 0:
raise FrameTimeout(f"{spec.name}: expected response did not arrive")
payload = reader.read_frame(port, remaining, clock=clock, sleep=sleep)
response_type = payload[0]
if response_type >= 0x80:
# Push notifications are valid unsolicited frames. They are not a
# response to this transaction and may be skipped safely.
counters.ignored_push_frames += 1
continue
if response_type == RESP_CODE_ERR:
error_code = payload[1] if len(payload) >= 2 else None
raise ProtocolError(
f"{spec.name}: device returned ERR code {error_code!r}"
)
if response_type != spec.response_type:
raise ProtocolError(
f"{spec.name}: response type 0x{response_type:02X}, "
f"expected 0x{spec.response_type:02X}"
)
return spec.validator(payload)
def _validate_config(config: StressConfig) -> None:
if not config.port:
raise ValueError("port must not be empty")
if config.mode not in ("persistent", "reopen", "both"):
raise ValueError(f"unsupported mode {config.mode!r}")
if config.count < 1:
raise ValueError("count must be at least 1")
if config.baudrate < 1:
raise ValueError("baudrate must be positive")
for name in config.queries:
if name not in SAFE_QUERY_SPECS:
raise ValueError(f"unknown or unsafe query {name!r}")
if not config.queries:
raise ValueError("at least one safe query must be selected")
for name, value in (
("response_timeout", config.response_timeout),
("read_poll_timeout", config.read_poll_timeout),
("write_timeout", config.write_timeout),
):
if value <= 0:
raise ValueError(f"{name} must be greater than zero")
for name, value in (
("open_delay", config.open_delay),
("request_delay", config.request_delay),
("cycle_delay", config.cycle_delay),
("close_delay", config.close_delay),
):
if value < 0:
raise ValueError(f"{name} must not be negative")
def _make_pyserial_factory() -> Callable[[StressConfig], Any]:
try:
import serial # type: ignore
except ImportError as exc:
raise DependencyError(
"pyserial is required; install it with 'python -m pip install "
"pyserial'"
) from exc
def factory(config: StressConfig) -> Any:
# Configure DTR/RTS while closed so opening a port never deliberately
# asserts a reset/boot strap control line.
port = serial.Serial()
port.port = config.port
port.baudrate = config.baudrate
port.timeout = min(config.read_poll_timeout, config.response_timeout)
port.write_timeout = config.write_timeout
port.dtr = False
port.rts = False
port.open()
return port
return factory
def _open_prepared_port(
config: StressConfig,
serial_factory: Callable[[StressConfig], Any],
counters: TransportCounters,
sleep: Callable[[float], None],
) -> Any:
port = serial_factory(config)
if not bool(getattr(port, "is_open", True)):
raise HilError(f"serial factory returned a closed port for {config.port}")
counters.ports_opened += 1
try:
if config.open_delay:
sleep(config.open_delay)
port.reset_input_buffer()
port.reset_output_buffer()
except BaseException as prepare_exc:
try:
_close_port_synchronously(port, counters)
except BaseException as close_exc:
raise HilError(
f"serial preparation failed ({prepare_exc}); synchronous "
f"close also failed ({close_exc})"
) from close_exc
raise
return port
def _close_port_synchronously(port: Any, counters: TransportCounters) -> None:
flush_error: Optional[BaseException] = None
try:
port.flush()
except BaseException as exc: # close the OS handle even after flush failure
flush_error = exc
try:
port.close()
except BaseException as exc:
if flush_error is not None:
raise HilError(
f"serial flush failed ({flush_error}); close also failed ({exc})"
) from exc
raise
if bool(getattr(port, "is_open", False)):
raise HilError("serial close returned while the port remained open")
counters.ports_closed += 1
if flush_error is not None:
raise HilError(f"serial flush before close failed: {flush_error}")
def _new_phase(cycles: int) -> Dict[str, Any]:
return {
"cycles_requested": cycles,
"cycles_started": 0,
"cycles_completed": 0,
"requests_completed": 0,
"responses_by_request": {},
"last_validated_payload": {},
}
def _record_response(phase: Dict[str, Any], name: str,
parsed: Dict[str, Any]) -> None:
phase["requests_completed"] += 1
by_request = phase["responses_by_request"]
by_request[name] = by_request.get(name, 0) + 1
phase["last_validated_payload"][name] = parsed
def _verbose(config: StressConfig, message: str) -> None:
if config.verbose:
print(message, file=sys.stderr, flush=True)
def _run_persistent(
config: StressConfig,
serial_factory: Callable[[StressConfig], Any],
counters: TransportCounters,
phase: Dict[str, Any],
clock: Callable[[], float],
sleep: Callable[[float], None],
) -> None:
app_start = make_app_start_spec(config.app_name)
query_specs = [SAFE_QUERY_SPECS[name] for name in config.queries]
port = None
pending_error: Optional[BaseException] = None
reader = DeviceFrameReader(counters)
try:
port = _open_prepared_port(config, serial_factory, counters, sleep)
parsed = transact(
port, reader, app_start, config.response_timeout, counters,
clock=clock, sleep=sleep,
)
_record_response(phase, app_start.name, parsed)
_verbose(config, "persistent APP_START validated")
for cycle in range(config.count):
phase["cycles_started"] += 1
# Rotate the same read-only set so adjacent command transitions
# differ from one cycle to the next without randomizing a failure.
offset = cycle % len(query_specs)
ordered = query_specs[offset:] + query_specs[:offset]
for request_index, spec in enumerate(ordered):
parsed = transact(
port, reader, spec, config.response_timeout, counters,
clock=clock, sleep=sleep,
)
_record_response(phase, spec.name, parsed)
if config.request_delay and request_index + 1 < len(ordered):
sleep(config.request_delay)
phase["cycles_completed"] += 1
_verbose(
config,
f"persistent cycle {cycle + 1}/{config.count} validated",
)
if config.cycle_delay and cycle + 1 < config.count:
sleep(config.cycle_delay)
except BaseException as exc:
pending_error = exc
finally:
if port is not None:
try:
_close_port_synchronously(port, counters)
except BaseException as close_exc:
if pending_error is None:
pending_error = close_exc
else:
pending_error = HilError(
f"{pending_error}; synchronous close also failed: "
f"{close_exc}"
)
if pending_error is not None:
raise pending_error
def _run_reopen(
config: StressConfig,
serial_factory: Callable[[StressConfig], Any],
counters: TransportCounters,
phase: Dict[str, Any],
clock: Callable[[], float],
sleep: Callable[[float], None],
) -> None:
app_start = make_app_start_spec(config.app_name)
for cycle in range(config.count):
phase["cycles_started"] += 1
port = None
pending_error: Optional[BaseException] = None
try:
port = _open_prepared_port(config, serial_factory, counters, sleep)
reader = DeviceFrameReader(counters)
parsed = transact(
port, reader, app_start, config.response_timeout, counters,
clock=clock, sleep=sleep,
)
_record_response(phase, app_start.name, parsed)
except BaseException as exc:
pending_error = exc
finally:
if port is not None:
try:
_close_port_synchronously(port, counters)
except BaseException as close_exc:
if pending_error is None:
pending_error = close_exc
else:
pending_error = HilError(
f"{pending_error}; synchronous close also failed: "
f"{close_exc}"
)
if pending_error is not None:
raise pending_error
phase["cycles_completed"] += 1
_verbose(config, f"reopen cycle {cycle + 1}/{config.count} validated")
if config.close_delay and cycle + 1 < config.count:
sleep(config.close_delay)
def run_stress(
config: StressConfig,
serial_factory: Optional[Callable[[StressConfig], Any]] = None,
clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
) -> Dict[str, Any]:
"""Run selected phases and always return a JSON-serializable summary."""
counters = TransportCounters()
phases: Dict[str, Dict[str, Any]] = {}
if config.mode in ("persistent", "both"):
phases["persistent"] = _new_phase(config.count)
if config.mode in ("reopen", "both"):
phases["reopen"] = _new_phase(config.count)
summary: Dict[str, Any] = {
"schema_version": 1,
"tool": "esp32_companion_serial_stress",
"ok": False,
"parameters": {
**asdict(config),
"queries": list(config.queries),
},
"phases": phases,
"transport": {},
"failure": None,
"elapsed_seconds": 0.0,
}
started = clock()
active_phase = "setup"
try:
_validate_config(config)
if serial_factory is None:
serial_factory = _make_pyserial_factory()
if "persistent" in phases:
active_phase = "persistent"
_run_persistent(
config, serial_factory, counters, phases[active_phase],
clock, sleep,
)
if "reopen" in phases:
active_phase = "reopen"
_run_reopen(
config, serial_factory, counters, phases[active_phase],
clock, sleep,
)
summary["ok"] = True
except KeyboardInterrupt:
summary["failure"] = {
"phase": active_phase,
"type": "KeyboardInterrupt",
"message": "interrupted by user",
}
except BaseException as exc:
summary["failure"] = {
"phase": active_phase,
"type": type(exc).__name__,
"message": str(exc),
}
finally:
summary["transport"] = asdict(counters)
summary["elapsed_seconds"] = round(max(0.0, clock() - started), 6)
return summary
def _positive_int(value: str) -> int:
parsed = int(value, 10)
if parsed < 1:
raise argparse.ArgumentTypeError("must be at least 1")
return parsed
def _positive_float(value: str) -> float:
parsed = float(value)
if parsed <= 0:
raise argparse.ArgumentTypeError("must be greater than zero")
return parsed
def _nonnegative_float(value: str) -> float:
parsed = float(value)
if parsed < 0:
raise argparse.ArgumentTypeError("must not be negative")
return parsed
def _safe_query_list(value: str) -> Tuple[str, ...]:
names = tuple(item.strip() for item in value.split(",") if item.strip())
if not names:
raise argparse.ArgumentTypeError("select at least one query")
unknown = [name for name in names if name not in SAFE_QUERY_SPECS]
if unknown:
raise argparse.ArgumentTypeError(
"unknown query(s): " + ", ".join(unknown)
)
if len(set(names)) != len(names):
raise argparse.ArgumentTypeError("query names must not be repeated")
return names
def build_argument_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Stress an ESP32 MeshCore Companion COM port using APP_START and "
"read-only, non-radio framed queries."
)
)
parser.add_argument("--port", required=True, help="Windows COM port, e.g. COM7")
parser.add_argument(
"--mode", choices=("persistent", "reopen", "both"), default="both",
help="session pattern to exercise (default: both)",
)
parser.add_argument(
"--count", type=_positive_int, default=100,
help="cycles per selected mode (default: 100)",
)
parser.add_argument("--baudrate", type=_positive_int, default=115200)
parser.add_argument("--app-name", default="MeshCore-HIL-Stress")
parser.add_argument(
"--queries", type=_safe_query_list,
default=tuple(SAFE_QUERY_SPECS),
metavar="LIST",
help=(
"comma-separated safe persistent queries: "
+ ",".join(SAFE_QUERY_SPECS)
),
)
parser.add_argument(
"--response-timeout", type=_positive_float, default=2.0,
help="whole-response deadline in seconds",
)
parser.add_argument(
"--read-poll-timeout", type=_positive_float, default=0.05,
help="pyserial read poll timeout in seconds",
)
parser.add_argument(
"--write-timeout", type=_positive_float, default=2.0,
help="pyserial write timeout in seconds",
)
parser.add_argument(
"--open-delay", type=_nonnegative_float, default=0.25,
help="settle delay after each open",
)
parser.add_argument(
"--request-delay", type=_nonnegative_float, default=0.02,
help="delay between persistent requests",
)
parser.add_argument(
"--cycle-delay", type=_nonnegative_float, default=0.05,
help="delay between persistent cycles",
)
parser.add_argument(
"--close-delay", type=_nonnegative_float, default=0.10,
help="delay after each completed close/reopen cycle",
)
parser.add_argument(
"--verbose", action="store_true",
help="write per-cycle progress to stderr",
)
parser.add_argument(
"--pretty", action="store_true",
help="indent the final JSON summary",
)
return parser
def main(argv: Optional[Sequence[str]] = None) -> int:
args = build_argument_parser().parse_args(argv)
config = StressConfig(
port=args.port,
mode=args.mode,
count=args.count,
baudrate=args.baudrate,
app_name=args.app_name,
queries=args.queries,
response_timeout=args.response_timeout,
read_poll_timeout=args.read_poll_timeout,
write_timeout=args.write_timeout,
open_delay=args.open_delay,
request_delay=args.request_delay,
cycle_delay=args.cycle_delay,
close_delay=args.close_delay,
verbose=args.verbose,
)
summary = run_stress(config)
print(json.dumps(summary, indent=2 if args.pretty else None, sort_keys=True))
return 0 if summary["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""Offline tests for esp32_companion_serial_stress.py."""
from __future__ import annotations
from contextlib import redirect_stdout
import io
import json
import struct
import unittest
from unittest import mock
import esp32_companion_serial_stress as hil
def device_frame(payload: bytes) -> bytes:
return b">" + struct.pack("<H", len(payload)) + payload
def fixed_text(value: str, size: int) -> bytes:
encoded = value.encode("utf-8")
if len(encoded) >= size:
raise ValueError("fixture string is too long")
return encoded + bytes(size - len(encoded))
def self_info_payload() -> bytes:
payload = bytearray(58)
payload[0:4] = bytes((hil.RESP_CODE_SELF_INFO, hil.ADV_TYPE_CHAT, 20, 22))
payload[4:36] = bytes(range(1, 33))
struct.pack_into("<ii", payload, 36, 47_606_200, -122_332_100)
payload[44:48] = bytes((1, 0, 0, 1))
struct.pack_into("<II", payload, 48, 910_525, 62_500)
payload[56:58] = bytes((7, 5))
return bytes(payload) + b"HIL Node"
def device_info_payload() -> bytes:
return b"".join(
(
bytes((hil.RESP_CODE_DEVICE_INFO, 14, 50, 40)),
struct.pack("<I", 123456),
fixed_text("01-Sep-2026", 12),
fixed_text("Heltec", 40),
fixed_text("v1.17.1-test", 20),
bytes((0, 2)),
)
)
def response_for(request: bytes) -> bytes:
command = request[0]
if command == hil.CMD_APP_START:
return self_info_payload()
if command == hil.CMD_DEVICE_QUERY:
return device_info_payload()
if command == hil.CMD_GET_DEVICE_TIME:
return struct.pack("<BI", hil.RESP_CODE_CURR_TIME, 1_788_200_000)
if command == hil.CMD_GET_BATT_AND_STORAGE:
return struct.pack(
"<BHII", hil.RESP_CODE_BATT_AND_STORAGE, 4012, 64, 1024
)
if command == hil.CMD_GET_STATS:
return struct.pack(
"<BBHIHB", hil.RESP_CODE_STATS, hil.STATS_TYPE_CORE,
4000, 1234, 0, 2,
)
raise AssertionError(f"unsafe or unexpected command {command}")
class FakeSerial:
def __init__(self, responder=response_for, prefix: bytes = b"") -> None:
self.responder = responder
self.prefix = prefix
self.rx = bytearray()
self.writes = []
self.is_open = True
self.flush_count = 0
self.input_resets = 0
self.output_resets = 0
self.close_count = 0
@property
def in_waiting(self) -> int:
return len(self.rx)
def write(self, frame: bytes) -> int:
self.writes.append(bytes(frame))
if len(frame) < 4 or frame[0] != hil.HOST_FRAME_MARKER:
raise AssertionError("invalid host frame")
length = frame[1] | (frame[2] << 8)
if length != len(frame) - 3:
raise AssertionError("invalid host length")
self.rx.extend(self.prefix)
self.rx.extend(device_frame(self.responder(frame[3:])))
return len(frame)
def read(self, size: int) -> bytes:
size = min(size, len(self.rx))
data = bytes(self.rx[:size])
del self.rx[:size]
return data
def flush(self) -> None:
self.flush_count += 1
def reset_input_buffer(self) -> None:
self.input_resets += 1
self.rx.clear()
def reset_output_buffer(self) -> None:
self.output_resets += 1
def close(self) -> None:
self.close_count += 1
self.is_open = False
class FakeFactory:
def __init__(self, responder=response_for, prefix: bytes = b"") -> None:
self.responder = responder
self.prefix = prefix
self.instances = []
def __call__(self, _config: hil.StressConfig) -> FakeSerial:
port = FakeSerial(self.responder, self.prefix)
self.instances.append(port)
return port
def fast_config(**overrides) -> hil.StressConfig:
values = {
"port": "FAKE",
"count": 2,
"open_delay": 0,
"request_delay": 0,
"cycle_delay": 0,
"close_delay": 0,
"response_timeout": 0.05,
"read_poll_timeout": 0.001,
"write_timeout": 0.05,
}
values.update(overrides)
return hil.StressConfig(**values)
class FrameProtocolTest(unittest.TestCase):
def test_host_frame_uses_marker_and_little_endian_length(self) -> None:
frame = hil.encode_host_frame(b"\x01\xAA\xBB")
self.assertEqual(frame, b"<\x03\x00\x01\xAA\xBB")
def test_reader_resynchronizes_after_ascii_prompt_candidate(self) -> None:
counters = hil.TransportCounters()
reader = hil.DeviceFrameReader(counters)
port = FakeSerial()
port.rx.extend(b"terminal banner\r\n> " + device_frame(
struct.pack("<BI", hil.RESP_CODE_CURR_TIME, 123)
))
payload = reader.read_frame(port, 0.05)
self.assertEqual(payload[0], hil.RESP_CODE_CURR_TIME)
self.assertGreater(counters.discarded_prefix_bytes, 0)
self.assertEqual(counters.invalid_length_candidates, 1)
def test_reader_rejects_stream_without_device_marker(self) -> None:
counters = hil.TransportCounters()
reader = hil.DeviceFrameReader(counters)
port = FakeSerial()
port.rx.extend(b"plain text only")
with self.assertRaises(hil.FrameTimeout):
reader.read_frame(port, 0.005)
class PayloadValidationTest(unittest.TestCase):
def test_all_safe_response_payloads_validate(self) -> None:
hil.validate_app_start_response(self_info_payload())
hil.validate_device_info_response(device_info_payload())
hil.validate_device_time_response(
struct.pack("<BI", hil.RESP_CODE_CURR_TIME, 123)
)
hil.validate_battery_storage_response(
struct.pack("<BHII", hil.RESP_CODE_BATT_AND_STORAGE, 4000, 1, 2)
)
hil.validate_core_stats_response(
struct.pack("<BBHIHB", hil.RESP_CODE_STATS, 0, 4000, 9, 0, 0)
)
def test_wrong_length_and_payload_are_rejected(self) -> None:
with self.assertRaises(hil.ProtocolError):
hil.validate_device_time_response(b"\x09\0\0\0")
invalid = bytearray(self_info_payload())
invalid[56] = 99
with self.assertRaises(hil.ProtocolError):
hil.validate_app_start_response(bytes(invalid))
def test_wrong_response_type_fails_transaction(self) -> None:
def wrong_response(_request: bytes) -> bytes:
return struct.pack("<BI", hil.RESP_CODE_CURR_TIME, 123)
port = FakeSerial(wrong_response)
counters = hil.TransportCounters()
reader = hil.DeviceFrameReader(counters)
with self.assertRaises(hil.ProtocolError):
hil.transact(
port,
reader,
hil.SAFE_QUERY_SPECS["battery_storage"],
0.05,
counters,
)
class StressModesTest(unittest.TestCase):
def test_persistent_mode_uses_one_synchronously_closed_port(self) -> None:
factory = FakeFactory(prefix=b"startup> ")
config = fast_config(mode="persistent", count=3)
summary = hil.run_stress(config, serial_factory=factory, sleep=lambda _: None)
self.assertTrue(summary["ok"], summary["failure"])
self.assertEqual(len(factory.instances), 1)
port = factory.instances[0]
self.assertFalse(port.is_open)
self.assertEqual(port.close_count, 1)
self.assertGreaterEqual(port.flush_count, 2)
phase = summary["phases"]["persistent"]
self.assertEqual(phase["cycles_completed"], 3)
self.assertEqual(phase["requests_completed"], 1 + 3 * 4)
self.assertEqual(port.writes[0][3], hil.CMD_APP_START)
commands = {frame[3] for frame in port.writes[1:]}
self.assertEqual(
commands,
{
hil.CMD_DEVICE_QUERY,
hil.CMD_GET_DEVICE_TIME,
hil.CMD_GET_BATT_AND_STORAGE,
hil.CMD_GET_STATS,
},
)
def test_reopen_mode_uses_fresh_handle_for_every_app_start(self) -> None:
factory = FakeFactory()
config = fast_config(mode="reopen", count=4)
summary = hil.run_stress(config, serial_factory=factory, sleep=lambda _: None)
self.assertTrue(summary["ok"], summary["failure"])
self.assertEqual(len(factory.instances), 4)
self.assertTrue(all(not port.is_open for port in factory.instances))
self.assertTrue(all(port.close_count == 1 for port in factory.instances))
self.assertTrue(all(len(port.writes) == 1 for port in factory.instances))
self.assertTrue(
all(port.writes[0][3] == hil.CMD_APP_START
for port in factory.instances)
)
self.assertEqual(summary["transport"]["ports_opened"], 4)
self.assertEqual(summary["transport"]["ports_closed"], 4)
def test_failure_summary_is_machine_readable_and_closes_port(self) -> None:
def wrong_response(request: bytes) -> bytes:
if request[0] == hil.CMD_APP_START:
return self_info_payload()
return bytes((hil.RESP_CODE_ERR, 6))
factory = FakeFactory(wrong_response)
config = fast_config(
mode="persistent", count=1, queries=("device_time",)
)
summary = hil.run_stress(config, serial_factory=factory, sleep=lambda _: None)
self.assertFalse(summary["ok"])
self.assertEqual(summary["failure"]["phase"], "persistent")
self.assertEqual(summary["failure"]["type"], "ProtocolError")
self.assertIn("ERR code 6", summary["failure"]["message"])
self.assertFalse(factory.instances[0].is_open)
self.assertEqual(summary["transport"]["ports_closed"], 1)
def test_preparation_failure_still_closes_open_handle(self) -> None:
class ResetFailureSerial(FakeSerial):
def reset_input_buffer(self) -> None:
raise OSError("reset failed")
class ResetFailureFactory:
def __init__(self) -> None:
self.instance = ResetFailureSerial()
def __call__(self, _config: hil.StressConfig) -> FakeSerial:
return self.instance
factory = ResetFailureFactory()
summary = hil.run_stress(
fast_config(mode="persistent", count=1),
serial_factory=factory,
sleep=lambda _: None,
)
self.assertFalse(summary["ok"])
self.assertFalse(factory.instance.is_open)
self.assertEqual(summary["transport"]["ports_opened"], 1)
self.assertEqual(summary["transport"]["ports_closed"], 1)
def test_main_prints_json_and_returns_failure_exit_code(self) -> None:
failure = {
"schema_version": 1,
"tool": "esp32_companion_serial_stress",
"ok": False,
"failure": {"type": "ProtocolError", "message": "fixture"},
}
output = io.StringIO()
with mock.patch.object(hil, "run_stress", return_value=failure):
with redirect_stdout(output):
exit_code = hil.main(["--port", "FAKE"])
self.assertEqual(exit_code, 1)
self.assertEqual(json.loads(output.getvalue()), failure)
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -568,6 +568,7 @@ board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode}
build_flags =
${env:heltec_v4_companion_radio_wifi_mqtt_femon.build_flags}
-D MESHCORE_REQUIRES_COMPANION_RADIO_FULL=1
-D UI_WIFI_SETUP_HOME_PAGE=1
[env:heltec_v4_3_companion_radio_full_femoff]
extends = env:heltec_v4_3_companion_radio_wifi_mqtt_femoff
@@ -575,6 +576,7 @@ board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode}
build_flags =
${env:heltec_v4_3_companion_radio_wifi_mqtt_femoff.build_flags}
-D MESHCORE_REQUIRES_COMPANION_RADIO_FULL=1
-D UI_WIFI_SETUP_HOME_PAGE=1
[env:heltec_v4_sensor]
extends = heltec_v4_oled
+182 -17
View File
@@ -255,7 +255,7 @@ body.tab-cli{padding-bottom:0}
<div class="f"><label>Network name (SSID)</label>
<div class="row" style="gap:8px">
<input type="text" data-k="wifi.ssid" id="wz-ssid" autocapitalize="off" autocorrect="off" style="flex:1">
<button class="btn sec" type="button" onmousedown="event.preventDefault()" onclick="openScan('wz-ssid')">Scan</button>
<button class="btn sec" id="wz-scan-btn" type="button" aria-controls="scan-panel" aria-expanded="false" onmousedown="event.preventDefault()" onclick="openScan('wz-ssid')">Scan</button>
</div></div>
<div class="f"><label>Password</label>
<div class="pwrow">
@@ -504,7 +504,7 @@ body.tab-cli{padding-bottom:0}
<div class="f"><label>Network name (SSID)</label>
<div class="row" style="gap:8px">
<input type="text" data-k="wifi.ssid" id="app-ssid" autocapitalize="off" autocorrect="off" style="flex:1">
<button class="btn sec" type="button" onmousedown="event.preventDefault()" onclick="openScan('app-ssid')">Scan</button>
<button class="btn sec" id="app-scan-btn" type="button" aria-controls="scan-panel" aria-expanded="false" onmousedown="event.preventDefault()" onclick="openScan('app-ssid')">Scan</button>
</div></div>
<div class="f"><label>Password</label>
<div class="pwrow">
@@ -579,11 +579,11 @@ body.tab-cli{padding-bottom:0}
</div>
<!-- inline scan panel: moved under whichever SSID field requested it -->
<div class="scanpanel hide" id="scan-panel">
<div class="scanpanel hide" id="scan-panel" role="region" aria-label="Nearby WiFi networks" aria-hidden="true">
<div class="sp-head">Nearby networks<span style="flex:1"></span>
<button class="btn sec sm" type="button" onmousedown="event.preventDefault()" onclick="startScan(true)">Rescan</button>
<button class="btn sec sm" type="button" onmousedown="event.preventDefault()" onclick="closeScan()">Close</button></div>
<div id="scan-list"><div style="text-align:center;padding:18px"><span class="spin"></span></div></div>
<div id="scan-list" aria-live="polite" aria-busy="true"><div style="text-align:center;padding:18px"><span class="spin"></span></div></div>
</div>
<div class="toast" id="toast"></div>
@@ -617,7 +617,7 @@ body.tab-cli{padding-bottom:0}
<script>
"use strict";
var SENTINEL="********";
var st={mode:"",authed:false,hasMqtt:false,hasCli:false,wifiPsk64:false,passwordSupported:false,needsPassword:false,radioOptional:false,skipRadio:false,skipMqtt:false,caps:0,cfg:null,orig:{},dirty:{},presets:[],nslots:0,statsTimer:0,hist:{heap:[],noise:[]},scanTarget:"",scanTimer:0};
var st={mode:"",authed:false,hasMqtt:false,hasCli:false,wifiPsk64:false,passwordSupported:false,needsPassword:false,radioOptional:false,skipRadio:false,skipMqtt:false,caps:0,cfg:null,orig:{},dirty:{},presets:[],nslots:0,statsTimer:0,hist:{heap:[],noise:[]},scanTarget:"",scanTimer:0,scanEpoch:0,configEpoch:0,wizardScanOpened:false,configLoadCapture:null,wifiPasswordAutoCleared:false};
function $(s){return document.querySelector(s)}
function $$(s){return Array.prototype.slice.call(document.querySelectorAll(s))}
@@ -813,6 +813,62 @@ function setEl(el,v){
if(el.type==="checkbox")el.checked=(v==="on");
else el.value=v;
}
function restoreConfigLoadEdits(capture){
if(!capture)return;
var owns=Object.prototype.hasOwnProperty;
// An early scan choice can happen before the masked stored password arrives.
// In that case the click handler saw an empty field and had nothing to clear;
// turn the newly loaded sentinel into an explicit empty password here unless
// the operator also entered (or deliberately cleared) a password meanwhile.
if(owns.call(capture.values,"wifi.ssid")
&&capture.values["wifi.ssid"]!==st.orig["wifi.ssid"]
&&!owns.call(capture.values,"wifi.pwd")
&&st.orig["wifi.pwd"]===SENTINEL){
capture.values["wifi.pwd"]="";
capture.wifiPasswordAutoCleared=true;
}
// Preserve why an empty password was captured. A config refresh resets the
// live marker before replaying edits; without this metadata, returning to the
// original SSID would leave an automatic clear looking like a deliberate one.
if(capture.wifiPasswordAutoCleared===true
&&capture.values["wifi.pwd"]===""){
var capturedSsid=owns.call(capture.values,"wifi.ssid")
?capture.values["wifi.ssid"]:st.orig["wifi.ssid"];
if(capturedSsid===st.orig["wifi.ssid"]&&st.orig["wifi.pwd"]===SENTINEL){
capture.values["wifi.pwd"]=SENTINEL;
capture.wifiPasswordAutoCleared=false;
}else if(capturedSsid!==st.orig["wifi.ssid"]){
st.wifiPasswordAutoCleared=true;
}
}
Object.keys(capture.values).forEach(function(k){
var v=capture.values[k],dirty=v!==st.orig[k];
$$('[data-k]').forEach(function(el){
if(el.dataset.k===k){setEl(el,v);el.classList.toggle("dirty",dirty)}
});
if(dirty)st.dirty[k]=v;else delete st.dirty[k];
});
Object.keys(capture.confirms).forEach(function(k){
$$('[data-cfm]').forEach(function(el){if(el.dataset.cfm===k)el.value=capture.confirms[k]})
});
if(capture.radio){
// Replay only radio fields the operator actually touched. Untouched fields
// may still have been blank or at DOM defaults while /api/config was in
// flight, so capturing the whole tuple would overwrite the server values.
Object.keys(capture.radio).forEach(function(k){
var el=$('[data-rg="'+k+'"]');if(el)setEl(el,capture.radio[k]);
});
var restoredRadio=radioCombo(),radioDirty=!comboEq(restoredRadio,st.orig.radio);
$$('[data-rg]').forEach(function(el){el.classList.toggle("dirty",radioDirty)});
if(radioDirty)st.dirty.radio=restoredRadio;else delete st.dirty.radio;
}
if(capture.rxps){
Object.keys(capture.rxps).forEach(function(k){
var el=$('[data-rxps="'+k+'"]');if(el)setEl(el,capture.rxps[k]);
});
refreshRxps(true);
}
}
function rxpsCfgValue(r){
if(!r.rxps_enabled)return"off";
var level=Number(r.rxps_level)||0,preamble=Number(r.rxps_preamble)||0;
@@ -854,9 +910,15 @@ function radioCombo(){
var p={};$$("[data-rg]").forEach(function(el){p[el.dataset.rg]=el.value});
return p.freq+","+p.bw+","+p.sf+","+p.cr;
}
function loadConfig(){
function loadConfig(loadCapture){
var epoch=++st.configEpoch;
if(loadCapture)loadCapture.loadEpoch=epoch;
return api("/api/config").then(function(c){
// A newer view/load owns the form now. Do not let this response reset its
// baseline or discard edits simply because the older request finished last.
if(epoch!==st.configEpoch)return false;
st.cfg=c;st.orig={};st.dirty={};st.skipRadio=false;st.skipMqtt=false;
st.wifiPasswordAutoCleared=false;
if(st.hasMqtt){buildSlots("#app-slots");buildSlots("#wz-slots")}
$$("[data-k]").forEach(function(el){
var k=el.dataset.k,v=cfgVal(k);
@@ -869,7 +931,12 @@ function loadConfig(){
st.orig.radio=radioCombo();afHint();
for(var i=1;i<=st.nslots;i++){refreshSlotFields($("#app-slots"),i);refreshSlotFields($("#wz-slots"),i)}
syncPacketFilters();
// The wizard is deliberately visible while this local request runs. Replay
// any values the operator changed meanwhile against the newly loaded
// baseline instead of silently replacing their selection or typing.
restoreConfigLoadEdits(loadCapture);
updateSaveBar();
return true;
});
}
function markDirty(k,v,el){
@@ -888,6 +955,10 @@ function afHint(){
}
document.addEventListener("input",function(ev){
var el=ev.target;
if(el.dataset.k==="wifi.pwd"&&!ev.meshAutoWiFiPassword)st.wifiPasswordAutoCleared=false;
// Typed SSIDs follow the same safety rule as scan selections. A masked value
// means "retain the old network's secret" and must not follow a new network.
if(el.dataset.k==="wifi.ssid")clearInheritedWiFiPassword(el.id,elVal(el));
if(el.dataset.rxps){
refreshRxps(true);
}else if(el.dataset.k){
@@ -906,6 +977,24 @@ document.addEventListener("input",function(ev){
s.value=m>=0?String(m):(("radio" in st.dirty)?"keep":s.value);
}
}
var capture=st.configLoadCapture;
if(capture&&el.closest&&el.closest(capture.root)){
if(el.dataset.k){
capture.values[el.dataset.k]=elVal(el);
if(el.dataset.k==="wifi.pwd"){
capture.wifiPasswordAutoCleared=!!ev.meshAutoWiFiPassword&&elVal(el)==="";
}
}
else if(el.dataset.cfm)capture.confirms[el.dataset.cfm]=el.value;
else if(el.dataset.rg){
if(!capture.radio)capture.radio={};
capture.radio[el.dataset.rg]=elVal(el);
}
else if(el.dataset.rxps){
if(!capture.rxps)capture.rxps={};
capture.rxps[el.dataset.rxps]=elVal(el);
}
}
});
function updateSaveBar(){
var n=Object.keys(st.dirty).length;
@@ -913,7 +1002,7 @@ function updateSaveBar(){
$("#savebar").classList.toggle("show",n>0&&appVisible);
$("#save-n").textContent=n+(n===1?" change":" changes");
}
function revertAll(){loadConfig().then(function(){toast("Reverted")})}
function revertAll(){loadConfig().then(function(applied){if(applied)toast("Reverted")})}
/* ---------- slots ---------- */
// maxlength per destination MQTTPrefs buffer minus the NUL (host[64],
@@ -1211,8 +1300,15 @@ function loadConfigSoft(){ // re-sync origin values without clobbering result ch
/* ---------- tabs / app ---------- */
function enterApp(){
var capture={root:"#v-app",values:{},confirms:{}};
st.configLoadCapture=capture;
show("#v-app");
loadConfig().catch(function(e){if(e.message!=="auth")toast("Failed to load config")});
loadConfig(capture).then(function(){
if(st.configLoadCapture===capture)st.configLoadCapture=null;
}).catch(function(e){
if(st.configLoadCapture===capture)st.configLoadCapture=null;
if(e.message!=="auth")toast("Failed to load config")
});
}
// Setup-mode shortcut: straight past the guided steps into the terminal, for
// operators who already have a config to paste. Setup mode authenticates by
@@ -1302,6 +1398,9 @@ function dropKeyboard(target){
if(ae&&ae!==document.body&&ae.blur)ae.blur();
}
function openScan(target){
// A manual tap while the wizard is still loading also fulfils its one-time
// automatic open; do not reopen a panel the operator already closed.
if(target==="wz-ssid")st.wizardScanOpened=true;
st.scanTarget=target;
// Move the panel into the flow right below the target SSID field: iOS
// keeps the focused field visible above the keyboard, so the panel under
@@ -1309,35 +1408,80 @@ function openScan(target){
var el=document.getElementById(target),panel=$("#scan-panel");
var f=el&&el.closest(".f");
if(f&&f.parentNode)f.parentNode.insertBefore(panel,f.nextSibling);
panel.classList.remove("hide");
panel.classList.remove("hide");panel.setAttribute("aria-hidden","false");
$("#wz-scan-btn").setAttribute("aria-expanded",target==="wz-ssid"?"true":"false");
$("#app-scan-btn").setAttribute("aria-expanded",target==="app-ssid"?"true":"false");
// best-effort keyboard drop (works in real Safari; the CNA sheet ignores it)
dropKeyboard(target);
setTimeout(function(){dropKeyboard(target)},150);
startScan(false)}
function closeScan(){$("#scan-panel").classList.add("hide");clearTimeout(st.scanTimer)}
function closeScan(){
$("#scan-panel").classList.add("hide");
$("#scan-panel").setAttribute("aria-hidden","true");
$("#wz-scan-btn").setAttribute("aria-expanded","false");
$("#app-scan-btn").setAttribute("aria-expanded","false");
clearTimeout(st.scanTimer);st.scanTimer=0;
st.scanEpoch++;
}
function startScan(force){
clearTimeout(st.scanTimer);st.scanTimer=0;
var epoch=++st.scanEpoch;
$("#scan-list").setAttribute("aria-busy","true");
$("#scan-list").innerHTML='<div style="text-align:center;padding:18px"><span class="spin"></span></div>';
pollScan(force?"/api/scan?rescan=1":"/api/scan");
pollScan(force?"/api/scan?rescan=1":"/api/scan",epoch);
}
function bars(rssi){var n=rssi>-55?4:rssi>-67?3:rssi>-78?2:1;var o="";
for(var i=1;i<=4;i++)o+='<span style="display:inline-block;width:3px;margin-right:1px;border-radius:1px;height:'+(3+i*3)+'px;background:'+(i<=n?"var(--acc)":"var(--line)")+'"></span>';
return'<span style="display:inline-flex;align-items:flex-end;height:15px">'+o+"</span>"}
function pollScan(url){
function pollScan(url,epoch){
if(epoch!==st.scanEpoch)return;
api(url).then(function(r){
if(r.state==="scanning"){st.scanTimer=setTimeout(function(){pollScan("/api/scan")},1500);return}
if(epoch!==st.scanEpoch)return;
if(r.state==="scanning"){st.scanTimer=setTimeout(function(){pollScan("/api/scan",epoch)},1500);return}
var nets=(r.networks||[]).filter(function(n){return n.ssid});
nets.sort(function(a,b){return b.rssi-a.rssi});
var seen={};nets=nets.filter(function(n){if(seen[n.ssid])return false;seen[n.ssid]=1;return true});
$("#scan-list").setAttribute("aria-busy","false");
$("#scan-list").innerHTML=nets.length?nets.map(function(n){
return'<button type="button" class="net" data-ssid="'+esc(n.ssid)+'" data-channel="'+esc(n.channel||"")+'">'+bars(n.rssi)
+'<span class="ss">'+esc(n.ssid)+"</span>"+(n.enc?"&#128274;":"")+"<small>ch "+esc(n.channel||"?")+" &middot; "+n.rssi+"</small></button>";
}).join(""):'<p style="color:var(--mut);padding:12px">No networks found.</p>';
}).catch(function(){$("#scan-list").innerHTML='<p style="color:var(--mut);padding:12px">Scan failed.</p>'});
}).catch(function(){
if(epoch!==st.scanEpoch)return;
$("#scan-list").setAttribute("aria-busy","false");
$("#scan-list").innerHTML='<p style="color:var(--mut);padding:12px">Scan failed.</p>'
});
}
function clearInheritedWiFiPassword(target,nextSsid){
var pwdId=target==="wz-ssid"?"wz-wifi-pwd":target==="app-ssid"?"app-wifi-pwd":"";
var pwd=pwdId&&document.getElementById(pwdId);
// The sentinel means "keep the stored secret". It is valid only while the
// stored SSID is retained; carrying it to another SSID silently pairs that
// network with the previous network's password.
if(!pwd)return;
if(nextSsid===st.orig["wifi.ssid"]){
if(st.wifiPasswordAutoCleared&&pwd.value===""){
st.wifiPasswordAutoCleared=false;
pwd.value=SENTINEL;
var restoreEvent=new Event("input",{bubbles:true});
restoreEvent.meshAutoWiFiPassword=true;
pwd.dispatchEvent(restoreEvent);
}
return;
}
if(pwd.value!==SENTINEL)return;
st.wifiPasswordAutoCleared=true;
pwd.value="";
var clearEvent=new Event("input",{bubbles:true});
clearEvent.meshAutoWiFiPassword=true;
pwd.dispatchEvent(clearEvent);
}
$("#scan-list").addEventListener("click",function(ev){
var b=ev.target.closest(".net");if(!b)return;
var el=document.getElementById(st.scanTarget);
el.value=b.dataset.ssid;
var selected=b.dataset.ssid;
clearInheritedWiFiPassword(st.scanTarget,selected);
el.value=selected;
el.dispatchEvent(new Event("input",{bubbles:true}));
var channel=Number(b.dataset.channel);
if((st.caps&32768)&&channel>=1&&channel<=13&&Math.floor(channel)===channel){
@@ -1415,9 +1559,29 @@ function wzRadioSel(sel){
sel.classList.toggle("dirty",combo!==st.orig.radio);
markDirty("radio",combo);
}
function openWizardScanOnce(){
if(st.wizardScanOpened)return;
st.wizardScanOpened=true;
// Setup mode starts the hardware scan while bringing up its AP. Poll that
// scan through the existing inline picker (without forcing a second scan),
// and leave SSID/password fields untouched until a network is selected.
openScan("wz-ssid");
}
function showWizard(){
var capture={root:"#v-wizard",values:{},confirms:{}};
st.configLoadCapture=capture;
show("#v-wizard");
loadConfig().then(wzRadioFill).catch(function(){});
// Initialize stored credentials first so a late config reply cannot replace
// text entered after the automatically opened scan panel appears.
loadConfig(capture).then(function(applied){
if(st.configLoadCapture===capture)st.configLoadCapture=null;
if(!applied)return;
wzRadioFill();openWizardScanOnce()
}).catch(function(){
var current=st.configEpoch===capture.loadEpoch;
if(st.configLoadCapture===capture)st.configLoadCapture=null;
if(current)openWizardScanOnce()
});
}
function wzGo(n){
if(!st.hasMqtt&&n===3)n=4;
@@ -1500,7 +1664,8 @@ function buildReview(){
var rows=[],d=st.dirty;
function row(l,v){rows.push('<div class="kv"><i>'+l+"</i><b>"+esc(v)+"</b></div>")}
row("WiFi network",d["wifi.ssid"]||st.orig["wifi.ssid"]||"(not set)");
row("WiFi password",d["wifi.pwd"]?"******":(st.orig["wifi.pwd"]?"(unchanged)":"(open network)"));
var pwdChanged=Object.prototype.hasOwnProperty.call(d,"wifi.pwd");
row("WiFi password",pwdChanged?(d["wifi.pwd"]?"******":"(open network)"):(st.orig["wifi.pwd"]?"(unchanged)":"(open network)"));
if(st.caps&32768)row("ESP-NOW / WiFi channel","Channel "+eff("espnow.channel")+(d["espnow.channel"]?" (reboot required)":""));
if(d.name||st.orig.name)row("Node name",d.name||st.orig.name);
var rc=d.radio||st.orig.radio||"",rp=null;