diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 81c99b73..65de6abe 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -90,6 +90,11 @@ jobs: - name: Verify ESP32 USB sleep and G3 button wake run: python3 -B test/test_esp32_usb_sleep.py + - name: Verify Companion ASCII defaults and USB session cleanup + run: | + python3 -B test/test_companion_usb_default.py + python3 -B test/test_esp32_usb_serial_hygiene.py + - name: Verify message reader buttons, touch targets, and footer layouts working-directory: test run: >- diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 8e7769aa..c1b521ca 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -1104,7 +1104,7 @@ const picocom = "picocom -b 115200 --imap spchex --initstring $'\\r+++MESHCORE-TERM-START\\r' /dev/ttyACM0"; section("Companion USB terminal (Linux / Bash)", [ { label: "Connect", commands: [picocom], - text: "Run this in Bash on the Linux computer connected to the radio. The init string opens the ASCII CLI; the leading carriage return clears an unfinished input line. spchex displays binary control bytes safely during the transition." }, + text: "Run this in Bash on the Linux computer connected to the radio. Updated 1.17.1.6 USB Companions default to ASCII at boot and after an observable USB session reset; a valid app frame selects Binary Companion. The init string also opens the ASCII CLI on older firmware or a port left in Binary mode; the leading carriage return clears an unfinished input line. spchex displays binary control bytes safely during the transition." }, { label: "Recover scrambled terminal", commands: ["reset", picocom], text: "First exit picocom with Ctrl+A, then Ctrl+X. Run these commands in your Linux shell: reset repairs the terminal display, then picocom reconnects with safe input mapping." }, ], "Replace /dev/ttyACM0 with your radio's port, such as /dev/ttyACM1 or /dev/ttyUSB0. Use the primary USB data interface (00 when multiple interfaces appear), and close other apps using that port. At the radio prompt, run version and board to identify the node. Exit picocom with Ctrl+A, then Ctrl+X."); @@ -1209,8 +1209,8 @@ guide.href = "https://github.com/mikecarper/MeshCore/blob/keymindCascade/docs/role_feature_switches.md"; links.appendChild(guide); panel.appendChild(links); - if (isFullCompanion(profile) || ["repeater", "room", "sensor"].includes(profile.role)) { - panel.appendChild(createElement("p", "Use a data-capable USB cable and 115200 baud. Full Companion and infrastructure start in ASCII mode. " + (profile.role === "companion" + if (isFullCompanion(profile) || (profile.role === "companion" && ["usb", "usb-ble"].includes(profile.mode)) || ["repeater", "room", "sensor"].includes(profile.role)) { + panel.appendChild(createElement("p", "Use a data-capable USB cable and 115200 baud. Updated USB Companion and infrastructure builds start in ASCII mode. " + (profile.role === "companion" ? "Open Companion USB terminal below for Linux connection and recovery commands. Use board and version to identify the node." : "Use board and ver to identify the node."))); } diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 4d46812c..a538671b 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -368,7 +368,7 @@ itself. | Platform | Interface | Purpose | | --- | --- | --- | -| Both | USB, 115200 baud | ASCII after boot; automatically switches on the first complete Binary Companion frame | +| Both | USB, 115200 baud | ASCII after boot or observable USB session reset; automatically switches on the first complete Binary Companion frame | | Both | BLE | Binary Companion; display builds show a random session PIN, while headless builds default to `123456` | | ESP32 | TCP 5000 | Binary Companion over WiFi | | ESP32 | HTTP 80 | Companion WebConfig and first-boot WiFi setup | @@ -433,7 +433,8 @@ Expose them only on a trusted LAN or temporary setup network. See ## USB Binary and text terminal modes -Full Companion USB starts in the ASCII terminal after boot. MeshCore apps and +Full Companion USB starts in the ASCII terminal after boot or an observable USB +session reset. MeshCore apps and `meshcli` send a `<`-prefixed framed command, which automatically hands the untouched frame to the Binary Companion parser: @@ -443,7 +444,8 @@ meshcli -s /dev/ttyACM0 -b 115200 ver The automatic probe runs only at an empty prompt. A complete frame confirms binary mode; an incomplete probe returns to ASCII after one second. Binary mode -then remains selected until reboot or the explicit terminal start token. See +then remains selected until an observable USB session reset, reboot, or the +explicit terminal start token. See [Full Companion USB CLI and binary switcher](./full_companion_usb_switcher.md) for the byte-level state machine, logging and mOTA ownership, recovery paths, and known limitations. @@ -649,10 +651,11 @@ Return to Binary mode with: +++MESHCORE-TERM-STOP ``` -Closing an armed ASCII USB data connection also changes the port to Binary -mode when the hardware can report disconnect. A USB-to-UART bridge may not be -able to report this event. A different baud rate, including 57600, does not -select ASCII mode. +An observable USB session reset restores ASCII, including after a Binary client +disconnects. Native USB with DTR can detect a terminal closing; ESP32 hardware +USB Serial/JTAG detects bus resets and physical host loss. UART bridges usually +cannot report a terminal closing, so use the start token if they remain in +Binary mode. A different baud rate, including 57600, does not select ASCII. On an ESP32 Full Companion built with `OTA_FOLDER_SERIAL`, `motatool` can keep the USB serial port open as an mOTA folder source when WiFi is unavailable: diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index bd2da683..0dad2223 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -6,7 +6,7 @@ controls. Select On, Off, or Check to view and copy the commands; Companion MQTT and GPS show their app/WebConfig steps instead. See [feature switches by role](role_feature_switches.md) for the full reference. The [USB web console](https://flasher.meshcore.io/console) works with the -default ASCII terminal on Full Companion and infrastructure roles. +default ASCII terminal on USB Companion and infrastructure roles. Pick the choices in any order. Every selection narrows all the other controls to firmware combinations that were actually built in the current release set. @@ -300,6 +300,11 @@ Full, USB and USB + Bluetooth Companion results include **Companion USB terminal (Linux / Bash)** under **Restore your settings after flashing**, with copyable connection and recovery commands. +Updated 1.17.1.6 USB Companions default to ASCII at boot and after an observable +USB session reset. `picocom -b 115200 /dev/ttyACM0` is sufficient for a fresh +session. A valid framed app command automatically selects Binary Companion. +The command below also works with older firmware or a port left in Binary mode. + Run this in Bash on the Linux computer connected to your radio: ```bash @@ -310,7 +315,7 @@ picocom -b 115200 --imap spchex \ Replace `/dev/ttyACM0` with your radio's port, such as `/dev/ttyACM1` or `/dev/ttyUSB0`. Use the primary USB data interface (`00` when multiple interfaces -appear), and close other apps using it. The init string opens the ASCII CLI; +appear), and close other apps using it. The init string requests the ASCII CLI; its leading carriage return clears an unfinished input line. `--imap spchex` displays binary control bytes safely during the transition. diff --git a/docs/full_companion_features.md b/docs/full_companion_features.md index 6b542c6d..007bdc00 100644 --- a/docs/full_companion_features.md +++ b/docs/full_companion_features.md @@ -23,7 +23,8 @@ for the corrected download; the earlier `aa20e927` Wireless Paper image has Close the app or serial monitor using the USB port, then open a terminal at 115200 baud, or use the [USB web console](https://flasher.meshcore.io/console). -A fresh Full Companion boot starts in ASCII text mode. If it is already in Binary +A fresh Full Companion boot or observable USB session reset starts in ASCII +text mode. If it is already in Binary Companion mode, send this line once and wait for the terminal banner: ```text diff --git a/docs/full_companion_usb_switcher.md b/docs/full_companion_usb_switcher.md index aec4717c..1385c8b5 100644 --- a/docs/full_companion_usb_switcher.md +++ b/docs/full_companion_usb_switcher.md @@ -1,19 +1,20 @@ -# Full Companion USB CLI and binary switcher +# Companion USB CLI and binary switcher -Full Companion uses one primary USB serial interface for two incompatible wire +USB Companion uses one primary USB serial interface for two incompatible wire formats: - a human-readable ASCII command line; - the framed Binary Companion protocol used by MeshCore apps and `meshcli`. -The primary interface starts in the ASCII terminal after each boot. A Binary +The primary interface starts in the ASCII terminal after each boot and returns +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 automatic behavior is compiled only into `companion_radio_full` targets. -Ordinary USB Companion builds continue to use the explicit -`+++MESHCORE-TERM-START` and `+++MESHCORE-TERM-STOP` controls described in the -[Terminal Chat CLI guide](./terminal_chat_cli.md). +This behavior applies to Full and ordinary USB Companion builds in the updated +1.17.1.6 release. 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). ## Wire formats @@ -61,7 +62,7 @@ boot ------> ASCII terminal Binary Companion +--------------------------------+ ASCII terminal -- +++MESHCORE-TERM-STOP ------> Binary Companion -ASCII terminal -- observable USB disconnect ---> Binary Companion +any mode ------- observable USB session reset ---> ASCII terminal ESP32 logging terminal -- usb.logging off -----> ASCII terminal any mode ------- reboot ------------------------> ASCII terminal ``` @@ -72,7 +73,7 @@ switched; they remain binary. ## How automatic detection works -1. Full Companion initializes the normal USB Binary Companion interface, then +1. Companion initializes the normal USB Binary Companion interface, then gives its primary stream to the ASCII terminal before normal loop service begins. 2. While the prompt has no buffered input, the terminal peeks at the next byte. @@ -144,7 +145,7 @@ mOTA traffic on the same stream. | Situation | Primary USB behavior | | --- | --- | -| Full Companion after boot | ASCII; a complete `<` frame switches to Binary Companion | +| USB Companion after boot or observable session reset | ASCII; a complete `<` frame switches to Binary Companion | | nRF52 logging enabled | Primary interface still follows the switcher; logs use the optional second interface | | ESP32 logging enabled at boot | Logging terminal owns the only USB TTY; automatic `<` detection is disabled | | nRF52 USB serial mOTA active | mOTA owns primary USB; ASCII and Binary Companion are unavailable there | @@ -178,17 +179,22 @@ leading/trailing whitespace, or a partial line remain ordinary terminal input. This mechanism is deliberately small and deterministic, but it is not a full protocol negotiation layer. -### It is startup selection, not per-connection negotiation +### Session detection depends on the USB hardware After the first complete binary frame, the device stays in Binary Companion -mode. Closing `meshcli` does not automatically restore ASCII. Use the terminal -start token or reboot when an ASCII prompt is needed again. +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. -Conversely, closing an ASCII terminal on native USB normally changes the port -to binary mode because the firmware can observe USB DTR/data disconnect. The -next client therefore sees binary mode, not a new ASCII session. A USB-to-UART -bridge often cannot report disconnect, so it can remain in ASCII until the stop -token or a reboot. +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 +start token to return from Binary mode without rebooting. Idle time alone never +changes the selected protocol. + +An active network terminal temporarily delays restoring the USB ASCII prompt. +If a fresh USB app sends a complete binary frame during that delay, its Binary +mode remains selected when the network terminal closes. ### Detection works only at an empty prompt diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index 204c57b3..196e79e8 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -83,9 +83,15 @@ password fields masked; explicit local CLI getters return their values. ## Companion USB mode -An ordinary Companion USB build starts in the normal binary Companion protocol -at 115200 baud. Use this command to switch the same USB connection into terminal -mode as soon as `picocom` opens it: +Updated 1.17.1.6 USB Companion builds, including ordinary and Full builds, start +in ASCII at 115200 baud. A valid framed app command automatically selects Binary +Companion. You can open a terminal without a start token: + +```sh +picocom --baud 115200 /dev/ttyACM0 +``` + +For older firmware or a port already in Binary mode, use: ```sh picocom --baud 115200 \ @@ -119,10 +125,11 @@ Send the following exact sequence to return to the binary protocol: +++MESHCORE-TERM-STOP ``` -Closing the serial connection also returns native-USB devices to binary mode. -Boards whose USB connector is implemented by a USB-to-UART bridge cannot -observe the host closing the port; on those boards, use the stop sequence or -reboot the device. +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 +usually cannot detect a terminal closing. Use the start token on ports that +remain in Binary mode; idle time does not select ASCII. Both modes use the same port at 115200. Selecting 57600 is not a portable mode switch: native USB CDC devices ignore the requested baud, while USB-to-UART @@ -130,10 +137,9 @@ devices really change the UART timing and receive corrupt data. Binary mode is the framed Companion API used by apps and `meshcli`; close the terminal before opening that port from an app. -Full Companion differs: its primary USB interface starts in ASCII after boot -and automatically switches when it sees a complete `<`-prefixed Companion -frame at an empty prompt. The explicit start/stop tokens remain available. See -[Full Companion USB CLI and binary switcher](./full_companion_usb_switcher.md) +All USB Companion builds automatically switch when they see a complete +`<`-prefixed Companion frame at an empty prompt. The explicit start/stop tokens +remain available. See [Companion USB CLI and binary switcher](./full_companion_usb_switcher.md) for the state machine and limitations. ## Commands diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index af1a7bb7..99e19bc2 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -474,11 +474,10 @@ static void serviceCompanionPowerSaving(bool force = false) { static char usb_terminal_line[MAX_TRANS_UNIT * 2 + 32]; static size_t usb_terminal_line_len = 0; static bool usb_terminal_discard_line = false; -static bool usb_terminal_disconnect_armed = false; +static bool usb_host_session_connected = false; static bool usb_logging_terminal_mode = false; -#if defined(COMPANION_RADIO_FULL) static mesh::UsbBinaryStartupProbe usb_binary_startup_probe; -#endif +static mesh::UsbAsciiSessionDefault usb_ascii_session_default; #if COMPANION_FEATURE_USB_MOTA_SOURCE static bool usb_mota_mode = false; static char usb_mota_line[32]; @@ -597,15 +596,13 @@ static void cancelUsbSerialOperations() { } static void enterUsbTerminalMode() { -#if defined(COMPANION_RADIO_FULL) usb_binary_startup_probe.cancel(); -#endif + usb_ascii_session_default.cancel(); cancelUsbSerialOperations(); mesh::discardUsbTerminalOutput(); usb_serial_interface.setPassthroughMode(true); clearUsbTerminalLine(); usb_terminal_discard_line = false; - usb_terminal_disconnect_armed = isUsbTerminalDataConnected(); usb_logging_terminal_mode = false; the_mesh.enterTerminalMode(); } @@ -626,7 +623,6 @@ static void leaveUsbTerminalMode(bool acknowledge) { usb_serial_interface.setPassthroughMode(false); clearUsbTerminalLine(); usb_terminal_discard_line = false; - usb_terminal_disconnect_armed = false; usb_logging_terminal_mode = false; } @@ -740,52 +736,29 @@ static void serviceUsbMota() { static bool usb_terminal_host_reset_completion_pending = false; static uint32_t usb_terminal_host_reset_retry_at = 0; -static void resetUsbTerminalHostSession(bool preserve_ascii_terminal) { +static void resetUsbTerminalHostSession() { // Stop only work routed to the old USB host; a simultaneous BLE/WiFi // transaction is independent and must continue. Clear the route after // stopping its producer so no response can fall back to another transport. cancelUsbSerialOperations(); the_mesh.resetUsbHostSessionInput(); - bool protocol_owner_reset = false; #if COMPANION_FEATURE_USB_MOTA_SOURCE if (usb_mota_mode) { leaveUsbMotaMode(false); - protocol_owner_reset = true; } #endif - if (!protocol_owner_reset && the_mesh.isTerminalMode()) { - if (preserve_ascii_terminal) { - // Full Companion's ordinary HWCDC terminal is the default protocol. - // Clear the old host's input/output without turning the next physical - // session into a silent Binary-only port. - the_mesh.resetTerminalSession(); - mesh::discardUsbTerminalOutput(); - clearUsbTerminalLine(); - usb_terminal_discard_line = false; - usb_terminal_disconnect_armed = false; -#if defined(COMPANION_RADIO_FULL) - usb_binary_startup_probe.cancel(); -#endif - } else { - leaveUsbTerminalMode(false); - } - protocol_owner_reset = true; - } - - if (!protocol_owner_reset) { - mesh::discardUsbTerminalOutput(); - usb_serial_interface.setPassthroughMode(false); - clearUsbTerminalLine(); - usb_terminal_discard_line = false; - usb_terminal_disconnect_armed = false; - usb_logging_terminal_mode = false; -#if defined(COMPANION_RADIO_FULL) - usb_binary_startup_probe.cancel(); -#endif - } + if (the_mesh.isTerminalMode()) leaveUsbTerminalMode(false); + mesh::discardUsbTerminalOutput(); + usb_serial_interface.setPassthroughMode(false); + clearUsbTerminalLine(); + usb_terminal_discard_line = false; + usb_logging_terminal_mode = false; + usb_binary_startup_probe.cancel(); // setPassthroughMode() clears parser/queue state but frame ownership is a // separate proof. Revoke all three explicitly at the session boundary. usb_serial_interface.resetSessionState(); + usb_ascii_session_default.request( + usb_serial_interface.getCompletedFrameCount()); #if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \ && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT // Purge HWCDC's queues behind a temporary diagnostics gate. This keeps the @@ -804,10 +777,16 @@ static void resetUsbTerminalHostSession(bool preserve_ascii_terminal) { } static void serviceUsbTerminalHostSessionReset() { -#if defined(NRF52_PLATFORM) - if (mesh::takeUsbTerminalSessionReset()) { +#if defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) \ + || MESH_ESP32_TINYUSB_NONBLOCKING + // Observe disconnects even while Binary or mOTA owns USB. Owner-task reset + // events also catch a close/reopen that happens between two main-loop polls. + const bool connected = isUsbTerminalDataConnected(); + const bool disconnected = usb_host_session_connected && !connected; + usb_host_session_connected = connected; + if (mesh::takeUsbTerminalSessionReset() || disconnected) { usb_terminal_host_reset_completion_pending = true; - resetUsbTerminalHostSession(false); + resetUsbTerminalHostSession(); } if (usb_terminal_host_reset_completion_pending @@ -839,11 +818,36 @@ static void serviceUsbTerminalHostSessionReset() { const bool sustained_host_loss = usb_hwcdc_host_presence.takeSustainedHostLoss(); if (hardware_bus_reset || physical_host_loss || sustained_host_loss) { - resetUsbTerminalHostSession(true); + resetUsbTerminalHostSession(); } #endif } +static void serviceUsbAsciiSessionDefault() { + if (usb_ascii_session_default.shouldRestore( + !usb_terminal_host_reset_completion_pending, +#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG) + isNetworkTerminalActive(), +#else + false, +#endif + usb_serial_interface.getCompletedFrameCount())) { + enterUsbTerminalMode(); + } +} + +static void beginUsbDefaultSession() { +#if MESH_USB_LOGGING_AVAILABLE + if (!mesh::hasDedicatedUsbLoggingPort() && mesh::isUsbLoggingEnabled()) { + enterUsbLoggingTerminalMode(); + return; + } +#endif + usb_ascii_session_default.request( + usb_serial_interface.getCompletedFrameCount()); + serviceUsbAsciiSessionDefault(); +} + static void serviceUsbTerminal() { #if COMPANION_FEATURE_USB_MOTA_SOURCE if (usb_mota_mode) { @@ -855,18 +859,13 @@ static void serviceUsbTerminal() { // 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 restores the build's normal USB mode: ASCII for - // Full Companion and Binary Companion for every other single-TTY build. + // 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, -#if defined(COMPANION_RADIO_FULL) true, -#else - false, -#endif #if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG) isNetworkTerminalActive() #else @@ -901,7 +900,6 @@ static void serviceUsbTerminal() { } #endif if (!the_mesh.isTerminalMode()) { -#if defined(COMPANION_RADIO_FULL) const mesh::UsbBinaryStartupProbe::Result probe_result = usb_binary_startup_probe.poll( millis(), usb_serial_interface.getCompletedFrameCount(), @@ -911,7 +909,6 @@ static void serviceUsbTerminal() { enterUsbTerminalMode(); return; } -#endif if (usb_serial_interface.takeControlSequence()) { enterUsbTerminalMode(); #if COMPANION_FEATURE_USB_MOTA_SOURCE @@ -922,23 +919,7 @@ static void serviceUsbTerminal() { return; } -#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \ - && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT - // HWCDC has no DTR/open signal, and its bool operator is only a transient - // RX/TX activity heuristic. Do not interpret an idle interval as the host - // closing Full Companion's startup terminal. TCP handoff is separately - // arbitrated by hasObservableActiveUsbTerminalClient(), using actual input. -#else - if (isUsbTerminalDataConnected()) { - usb_terminal_disconnect_armed = true; - } else if (usb_terminal_disconnect_armed) { - leaveUsbTerminalMode(false); - return; - } -#endif - -#if defined(COMPANION_RADIO_FULL) - // Full Companion boots as a useful ASCII terminal. MeshCLI's first framed + // Companion boots as a useful ASCII terminal. MeshCLI's first framed // command begins with '<'; hand that byte over untouched at an empty prompt. // A malformed or accidental probe times out and restores the terminal. if (!usb_logging_terminal_mode @@ -950,7 +931,6 @@ static void serviceUsbTerminal() { usb_binary_startup_probe.start(millis(), frame_count); return; } -#endif Stream& usb_input = mesh::usbCompanionPort(); while (usb_input.available()) { @@ -981,7 +961,7 @@ static void serviceUsbTerminal() { #if COMPANION_FEATURE_USB_MOTA_SOURCE // motatool is deliberately text-first: `serve --serial` opens the port // and sends this command before its binary mOTA request/reply traffic. - // Full Companion now boots in ASCII, so transfer ownership directly + // USB Companion boots in ASCII, so transfer ownership directly // instead of letting the ordinary terminal command handler leave the // stream in line-oriented mode. if (strcmp(usb_terminal_line, USB_MOTA_START_TOKEN) == 0) { @@ -995,15 +975,10 @@ static void serviceUsbTerminal() { #if MESH_USB_LOGGING_AVAILABLE if (usb_logging_terminal_mode && !mesh::isUsbLoggingEnabled()) { -#if defined(COMPANION_RADIO_FULL) - // The command reply belongs to the Full Companion's normal ASCII + // The command reply belongs to the Companion's normal ASCII // session. A separate, observable mode switch is required before the // port accepts framed Binary Companion traffic again. usb_logging_terminal_mode = false; -#else - leaveUsbTerminalMode(true); - return; -#endif } #endif usbTerminalOutput().print("> "); @@ -1029,7 +1004,6 @@ static void serviceUsbTerminal() { } } -#if defined(COMPANION_RADIO_FULL) static void expireUsbBinaryStartupProbeBeforeDispatch() { const uint32_t now = millis(); if (!usb_binary_startup_probe.hasTimedOut(now)) return; @@ -1045,7 +1019,6 @@ static void expireUsbBinaryStartupProbeBeforeDispatch() { enterUsbTerminalMode(); } #endif -#endif #if defined(ENABLE_USB_INTERFACE) && (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)) static bool isNetworkTerminalActive() { @@ -1062,9 +1035,7 @@ bool MyMesh::beginStreamTerminal(Stream& output) { if (isAnyNetworkTerminalMode()) return false; const bool ascii_selected = isTerminalMode(); const bool input_idle = usb_terminal_line_len == 0 && !usb_terminal_discard_line -#if defined(COMPANION_RADIO_FULL) && !usb_binary_startup_probe.isActive() -#endif ; if (!browser_usb_handoff.begin(ascii_selected, hasObservableActiveUsbTerminalClient(), input_idle, @@ -2621,21 +2592,9 @@ void setup() { usb_serial_interface.setConnectedCheck([]() { return (bool)Serial; }); #endif interface_manager.addInterface(InterfaceType::USB, &usb_serial_interface); -#if MESH_USB_LOGGING_AVAILABLE - if (!mesh::hasDedicatedUsbLoggingPort() - && mesh::isUsbLoggingEnabled()) { - // Apply a saved single-TTY logging preference before the dispatcher can - // emit its first framed Companion response on this interface. - enterUsbLoggingTerminalMode(); - } -#endif -#if defined(COMPANION_RADIO_FULL) - if (!the_mesh.isTerminalMode()) { - // Full Companion's primary USB port is an ASCII CLI until a Companion - // client presents a valid binary frame. BLE/WiFi/Ethernet stay binary. - enterUsbTerminalMode(); - } -#endif + // Select ASCII before any framed dispatch, including non-Full USB builds. + // BLE/WiFi/Ethernet keep their existing Binary Companion transports. + beginUsbDefaultSession(); #endif // add ethernet interface @@ -2687,6 +2646,7 @@ void loop() { #endif #if defined(ENABLE_USB_INTERFACE) serviceUsbTerminalHostSessionReset(); + serviceUsbAsciiSessionDefault(); #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. @@ -2696,7 +2656,7 @@ void loop() { // serial-mOTA frame in this iteration. mesh::serviceUsbTerminalPort(); #endif -#if defined(ENABLE_USB_INTERFACE) && defined(COMPANION_RADIO_FULL) +#if defined(ENABLE_USB_INTERFACE) expireUsbBinaryStartupProbeBeforeDispatch(); #endif the_mesh.loop(); diff --git a/src/helpers/UsbAsciiBinarySwitch.h b/src/helpers/UsbAsciiBinarySwitch.h index 44ae39f5..baa84b75 100644 --- a/src/helpers/UsbAsciiBinarySwitch.h +++ b/src/helpers/UsbAsciiBinarySwitch.h @@ -215,7 +215,7 @@ public: } }; -// Coordinates the Full Companion USB startup handoff without consuming the +// Coordinates the Companion USB startup handoff without consuming the // '<' byte. ArduinoSerialInterface remains the only binary protocol parser. class UsbBinaryStartupProbe { public: @@ -271,6 +271,34 @@ public: } }; +// A new physical/data session starts in ASCII after the old transport has +// been cleaned. A network terminal may temporarily own the CLI; a complete +// USB frame received while it does is an explicit choice of Binary mode. +class UsbAsciiSessionDefault { + bool _pending = false; + uint32_t _frame_count_at_reset = 0; + +public: + void request(uint32_t completed_frame_count) { + _pending = true; + _frame_count_at_reset = completed_frame_count; + } + + void cancel() { _pending = false; } + + bool shouldRestore(bool transport_ready, bool network_terminal_active, + uint32_t completed_frame_count) { + if (!_pending) return false; + if (completed_frame_count != _frame_count_at_reset) { + _pending = false; + return false; + } + if (!transport_ready || network_terminal_active) return false; + _pending = false; + return true; + } +}; + // Tracks the narrow case where TCP temporarily borrows an idle startup ASCII // terminal. A complete USB Binary frame during the TCP session wins ownership, // so closing TCP must not force that USB client back to ASCII. diff --git a/test/test_companion_usb_default.py b/test/test_companion_usb_default.py new file mode 100644 index 00000000..749b3e62 --- /dev/null +++ b/test/test_companion_usb_default.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Execute the firmware's startup and host-reset functions on each USB type.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +MAIN = (ROOT / "examples/companion_radio/main.cpp").read_text() + + +def function(signature): + start = MAIN.index(signature) + end = MAIN.index("{", start) + 1 + depth = 1 + while depth: + depth += (MAIN[end] == "{") - (MAIN[end] == "}") + end += 1 + return MAIN[start:end] + + +HARNESS = r''' +#include +#include +#include +#include "helpers/UsbAsciiBinarySwitch.h" +#define COMPANION_FEATURE_USB_MOTA_SOURCE 1 +#define COMPANION_FEATURE_NETWORK_TERMINAL 1 +#define MESH_USB_LOGGING_AVAILABLE 1 +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 uint32_t millis() { return clock_ms; } +struct Mesh { + bool terminal = false, stale_input = true; + bool isTerminalMode() const { return terminal; } + void resetUsbHostSessionInput() { stale_input = false; } +} the_mesh; +struct SerialInterface { + bool passthrough = false, old_frame = true; + uint32_t completed = 7; + void setPassthroughMode(bool value) { passthrough = value; } + 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_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; +static unsigned line_length = 5; +static mesh::UsbBinaryStartupProbe usb_binary_startup_probe; +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; +namespace mesh { +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; } +} +static bool isUsbTerminalDataConnected() { return data_connected; } +static bool isNetworkTerminalActive() { return network_active; } +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() { + assert(transport_ready && !network_active); + ++entered_ascii; + the_mesh.terminal = true; + usb_serial_interface.passthrough = true; + usb_binary_startup_probe.cancel(); + usb_ascii_session_default.cancel(); + usb_logging_terminal_mode = false; +} +static void enterUsbLoggingTerminalMode() { + enterUsbTerminalMode(); + usb_logging_terminal_mode = true; +} +@FUNCTIONS@ +static void service() { + serviceUsbTerminalHostSessionReset(); + serviceUsbAsciiSessionDefault(); +} +static void boundary(bool event = true) { + reset_event = event; + data_connected = false; + service(); +} +int main() { + // No Full macro is required: ordinary USB Companions also boot in ASCII. + beginUsbDefaultSession(); + assert(the_mesh.terminal && usb_serial_interface.passthrough); + service(); // observe the open host before it can close + for (bool binary : {false, true}) { + the_mesh.terminal = !binary; + usb_serial_interface.passthrough = !binary; + the_mesh.stale_input = true; + usb_serial_interface.old_frame = true; + line_length = 5; + usb_binary_startup_probe.start(clock_ms, usb_serial_interface.completed); + boundary(); + assert(the_mesh.terminal && usb_serial_interface.passthrough); + assert(!the_mesh.stale_input && !usb_serial_interface.old_frame); + assert(line_length == 0 && !usb_binary_startup_probe.isActive()); + data_connected = true; + service(); + } + // Close/reopen can happen entirely between polls; the generation event wins. + the_mesh.terminal = false; + reset_event = true; + data_connected = true; + service(); + assert(the_mesh.terminal); + // Do not expose ASCII while a native USB queue is still being purged. + transport_ready = false; + the_mesh.terminal = false; + boundary(); + assert(!the_mesh.terminal && usb_terminal_host_reset_completion_pending); + transport_ready = true; + clock_ms += USB_TRANSPORT_RESET_RETRY_MS; + service(); + assert(the_mesh.terminal && !usb_terminal_host_reset_completion_pending); + // A reset ends mOTA's exclusive USB attachment, but leaves network CLI alone. + usb_mota_mode = true; + the_mesh.terminal = false; + network_active = true; + boundary(); + assert(!usb_mota_mode && !the_mesh.terminal); + network_active = false; + service(); + assert(the_mesh.terminal); + // A fresh binary client during a network session keeps its selected mode. + the_mesh.terminal = false; + network_active = true; + boundary(); + ++usb_serial_interface.completed; + service(); + network_active = false; + service(); + assert(!the_mesh.terminal); +#if defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) || MESH_ESP32_TINYUSB_NONBLOCKING + // DTR fallback also works from Binary when no owner-task event is available. + data_connected = true; + service(); + boundary(false); + assert(the_mesh.terminal); +#endif + logging_enabled = true; + beginUsbDefaultSession(); + assert(the_mesh.terminal && usb_logging_terminal_mode); + dedicated_logging = true; + beginUsbDefaultSession(); + assert(the_mesh.terminal && !usb_logging_terminal_mode); + assert(cancelled_routes >= 6); +} +''' + + +class CompanionUsbDefaultTest(unittest.TestCase): + def test_startup_and_reconnection_for_each_usb_transport(self): + functions = "\n".join(function("static void " + name + "(") for name in ( + "resetUsbTerminalHostSession", "serviceUsbTerminalHostSessionReset", + "serviceUsbAsciiSessionDefault", "beginUsbDefaultSession")) + profiles = { + "nrf52": ["NRF52_PLATFORM=1"], + "esp32-tinyusb": ["ESP32=1", "ARDUINO_USB_MODE=0", "ARDUINO_USB_CDC_ON_BOOT=1", "MESH_ESP32_TINYUSB_NONBLOCKING=1"], + "esp32-hwcdc": ["ESP32=1", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=1"], + "rp2040": ["RP2040_PLATFORM=1"], + } + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "usb-default.cpp" + source.write_text(HARNESS.replace("@FUNCTIONS@", functions)) + for name, defines in profiles.items(): + for full in (False, True): + with self.subTest(transport=name, full=full): + binary = Path(directory) / (name + str(full)) + flags = ["-D" + flag for flag in defines] + if full: + flags.append("-DCOMPANION_RADIO_FULL=1") + subprocess.run(["g++", "-std=c++17", "-I", str(ROOT / "src"), + *flags, str(source), "-o", str(binary)], check=True) + subprocess.run([str(binary)], check=True) + + def test_startup_and_reconnect_selection_run_before_binary_dispatch(self): + setup = function("void setup() {") + self.assertIn("beginUsbDefaultSession();", setup) + 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("expireUsbBinaryStartupProbeBeforeDispatch();"), loop.index("the_mesh.loop();")) + self.assertNotIn("COMPANION_RADIO_FULL", MAIN) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_esp32_usb_serial_hygiene.py b/test/test_esp32_usb_serial_hygiene.py index 5806d926..2331fec0 100644 --- a/test/test_esp32_usb_serial_hygiene.py +++ b/test/test_esp32_usb_serial_hygiene.py @@ -104,14 +104,14 @@ class Esp32UsbSerialHygieneTest(unittest.TestCase): ) self.assertIn("usb_terminal_host_reset_completion_pending", service) self.assertIn("USB_TRANSPORT_RESET_RETRY_MS", service) - self.assertIn("resetUsbTerminalHostSession(true);", service) + self.assertIn("resetUsbTerminalHostSession();", service) self.assertIn("cancelUsbSerialOperations();", reset) self.assertIn("usb_serial_interface.resetSessionState();", reset) self.assertIn("if (!mesh::resetUsbCompanionTransport())", reset) self.assertIn("usb_terminal_host_reset_completion_pending = true", reset) self.assertNotIn("board.reboot()", reset) - self.assertIn("if (preserve_ascii_terminal)", reset) - self.assertIn("the_mesh.resetTerminalSession();", reset) + self.assertIn("usb_ascii_session_default.request(", reset) + self.assertIn("leaveUsbTerminalMode(false);", reset) logging = source("src/helpers/UsbLogging.cpp") helper_start = logging.index("static bool detachEsp32HwcdcPads()") diff --git a/test/test_nrf52_usb_logging_contract.py b/test/test_nrf52_usb_logging_contract.py index ee96d37a..914a535b 100644 --- a/test/test_nrf52_usb_logging_contract.py +++ b/test/test_nrf52_usb_logging_contract.py @@ -503,9 +503,9 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase): ] self.assertIn("mesh::takeUsbTerminalSessionReset()", reset_service) self.assertIn("mesh::tryCompleteUsbTerminalSessionReset()", reset_service) - self.assertIn("resetUsbTerminalHostSession(false);", reset_service) + self.assertIn("resetUsbTerminalHostSession();", reset_service) self.assertLess( - reset_service.index("resetUsbTerminalHostSession(false);"), + reset_service.index("resetUsbTerminalHostSession();"), reset_service.index("mesh::tryCompleteUsbTerminalSessionReset()"), ) self.assertIn("leaveUsbMotaMode(false);", reset_helper) @@ -513,6 +513,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase): self.assertIn("usb_serial_interface.setPassthroughMode(false);", reset_helper) self.assertIn("clearUsbTerminalLine();", reset_helper) self.assertIn("usb_binary_startup_probe.cancel();", reset_helper) + self.assertIn("usb_ascii_session_default.request(", reset_helper) self.assertIn("usb_terminal_host_reset_completion_pending", reset_service) self.assertIn("cancelUsbSerialOperations();", reset_helper) usb_cancel = main[ 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 b3548f5c..f4f8a58e 100644 --- a/test/test_serial_mode_switch/test_serial_mode_switch.cpp +++ b/test/test_serial_mode_switch/test_serial_mode_switch.cpp @@ -770,6 +770,42 @@ TEST(SerialModeSwitch, UsbBinaryActivityWinsDuringTcpBorrow) { EXPECT_FALSE(handoff.shouldRestoreAscii(21)); } +TEST(SerialModeSwitch, UsbAsciiDefaultWaitsForCleanupAndNetworkOwner) { + mesh::UsbAsciiSessionDefault session; + session.request(10); + EXPECT_FALSE(session.shouldRestore(false, false, 10)); + EXPECT_FALSE(session.shouldRestore(true, true, 10)); + EXPECT_TRUE(session.shouldRestore(true, false, 10)); + EXPECT_FALSE(session.shouldRestore(true, false, 10)); +} + +TEST(SerialModeSwitch, FreshUsbSessionRestoresAsciiAfterAnEarlierBinaryClient) { + mesh::UsbAsciiSessionDefault session; + session.request(10); + EXPECT_TRUE(session.shouldRestore(true, false, 10)); + // Current-session Binary remains selected; a fresh boundary restores ASCII. + EXPECT_FALSE(session.shouldRestore(true, false, 15)); + session.request(15); + EXPECT_TRUE(session.shouldRestore(true, false, 15)); +} + +TEST(SerialModeSwitch, NewBinaryClientDuringNetworkOwnershipKeepsItsMode) { + mesh::UsbAsciiSessionDefault session; + session.request(10); + EXPECT_FALSE(session.shouldRestore(true, true, 10)); + EXPECT_FALSE(session.shouldRestore(true, true, 11)); + EXPECT_FALSE(session.shouldRestore(true, false, 11)); + session.request(11); + EXPECT_TRUE(session.shouldRestore(true, false, 11)); +} + +TEST(SerialModeSwitch, ManualAsciiEntryCancelsDeferredDefaultSelection) { + mesh::UsbAsciiSessionDefault session; + session.request(0); + session.cancel(); + EXPECT_FALSE(session.shouldRestore(true, false, 0)); +} + TEST(SerialModeSwitch, SingleTtyLoggingCannotStealNetworkTerminal) { using Action = mesh::UsbLoggingTerminalAction; EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(