diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 000ba4fbb..a02f1eab5 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -196,8 +196,15 @@ jobs: - name: Verify Companion ASCII defaults and USB session cleanup run: | python3 -B test/test_companion_usb_default.py + python3 -B test/test_common_cli_logging_transaction.py + python3 -B test/test_usb_diagnostic_logging.py python3 -B test/test_nrf52_usb_client.py + python3 -B test/test_nrf52_usb_lifecycle.py + python3 -B test/test_nrf52_usb_logging_contract.py python3 -B test/test_esp32_usb_serial_hygiene.py + python3 -B test/test_esp32_tinyusb_nonblocking.py + python3 -B test/test_esp32_tinyusb_cooperative_output.py + python3 -B test/test_esp32_tinyusb_role_hygiene.py python3 -B test/test_stm32_float_conversion.py - name: Verify Companion contact persistence and OTA sleep guards diff --git a/docs/test-results/usb_serial_backpressure.md b/docs/test-results/usb_serial_backpressure.md index f25626445..4928f056d 100644 --- a/docs/test-results/usb_serial_backpressure.md +++ b/docs/test-results/usb_serial_backpressure.md @@ -45,6 +45,9 @@ python test/test_esp32_tinyusb_role_hygiene.py python test/test_esp32_tinyusb_cooperative_output.py python test/test_esp32_usb_serial_hygiene.py python test/test_nrf52_usb_logging_contract.py +python test/test_nrf52_usb_lifecycle.py +python test/test_common_cli_logging_transaction.py +python test/test_usb_diagnostic_logging.py pio test -e native -f test_nrf52_debug_output -f test_serial_packet_log -f test_serial_mode_switch -f test_mesh_tables ``` @@ -72,3 +75,31 @@ logins with the relay running, paused, and stopped, including a host that leaves USB open without reading. Check LoRa recovery separately from USB OUT recovery; fixing transmit backpressure does not prove an unrelated OUT endpoint fault is resolved. Do not erase or repartition the radio as part of this test. + +## USB logging ownership and session recovery + +On a single-port Companion, enabling USB logging parks framed USB traffic +before opening the diagnostic gate. A TCP/browser terminal retains its CLI +ownership; USB is log-only during that session. Disabling logging restores USB +traffic without accepting bytes typed during the log-only interval. A dedicated +nRF52 logging port does not park the separate Companion port. + +Ethernet and enabled MQTT diagnostics honor the runtime logging gate and use +the bounded USB facade. Infrastructure logging setters commit preferences +before changing live output; failed saves leave previous settings unchanged. +A successful save with a failed MQTT runtime change reports those two outcomes +separately. + +ESP32 native-USB repeater and room ACL listings retain short-write suffixes and +advance one row per service pass, using their existing output buffer. Reconnect +cancels the old listing. The listing is a bounded live view, not an immutable +snapshot of clients changed during output. + +nRF52 FIFO cleanup also checks for an already-armed IN packet. If one exists +at a session boundary (including a pending zero-length packet), both USB ports +briefly detach and re-enumerate: a FIFO clear alone cannot recall endpoint RAM. +Reattachment waits for the local unplug event and a 20 ms minimum interval, +without blocking the mesh loop. Dedicated CDC descriptors are added while +detached even if the host has not yet completed enumeration. These recovery +paths have native regression coverage; real host/board timing still requires +hardware qualification. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 197fa2ca9..a978e5e19 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2641,7 +2641,7 @@ void MyMesh::begin(bool has_display, bool radio_available, #if MESH_USB_LOGGING_AVAILABLE const bool usb_logging_enabled = _prefs.usb_logging_enabled != 0; mesh::setUsbDebugEnabled(_prefs.usb_debug_enabled != 0); - mesh::setUsbLoggingEnabled(usb_logging_enabled); + applyUsbLoggingState(usb_logging_enabled); if (!mesh::saveUsbLoggingBootPreference(usb_logging_enabled)) { MESH_DEBUG_PRINTLN("Unable to save next-boot USB logging interface state"); } @@ -3335,7 +3335,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, snprintf(reply, reply_size, "Error: use set usb.logging [reboot]"); } else { _prefs.usb_logging_enabled = enabled ? 1 : 0; - mesh::setUsbLoggingEnabled(enabled); + applyUsbLoggingState(enabled); if (!savePrefs()) { snprintf(reply, reply_size, "Error: USB logging changed for this boot but save failed"); } else if (!mesh::saveUsbLoggingBootPreference(enabled)) { @@ -3853,11 +3853,14 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, _mqtt_enabled = wifi; if (!wifi) stopMQTT(); _prefs.usb_logging_enabled = usb ? 1 : 0; - mesh::setUsbLoggingEnabled(usb); + applyUsbLoggingState(usb); if (!savePrefs() || !mesh::saveUsbLoggingBootPreference(usb)) { snprintf(reply, reply_size, "Error: logging output changed but USB setting could not be saved"); } else { - snprintf(reply, reply_size, "OK - logging.output %s (saved)", command + 19); + // USB ownership changes can clear the terminal's input buffer while + // this command is executing. Use parsed values, not that borrowed text. + snprintf(reply, reply_size, "OK - logging.output %s (saved)", + mesh::cli::loggingOutputName(usb, wifi)); } } return true; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 64a4c5729..a36608a69 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -302,6 +302,7 @@ public: // setup/status commands here; Full builds add TempRadio and OTA commands. bool handleLocalControlCommand(const char* command, char* reply, size_t reply_size); + void applyUsbLoggingState(bool enabled); bool handleTxRoutingCommand(const char* command, char* reply, size_t reply_size); int getRecentlyHeard(AdvertPath dest[], int max_num); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 9d8685929..bec977eda 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -626,6 +626,8 @@ static size_t usb_terminal_line_len = 0; static bool usb_terminal_discard_line = false; static bool usb_host_session_connected = false; static bool usb_logging_terminal_mode = false; +static bool usb_logging_network_parked = false; +static bool usb_protocol_initialized = false; static mesh::UsbBinaryStartupProbe usb_binary_startup_probe; static mesh::UsbAsciiSessionDefault usb_ascii_session_default; #if COMPANION_FEATURE_USB_MOTA_SOURCE @@ -774,7 +776,10 @@ static void leaveUsbTerminalMode(bool acknowledge) { mesh::discardUsbTerminalOutput(); } the_mesh.exitTerminalMode(); - usb_serial_interface.setPassthroughMode(false); + // A network handoff can leave the USB CLI while diagnostics remain enabled. + // Do not briefly reopen the framed transport on that same logging stream. + usb_serial_interface.setPassthroughMode( + !mesh::hasDedicatedUsbLoggingPort() && mesh::isUsbLoggingEnabled()); clearUsbTerminalLine(); usb_terminal_discard_line = false; usb_logging_terminal_mode = false; @@ -1013,69 +1018,16 @@ static void beginUsbDefaultSession() { serviceUsbAsciiSessionDefault(); } +static void serviceUsbLoggingOwnership(bool logging_enabled); + static void serviceUsbTerminal() { + serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled()); + if (usb_logging_network_parked) return; #if COMPANION_FEATURE_USB_MOTA_SOURCE if (usb_mota_mode) { -#if MESH_USB_LOGGING_AVAILABLE - // A single-TTY logger and framed USB mOTA cannot share the primary USB - // stream. A remote logging change can arrive while mOTA owns USB, so stop - // the transfer before its next binary exchange and return to ASCII. - if (!mesh::hasDedicatedUsbLoggingPort() - && mesh::isUsbLoggingEnabled()) { - leaveUsbMotaMode(false); - enterUsbLoggingTerminalMode(); - usbTerminalOutput().print( - "\r\nUSB mOTA stopped: USB logging owns this port\r\n> "); - return; - } -#endif serviceUsbMota(); return; } -#endif - // A saved logging-on preference makes the one available TTY behave like a - // logging repeater: plaintext diagnostics plus an input-capable CLI. Put the - // Companion interface into passthrough before it can mix framed traffic with - // logs. An active TCP terminal owns the role CLI, so logging must not reclaim - // it. Turning logging off retains the ordinary ASCII terminal. -#if MESH_USB_LOGGING_AVAILABLE - const mesh::UsbLoggingTerminalAction logging_action = - mesh::selectUsbLoggingTerminalAction( - mesh::hasDedicatedUsbLoggingPort(), mesh::isUsbLoggingEnabled(), - the_mesh.isTerminalMode(), usb_logging_terminal_mode, - true, -#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG) - isNetworkTerminalActive() -#else - false -#endif - ); - switch (logging_action) { - case mesh::UsbLoggingTerminalAction::CLAIM_USB: - if (!the_mesh.isTerminalMode()) { - enterUsbLoggingTerminalMode(); - return; - } - usb_logging_terminal_mode = true; - break; - case mesh::UsbLoggingTerminalAction::RETURN_TO_BINARY: - leaveUsbTerminalMode(true); - return; - case mesh::UsbLoggingTerminalAction::KEEP_ASCII: - // Logging may also be disabled over BLE/WiFi. Stop treating this session - // as the logging terminal, but keep the ordinary ASCII terminal active; - // do not silently change the USB protocol underneath an idle host. A - // remote mode change also cancels any partially typed USB command before - // drawing a fresh prompt. - usb_logging_terminal_mode = false; - clearUsbTerminalLine(); - usb_terminal_discard_line = false; - usbTerminalOutput().print( - "\r\nUSB logging off; ASCII terminal active\r\n> "); - break; - case mesh::UsbLoggingTerminalAction::NO_ACTION: - break; - } #endif if (!the_mesh.isTerminalMode()) { const mesh::UsbBinaryStartupProbe::Result probe_result = @@ -1206,6 +1158,91 @@ static void serviceUsbTerminal() { } } +static void serviceUsbLoggingOwnership(bool logging_enabled) { + // A saved logging-on preference makes the one available TTY behave like a + // logging repeater: plaintext diagnostics plus an input-capable CLI. Put the + // Companion interface into passthrough before it can mix framed traffic with + // logs. An active TCP terminal owns the role CLI, so logging must not reclaim + // it. Turning logging off retains the ordinary ASCII terminal. +#if MESH_USB_LOGGING_AVAILABLE +#if COMPANION_FEATURE_USB_MOTA_SOURCE + if (usb_mota_mode) { + if (mesh::hasDedicatedUsbLoggingPort() || !logging_enabled) return; + // Run before framed dispatch, not just the later terminal-input service. + leaveUsbMotaMode(false); + } +#endif + const mesh::UsbLoggingTerminalAction logging_action = + mesh::selectUsbLoggingTerminalAction( + mesh::hasDedicatedUsbLoggingPort(), logging_enabled, + the_mesh.isTerminalMode(), usb_logging_terminal_mode, + true, +#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG) + isNetworkTerminalActive() +#else + false +#endif + ); + if (usb_logging_network_parked + && logging_action != mesh::UsbLoggingTerminalAction::PARK_USB_FOR_NETWORK) { + // Bytes typed while USB was log-only belong to neither the new ASCII CLI + // nor a new Binary client. Bound the discard to the existing RX snapshot. + Stream& input = mesh::usbCompanionPort(); + int pending = input.available(); + while (pending-- > 0) input.read(); + usb_logging_network_parked = false; + usb_serial_interface.setPassthroughMode(the_mesh.isTerminalMode()); + } + switch (logging_action) { + case mesh::UsbLoggingTerminalAction::PARK_USB_FOR_NETWORK: + if (!usb_logging_network_parked + || !usb_serial_interface.isPassthroughMode()) { + usb_binary_startup_probe.cancel(); + cancelUsbSerialOperations(); + mesh::discardUsbTerminalOutput(); + usb_serial_interface.setPassthroughMode(true); + clearUsbTerminalLine(); + usb_terminal_discard_line = false; + } + usb_logging_network_parked = true; + return; // Do not move the role CLI away from TCP/browser. + case mesh::UsbLoggingTerminalAction::CLAIM_USB: + usb_logging_network_parked = false; + if (!the_mesh.isTerminalMode()) { + enterUsbLoggingTerminalMode(); + return; + } + usb_logging_terminal_mode = true; + break; + case mesh::UsbLoggingTerminalAction::RETURN_TO_BINARY: + leaveUsbTerminalMode(true); + return; + case mesh::UsbLoggingTerminalAction::KEEP_ASCII: + // Logging may also be disabled over BLE/WiFi. Stop treating this session + // as the logging terminal, but keep the ordinary ASCII terminal active; + // do not silently change the USB protocol underneath an idle host. A + // remote mode change also cancels any partially typed USB command before + // drawing a fresh prompt. + usb_logging_terminal_mode = false; + clearUsbTerminalLine(); + usb_terminal_discard_line = false; + usbTerminalOutput().print( + "\r\nUSB logging off; ASCII terminal active\r\n> "); + break; + case mesh::UsbLoggingTerminalAction::NO_ACTION: + break; + } +#endif +} + +void MyMesh::applyUsbLoggingState(bool enabled) { + // Park the framed transport before opening the diagnostic gate, including + // TCP/browser commands which execute outside the mesh dispatcher. + if (enabled && usb_protocol_initialized) serviceUsbLoggingOwnership(true); + mesh::setUsbLoggingEnabled(enabled); + if (usb_protocol_initialized) serviceUsbLoggingOwnership(enabled); +} + static void expireUsbBinaryStartupProbeBeforeDispatch() { const uint32_t now = millis(); if (!usb_binary_startup_probe.hasTimedOut(now)) return; @@ -1222,6 +1259,12 @@ static void expireUsbBinaryStartupProbeBeforeDispatch() { } #endif +#if !defined(ENABLE_USB_INTERFACE) +void MyMesh::applyUsbLoggingState(bool enabled) { + mesh::setUsbLoggingEnabled(enabled); +} +#endif + #if defined(ENABLE_USB_INTERFACE) && (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)) static bool isNetworkTerminalActive() { return the_mesh.isAnyNetworkTerminalMode(); @@ -1243,7 +1286,10 @@ bool MyMesh::beginStreamTerminal(Stream& output) { hasObservableActiveUsbTerminalClient(), input_idle, usb_serial_interface.getCompletedFrameCount())) return false; if (ascii_selected) leaveUsbTerminalMode(false); - if (enterNetworkTerminalMode(output)) return true; + if (enterNetworkTerminalMode(output)) { + serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled()); + return true; + } if (browser_usb_handoff.shouldRestoreAscii( usb_serial_interface.getCompletedFrameCount())) enterUsbTerminalMode(); return false; @@ -1943,6 +1989,9 @@ void halt() { "ERROR: another client currently owns the Full Companion terminal\r\n"); ota_console_client.stop(); } +#if defined(ENABLE_USB_INTERFACE) + serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled()); +#endif #else ota_console_client.print("Local CLI and OTA console - type `version` or `ota status`\r\n> "); #endif @@ -3263,6 +3312,7 @@ void setup() { usb_serial_interface.setConnectedCheck([]() { return (bool)Serial; }); #endif interface_manager.addInterface(InterfaceType::USB, &usb_serial_interface); + usb_protocol_initialized = true; // Select ASCII before any framed dispatch, including non-Full USB builds. // BLE/WiFi/Ethernet keep their existing Binary Companion transports. beginUsbDefaultSession(); @@ -3325,7 +3375,9 @@ static bool usbLoggingRecoverySafe(void*) { #endif // The logging CDC may be separate, but USB re-enumeration/reboot affects the // primary Companion connection too. Never reset a live Binary client. - if (!the_mesh.isTerminalMode() + // A network CLI can own the role while USB is parked in log-only + // passthrough, so the USB transport, not the role CLI, identifies Binary. + if (!usb_serial_interface.isPassthroughMode() && (isUsbTerminalDataConnected() || usb_serial_interface.hasPendingIO())) return false; const auto usb = mesh::usbLoggingStatus(); if (usb.reader_connected && !usb.stalled @@ -3351,6 +3403,7 @@ void loop() { #if defined(ENABLE_USB_INTERFACE) serviceUsbTerminalHostSessionReset(); serviceUsbAsciiSessionDefault(); + serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled()); // Before framed dispatch. #endif // Identify nRF52 CDC 1 when a terminal opens it. Doing this on the connection // edge avoids losing the marker before the host has opened the port. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 9c2c99c35..dd33b54d2 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -5092,9 +5092,34 @@ void MyMesh::dumpLogFile() { #endif } +void MyMesh::printAclSerial() { +#if MESH_USB_CONSOLE_COOPERATIVE + if (hasPendingSerialOutput()) { + mesh::usbConsolePort().printf("Err - USB output busy\r\n"); + return; + } + // Reuse the bounded file-output buffer. Command admission prevents overlapping + // USB jobs, and session reset cancels the retained row before a new host reads. + serial_acl_next = 0; + serial_acl_count = acl.getNumClients(); + serial_acl_header = true; + serial_log_pending_size = 0; +#else + mesh::usbConsolePort().printf("ACL:\r\n"); + for (int i = 0; i < acl.getNumClients(); i++) { + auto c = acl.getClientByIdx(i); + if (c->permissions == 0) continue; + char public_key[PUB_KEY_SIZE * 2 + 1]; + mesh::Utils::toHex(public_key, c->id.pub_key, PUB_KEY_SIZE); + mesh::usbConsolePort().printf("%02X %s\n", c->permissions, public_key); + } +#endif +} + #if MESH_USB_CONSOLE_COOPERATIVE bool MyMesh::hasPendingSerialOutput() const { - return serial_log_active || serial_log_eof_pending || serial_recent_next >= 0; + return serial_log_active || serial_log_eof_pending || serial_recent_next >= 0 + || serial_acl_next >= 0; } void MyMesh::cancelPendingSerialOutput() { @@ -5104,6 +5129,9 @@ void MyMesh::cancelPendingSerialOutput() { serial_log_skip_line = false; serial_log_remaining = 0; serial_log_pending_size = 0; + serial_acl_next = -1; + serial_acl_count = 0; + serial_acl_header = false; serial_recent_next = -1; serial_recent_count = 0; serial_recent_header = false; @@ -5114,6 +5142,43 @@ void MyMesh::cancelPendingSerialOutput() { void MyMesh::servicePendingSerialOutput() { Stream& console = mesh::usbConsolePort(); + if (serial_acl_next >= 0) { + if (serial_log_pending_size == 0) { + if (serial_acl_header) { + static const char header[] = "ACL:\r\n"; + memcpy(serial_log_pending, header, sizeof(header) - 1); + serial_log_pending_size = sizeof(header) - 1; + serial_acl_header = false; + } else { + // At most one row per pass; deleted entries can be skipped without + // allocating a table-sized response or waiting for USB FIFO space. + while (serial_acl_next < serial_acl_count + && serial_acl_next < acl.getNumClients()) { + auto* client = acl.getClientByIdx(serial_acl_next++); + if (!client->permissions) continue; + char key[PUB_KEY_SIZE * 2 + 1]; + mesh::Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE); + serial_log_pending_size = snprintf(serial_log_pending, + sizeof(serial_log_pending), "%02X %s\n", client->permissions, key); + break; + } + if (serial_log_pending_size == 0) { + serial_acl_next = -1; + return; + } + } + } + if (console.availableForWrite() < static_cast(serial_log_pending_size)) return; + size_t written = console.write( + reinterpret_cast(serial_log_pending), serial_log_pending_size); + if (written > serial_log_pending_size) written = serial_log_pending_size; + serial_log_pending_size -= written; + if (written > 0 && serial_log_pending_size > 0) { + memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size); + } + return; + } + if (serial_recent_next >= 0) { char record[64]; int length = 0; @@ -12093,17 +12158,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * return; } #endif - mesh::usbConsolePort().printf("ACL:\r\n"); - for (int i = 0; i < acl.getNumClients(); i++) { - auto c = acl.getClientByIdx(i); - if (c->permissions == 0) continue; // skip deleted (or guest) entries - - // Admit each line together so concurrent USB diagnostics cannot split - // a public key or insert text between its permission prefix and value. - char public_key[PUB_KEY_SIZE * 2 + 1]; - mesh::Utils::toHex(public_key, c->id.pub_key, PUB_KEY_SIZE); - mesh::usbConsolePort().printf("%02X %s\n", c->permissions, public_key); - } + printAclSerial(); reply[0] = 0; } else if (handleClientPathCommand(sender, command, reply)) { return; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 9b3932120..c498d483e 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -326,6 +326,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks bool serial_log_active = false; bool serial_log_eof_pending = false; bool serial_log_skip_line = false; + int serial_acl_next = -1; + int serial_acl_count = 0; + bool serial_acl_header = false; int serial_recent_next = -1; int serial_recent_count = 0; bool serial_recent_header = false; @@ -1077,6 +1080,7 @@ public: } void dumpLogFile() override; + void printAclSerial(); #if MESH_USB_CONSOLE_COOPERATIVE // Large local-only replies advance between radio service passes. bool hasPendingSerialOutput() const; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 7af80aa33..79680ba62 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1669,9 +1669,33 @@ void MyMesh::dumpLogFile() { #endif } +void MyMesh::printAclSerial() { +#if MESH_USB_CONSOLE_COOPERATIVE + if (hasPendingSerialOutput()) { + mesh::usbConsolePort().printf("Err - USB output busy\r\n"); + return; + } + // Reuse the bounded file-output buffer. Command admission prevents overlapping + // USB jobs, and session reset cancels the retained row before a new host reads. + serial_acl_next = 0; + serial_acl_count = acl.getNumClients(); + serial_acl_header = true; + serial_log_pending_size = 0; +#else + mesh::usbConsolePort().printf("ACL:\r\n"); + for (int i = 0; i < acl.getNumClients(); i++) { + auto c = acl.getClientByIdx(i); + if (c->permissions == 0) continue; + char public_key[PUB_KEY_SIZE * 2 + 1]; + mesh::Utils::toHex(public_key, c->id.pub_key, PUB_KEY_SIZE); + mesh::usbConsolePort().printf("%02X %s\n", c->permissions, public_key); + } +#endif +} + #if MESH_USB_CONSOLE_COOPERATIVE bool MyMesh::hasPendingSerialOutput() const { - return serial_log_active || serial_log_eof_pending; + return serial_log_active || serial_log_eof_pending || serial_acl_next >= 0; } void MyMesh::cancelPendingSerialOutput() { @@ -1681,10 +1705,49 @@ void MyMesh::cancelPendingSerialOutput() { serial_log_skip_line = false; serial_log_remaining = 0; serial_log_pending_size = 0; + serial_acl_next = -1; + serial_acl_count = 0; + serial_acl_header = false; } void MyMesh::servicePendingSerialOutput() { Stream& console = mesh::usbConsolePort(); + if (serial_acl_next >= 0) { + if (serial_log_pending_size == 0) { + if (serial_acl_header) { + static const char header[] = "ACL:\r\n"; + memcpy(serial_log_pending, header, sizeof(header) - 1); + serial_log_pending_size = sizeof(header) - 1; + serial_acl_header = false; + } else { + // At most one row per pass; deleted entries can be skipped without + // allocating a table-sized response or waiting for USB FIFO space. + while (serial_acl_next < serial_acl_count + && serial_acl_next < acl.getNumClients()) { + auto* client = acl.getClientByIdx(serial_acl_next++); + if (!client->permissions) continue; + char key[PUB_KEY_SIZE * 2 + 1]; + mesh::Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE); + serial_log_pending_size = snprintf(serial_log_pending, + sizeof(serial_log_pending), "%02X %s\n", client->permissions, key); + break; + } + if (serial_log_pending_size == 0) { + serial_acl_next = -1; + return; + } + } + } + if (console.availableForWrite() < static_cast(serial_log_pending_size)) return; + size_t written = console.write( + reinterpret_cast(serial_log_pending), serial_log_pending_size); + if (written > serial_log_pending_size) written = serial_log_pending_size; + serial_log_pending_size -= written; + if (written > 0 && serial_log_pending_size > 0) { + memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size); + } + return; + } if (!serial_log_active) { // CommonCLI's synchronous EOF is suppressed until the queued dump ends. static const char eof[] = " -> EOF\r\n"; @@ -2440,17 +2503,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply return; } #endif - mesh::usbConsolePort().printf("ACL:\r\n"); - for (int i = 0; i < acl.getNumClients(); i++) { - auto c = acl.getClientByIdx(i); - if (c->permissions == 0) continue; // skip deleted (or guest) entries - - // Admit each line together so concurrent USB diagnostics cannot split - // a public key or insert text between its permission prefix and value. - char public_key[PUB_KEY_SIZE * 2 + 1]; - mesh::Utils::toHex(public_key, c->id.pub_key, PUB_KEY_SIZE); - mesh::usbConsolePort().printf("%02X %s\n", c->permissions, public_key); - } + printAclSerial(); reply[0] = 0; #if defined(WITH_MQTT_NEIGHBORS) } else if (memcmp(command, "discover.neighbors", 18) == 0) { diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 242dc1658..155b4a69a 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -189,6 +189,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, bool serial_log_active = false; bool serial_log_eof_pending = false; bool serial_log_skip_line = false; + int serial_acl_next = -1; + int serial_acl_count = 0; + bool serial_acl_header = false; #endif uint32_t last_millis; uint64_t uptime_millis; @@ -492,6 +495,7 @@ public: } void dumpLogFile() override; + void printAclSerial(); #if MESH_USB_CONSOLE_COOPERATIVE // Large local-only replies advance between radio service passes. bool hasPendingSerialOutput() const; diff --git a/scripts/nrf52_usb_power_fix.py b/scripts/nrf52_usb_power_fix.py index 9932652bf..81405f95f 100644 --- a/scripts/nrf52_usb_power_fix.py +++ b/scripts/nrf52_usb_power_fix.py @@ -76,9 +76,43 @@ PORT_INIT = """ if (usb_reg & POWER_USBREGSTATUS_VBUSDETECT_Msk) { } """ +# A FIFO clear does not cancel the packet already submitted to the controller. +# Expose only a read-only owner-state query; firmware uses the supported USB +# detach/re-enumeration path when a session boundary finds an armed IN packet. +# Do not invent an endpoint abort that would desynchronize bulk data toggles. +CDC_WRITE_CLEAR = """bool tud_cdc_n_write_clear(uint8_t itf) { + return tu_fifo_clear(&_cdcd_itf[itf].tx_ff); +} +""" +CDC_SESSION_QUERY = """ +bool mesh_tud_cdc_n_tx_pending(uint8_t itf) { + if (itf >= CFG_TUD_CDC) return false; + uint8_t const ep_in = _cdcd_itf[itf].ep_in; + return ep_in != 0 && usbd_edpt_busy(0, ep_in); +} +""" +CDC_LOCAL_INCLUDE = '#include "cdc_device.h"' +CDC_ROOT_INCLUDE = '#include "class/cdc/cdc_device.h"' +NRF_DISCONNECT = """void dcd_disconnect(uint8_t rhport) { + (void) rhport; + NRF_USBD->USBPULLUP = 0; + + // Disable Pull-up does not trigger Power USB Removed, in fact it have no + // impact on the USB Power status at all -> need to submit unplugged event to the stack. + dcd_event_bus_signal(0, DCD_EVENT_UNPLUGGED, false); +} +""" + def patched_source(source): source = source.replace("\r\n", "\n") + # Session recovery waits for TinyUSB's owner to consume this explicit + # local unplug event. A physical VBUS edge is neither required nor assumed. + if source.count(NRF_DISCONNECT) != 1: + raise RuntimeError( + "nRF52 USB session fix: changed disconnect/unmount behavior; " + "review the framework update before building" + ) if READY in source and ATTACH in source and OLD_READY not in source and OLD_ATTACH not in source: return source if source.count(OLD_READY) != 1 or source.count(OLD_ATTACH) != 1: @@ -101,17 +135,36 @@ def patched_port_source(source): return source.replace(OLD_PORT_INIT, PORT_INIT) +def patched_cdc_source(source): + source = source.replace("\r\n", "\n") + if (source.count(CDC_SESSION_QUERY) == 1 + and source.count(CDC_ROOT_INCLUDE) == 1 + and CDC_LOCAL_INCLUDE not in source): + return source + if (source.count(CDC_WRITE_CLEAR) != 1 or source.count(CDC_LOCAL_INCLUDE) != 1 + or CDC_SESSION_QUERY in source or CDC_ROOT_INCLUDE in source): + raise RuntimeError( + "nRF52 USB session fix: unrecognized TinyUSB CDC driver; review " + "the framework update before building (shared SDK was not modified)" + ) + # Middleware relocates this source into BUILD_DIR. Its original sibling + # include no longer resolves there; the TinyUSB root already on CPPPATH + # provides a portable, SDK-local path (including cdc_device.h's siblings). + return source.replace(CDC_LOCAL_INCLUDE, CDC_ROOT_INCLUDE).replace( + CDC_WRITE_CLEAR, CDC_WRITE_CLEAR + CDC_SESSION_QUERY) + + def replace_driver(build_env, node): # SCons passes a not-yet-created VariantDir node, not the SDK source path. source = Path(node.srcnode().get_abspath()) - patcher = (patched_port_source if source.name == "Adafruit_TinyUSB_nrf.cpp" - else patched_source) + patcher = {"Adafruit_TinyUSB_nrf.cpp": patched_port_source, + "cdc_device.c": patched_cdc_source}.get(source.name, patched_source) patched = patcher(source.read_text(encoding="utf-8")) destination = Path(build_env.subst("$BUILD_DIR")) / "patched-nrf52-usb" / source.name destination.parent.mkdir(parents=True, exist_ok=True) if not destination.exists() or destination.read_text(encoding="utf-8") != patched: destination.write_text(patched, encoding="utf-8") - print("nRF52 USB: build-local bounded READY/HFCLK fix enabled") + print("nRF52 USB: build-local bounded startup/session fix enabled") return build_env.File(str(destination)) @@ -124,6 +177,10 @@ def install(build_env): replace_driver, "*Adafruit_TinyUSB_Arduino*src*arduino*ports*nrf*Adafruit_TinyUSB_nrf.cpp", ) + build_env.AddBuildMiddleware( + replace_driver, + "*Adafruit_TinyUSB_Arduino*src*class*cdc*cdc_device.c", + ) if "Import" in globals(): diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index ecf51088c..8c8bddee0 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -3412,9 +3412,15 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep while (*value == ' ' || *value == '\t') value++; bool enabled = false, reboot_if_needed = false; if (mesh::cli::parseLoggingToggle(value, enabled, reboot_if_needed)) { + const uint8_t previous = _prefs->usb_logging_enabled; _prefs->usb_logging_enabled = enabled ? 1 : 0; + if (!trySavePrefs()) { + _prefs->usb_logging_enabled = previous; + strcpy(reply, "Error: USB logging not saved; unchanged"); + return; + } + // Commit before changing the stream's live protocol ownership. mesh::setUsbLoggingEnabled(enabled); - savePrefs(); snprintf(reply, 160, "OK - USB logging %s (saved)", enabled ? "on" : "off"); } else { strcpy(reply, "Error: usage set usb.logging on|off"); @@ -3454,11 +3460,21 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "Error: usage set logging.output off|usb|wifi|both"); return; } + const uint8_t previous_usb = _prefs->usb_logging_enabled; + const uint8_t previous_bridge = _prefs->bridge_enabled; _prefs->usb_logging_enabled = usb_enabled ? 1 : 0; _prefs->bridge_enabled = wifi_enabled ? 1 : 0; + if (!trySavePrefs()) { + _prefs->usb_logging_enabled = previous_usb; + _prefs->bridge_enabled = previous_bridge; + strcpy(reply, "Error: logging output not saved; unchanged"); + return; + } mesh::setUsbLoggingEnabled(usb_enabled); - _callbacks->setMqttBridgeState(wifi_enabled); - savePrefs(); + if (!_callbacks->setMqttBridgeState(wifi_enabled)) { + strcpy(reply, "Error: logging output saved, but MQTT runtime change failed"); + return; + } snprintf(reply, 160, "OK - logging.output %s (saved)", value); return; } diff --git a/src/helpers/NetworkLink.cpp b/src/helpers/NetworkLink.cpp index 625be1f73..dbf23519a 100644 --- a/src/helpers/NetworkLink.cpp +++ b/src/helpers/NetworkLink.cpp @@ -3,8 +3,8 @@ #if defined(ESP_PLATFORM) #include "MQTTConnectionPolicy.h" -#include "WifiPowerSavePolicy.h" #include "UsbLogging.h" +#include "WifiPowerSavePolicy.h" #include #include @@ -26,7 +26,7 @@ extern void tcpipInit(); #endif // Same "MQTT: " prefix as MQTT_DEBUG_PRINTLN so existing log greps keep matching. -#if defined(MQTT_DEBUG) +#if defined(MQTT_DEBUG) && MQTT_DEBUG #define NETWORK_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbDebugLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("MQTT: " F "\n", ##__VA_ARGS__); } } while (0) #else #define NETWORK_DEBUG_PRINTLN(...) do {} while (0) diff --git a/src/helpers/UsbAsciiBinarySwitch.h b/src/helpers/UsbAsciiBinarySwitch.h index baa84b75c..68350106c 100644 --- a/src/helpers/UsbAsciiBinarySwitch.h +++ b/src/helpers/UsbAsciiBinarySwitch.h @@ -333,6 +333,7 @@ public: // must not be displaced just because USB logging is enabled. enum class UsbLoggingTerminalAction : uint8_t { NO_ACTION, + PARK_USB_FOR_NETWORK, CLAIM_USB, RETURN_TO_BINARY, KEEP_ASCII, @@ -342,9 +343,15 @@ inline UsbLoggingTerminalAction selectUsbLoggingTerminalAction( bool has_dedicated_logging_port, bool logging_enabled, bool usb_terminal_active, bool usb_logging_terminal_active, bool full_companion, bool network_terminal_active) { - if (has_dedicated_logging_port || network_terminal_active) { + if (has_dedicated_logging_port) { return UsbLoggingTerminalAction::NO_ACTION; } + if (network_terminal_active) { + // Keep the network CLI owner, but never leave framed USB traffic enabled + // on the same CDC stream receiving ASCII diagnostics. + return logging_enabled ? UsbLoggingTerminalAction::PARK_USB_FOR_NETWORK + : UsbLoggingTerminalAction::NO_ACTION; + } if (logging_enabled) { return UsbLoggingTerminalAction::CLAIM_USB; } diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index 1aaa3feca..444de6144 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -29,6 +29,9 @@ (defined(USE_TINYUSB) || defined(ENABLE_USB_INTERFACE) || defined(OTA_FOLDER_SERIAL)) #define MESH_NRF52_PRIMARY_USB_NONBLOCKING 1 #include + // Build-local CDC query installed by scripts/nrf52_usb_power_fix.py. It does + // not clear/cancel an endpoint or change its USB bulk data-toggle state. + extern "C" bool mesh_tud_cdc_n_tx_pending(uint8_t instance); #if !defined(CFG_TUD_CDC) || CFG_TUD_CDC < 1 #error "nRF52 USB Companion/mOTA requires CFG_TUD_CDC >= 1" #endif @@ -472,6 +475,52 @@ static std::atomic primary_usb_line_state_dtr{false}; static std::atomic primary_usb_rx_generation{UINT32_MAX}; static constexpr uint32_t primary_usb_session_settle_millis = 8; static std::atomic primary_usb_reset_settle_until{0}; +static std::atomic nrf52_usb_reenumeration_pending{false}; +static std::atomic nrf52_usb_reattach_after{0}; +static constexpr uint32_t nrf52_usb_detach_millis = 20; + +static void requestNrf52UsbSessionReenumeration() { + // Only an armed old-session packet needs this recovery. TinyUSB has no safe + // nRF52 per-endpoint abort API; FIFO clears cannot retract endpoint RAM. A + // physical detach guarantees that packet cannot enter a reopened handle, + // and the host's fresh enumeration resets endpoint/data-toggle state. Both + // CDC ports briefly re-enumerate whenever a close finds a pending transfer, + // including an unread packet or the stack's trailing zero-length packet. + if (nrf52_usb_reenumeration_pending.exchange(true, + std::memory_order_acq_rel)) return; + primary_usb_line_state_dtr.store(false, std::memory_order_release); + primary_usb_reset_generation.fetch_add(1, std::memory_order_acq_rel); + nrf52_usb_reattach_after.store(millis() + nrf52_usb_detach_millis, + std::memory_order_release); + TinyUSBDevice.detach(); +} + +static void clearNrf52UsbTxForSession(uint8_t instance) { + (void)tud_cdc_n_write_clear(instance); + if (mesh_tud_cdc_n_tx_pending(instance)) { + requestNrf52UsbSessionReenumeration(); + } +} + +static void serviceNrf52UsbSessionReenumeration() { + if (!nrf52_usb_reenumeration_pending.load(std::memory_order_acquire) + || (int32_t)(millis() - nrf52_usb_reattach_after.load( + std::memory_order_acquire)) < 0 + || tud_mounted()) return; +#if defined(USE_TINYUSB) + // A watchdog recovery can overlap an old-session endpoint quarantine. Both + // detach deadlines must expire before either service reattaches the device. + if (usb_logging_watchdog_detached + && (int32_t)(millis() - usb_logging_watchdog_attach_at) < 0) return; +#endif + // Waiting for unmount as well as the quiet interval ensures TinyUSB's owner + // has consumed the unplug event. Never reattach with its old class state. + TinyUSBDevice.attach(); +#if defined(USE_TINYUSB) + usb_logging_watchdog_detached = false; +#endif + nrf52_usb_reenumeration_pending.store(false, std::memory_order_release); +} static uint32_t primaryUsbSessionGeneration() { return primary_usb_reset_generation.load(std::memory_order_acquire); @@ -480,7 +529,8 @@ static uint32_t primaryUsbSessionGeneration() { static bool canAccessPrimaryUsbSession(void*) { if (primary_usb_terminal_discard_pending.load(std::memory_order_acquire)) return false; const uint32_t generation = primaryUsbSessionGeneration(); - if (!tud_mounted() + if (nrf52_usb_reenumeration_pending.load(std::memory_order_acquire) + || !tud_mounted() || primary_usb_allowed_generation.load(std::memory_order_acquire) != generation) return false; @@ -505,9 +555,9 @@ static void endPrimaryUsbHostSession(bool clear_cdc_fifos) { && !(clear_cdc_fifos && tud_cdc_n_available(0) != 0)) return; // Publish a short quiescence deadline before closing the generation gate. - // TinyUSB's TX clear does not cancel an endpoint transfer already in its - // class buffer, so the later final TX purge catches a racing old-session - // write. RX is purged synchronously below; bytes sent by a reopened host + // FIFO-only cleanup suffices when no old IN packet is armed; otherwise + // quarantine and physically re-enumerate rather than leaking that packet. + // RX is purged synchronously below; bytes sent by a reopened host // during the settle interval remain queued until the gate reopens. primary_usb_reset_settle_until.store( millis() + primary_usb_session_settle_millis, @@ -520,7 +570,7 @@ static void endPrimaryUsbHostSession(bool clear_cdc_fifos) { // The line-state callback runs before TinyUSB resets the device. A device // unmount/remount boundary has already reset these class FIFOs. tud_cdc_n_read_flush(0); - (void)tud_cdc_n_write_clear(0); + clearNrf52UsbTxForSession(0); } } #endif @@ -537,6 +587,7 @@ static size_t writeTinyUsbCdcOnce(void* context, const uint8_t* data, return 0; } #if defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB) + if (nrf52_usb_reenumeration_pending.load(std::memory_order_acquire)) return 0; if (instance == 0) { if (!canAccessPrimaryUsbSession(nullptr)) return 0; } else @@ -714,7 +765,7 @@ static void restartDedicatedUsbLoggingHostSession() { // identity marker after a rapid reopen. dedicated_usb_logging_port_connected.store(false, std::memory_order_release); - (void)tud_cdc_n_write_clear(1); + clearNrf52UsbTxForSession(1); resetDedicatedUsbLoggingUsbTaskState(); dedicated_usb_logging_quiet_sofs = dedicated_usb_logging_host_settle_sofs; @@ -755,9 +806,18 @@ static void handleDedicatedUsbLoggingLineCoding() { // 1 kHz also catches a close/reopen that occurs entirely between main-loop // service calls. void serviceDedicatedUsbLoggingFromUsbTask() { + if (nrf52_usb_reenumeration_pending.load(std::memory_order_acquire)) { + dedicated_usb_logging_port_connected.store(false, + std::memory_order_release); + resetDedicatedUsbLoggingUsbTaskState(); + return; + } if (dedicated_usb_logging_watchdog_reset_requested.exchange(false, std::memory_order_acq_rel)) { restartDedicatedUsbLoggingHostSession(); + // Clearing an armed old packet can itself request a detach. Do not touch + // any endpoint again in this owner tick while that recovery is pending. + if (nrf52_usb_reenumeration_pending.load(std::memory_order_acquire)) return; } const uint32_t reset_generation = dedicated_usb_logging_reset_generation.load(std::memory_order_acquire); @@ -788,9 +848,9 @@ void serviceDedicatedUsbLoggingFromUsbTask() { if (dedicated_usb_logging_quiet_sofs > 0) { dedicated_usb_logging_port_connected.store( false, std::memory_order_release); - // Drop anything already in flight from the prior session and anything a - // producer raced into the owner queue before the callback gated it. - (void)tud_cdc_n_write_clear(1); + // Drop racing producer bytes. An armed old IN packet requires a physical + // detach, not another FIFO clear or an arbitrary number of quiet SOFs. + clearNrf52UsbTxForSession(1); resetDedicatedUsbLoggingUsbTaskState(); --dedicated_usb_logging_quiet_sofs; return; @@ -958,19 +1018,17 @@ void beginUsbLoggingPort() { return; } + // The core already started USB before setup. A host can have fetched the + // old descriptor even while mounted() is false. Detach BEFORE mutating the + // live configuration buffer, allow the owner to consume the unplug event, + // then always enumerate the complete descriptor (not just mounted hosts). + TinyUSBDevice.detach(); + delay(10); dedicated_usb_logging_port.begin(115200); dedicated_usb_logging_port_configured = true; dedicated_usb_logging_port_started = true; - // The nRF52 core starts TinyUSB before setup(). If the host completed - // enumeration in that small window, reconnect once so it reads the expanded - // two-CDC descriptor. Usually enumeration has not completed and no reconnect - // is needed. - if (TinyUSBDevice.mounted()) { - TinyUSBDevice.detach(); - delay(10); - TinyUSBDevice.attach(); - } + TinyUSBDevice.attach(); #endif } @@ -980,11 +1038,19 @@ void serviceUsbLoggingPort() { // A timed reconnect retains all descriptors and never blocks the radio loop // in delay(), Serial.end(), or a host-progress wait. if (usb_logging_watchdog_detached - && int32_t(millis() - usb_logging_watchdog_attach_at) >= 0) { + && int32_t(millis() - usb_logging_watchdog_attach_at) >= 0 +#if defined(NRF52_PLATFORM) + && !nrf52_usb_reenumeration_pending.load(std::memory_order_acquire) + && !tud_mounted() +#endif + ) { (void)tud_connect(); usb_logging_watchdog_detached = false; } #endif +#if defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)) + serviceNrf52UsbSessionReenumeration(); +#endif #if MESH_ESP32_TINYUSB_NONBLOCKING serviceEsp32TinyUsbPorts(); #elif MESH_ESP32_HWCDC_SESSION_GUARD @@ -1352,7 +1418,7 @@ static void completePrimaryUsbSessionReset(void*) { // the close/line-coding boundary was captured. Do not purge it again here: // hosts such as meshcli send APP_START immediately after opening the port, // while this generation gate is deliberately settling. - (void)tud_cdc_n_write_clear(0); + clearNrf52UsbTxForSession(0); const uint32_t cleaned_generation = primary_usb_terminal_taken_reset_generation; primary_usb_allowed_generation.store(cleaned_generation, @@ -1366,6 +1432,8 @@ bool tryCompleteUsbTerminalSessionReset() { return esp32_tinyusb_clean_generation.load(std::memory_order_acquire) == esp32_tinyusb_reset_generation.load(std::memory_order_acquire); #elif defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)) + serviceNrf52UsbSessionReenumeration(); + if (nrf52_usb_reenumeration_pending.load(std::memory_order_acquire)) return false; serviceUsbTerminalPort(); const uint32_t generation = primaryUsbSessionGeneration(); if (primary_usb_terminal_discard_pending.load(std::memory_order_acquire) @@ -1380,7 +1448,8 @@ bool tryCompleteUsbTerminalSessionReset() { if (!nonblocking_primary_usb_companion_port.tryRunExclusive( completePrimaryUsbSessionReset)) return false; return primary_usb_allowed_generation.load(std::memory_order_acquire) - == primaryUsbSessionGeneration(); + == primaryUsbSessionGeneration() + && !nrf52_usb_reenumeration_pending.load(std::memory_order_acquire); #elif MESH_ESP32_HWCDC_SESSION_GUARD if (!esp32_hwcdc_cleanup_pending && esp32_hwcdc_allowed_generation.load(std::memory_order_acquire) @@ -1519,7 +1588,7 @@ UsbLoggingRecoveryResult recoverUsbLoggingTransport(uint8_t stage) { if (tud_cdc_n_connected(1)) tud_sof_cb_enable(true); #else endPrimaryUsbHostSession(false); - (void)tud_cdc_n_write_clear(0); + clearNrf52UsbTxForSession(0); probeUsbLoggingTransport(); #endif } else if (!usb_logging_watchdog_detached) { @@ -1534,8 +1603,12 @@ UsbLoggingRecoveryResult recoverUsbLoggingTransport(uint8_t stage) { } bool isUsbLoggingTransportRecoveryPending() { -#if MESH_ESP32_TINYUSB_NONBLOCKING \ - || (defined(NRF52_PLATFORM) && defined(USE_TINYUSB)) +#if defined(NRF52_PLATFORM) && defined(USE_TINYUSB) + return usb_logging_watchdog_detached + || nrf52_usb_reenumeration_pending.load(std::memory_order_acquire); +#elif defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE) + return nrf52_usb_reenumeration_pending.load(std::memory_order_acquire); +#elif MESH_ESP32_TINYUSB_NONBLOCKING return usb_logging_watchdog_detached; #elif MESH_ESP32_HWCDC_SESSION_GUARD return esp32_hwcdc_cleanup_pending; diff --git a/src/helpers/nrf52/EthernetCLI.h b/src/helpers/nrf52/EthernetCLI.h index 0a69654c3..495acd374 100644 --- a/src/helpers/nrf52/EthernetCLI.h +++ b/src/helpers/nrf52/EthernetCLI.h @@ -6,6 +6,16 @@ #include #include #include +#include + +// Ethernet startup runs on a FreeRTOS task, and client acceptance runs in the +// mesh loop. Never let either path wait on a CDC host which stopped reading. +// Keep diagnostics separate from functional replies on the Ethernet client. +#define ETHERNET_CLI_LOG(F, ...) do { \ + if (mesh::isUsbDebugLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { \ + mesh::usbLoggingPort().printf("ETH: " F "\n", ##__VA_ARGS__); \ + } \ +} while (0) #define PIN_SPI1_MISO (29) #define PIN_SPI1_MOSI (30) @@ -41,7 +51,7 @@ static bool ethernet_take_session_reset() { static void ethernet_task(void* param) { (void)param; - Serial.println("ETH: Initializing hardware"); + ETHERNET_CLI_LOG("Initializing hardware"); // WB_IO2 (power enable) is already driven HIGH by early constructor // in RAK4631Board.cpp to support POE boot. // Skip hardware reset - the W5100S comes out of power-on reset cleanly, @@ -54,30 +64,30 @@ static void ethernet_task(void* param) { uint8_t mac[6]; generateEthernetMac(mac); - Serial.printf("ETH: MAC: %02X:%02X:%02X:%02X:%02X:%02X\n", + ETHERNET_CLI_LOG("MAC: %02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); // Retry loop: keep trying until we get an IP while (!ethernet_running) { - Serial.println("ETH: Attempting DHCP..."); + ETHERNET_CLI_LOG("Attempting DHCP..."); if (Ethernet.begin(mac, 10000, 2000) == 0) { if (Ethernet.hardwareStatus() == EthernetNoHardware) { - Serial.println("ETH: Hardware not found, giving up"); + ETHERNET_CLI_LOG("Hardware not found, giving up"); vTaskDelete(NULL); return; } if (Ethernet.linkStatus() == LinkOFF) { - Serial.println("ETH: Cable not connected, will retry"); + ETHERNET_CLI_LOG("Cable not connected, will retry"); } else { - Serial.println("ETH: DHCP failed, will retry"); + ETHERNET_CLI_LOG("DHCP failed, will retry"); } vTaskDelay(pdMS_TO_TICKS(ETHERNET_RETRY_INTERVAL_MS)); continue; } IPAddress ip = Ethernet.localIP(); - Serial.printf("ETH: IP: %u.%u.%u.%u\n", ip[0], ip[1], ip[2], ip[3]); - Serial.printf("ETH: Listening on TCP port %d\n", ETHERNET_TCP_PORT); + ETHERNET_CLI_LOG("IP: %u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); + ETHERNET_CLI_LOG("Listening on TCP port %d", ETHERNET_TCP_PORT); ethernet_server.begin(); ethernet_running = true; } @@ -115,7 +125,7 @@ static void ethernet_check_client() { ethernet_client = newClient; ethernet_session_reset = true; IPAddress ip = ethernet_client.remoteIP(); - Serial.printf("ETH: Client connected from %u.%u.%u.%u\n", ip[0], ip[1], ip[2], ip[3]); + ETHERNET_CLI_LOG("Client connected from %u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); ethernet_client.println(ETHERNET_CLI_BANNER); } } @@ -161,4 +171,6 @@ static void ethernet_send_reply(const char* reply) { } } +#undef ETHERNET_CLI_LOG + #endif // ETHERNET_ENABLED diff --git a/test/fixtures/client_acl_cli/test_client_acl_cli.cpp b/test/fixtures/client_acl_cli/test_client_acl_cli.cpp index e85e704da..824bb06d4 100644 --- a/test/fixtures/client_acl_cli/test_client_acl_cli.cpp +++ b/test/fixtures/client_acl_cli/test_client_acl_cli.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -220,6 +221,11 @@ static void actual_role_dispatch_handles_radio_and_local() { CHECK(reply[0] == 0); CHECK(mesh::console.output.find("ACL:") == 0); CHECK(mesh::console.output.find(row(admin)) != std::string::npos); + std::string local_output = mesh::console.output; + local_output.erase(std::remove(local_output.begin(), local_output.end(), '\r'), + local_output.end()); + CHECK(local_output == "ACL:\n" + row(admin) + "\n" + + row(acl.getClientByIdx(1)) + "\n"); strcpy(command, "get acl"); char paged[] = "get acl 1"; mesh::console.output.clear(); @@ -227,6 +233,27 @@ static void actual_role_dispatch_handles_radio_and_local() { CHECK(mesh::console.output.empty()); CHECK(reply == expected); } + + // Also exercise every role's local full-table listing, not just remote pages. + // The real extracted helper must emit every active key, skip deleted entries, + // and never include the secret fields stored alongside ACL public identities. + ClientACL full; + full.load(&fs, self); + std::string expected_local = "ACL:\n"; + for (unsigned i = 0; i < MAX_CLIENTS; ++i) { + auto* client = add(full, i, i % 7 == 0 ? 0 : PERM_ACL_ADMIN); + if (client->permissions) expected_local += row(client) + "\n"; + } + for (auto handler : {repeaterCommand, roomCommand, sensorCommand}) { + char command[] = "get acl", reply[160] = {}; + mesh::console.output.clear(); + handler(full, nullptr, 0, command, reply); + CHECK(reply[0] == 0); + std::string local_output = mesh::console.output; + local_output.erase(std::remove(local_output.begin(), local_output.end(), '\r'), + local_output.end()); + CHECK(local_output == expected_local); + } } static void repeater_delegation_stays_denied() { diff --git a/test/fixtures/nrf52_usb_console/mocks/Adafruit_TinyUSB.h b/test/fixtures/nrf52_usb_console/mocks/Adafruit_TinyUSB.h index aa5084ce9..af3b4e0b1 100644 --- a/test/fixtures/nrf52_usb_console/mocks/Adafruit_TinyUSB.h +++ b/test/fixtures/nrf52_usb_console/mocks/Adafruit_TinyUSB.h @@ -15,3 +15,9 @@ uint32_t tud_cdc_n_write_flush(uint8_t); uint32_t tud_cdc_n_write_clear(uint8_t); void tud_cdc_get_line_coding(cdc_line_coding_t*); void TinyUSB_Port_EnterDFU(); +struct MockUsbDevice { + void detach(); + void attach(); +}; +extern MockUsbDevice TinyUSBDevice; +extern "C" bool mesh_tud_cdc_n_tx_pending(uint8_t); diff --git a/test/fixtures/nrf52_usb_console/test_console.cpp b/test/fixtures/nrf52_usb_console/test_console.cpp index 009350999..5739b4c9e 100644 --- a/test/fixtures/nrf52_usb_console/test_console.cpp +++ b/test/fixtures/nrf52_usb_console/test_console.cpp @@ -8,7 +8,10 @@ #include MockSerial Serial; +MockUsbDevice TinyUSBDevice; static bool mounted = true, dtr = true, dfu = false; +static bool attached = true, armed_in = false; +static unsigned detaches = 0, attaches = 0; static uint32_t baud = 115200; static std::string fifo, host; static std::function during_write; @@ -19,6 +22,12 @@ extern "C" void tud_umount_cb(); bool tud_mounted() { return mounted; } bool tud_connect() { mounted = true; return true; } bool tud_disconnect() { mounted = false; dtr = false; return true; } +void MockUsbDevice::detach() { attached = false; ++detaches; } +void MockUsbDevice::attach() { attached = true; ++attaches; } +extern "C" bool mesh_tud_cdc_n_tx_pending(uint8_t n) { + assert(n == 0); + return armed_in; +} bool tud_cdc_n_connected(uint8_t n) { assert(n == 0); return mounted && dtr; } uint32_t tud_cdc_n_available(uint8_t n) { assert(n == 0); return 0; } void tud_cdc_n_read_flush(uint8_t n) { assert(n == 0); } @@ -124,6 +133,37 @@ int main() { mounted = true; dtr = true; tud_cdc_line_state_cb(0, true, false); service(); + + // Model an armed endpoint independently from its cleared software FIFO. + // The upstream close recovery and local watchdog detach must obey BOTH + // quiet intervals, never reattach while the owner still reports mounted. + fifo = "OLD"; + armed_in = true; + dtr = false; + tud_cdc_line_state_cb(0, false, false); + assert(fifo.empty() && armed_in && !attached && detaches == 1); + assert(mesh::isUsbLoggingTransportRecoveryPending()); + assert(mesh::recoverUsbLoggingTransport(2) == mesh::UsbLoggingRecoveryResult::Attempted); + const auto detached_at = g_mock_millis; + g_mock_millis += 20; + mesh::serviceUsbLoggingPort(); + assert(attaches == 0 && !attached); + armed_in = false; + tud_umount_cb(); + g_mock_millis = detached_at + 49; + mesh::serviceUsbLoggingPort(); + assert(attaches == 0 && mesh::isUsbLoggingTransportRecoveryPending()); + ++g_mock_millis; + mesh::serviceUsbLoggingPort(); + assert(attaches == 1 && attached && !mesh::isUsbLoggingTransportRecoveryPending()); + mesh::serviceUsbLoggingPort(); + assert(attaches == 1); // no duplicate connect from the second timer + mounted = true; dtr = true; + tud_cdc_line_state_cb(0, true, false); + service(); + drain(); + assert(host.empty()); // old armed packet cannot leak into a new handle + baud = 1200; dtr = false; tud_cdc_line_state_cb(0, false, false); assert(dfu); // Existing bootloader entry is preserved. diff --git a/test/fixtures/nrf52_usb_power_original.c b/test/fixtures/nrf52_usb_power_original.c index a5ac1d9a4..1a007ff95 100644 --- a/test/fixtures/nrf52_usb_power_original.c +++ b/test/fixtures/nrf52_usb_power_original.c @@ -25,6 +25,17 @@ */ // Pinned framework d541301: real power handler before the build-local fix. +// The actual disconnect path is retained too: pull-up disable alone does NOT +// produce a POWER removal edge, so local session recovery needs this event. +void dcd_disconnect(uint8_t rhport) { + (void) rhport; + NRF_USBD->USBPULLUP = 0; + + // Disable Pull-up does not trigger Power USB Removed, in fact it have no + // impact on the USB Power status at all -> need to submit unplugged event to the stack. + dcd_event_bus_signal(0, DCD_EVENT_UNPLUGGED, false); +} + void tusb_hal_nrf_power_event(uint32_t event); void tusb_hal_nrf_power_event(uint32_t event) { // Value is chosen to be as same as NRFX_POWER_USB_EVT_* in nrfx_power.h diff --git a/test/fixtures/usb_diagnostic_logging/mocks/Arduino.h b/test/fixtures/usb_diagnostic_logging/mocks/Arduino.h new file mode 100644 index 000000000..6acf786a1 --- /dev/null +++ b/test/fixtures/usb_diagnostic_logging/mocks/Arduino.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +extern uint32_t mock_millis; +inline uint32_t millis() { return mock_millis; } +inline void delay(uint32_t value) { mock_millis += value; } + +class Stream { + public: + virtual ~Stream() = default; + virtual int available() = 0; + virtual int read() = 0; + virtual int peek() = 0; + virtual void flush() = 0; + virtual int availableForWrite() { return 0; } + virtual size_t write(uint8_t value) = 0; + virtual size_t write(const uint8_t* data, size_t size) = 0; + size_t printf(const char* format, ...) { + char buffer[256]; + va_list args; + va_start(args, format); + const int count = vsnprintf(buffer, sizeof(buffer), format, args); + va_end(args); + assert(count >= 0 && static_cast(count) < sizeof(buffer)); + return write(reinterpret_cast(buffer), static_cast(count)); + } +}; + +class RawSerial { + public: + int availableForWrite() { return 256; } + template void printf(Args...) { assert(false && "raw USB write"); } + template void println(Args...) { assert(false && "raw USB write"); } +}; +extern RawSerial Serial; + +struct MockFicr { uint32_t DEVICEID[2] = {0x123456, 0}; }; +extern MockFicr mock_ficr; +#define NRF_FICR (&mock_ficr) +#define NRF_SPIM1 1 +#define WB_IO2 2 +#define OUTPUT 1 +#define HIGH 1 +inline void pinMode(int, int) {} +inline void digitalWrite(int, int) {} +inline void vTaskDelete(void*) {} +inline void vTaskDelay(uint32_t value) { delay(value); } +inline uint32_t pdMS_TO_TICKS(uint32_t value) { return value; } +inline void xTaskCreate(void (*)(void*), const char*, int, void*, int, void*) {} diff --git a/test/fixtures/usb_diagnostic_logging/mocks/RAK13800_W5100S.h b/test/fixtures/usb_diagnostic_logging/mocks/RAK13800_W5100S.h new file mode 100644 index 000000000..3391f6ba6 --- /dev/null +++ b/test/fixtures/usb_diagnostic_logging/mocks/RAK13800_W5100S.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include + +class IPAddress { + public: + uint8_t operator[](size_t index) const { + static const uint8_t address[4] = {192, 168, 1, 42}; + return address[index]; + } +}; + +extern std::string ethernet_output; +extern std::string ethernet_input; +extern bool accept_client; +class EthernetClient { + bool _connected = false; + public: + explicit EthernetClient(bool connected = false) : _connected(connected) {} + explicit operator bool() const { return _connected; } + bool connected() const { return _connected; } + void stop() { _connected = false; } + IPAddress remoteIP() const { return {}; } + int available() const { return static_cast(ethernet_input.size()); } + int read() { + assert(!ethernet_input.empty()); + const char value = ethernet_input.front(); + ethernet_input.erase(0, 1); + return value; + } + void print(const char* text) { ethernet_output += text; } + void println(const char* text = "") { ethernet_output += std::string(text) + "\r\n"; } +}; + +class EthernetServer { + public: + explicit EthernetServer(int) {} + void begin() {} + EthernetClient accept() { + const bool accepted = accept_client; + accept_client = false; + return EthernetClient(accepted); + } +}; + +enum { EthernetNoHardware = 0, EthernetPresent = 1, LinkOFF = 0, LinkON = 1 }; +class MockEthernet { + public: + int failures = 0; + int hardware = EthernetPresent; + int link = LinkON; + void init(SPIClass&, int) {} + int begin(uint8_t*, int, int) { + if (failures > 0) { --failures; return 0; } + return 1; + } + int hardwareStatus() const { return hardware; } + int linkStatus() const { return link; } + IPAddress localIP() const { return {}; } + void maintain() {} +}; +extern MockEthernet Ethernet; diff --git a/test/fixtures/usb_diagnostic_logging/mocks/SPI.h b/test/fixtures/usb_diagnostic_logging/mocks/SPI.h new file mode 100644 index 000000000..2ac1f2953 --- /dev/null +++ b/test/fixtures/usb_diagnostic_logging/mocks/SPI.h @@ -0,0 +1,7 @@ +#pragma once + +class SPIClass { + public: + SPIClass(int, int, int, int) {} + void begin() {} +}; diff --git a/test/fixtures/usb_diagnostic_logging/test.cpp b/test/fixtures/usb_diagnostic_logging/test.cpp new file mode 100644 index 000000000..f36be2efb --- /dev/null +++ b/test/fixtures/usb_diagnostic_logging/test.cpp @@ -0,0 +1,157 @@ +#include +#include +#include "NetworkLoggingMacro.h" + +#include + +uint32_t mock_millis = 0; +RawSerial Serial; +MockFicr mock_ficr; +MockEthernet Ethernet; +std::string ethernet_output; +std::string ethernet_input; +bool accept_client = false; + +class MockUsb : public Stream { + public: + int capacity = 256; + bool refuse_write = false; + unsigned int tries = 0; + std::string output; + int available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } + void flush() override { assert(false && "blocking flush"); } + int availableForWrite() override { return capacity; } + size_t write(uint8_t) override { assert(false && "blocking write"); return 0; } + size_t write(const uint8_t*, size_t) override { assert(false && "blocking write"); return 0; } +}; + +static MockUsb usb; +static bool logging_enabled = true; +static bool debug_enabled = true; +static size_t tryUsbWrite(void*, const uint8_t* data, size_t size) { + ++usb.tries; + if (usb.refuse_write || usb.capacity <= 0) return 0; + const size_t written = size < static_cast(usb.capacity) + ? size : static_cast(usb.capacity); + usb.output.append(reinterpret_cast(data), written); + return written; +} +static mesh::SingleAttemptNonBlockingStream logging_port(usb, tryUsbWrite); +namespace mesh { +bool isUsbLoggingEnabled() { return logging_enabled; } +bool isUsbDebugEnabled() { return debug_enabled; } +Stream& usbLoggingPort() { return logging_port; } +} + +static void reset() { + usb.output.clear(); + usb.capacity = 256; + usb.refuse_write = false; + usb.tries = 0; + logging_enabled = true; + debug_enabled = true; + ethernet_running = false; + ethernet_client = EthernetClient(); + ethernet_output.clear(); + ethernet_input.clear(); + accept_client = false; + Ethernet = MockEthernet(); + mock_millis = 0; +} + +static void acceptClientAndReply() { + accept_client = true; + ethernet_loop_maintain(); + assert(ethernet_take_session_reset()); + assert(!ethernet_take_session_reset()); + ethernet_input = "eth.status\r"; + char command[80] = {}; + assert(ethernet_read_line(command, sizeof(command))); + assert(strcmp(command, "eth.status") == 0); + char reply[80] = {}; + assert(ethernet_handle_command(command, reply)); + ethernet_send_reply(reply); + assert(ethernet_output == "MeshCore CLI\r\n\r\n -> ETH: 192.168.1.42:23\r\n"); +} + +int main() { + (void)ðernet_start_task; + // Runtime-off diagnostics must not attempt USB, while functional Ethernet + // commands and the client banner still work. + reset(); + logging_enabled = false; + ethernet_task(nullptr); + acceptClientAndReply(); + assert(usb.output.empty() && usb.tries == 0 && mock_millis == 0); + + // The master switch can remain on for packets while verbose ETH output is + // disabled. Its functional banner and command reply still reach the client. + reset(); + debug_enabled = false; + ethernet_task(nullptr); + acceptClientAndReply(); + assert(logging_enabled && usb.output.empty() && usb.tries == 0); + + reset(); + ethernet_task(nullptr); + acceptClientAndReply(); + assert(usb.output.find("ETH: MAC: 02:92:1F:12:34:56\n") != std::string::npos); + assert(usb.output.find("ETH: Listening on TCP port 23\n") != std::string::npos); + assert(usb.output.find("ETH: Client connected from 192.168.1.42\n") != std::string::npos); + assert(usb.tries == 6 && mock_millis == 0); + + // An open-but-stalled host has no TX space. No raw write or wait is allowed. + reset(); + usb.capacity = 0; + ethernet_task(nullptr); + acceptClientAndReply(); + assert(usb.output.empty() && usb.tries == 0 && mock_millis == 0); + + // The host can also vanish after the capacity preflight. The real shared + // single-attempt facade drops each diagnostic instead of retrying or waiting. + reset(); + usb.refuse_write = true; + ethernet_task(nullptr); + acceptClientAndReply(); + assert(usb.output.empty() && usb.tries == 6 && mock_millis == 0); + + for (int failure : {0, 1, 2}) { + reset(); + Ethernet.failures = 1; + if (failure == 0) Ethernet.hardware = EthernetNoHardware; + if (failure == 1) Ethernet.link = LinkOFF; + ethernet_task(nullptr); + const char* expected = failure == 0 ? "Hardware not found, giving up" + : failure == 1 ? "Cable not connected, will retry" : "DHCP failed, will retry"; + assert(usb.output.find(expected) != std::string::npos); + assert(mock_millis == (failure == 0 ? 0u : 30000u)); + } + + reset(); + int evaluated = 0; + logging_enabled = false; + NETWORK_DEBUG_PRINTLN("suppressed %d", ++evaluated); + assert(evaluated == 0 && usb.tries == 0); + logging_enabled = true; + debug_enabled = false; + NETWORK_DEBUG_PRINTLN("packet-only %d", ++evaluated); + assert(evaluated == 0 && usb.tries == 0); + debug_enabled = true; + usb.capacity = 0; + NETWORK_DEBUG_PRINTLN("stalled %d", ++evaluated); + assert(evaluated == 0 && usb.tries == 0); + usb.capacity = 256; + NETWORK_DEBUG_PRINTLN("visible %d", ++evaluated); +#if defined(MQTT_DEBUG) && MQTT_DEBUG + assert(evaluated == 1 && usb.output == "MQTT: visible 1\n" && usb.tries == 1); + usb.refuse_write = true; + NETWORK_DEBUG_PRINTLN("disconnected %d", ++evaluated); + assert(usb.tries == 2 && usb.output == "MQTT: visible 1\n"); +#else + assert(evaluated == 0 && usb.output.empty() && usb.tries == 0); +#endif + assert(mock_millis == 0); + std::cout << "PASS: gated USB diagnostics\n"; +} diff --git a/test/test_client_acl_cli.py b/test/test_client_acl_cli.py index 8143e375e..5939fc9f7 100644 --- a/test/test_client_acl_cli.py +++ b/test/test_client_acl_cli.py @@ -32,12 +32,23 @@ def role_handler(role, source): # The room source opens its next conditional branch's preprocessor guard # immediately before the closing brace of the local ACL branch. local = re.sub(r"\n#if defined\(WITH_MQTT_NEIGHBORS\)\n\s*}$", "\n}", local) + usb_acl_helper = "" + if role != "sensor": + # Keep local dispatch attached to the actual USB listing helper. These + # fixtures exercise legacy/nonnative USB; native-USB short writes are + # covered separately by test_esp32_tinyusb_cooperative_output.py. + usb_acl_helper = extract_braced(source, "void MyMesh::printAclSerial()") + usb_acl_helper = usb_acl_helper.replace( + "void MyMesh::printAclSerial()", + f"static void {role}PrintAclSerial(ClientACL& acl)", 1) + local = local.replace("printAclSerial();", f"{role}PrintAclSerial(acl);") guard = "" if role == "repeater": guard_start = body.rindex("if (sender && !sender->isAdmin())", 0, body.index("mesh::cli::handleACLGet(")) guard = extract_braced(body[guard_start:], "if (sender && !sender->isAdmin())") return f""" +{usb_acl_helper} static void {role}Command(ClientACL& acl, ClientInfo* sender, uint32_t sender_timestamp, char* command, char* reply) {{ const int gpio_client_index = sender == nullptr ? -1 : 0; @@ -95,6 +106,7 @@ namespace mesh { class Utils { public: result = subprocess.run([ compiler, "-std=c++17", "-Wall", "-Wextra", "-DESP32=1", "-DESP32_PLATFORM=1", "-DMESH_ENABLE_FLOOD_RULE_ENGINE=1", + "-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=0", f"-DMAX_CLIENTS={clients}", f"-I{work}", f"-I{ACL_MOCKS}", f"-I{ROOT / 'src'}", str(FIXTURE / "test_client_acl_cli.cpp"), "-o", str(binary), diff --git a/test/test_common_cli_logging_transaction.py b/test/test_common_cli_logging_transaction.py new file mode 100644 index 000000000..fb1d64bcd --- /dev/null +++ b/test/test_common_cli_logging_transaction.py @@ -0,0 +1,150 @@ +"""Run the real logging setters with failed saves and failed MQTT transitions.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include "helpers/CLICommandUtils.h" +struct Prefs { uint8_t usb_logging_enabled = 0, bridge_enabled = 0; }; +static bool live_usb = false; +static unsigned usb_calls = 0; +namespace mesh { +void setUsbLoggingEnabled(bool value) { live_usb = value; ++usb_calls; } +} +struct Callbacks { + bool live_wifi = false, apply_ok = true; + unsigned calls = 0; + bool setMqttBridgeState(bool value) { + ++calls; + if (apply_ok) live_wifi = value; + return apply_ok; + } +}; +struct CLI { + Prefs prefs, persisted; + Prefs* _prefs = &prefs; + Callbacks callbacks; + Callbacks* _callbacks = &callbacks; + bool save_ok = true; + unsigned saves = 0; + bool trySavePrefs() { ++saves; if (save_ok) persisted = prefs; return save_ok; } + void set(const char* config, char* reply) { @BRANCHES@ } +}; +static void check(const char* command, bool usb, bool wifi, bool failed) { + CLI cli; + cli.prefs = {uint8_t(!usb), uint8_t(!wifi)}; + cli.persisted = cli.prefs; + live_usb = !usb; + cli.callbacks.live_wifi = !wifi; + usb_calls = 0; + cli.save_ok = !failed; + char reply[160]{}; + cli.set(command, reply); + assert(cli.saves == 1); + assert(cli.prefs.usb_logging_enabled == (failed ? !usb : usb)); + assert(cli.persisted.usb_logging_enabled == (failed ? !usb : usb)); + assert(live_usb == (failed ? !usb : usb)); + assert(usb_calls == (failed ? 0u : 1u)); + assert(strstr(reply, failed ? "unchanged" : "(saved)")); + if (strncmp(command, "logging.output", 14) == 0) { + assert(cli.prefs.bridge_enabled == (failed ? !wifi : wifi)); + assert(cli.persisted.bridge_enabled == (failed ? !wifi : wifi)); + assert(cli.callbacks.live_wifi == (failed ? !wifi : wifi)); + assert(cli.callbacks.calls == (failed ? 0u : 1u)); + } else { + assert(cli.callbacks.calls == 0); + } +} +int main() { + for (bool failed : {false, true}) { + check("usb.logging on", true, false, failed); + check("usb.logging off", false, false, failed); +#if WITH_MQTT_BRIDGE + check("logging.output off", false, false, failed); + check("logging.output usb", true, false, failed); + check("logging.output wifi", false, true, failed); + check("logging.output both", true, true, failed); +#endif + } + CLI cli; + char reply[160]{}; + cli.set("usb.logging invalid", reply); + assert(cli.saves == 0 && strstr(reply, "Error:")); +#if WITH_MQTT_BRIDGE + cli.set("logging.output invalid", reply); + assert(cli.saves == 0 && strstr(reply, "Error:")); + cli.callbacks.apply_ok = false; + cli.set("logging.output both", reply); + assert(cli.persisted.usb_logging_enabled && cli.persisted.bridge_enabled); + assert(live_usb && !cli.callbacks.live_wifi); + assert(strstr(reply, "saved, but MQTT runtime change failed")); +#endif +} +''' + + +class LoggingTransactionTest(unittest.TestCase): + def test_companion_reply_survives_input_buffer_reset_during_mode_change(self): + source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text() + branch = extract_braced(source, 'if (strncmp(command, "set logging.output ", 19) == 0)') + harness = r''' +#include +#include +#include +#include +#include "helpers/CLICommandUtils.h" +static char input[64]; +namespace CompanionMqttSetupPortal { static bool saveEnabled(bool) { return true; } } +namespace mesh { static bool saveUsbLoggingBootPreference(bool) { return true; } } +struct Mesh { + bool _mqtt_enabled = true; + struct { unsigned usb_logging_enabled = 1; } _prefs; + void stopMQTT() {} + bool savePrefs() { return true; } + void applyUsbLoggingState(bool) { memset(input, 0, sizeof(input)); } + bool handle(const char* command, char* reply, size_t reply_size) { @BRANCH@ return false; } +}; +int main() { + for (const char* mode : {"off", "usb", "wifi", "both"}) { + Mesh node; + char reply[160], expected[160]; + snprintf(input, sizeof(input), "set logging.output %s", mode); + snprintf(expected, sizeof(expected), "OK - logging.output %s (saved)", mode); + assert(node.handle(input, reply, sizeof(reply))); + assert(input[0] == 0 && strcmp(reply, expected) == 0); + } +} +'''.replace('@BRANCH@', branch) + with tempfile.TemporaryDirectory() as directory: + cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test.exe' + cpp.write_text(harness) + subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror', + '-I' + str(ROOT / 'src'), str(cpp), '-o', str(exe)], check=True) + subprocess.run([str(exe)], check=True) + + def test_real_setters_preserve_live_and_saved_state_on_failure(self): + source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text() + usb = extract_braced(source, 'if (strncmp(config, "usb.logging", 11) == 0') + mqtt = extract_braced(source, 'if (strncmp(config, "logging.output", 14) == 0') + for enabled in (0, 1): + with self.subTest(mqtt=enabled), tempfile.TemporaryDirectory() as directory: + cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test.exe' + cpp.write_text(HARNESS.replace('@BRANCHES@', usb + ('\n' + mqtt if enabled else ''))) + subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror', + '-DWITH_MQTT_BRIDGE=' + str(enabled), + '-I' + str(ROOT / 'src'), str(cpp), '-o', str(exe)], check=True) + subprocess.run([str(exe)], check=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_companion_usb_default.py b/test/test_companion_usb_default.py index caa2c05ea..7e7c01f47 100644 --- a/test/test_companion_usb_default.py +++ b/test/test_companion_usb_default.py @@ -38,16 +38,20 @@ struct Mesh { bool terminal = false, stale_input = true; bool isTerminalMode() const { return terminal; } void resetUsbHostSessionInput() { stale_input = false; } + void applyUsbLoggingState(bool); } the_mesh; struct SerialInterface { bool passthrough = false, old_frame = true; uint32_t completed = 7; void setPassthroughMode(bool value) { passthrough = value; } + bool isPassthroughMode() const { return passthrough; } void resetSessionState() { old_frame = false; } uint32_t getCompletedFrameCount() const { return completed; } } usb_serial_interface; struct Board { bool isUsbHostConnected() const { return data_connected; } } board; static bool usb_mota_mode = false, usb_logging_terminal_mode = false; +static bool usb_logging_network_parked = false; +static bool usb_protocol_initialized = true; static bool usb_host_session_connected = false, usb_terminal_discard_line = true; static bool usb_terminal_host_reset_completion_pending = false; static uint32_t usb_terminal_host_reset_retry_at = 0; @@ -57,13 +61,27 @@ static mesh::UsbAsciiSessionDefault usb_ascii_session_default; static mesh::UsbHostPresenceDebouncer usb_hwcdc_host_presence; static constexpr unsigned USB_TRANSPORT_RESET_RETRY_MS = 1000; static constexpr unsigned USB_HOST_LOSS_EDGE_MS = 100, USB_HOST_LOSS_GRACE_MS = 2000; +struct Stream { + int pending = 0; + int available() const { return pending; } + int read() { return pending-- > 0 ? 'x' : -1; } + void print(const char*) {} +} usb_stream; +static Stream& usbTerminalOutput() { return usb_stream; } namespace mesh { +Stream& usbCompanionPort() { return usb_stream; } void discardUsbTerminalOutput() {} bool takeUsbTerminalSessionReset() { bool value = reset_event; reset_event = false; return value; } bool resetUsbCompanionTransport() { return transport_ready; } bool tryCompleteUsbTerminalSessionReset() { return transport_ready; } bool hasDedicatedUsbLoggingPort() { return dedicated_logging; } bool isUsbLoggingEnabled() { return logging_enabled; } +void setUsbLoggingEnabled(bool enabled) { + if (enabled && usb_protocol_initialized && !dedicated_logging) { + assert(usb_serial_interface.passthrough); // Must park before gate opens. + } + logging_enabled = enabled; +} } static bool isUsbTerminalDataConnected() { return data_connected; } static bool isNetworkTerminalActive() { return network_active; } @@ -89,6 +107,7 @@ static void enterUsbLoggingTerminalMode() { static void service() { serviceUsbTerminalHostSessionReset(); serviceUsbAsciiSessionDefault(); + serviceUsbLoggingOwnership(logging_enabled); } static void boundary(bool event = true) { reset_event = event; @@ -156,6 +175,41 @@ int main() { boundary(false); assert(the_mesh.terminal); #endif + // A network CLI must retain ownership while USB diagnostics exclude frames. + the_mesh.terminal = false; + usb_serial_interface.passthrough = false; + network_active = true; + logging_enabled = false; + the_mesh.applyUsbLoggingState(true); + assert(network_active && !the_mesh.terminal); + assert(usb_serial_interface.passthrough && usb_logging_network_parked); + usb_stream.pending = 5; + serviceUsbLoggingOwnership(logging_enabled); + assert(usb_stream.pending == 5); // Still log-only; no USB CLI takes ownership. + the_mesh.applyUsbLoggingState(false); + assert(network_active && !the_mesh.terminal); + assert(!usb_serial_interface.passthrough && !usb_logging_network_parked); + assert(usb_stream.pending == 0); + logging_enabled = true; + serviceUsbLoggingOwnership(logging_enabled); + network_active = false; + usb_stream.pending = 5; + serviceUsbLoggingOwnership(logging_enabled); + assert(the_mesh.terminal && usb_logging_terminal_mode); + assert(usb_serial_interface.passthrough && !usb_logging_network_parked); + assert(usb_stream.pending == 0); + // An ASCII-restoring network handoff must not accidentally reenable frames. + logging_enabled = false; + usb_logging_network_parked = true; + usb_logging_terminal_mode = false; + serviceUsbLoggingOwnership(logging_enabled); + assert(the_mesh.terminal && usb_serial_interface.passthrough); + // Remote logging cancels exclusive mOTA before the next framed dispatch. + usb_mota_mode = true; + the_mesh.terminal = false; + logging_enabled = true; + serviceUsbLoggingOwnership(logging_enabled); + assert(!usb_mota_mode && the_mesh.terminal && usb_serial_interface.passthrough); logging_enabled = true; beginUsbDefaultSession(); assert(the_mesh.terminal && usb_logging_terminal_mode); @@ -208,9 +262,11 @@ class CompanionUsbDefaultTest(unittest.TestCase): self.assertIn("companion_transport_boot_mode =\n CompanionTransportMode::Bluetooth;", MAIN) def test_startup_and_reconnection_for_each_usb_transport(self): - functions = "\n".join(function("static void " + name + "(") for name in ( + functions = "\n".join(function("static void " + name + "() {") for name in ( "resetUsbTerminalHostSession", "serviceUsbTerminalHostSessionReset", "serviceUsbAsciiSessionDefault", "beginUsbDefaultSession")) + functions += "\n" + function("static void serviceUsbLoggingOwnership(bool logging_enabled) {") + functions += "\n" + function("void MyMesh::applyUsbLoggingState(bool enabled) {").replace("MyMesh::", "Mesh::") profiles = { "nrf52": ["NRF52_PLATFORM=1"], "esp32-tinyusb": ["ESP32=1", "ARDUINO_USB_MODE=0", "ARDUINO_USB_CDC_ON_BOOT=1", "MESH_ESP32_TINYUSB_NONBLOCKING=1"], @@ -237,8 +293,22 @@ class CompanionUsbDefaultTest(unittest.TestCase): loop = function("\nvoid loop() {") self.assertLess(loop.index("serviceUsbTerminalHostSessionReset();"), loop.index("serviceUsbAsciiSessionDefault();")) self.assertLess(loop.index("serviceUsbAsciiSessionDefault();"), loop.index("the_mesh.loop();")) + self.assertLess(loop.index("serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());"), loop.index("the_mesh.loop();")) self.assertLess(loop.index("expireUsbBinaryStartupProbeBeforeDispatch();"), loop.index("the_mesh.loop();")) self.assertNotIn("COMPANION_RADIO_FULL", MAIN) + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + self.assertNotIn("mesh::setUsbLoggingEnabled(", source) + logging_reply = source[source.index('if (strncmp(command, "set logging.output ", 19)'): + source.index('if ((strncmp(command, "get ", 4)', source.index('if (strncmp(command, "set logging.output ", 19)'))] + self.assertIn("mesh::cli::loggingOutputName(usb, wifi)", logging_reply) + terminal = function("static void serviceUsbTerminal() {") + self.assertLess(terminal.index("serviceUsbLoggingOwnership("), terminal.index("if (usb_mota_mode)")) + leave = function("static void leaveUsbTerminalMode(bool acknowledge) {") + self.assertIn("!mesh::hasDedicatedUsbLoggingPort() && mesh::isUsbLoggingEnabled()", leave) + browser = function("bool MyMesh::beginStreamTerminal(Stream& output) {") + self.assertIn("serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());", browser) + tcp = function("static void ota_console_loop() {") + self.assertIn("serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());", tcp) if __name__ == "__main__": diff --git a/test/test_companion_usb_logging.py b/test/test_companion_usb_logging.py index 079e13b27..b6a52226f 100644 --- a/test/test_companion_usb_logging.py +++ b/test/test_companion_usb_logging.py @@ -390,7 +390,7 @@ inline char* utoa(unsigned int value,char* output,int base){ self.assertIn('_prefs.usb_debug_enabled = 0;', source) self.assertIn('_prefs.usb_debug_enabled = _prefs.usb_debug_enabled == 1 ? 1 : 0;', source) apply = source.index('mesh::setUsbDebugEnabled(_prefs.usb_debug_enabled != 0);') - self.assertLess(apply, source.index('mesh::setUsbLoggingEnabled(usb_logging_enabled);')) + self.assertLess(apply, source.index('applyUsbLoggingState(usb_logging_enabled);')) header = (ROOT / 'examples/companion_radio/MyMesh.h').read_text() self.assertIn('const char* getLogDateTime() override;', header) diff --git a/test/test_esp32_tinyusb_cooperative_output.py b/test/test_esp32_tinyusb_cooperative_output.py index 168ea70b7..4a4b670b6 100644 --- a/test/test_esp32_tinyusb_cooperative_output.py +++ b/test/test_esp32_tinyusb_cooperative_output.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Execute the real native-TinyUSB role output pumps with small host stubs.""" +"""Execute the real native-USB role output pumps with small host stubs.""" from pathlib import Path import os @@ -23,8 +23,10 @@ HARNESS = r''' #include #include #include -#define MESH_ESP32_TINYUSB_NONBLOCKING 1 +@USB_CONFIG@ +#define MESH_ESP32_USB_CONSOLE_COOPERATIVE 1 #define MESH_USB_CONSOLE_COOPERATIVE 1 +#define PUB_KEY_SIZE 32 #define MAX_ROUTE_HASH_BYTES 3 #define PACKET_LOG_FILE "/packet_log" static void require(bool ok, const char* what) { @@ -104,6 +106,15 @@ struct SimpleMeshTables { return result < static_cast(rows.size()) ? &rows[result] : nullptr; } }; +struct FakeAcl { + struct Client { + struct { uint8_t pub_key[PUB_KEY_SIZE]; } id{}; + uint8_t permissions = 3; + }; + std::vector clients; + int getNumClients() const { return static_cast(clients.size()); } + Client* getClientByIdx(int index) { return &clients.at(index); } +}; class MyMesh { public: FakeFS* _fs = &fs; @@ -114,6 +125,9 @@ class MyMesh { bool serial_log_active = false; bool serial_log_eof_pending = false; bool serial_log_skip_line = false; + int serial_acl_next = -1; + int serial_acl_count = 0; + bool serial_acl_header = false; int serial_recent_next = -1; int serial_recent_count = 0; bool serial_recent_header = false; @@ -121,8 +135,10 @@ class MyMesh { SimpleMeshTables::RecentRepeaterInfo serial_recent_cursor{}; int serial_recent_cursor_index = -1; SimpleMeshTables tables; + FakeAcl acl; const SimpleMeshTables* getTables() const { return &tables; } void dumpLogFile(); + void printAclSerial(); bool hasPendingSerialOutput() const; void servicePendingSerialOutput(); void cancelPendingSerialOutput(); @@ -207,6 +223,78 @@ int main() { drain(radio); require(console.output.empty(), "canceled old-session output leaked"); } + setupFile(""); + { + MyMesh radio; + std::string expected = "ACL:\r\n"; + for (int i = 0; i < 32; ++i) { + FakeAcl::Client client; + std::fill(std::begin(client.id.pub_key), std::end(client.id.pub_key), i); + radio.acl.clients.push_back(client); + char key[PUB_KEY_SIZE * 2 + 1], line[80]; + mesh::Utils::toHex(key, client.id.pub_key, PUB_KEY_SIZE); + snprintf(line, sizeof(line), "%02X %s\n", client.permissions, key); + expected += line; + } + require(expected.size() > 2048, "ACL test does not exceed admission reserve"); + console.capacity = 256; // Accepted with a nearly-full HWCDC/logging FIFO. + radio.printAclSerial(); + require(console.output.empty(), "ACL is still emitted synchronously"); + radio.acl.clients.emplace_back(); // New arrivals must not extend the snapshot. + console.capacity = 0; + for (int i = 0; i < 1000; ++i) radio.servicePendingSerialOutput(); + require(console.output.empty(), "ACL wrote into a stalled host"); + require(radio.serial_acl_next == 0, "stalled header skipped an ACL row"); + console.fail_once = true; + console.short_limit = 17; + drain(radio, 73); + require(console.output == expected, "full ACL lost or duplicated short-write bytes"); + require(!radio.hasPendingSerialOutput(), "ACL blocks the next command after completion"); + } + setupFile(""); + { + MyMesh radio; + radio.acl.clients.resize(32); + for (auto& client : radio.acl.clients) client.permissions = 0; + radio.printAclSerial(); + drain(radio, 73); + require(console.output == "ACL:\r\n", "deleted ACL clients were printed"); + } + setupFile(""); + { + MyMesh radio; + radio.acl.clients.resize(1); + std::fill(std::begin(radio.acl.clients[0].id.pub_key), + std::end(radio.acl.clients[0].id.pub_key), 0xAA); + radio.printAclSerial(); + radio.servicePendingSerialOutput(); + console.short_limit = 7; + radio.servicePendingSerialOutput(); + require(radio.serial_log_pending_size > 0, "ACL short-write suffix was not retained"); + radio.cancelPendingSerialOutput(); + require(!radio.hasPendingSerialOutput(), "ACL cancellation leaves a pending job"); + console.output.clear(); + console.records.clear(); + console.short_limit = 0; + radio.acl.clients.clear(); + radio.printAclSerial(); + drain(radio, 73); + require(console.output == "ACL:\r\n", "old ACL row leaked after reconnect cancellation"); + } + setupFile("existing file dump\n"); + { + MyMesh radio; + radio.dumpLogFile(); + console.capacity = 0; + radio.servicePendingSerialOutput(); + const size_t pending = radio.serial_log_pending_size; + radio.printAclSerial(); + require(radio.serial_log_pending_size == pending && radio.serial_acl_next == -1, + "overlapping ACL command overwrote a pending file record"); + drain(radio); + require(console.output == "existing file dump\n" + eof, + "overlapping ACL command corrupted an existing output job"); + } @RECENT_TEST@ std::cout << "cooperative output checks passed\n"; return 0; @@ -241,12 +329,23 @@ RECENT_TEST = r''' class CooperativeOutputTest(unittest.TestCase): + def test_usb_acl_commands_route_to_the_cooperative_pump(self): + for role in ("simple_repeater", "simple_room_server"): + with self.subTest(role=role): + text = (ROOT / f"examples/{role}/MyMesh.cpp").read_text() + start = text.index('strcmp(command, "get acl") == 0) {') + end = text.index("reply[0] = 0;", text.index("#endif", start)) + self.assertIn("printAclSerial();", text[start:end]) + self.assertNotIn("mesh::usbConsolePort().printf", text[start:end]) + def test_real_role_pumps(self): compiler = shutil.which("g++") or shutil.which("clang++") if not compiler: self.skipTest("A host C++ compiler is required for pump execution") - for role in ("simple_repeater", "simple_room_server"): - with self.subTest(role=role), tempfile.TemporaryDirectory() as temporary: + for role, config in ((role, config) + for role in ("simple_repeater", "simple_room_server") + for config in ("MESH_ESP32_TINYUSB_NONBLOCKING", "MESH_ESP32_HWCDC_SESSION_GUARD")): + with self.subTest(role=role, transport=config), tempfile.TemporaryDirectory() as temporary: text = (ROOT / f"examples/{role}/MyMesh.cpp").read_text() dump_start = text.index("void MyMesh::dumpLogFile()") dump_end = text.index("\n#if MESH_USB_CONSOLE_COOPERATIVE\nbool MyMesh::hasPendingSerialOutput", dump_start) @@ -261,7 +360,7 @@ class CooperativeOutputTest(unittest.TestCase): methods = text[format_start:format_end] + "\n" + text[recent_start:recent_end] + "\n" + methods program = HARNESS.replace("@METHODS@", methods).replace( "@RECENT_TEST@", RECENT_TEST if role == "simple_repeater" else "" - ) + ).replace("@USB_CONFIG@", f"#define {config} 1") executable = Path(temporary) / ("pump.exe" if os.name == "nt" else "pump") build = subprocess.run( [compiler, "-std=c++17", "-O0", "-I", str(ROOT / "src"), diff --git a/test/test_esp32_tinyusb_role_hygiene.py b/test/test_esp32_tinyusb_role_hygiene.py index 6f54fe9a8..802f49ce2 100644 --- a/test/test_esp32_tinyusb_role_hygiene.py +++ b/test/test_esp32_tinyusb_role_hygiene.py @@ -154,6 +154,7 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase): ) self.assertIsNotNone(guard) self.assertEqual(shutdown.count("Serial.flush();"), 1) + self.assertNotIn("Serial.flush();", sleep) def test_large_file_dumps_are_bounded_and_cancelable(self): for role in ROLES: @@ -185,6 +186,7 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase): def test_recent_list_advances_only_after_whole_row_admission(self): text = source("examples/simple_repeater/MyMesh.cpp") start = text.index("void MyMesh::servicePendingSerialOutput()") + start = text.index("if (serial_recent_next >= 0)", start) service = text[start : text.index("if (!serial_log_active)", start)] self.assertIn("char record[64];", service) self.assertIn("serial_recent_next < serial_recent_count", service) diff --git a/test/test_nrf52_usb_client.py b/test/test_nrf52_usb_client.py index 5211a2606..091e6fba4 100644 --- a/test/test_nrf52_usb_client.py +++ b/test/test_nrf52_usb_client.py @@ -31,6 +31,10 @@ HARNESS = r''' static bool mounted = true, dtr[2] = {false, false}, dfu = false; static uint32_t now_ms = 100, rx_count = 0, tx_count = 0; static unsigned rx_flushes = 0, tx_clears = 0, log_edges = 0; +// Model the endpoint separately from the software FIFO. A FIFO clear must +// leave an armed packet intact; only a real detach + enumeration retires it. +static bool armed_in[2] = {false, false}, attached = true; +static unsigned detaches = 0, attaches = 0; static bool close_during_rx_sample = false; static uint32_t millis() { return now_ms; } static bool tud_mounted() { return mounted; } @@ -46,6 +50,11 @@ static uint32_t tud_cdc_n_available(uint8_t) { } static void tud_cdc_n_read_flush(uint8_t) { ++rx_flushes; rx_count = 0; } static uint32_t tud_cdc_n_write_clear(uint8_t) { ++tx_clears; tx_count = 0; return 0; } +static bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed_in[n]; } +struct UsbDevice { + void detach() { ++detaches; attached = false; } + void attach() { ++attaches; attached = true; } +} TinyUSBDevice; static uint32_t tud_cdc_n_write_available(uint8_t) { return 64 - tx_count; } static uint32_t tud_cdc_n_write(uint8_t, const void*, uint32_t size) { const auto count = std::min(size, 64 - tx_count); tx_count += count; return count; @@ -56,6 +65,10 @@ static void tud_cdc_get_line_coding(cdc_line_coding_t* coding) { coding->bit_rat static void TinyUSB_Port_EnterDFU() { dfu = true; } namespace mesh { static std::atomic usb_logging_tx_waiting{false}; +#if defined(USE_TINYUSB) +static bool usb_logging_watchdog_detached = false; +static uint32_t usb_logging_watchdog_attach_at = 0; +#endif static bool isUsbLoggingEnabled() { return true; } static void clearUsbLoggingClientActivity() {} @ATTEMPT@ @@ -148,6 +161,32 @@ int main() { // No write waits when the FIFO is full, including with DTR low. tx_count = 64; assert(mesh::writeTinyUsbCdcOnce(nullptr, bytes, 4) == 0); + // A previously submitted IN packet survives FIFO clearing. Quarantine the + // entire device immediately, but never wait on the unread endpoint. + tx_count = 4; armed_in[0] = true; + rx_count = 4; + assert(mesh::isUsbCompanionClientConnected()); + line(false); + assert(tx_count == 0 && armed_in[0] && !attached && detaches == 1); + assert(!mesh::isUsbCompanionClientConnected()); + assert(mesh::writeTinyUsbCdcOnce(nullptr, bytes, 4) == 0); + // Repeated control callbacks cannot cause a detach/attach storm. + meshTinyUsbCdcLineCodingChanged(0); + line(false); + assert(detaches == 1); + now_ms += 100; + mesh::serviceNrf52UsbSessionReenumeration(); + assert(attaches == 0); // owner has not consumed unplug yet + mounted = false; armed_in[0] = false; dtr[0] = false; + meshTinyUsbDeviceSessionBoundary(); + mesh::serviceNrf52UsbSessionReenumeration(); + assert(attaches == 1 && attached); + mounted = true; + meshTinyUsbDeviceSessionBoundary(); + settle(); + rx_count = 4; + assert(mesh::isUsbCompanionClientConnected()); + rx_count = 0; baud = 1200; line(false); assert(dfu); diff --git a/test/test_nrf52_usb_lifecycle.py b/test/test_nrf52_usb_lifecycle.py new file mode 100644 index 000000000..b9478a47d --- /dev/null +++ b/test/test_nrf52_usb_lifecycle.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Run actual nRF52 USB lifecycle code with independent FIFO/endpoint state.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +USB = (ROOT / "src/helpers/UsbLogging.cpp").read_text() + + +def function(signature): + start = USB.index(signature) + end = USB.index("{", start) + 1 + depth = 1 + while depth: + depth += (USB[end] == "{") - (USB[end] == "}") + end += 1 + return USB[start:end] + + +class Nrf52UsbLifecycleTest(unittest.TestCase): + def run_native(self, source): + compiler = shutil.which("g++") + if compiler is None: + self.skipTest("C++ compiler unavailable") + with tempfile.TemporaryDirectory() as directory: + cpp = Path(directory) / "lifecycle.cpp" + binary = Path(directory) / "lifecycle" + cpp.write_text(source) + compiled = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", + str(cpp), "-o", str(binary)], + capture_output=True, text=True) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + checked = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + + def test_framework_detach_queues_local_unmount_without_vbus_edge(self): + pinned_driver = (ROOT / "test/fixtures/nrf52_usb_power_original.c").read_text() + start = pinned_driver.index("void dcd_disconnect(") + disconnect = pinned_driver[start:pinned_driver.index("\n}\n", start) + 3] + self.run_native(r''' +#include +#include +static bool mounted = true, vbus = true; +static unsigned owner_events = 0, reset_count = 0; +static constexpr unsigned DCD_EVENT_UNPLUGGED = 0; +struct Registers { unsigned USBPULLUP = 1; } registers; +#define NRF_USBD (®isters) +static void dcd_event_bus_signal(unsigned port, unsigned event, bool isr) { + assert(port == 0 && event == DCD_EVENT_UNPLUGGED && !isr); + ++owner_events; // enqueue, do not automatically fake a VBUS/unmount edge +} +@DISCONNECT@ +// Actual usbd.c wrapper and Adafruit_USBD_Device.h inline entry point. +static bool tud_disconnect() { dcd_disconnect(0); return true; } +struct Device { bool detach() { return tud_disconnect(); } } TinyUSBDevice; +static void usbd_reset(unsigned port) { + assert(port == 0); mounted = false; ++reset_count; +} +static void tud_umount_cb() {} +static void owner_task() { + assert(owner_events == 1); --owner_events; + // Exact DCD_EVENT_UNPLUGGED owner branch from the pinned usbd.c. + usbd_reset(0); + tud_umount_cb(); +} +int main() { + assert(TinyUSBDevice.detach()); + assert(vbus && registers.USBPULLUP == 0 && mounted); + assert(owner_events == 1); // without this, recovery would wait forever + owner_task(); + assert(vbus && !mounted && reset_count == 1); +} +'''.replace("@DISCONNECT@", disconnect)) + + def test_descriptor_mutation_is_detached_even_before_mount(self): + source = r''' +#include +#include +#include +#define MESH_DUAL_CDC_LOGGING 1 +static bool attached = true, mounted = false, descriptor_fetched = false; +static unsigned detach_count = 0, attach_count = 0, descriptor_count = 1; +static unsigned delay_ms = 0; +struct Device { + bool mounted() { return ::mounted; } + void detach() { attached = false; ++detach_count; } + void attach() { + assert(descriptor_count == 2 && delay_ms >= 10); + attached = true; ++attach_count; + // Fresh enumeration replaces a host's old cached one-port descriptor. + descriptor_fetched = false; + } +} TinyUSBDevice; +static void delay(unsigned value) { assert(!attached); delay_ms += value; } +namespace mesh { +static std::atomic usb_logging_preference_known{false}; +static bool enabled = false, dedicated_usb_logging_port_started = false; +static bool dedicated_usb_logging_port_configured = false; +static bool isUsbLoggingEnabled() { return enabled; } +static bool isUsbDebugLoggingEnabled() { return false; } +static void setPlatformDebugOutputEnabled(bool) {} +struct Port { + void begin(unsigned baud) { + assert(baud == 115200 && !attached && delay_ms >= 10); + ++descriptor_count; + } +} dedicated_usb_logging_port; +@BEGIN@ +} +int main() { + // No descriptor or reconnect when the preference is unknown/disabled. + mesh::beginUsbLoggingPort(); + mesh::usb_logging_preference_known = true; + mesh::beginUsbLoggingPort(); + assert(detach_count == 0); + // Host fetched the old descriptor but has not SET_CONFIGURATION yet. + descriptor_fetched = true; + assert(!mounted); + mesh::enabled = true; + mesh::beginUsbLoggingPort(); + assert(detach_count == 1 && attach_count == 1 && attached); + assert(!descriptor_fetched && descriptor_count == 2); + assert(mesh::dedicated_usb_logging_port_configured); + mesh::beginUsbLoggingPort(); + assert(detach_count == 1 && attach_count == 1); // idempotent +} +'''.replace("@BEGIN@", function("void beginUsbLoggingPort()")) + self.run_native(source) + + def test_armed_logging_packet_and_racing_primary_packet_force_clean_bus(self): + start = USB.index("static std::atomic primary_usb_reset_generation") + end_start = USB.index("static void endPrimaryUsbHostSession(", start) + gates = USB[start:end_start] + function("static void endPrimaryUsbHostSession(") + source = r''' +#include +#include +#include +#define ENABLE_USB_INTERFACE 1 +static bool mounted = true, attached = true, armed[2] = {false, true}; +static uint32_t fifo[2] = {0, 32}, now = UINT32_MAX - 10; +static unsigned detach_count = 0, attach_count = 0, reset_count = 0; +static uint32_t millis() { return now; } +static bool tud_mounted() { return mounted; } +static uint32_t tud_cdc_n_available(uint8_t) { return 0; } +static void tud_cdc_n_read_flush(uint8_t) {} +static bool tud_cdc_n_write_clear(uint8_t n) { fifo[n] = 0; return true; } +static bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed[n]; } +struct Device { + void detach() { ++detach_count; attached = false; } + void attach() { ++attach_count; attached = true; } +} TinyUSBDevice; +namespace mesh { +static void clearUsbLoggingClientActivity() {} +@GATES@ +static std::atomic dedicated_usb_logging_port_connected{true}; +static std::atomic dedicated_usb_logging_reset_generation{0}; +static uint8_t dedicated_usb_logging_quiet_sofs = 0; +static constexpr uint8_t dedicated_usb_logging_host_settle_sofs = 50; +struct StatsParser { void reset() {} } dedicated_usb_logging_stats_parser; +static uint32_t primary_usb_terminal_taken_reset_generation = 0; +static void resetDedicatedUsbLoggingUsbTaskState() { ++reset_count; } +@RESTART@ +@COMPLETE@ +} +int main() { + // CDC1 old data is in endpoint RAM, not the software FIFO. No 50-SOF + // waiting/clearing policy can retract it without re-enumeration. + mesh::restartDedicatedUsbLoggingHostSession(); + assert(fifo[1] == 0 && armed[1] && !attached && detach_count == 1); + assert(!mesh::dedicated_usb_logging_port_connected && reset_count == 1); + assert(!mesh::canAccessPrimaryUsbSession(nullptr)); // both ports gated + mesh::restartDedicatedUsbLoggingHostSession(); + assert(detach_count == 1); // repeated control requests do not reset timer + now += 19; mounted = false; armed[1] = false; + mesh::serviceNrf52UsbSessionReenumeration(); + assert(attach_count == 0); // wrap-safe bounded detach interval + ++now; + mesh::serviceNrf52UsbSessionReenumeration(); + assert(attach_count == 1 && attached); + mesh::serviceNrf52UsbSessionReenumeration(); + assert(attach_count == 1); + // The final exclusive purge also catches an old producer that submitted + // a primary IN packet after the owner callback's first FIFO clear. + mounted = true; armed[0] = true; fifo[0] = 16; + mesh::primary_usb_terminal_taken_reset_generation = + mesh::primaryUsbSessionGeneration(); + mesh::completePrimaryUsbSessionReset(nullptr); + assert(fifo[0] == 0 && armed[0] && !attached && detach_count == 2); + assert(!mesh::canAccessPrimaryUsbSession(nullptr)); +} +''' + source = source.replace("@GATES@", gates) + source = source.replace("@RESTART@", function("static void restartDedicatedUsbLoggingHostSession()")) + source = source.replace("@COMPLETE@", function("static void completePrimaryUsbSessionReset(")) + self.run_native(source) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_nrf52_usb_logging_contract.py b/test/test_nrf52_usb_logging_contract.py index 8f745eb09..4170b8b09 100644 --- a/test/test_nrf52_usb_logging_contract.py +++ b/test/test_nrf52_usb_logging_contract.py @@ -255,7 +255,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase): ):] self.assertIn("if (instance == 1)", bridge) self.assertIn("handleDedicatedUsbLoggingLineState(dtr);", bridge) - self.assertIn("tud_cdc_n_write_clear(1);", source) + self.assertIn("clearNrf52UsbTxForSession(1);", source) self.assertIn("endPrimaryUsbHostSession(true);", bridge) self.assertIn("if (instance == 0 && !dtr)", bridge) self.assertIn("tud_cdc_get_line_coding(&coding);", bridge) @@ -306,7 +306,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase): ) self.assertLess( session_end.index("tud_cdc_n_read_flush(0)"), - session_end.index("tud_cdc_n_write_clear(0)"), + session_end.index("clearNrf52UsbTxForSession(0)"), ) self.assertIn( "dedicated_usb_logging_line_state_dtr.exchange(", source @@ -332,7 +332,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase): restart_gate = restart.index( "dedicated_usb_logging_port_connected.store(false" ) - restart_fifo = restart.index("tud_cdc_n_write_clear(1)") + restart_fifo = restart.index("clearNrf52UsbTxForSession(1)") restart_state = restart.index("resetDedicatedUsbLoggingUsbTaskState()") restart_quiet = restart.index("dedicated_usb_logging_quiet_sofs =") restart_generation = restart.index( @@ -368,7 +368,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase): self.assertLess(marker_write, queued_drain) quiet_block = owner_service[quiet_start:positive_gate] - quiet_fifo = quiet_block.index("tud_cdc_n_write_clear(1)") + quiet_fifo = quiet_block.index("clearNrf52UsbTxForSession(1)") quiet_state = quiet_block.index( "resetDedicatedUsbLoggingUsbTaskState()" ) @@ -595,7 +595,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase): # A second purge after the settle gate would erase a new host's # immediately sent APP_START (meshcli does not delay after open). self.assertNotIn("tud_cdc_n_read_flush(0);", completion) - self.assertIn("tud_cdc_n_write_clear(0);", completion) + self.assertIn("clearNrf52UsbTxForSession(0);", completion) self.assertIn("primary_usb_allowed_generation.store(", completion) self.assertIn(".tryRunExclusive(", source) self.assertIn("primary_usb_reset_settle_until.store(", source) diff --git a/test/test_nrf52_usb_power.py b/test/test_nrf52_usb_power.py index 4ebfd0160..d577c1fda 100644 --- a/test/test_nrf52_usb_power.py +++ b/test/test_nrf52_usb_power.py @@ -46,6 +46,116 @@ int main() { class UsbPowerTests(unittest.TestCase): + def test_cdc_query_tracks_armed_endpoint_not_fifo_bytes(self): + original = r''' +#include +#include +#include +#include "cdc_device.h" +#define CFG_TUD_CDC 2 +struct { uint8_t ep_in; unsigned tx_ff; } _cdcd_itf[2]; +static bool armed[256]; +static bool usbd_edpt_busy(uint8_t port, uint8_t endpoint) { + assert(port == 0); return armed[endpoint]; +} +static bool tu_fifo_clear(unsigned* fifo) { *fifo = 0; return true; } +'''+ FIX.CDC_WRITE_CLEAR + r''' +int main(void) { + _cdcd_itf[0].ep_in = 0x81; + _cdcd_itf[1].ep_in = 0x83; + _cdcd_itf[0].tx_ff = 16; + armed[0x81] = true; + assert(mesh_tud_cdc_n_tx_pending(0)); + tud_cdc_n_write_clear(0); + assert(_cdcd_itf[0].tx_ff == 0 && mesh_tud_cdc_n_tx_pending(0)); + assert(!mesh_tud_cdc_n_tx_pending(1)); + assert(!mesh_tud_cdc_n_tx_pending(2)); + armed[0x81] = false; + assert(!mesh_tud_cdc_n_tx_pending(0)); + _cdcd_itf[1].ep_in = 0; + armed[0] = true; // unopened CDC must not query control endpoint + assert(!mesh_tud_cdc_n_tx_pending(1)); +} +''' + patched = FIX.patched_cdc_source(original) + self.assertEqual(patched, FIX.patched_cdc_source(patched)) + self.assertEqual(patched, FIX.patched_cdc_source(original.replace("\n", "\r\n"))) + compiler = shutil.which("gcc") + if compiler is None: + self.skipTest("C compiler unavailable") + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "cdc.c" + binary = Path(directory) / "cdc" + header = Path(directory) / "class/cdc/cdc_device.h" + header.parent.mkdir(parents=True) + header.write_text("// Only the isolated session-query API is tested here.\n") + source.write_text(patched) + subprocess.run([compiler, "-std=c11", "-Wall", "-Wextra", "-Werror", + "-I", directory, str(source), "-o", str(binary)], + check=True, capture_output=True) + subprocess.run([str(binary)], check=True, capture_output=True) + + def test_relocated_cdc_driver_compiles_against_real_tinyusb_headers(self): + # Exercise the middleware's actual relocated file, not just an API + # extraction: cdc_device.h is originally a sibling of cdc_device.c. + # CI without this hardware framework can still run the isolated API + # and relocation guards; a local hardware build runs this full check. + sdk = (Path.home() / ".platformio/packages/framework-arduinoadafruitnrf52/" + "libraries/Adafruit_TinyUSB_Arduino/src") + original = sdk / "class/cdc/cdc_device.c" + compiler = shutil.which("gcc") + if compiler is None or not original.is_file(): + self.skipTest("native compiler/nRF52 TinyUSB framework unavailable") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config = root / "tusb_config.h" + config.write_text(""" +#define CFG_TUSB_MCU OPT_MCU_NONE +#define CFG_TUSB_OS OPT_OS_NONE +#define CFG_TUSB_RHPORT0_MODE OPT_MODE_DEVICE +#define CFG_TUD_ENABLED 1 +#define CFG_TUD_CDC 2 +#define CFG_TUD_CDC_RX_BUFSIZE 256 +#define CFG_TUD_CDC_TX_BUFSIZE 256 +#define CFG_TUSB_DEBUG 0 +""") + class Node: + def srcnode(self): return self + def get_abspath(self): return str(original) + class Env: + def subst(self, value): + assert value == "$BUILD_DIR" + return str(root / "build") + def File(self, value): return Path(value) + patched = FIX.replace_driver(Env(), Node()) + self.assertNotEqual(patched.parent, original.parent) + self.assertIn(FIX.CDC_ROOT_INCLUDE, patched.read_text()) + result = subprocess.run( + [compiler, "-std=c11", '-DCFG_TUSB_CONFIG_FILE="' + config.as_posix() + '"', + "-I", str(root), "-I", str(sdk), + "-c", str(patched), "-o", str(root / "cdc.o")], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_changed_cdc_driver_fails_closed(self): + prefix = FIX.CDC_LOCAL_INCLUDE + "\n" + for source in ("", prefix + FIX.CDC_WRITE_CLEAR.replace("tx_ff", "tx_fifo"), + prefix + FIX.CDC_WRITE_CLEAR * 2, + prefix + FIX.CDC_WRITE_CLEAR + FIX.CDC_SESSION_QUERY, + FIX.CDC_ROOT_INCLUDE + "\n" + FIX.CDC_WRITE_CLEAR): + with self.subTest(source=source), self.assertRaises(RuntimeError): + FIX.patched_cdc_source(source) + + def test_cdc_query_is_built_from_local_copy(self): + hooks = [] + class Env: + def AddBuildMiddleware(self, handler, pattern): + hooks.append((handler, pattern)) + FIX.install(Env()) + self.assertEqual(len(hooks), 3) + self.assertTrue(any("class*cdc*cdc_device.c" in pattern + for _, pattern in hooks)) + def test_actual_handler_and_inherited_hang(self): exercise(FIX.patched_source(ORIGINAL)) @@ -76,7 +186,9 @@ class UsbPowerTests(unittest.TestCase): def test_unrecognized_or_partly_fixed_driver_fails_closed(self): for source in ("", ORIGINAL.replace("hfclk_running()", "clock_running()"), - ORIGINAL + ORIGINAL, ORIGINAL.replace(FIX.OLD_READY, FIX.READY)): + ORIGINAL + ORIGINAL, ORIGINAL.replace(FIX.OLD_READY, FIX.READY), + ORIGINAL.replace("dcd_event_bus_signal(0, DCD_EVENT_UNPLUGGED, false);", + "// removed local disconnect event")): with self.subTest(source=source[:40]), self.assertRaises(RuntimeError): FIX.patched_source(source) diff --git a/test/test_serial_mode_switch/test_serial_mode_switch.cpp b/test/test_serial_mode_switch/test_serial_mode_switch.cpp index 2ef0beb35..1b4a7eb6d 100644 --- a/test/test_serial_mode_switch/test_serial_mode_switch.cpp +++ b/test/test_serial_mode_switch/test_serial_mode_switch.cpp @@ -858,9 +858,15 @@ TEST(SerialModeSwitch, SingleTtyLoggingCannotStealNetworkTerminal) { using Action = mesh::UsbLoggingTerminalAction; EXPECT_EQ(mesh::selectUsbLoggingTerminalAction( false, true, false, false, true, true), - Action::NO_ACTION); + Action::PARK_USB_FOR_NETWORK); EXPECT_EQ(mesh::selectUsbLoggingTerminalAction( false, true, false, false, false, true), + Action::PARK_USB_FOR_NETWORK); + EXPECT_EQ(mesh::selectUsbLoggingTerminalAction( + true, true, false, false, true, true), + Action::NO_ACTION); + EXPECT_EQ(mesh::selectUsbLoggingTerminalAction( + false, false, false, false, true, true), Action::NO_ACTION); } diff --git a/test/test_usb_diagnostic_logging.py b/test/test_usb_diagnostic_logging.py new file mode 100644 index 000000000..6766dd411 --- /dev/null +++ b/test/test_usb_diagnostic_logging.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Exercise Ethernet diagnostics and the production NetworkLink logging macro.""" + +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/usb_diagnostic_logging" + + +class UsbDiagnosticLoggingTests(unittest.TestCase): + def test_runtime_gate_backpressure_and_functional_ethernet_output(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + network = (ROOT / "src/helpers/NetworkLink.cpp").read_text(encoding="utf-8") + start = network.index("// Same \"MQTT: \" prefix") + macro = network[start:network.index("namespace {", start)] + with tempfile.TemporaryDirectory(prefix="meshcore-usb-diagnostics-") as directory: + temporary = Path(directory) + (temporary / "NetworkLoggingMacro.h").write_text(macro, encoding="utf-8") + for debug in (None, 0, 1): + with self.subTest(mqtt_debug=debug): + binary = temporary / "usb-diagnostics.exe" + defines = [] if debug is None else [f"-DMQTT_DEBUG={debug}"] + compiled = subprocess.run( + [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-DARDUINO=1", "-DETHERNET_ENABLED=1", *defines, + f"-I{FIXTURE / 'mocks'}", f"-I{ROOT / 'src'}", + f"-I{temporary}", str(FIXTURE / "test.cpp"), + "-o", str(binary)], + capture_output=True, text=True, timeout=60, + ) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + checked = subprocess.run( + [str(binary)], capture_output=True, text=True, timeout=10, + ) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn("PASS: gated USB diagnostics", checked.stdout) + + def test_no_raw_serial_diagnostic_writes(self): + for relative in ("src/helpers/nrf52/EthernetCLI.h", "src/helpers/NetworkLink.cpp"): + with self.subTest(source=relative): + source = (ROOT / relative).read_text(encoding="utf-8") + self.assertIn("UsbLogging.h", source) + self.assertIn("mesh::isUsbDebugLoggingEnabled()", source) + self.assertIn("mesh::usbLoggingPort()", source) + self.assertIsNone(re.search(r"\bSerial\.(?:print|println|printf|write)\s*\(", source)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_usb_logging_watchdog_roles.py b/test/test_usb_logging_watchdog_roles.py index 8d8a5451e..70d2c3342 100644 --- a/test/test_usb_logging_watchdog_roles.py +++ b/test/test_usb_logging_watchdog_roles.py @@ -35,9 +35,10 @@ struct Store { bool hasPendingContactWrites() const{return dirty;} }; struct SerialInterface { - bool pending=false, connected=false; + bool pending=false, connected=false, passthrough=true; bool hasPendingIO() const{return pending;} bool isConnected() const{return connected;} + bool isPassthroughMode() const{return passthrough;} }; struct RoleMesh { uint32_t dirty_contacts_expiry=0, _ota_update_at=0, set_radio_at=0, @@ -212,7 +213,7 @@ class UsbLoggingWatchdogRoleTests(unittest.TestCase): // even if the dedicated logging CDC is disconnected or its output is stuck. for(unsigned state=0;state<3;++state){ reset();mesh::status.reader_connected=state!=1;mesh::status.stalled=state==2; - the_mesh.terminal=false;usb_connected=true; + the_mesh.terminal=false;usb_serial_interface.passthrough=false;usb_connected=true; assert(!usbLoggingRecoverySafe(nullptr)); usb_connected=false;assert(usbLoggingRecoverySafe(nullptr)); usb_serial_interface.pending=true;assert(!usbLoggingRecoverySafe(nullptr)); @@ -222,6 +223,15 @@ class UsbLoggingWatchdogRoleTests(unittest.TestCase): usb_mota_mode=false; #endif } + // A network CLI owns the role without owning a log-only USB transport. + // The new network parking state must not make a stalled logger look like a + // live Binary Companion, including a host which keeps DTR asserted. + for(unsigned state=0;state<3;++state){ + reset();the_mesh.terminal=false;usb_connected=true; + mesh::status.reader_connected=state!=1;mesh::status.stalled=state==2; + assert(usb_serial_interface.isPassthroughMode()); + assert(usbLoggingRecoverySafe(nullptr)); + } reset();usb_terminal_line_len=1;assert(!usbLoggingRecoverySafe(nullptr)); mesh::status.reader_connected=false;assert(usbLoggingRecoverySafe(nullptr)); mesh::status.reader_connected=true;mesh::status.stalled=true;