Fix USB logging ownership, persistence, and reconnect safety

This commit is contained in:
mikecarper committed 2026-10-03 14:45:57 -07:00
1 parent c1a533572a
commit 91d533bfff
32 files changed
+1543 -140

No files matched your search

+7
View File
@@ -195,8 +195,15 @@ jobs:
- name: Verify Companion ASCII defaults and USB session cleanup
run: |
python3 -B test/test_companion_usb_default.py
python3 -B test/test_common_cli_logging_transaction.py
python3 -B test/test_usb_diagnostic_logging.py
python3 -B test/test_nrf52_usb_client.py
python3 -B test/test_nrf52_usb_lifecycle.py
python3 -B test/test_nrf52_usb_logging_contract.py
python3 -B test/test_esp32_usb_serial_hygiene.py
python3 -B test/test_esp32_tinyusb_nonblocking.py
python3 -B test/test_esp32_tinyusb_cooperative_output.py
python3 -B test/test_esp32_tinyusb_role_hygiene.py
python3 -B test/test_stm32_float_conversion.py
- name: Verify Companion contact persistence and OTA sleep guards
@@ -45,6 +45,9 @@ python test/test_esp32_tinyusb_role_hygiene.py
python test/test_esp32_tinyusb_cooperative_output.py
python test/test_esp32_usb_serial_hygiene.py
python test/test_nrf52_usb_logging_contract.py
python test/test_nrf52_usb_lifecycle.py
python test/test_common_cli_logging_transaction.py
python test/test_usb_diagnostic_logging.py
pio test -e native -f test_nrf52_debug_output -f test_serial_packet_log -f test_serial_mode_switch -f test_mesh_tables
```
@@ -72,3 +75,31 @@ logins with the relay running, paused, and stopped, including a host that leaves
USB open without reading. Check LoRa recovery separately from USB OUT recovery;
fixing transmit backpressure does not prove an unrelated OUT endpoint fault is
resolved. Do not erase or repartition the radio as part of this test.
## USB logging ownership and session recovery
On a single-port Companion, enabling USB logging parks framed USB traffic
before opening the diagnostic gate. A TCP/browser terminal retains its CLI
ownership; USB is log-only during that session. Disabling logging restores USB
traffic without accepting bytes typed during the log-only interval. A dedicated
nRF52 logging port does not park the separate Companion port.
Ethernet and enabled MQTT diagnostics honor the runtime logging gate and use
the bounded USB facade. Infrastructure logging setters commit preferences
before changing live output; failed saves leave previous settings unchanged.
A successful save with a failed MQTT runtime change reports those two outcomes
separately.
ESP32 native-USB repeater and room ACL listings retain short-write suffixes and
advance one row per service pass, using their existing output buffer. Reconnect
cancels the old listing. The listing is a bounded live view, not an immutable
snapshot of clients changed during output.
nRF52 FIFO cleanup also checks for an already-armed IN packet. If one exists
at a session boundary (including a pending zero-length packet), both USB ports
briefly detach and re-enumerate: a FIFO clear alone cannot recall endpoint RAM.
Reattachment waits for the local unplug event and a 20 ms minimum interval,
without blocking the mesh loop. Dedicated CDC descriptors are added while
detached even if the host has not yet completed enumeration. These recovery
paths have native regression coverage; real host/board timing still requires
hardware qualification.
+7 -4
View File
@@ -2607,7 +2607,7 @@ void MyMesh::begin(bool has_display, bool radio_available,
}
#if MESH_USB_LOGGING_AVAILABLE
const bool usb_logging_enabled = _prefs.usb_logging_enabled != 0;
mesh::setUsbLoggingEnabled(usb_logging_enabled);
applyUsbLoggingState(usb_logging_enabled);
if (!mesh::saveUsbLoggingBootPreference(usb_logging_enabled)) {
MESH_DEBUG_PRINTLN("Unable to save next-boot USB logging interface state");
}
@@ -3259,7 +3259,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
snprintf(reply, reply_size, "Error: use set usb.logging <on|off> [reboot]");
} else {
_prefs.usb_logging_enabled = enabled ? 1 : 0;
mesh::setUsbLoggingEnabled(enabled);
applyUsbLoggingState(enabled);
if (!savePrefs()) {
snprintf(reply, reply_size, "Error: USB logging changed for this boot but save failed");
} else if (!mesh::saveUsbLoggingBootPreference(enabled)) {
@@ -3777,11 +3777,14 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
_mqtt_enabled = wifi;
if (!wifi) stopMQTT();
_prefs.usb_logging_enabled = usb ? 1 : 0;
mesh::setUsbLoggingEnabled(usb);
applyUsbLoggingState(usb);
if (!savePrefs() || !mesh::saveUsbLoggingBootPreference(usb)) {
snprintf(reply, reply_size, "Error: logging output changed but USB setting could not be saved");
} else {
snprintf(reply, reply_size, "OK - logging.output %s (saved)", command + 19);
// USB ownership changes can clear the terminal's input buffer while
// this command is executing. Use parsed values, not that borrowed text.
snprintf(reply, reply_size, "OK - logging.output %s (saved)",
mesh::cli::loggingOutputName(usb, wifi));
}
}
return true;
+1
View File
@@ -301,6 +301,7 @@ public:
// setup/status commands here; Full builds add TempRadio and OTA commands.
bool handleLocalControlCommand(const char* command, char* reply,
size_t reply_size);
void applyUsbLoggingState(bool enabled);
bool handleTxRoutingCommand(const char* command, char* reply, size_t reply_size);
int getRecentlyHeard(AdvertPath dest[], int max_num);
+110 -59
View File
@@ -624,6 +624,8 @@ static size_t usb_terminal_line_len = 0;
static bool usb_terminal_discard_line = false;
static bool usb_host_session_connected = false;
static bool usb_logging_terminal_mode = false;
static bool usb_logging_network_parked = false;
static bool usb_protocol_initialized = false;
static mesh::UsbBinaryStartupProbe usb_binary_startup_probe;
static mesh::UsbAsciiSessionDefault usb_ascii_session_default;
#if COMPANION_FEATURE_USB_MOTA_SOURCE
@@ -772,7 +774,10 @@ static void leaveUsbTerminalMode(bool acknowledge) {
mesh::discardUsbTerminalOutput();
}
the_mesh.exitTerminalMode();
usb_serial_interface.setPassthroughMode(false);
// A network handoff can leave the USB CLI while diagnostics remain enabled.
// Do not briefly reopen the framed transport on that same logging stream.
usb_serial_interface.setPassthroughMode(
!mesh::hasDedicatedUsbLoggingPort() && mesh::isUsbLoggingEnabled());
clearUsbTerminalLine();
usb_terminal_discard_line = false;
usb_logging_terminal_mode = false;
@@ -1011,69 +1016,16 @@ static void beginUsbDefaultSession() {
serviceUsbAsciiSessionDefault();
}
static void serviceUsbLoggingOwnership(bool logging_enabled);
static void serviceUsbTerminal() {
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());
if (usb_logging_network_parked) return;
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (usb_mota_mode) {
#if MESH_USB_LOGGING_AVAILABLE
// A single-TTY logger and framed USB mOTA cannot share the primary USB
// stream. A remote logging change can arrive while mOTA owns USB, so stop
// the transfer before its next binary exchange and return to ASCII.
if (!mesh::hasDedicatedUsbLoggingPort()
&& mesh::isUsbLoggingEnabled()) {
leaveUsbMotaMode(false);
enterUsbLoggingTerminalMode();
usbTerminalOutput().print(
"\r\nUSB mOTA stopped: USB logging owns this port\r\n> ");
return;
}
#endif
serviceUsbMota();
return;
}
#endif
// A saved logging-on preference makes the one available TTY behave like a
// logging repeater: plaintext diagnostics plus an input-capable CLI. Put the
// Companion interface into passthrough before it can mix framed traffic with
// logs. An active TCP terminal owns the role CLI, so logging must not reclaim
// it. Turning logging off retains the ordinary ASCII terminal.
#if MESH_USB_LOGGING_AVAILABLE
const mesh::UsbLoggingTerminalAction logging_action =
mesh::selectUsbLoggingTerminalAction(
mesh::hasDedicatedUsbLoggingPort(), mesh::isUsbLoggingEnabled(),
the_mesh.isTerminalMode(), usb_logging_terminal_mode,
true,
#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)
isNetworkTerminalActive()
#else
false
#endif
);
switch (logging_action) {
case mesh::UsbLoggingTerminalAction::CLAIM_USB:
if (!the_mesh.isTerminalMode()) {
enterUsbLoggingTerminalMode();
return;
}
usb_logging_terminal_mode = true;
break;
case mesh::UsbLoggingTerminalAction::RETURN_TO_BINARY:
leaveUsbTerminalMode(true);
return;
case mesh::UsbLoggingTerminalAction::KEEP_ASCII:
// Logging may also be disabled over BLE/WiFi. Stop treating this session
// as the logging terminal, but keep the ordinary ASCII terminal active;
// do not silently change the USB protocol underneath an idle host. A
// remote mode change also cancels any partially typed USB command before
// drawing a fresh prompt.
usb_logging_terminal_mode = false;
clearUsbTerminalLine();
usb_terminal_discard_line = false;
usbTerminalOutput().print(
"\r\nUSB logging off; ASCII terminal active\r\n> ");
break;
case mesh::UsbLoggingTerminalAction::NO_ACTION:
break;
}
#endif
if (!the_mesh.isTerminalMode()) {
const mesh::UsbBinaryStartupProbe::Result probe_result =
@@ -1200,6 +1152,91 @@ static void serviceUsbTerminal() {
}
}
static void serviceUsbLoggingOwnership(bool logging_enabled) {
// A saved logging-on preference makes the one available TTY behave like a
// logging repeater: plaintext diagnostics plus an input-capable CLI. Put the
// Companion interface into passthrough before it can mix framed traffic with
// logs. An active TCP terminal owns the role CLI, so logging must not reclaim
// it. Turning logging off retains the ordinary ASCII terminal.
#if MESH_USB_LOGGING_AVAILABLE
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (usb_mota_mode) {
if (mesh::hasDedicatedUsbLoggingPort() || !logging_enabled) return;
// Run before framed dispatch, not just the later terminal-input service.
leaveUsbMotaMode(false);
}
#endif
const mesh::UsbLoggingTerminalAction logging_action =
mesh::selectUsbLoggingTerminalAction(
mesh::hasDedicatedUsbLoggingPort(), logging_enabled,
the_mesh.isTerminalMode(), usb_logging_terminal_mode,
true,
#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)
isNetworkTerminalActive()
#else
false
#endif
);
if (usb_logging_network_parked
&& logging_action != mesh::UsbLoggingTerminalAction::PARK_USB_FOR_NETWORK) {
// Bytes typed while USB was log-only belong to neither the new ASCII CLI
// nor a new Binary client. Bound the discard to the existing RX snapshot.
Stream& input = mesh::usbCompanionPort();
int pending = input.available();
while (pending-- > 0) input.read();
usb_logging_network_parked = false;
usb_serial_interface.setPassthroughMode(the_mesh.isTerminalMode());
}
switch (logging_action) {
case mesh::UsbLoggingTerminalAction::PARK_USB_FOR_NETWORK:
if (!usb_logging_network_parked
|| !usb_serial_interface.isPassthroughMode()) {
usb_binary_startup_probe.cancel();
cancelUsbSerialOperations();
mesh::discardUsbTerminalOutput();
usb_serial_interface.setPassthroughMode(true);
clearUsbTerminalLine();
usb_terminal_discard_line = false;
}
usb_logging_network_parked = true;
return; // Do not move the role CLI away from TCP/browser.
case mesh::UsbLoggingTerminalAction::CLAIM_USB:
usb_logging_network_parked = false;
if (!the_mesh.isTerminalMode()) {
enterUsbLoggingTerminalMode();
return;
}
usb_logging_terminal_mode = true;
break;
case mesh::UsbLoggingTerminalAction::RETURN_TO_BINARY:
leaveUsbTerminalMode(true);
return;
case mesh::UsbLoggingTerminalAction::KEEP_ASCII:
// Logging may also be disabled over BLE/WiFi. Stop treating this session
// as the logging terminal, but keep the ordinary ASCII terminal active;
// do not silently change the USB protocol underneath an idle host. A
// remote mode change also cancels any partially typed USB command before
// drawing a fresh prompt.
usb_logging_terminal_mode = false;
clearUsbTerminalLine();
usb_terminal_discard_line = false;
usbTerminalOutput().print(
"\r\nUSB logging off; ASCII terminal active\r\n> ");
break;
case mesh::UsbLoggingTerminalAction::NO_ACTION:
break;
}
#endif
}
void MyMesh::applyUsbLoggingState(bool enabled) {
// Park the framed transport before opening the diagnostic gate, including
// TCP/browser commands which execute outside the mesh dispatcher.
if (enabled && usb_protocol_initialized) serviceUsbLoggingOwnership(true);
mesh::setUsbLoggingEnabled(enabled);
if (usb_protocol_initialized) serviceUsbLoggingOwnership(enabled);
}
static void expireUsbBinaryStartupProbeBeforeDispatch() {
const uint32_t now = millis();
if (!usb_binary_startup_probe.hasTimedOut(now)) return;
@@ -1216,6 +1253,12 @@ static void expireUsbBinaryStartupProbeBeforeDispatch() {
}
#endif
#if !defined(ENABLE_USB_INTERFACE)
void MyMesh::applyUsbLoggingState(bool enabled) {
mesh::setUsbLoggingEnabled(enabled);
}
#endif
#if defined(ENABLE_USB_INTERFACE) && (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG))
static bool isNetworkTerminalActive() {
return the_mesh.isAnyNetworkTerminalMode();
@@ -1237,7 +1280,10 @@ bool MyMesh::beginStreamTerminal(Stream& output) {
hasObservableActiveUsbTerminalClient(), input_idle,
usb_serial_interface.getCompletedFrameCount())) return false;
if (ascii_selected) leaveUsbTerminalMode(false);
if (enterNetworkTerminalMode(output)) return true;
if (enterNetworkTerminalMode(output)) {
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());
return true;
}
if (browser_usb_handoff.shouldRestoreAscii(
usb_serial_interface.getCompletedFrameCount())) enterUsbTerminalMode();
return false;
@@ -1937,6 +1983,9 @@ void halt() {
"ERROR: another client currently owns the Full Companion terminal\r\n");
ota_console_client.stop();
}
#if defined(ENABLE_USB_INTERFACE)
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());
#endif
#else
ota_console_client.print("Local CLI and OTA console - type `version` or `ota status`\r\n> ");
#endif
@@ -3248,6 +3297,7 @@ void setup() {
usb_serial_interface.setConnectedCheck([]() { return (bool)Serial; });
#endif
interface_manager.addInterface(InterfaceType::USB, &usb_serial_interface);
usb_protocol_initialized = true;
// Select ASCII before any framed dispatch, including non-Full USB builds.
// BLE/WiFi/Ethernet keep their existing Binary Companion transports.
beginUsbDefaultSession();
@@ -3317,6 +3367,7 @@ void loop() {
#if defined(ENABLE_USB_INTERFACE)
serviceUsbTerminalHostSessionReset();
serviceUsbAsciiSessionDefault();
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled()); // Before framed dispatch.
#endif
// Identify nRF52 CDC 1 when a terminal opens it. Doing this on the connection
// edge avoids losing the marker before the host has opened the port.
+67 -12
View File
@@ -5092,9 +5092,34 @@ void MyMesh::dumpLogFile() {
#endif
}
void MyMesh::printAclSerial() {
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
if (hasPendingSerialOutput()) {
mesh::usbConsolePort().printf("Err - USB output busy\r\n");
return;
}
// Reuse the bounded file-output buffer. Command admission prevents overlapping
// USB jobs, and session reset cancels the retained row before a new host reads.
serial_acl_next = 0;
serial_acl_count = acl.getNumClients();
serial_acl_header = true;
serial_log_pending_size = 0;
#else
mesh::usbConsolePort().printf("ACL:\r\n");
for (int i = 0; i < acl.getNumClients(); i++) {
auto c = acl.getClientByIdx(i);
if (c->permissions == 0) continue;
char public_key[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(public_key, c->id.pub_key, PUB_KEY_SIZE);
mesh::usbConsolePort().printf("%02X %s\n", c->permissions, public_key);
}
#endif
}
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
bool MyMesh::hasPendingSerialOutput() const {
return serial_log_active || serial_log_eof_pending || serial_recent_next >= 0;
return serial_log_active || serial_log_eof_pending || serial_recent_next >= 0
|| serial_acl_next >= 0;
}
void MyMesh::cancelPendingSerialOutput() {
@@ -5104,6 +5129,9 @@ void MyMesh::cancelPendingSerialOutput() {
serial_log_skip_line = false;
serial_log_remaining = 0;
serial_log_pending_size = 0;
serial_acl_next = -1;
serial_acl_count = 0;
serial_acl_header = false;
serial_recent_next = -1;
serial_recent_count = 0;
serial_recent_header = false;
@@ -5114,6 +5142,43 @@ void MyMesh::cancelPendingSerialOutput() {
void MyMesh::servicePendingSerialOutput() {
Stream& console = mesh::usbConsolePort();
if (serial_acl_next >= 0) {
if (serial_log_pending_size == 0) {
if (serial_acl_header) {
static const char header[] = "ACL:\r\n";
memcpy(serial_log_pending, header, sizeof(header) - 1);
serial_log_pending_size = sizeof(header) - 1;
serial_acl_header = false;
} else {
// At most one row per pass; deleted entries can be skipped without
// allocating a table-sized response or waiting for USB FIFO space.
while (serial_acl_next < serial_acl_count
&& serial_acl_next < acl.getNumClients()) {
auto* client = acl.getClientByIdx(serial_acl_next++);
if (!client->permissions) continue;
char key[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE);
serial_log_pending_size = snprintf(serial_log_pending,
sizeof(serial_log_pending), "%02X %s\n", client->permissions, key);
break;
}
if (serial_log_pending_size == 0) {
serial_acl_next = -1;
return;
}
}
}
if (console.availableForWrite() < static_cast<int>(serial_log_pending_size)) return;
size_t written = console.write(
reinterpret_cast<const uint8_t*>(serial_log_pending), serial_log_pending_size);
if (written > serial_log_pending_size) written = serial_log_pending_size;
serial_log_pending_size -= written;
if (written > 0 && serial_log_pending_size > 0) {
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
}
return;
}
if (serial_recent_next >= 0) {
char record[64];
int length = 0;
@@ -12093,17 +12158,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
return;
}
#endif
mesh::usbConsolePort().printf("ACL:\r\n");
for (int i = 0; i < acl.getNumClients(); i++) {
auto c = acl.getClientByIdx(i);
if (c->permissions == 0) continue; // skip deleted (or guest) entries
// Admit each line together so concurrent USB diagnostics cannot split
// a public key or insert text between its permission prefix and value.
char public_key[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(public_key, c->id.pub_key, PUB_KEY_SIZE);
mesh::usbConsolePort().printf("%02X %s\n", c->permissions, public_key);
}
printAclSerial();
reply[0] = 0;
} else if (handleClientPathCommand(sender, command, reply)) {
return;
+4
View File
@@ -322,6 +322,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
bool serial_log_active = false;
bool serial_log_eof_pending = false;
bool serial_log_skip_line = false;
int serial_acl_next = -1;
int serial_acl_count = 0;
bool serial_acl_header = false;
int serial_recent_next = -1;
int serial_recent_count = 0;
bool serial_recent_header = false;
@@ -1073,6 +1076,7 @@ public:
}
void dumpLogFile() override;
void printAclSerial();
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
// Large local-only replies advance between radio service passes.
bool hasPendingSerialOutput() const;
+65 -12
View File
@@ -1669,9 +1669,33 @@ void MyMesh::dumpLogFile() {
#endif
}
void MyMesh::printAclSerial() {
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
if (hasPendingSerialOutput()) {
mesh::usbConsolePort().printf("Err - USB output busy\r\n");
return;
}
// Reuse the bounded file-output buffer. Command admission prevents overlapping
// USB jobs, and session reset cancels the retained row before a new host reads.
serial_acl_next = 0;
serial_acl_count = acl.getNumClients();
serial_acl_header = true;
serial_log_pending_size = 0;
#else
mesh::usbConsolePort().printf("ACL:\r\n");
for (int i = 0; i < acl.getNumClients(); i++) {
auto c = acl.getClientByIdx(i);
if (c->permissions == 0) continue;
char public_key[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(public_key, c->id.pub_key, PUB_KEY_SIZE);
mesh::usbConsolePort().printf("%02X %s\n", c->permissions, public_key);
}
#endif
}
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
bool MyMesh::hasPendingSerialOutput() const {
return serial_log_active || serial_log_eof_pending;
return serial_log_active || serial_log_eof_pending || serial_acl_next >= 0;
}
void MyMesh::cancelPendingSerialOutput() {
@@ -1681,10 +1705,49 @@ void MyMesh::cancelPendingSerialOutput() {
serial_log_skip_line = false;
serial_log_remaining = 0;
serial_log_pending_size = 0;
serial_acl_next = -1;
serial_acl_count = 0;
serial_acl_header = false;
}
void MyMesh::servicePendingSerialOutput() {
Stream& console = mesh::usbConsolePort();
if (serial_acl_next >= 0) {
if (serial_log_pending_size == 0) {
if (serial_acl_header) {
static const char header[] = "ACL:\r\n";
memcpy(serial_log_pending, header, sizeof(header) - 1);
serial_log_pending_size = sizeof(header) - 1;
serial_acl_header = false;
} else {
// At most one row per pass; deleted entries can be skipped without
// allocating a table-sized response or waiting for USB FIFO space.
while (serial_acl_next < serial_acl_count
&& serial_acl_next < acl.getNumClients()) {
auto* client = acl.getClientByIdx(serial_acl_next++);
if (!client->permissions) continue;
char key[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE);
serial_log_pending_size = snprintf(serial_log_pending,
sizeof(serial_log_pending), "%02X %s\n", client->permissions, key);
break;
}
if (serial_log_pending_size == 0) {
serial_acl_next = -1;
return;
}
}
}
if (console.availableForWrite() < static_cast<int>(serial_log_pending_size)) return;
size_t written = console.write(
reinterpret_cast<const uint8_t*>(serial_log_pending), serial_log_pending_size);
if (written > serial_log_pending_size) written = serial_log_pending_size;
serial_log_pending_size -= written;
if (written > 0 && serial_log_pending_size > 0) {
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
}
return;
}
if (!serial_log_active) {
// CommonCLI's synchronous EOF is suppressed until the queued dump ends.
static const char eof[] = " -> EOF\r\n";
@@ -2440,17 +2503,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
return;
}
#endif
mesh::usbConsolePort().printf("ACL:\r\n");
for (int i = 0; i < acl.getNumClients(); i++) {
auto c = acl.getClientByIdx(i);
if (c->permissions == 0) continue; // skip deleted (or guest) entries
// Admit each line together so concurrent USB diagnostics cannot split
// a public key or insert text between its permission prefix and value.
char public_key[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(public_key, c->id.pub_key, PUB_KEY_SIZE);
mesh::usbConsolePort().printf("%02X %s\n", c->permissions, public_key);
}
printAclSerial();
reply[0] = 0;
#if defined(WITH_MQTT_NEIGHBORS)
} else if (memcmp(command, "discover.neighbors", 18) == 0) {
+4
View File
@@ -185,6 +185,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
bool serial_log_active = false;
bool serial_log_eof_pending = false;
bool serial_log_skip_line = false;
int serial_acl_next = -1;
int serial_acl_count = 0;
bool serial_acl_header = false;
#endif
uint32_t last_millis;
uint64_t uptime_millis;
@@ -488,6 +491,7 @@ public:
}
void dumpLogFile() override;
void printAclSerial();
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
// Large local-only replies advance between radio service passes.
bool hasPendingSerialOutput() const;
+60 -3
View File
@@ -76,9 +76,43 @@ PORT_INIT = """ if (usb_reg & POWER_USBREGSTATUS_VBUSDETECT_Msk) {
}
"""
# A FIFO clear does not cancel the packet already submitted to the controller.
# Expose only a read-only owner-state query; firmware uses the supported USB
# detach/re-enumeration path when a session boundary finds an armed IN packet.
# Do not invent an endpoint abort that would desynchronize bulk data toggles.
CDC_WRITE_CLEAR = """bool tud_cdc_n_write_clear(uint8_t itf) {
return tu_fifo_clear(&_cdcd_itf[itf].tx_ff);
}
"""
CDC_SESSION_QUERY = """
bool mesh_tud_cdc_n_tx_pending(uint8_t itf) {
if (itf >= CFG_TUD_CDC) return false;
uint8_t const ep_in = _cdcd_itf[itf].ep_in;
return ep_in != 0 && usbd_edpt_busy(0, ep_in);
}
"""
CDC_LOCAL_INCLUDE = '#include "cdc_device.h"'
CDC_ROOT_INCLUDE = '#include "class/cdc/cdc_device.h"'
NRF_DISCONNECT = """void dcd_disconnect(uint8_t rhport) {
(void) rhport;
NRF_USBD->USBPULLUP = 0;
// Disable Pull-up does not trigger Power USB Removed, in fact it have no
// impact on the USB Power status at all -> need to submit unplugged event to the stack.
dcd_event_bus_signal(0, DCD_EVENT_UNPLUGGED, false);
}
"""
def patched_source(source):
source = source.replace("\r\n", "\n")
# Session recovery waits for TinyUSB's owner to consume this explicit
# local unplug event. A physical VBUS edge is neither required nor assumed.
if source.count(NRF_DISCONNECT) != 1:
raise RuntimeError(
"nRF52 USB session fix: changed disconnect/unmount behavior; "
"review the framework update before building"
)
if READY in source and ATTACH in source and OLD_READY not in source and OLD_ATTACH not in source:
return source
if source.count(OLD_READY) != 1 or source.count(OLD_ATTACH) != 1:
@@ -101,17 +135,36 @@ def patched_port_source(source):
return source.replace(OLD_PORT_INIT, PORT_INIT)
def patched_cdc_source(source):
source = source.replace("\r\n", "\n")
if (source.count(CDC_SESSION_QUERY) == 1
and source.count(CDC_ROOT_INCLUDE) == 1
and CDC_LOCAL_INCLUDE not in source):
return source
if (source.count(CDC_WRITE_CLEAR) != 1 or source.count(CDC_LOCAL_INCLUDE) != 1
or CDC_SESSION_QUERY in source or CDC_ROOT_INCLUDE in source):
raise RuntimeError(
"nRF52 USB session fix: unrecognized TinyUSB CDC driver; review "
"the framework update before building (shared SDK was not modified)"
)
# Middleware relocates this source into BUILD_DIR. Its original sibling
# include no longer resolves there; the TinyUSB root already on CPPPATH
# provides a portable, SDK-local path (including cdc_device.h's siblings).
return source.replace(CDC_LOCAL_INCLUDE, CDC_ROOT_INCLUDE).replace(
CDC_WRITE_CLEAR, CDC_WRITE_CLEAR + CDC_SESSION_QUERY)
def replace_driver(build_env, node):
# SCons passes a not-yet-created VariantDir node, not the SDK source path.
source = Path(node.srcnode().get_abspath())
patcher = (patched_port_source if source.name == "Adafruit_TinyUSB_nrf.cpp"
else patched_source)
patcher = {"Adafruit_TinyUSB_nrf.cpp": patched_port_source,
"cdc_device.c": patched_cdc_source}.get(source.name, patched_source)
patched = patcher(source.read_text(encoding="utf-8"))
destination = Path(build_env.subst("$BUILD_DIR")) / "patched-nrf52-usb" / source.name
destination.parent.mkdir(parents=True, exist_ok=True)
if not destination.exists() or destination.read_text(encoding="utf-8") != patched:
destination.write_text(patched, encoding="utf-8")
print("nRF52 USB: build-local bounded READY/HFCLK fix enabled")
print("nRF52 USB: build-local bounded startup/session fix enabled")
return build_env.File(str(destination))
@@ -124,6 +177,10 @@ def install(build_env):
replace_driver,
"*Adafruit_TinyUSB_Arduino*src*arduino*ports*nrf*Adafruit_TinyUSB_nrf.cpp",
)
build_env.AddBuildMiddleware(
replace_driver,
"*Adafruit_TinyUSB_Arduino*src*class*cdc*cdc_device.c",
)
if "Import" in globals():
+19 -3
View File
@@ -3389,9 +3389,15 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
while (*value == ' ' || *value == '\t') value++;
bool enabled = false, reboot_if_needed = false;
if (mesh::cli::parseLoggingToggle(value, enabled, reboot_if_needed)) {
const uint8_t previous = _prefs->usb_logging_enabled;
_prefs->usb_logging_enabled = enabled ? 1 : 0;
if (!trySavePrefs()) {
_prefs->usb_logging_enabled = previous;
strcpy(reply, "Error: USB logging not saved; unchanged");
return;
}
// Commit before changing the stream's live protocol ownership.
mesh::setUsbLoggingEnabled(enabled);
savePrefs();
snprintf(reply, 160, "OK - USB logging %s (saved)", enabled ? "on" : "off");
} else {
strcpy(reply, "Error: usage set usb.logging on|off");
@@ -3410,11 +3416,21 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
strcpy(reply, "Error: usage set logging.output off|usb|wifi|both");
return;
}
const uint8_t previous_usb = _prefs->usb_logging_enabled;
const uint8_t previous_bridge = _prefs->bridge_enabled;
_prefs->usb_logging_enabled = usb_enabled ? 1 : 0;
_prefs->bridge_enabled = wifi_enabled ? 1 : 0;
if (!trySavePrefs()) {
_prefs->usb_logging_enabled = previous_usb;
_prefs->bridge_enabled = previous_bridge;
strcpy(reply, "Error: logging output not saved; unchanged");
return;
}
mesh::setUsbLoggingEnabled(usb_enabled);
_callbacks->setMqttBridgeState(wifi_enabled);
savePrefs();
if (!_callbacks->setMqttBridgeState(wifi_enabled)) {
strcpy(reply, "Error: logging output saved, but MQTT runtime change failed");
return;
}
snprintf(reply, 160, "OK - logging.output %s (saved)", value);
return;
}
+3 -2
View File
@@ -3,6 +3,7 @@
#if defined(ESP_PLATFORM)
#include "MQTTConnectionPolicy.h"
#include "UsbLogging.h"
#include "WifiPowerSavePolicy.h"
#include <atomic>
@@ -25,8 +26,8 @@ extern void tcpipInit();
#endif
// Same "MQTT: " prefix as MQTT_DEBUG_PRINTLN so existing log greps keep matching.
#if defined(MQTT_DEBUG)
#define NETWORK_DEBUG_PRINTLN(F, ...) do { if (Serial.availableForWrite() > 0) { Serial.printf("MQTT: " F "\n", ##__VA_ARGS__); } } while (0)
#if defined(MQTT_DEBUG) && MQTT_DEBUG
#define NETWORK_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("MQTT: " F "\n", ##__VA_ARGS__); } } while (0)
#else
#define NETWORK_DEBUG_PRINTLN(...) do {} while (0)
#endif
+8 -1
View File
@@ -333,6 +333,7 @@ public:
// must not be displaced just because USB logging is enabled.
enum class UsbLoggingTerminalAction : uint8_t {
NO_ACTION,
PARK_USB_FOR_NETWORK,
CLAIM_USB,
RETURN_TO_BINARY,
KEEP_ASCII,
@@ -342,9 +343,15 @@ inline UsbLoggingTerminalAction selectUsbLoggingTerminalAction(
bool has_dedicated_logging_port, bool logging_enabled,
bool usb_terminal_active, bool usb_logging_terminal_active,
bool full_companion, bool network_terminal_active) {
if (has_dedicated_logging_port || network_terminal_active) {
if (has_dedicated_logging_port) {
return UsbLoggingTerminalAction::NO_ACTION;
}
if (network_terminal_active) {
// Keep the network CLI owner, but never leave framed USB traffic enabled
// on the same CDC stream receiving ASCII diagnostics.
return logging_enabled ? UsbLoggingTerminalAction::PARK_USB_FOR_NETWORK
: UsbLoggingTerminalAction::NO_ACTION;
}
if (logging_enabled) {
return UsbLoggingTerminalAction::CLAIM_USB;
}
+73 -20
View File
@@ -25,6 +25,9 @@
(defined(ENABLE_USB_INTERFACE) || defined(OTA_FOLDER_SERIAL))
#define MESH_NRF52_PRIMARY_USB_NONBLOCKING 1
#include <Adafruit_TinyUSB.h>
// Build-local CDC query installed by scripts/nrf52_usb_power_fix.py. It does
// not clear/cancel an endpoint or change its USB bulk data-toggle state.
extern "C" bool mesh_tud_cdc_n_tx_pending(uint8_t instance);
#if !defined(CFG_TUD_CDC) || CFG_TUD_CDC < 1
#error "nRF52 USB Companion/mOTA requires CFG_TUD_CDC >= 1"
#endif
@@ -437,6 +440,43 @@ 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};
static std::atomic<bool> nrf52_usb_reenumeration_pending{false};
static std::atomic<uint32_t> nrf52_usb_reattach_after{0};
static constexpr uint32_t nrf52_usb_detach_millis = 20;
static void requestNrf52UsbSessionReenumeration() {
// Only an armed old-session packet needs this recovery. TinyUSB has no safe
// nRF52 per-endpoint abort API; FIFO clears cannot retract endpoint RAM. A
// physical detach guarantees that packet cannot enter a reopened handle,
// and the host's fresh enumeration resets endpoint/data-toggle state. Both
// CDC ports briefly re-enumerate whenever a close finds a pending transfer,
// including an unread packet or the stack's trailing zero-length packet.
if (nrf52_usb_reenumeration_pending.exchange(true,
std::memory_order_acq_rel)) return;
primary_usb_line_state_dtr.store(false, std::memory_order_release);
primary_usb_reset_generation.fetch_add(1, std::memory_order_acq_rel);
nrf52_usb_reattach_after.store(millis() + nrf52_usb_detach_millis,
std::memory_order_release);
TinyUSBDevice.detach();
}
static void clearNrf52UsbTxForSession(uint8_t instance) {
(void)tud_cdc_n_write_clear(instance);
if (mesh_tud_cdc_n_tx_pending(instance)) {
requestNrf52UsbSessionReenumeration();
}
}
static void serviceNrf52UsbSessionReenumeration() {
if (!nrf52_usb_reenumeration_pending.load(std::memory_order_acquire)
|| (int32_t)(millis() - nrf52_usb_reattach_after.load(
std::memory_order_acquire)) < 0
|| tud_mounted()) return;
// Waiting for unmount as well as the quiet interval ensures TinyUSB's owner
// has consumed the unplug event. Never reattach with its old class state.
TinyUSBDevice.attach();
nrf52_usb_reenumeration_pending.store(false, std::memory_order_release);
}
static uint32_t primaryUsbSessionGeneration() {
return primary_usb_reset_generation.load(std::memory_order_acquire);
@@ -444,7 +484,8 @@ static uint32_t primaryUsbSessionGeneration() {
static bool canAccessPrimaryUsbSession(void*) {
const uint32_t generation = primaryUsbSessionGeneration();
if (!tud_mounted()
if (nrf52_usb_reenumeration_pending.load(std::memory_order_acquire)
|| !tud_mounted()
|| primary_usb_allowed_generation.load(std::memory_order_acquire)
!= generation) return false;
@@ -469,9 +510,9 @@ static void endPrimaryUsbHostSession(bool clear_cdc_fifos) {
&& !(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
// class buffer, so the later final TX purge catches a racing old-session
// write. RX is purged synchronously below; bytes sent by a reopened host
// FIFO-only cleanup suffices when no old IN packet is armed; otherwise
// quarantine and physically re-enumerate rather than leaking that packet.
// RX is purged synchronously below; bytes sent by a reopened host
// during the settle interval remain queued until the gate reopens.
primary_usb_reset_settle_until.store(
millis() + primary_usb_session_settle_millis,
@@ -481,7 +522,7 @@ static void endPrimaryUsbHostSession(bool clear_cdc_fifos) {
// The line-state callback runs before TinyUSB resets the device. A device
// unmount/remount boundary has already reset these class FIFOs.
tud_cdc_n_read_flush(0);
(void)tud_cdc_n_write_clear(0);
clearNrf52UsbTxForSession(0);
}
}
#endif
@@ -498,6 +539,7 @@ static size_t writeTinyUsbCdcOnce(void* context, const uint8_t* data,
return 0;
}
#if defined(ENABLE_USB_INTERFACE)
if (nrf52_usb_reenumeration_pending.load(std::memory_order_acquire)) return 0;
if (instance == 0) {
if (!canAccessPrimaryUsbSession(nullptr)) return 0;
} else
@@ -641,7 +683,7 @@ static void restartDedicatedUsbLoggingHostSession() {
// identity marker after a rapid reopen.
dedicated_usb_logging_port_connected.store(false,
std::memory_order_release);
(void)tud_cdc_n_write_clear(1);
clearNrf52UsbTxForSession(1);
resetDedicatedUsbLoggingUsbTaskState();
dedicated_usb_logging_quiet_sofs =
dedicated_usb_logging_host_settle_sofs;
@@ -682,6 +724,12 @@ static void handleDedicatedUsbLoggingLineCoding() {
// 1 kHz also catches a close/reopen that occurs entirely between main-loop
// service calls.
void serviceDedicatedUsbLoggingFromUsbTask() {
if (nrf52_usb_reenumeration_pending.load(std::memory_order_acquire)) {
dedicated_usb_logging_port_connected.store(false,
std::memory_order_release);
resetDedicatedUsbLoggingUsbTaskState();
return;
}
const uint32_t reset_generation =
dedicated_usb_logging_reset_generation.load(std::memory_order_acquire);
if (reset_generation != dedicated_usb_logging_seen_reset_generation) {
@@ -708,9 +756,9 @@ void serviceDedicatedUsbLoggingFromUsbTask() {
if (dedicated_usb_logging_quiet_sofs > 0) {
dedicated_usb_logging_port_connected.store(
false, std::memory_order_release);
// Drop anything already in flight from the prior session and anything a
// producer raced into the owner queue before the callback gated it.
(void)tud_cdc_n_write_clear(1);
// Drop racing producer bytes. An armed old IN packet requires a physical
// detach, not another FIFO clear or an arbitrary number of quiet SOFs.
clearNrf52UsbTxForSession(1);
resetDedicatedUsbLoggingUsbTaskState();
--dedicated_usb_logging_quiet_sofs;
return;
@@ -851,23 +899,24 @@ void beginUsbLoggingPort() {
return;
}
// The core already started USB before setup. A host can have fetched the
// old descriptor even while mounted() is false. Detach BEFORE mutating the
// live configuration buffer, allow the owner to consume the unplug event,
// then always enumerate the complete descriptor (not just mounted hosts).
TinyUSBDevice.detach();
delay(10);
dedicated_usb_logging_port.begin(115200);
dedicated_usb_logging_port_configured = true;
dedicated_usb_logging_port_started = true;
// The nRF52 core starts TinyUSB before setup(). If the host completed
// enumeration in that small window, reconnect once so it reads the expanded
// two-CDC descriptor. Usually enumeration has not completed and no reconnect
// is needed.
if (TinyUSBDevice.mounted()) {
TinyUSBDevice.detach();
delay(10);
TinyUSBDevice.attach();
}
TinyUSBDevice.attach();
#endif
}
void serviceUsbLoggingPort() {
#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE)
serviceNrf52UsbSessionReenumeration();
#endif
#if MESH_ESP32_TINYUSB_NONBLOCKING
serviceEsp32TinyUsbPorts();
#elif MESH_ESP32_HWCDC_SESSION_GUARD
@@ -1214,7 +1263,7 @@ static void completePrimaryUsbSessionReset(void*) {
// the close/line-coding boundary was captured. Do not purge it again here:
// hosts such as meshcli send APP_START immediately after opening the port,
// while this generation gate is deliberately settling.
(void)tud_cdc_n_write_clear(0);
clearNrf52UsbTxForSession(0);
const uint32_t cleaned_generation =
primary_usb_terminal_taken_reset_generation;
primary_usb_allowed_generation.store(cleaned_generation,
@@ -1228,11 +1277,15 @@ bool tryCompleteUsbTerminalSessionReset() {
return esp32_tinyusb_clean_generation.load(std::memory_order_acquire)
== esp32_tinyusb_reset_generation.load(std::memory_order_acquire);
#elif defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE)
serviceNrf52UsbSessionReenumeration();
if (nrf52_usb_reenumeration_pending.load(std::memory_order_acquire)) return false;
const uint32_t settle_until =
primary_usb_reset_settle_until.load(std::memory_order_acquire);
if ((int32_t)(millis() - settle_until) < 0) return false;
return nonblocking_primary_usb_companion_port.tryRunExclusive(
const bool completed = nonblocking_primary_usb_companion_port.tryRunExclusive(
completePrimaryUsbSessionReset);
return completed
&& !nrf52_usb_reenumeration_pending.load(std::memory_order_acquire);
#elif MESH_ESP32_HWCDC_SESSION_GUARD
if (!esp32_hwcdc_cleanup_pending
&& esp32_hwcdc_allowed_generation.load(std::memory_order_acquire)
+21 -9
View File
@@ -6,6 +6,16 @@
#include <SPI.h>
#include <RAK13800_W5100S.h>
#include <helpers/nrf52/EthernetMac.h>
#include <helpers/UsbLogging.h>
// Ethernet startup runs on a FreeRTOS task, and client acceptance runs in the
// mesh loop. Never let either path wait on a CDC host which stopped reading.
// Keep diagnostics separate from functional replies on the Ethernet client.
#define ETHERNET_CLI_LOG(F, ...) do { \
if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { \
mesh::usbLoggingPort().printf("ETH: " F "\n", ##__VA_ARGS__); \
} \
} while (0)
#define PIN_SPI1_MISO (29)
#define PIN_SPI1_MOSI (30)
@@ -41,7 +51,7 @@ static bool ethernet_take_session_reset() {
static void ethernet_task(void* param) {
(void)param;
Serial.println("ETH: Initializing hardware");
ETHERNET_CLI_LOG("Initializing hardware");
// WB_IO2 (power enable) is already driven HIGH by early constructor
// in RAK4631Board.cpp to support POE boot.
// Skip hardware reset - the W5100S comes out of power-on reset cleanly,
@@ -54,30 +64,30 @@ static void ethernet_task(void* param) {
uint8_t mac[6];
generateEthernetMac(mac);
Serial.printf("ETH: MAC: %02X:%02X:%02X:%02X:%02X:%02X\n",
ETHERNET_CLI_LOG("MAC: %02X:%02X:%02X:%02X:%02X:%02X",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
// Retry loop: keep trying until we get an IP
while (!ethernet_running) {
Serial.println("ETH: Attempting DHCP...");
ETHERNET_CLI_LOG("Attempting DHCP...");
if (Ethernet.begin(mac, 10000, 2000) == 0) {
if (Ethernet.hardwareStatus() == EthernetNoHardware) {
Serial.println("ETH: Hardware not found, giving up");
ETHERNET_CLI_LOG("Hardware not found, giving up");
vTaskDelete(NULL);
return;
}
if (Ethernet.linkStatus() == LinkOFF) {
Serial.println("ETH: Cable not connected, will retry");
ETHERNET_CLI_LOG("Cable not connected, will retry");
} else {
Serial.println("ETH: DHCP failed, will retry");
ETHERNET_CLI_LOG("DHCP failed, will retry");
}
vTaskDelay(pdMS_TO_TICKS(ETHERNET_RETRY_INTERVAL_MS));
continue;
}
IPAddress ip = Ethernet.localIP();
Serial.printf("ETH: IP: %u.%u.%u.%u\n", ip[0], ip[1], ip[2], ip[3]);
Serial.printf("ETH: Listening on TCP port %d\n", ETHERNET_TCP_PORT);
ETHERNET_CLI_LOG("IP: %u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]);
ETHERNET_CLI_LOG("Listening on TCP port %d", ETHERNET_TCP_PORT);
ethernet_server.begin();
ethernet_running = true;
}
@@ -115,7 +125,7 @@ static void ethernet_check_client() {
ethernet_client = newClient;
ethernet_session_reset = true;
IPAddress ip = ethernet_client.remoteIP();
Serial.printf("ETH: Client connected from %u.%u.%u.%u\n", ip[0], ip[1], ip[2], ip[3]);
ETHERNET_CLI_LOG("Client connected from %u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]);
ethernet_client.println(ETHERNET_CLI_BANNER);
}
}
@@ -161,4 +171,6 @@ static void ethernet_send_reply(const char* reply) {
}
}
#undef ETHERNET_CLI_LOG
#endif // ETHERNET_ENABLED
+27
View File
@@ -2,6 +2,7 @@
#include <helpers/ClientACLCLI.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/LazyPersistence.h>
#include <algorithm>
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
@@ -220,6 +221,11 @@ static void actual_role_dispatch_handles_radio_and_local() {
CHECK(reply[0] == 0);
CHECK(mesh::console.output.find("ACL:") == 0);
CHECK(mesh::console.output.find(row(admin)) != std::string::npos);
std::string local_output = mesh::console.output;
local_output.erase(std::remove(local_output.begin(), local_output.end(), '\r'),
local_output.end());
CHECK(local_output == "ACL:\n" + row(admin) + "\n"
+ row(acl.getClientByIdx(1)) + "\n");
strcpy(command, "get acl");
char paged[] = "get acl 1";
mesh::console.output.clear();
@@ -227,6 +233,27 @@ static void actual_role_dispatch_handles_radio_and_local() {
CHECK(mesh::console.output.empty());
CHECK(reply == expected);
}
// Also exercise every role's local full-table listing, not just remote pages.
// The real extracted helper must emit every active key, skip deleted entries,
// and never include the secret fields stored alongside ACL public identities.
ClientACL full;
full.load(&fs, self);
std::string expected_local = "ACL:\n";
for (unsigned i = 0; i < MAX_CLIENTS; ++i) {
auto* client = add(full, i, i % 7 == 0 ? 0 : PERM_ACL_ADMIN);
if (client->permissions) expected_local += row(client) + "\n";
}
for (auto handler : {repeaterCommand, roomCommand, sensorCommand}) {
char command[] = "get acl", reply[160] = {};
mesh::console.output.clear();
handler(full, nullptr, 0, command, reply);
CHECK(reply[0] == 0);
std::string local_output = mesh::console.output;
local_output.erase(std::remove(local_output.begin(), local_output.end(), '\r'),
local_output.end());
CHECK(local_output == expected_local);
}
}
static void repeater_delegation_stays_denied() {
+11
View File
@@ -25,6 +25,17 @@
*/
// Pinned framework d541301: real power handler before the build-local fix.
// The actual disconnect path is retained too: pull-up disable alone does NOT
// produce a POWER removal edge, so local session recovery needs this event.
void dcd_disconnect(uint8_t rhport) {
(void) rhport;
NRF_USBD->USBPULLUP = 0;
// Disable Pull-up does not trigger Power USB Removed, in fact it have no
// impact on the USB Power status at all -> need to submit unplugged event to the stack.
dcd_event_bus_signal(0, DCD_EVENT_UNPLUGGED, false);
}
void tusb_hal_nrf_power_event(uint32_t event);
void tusb_hal_nrf_power_event(uint32_t event) {
// Value is chosen to be as same as NRFX_POWER_USB_EVT_* in nrfx_power.h
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <cassert>
#include <cstdarg>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
extern uint32_t mock_millis;
inline uint32_t millis() { return mock_millis; }
inline void delay(uint32_t value) { mock_millis += value; }
class Stream {
public:
virtual ~Stream() = default;
virtual int available() = 0;
virtual int read() = 0;
virtual int peek() = 0;
virtual void flush() = 0;
virtual int availableForWrite() { return 0; }
virtual size_t write(uint8_t value) = 0;
virtual size_t write(const uint8_t* data, size_t size) = 0;
size_t printf(const char* format, ...) {
char buffer[256];
va_list args;
va_start(args, format);
const int count = vsnprintf(buffer, sizeof(buffer), format, args);
va_end(args);
assert(count >= 0 && static_cast<size_t>(count) < sizeof(buffer));
return write(reinterpret_cast<const uint8_t*>(buffer), static_cast<size_t>(count));
}
};
class RawSerial {
public:
int availableForWrite() { return 256; }
template <typename... Args> void printf(Args...) { assert(false && "raw USB write"); }
template <typename... Args> void println(Args...) { assert(false && "raw USB write"); }
};
extern RawSerial Serial;
struct MockFicr { uint32_t DEVICEID[2] = {0x123456, 0}; };
extern MockFicr mock_ficr;
#define NRF_FICR (&mock_ficr)
#define NRF_SPIM1 1
#define WB_IO2 2
#define OUTPUT 1
#define HIGH 1
inline void pinMode(int, int) {}
inline void digitalWrite(int, int) {}
inline void vTaskDelete(void*) {}
inline void vTaskDelay(uint32_t value) { delay(value); }
inline uint32_t pdMS_TO_TICKS(uint32_t value) { return value; }
inline void xTaskCreate(void (*)(void*), const char*, int, void*, int, void*) {}
@@ -0,0 +1,63 @@
#pragma once
#include <Arduino.h>
#include <SPI.h>
class IPAddress {
public:
uint8_t operator[](size_t index) const {
static const uint8_t address[4] = {192, 168, 1, 42};
return address[index];
}
};
extern std::string ethernet_output;
extern std::string ethernet_input;
extern bool accept_client;
class EthernetClient {
bool _connected = false;
public:
explicit EthernetClient(bool connected = false) : _connected(connected) {}
explicit operator bool() const { return _connected; }
bool connected() const { return _connected; }
void stop() { _connected = false; }
IPAddress remoteIP() const { return {}; }
int available() const { return static_cast<int>(ethernet_input.size()); }
int read() {
assert(!ethernet_input.empty());
const char value = ethernet_input.front();
ethernet_input.erase(0, 1);
return value;
}
void print(const char* text) { ethernet_output += text; }
void println(const char* text = "") { ethernet_output += std::string(text) + "\r\n"; }
};
class EthernetServer {
public:
explicit EthernetServer(int) {}
void begin() {}
EthernetClient accept() {
const bool accepted = accept_client;
accept_client = false;
return EthernetClient(accepted);
}
};
enum { EthernetNoHardware = 0, EthernetPresent = 1, LinkOFF = 0, LinkON = 1 };
class MockEthernet {
public:
int failures = 0;
int hardware = EthernetPresent;
int link = LinkON;
void init(SPIClass&, int) {}
int begin(uint8_t*, int, int) {
if (failures > 0) { --failures; return 0; }
return 1;
}
int hardwareStatus() const { return hardware; }
int linkStatus() const { return link; }
IPAddress localIP() const { return {}; }
void maintain() {}
};
extern MockEthernet Ethernet;
+7
View File
@@ -0,0 +1,7 @@
#pragma once
class SPIClass {
public:
SPIClass(int, int, int, int) {}
void begin() {}
};
+142
View File
@@ -0,0 +1,142 @@
#include <helpers/NonBlockingWriteStream.h>
#include <helpers/nrf52/EthernetCLI.h>
#include "NetworkLoggingMacro.h"
#include <iostream>
uint32_t mock_millis = 0;
RawSerial Serial;
MockFicr mock_ficr;
MockEthernet Ethernet;
std::string ethernet_output;
std::string ethernet_input;
bool accept_client = false;
class MockUsb : public Stream {
public:
int capacity = 256;
bool refuse_write = false;
unsigned int tries = 0;
std::string output;
int available() override { return 0; }
int read() override { return -1; }
int peek() override { return -1; }
void flush() override { assert(false && "blocking flush"); }
int availableForWrite() override { return capacity; }
size_t write(uint8_t) override { assert(false && "blocking write"); return 0; }
size_t write(const uint8_t*, size_t) override { assert(false && "blocking write"); return 0; }
};
static MockUsb usb;
static bool logging_enabled = true;
static size_t tryUsbWrite(void*, const uint8_t* data, size_t size) {
++usb.tries;
if (usb.refuse_write || usb.capacity <= 0) return 0;
const size_t written = size < static_cast<size_t>(usb.capacity)
? size : static_cast<size_t>(usb.capacity);
usb.output.append(reinterpret_cast<const char*>(data), written);
return written;
}
static mesh::SingleAttemptNonBlockingStream logging_port(usb, tryUsbWrite);
namespace mesh {
bool isUsbLoggingEnabled() { return logging_enabled; }
Stream& usbLoggingPort() { return logging_port; }
}
static void reset() {
usb.output.clear();
usb.capacity = 256;
usb.refuse_write = false;
usb.tries = 0;
logging_enabled = true;
ethernet_running = false;
ethernet_client = EthernetClient();
ethernet_output.clear();
ethernet_input.clear();
accept_client = false;
Ethernet = MockEthernet();
mock_millis = 0;
}
static void acceptClientAndReply() {
accept_client = true;
ethernet_loop_maintain();
assert(ethernet_take_session_reset());
assert(!ethernet_take_session_reset());
ethernet_input = "eth.status\r";
char command[80] = {};
assert(ethernet_read_line(command, sizeof(command)));
assert(strcmp(command, "eth.status") == 0);
char reply[80] = {};
assert(ethernet_handle_command(command, reply));
ethernet_send_reply(reply);
assert(ethernet_output == "MeshCore CLI\r\n\r\n -> ETH: 192.168.1.42:23\r\n");
}
int main() {
(void)&ethernet_start_task;
// Runtime-off diagnostics must not attempt USB, while functional Ethernet
// commands and the client banner still work.
reset();
logging_enabled = false;
ethernet_task(nullptr);
acceptClientAndReply();
assert(usb.output.empty() && usb.tries == 0 && mock_millis == 0);
reset();
ethernet_task(nullptr);
acceptClientAndReply();
assert(usb.output.find("ETH: MAC: 02:92:1F:12:34:56\n") != std::string::npos);
assert(usb.output.find("ETH: Listening on TCP port 23\n") != std::string::npos);
assert(usb.output.find("ETH: Client connected from 192.168.1.42\n") != std::string::npos);
assert(usb.tries == 6 && mock_millis == 0);
// An open-but-stalled host has no TX space. No raw write or wait is allowed.
reset();
usb.capacity = 0;
ethernet_task(nullptr);
acceptClientAndReply();
assert(usb.output.empty() && usb.tries == 0 && mock_millis == 0);
// The host can also vanish after the capacity preflight. The real shared
// single-attempt facade drops each diagnostic instead of retrying or waiting.
reset();
usb.refuse_write = true;
ethernet_task(nullptr);
acceptClientAndReply();
assert(usb.output.empty() && usb.tries == 6 && mock_millis == 0);
for (int failure : {0, 1, 2}) {
reset();
Ethernet.failures = 1;
if (failure == 0) Ethernet.hardware = EthernetNoHardware;
if (failure == 1) Ethernet.link = LinkOFF;
ethernet_task(nullptr);
const char* expected = failure == 0 ? "Hardware not found, giving up"
: failure == 1 ? "Cable not connected, will retry" : "DHCP failed, will retry";
assert(usb.output.find(expected) != std::string::npos);
assert(mock_millis == (failure == 0 ? 0u : 30000u));
}
reset();
int evaluated = 0;
logging_enabled = false;
NETWORK_DEBUG_PRINTLN("suppressed %d", ++evaluated);
assert(evaluated == 0 && usb.tries == 0);
logging_enabled = true;
usb.capacity = 0;
NETWORK_DEBUG_PRINTLN("stalled %d", ++evaluated);
assert(evaluated == 0 && usb.tries == 0);
usb.capacity = 256;
NETWORK_DEBUG_PRINTLN("visible %d", ++evaluated);
#if defined(MQTT_DEBUG) && MQTT_DEBUG
assert(evaluated == 1 && usb.output == "MQTT: visible 1\n" && usb.tries == 1);
usb.refuse_write = true;
NETWORK_DEBUG_PRINTLN("disconnected %d", ++evaluated);
assert(usb.tries == 2 && usb.output == "MQTT: visible 1\n");
#else
assert(evaluated == 0 && usb.output.empty() && usb.tries == 0);
#endif
assert(mock_millis == 0);
std::cout << "PASS: gated USB diagnostics\n";
}
+12
View File
@@ -32,12 +32,23 @@ def role_handler(role, source):
# The room source opens its next conditional branch's preprocessor guard
# immediately before the closing brace of the local ACL branch.
local = re.sub(r"\n#if defined\(WITH_MQTT_NEIGHBORS\)\n\s*}$", "\n}", local)
usb_acl_helper = ""
if role != "sensor":
# Keep local dispatch attached to the actual USB listing helper. These
# fixtures exercise legacy/nonnative USB; native-USB short writes are
# covered separately by test_esp32_tinyusb_cooperative_output.py.
usb_acl_helper = extract_braced(source, "void MyMesh::printAclSerial()")
usb_acl_helper = usb_acl_helper.replace(
"void MyMesh::printAclSerial()",
f"static void {role}PrintAclSerial(ClientACL& acl)", 1)
local = local.replace("printAclSerial();", f"{role}PrintAclSerial(acl);")
guard = ""
if role == "repeater":
guard_start = body.rindex("if (sender && !sender->isAdmin())", 0,
body.index("mesh::cli::handleACLGet("))
guard = extract_braced(body[guard_start:], "if (sender && !sender->isAdmin())")
return f"""
{usb_acl_helper}
static void {role}Command(ClientACL& acl, ClientInfo* sender,
uint32_t sender_timestamp, char* command, char* reply) {{
const int gpio_client_index = sender == nullptr ? -1 : 0;
@@ -95,6 +106,7 @@ namespace mesh { class Utils { public:
result = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra", "-DESP32=1",
"-DESP32_PLATFORM=1", "-DMESH_ENABLE_FLOOD_RULE_ENGINE=1",
"-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=0",
f"-DMAX_CLIENTS={clients}", f"-I{work}", f"-I{ACL_MOCKS}",
f"-I{ROOT / 'src'}", str(FIXTURE / "test_client_acl_cli.cpp"),
"-o", str(binary),
+150
View File
@@ -0,0 +1,150 @@
"""Run the real logging setters with failed saves and failed MQTT transitions."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include "helpers/CLICommandUtils.h"
struct Prefs { uint8_t usb_logging_enabled = 0, bridge_enabled = 0; };
static bool live_usb = false;
static unsigned usb_calls = 0;
namespace mesh {
void setUsbLoggingEnabled(bool value) { live_usb = value; ++usb_calls; }
}
struct Callbacks {
bool live_wifi = false, apply_ok = true;
unsigned calls = 0;
bool setMqttBridgeState(bool value) {
++calls;
if (apply_ok) live_wifi = value;
return apply_ok;
}
};
struct CLI {
Prefs prefs, persisted;
Prefs* _prefs = &prefs;
Callbacks callbacks;
Callbacks* _callbacks = &callbacks;
bool save_ok = true;
unsigned saves = 0;
bool trySavePrefs() { ++saves; if (save_ok) persisted = prefs; return save_ok; }
void set(const char* config, char* reply) { @BRANCHES@ }
};
static void check(const char* command, bool usb, bool wifi, bool failed) {
CLI cli;
cli.prefs = {uint8_t(!usb), uint8_t(!wifi)};
cli.persisted = cli.prefs;
live_usb = !usb;
cli.callbacks.live_wifi = !wifi;
usb_calls = 0;
cli.save_ok = !failed;
char reply[160]{};
cli.set(command, reply);
assert(cli.saves == 1);
assert(cli.prefs.usb_logging_enabled == (failed ? !usb : usb));
assert(cli.persisted.usb_logging_enabled == (failed ? !usb : usb));
assert(live_usb == (failed ? !usb : usb));
assert(usb_calls == (failed ? 0u : 1u));
assert(strstr(reply, failed ? "unchanged" : "(saved)"));
if (strncmp(command, "logging.output", 14) == 0) {
assert(cli.prefs.bridge_enabled == (failed ? !wifi : wifi));
assert(cli.persisted.bridge_enabled == (failed ? !wifi : wifi));
assert(cli.callbacks.live_wifi == (failed ? !wifi : wifi));
assert(cli.callbacks.calls == (failed ? 0u : 1u));
} else {
assert(cli.callbacks.calls == 0);
}
}
int main() {
for (bool failed : {false, true}) {
check("usb.logging on", true, false, failed);
check("usb.logging off", false, false, failed);
#if WITH_MQTT_BRIDGE
check("logging.output off", false, false, failed);
check("logging.output usb", true, false, failed);
check("logging.output wifi", false, true, failed);
check("logging.output both", true, true, failed);
#endif
}
CLI cli;
char reply[160]{};
cli.set("usb.logging invalid", reply);
assert(cli.saves == 0 && strstr(reply, "Error:"));
#if WITH_MQTT_BRIDGE
cli.set("logging.output invalid", reply);
assert(cli.saves == 0 && strstr(reply, "Error:"));
cli.callbacks.apply_ok = false;
cli.set("logging.output both", reply);
assert(cli.persisted.usb_logging_enabled && cli.persisted.bridge_enabled);
assert(live_usb && !cli.callbacks.live_wifi);
assert(strstr(reply, "saved, but MQTT runtime change failed"));
#endif
}
'''
class LoggingTransactionTest(unittest.TestCase):
def test_companion_reply_survives_input_buffer_reset_during_mode_change(self):
source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
branch = extract_braced(source, 'if (strncmp(command, "set logging.output ", 19) == 0)')
harness = r'''
#include <cassert>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include "helpers/CLICommandUtils.h"
static char input[64];
namespace CompanionMqttSetupPortal { static bool saveEnabled(bool) { return true; } }
namespace mesh { static bool saveUsbLoggingBootPreference(bool) { return true; } }
struct Mesh {
bool _mqtt_enabled = true;
struct { unsigned usb_logging_enabled = 1; } _prefs;
void stopMQTT() {}
bool savePrefs() { return true; }
void applyUsbLoggingState(bool) { memset(input, 0, sizeof(input)); }
bool handle(const char* command, char* reply, size_t reply_size) { @BRANCH@ return false; }
};
int main() {
for (const char* mode : {"off", "usb", "wifi", "both"}) {
Mesh node;
char reply[160], expected[160];
snprintf(input, sizeof(input), "set logging.output %s", mode);
snprintf(expected, sizeof(expected), "OK - logging.output %s (saved)", mode);
assert(node.handle(input, reply, sizeof(reply)));
assert(input[0] == 0 && strcmp(reply, expected) == 0);
}
}
'''.replace('@BRANCH@', branch)
with tempfile.TemporaryDirectory() as directory:
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test.exe'
cpp.write_text(harness)
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-I' + str(ROOT / 'src'), str(cpp), '-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
def test_real_setters_preserve_live_and_saved_state_on_failure(self):
source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
usb = extract_braced(source, 'if (strncmp(config, "usb.logging", 11) == 0')
mqtt = extract_braced(source, 'if (strncmp(config, "logging.output", 14) == 0')
for enabled in (0, 1):
with self.subTest(mqtt=enabled), tempfile.TemporaryDirectory() as directory:
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test.exe'
cpp.write_text(HARNESS.replace('@BRANCHES@', usb + ('\n' + mqtt if enabled else '')))
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-DWITH_MQTT_BRIDGE=' + str(enabled),
'-I' + str(ROOT / 'src'), str(cpp), '-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
if __name__ == '__main__':
unittest.main()
+71 -1
View File
@@ -38,16 +38,20 @@ struct Mesh {
bool terminal = false, stale_input = true;
bool isTerminalMode() const { return terminal; }
void resetUsbHostSessionInput() { stale_input = false; }
void applyUsbLoggingState(bool);
} the_mesh;
struct SerialInterface {
bool passthrough = false, old_frame = true;
uint32_t completed = 7;
void setPassthroughMode(bool value) { passthrough = value; }
bool isPassthroughMode() const { return passthrough; }
void resetSessionState() { old_frame = false; }
uint32_t getCompletedFrameCount() const { return completed; }
} usb_serial_interface;
struct Board { bool isUsbHostConnected() const { return data_connected; } } board;
static bool usb_mota_mode = false, usb_logging_terminal_mode = false;
static bool usb_logging_network_parked = false;
static bool usb_protocol_initialized = true;
static bool usb_host_session_connected = false, usb_terminal_discard_line = true;
static bool usb_terminal_host_reset_completion_pending = false;
static uint32_t usb_terminal_host_reset_retry_at = 0;
@@ -57,13 +61,27 @@ static mesh::UsbAsciiSessionDefault usb_ascii_session_default;
static mesh::UsbHostPresenceDebouncer usb_hwcdc_host_presence;
static constexpr unsigned USB_TRANSPORT_RESET_RETRY_MS = 1000;
static constexpr unsigned USB_HOST_LOSS_EDGE_MS = 100, USB_HOST_LOSS_GRACE_MS = 2000;
struct Stream {
int pending = 0;
int available() const { return pending; }
int read() { return pending-- > 0 ? 'x' : -1; }
void print(const char*) {}
} usb_stream;
static Stream& usbTerminalOutput() { return usb_stream; }
namespace mesh {
Stream& usbCompanionPort() { return usb_stream; }
void discardUsbTerminalOutput() {}
bool takeUsbTerminalSessionReset() { bool value = reset_event; reset_event = false; return value; }
bool resetUsbCompanionTransport() { return transport_ready; }
bool tryCompleteUsbTerminalSessionReset() { return transport_ready; }
bool hasDedicatedUsbLoggingPort() { return dedicated_logging; }
bool isUsbLoggingEnabled() { return logging_enabled; }
void setUsbLoggingEnabled(bool enabled) {
if (enabled && usb_protocol_initialized && !dedicated_logging) {
assert(usb_serial_interface.passthrough); // Must park before gate opens.
}
logging_enabled = enabled;
}
}
static bool isUsbTerminalDataConnected() { return data_connected; }
static bool isNetworkTerminalActive() { return network_active; }
@@ -89,6 +107,7 @@ static void enterUsbLoggingTerminalMode() {
static void service() {
serviceUsbTerminalHostSessionReset();
serviceUsbAsciiSessionDefault();
serviceUsbLoggingOwnership(logging_enabled);
}
static void boundary(bool event = true) {
reset_event = event;
@@ -156,6 +175,41 @@ int main() {
boundary(false);
assert(the_mesh.terminal);
#endif
// A network CLI must retain ownership while USB diagnostics exclude frames.
the_mesh.terminal = false;
usb_serial_interface.passthrough = false;
network_active = true;
logging_enabled = false;
the_mesh.applyUsbLoggingState(true);
assert(network_active && !the_mesh.terminal);
assert(usb_serial_interface.passthrough && usb_logging_network_parked);
usb_stream.pending = 5;
serviceUsbLoggingOwnership(logging_enabled);
assert(usb_stream.pending == 5); // Still log-only; no USB CLI takes ownership.
the_mesh.applyUsbLoggingState(false);
assert(network_active && !the_mesh.terminal);
assert(!usb_serial_interface.passthrough && !usb_logging_network_parked);
assert(usb_stream.pending == 0);
logging_enabled = true;
serviceUsbLoggingOwnership(logging_enabled);
network_active = false;
usb_stream.pending = 5;
serviceUsbLoggingOwnership(logging_enabled);
assert(the_mesh.terminal && usb_logging_terminal_mode);
assert(usb_serial_interface.passthrough && !usb_logging_network_parked);
assert(usb_stream.pending == 0);
// An ASCII-restoring network handoff must not accidentally reenable frames.
logging_enabled = false;
usb_logging_network_parked = true;
usb_logging_terminal_mode = false;
serviceUsbLoggingOwnership(logging_enabled);
assert(the_mesh.terminal && usb_serial_interface.passthrough);
// Remote logging cancels exclusive mOTA before the next framed dispatch.
usb_mota_mode = true;
the_mesh.terminal = false;
logging_enabled = true;
serviceUsbLoggingOwnership(logging_enabled);
assert(!usb_mota_mode && the_mesh.terminal && usb_serial_interface.passthrough);
logging_enabled = true;
beginUsbDefaultSession();
assert(the_mesh.terminal && usb_logging_terminal_mode);
@@ -208,9 +262,11 @@ class CompanionUsbDefaultTest(unittest.TestCase):
self.assertIn("companion_transport_boot_mode =\n CompanionTransportMode::Bluetooth;", MAIN)
def test_startup_and_reconnection_for_each_usb_transport(self):
functions = "\n".join(function("static void " + name + "(") for name in (
functions = "\n".join(function("static void " + name + "() {") for name in (
"resetUsbTerminalHostSession", "serviceUsbTerminalHostSessionReset",
"serviceUsbAsciiSessionDefault", "beginUsbDefaultSession"))
functions += "\n" + function("static void serviceUsbLoggingOwnership(bool logging_enabled) {")
functions += "\n" + function("void MyMesh::applyUsbLoggingState(bool enabled) {").replace("MyMesh::", "Mesh::")
profiles = {
"nrf52": ["NRF52_PLATFORM=1"],
"esp32-tinyusb": ["ESP32=1", "ARDUINO_USB_MODE=0", "ARDUINO_USB_CDC_ON_BOOT=1", "MESH_ESP32_TINYUSB_NONBLOCKING=1"],
@@ -237,8 +293,22 @@ class CompanionUsbDefaultTest(unittest.TestCase):
loop = function("\nvoid loop() {")
self.assertLess(loop.index("serviceUsbTerminalHostSessionReset();"), loop.index("serviceUsbAsciiSessionDefault();"))
self.assertLess(loop.index("serviceUsbAsciiSessionDefault();"), loop.index("the_mesh.loop();"))
self.assertLess(loop.index("serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());"), loop.index("the_mesh.loop();"))
self.assertLess(loop.index("expireUsbBinaryStartupProbeBeforeDispatch();"), loop.index("the_mesh.loop();"))
self.assertNotIn("COMPANION_RADIO_FULL", MAIN)
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
self.assertNotIn("mesh::setUsbLoggingEnabled(", source)
logging_reply = source[source.index('if (strncmp(command, "set logging.output ", 19)'):
source.index('if ((strncmp(command, "get ", 4)', source.index('if (strncmp(command, "set logging.output ", 19)'))]
self.assertIn("mesh::cli::loggingOutputName(usb, wifi)", logging_reply)
terminal = function("static void serviceUsbTerminal() {")
self.assertLess(terminal.index("serviceUsbLoggingOwnership("), terminal.index("if (usb_mota_mode)"))
leave = function("static void leaveUsbTerminalMode(bool acknowledge) {")
self.assertIn("!mesh::hasDedicatedUsbLoggingPort() && mesh::isUsbLoggingEnabled()", leave)
browser = function("bool MyMesh::beginStreamTerminal(Stream& output) {")
self.assertIn("serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());", browser)
tcp = function("static void ota_console_loop() {")
self.assertIn("serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());", tcp)
if __name__ == "__main__":
+103 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Execute the real native-TinyUSB role output pumps with small host stubs."""
"""Execute the real native-USB role output pumps with small host stubs."""
from pathlib import Path
import os
@@ -23,8 +23,9 @@ HARNESS = r'''
#include <vector>
#include <cstdint>
#include <helpers/FileRead.h>
#define MESH_ESP32_TINYUSB_NONBLOCKING 1
@USB_CONFIG@
#define MESH_ESP32_USB_CONSOLE_COOPERATIVE 1
#define PUB_KEY_SIZE 32
#define MAX_ROUTE_HASH_BYTES 3
#define PACKET_LOG_FILE "/packet_log"
static void require(bool ok, const char* what) {
@@ -104,6 +105,15 @@ struct SimpleMeshTables {
return result < static_cast<int>(rows.size()) ? &rows[result] : nullptr;
}
};
struct FakeAcl {
struct Client {
struct { uint8_t pub_key[PUB_KEY_SIZE]; } id{};
uint8_t permissions = 3;
};
std::vector<Client> clients;
int getNumClients() const { return static_cast<int>(clients.size()); }
Client* getClientByIdx(int index) { return &clients.at(index); }
};
class MyMesh {
public:
FakeFS* _fs = &fs;
@@ -114,6 +124,9 @@ class MyMesh {
bool serial_log_active = false;
bool serial_log_eof_pending = false;
bool serial_log_skip_line = false;
int serial_acl_next = -1;
int serial_acl_count = 0;
bool serial_acl_header = false;
int serial_recent_next = -1;
int serial_recent_count = 0;
bool serial_recent_header = false;
@@ -121,8 +134,10 @@ class MyMesh {
SimpleMeshTables::RecentRepeaterInfo serial_recent_cursor{};
int serial_recent_cursor_index = -1;
SimpleMeshTables tables;
FakeAcl acl;
const SimpleMeshTables* getTables() const { return &tables; }
void dumpLogFile();
void printAclSerial();
bool hasPendingSerialOutput() const;
void servicePendingSerialOutput();
void cancelPendingSerialOutput();
@@ -207,6 +222,78 @@ int main() {
drain(radio);
require(console.output.empty(), "canceled old-session output leaked");
}
setupFile("");
{
MyMesh radio;
std::string expected = "ACL:\r\n";
for (int i = 0; i < 32; ++i) {
FakeAcl::Client client;
std::fill(std::begin(client.id.pub_key), std::end(client.id.pub_key), i);
radio.acl.clients.push_back(client);
char key[PUB_KEY_SIZE * 2 + 1], line[80];
mesh::Utils::toHex(key, client.id.pub_key, PUB_KEY_SIZE);
snprintf(line, sizeof(line), "%02X %s\n", client.permissions, key);
expected += line;
}
require(expected.size() > 2048, "ACL test does not exceed admission reserve");
console.capacity = 256; // Accepted with a nearly-full HWCDC/logging FIFO.
radio.printAclSerial();
require(console.output.empty(), "ACL is still emitted synchronously");
radio.acl.clients.emplace_back(); // New arrivals must not extend the snapshot.
console.capacity = 0;
for (int i = 0; i < 1000; ++i) radio.servicePendingSerialOutput();
require(console.output.empty(), "ACL wrote into a stalled host");
require(radio.serial_acl_next == 0, "stalled header skipped an ACL row");
console.fail_once = true;
console.short_limit = 17;
drain(radio, 73);
require(console.output == expected, "full ACL lost or duplicated short-write bytes");
require(!radio.hasPendingSerialOutput(), "ACL blocks the next command after completion");
}
setupFile("");
{
MyMesh radio;
radio.acl.clients.resize(32);
for (auto& client : radio.acl.clients) client.permissions = 0;
radio.printAclSerial();
drain(radio, 73);
require(console.output == "ACL:\r\n", "deleted ACL clients were printed");
}
setupFile("");
{
MyMesh radio;
radio.acl.clients.resize(1);
std::fill(std::begin(radio.acl.clients[0].id.pub_key),
std::end(radio.acl.clients[0].id.pub_key), 0xAA);
radio.printAclSerial();
radio.servicePendingSerialOutput();
console.short_limit = 7;
radio.servicePendingSerialOutput();
require(radio.serial_log_pending_size > 0, "ACL short-write suffix was not retained");
radio.cancelPendingSerialOutput();
require(!radio.hasPendingSerialOutput(), "ACL cancellation leaves a pending job");
console.output.clear();
console.records.clear();
console.short_limit = 0;
radio.acl.clients.clear();
radio.printAclSerial();
drain(radio, 73);
require(console.output == "ACL:\r\n", "old ACL row leaked after reconnect cancellation");
}
setupFile("existing file dump\n");
{
MyMesh radio;
radio.dumpLogFile();
console.capacity = 0;
radio.servicePendingSerialOutput();
const size_t pending = radio.serial_log_pending_size;
radio.printAclSerial();
require(radio.serial_log_pending_size == pending && radio.serial_acl_next == -1,
"overlapping ACL command overwrote a pending file record");
drain(radio);
require(console.output == "existing file dump\n" + eof,
"overlapping ACL command corrupted an existing output job");
}
@RECENT_TEST@
std::cout << "cooperative output checks passed\n";
return 0;
@@ -241,12 +328,23 @@ RECENT_TEST = r'''
class CooperativeOutputTest(unittest.TestCase):
def test_usb_acl_commands_route_to_the_cooperative_pump(self):
for role in ("simple_repeater", "simple_room_server"):
with self.subTest(role=role):
text = (ROOT / f"examples/{role}/MyMesh.cpp").read_text()
start = text.index('strcmp(command, "get acl") == 0) {')
end = text.index("reply[0] = 0;", text.index("#endif", start))
self.assertIn("printAclSerial();", text[start:end])
self.assertNotIn("mesh::usbConsolePort().printf", text[start:end])
def test_real_role_pumps(self):
compiler = shutil.which("g++") or shutil.which("clang++")
if not compiler:
self.skipTest("A host C++ compiler is required for pump execution")
for role in ("simple_repeater", "simple_room_server"):
with self.subTest(role=role), tempfile.TemporaryDirectory() as temporary:
for role, config in ((role, config)
for role in ("simple_repeater", "simple_room_server")
for config in ("MESH_ESP32_TINYUSB_NONBLOCKING", "MESH_ESP32_HWCDC_SESSION_GUARD")):
with self.subTest(role=role, transport=config), tempfile.TemporaryDirectory() as temporary:
text = (ROOT / f"examples/{role}/MyMesh.cpp").read_text()
dump_start = text.index("void MyMesh::dumpLogFile()")
dump_end = text.index("\n#if MESH_ESP32_USB_CONSOLE_COOPERATIVE\nbool MyMesh::hasPendingSerialOutput", dump_start)
@@ -261,7 +359,7 @@ class CooperativeOutputTest(unittest.TestCase):
methods = text[format_start:format_end] + "\n" + text[recent_start:recent_end] + "\n" + methods
program = HARNESS.replace("@METHODS@", methods).replace(
"@RECENT_TEST@", RECENT_TEST if role == "simple_repeater" else ""
)
).replace("@USB_CONFIG@", f"#define {config} 1")
executable = Path(temporary) / ("pump.exe" if os.name == "nt" else "pump")
build = subprocess.run(
[compiler, "-std=c++17", "-O0", "-I", str(ROOT / "src"),
+7 -2
View File
@@ -140,15 +140,19 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase):
def test_sleep_keeps_raw_flush_only_for_other_esp32_transports(self):
text = source("src/helpers/ESP32Board.cpp")
shutdown = text[text.index("void ESP32Board::shutdownPeripherals()")
: text.index("void ESP32Board::powerOff()")]
sleep = text[text.index("void ESP32Board::enterDeepSleep(") :]
guard = re.search(
r"#if MESH_ESP32_USB_CONSOLE_COOPERATIVE\s+"
r"mesh::serviceUsbTerminalPort\(\);\s+"
r"#else\s+Serial\.flush\(\);\s+#endif",
sleep,
shutdown,
)
self.assertIsNotNone(guard)
self.assertEqual(sleep.count("Serial.flush();"), 1)
self.assertEqual(shutdown.count("Serial.flush();"), 1)
self.assertNotIn("Serial.flush();", sleep)
self.assertIn("shutdownPeripherals();", sleep)
def test_large_file_dumps_are_bounded_and_cancelable(self):
for role in ROLES:
@@ -180,6 +184,7 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase):
def test_recent_list_advances_only_after_whole_row_admission(self):
text = source("examples/simple_repeater/MyMesh.cpp")
start = text.index("void MyMesh::servicePendingSerialOutput()")
start = text.index("if (serial_recent_next >= 0)", start)
service = text[start : text.index("if (!serial_log_active)", start)]
self.assertIn("char record[64];", service)
self.assertIn("serial_recent_next < serial_recent_count", service)
+35
View File
@@ -31,6 +31,10 @@ HARNESS = r'''
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;
// Model the endpoint separately from the software FIFO. A FIFO clear must
// leave an armed packet intact; only a real detach + enumeration retires it.
static bool armed_in[2] = {false, false}, attached = true;
static unsigned detaches = 0, attaches = 0;
static bool close_during_rx_sample = false;
static uint32_t millis() { return now_ms; }
static bool tud_mounted() { return mounted; }
@@ -46,6 +50,11 @@ static uint32_t tud_cdc_n_available(uint8_t) {
}
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 bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed_in[n]; }
struct UsbDevice {
void detach() { ++detaches; attached = false; }
void attach() { ++attaches; attached = true; }
} TinyUSBDevice;
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;
@@ -144,6 +153,32 @@ int main() {
// No write waits when the FIFO is full, including with DTR low.
tx_count = 64;
assert(mesh::writeTinyUsbCdcOnce(nullptr, bytes, 4) == 0);
// A previously submitted IN packet survives FIFO clearing. Quarantine the
// entire device immediately, but never wait on the unread endpoint.
tx_count = 4; armed_in[0] = true;
rx_count = 4;
assert(mesh::isUsbCompanionClientConnected());
line(false);
assert(tx_count == 0 && armed_in[0] && !attached && detaches == 1);
assert(!mesh::isUsbCompanionClientConnected());
assert(mesh::writeTinyUsbCdcOnce(nullptr, bytes, 4) == 0);
// Repeated control callbacks cannot cause a detach/attach storm.
meshTinyUsbCdcLineCodingChanged(0);
line(false);
assert(detaches == 1);
now_ms += 100;
mesh::serviceNrf52UsbSessionReenumeration();
assert(attaches == 0); // owner has not consumed unplug yet
mounted = false; armed_in[0] = false; dtr[0] = false;
meshTinyUsbDeviceSessionBoundary();
mesh::serviceNrf52UsbSessionReenumeration();
assert(attaches == 1 && attached);
mounted = true;
meshTinyUsbDeviceSessionBoundary();
settle();
rx_count = 4;
assert(mesh::isUsbCompanionClientConnected());
rx_count = 0;
baud = 1200;
line(false);
assert(dfu);
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Run actual nRF52 USB lifecycle code with independent FIFO/endpoint state."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
USB = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
def function(signature):
start = USB.index(signature)
end = USB.index("{", start) + 1
depth = 1
while depth:
depth += (USB[end] == "{") - (USB[end] == "}")
end += 1
return USB[start:end]
class Nrf52UsbLifecycleTest(unittest.TestCase):
def run_native(self, source):
compiler = shutil.which("g++")
if compiler is None:
self.skipTest("C++ compiler unavailable")
with tempfile.TemporaryDirectory() as directory:
cpp = Path(directory) / "lifecycle.cpp"
binary = Path(directory) / "lifecycle"
cpp.write_text(source)
subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra",
str(cpp), "-o", str(binary)], check=True,
capture_output=True)
subprocess.run([str(binary)], check=True, capture_output=True)
def test_framework_detach_queues_local_unmount_without_vbus_edge(self):
pinned_driver = (ROOT / "test/fixtures/nrf52_usb_power_original.c").read_text()
start = pinned_driver.index("void dcd_disconnect(")
disconnect = pinned_driver[start:pinned_driver.index("\n}\n", start) + 3]
self.run_native(r'''
#include <cassert>
#include <cstdint>
static bool mounted = true, vbus = true;
static unsigned owner_events = 0, reset_count = 0;
static constexpr unsigned DCD_EVENT_UNPLUGGED = 0;
struct Registers { unsigned USBPULLUP = 1; } registers;
#define NRF_USBD (&registers)
static void dcd_event_bus_signal(unsigned port, unsigned event, bool isr) {
assert(port == 0 && event == DCD_EVENT_UNPLUGGED && !isr);
++owner_events; // enqueue, do not automatically fake a VBUS/unmount edge
}
@DISCONNECT@
// Actual usbd.c wrapper and Adafruit_USBD_Device.h inline entry point.
static bool tud_disconnect() { dcd_disconnect(0); return true; }
struct Device { bool detach() { return tud_disconnect(); } } TinyUSBDevice;
static void usbd_reset(unsigned port) {
assert(port == 0); mounted = false; ++reset_count;
}
static void tud_umount_cb() {}
static void owner_task() {
assert(owner_events == 1); --owner_events;
// Exact DCD_EVENT_UNPLUGGED owner branch from the pinned usbd.c.
usbd_reset(0);
tud_umount_cb();
}
int main() {
assert(TinyUSBDevice.detach());
assert(vbus && registers.USBPULLUP == 0 && mounted);
assert(owner_events == 1); // without this, recovery would wait forever
owner_task();
assert(vbus && !mounted && reset_count == 1);
}
'''.replace("@DISCONNECT@", disconnect))
def test_descriptor_mutation_is_detached_even_before_mount(self):
source = r'''
#include <atomic>
#include <cassert>
#include <cstdint>
#define MESH_DUAL_CDC_LOGGING 1
static bool attached = true, mounted = false, descriptor_fetched = false;
static unsigned detach_count = 0, attach_count = 0, descriptor_count = 1;
static unsigned delay_ms = 0;
struct Device {
bool mounted() { return ::mounted; }
void detach() { attached = false; ++detach_count; }
void attach() {
assert(descriptor_count == 2 && delay_ms >= 10);
attached = true; ++attach_count;
// Fresh enumeration replaces a host's old cached one-port descriptor.
descriptor_fetched = false;
}
} TinyUSBDevice;
static void delay(unsigned value) { assert(!attached); delay_ms += value; }
namespace mesh {
static std::atomic<bool> usb_logging_preference_known{false};
static bool enabled = false, dedicated_usb_logging_port_started = false;
static bool dedicated_usb_logging_port_configured = false;
static bool isUsbLoggingEnabled() { return enabled; }
static void setPlatformDebugOutputEnabled(bool) {}
struct Port {
void begin(unsigned baud) {
assert(baud == 115200 && !attached && delay_ms >= 10);
++descriptor_count;
}
} dedicated_usb_logging_port;
@BEGIN@
}
int main() {
// No descriptor or reconnect when the preference is unknown/disabled.
mesh::beginUsbLoggingPort();
mesh::usb_logging_preference_known = true;
mesh::beginUsbLoggingPort();
assert(detach_count == 0);
// Host fetched the old descriptor but has not SET_CONFIGURATION yet.
descriptor_fetched = true;
assert(!mounted);
mesh::enabled = true;
mesh::beginUsbLoggingPort();
assert(detach_count == 1 && attach_count == 1 && attached);
assert(!descriptor_fetched && descriptor_count == 2);
assert(mesh::dedicated_usb_logging_port_configured);
mesh::beginUsbLoggingPort();
assert(detach_count == 1 && attach_count == 1); // idempotent
}
'''.replace("@BEGIN@", function("void beginUsbLoggingPort()"))
self.run_native(source)
def test_armed_logging_packet_and_racing_primary_packet_force_clean_bus(self):
start = USB.index("static std::atomic<uint32_t> primary_usb_reset_generation")
gates = USB[start:USB.index("\n#endif", start)]
source = r'''
#include <atomic>
#include <cassert>
#include <cstdint>
#define ENABLE_USB_INTERFACE 1
static bool mounted = true, attached = true, armed[2] = {false, true};
static uint32_t fifo[2] = {0, 32}, now = UINT32_MAX - 10;
static unsigned detach_count = 0, attach_count = 0, reset_count = 0;
static uint32_t millis() { return now; }
static bool tud_mounted() { return mounted; }
static uint32_t tud_cdc_n_available(uint8_t) { return 0; }
static void tud_cdc_n_read_flush(uint8_t) {}
static bool tud_cdc_n_write_clear(uint8_t n) { fifo[n] = 0; return true; }
static bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed[n]; }
struct Device {
void detach() { ++detach_count; attached = false; }
void attach() { ++attach_count; attached = true; }
} TinyUSBDevice;
namespace mesh {
@GATES@
static std::atomic<bool> dedicated_usb_logging_port_connected{true};
static std::atomic<uint32_t> dedicated_usb_logging_reset_generation{0};
static uint8_t dedicated_usb_logging_quiet_sofs = 0;
static constexpr uint8_t dedicated_usb_logging_host_settle_sofs = 50;
static uint32_t primary_usb_terminal_taken_reset_generation = 0;
static void resetDedicatedUsbLoggingUsbTaskState() { ++reset_count; }
@RESTART@
@COMPLETE@
}
int main() {
// CDC1 old data is in endpoint RAM, not the software FIFO. No 50-SOF
// waiting/clearing policy can retract it without re-enumeration.
mesh::restartDedicatedUsbLoggingHostSession();
assert(fifo[1] == 0 && armed[1] && !attached && detach_count == 1);
assert(!mesh::dedicated_usb_logging_port_connected && reset_count == 1);
assert(!mesh::canAccessPrimaryUsbSession(nullptr)); // both ports gated
mesh::restartDedicatedUsbLoggingHostSession();
assert(detach_count == 1); // repeated control requests do not reset timer
now += 19; mounted = false; armed[1] = false;
mesh::serviceNrf52UsbSessionReenumeration();
assert(attach_count == 0); // wrap-safe bounded detach interval
++now;
mesh::serviceNrf52UsbSessionReenumeration();
assert(attach_count == 1 && attached);
mesh::serviceNrf52UsbSessionReenumeration();
assert(attach_count == 1);
// The final exclusive purge also catches an old producer that submitted
// a primary IN packet after the owner callback's first FIFO clear.
mounted = true; armed[0] = true; fifo[0] = 16;
mesh::primary_usb_terminal_taken_reset_generation =
mesh::primaryUsbSessionGeneration();
mesh::completePrimaryUsbSessionReset(nullptr);
assert(fifo[0] == 0 && armed[0] && !attached && detach_count == 2);
assert(!mesh::canAccessPrimaryUsbSession(nullptr));
}
'''
source = source.replace("@GATES@", gates)
source = source.replace("@RESTART@", function("static void restartDedicatedUsbLoggingHostSession()"))
source = source.replace("@COMPLETE@", function("static void completePrimaryUsbSessionReset("))
self.run_native(source)
if __name__ == "__main__":
unittest.main()
+5 -5
View File
@@ -255,7 +255,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase):
):]
self.assertIn("if (instance == 1)", bridge)
self.assertIn("handleDedicatedUsbLoggingLineState(dtr);", bridge)
self.assertIn("tud_cdc_n_write_clear(1);", source)
self.assertIn("clearNrf52UsbTxForSession(1);", source)
self.assertIn("endPrimaryUsbHostSession(true);", bridge)
self.assertIn("if (instance == 0 && !dtr)", bridge)
self.assertIn("tud_cdc_get_line_coding(&coding);", bridge)
@@ -306,7 +306,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase):
)
self.assertLess(
session_end.index("tud_cdc_n_read_flush(0)"),
session_end.index("tud_cdc_n_write_clear(0)"),
session_end.index("clearNrf52UsbTxForSession(0)"),
)
self.assertIn(
"dedicated_usb_logging_line_state_dtr.exchange(", source
@@ -332,7 +332,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase):
restart_gate = restart.index(
"dedicated_usb_logging_port_connected.store(false"
)
restart_fifo = restart.index("tud_cdc_n_write_clear(1)")
restart_fifo = restart.index("clearNrf52UsbTxForSession(1)")
restart_state = restart.index("resetDedicatedUsbLoggingUsbTaskState()")
restart_quiet = restart.index("dedicated_usb_logging_quiet_sofs =")
restart_generation = restart.index(
@@ -368,7 +368,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase):
self.assertLess(marker_write, queued_drain)
quiet_block = owner_service[quiet_start:positive_gate]
quiet_fifo = quiet_block.index("tud_cdc_n_write_clear(1)")
quiet_fifo = quiet_block.index("clearNrf52UsbTxForSession(1)")
quiet_state = quiet_block.index(
"resetDedicatedUsbLoggingUsbTaskState()"
)
@@ -595,7 +595,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase):
# A second purge after the settle gate would erase a new host's
# immediately sent APP_START (meshcli does not delay after open).
self.assertNotIn("tud_cdc_n_read_flush(0);", completion)
self.assertIn("tud_cdc_n_write_clear(0);", completion)
self.assertIn("clearNrf52UsbTxForSession(0);", completion)
self.assertIn("primary_usb_allowed_generation.store(", completion)
self.assertIn(".tryRunExclusive(", source)
self.assertIn("primary_usb_reset_settle_until.store(", source)
+113 -1
View File
@@ -46,6 +46,116 @@ int main() {
class UsbPowerTests(unittest.TestCase):
def test_cdc_query_tracks_armed_endpoint_not_fifo_bytes(self):
original = r'''
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include "cdc_device.h"
#define CFG_TUD_CDC 2
struct { uint8_t ep_in; unsigned tx_ff; } _cdcd_itf[2];
static bool armed[256];
static bool usbd_edpt_busy(uint8_t port, uint8_t endpoint) {
assert(port == 0); return armed[endpoint];
}
static bool tu_fifo_clear(unsigned* fifo) { *fifo = 0; return true; }
'''+ FIX.CDC_WRITE_CLEAR + r'''
int main(void) {
_cdcd_itf[0].ep_in = 0x81;
_cdcd_itf[1].ep_in = 0x83;
_cdcd_itf[0].tx_ff = 16;
armed[0x81] = true;
assert(mesh_tud_cdc_n_tx_pending(0));
tud_cdc_n_write_clear(0);
assert(_cdcd_itf[0].tx_ff == 0 && mesh_tud_cdc_n_tx_pending(0));
assert(!mesh_tud_cdc_n_tx_pending(1));
assert(!mesh_tud_cdc_n_tx_pending(2));
armed[0x81] = false;
assert(!mesh_tud_cdc_n_tx_pending(0));
_cdcd_itf[1].ep_in = 0;
armed[0] = true; // unopened CDC must not query control endpoint
assert(!mesh_tud_cdc_n_tx_pending(1));
}
'''
patched = FIX.patched_cdc_source(original)
self.assertEqual(patched, FIX.patched_cdc_source(patched))
self.assertEqual(patched, FIX.patched_cdc_source(original.replace("\n", "\r\n")))
compiler = shutil.which("gcc")
if compiler is None:
self.skipTest("C compiler unavailable")
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "cdc.c"
binary = Path(directory) / "cdc"
header = Path(directory) / "class/cdc/cdc_device.h"
header.parent.mkdir(parents=True)
header.write_text("// Only the isolated session-query API is tested here.\n")
source.write_text(patched)
subprocess.run([compiler, "-std=c11", "-Wall", "-Wextra", "-Werror",
"-I", directory, str(source), "-o", str(binary)],
check=True, capture_output=True)
subprocess.run([str(binary)], check=True, capture_output=True)
def test_relocated_cdc_driver_compiles_against_real_tinyusb_headers(self):
# Exercise the middleware's actual relocated file, not just an API
# extraction: cdc_device.h is originally a sibling of cdc_device.c.
# CI without this hardware framework can still run the isolated API
# and relocation guards; a local hardware build runs this full check.
sdk = (Path.home() / ".platformio/packages/framework-arduinoadafruitnrf52/"
"libraries/Adafruit_TinyUSB_Arduino/src")
original = sdk / "class/cdc/cdc_device.c"
compiler = shutil.which("gcc")
if compiler is None or not original.is_file():
self.skipTest("native compiler/nRF52 TinyUSB framework unavailable")
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
config = root / "tusb_config.h"
config.write_text("""
#define CFG_TUSB_MCU OPT_MCU_NONE
#define CFG_TUSB_OS OPT_OS_NONE
#define CFG_TUSB_RHPORT0_MODE OPT_MODE_DEVICE
#define CFG_TUD_ENABLED 1
#define CFG_TUD_CDC 2
#define CFG_TUD_CDC_RX_BUFSIZE 256
#define CFG_TUD_CDC_TX_BUFSIZE 256
#define CFG_TUSB_DEBUG 0
""")
class Node:
def srcnode(self): return self
def get_abspath(self): return str(original)
class Env:
def subst(self, value):
assert value == "$BUILD_DIR"
return str(root / "build")
def File(self, value): return Path(value)
patched = FIX.replace_driver(Env(), Node())
self.assertNotEqual(patched.parent, original.parent)
self.assertIn(FIX.CDC_ROOT_INCLUDE, patched.read_text())
result = subprocess.run(
[compiler, "-std=c11", '-DCFG_TUSB_CONFIG_FILE="' + config.as_posix() + '"',
"-I", str(root), "-I", str(sdk),
"-c", str(patched), "-o", str(root / "cdc.o")],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_changed_cdc_driver_fails_closed(self):
prefix = FIX.CDC_LOCAL_INCLUDE + "\n"
for source in ("", prefix + FIX.CDC_WRITE_CLEAR.replace("tx_ff", "tx_fifo"),
prefix + FIX.CDC_WRITE_CLEAR * 2,
prefix + FIX.CDC_WRITE_CLEAR + FIX.CDC_SESSION_QUERY,
FIX.CDC_ROOT_INCLUDE + "\n" + FIX.CDC_WRITE_CLEAR):
with self.subTest(source=source), self.assertRaises(RuntimeError):
FIX.patched_cdc_source(source)
def test_cdc_query_is_built_from_local_copy(self):
hooks = []
class Env:
def AddBuildMiddleware(self, handler, pattern):
hooks.append((handler, pattern))
FIX.install(Env())
self.assertEqual(len(hooks), 3)
self.assertTrue(any("class*cdc*cdc_device.c" in pattern
for _, pattern in hooks))
def test_actual_handler_and_inherited_hang(self):
exercise(FIX.patched_source(ORIGINAL))
@@ -76,7 +186,9 @@ class UsbPowerTests(unittest.TestCase):
def test_unrecognized_or_partly_fixed_driver_fails_closed(self):
for source in ("", ORIGINAL.replace("hfclk_running()", "clock_running()"),
ORIGINAL + ORIGINAL, ORIGINAL.replace(FIX.OLD_READY, FIX.READY)):
ORIGINAL + ORIGINAL, ORIGINAL.replace(FIX.OLD_READY, FIX.READY),
ORIGINAL.replace("dcd_event_bus_signal(0, DCD_EVENT_UNPLUGGED, false);",
"// removed local disconnect event")):
with self.subTest(source=source[:40]), self.assertRaises(RuntimeError):
FIX.patched_source(source)
@@ -858,9 +858,15 @@ TEST(SerialModeSwitch, SingleTtyLoggingCannotStealNetworkTerminal) {
using Action = mesh::UsbLoggingTerminalAction;
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
false, true, false, false, true, true),
Action::NO_ACTION);
Action::PARK_USB_FOR_NETWORK);
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
false, true, false, false, false, true),
Action::PARK_USB_FOR_NETWORK);
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
true, true, false, false, true, true),
Action::NO_ACTION);
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
false, false, false, false, true, true),
Action::NO_ACTION);
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Exercise Ethernet diagnostics and the production NetworkLink logging macro."""
from pathlib import Path
import os
import re
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "test/fixtures/usb_diagnostic_logging"
class UsbDiagnosticLoggingTests(unittest.TestCase):
def test_runtime_gate_backpressure_and_functional_ethernet_output(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++17 compiler is required")
network = (ROOT / "src/helpers/NetworkLink.cpp").read_text(encoding="utf-8")
start = network.index("// Same \"MQTT: \" prefix")
macro = network[start:network.index("namespace {", start)]
with tempfile.TemporaryDirectory(prefix="meshcore-usb-diagnostics-") as directory:
temporary = Path(directory)
(temporary / "NetworkLoggingMacro.h").write_text(macro, encoding="utf-8")
for debug in (None, 0, 1):
with self.subTest(mqtt_debug=debug):
binary = temporary / "usb-diagnostics.exe"
defines = [] if debug is None else [f"-DMQTT_DEBUG={debug}"]
compiled = subprocess.run(
[compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-DARDUINO=1", "-DETHERNET_ENABLED=1", *defines,
f"-I{FIXTURE / 'mocks'}", f"-I{ROOT / 'src'}",
f"-I{temporary}", str(FIXTURE / "test.cpp"),
"-o", str(binary)],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
checked = subprocess.run(
[str(binary)], capture_output=True, text=True, timeout=10,
)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("PASS: gated USB diagnostics", checked.stdout)
def test_no_raw_serial_diagnostic_writes(self):
for relative in ("src/helpers/nrf52/EthernetCLI.h", "src/helpers/NetworkLink.cpp"):
with self.subTest(source=relative):
source = (ROOT / relative).read_text(encoding="utf-8")
self.assertIn("UsbLogging.h", source)
self.assertIn("mesh::isUsbLoggingEnabled()", source)
self.assertIn("mesh::usbLoggingPort()", source)
self.assertIsNone(re.search(r"\bSerial\.(?:print|println|printf|write)\s*\(", source))
if __name__ == "__main__":
unittest.main()