Improve companion setup and USB session recovery
This commit is contained in:
1 parent
ecaa5ac2a0
commit
cf70478c51
34 files changed
+6283
-2147
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
+1944
-1873
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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?"🔒":"")+"<small>ch "+esc(n.channel||"?")+" · "+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;
|
||||
|
||||
Reference in new issue
Block a user