mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 09:52:23 +00:00
Fix Companion USB clients and shared-port logging
This commit is contained in:
1 parent
bd30388436
commit
b5440c75c7
14 files changed
+731
-47
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
'''
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user