Fix USB logging ownership, persistence, and reconnect safety
This commit is contained in:
1 parent
c1a533572a
commit
91d533bfff
32 files changed
+1543
-140
No files matched your search
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
class SPIClass {
|
||||
public:
|
||||
SPIClass(int, int, int, int) {}
|
||||
void begin() {}
|
||||
};
|
||||
+142
@@ -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)ðernet_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";
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
@@ -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__":
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (®isters)
|
||||
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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user