Fix Companion USB clients and shared-port logging

This commit is contained in:
mikecarper committed 2026-09-14 13:36:17 -07:00
1 parent bd30388436
commit b5440c75c7
14 files changed
+731 -47

No files matched your search

+1
View File
@@ -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
+117
View File
@@ -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.
+30 -14
View File
@@ -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
+8
View File
@@ -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
+5 -2
View File
@@ -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;
}
+5 -1
View File
@@ -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;
+25 -5
View File
@@ -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.
+43 -16
View File
@@ -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<uint32_t> primary_usb_reset_generation{0};
static std::atomic<uint32_t> primary_usb_allowed_generation{0};
static std::atomic<bool> primary_usb_line_state_dtr{false};
static std::atomic<uint32_t> primary_usb_rx_generation{UINT32_MAX};
static constexpr uint32_t primary_usb_session_settle_millis = 8;
static std::atomic<uint32_t> 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)
+8 -1
View File
@@ -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();
+7 -1
View File
@@ -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);
}
'''
+1 -1
View File
@@ -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
+320
View File
@@ -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 <atomic>
#include <cassert>
#include <cstdint>
#include <cstddef>
#include <algorithm>
#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<void*>(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 <cassert>
#include <cstring>
#include <string>
#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 <cassert>
#include <cstdint>
#include <cstring>
#include <cstdarg>
#include <cstdio>
#include <string>
#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<const uint8_t*>(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<const char*>(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<const char*>(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<uint32_t> 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()
+4 -6
View File
@@ -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(
+157
View File
@@ -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))