diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index a50d36510..95301b1e8 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -105,6 +105,7 @@ jobs: - name: Verify Companion ASCII defaults and USB session cleanup run: | python3 -B test/test_companion_usb_default.py + python3 -B test/test_nrf52_usb_client.py python3 -B test/test_esp32_usb_serial_hygiene.py python3 -B test/test_stm32_float_conversion.py diff --git a/docs/companion_usb_client_validation.md b/docs/companion_usb_client_validation.md new file mode 100644 index 000000000..d6035e987 --- /dev/null +++ b/docs/companion_usb_client_validation.md @@ -0,0 +1,117 @@ +# USB Companion client compatibility + +The RAK4631 report was reproduced on the MercerWoodMesh Pi with the published +`RAK_4631_companion_radio_full-v1.17.1.6-halo-keymind-cascade-dev-d4a641ff` +image. The fixes described here are in the source and test firmware; +the existing GitHub release assets have not yet been replaced with this fix. +Hardware validation was completed on 2026-09-14 with test builds labeled +`v1.17.1.6-usb-client-test-0e5955f8` (base commit plus these USB fixes). + +## Port selection + +| Hardware | Companion/ASCII port | USB logging | +| --- | --- | --- | +| RAK4631 and other nRF52 Full Companions | Primary CDC, Linux `*-if00` | Optional second CDC, `*-if02` | +| ESP32 Full Companions | Primary USB serial port | Shares that port; must be off for Binary Companion | + +`+++MESHCORE-TERM-STOP` changes the primary port to Binary Companion. Its +`OK - Binary mode` reply does not move the client to a second port. Close the +terminal before opening that same port with `meshcli -s PORT infos`. Do not use +MeshCLI's repeater-mode `-r` option for a binary Companion connection. + +## Two reproduced failures + +1. Stock `meshcore-cli` 1.6.3 was tested with `meshcore` 2.3.10, whose serial client + deliberately deasserts DTR. The nRF52 primary USB gate and response route + both required DTR. A raw battery query returned a valid 14-byte response + with DTR high and no bytes with DTR low. +2. The automatic ASCII banner ended in `> `. MeshCLI recognized that `>` as a + binary frame marker and consumed the real response's leading `>` as a + length byte. Even forcing DTR high in a diagnostic client did not recover + the first `APP_START` response. Stock MeshCLI exited without node information. + +The nRF52 transport now accepts real USB input as evidence of a client, tagged +with the current session generation. Close, line-coding, and device-reset +events invalidate that evidence; an old reader racing cleanup cannot grant +access to a newer session. The write path remains bounded and does not wait +for an unread host. CDC1 logging retains its own DTR requirement. + +The default ASCII terminal now suppresses its banner and unsolicited terminal +events until ASCII input arrives. Enter or a command reveals the terminal. +A binary probe receives a clean first response. Explicit START/STOP remains +available, and an incomplete binary probe still times out back to ASCII. + +On a shared logging port, STOP now reports +`ERROR: set usb.logging off before Binary mode` while logging is enabled. +It no longer acknowledges Binary mode and immediately reclaims the port for +ASCII logging. + +## Hardware validation + +The RAK4631 Full test firmware passed with an unmodified installed MeshCLI and +its normal DTR-low serial library: + +- ASCII `ver` from the default mode, without START; +- explicit STOP acknowledgement followed by 10 binary battery queries; +- 10 immediate close/reopen queries each with DTR low and high; +- 10 stock-library reconnects and three separate `meshcli ... infos` runs; +- all of the above while the second CDC streamed logs: 598 bytes received, + no logging disconnections. + +The Heltec V4 Full Companion also passed using its ESP32-S3 USB Serial/JTAG +interface, with USB logging disabled: + +- default ASCII `ver`, then STOP and 10 clean binary battery replies; +- 10 stock-library reconnects and three unmodified `meshcli ... infos` runs; +- a stock-client connection directly after reboot, without either mode token. + +The shared-port logging policy was exercised on the V4 as well. With logging +on, STOP returned the explicit error, a binary probe could not claim the port, +and stock MeshCLI could not connect. After `set usb.logging off`, STOP worked, +10 binary replies were clean, and three stock-client reconnects succeeded. + +After testing, the V4's complete original flash snapshot was restored and +verified by the flasher. Its original `v1.17.1-soak-v4-B` firmware answered +again, and the previously active memory-soak collector was restarted. This +test interrupted that board's uptime; the restored firmware starts a new run. +Fresh successful V4 telemetry was verified at `2026-09-14T19:46:01Z`. +The RAK4631 remains on the patched Full Companion test build with its separate +logging interface enabled. The Pi's previously active services were restored. + +The nRF52 DTR stress sequence is deliberately not used on ESP32. Changing those +control lines during immediate close/reopen triggered the V4's hardware +bootloader and produced an `ESP` boot message. Preselecting RTS before opening +did not prevent that transition on this host. ESP32 reconnect results above +use the unmodified client's normal connection sequence instead. + +The test issues local information queries only. It does not transmit test chat +messages over LoRa. Firmware installation and logging configuration are +separate from the reusable test runner: + +```sh +python3 tools/hil/companion_usb_client.py \ + --port /dev/serial/by-id/PRIMARY-if00 \ + --logging-port /dev/serial/by-id/LOGGING-if02 \ + --test-dtr --cycles 10 --output usb-client-results.json +``` + +Replace the example paths with the device's actual stable paths. Install +`meshcore-cli` in the Python environment used for the test. Start after a fresh +reboot so the default-ASCII check has a new session. On ESP32, omit both +`--logging-port` and `--test-dtr`, and disable USB logging first. + +## Automated checks + +- 53 Python USB checks pass, including compiled execution of the real nRF52 + callbacks and transport gate with DTR-low clients, cleanup races, early + input, line-coding changes, physical disconnects, full TX FIFO, CDC1 isolation, + and the 1200-baud bootloader entry. +- The real terminal methods are compiled for ordinary and Full modes to verify + that unsolicited text is suppressed before the first binary response. +- The actual STOP branch is exercised for logging off, shared-port logging on, + and dedicated-port logging on. +- 63 native serial-switching, flow-control, and terminal-session tests pass. +- RAK4631 Full and Heltec V4 Full firmware compile and pass their packaged + capability checks. + +The USB workflow runs the new compiled regression tests on subsequent changes. diff --git a/docs/full_companion_usb_switcher.md b/docs/full_companion_usb_switcher.md index 9be879dc5..f036155cf 100644 --- a/docs/full_companion_usb_switcher.md +++ b/docs/full_companion_usb_switcher.md @@ -11,8 +11,14 @@ to ASCII after a USB session reset that the hardware can observe. A Binary Companion client does not need to send a special mode command: its first valid frame automatically hands the interface to the binary parser. -This behavior applies to Full and ordinary USB Companion builds in the updated -1.17.1.6 release. The explicit `+++MESHCORE-TERM-START` and +Current source keeps the default terminal quiet until the host sends ASCII +input. Press Enter or type a command to see the banner. This prevents the ASCII +`>` prompt from being mistaken for the beginning of an app's first binary +response. See [USB client compatibility validation](companion_usb_client_validation.md) +for the tested firmware and release status. + +These mode controls apply to Full and ordinary USB Companion builds. The +explicit `+++MESHCORE-TERM-START` and `+++MESHCORE-TERM-STOP` controls remain available, including for older firmware. See the [Terminal Chat CLI guide](./terminal_chat_cli.md). @@ -151,6 +157,11 @@ mOTA traffic on the same stream. | nRF52 USB serial mOTA active | mOTA owns primary USB; ASCII and Binary Companion are unavailable there | | BLE/WiFi/Ethernet/hardware serial | Always Binary Companion and unaffected by the USB mode | +On RAK4631 and other nRF52 Full Companions, ASCII and Binary Companion both use +the primary interface (`*-if00` on Linux). The optional `*-if02` interface is +for logging. `OK - Binary mode` acknowledges a mode change on the same primary +port; it does not move the connection to the logging port. + On every ESP32 Full Companion, use `set usb.logging off` in its logging terminal before trying to use USB with an app. That command stops logging and returns to the ordinary ASCII terminal; it does not select Binary Companion. The app's @@ -162,6 +173,10 @@ base where supported; RC32 and ESP32-C6 keep their board-required Arduino 3.x platform but follow the same one-TTY policy. No ESP32 Full build enumerates a second CDC interface. +With logging enabled on a shared port, the stop token is rejected with +`ERROR: set usb.logging off before Binary mode`. Turn logging off first; a +successful binary-mode acknowledgement must not immediately revert to logging. + On nRF52, serial `motatool` is also text-first. Its exact initial `ota folder on` line is recognized in either startup ASCII or Binary Companion mode. From ASCII, the firmware leaves terminal mode and directly enters @@ -186,6 +201,12 @@ mode for that session. Native USB with DTR/session events restores ASCII when the host closes or resets the connection. Old input, queued output and mOTA ownership are cleared before selecting the default for the next session. +Some clients, including `meshcore` 2.3.10 used by stock MeshCLI, deliberately +deassert DTR. The nRF52 primary interface also accepts actual incoming USB data +as evidence of a client in the current session. Closing, changing line coding, +or resetting USB invalidates that evidence. The dedicated logging interface +continues to require its own connected reader. + ESP32 hardware USB Serial/JTAG reports bus reset and physical host loss, but cannot reliably report a terminal program closing its handle. A USB-to-UART bridge also usually cannot report that close. On those ports, use the terminal @@ -216,19 +237,14 @@ unknown or malformed at the application layer. This is safe for stream separation but means a random complete frame can leave the device in binary mode until an observable USB session reset or a manual switch back. -### The terminal banner is best effort +### The default terminal waits for input -The firmware enters ASCII mode during boot, often before a host opens the CDC -device. The banner may therefore be absent even though the terminal is ready; -send a newline or a harmless `get` command rather than treating a missing -banner as proof of binary mode. - -A host that remains connected across a reboot may receive ASCII banner and -prompt bytes before the first binary response. Binary clients should discard -leading bytes until a plausible `>` frame marker and length are found, reject -implausible lengths, and resynchronize. The ordinary open-after-boot path has -been tested with `meshcli`, but third-party clients that assume byte zero is -always `>` may fail. +The firmware enters ASCII mode during boot but suppresses the banner and +unsolicited terminal events until ASCII input identifies a terminal client. +Send Enter or a harmless `get` command to display the banner. A binary client +instead gets a clean first response. Older builds that immediately printed +`> ` could consume the first binary response's marker as part of a bogus frame +length; scanning past leading text alone did not reliably recover that reply. ### Text and binary output cannot be interleaved diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index 196e79e8c..9c6d3e252 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -91,6 +91,10 @@ Companion. You can open a terminal without a start token: picocom --baud 115200 /dev/ttyACM0 ``` +Current source waits for input before printing the USB banner. Press Enter or +type a command. This preserves the clean first response needed by binary apps; +see [USB client validation](companion_usb_client_validation.md) for release status. + For older firmware or a port already in Binary mode, use: ```sh @@ -125,6 +129,10 @@ Send the following exact sequence to return to the binary protocol: +++MESHCORE-TERM-STOP ``` +If logging shares the primary USB port (ESP32), first run `set usb.logging off`. +With logging still on, the stop token reports an error and keeps ASCII logging +active. nRF52 Full's separate logging port can remain enabled. + An observable USB session reset restores ASCII after clearing the previous client's state. Native USB with DTR can detect a terminal closing; ESP32 hardware USB Serial/JTAG detects bus resets and physical host loss. USB-to-UART bridges diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f65225879..bfbc8a405 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -6850,6 +6850,7 @@ void MyMesh::appendRxPowerSavingAdjustmentNote(char* reply, size_t reply_size, #if COMPANION_FEATURE_TEXT_TERMINAL Stream& MyMesh::terminalOutput() { + if (isTerminalWaitingForInput()) return mesh::usbTerminalPort(false); return _terminal_output != NULL ? *_terminal_output : Serial; } @@ -6887,15 +6888,17 @@ void MyMesh::printTerminalBanner(bool show_binary_stop) { output.print("\r\n> "); } -void MyMesh::enterTerminalMode() { +void MyMesh::enterTerminalMode(bool show_banner) { _terminal_mode = true; + _terminal_usb_silent = !show_banner; _terminal_output = &mesh::usbTerminalPort(); resetTerminalSession(); - printTerminalBanner(true); + if (show_banner) printTerminalBanner(true); } void MyMesh::exitTerminalMode() { _terminal_mode = false; + _terminal_usb_silent = false; resetTerminalSession(); if (_terminal_output == &mesh::usbTerminalPort()) _terminal_output = NULL; } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index efdcd1557..10f4fb01b 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -213,12 +213,15 @@ public: void enterCLIRescue(); #if COMPANION_FEATURE_TEXT_TERMINAL - void enterTerminalMode(); + void enterTerminalMode(bool show_banner = true); void exitTerminalMode(); // Clear state owned by the current text-terminal host without changing the // protocol owner or printing a new banner. void resetTerminalSession(); bool isTerminalMode() const { return _terminal_mode; } + bool isTerminalWaitingForInput() const { + return _terminal_mode && _terminal_usb_silent; + } void handleTerminalCommand(char* command); #if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG) bool enterNetworkTerminalMode(Stream& output); @@ -525,6 +528,7 @@ private: bool _cli_rescue; #if COMPANION_FEATURE_TEXT_TERMINAL bool _terminal_mode; + bool _terminal_usb_silent = false; Stream* _terminal_output; mesh::TerminalDisplayFilter _terminal_display; bool _terminal_recipient_set; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 99e19bc25..0cde0622f 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -528,7 +528,9 @@ static void redrawUsbTerminalInput() { } static bool isUsbTerminalDataConnected() { -#if defined(RP2040_PLATFORM) +#if defined(NRF52_PLATFORM) + return mesh::isUsbCompanionClientConnected(); +#elif defined(RP2040_PLATFORM) return (bool)Serial; #else return board.isUsbDataConnected(); @@ -595,7 +597,7 @@ static void cancelUsbSerialOperations() { interface_manager.forgetReplyRouteForDisconnected(&usb_serial_interface); } -static void enterUsbTerminalMode() { +static void enterUsbTerminalMode(bool show_banner = true) { usb_binary_startup_probe.cancel(); usb_ascii_session_default.cancel(); cancelUsbSerialOperations(); @@ -604,7 +606,7 @@ static void enterUsbTerminalMode() { clearUsbTerminalLine(); usb_terminal_discard_line = false; usb_logging_terminal_mode = false; - the_mesh.enterTerminalMode(); + the_mesh.enterTerminalMode(show_banner); } static void enterUsbLoggingTerminalMode() { @@ -832,7 +834,10 @@ static void serviceUsbAsciiSessionDefault() { false, #endif usb_serial_interface.getCompletedFrameCount())) { - enterUsbTerminalMode(); + // A prompt contains '>', the Binary Companion frame marker. Do not send + // text to an unknown host: the first ASCII input reveals the terminal, + // while a framed app command switches without poisoning its first reply. + enterUsbTerminalMode(false); } } @@ -933,6 +938,9 @@ static void serviceUsbTerminal() { } Stream& usb_input = mesh::usbCompanionPort(); + if (the_mesh.isTerminalWaitingForInput() && usb_input.available() > 0) { + enterUsbTerminalMode(); + } while (usb_input.available()) { int value = usb_input.read(); if (value < 0) break; @@ -998,6 +1006,14 @@ static void serviceUsbTerminal() { mesh::cli::shouldMaskTerminalInput(usb_terminal_line) ? '*' : c); if (strcmp(usb_terminal_line, USB_TERMINAL_STOP_TOKEN) == 0) { +#if MESH_USB_LOGGING_AVAILABLE + if (!mesh::hasDedicatedUsbLoggingPort() && mesh::isUsbLoggingEnabled()) { + clearUsbTerminalLine(); + usbTerminalOutput().print( + "\r\nERROR: set usb.logging off before Binary mode\r\n> "); + return; + } +#endif leaveUsbTerminalMode(true); return; } @@ -2584,8 +2600,12 @@ void setup() { USB_HOST_LOSS_GRACE_MS, the_mesh.hasFiniteDelayedReplyForRoute(&usb_serial_interface)); }); +#elif defined(NRF52_PLATFORM) + usb_serial_interface.setConnectedCheck([]() { + return mesh::isUsbCompanionClientConnected(); + }); #elif (defined(ESP32) && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT) \ - || defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) + || defined(RP2040_PLATFORM) // native USB-CDC (TinyUSB): (bool)Serial reflects DTR, ie. the host really // has the port open. A classic ESP32 behind a UART bridge has no such // signal and keeps the assume-connected default. diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index 9c1674e16..1d511adb8 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -425,13 +425,16 @@ static void serviceEsp32HwcdcTxKick() { #if MESH_NRF52_PRIMARY_USB_NONBLOCKING #if defined(ENABLE_USB_INTERFACE) -// Writers/readers may access CDC0 only when DTR is high and the application has -// completed the current close epoch. Incrementing reset_generation therefore +// DTR or actual host input proves a CDC0 client. Stock MeshCLI deliberately +// deasserts DTR, so it cannot be a prerequisite for receiving its first frame. +// Access still requires the application to complete the current close epoch. +// Incrementing reset_generation therefore // closes the transport atomically; an older completion can publish only its // older epoch and can never reopen across a newer close. static std::atomic primary_usb_reset_generation{0}; static std::atomic primary_usb_allowed_generation{0}; 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}; @@ -440,15 +443,30 @@ static uint32_t primaryUsbSessionGeneration() { } static bool canAccessPrimaryUsbSession(void*) { - return primary_usb_line_state_dtr.load(std::memory_order_acquire) - && primary_usb_allowed_generation.load(std::memory_order_acquire) - == primary_usb_reset_generation.load(std::memory_order_acquire); + const uint32_t generation = primaryUsbSessionGeneration(); + if (!tud_mounted() + || primary_usb_allowed_generation.load(std::memory_order_acquire) + != generation) return false; + + if (!primary_usb_line_state_dtr.load(std::memory_order_acquire) + && primary_usb_rx_generation.load(std::memory_order_acquire) + != generation) { + if (tud_cdc_n_available(0) == 0) return false; + // Tag the proof with the epoch sampled before looking at RX. A callback + // racing this store can invalidate it without an old reader reopening the + // next host's session after cleanup. + primary_usb_rx_generation.store(generation, std::memory_order_release); + } + return primaryUsbSessionGeneration() == generation && tud_mounted(); } static void endPrimaryUsbHostSession(bool clear_cdc_fifos) { const bool previous = primary_usb_line_state_dtr.exchange( false, std::memory_order_acq_rel); - if (!previous) return; + const bool received = primary_usb_rx_generation.load(std::memory_order_acquire) + == primaryUsbSessionGeneration(); + if (!previous && !received + && !(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 @@ -480,11 +498,13 @@ static size_t writeTinyUsbCdcOnce(void* context, const uint8_t* data, return 0; } #if defined(ENABLE_USB_INTERFACE) - if (instance == 0 && !canAccessPrimaryUsbSession(nullptr)) { - return 0; - } + if (instance == 0) { + if (!canAccessPrimaryUsbSession(nullptr)) return 0; + } else #endif - if (!tud_cdc_n_connected(instance)) return 0; + { + if (!tud_cdc_n_connected(instance)) return 0; + } const size_t available = tud_cdc_n_write_available(instance); const size_t attempt = size < available ? size : available; @@ -1055,6 +1075,12 @@ Stream& usbCompanionPort() { #endif } +#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE) +bool isUsbCompanionClientConnected() { + return canAccessPrimaryUsbSession(nullptr); +} +#endif + Stream& usbMotaPort() { #if MESH_ESP32_TINYUSB_NONBLOCKING return nonblocking_esp32_tinyusb_mota_port; @@ -1067,7 +1093,8 @@ Stream& usbMotaPort() { #endif } -Stream& usbTerminalPort() { +Stream& usbTerminalPort(bool enabled) { + if (!enabled) return null_usb_logging_stream; #if MESH_ESP32_TINYUSB_NONBLOCKING return buffered_esp32_tinyusb_terminal_port; #elif defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE) @@ -1282,6 +1309,8 @@ extern "C" void meshTinyUsbCdcLineStateChanged(uint8_t instance, bool dtr, const bool was_open = mesh::primary_usb_line_state_dtr.load( std::memory_order_acquire); if (!was_open) { + // DTR rising can also reopen a prior DTR-low client's handle. + mesh::endPrimaryUsbHostSession(true); // Purge any late bytes from the closed owner before publishing the fresh // session. New-host input sent after SET_CONTROL_LINE_STATE completes is // then retained while the application-side generation gate settles. @@ -1312,11 +1341,9 @@ extern "C" void meshTinyUsbCdcLineCodingChanged(uint8_t instance) { // boundary. Restore the physical DTR sample after closing the epoch so an // intentional baud change on a continuously open handle recovers as soon // as the application has reset its parsers. - if (mesh::primary_usb_line_state_dtr.load(std::memory_order_acquire)) { - mesh::endPrimaryUsbHostSession(true); - mesh::primary_usb_line_state_dtr.store( - tud_cdc_n_connected(0), std::memory_order_release); - } + mesh::endPrimaryUsbHostSession(true); + mesh::primary_usb_line_state_dtr.store( + tud_cdc_n_connected(0), std::memory_order_release); return; } #if defined(MESH_DUAL_CDC_LOGGING) diff --git a/src/helpers/UsbLogging.h b/src/helpers/UsbLogging.h index 1a6c96f4c..634f71f2f 100644 --- a/src/helpers/UsbLogging.h +++ b/src/helpers/UsbLogging.h @@ -96,6 +96,12 @@ Stream& usbLoggingPort(); // Callers retain and retry unwritten suffixes. Other platforms retain Serial // or their hardware-CDC session facade. Stream& usbCompanionPort(); +// A primary nRF52 client may send commands with DTR low (stock MeshCLI does). +// Accept actual input in the current USB session as proof of a reader, while +// retaining the same close/reset generation gate used by the transport. +#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE) +bool isUsbCompanionClientConnected(); +#endif // Serial mOTA requests are binary records of at most 11 bytes. On TinyUSB this // facade admits a request only when the complete record fits in CDC0's current // TX capacity, so a retry can never append to a prefix from the prior attempt. @@ -104,7 +110,8 @@ Stream& usbMotaPort(); // bounded queue so a normal host receives complete multi-line replies. Service // it from the application loop; discard it before changing the CDC protocol or // after a host disconnect so stale text cannot prefix a later Binary session. -Stream& usbTerminalPort(); +// A quiet default terminal discards unsolicited text until the host sends ASCII. +Stream& usbTerminalPort(bool enabled = true); // Repeater/room-server/sensor console: protect both ESP32 native USB transports // while preserving the historical raw Serial behavior of other platforms. Stream& usbConsolePort(); diff --git a/test/test_companion_usb_default.py b/test/test_companion_usb_default.py index 749b3e622..a21f66869 100644 --- a/test/test_companion_usb_default.py +++ b/test/test_companion_usb_default.py @@ -32,6 +32,7 @@ static bool data_connected = true, reset_event = false, transport_ready = true; static bool network_active = false, logging_enabled = false, dedicated_logging = false; static uint32_t clock_ms = 2000; static unsigned cancelled_routes = 0, entered_ascii = 0; +static bool announced_ascii = false; static uint32_t millis() { return clock_ms; } struct Mesh { bool terminal = false, stale_input = true; @@ -70,9 +71,10 @@ static void clearUsbTerminalLine() { line_length = 0; } static void cancelUsbSerialOperations() { ++cancelled_routes; } static void leaveUsbMotaMode(bool) { usb_mota_mode = false; } static void leaveUsbTerminalMode(bool) { the_mesh.terminal = false; } -static void enterUsbTerminalMode() { +static void enterUsbTerminalMode(bool show_banner = true) { assert(transport_ready && !network_active); ++entered_ascii; + announced_ascii = show_banner; the_mesh.terminal = true; usb_serial_interface.passthrough = true; usb_binary_startup_probe.cancel(); @@ -97,6 +99,7 @@ int main() { // No Full macro is required: ordinary USB Companions also boot in ASCII. beginUsbDefaultSession(); assert(the_mesh.terminal && usb_serial_interface.passthrough); + assert(!announced_ascii); service(); // observe the open host before it can close for (bool binary : {false, true}) { the_mesh.terminal = !binary; @@ -107,6 +110,7 @@ int main() { usb_binary_startup_probe.start(clock_ms, usb_serial_interface.completed); boundary(); assert(the_mesh.terminal && usb_serial_interface.passthrough); + assert(!announced_ascii); assert(!the_mesh.stale_input && !usb_serial_interface.old_frame); assert(line_length == 0 && !usb_binary_startup_probe.isActive()); data_connected = true; @@ -155,9 +159,11 @@ int main() { logging_enabled = true; beginUsbDefaultSession(); assert(the_mesh.terminal && usb_logging_terminal_mode); + assert(announced_ascii); dedicated_logging = true; beginUsbDefaultSession(); assert(the_mesh.terminal && !usb_logging_terminal_mode); + assert(!announced_ascii); assert(cancelled_routes >= 6); } ''' diff --git a/test/test_esp32_usb_serial_hygiene.py b/test/test_esp32_usb_serial_hygiene.py index 2331fec0d..d9c8c4a77 100644 --- a/test/test_esp32_usb_serial_hygiene.py +++ b/test/test_esp32_usb_serial_hygiene.py @@ -183,7 +183,7 @@ class Esp32UsbSerialHygieneTest(unittest.TestCase): self.assertIn("return guarded_esp32_hwcdc_port;", companion) mota_port_start = logging.index("Stream& usbMotaPort()") mota_port = logging[mota_port_start : logging.index( - "Stream& usbTerminalPort()", mota_port_start + "Stream& usbTerminalPort(", mota_port_start )] self.assertIn( "return guarded_esp32_hwcdc_mota_port;", mota_port diff --git a/test/test_nrf52_usb_client.py b/test/test_nrf52_usb_client.py new file mode 100644 index 000000000..d8bafcc99 --- /dev/null +++ b/test/test_nrf52_usb_client.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""Execute the real nRF52 USB gates/callbacks with DTR-low and reopening hosts.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +USB = (ROOT / 'src/helpers/UsbLogging.cpp').read_text() + + +def function(source, signature): + start = source.index(signature) + end = source.index('{', start) + 1 + depth = 1 + while depth: + depth += (source[end] == '{') - (source[end] == '}') + end += 1 + return source[start:end] + + +HARNESS = r''' +#include +#include +#include +#include +#include +#define ENABLE_USB_INTERFACE 1 +#define NRF52_PLATFORM 1 +#define CFG_TUD_CDC 2 +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; +static bool close_during_rx_sample = false; +static uint32_t millis() { return now_ms; } +static bool tud_mounted() { return mounted; } +static bool tud_cdc_n_connected(uint8_t n) { return mounted && dtr[n]; } +extern "C" void meshTinyUsbCdcLineStateChanged(uint8_t, bool, bool); +static uint32_t tud_cdc_n_available(uint8_t) { + const auto count = rx_count; + if (close_during_rx_sample) { + close_during_rx_sample = false; + meshTinyUsbCdcLineStateChanged(0, false, false); + } + return count; +} +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 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; +} +struct cdc_line_coding_t { uint32_t bit_rate; }; +static uint32_t baud = 115200; +static void tud_cdc_get_line_coding(cdc_line_coding_t* coding) { coding->bit_rate = baud; } +static void TinyUSB_Port_EnterDFU() { dfu = true; } +namespace mesh { +@GATES@ +static uint32_t primary_usb_terminal_taken_reset_generation = 0; +@WRITE@ +@COMPLETE@ +@CONNECTED@ +static void handleDedicatedUsbLoggingLineState(bool) { ++log_edges; } +static void handleDedicatedUsbLoggingLineCoding() { ++log_edges; } +} +@CALLBACKS@ +static void settle() { + now_ms += 8; + mesh::primary_usb_terminal_taken_reset_generation = mesh::primaryUsbSessionGeneration(); + mesh::completePrimaryUsbSessionReset(nullptr); +} +static void line(bool value, uint8_t instance = 0) { + dtr[instance] = value; + meshTinyUsbCdcLineStateChanged(instance, value, false); +} +int main() { + uint8_t bytes[4] = {'>', 1, 0, 0}; + assert(!mesh::isUsbCompanionClientConnected()); + assert(mesh::writeTinyUsbCdcOnce(nullptr, bytes, 4) == 0); + // Ordinary terminals retain their DTR behavior. + line(true); + assert(mesh::isUsbCompanionClientConnected()); + assert(mesh::writeTinyUsbCdcOnce(nullptr, bytes, 4) == 4); + line(false); + assert(!mesh::isUsbCompanionClientConnected()); + settle(); + assert(!mesh::isUsbCompanionClientConnected()); + // Stock MeshCLI holds DTR low. Its first real input opens only this epoch. + rx_count = 4; + assert(mesh::isUsbCompanionClientConnected()); + rx_count = 0; + assert(mesh::isUsbCompanionClientConnected()); + assert(mesh::writeTinyUsbCdcOnce(nullptr, bytes, 4) == 4); + // Low-DTR close, and input sent immediately on the next open. + const auto old_generation = mesh::primaryUsbSessionGeneration(); + line(false); + assert(mesh::primaryUsbSessionGeneration() != old_generation); + rx_count = 4; + assert(!mesh::isUsbCompanionClientConnected()); + settle(); + assert(rx_count == 4 && mesh::isUsbCompanionClientConnected()); + rx_count = 0; + // A new handle's line coding also invalidates a DTR-low session. + meshTinyUsbCdcLineCodingChanged(0); + assert(!mesh::isUsbCompanionClientConnected()); + settle(); + assert(!mesh::isUsbCompanionClientConnected()); + // Closing after RX was sampled must not publish proof for the new epoch. + rx_count = 4; + close_during_rx_sample = true; + assert(!mesh::isUsbCompanionClientConnected()); + settle(); + assert(!mesh::isUsbCompanionClientConnected()); + // A real new packet works after that race; DTR rising starts a clean owner. + rx_count = 4; + assert(mesh::isUsbCompanionClientConnected()); + line(true); + assert(!mesh::isUsbCompanionClientConnected()); + settle(); + assert(mesh::isUsbCompanionClientConnected()); + line(false); settle(); + rx_count = 4; + assert(mesh::isUsbCompanionClientConnected()); + rx_count = 0; + // Physical loss revokes a low-DTR client even without a line-state callback. + mounted = false; + meshTinyUsbDeviceSessionBoundary(); + settle(); + assert(!mesh::isUsbCompanionClientConnected()); + mounted = true; + meshTinyUsbDeviceSessionBoundary(); + assert(!mesh::isUsbCompanionClientConnected()); + rx_count = 4; + assert(mesh::isUsbCompanionClientConnected()); + rx_count = 0; + // CDC1 keeps the explicit logging-reader requirement and cannot reset CDC0. + assert(mesh::writeTinyUsbCdcOnce(reinterpret_cast(1), bytes, 4) == 0); +#if MESH_DUAL_CDC_LOGGING + const auto before_logging = mesh::primaryUsbSessionGeneration(); + line(true, 1); + meshTinyUsbCdcLineCodingChanged(1); + assert(log_edges >= 2 && mesh::primaryUsbSessionGeneration() == before_logging); +#endif + // No write waits when the FIFO is full, including with DTR low. + tx_count = 64; + assert(mesh::writeTinyUsbCdcOnce(nullptr, bytes, 4) == 0); + baud = 1200; + line(false); + assert(dfu); +} +''' + + +class Nrf52UsbClientTest(unittest.TestCase): + def test_stop_token_reports_shared_logging_conflict(self): + main = (ROOT / 'examples/companion_radio/main.cpp').read_text() + stop = function(main, 'if (strcmp(usb_terminal_line, USB_TERMINAL_STOP_TOKEN) == 0)') + source = r''' +#include +#include +#include +#define MESH_USB_LOGGING_AVAILABLE 1 +static bool dedicated = false, logging = false, binary = false; +static const char* USB_TERMINAL_STOP_TOKEN = "+++MESHCORE-TERM-STOP"; +static char usb_terminal_line[32]; +namespace mesh { +bool hasDedicatedUsbLoggingPort() { return dedicated; } +bool isUsbLoggingEnabled() { return logging; } +} +struct Output { std::string bytes; void print(const char* s) { bytes += s; } } output; +static Output& usbTerminalOutput() { return output; } +static void clearUsbTerminalLine() { usb_terminal_line[0] = 0; } +static void leaveUsbTerminalMode(bool acknowledge) { assert(acknowledge); binary = true; } +static void stop() { @STOP@ } +int main() { + for (int mode = 0; mode != 3; ++mode) { + logging = mode != 0; dedicated = mode == 2; + binary = false; output.bytes.clear(); + strcpy(usb_terminal_line, USB_TERMINAL_STOP_TOKEN); + stop(); + if (mode == 1) { + assert(!binary && usb_terminal_line[0] == 0); + assert(output.bytes.find("ERROR: set usb.logging off before Binary mode") != std::string::npos); + } else { + assert(binary && output.bytes.empty()); + } + } +} +'''.replace('@STOP@', stop) + with tempfile.TemporaryDirectory() as directory: + cpp = Path(directory) / 'logging-mode.cpp' + binary = Path(directory) / 'logging-mode' + cpp.write_text(source) + subprocess.run(['g++', '-std=c++17', str(cpp), '-o', str(binary)], check=True) + subprocess.run([str(binary)], check=True) + + def test_default_terminal_is_quiet_until_ascii_input(self): + mesh = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text() + header = (ROOT / 'examples/companion_radio/MyMesh.h').read_text() + main = (ROOT / 'examples/companion_radio/main.cpp').read_text() + source = r''' +#include +#include +#include +#include +#include +#include +#define PUB_KEY_SIZE 32 +#define FIRMWARE_VERSION "test-usb-client" +class Stream { +public: + virtual int available() { return 0; } + virtual int read() { return -1; } + virtual int peek() { return -1; } + virtual void flush() {} + virtual size_t write(uint8_t value) { return write(&value, 1); } + virtual size_t write(const uint8_t*, size_t) = 0; + void print(const char* value) { write(reinterpret_cast(value), strlen(value)); } + void printf(const char* format, ...) { + char value[512]; va_list args; va_start(args, format); + vsnprintf(value, sizeof(value), format, args); va_end(args); print(value); + } +}; +class Capture : public Stream { +public: + std::string bytes; + size_t write(const uint8_t* data, size_t size) override { + bytes.append(reinterpret_cast(data), size); return size; + } +} Serial; +namespace mesh { +@NULL@ +static NullUsbLoggingStream null_usb_logging_stream; +Stream& usbCompanionPort() { return Serial; } +Stream& usbTerminalPort(bool enabled = true); +@PORT@ +struct Utils { + static void printHex(Stream& out, const uint8_t*, size_t) { out.print("00112233"); } +}; +} +class MyMesh { +public: + bool _terminal_mode = false, _terminal_usb_silent = false; + Stream* _terminal_output = nullptr; + struct { const char* node_name = "USB regression"; } _prefs; + struct { uint8_t pub_key[PUB_KEY_SIZE] = {}; } self_id; + void resetTerminalSession() {} + @WAITING@ + Stream& terminalOutput(); + void printTerminalBanner(bool); + void enterTerminalMode(bool show_banner = true); + void exitTerminalMode(); +}; +@METHODS@ +int main() { + MyMesh radio; + radio.enterTerminalMode(false); + assert(radio.isTerminalWaitingForInput() && Serial.bytes.empty()); + radio.terminalOutput().print("ADVERT from -> another node\r\n> "); + assert(Serial.bytes.empty()); // unsolicited radio events cannot corrupt an app probe + radio.exitTerminalMode(); + const uint8_t reply[] = {'>', 1, 0, 5}; + mesh::usbCompanionPort().write(reply, sizeof(reply)); + assert(Serial.bytes == std::string(reinterpret_cast(reply), sizeof(reply))); + Serial.bytes.clear(); + radio.enterTerminalMode(false); + radio.enterTerminalMode(); // first ASCII input reveals the ordinary terminal + assert(!radio.isTerminalWaitingForInput()); + assert(Serial.bytes.find("Companion test-usb-client") != std::string::npos); + assert(Serial.bytes.find("\r\n> ") != std::string::npos); + radio.exitTerminalMode(); + assert(!radio._terminal_mode && radio._terminal_output == nullptr); +} +''' + source = source.replace('@NULL@', function(USB, 'class NullUsbLoggingStream') + ';') + source = source.replace('@PORT@', function(USB, 'Stream& usbTerminalPort(')) + source = source.replace('@WAITING@', function(header, 'bool isTerminalWaitingForInput(')) + source = source.replace('@METHODS@', '\n'.join(function(mesh, signature) for signature in ( + 'Stream& MyMesh::terminalOutput(', 'void MyMesh::printTerminalBanner(', + 'void MyMesh::enterTerminalMode(', 'void MyMesh::exitTerminalMode('))) + with tempfile.TemporaryDirectory() as directory: + cpp = Path(directory) / 'quiet-terminal.cpp' + cpp.write_text(source) + for full in (False, True): + with self.subTest(full=full): + binary = Path(directory) / ('quiet-terminal-' + str(full)) + flags = ['-DCOMPANION_FEATURE_TEMP_RADIO=1'] if full else [] + subprocess.run(['g++', '-std=c++17', *flags, str(cpp), '-o', str(binary)], check=True) + subprocess.run([str(binary)], check=True) + terminal = function(main, 'static void serviceUsbTerminal()') + self.assertLess(terminal.index('usb_binary_startup_probe.shouldStart('), + terminal.index('the_mesh.isTerminalWaitingForInput()')) + self.assertIn('enterUsbTerminalMode(false);', function(main, 'static void serviceUsbAsciiSessionDefault(')) + + def test_real_usb_callbacks_and_transport_with_both_client_styles(self): + start = USB.index('static std::atomic primary_usb_reset_generation') + gates = USB[start:USB.index('\n#endif', start)] + source = HARNESS.replace('@GATES@', gates) + for marker, signature in ( + ('@WRITE@', 'static size_t writeTinyUsbCdcOnce('), + ('@COMPLETE@', 'static void completePrimaryUsbSessionReset('), + ('@CONNECTED@', 'bool isUsbCompanionClientConnected('), + ): + source = source.replace(marker, function(USB, signature)) + source = source.replace('@CALLBACKS@', '\n'.join(function(USB, 'extern "C" void ' + name + '(') for name in ( + 'meshTinyUsbCdcLineStateChanged', 'meshTinyUsbDeviceSessionBoundary', + 'meshTinyUsbCdcLineCodingChanged'))) + with tempfile.TemporaryDirectory() as directory: + cpp = Path(directory) / 'usb-client.cpp' + cpp.write_text(source) + for dual in (False, True): + with self.subTest(dedicated_logging=dual): + binary = Path(directory) / ('usb-client-' + str(dual)) + flags = ['-DMESH_DUAL_CDC_LOGGING=1'] if dual else [] + subprocess.run(['g++', '-std=c++17', *flags, str(cpp), '-o', str(binary)], check=True) + subprocess.run([str(binary)], check=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_nrf52_usb_logging_contract.py b/test/test_nrf52_usb_logging_contract.py index 914a535b9..96af98102 100644 --- a/test/test_nrf52_usb_logging_contract.py +++ b/test/test_nrf52_usb_logging_contract.py @@ -298,7 +298,8 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase): "static void endPrimaryUsbHostSession(" ))] self.assertIn("primary_usb_line_state_dtr.exchange(", session_end) - self.assertIn("if (!previous) return;", session_end) + self.assertIn("if (!previous && !received", session_end) + self.assertIn("primary_usb_rx_generation.load(", session_end) self.assertLess( session_end.index("primary_usb_reset_generation.fetch_add("), session_end.index("tud_cdc_n_read_flush(0)"), @@ -314,9 +315,6 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase): 'extern "C" void meshTinyUsbCdcLineCodingChanged(' ):] self.assertIn("if (instance == 0)", coding_bridge) - self.assertIn( - "primary_usb_line_state_dtr.load(", coding_bridge - ) self.assertIn("endPrimaryUsbHostSession(true);", coding_bridge) self.assertIn("tud_cdc_n_connected(0)", coding_bridge) self.assertIn("if (instance == 1)", coding_bridge) @@ -518,7 +516,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase): self.assertIn("cancelUsbSerialOperations();", reset_helper) usb_cancel = main[ main.index("static void cancelUsbSerialOperations()"): - main.index("static void enterUsbTerminalMode()") + main.index("static void enterUsbTerminalMode(") ] route_check = usb_cancel.index( "interface_manager.isReplyRouteFor(&usb_serial_interface)" @@ -613,7 +611,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase): ] self.assertIn("primary_usb_line_state_dtr.load(", access) self.assertIn("primary_usb_allowed_generation.load(", access) - self.assertIn("primary_usb_reset_generation.load(", access) + self.assertIn("primaryUsbSessionGeneration() == generation", access) loop = main[main.index("void loop() {"):] self.assertLess( diff --git a/tools/hil/companion_usb_client.py b/tools/hil/companion_usb_client.py new file mode 100644 index 000000000..7f3300804 --- /dev/null +++ b/tools/hil/companion_usb_client.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Check USB Companion ASCII/binary handoff with real, unmodified MeshCLI. + +Install meshcore-cli in a virtual environment first. This script issues only +local information queries and mode switches; it neither flashes nor sends LoRa +messages. The optional second port is opened as a simultaneous logging reader. +Start after a fresh reboot. Use --test-dtr only on nRF52; changing these control +lines can invoke an ESP32 hardware bootloader. +""" +import argparse +import asyncio +import importlib.metadata +import json +from pathlib import Path +import shutil +import subprocess +import threading +import time + +import serial +from meshcore import MeshCore + + +def open_tty(port, dtr=True): + tty = serial.Serial(port=None, baudrate=115200, timeout=2, exclusive=True) + tty.port = port + # ESP32 interprets some RTS/DTR combinations as reset/download control. + # Keep RTS deasserted before opening; toggling DTR while leaving the + # pyserial default RTS high can reboot the board instead of testing USB. + tty.rts = False + tty.dtr = dtr + tty.open() + return tty + + +def drain(tty, duration): + data = bytearray() + deadline = time.monotonic() + duration + while time.monotonic() < deadline: + data.extend(tty.read(max(1, min(4096, tty.in_waiting)))) + return bytes(data) + + +def battery_reply(tty): + tty.write(b'<\x01\x00\x14') + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + header = tty.read(3) + assert len(header) == 3 and header[0] == ord('>'), 'Missing or contaminated binary header: ' + header.hex() + size = int.from_bytes(header[1:], 'little') + assert 1 <= size <= 300, 'Invalid binary frame length' + payload = tty.read(size) + assert len(payload) == size, 'Truncated binary reply' + if payload[0] == 12: + return + # A real radio may push a received-message notification before its + # query reply. It is valid only as a complete, clean binary frame. + raise AssertionError('Missing battery/storage reply') + + +async def stock_connections(port, cycles): + for _ in range(cycles): + client = await MeshCore.create_serial(port, baudrate=115200) + assert client is not None, 'Stock MeshCLI library failed to connect' + try: + assert client.self_info, 'Stock client did not receive its first reply' + finally: + await client.disconnect() + await asyncio.sleep(0.02) + + +def run(args): + results = { + 'port': args.port, + 'logging_port': args.logging_port, + 'meshcore_cli': importlib.metadata.version('meshcore-cli'), + 'meshcore': importlib.metadata.version('meshcore'), + 'checks': [], + } + logging_tty = None + logging_thread = None + stop_logging = threading.Event() + logging_counts = {'bytes': 0, 'errors': 0} + if args.logging_port: + logging_tty = open_tty(args.logging_port) + logging_tty.timeout = 0.05 + + def read_logging(): + try: + while not stop_logging.is_set(): + logging_counts['bytes'] += len(logging_tty.read(max(1, min(4096, logging_tty.in_waiting)))) + except serial.SerialException: + logging_counts['errors'] += 1 + + logging_thread = threading.Thread(target=read_logging, daemon=True) + logging_thread.start() + try: + with open_tty(args.port) as tty: + tty.timeout = 0.02 + initial = drain(tty, 0.2) + assert not initial, 'Fresh connection emitted unsolicited text or stale data' + tty.write(b'ver\r') + response = drain(tty, 0.4) + assert b'Companion v' in response, 'Default ASCII terminal did not answer' + results['checks'].append('default ASCII accepts ver without START') + tty.write(b'+++MESHCORE-TERM-STOP\r') + assert b'OK - Binary mode' in drain(tty, 0.3), 'STOP acknowledgement missing' + tty.timeout = 2 + for _ in range(args.cycles): + battery_reply(tty) + results['checks'].append(f'{args.cycles} binary queries after explicit STOP') + + if args.test_dtr: + for dtr in (False, True): + for cycle in range(args.cycles): + results['active_case'] = dict(dtr=dtr, cycle=cycle + 1) + with open_tty(args.port, dtr) as tty: + # No startup delay or input flush: exercise the first + # frame while the device settles a close/reopen. + battery_reply(tty) + results['checks'].append(f'{args.cycles} immediate binary reconnects with DTR={dtr}') + results.pop('active_case', None) + + asyncio.run(stock_connections(args.port, args.cycles)) + results['checks'].append(f'{args.cycles} stock library reconnects') + cli = shutil.which('meshcli') + assert cli, 'meshcli is not installed in this environment' + for _ in range(3): + completed = subprocess.run([cli, '-j', '-s', args.port, 'infos'], capture_output=True, text=True, timeout=20) + assert completed.returncode == 0 and json.loads(completed.stdout).get('public_key'), 'Stock meshcli infos failed' + results['checks'].append('3 unmodified meshcli infos invocations') + assert not logging_counts['errors'], 'Logging interface disconnected during primary-port tests' + results['success'] = True + finally: + stop_logging.set() + if logging_thread: + logging_thread.join(timeout=1) + if logging_tty: + logging_tty.close() + results['logging'] = logging_counts + if args.output: + Path(args.output).write_text(json.dumps(results, indent=2) + '\n') + return results + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--port', required=True) + parser.add_argument('--logging-port') + parser.add_argument('--cycles', type=int, default=10) + parser.add_argument('--test-dtr', action='store_true', + help='Exercise nRF52 DTR-high/low reconnects. Do not use on ESP32: these control-line transitions can trigger its hardware bootloader.') + parser.add_argument('--output') + args = parser.parse_args() + if args.cycles < 1: + parser.error('--cycles must be positive') + print(json.dumps(run(args), indent=2))