Default all USB Companions to ASCII across host sessions

This commit is contained in:
mikecarper
2026-09-13 17:13:51 -07:00
parent 794cf15395
commit 0938da5ea6
13 changed files with 398 additions and 145 deletions
+5
View File
@@ -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: >-
+3 -3
View File
@@ -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.")));
}
+10 -7
View File
@@ -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:
+7 -2
View File
@@ -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.
+2 -1
View File
@@ -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
+24 -18
View File
@@ -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
+17 -11
View File
@@ -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
+57 -97
View File
@@ -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();
+29 -1
View File
@@ -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.
+202
View File
@@ -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 <cassert>
#include <cstdint>
#include <initializer_list>
#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()
+3 -3
View File
@@ -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()")
+3 -2
View File
@@ -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[
@@ -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(