Fix USB logging ownership, persistence, and reconnect safety

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

No files matched your search

+27
View File
@@ -2,6 +2,7 @@
#include <helpers/ClientACLCLI.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/LazyPersistence.h>
#include <algorithm>
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
@@ -220,6 +221,11 @@ static void actual_role_dispatch_handles_radio_and_local() {
CHECK(reply[0] == 0);
CHECK(mesh::console.output.find("ACL:") == 0);
CHECK(mesh::console.output.find(row(admin)) != std::string::npos);
std::string local_output = mesh::console.output;
local_output.erase(std::remove(local_output.begin(), local_output.end(), '\r'),
local_output.end());
CHECK(local_output == "ACL:\n" + row(admin) + "\n"
+ row(acl.getClientByIdx(1)) + "\n");
strcpy(command, "get acl");
char paged[] = "get acl 1";
mesh::console.output.clear();
@@ -227,6 +233,27 @@ static void actual_role_dispatch_handles_radio_and_local() {
CHECK(mesh::console.output.empty());
CHECK(reply == expected);
}
// Also exercise every role's local full-table listing, not just remote pages.
// The real extracted helper must emit every active key, skip deleted entries,
// and never include the secret fields stored alongside ACL public identities.
ClientACL full;
full.load(&fs, self);
std::string expected_local = "ACL:\n";
for (unsigned i = 0; i < MAX_CLIENTS; ++i) {
auto* client = add(full, i, i % 7 == 0 ? 0 : PERM_ACL_ADMIN);
if (client->permissions) expected_local += row(client) + "\n";
}
for (auto handler : {repeaterCommand, roomCommand, sensorCommand}) {
char command[] = "get acl", reply[160] = {};
mesh::console.output.clear();
handler(full, nullptr, 0, command, reply);
CHECK(reply[0] == 0);
std::string local_output = mesh::console.output;
local_output.erase(std::remove(local_output.begin(), local_output.end(), '\r'),
local_output.end());
CHECK(local_output == expected_local);
}
}
static void repeater_delegation_stays_denied() {
+11
View File
@@ -25,6 +25,17 @@
*/
// Pinned framework d541301: real power handler before the build-local fix.
// The actual disconnect path is retained too: pull-up disable alone does NOT
// produce a POWER removal edge, so local session recovery needs this event.
void dcd_disconnect(uint8_t rhport) {
(void) rhport;
NRF_USBD->USBPULLUP = 0;
// Disable Pull-up does not trigger Power USB Removed, in fact it have no
// impact on the USB Power status at all -> need to submit unplugged event to the stack.
dcd_event_bus_signal(0, DCD_EVENT_UNPLUGGED, false);
}
void tusb_hal_nrf_power_event(uint32_t event);
void tusb_hal_nrf_power_event(uint32_t event) {
// Value is chosen to be as same as NRFX_POWER_USB_EVT_* in nrfx_power.h
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <cassert>
#include <cstdarg>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
extern uint32_t mock_millis;
inline uint32_t millis() { return mock_millis; }
inline void delay(uint32_t value) { mock_millis += value; }
class Stream {
public:
virtual ~Stream() = default;
virtual int available() = 0;
virtual int read() = 0;
virtual int peek() = 0;
virtual void flush() = 0;
virtual int availableForWrite() { return 0; }
virtual size_t write(uint8_t value) = 0;
virtual size_t write(const uint8_t* data, size_t size) = 0;
size_t printf(const char* format, ...) {
char buffer[256];
va_list args;
va_start(args, format);
const int count = vsnprintf(buffer, sizeof(buffer), format, args);
va_end(args);
assert(count >= 0 && static_cast<size_t>(count) < sizeof(buffer));
return write(reinterpret_cast<const uint8_t*>(buffer), static_cast<size_t>(count));
}
};
class RawSerial {
public:
int availableForWrite() { return 256; }
template <typename... Args> void printf(Args...) { assert(false && "raw USB write"); }
template <typename... Args> void println(Args...) { assert(false && "raw USB write"); }
};
extern RawSerial Serial;
struct MockFicr { uint32_t DEVICEID[2] = {0x123456, 0}; };
extern MockFicr mock_ficr;
#define NRF_FICR (&mock_ficr)
#define NRF_SPIM1 1
#define WB_IO2 2
#define OUTPUT 1
#define HIGH 1
inline void pinMode(int, int) {}
inline void digitalWrite(int, int) {}
inline void vTaskDelete(void*) {}
inline void vTaskDelay(uint32_t value) { delay(value); }
inline uint32_t pdMS_TO_TICKS(uint32_t value) { return value; }
inline void xTaskCreate(void (*)(void*), const char*, int, void*, int, void*) {}
@@ -0,0 +1,63 @@
#pragma once
#include <Arduino.h>
#include <SPI.h>
class IPAddress {
public:
uint8_t operator[](size_t index) const {
static const uint8_t address[4] = {192, 168, 1, 42};
return address[index];
}
};
extern std::string ethernet_output;
extern std::string ethernet_input;
extern bool accept_client;
class EthernetClient {
bool _connected = false;
public:
explicit EthernetClient(bool connected = false) : _connected(connected) {}
explicit operator bool() const { return _connected; }
bool connected() const { return _connected; }
void stop() { _connected = false; }
IPAddress remoteIP() const { return {}; }
int available() const { return static_cast<int>(ethernet_input.size()); }
int read() {
assert(!ethernet_input.empty());
const char value = ethernet_input.front();
ethernet_input.erase(0, 1);
return value;
}
void print(const char* text) { ethernet_output += text; }
void println(const char* text = "") { ethernet_output += std::string(text) + "\r\n"; }
};
class EthernetServer {
public:
explicit EthernetServer(int) {}
void begin() {}
EthernetClient accept() {
const bool accepted = accept_client;
accept_client = false;
return EthernetClient(accepted);
}
};
enum { EthernetNoHardware = 0, EthernetPresent = 1, LinkOFF = 0, LinkON = 1 };
class MockEthernet {
public:
int failures = 0;
int hardware = EthernetPresent;
int link = LinkON;
void init(SPIClass&, int) {}
int begin(uint8_t*, int, int) {
if (failures > 0) { --failures; return 0; }
return 1;
}
int hardwareStatus() const { return hardware; }
int linkStatus() const { return link; }
IPAddress localIP() const { return {}; }
void maintain() {}
};
extern MockEthernet Ethernet;
+7
View File
@@ -0,0 +1,7 @@
#pragma once
class SPIClass {
public:
SPIClass(int, int, int, int) {}
void begin() {}
};
+142
View File
@@ -0,0 +1,142 @@
#include <helpers/NonBlockingWriteStream.h>
#include <helpers/nrf52/EthernetCLI.h>
#include "NetworkLoggingMacro.h"
#include <iostream>
uint32_t mock_millis = 0;
RawSerial Serial;
MockFicr mock_ficr;
MockEthernet Ethernet;
std::string ethernet_output;
std::string ethernet_input;
bool accept_client = false;
class MockUsb : public Stream {
public:
int capacity = 256;
bool refuse_write = false;
unsigned int tries = 0;
std::string output;
int available() override { return 0; }
int read() override { return -1; }
int peek() override { return -1; }
void flush() override { assert(false && "blocking flush"); }
int availableForWrite() override { return capacity; }
size_t write(uint8_t) override { assert(false && "blocking write"); return 0; }
size_t write(const uint8_t*, size_t) override { assert(false && "blocking write"); return 0; }
};
static MockUsb usb;
static bool logging_enabled = true;
static size_t tryUsbWrite(void*, const uint8_t* data, size_t size) {
++usb.tries;
if (usb.refuse_write || usb.capacity <= 0) return 0;
const size_t written = size < static_cast<size_t>(usb.capacity)
? size : static_cast<size_t>(usb.capacity);
usb.output.append(reinterpret_cast<const char*>(data), written);
return written;
}
static mesh::SingleAttemptNonBlockingStream logging_port(usb, tryUsbWrite);
namespace mesh {
bool isUsbLoggingEnabled() { return logging_enabled; }
Stream& usbLoggingPort() { return logging_port; }
}
static void reset() {
usb.output.clear();
usb.capacity = 256;
usb.refuse_write = false;
usb.tries = 0;
logging_enabled = true;
ethernet_running = false;
ethernet_client = EthernetClient();
ethernet_output.clear();
ethernet_input.clear();
accept_client = false;
Ethernet = MockEthernet();
mock_millis = 0;
}
static void acceptClientAndReply() {
accept_client = true;
ethernet_loop_maintain();
assert(ethernet_take_session_reset());
assert(!ethernet_take_session_reset());
ethernet_input = "eth.status\r";
char command[80] = {};
assert(ethernet_read_line(command, sizeof(command)));
assert(strcmp(command, "eth.status") == 0);
char reply[80] = {};
assert(ethernet_handle_command(command, reply));
ethernet_send_reply(reply);
assert(ethernet_output == "MeshCore CLI\r\n\r\n -> ETH: 192.168.1.42:23\r\n");
}
int main() {
(void)&ethernet_start_task;
// Runtime-off diagnostics must not attempt USB, while functional Ethernet
// commands and the client banner still work.
reset();
logging_enabled = false;
ethernet_task(nullptr);
acceptClientAndReply();
assert(usb.output.empty() && usb.tries == 0 && mock_millis == 0);
reset();
ethernet_task(nullptr);
acceptClientAndReply();
assert(usb.output.find("ETH: MAC: 02:92:1F:12:34:56\n") != std::string::npos);
assert(usb.output.find("ETH: Listening on TCP port 23\n") != std::string::npos);
assert(usb.output.find("ETH: Client connected from 192.168.1.42\n") != std::string::npos);
assert(usb.tries == 6 && mock_millis == 0);
// An open-but-stalled host has no TX space. No raw write or wait is allowed.
reset();
usb.capacity = 0;
ethernet_task(nullptr);
acceptClientAndReply();
assert(usb.output.empty() && usb.tries == 0 && mock_millis == 0);
// The host can also vanish after the capacity preflight. The real shared
// single-attempt facade drops each diagnostic instead of retrying or waiting.
reset();
usb.refuse_write = true;
ethernet_task(nullptr);
acceptClientAndReply();
assert(usb.output.empty() && usb.tries == 6 && mock_millis == 0);
for (int failure : {0, 1, 2}) {
reset();
Ethernet.failures = 1;
if (failure == 0) Ethernet.hardware = EthernetNoHardware;
if (failure == 1) Ethernet.link = LinkOFF;
ethernet_task(nullptr);
const char* expected = failure == 0 ? "Hardware not found, giving up"
: failure == 1 ? "Cable not connected, will retry" : "DHCP failed, will retry";
assert(usb.output.find(expected) != std::string::npos);
assert(mock_millis == (failure == 0 ? 0u : 30000u));
}
reset();
int evaluated = 0;
logging_enabled = false;
NETWORK_DEBUG_PRINTLN("suppressed %d", ++evaluated);
assert(evaluated == 0 && usb.tries == 0);
logging_enabled = true;
usb.capacity = 0;
NETWORK_DEBUG_PRINTLN("stalled %d", ++evaluated);
assert(evaluated == 0 && usb.tries == 0);
usb.capacity = 256;
NETWORK_DEBUG_PRINTLN("visible %d", ++evaluated);
#if defined(MQTT_DEBUG) && MQTT_DEBUG
assert(evaluated == 1 && usb.output == "MQTT: visible 1\n" && usb.tries == 1);
usb.refuse_write = true;
NETWORK_DEBUG_PRINTLN("disconnected %d", ++evaluated);
assert(usb.tries == 2 && usb.output == "MQTT: visible 1\n");
#else
assert(evaluated == 0 && usb.output.empty() && usb.tries == 0);
#endif
assert(mock_millis == 0);
std::cout << "PASS: gated USB diagnostics\n";
}
+12
View File
@@ -32,12 +32,23 @@ def role_handler(role, source):
# The room source opens its next conditional branch's preprocessor guard
# immediately before the closing brace of the local ACL branch.
local = re.sub(r"\n#if defined\(WITH_MQTT_NEIGHBORS\)\n\s*}$", "\n}", local)
usb_acl_helper = ""
if role != "sensor":
# Keep local dispatch attached to the actual USB listing helper. These
# fixtures exercise legacy/nonnative USB; native-USB short writes are
# covered separately by test_esp32_tinyusb_cooperative_output.py.
usb_acl_helper = extract_braced(source, "void MyMesh::printAclSerial()")
usb_acl_helper = usb_acl_helper.replace(
"void MyMesh::printAclSerial()",
f"static void {role}PrintAclSerial(ClientACL& acl)", 1)
local = local.replace("printAclSerial();", f"{role}PrintAclSerial(acl);")
guard = ""
if role == "repeater":
guard_start = body.rindex("if (sender && !sender->isAdmin())", 0,
body.index("mesh::cli::handleACLGet("))
guard = extract_braced(body[guard_start:], "if (sender && !sender->isAdmin())")
return f"""
{usb_acl_helper}
static void {role}Command(ClientACL& acl, ClientInfo* sender,
uint32_t sender_timestamp, char* command, char* reply) {{
const int gpio_client_index = sender == nullptr ? -1 : 0;
@@ -95,6 +106,7 @@ namespace mesh { class Utils { public:
result = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra", "-DESP32=1",
"-DESP32_PLATFORM=1", "-DMESH_ENABLE_FLOOD_RULE_ENGINE=1",
"-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=0",
f"-DMAX_CLIENTS={clients}", f"-I{work}", f"-I{ACL_MOCKS}",
f"-I{ROOT / 'src'}", str(FIXTURE / "test_client_acl_cli.cpp"),
"-o", str(binary),
+150
View File
@@ -0,0 +1,150 @@
"""Run the real logging setters with failed saves and failed MQTT transitions."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include "helpers/CLICommandUtils.h"
struct Prefs { uint8_t usb_logging_enabled = 0, bridge_enabled = 0; };
static bool live_usb = false;
static unsigned usb_calls = 0;
namespace mesh {
void setUsbLoggingEnabled(bool value) { live_usb = value; ++usb_calls; }
}
struct Callbacks {
bool live_wifi = false, apply_ok = true;
unsigned calls = 0;
bool setMqttBridgeState(bool value) {
++calls;
if (apply_ok) live_wifi = value;
return apply_ok;
}
};
struct CLI {
Prefs prefs, persisted;
Prefs* _prefs = &prefs;
Callbacks callbacks;
Callbacks* _callbacks = &callbacks;
bool save_ok = true;
unsigned saves = 0;
bool trySavePrefs() { ++saves; if (save_ok) persisted = prefs; return save_ok; }
void set(const char* config, char* reply) { @BRANCHES@ }
};
static void check(const char* command, bool usb, bool wifi, bool failed) {
CLI cli;
cli.prefs = {uint8_t(!usb), uint8_t(!wifi)};
cli.persisted = cli.prefs;
live_usb = !usb;
cli.callbacks.live_wifi = !wifi;
usb_calls = 0;
cli.save_ok = !failed;
char reply[160]{};
cli.set(command, reply);
assert(cli.saves == 1);
assert(cli.prefs.usb_logging_enabled == (failed ? !usb : usb));
assert(cli.persisted.usb_logging_enabled == (failed ? !usb : usb));
assert(live_usb == (failed ? !usb : usb));
assert(usb_calls == (failed ? 0u : 1u));
assert(strstr(reply, failed ? "unchanged" : "(saved)"));
if (strncmp(command, "logging.output", 14) == 0) {
assert(cli.prefs.bridge_enabled == (failed ? !wifi : wifi));
assert(cli.persisted.bridge_enabled == (failed ? !wifi : wifi));
assert(cli.callbacks.live_wifi == (failed ? !wifi : wifi));
assert(cli.callbacks.calls == (failed ? 0u : 1u));
} else {
assert(cli.callbacks.calls == 0);
}
}
int main() {
for (bool failed : {false, true}) {
check("usb.logging on", true, false, failed);
check("usb.logging off", false, false, failed);
#if WITH_MQTT_BRIDGE
check("logging.output off", false, false, failed);
check("logging.output usb", true, false, failed);
check("logging.output wifi", false, true, failed);
check("logging.output both", true, true, failed);
#endif
}
CLI cli;
char reply[160]{};
cli.set("usb.logging invalid", reply);
assert(cli.saves == 0 && strstr(reply, "Error:"));
#if WITH_MQTT_BRIDGE
cli.set("logging.output invalid", reply);
assert(cli.saves == 0 && strstr(reply, "Error:"));
cli.callbacks.apply_ok = false;
cli.set("logging.output both", reply);
assert(cli.persisted.usb_logging_enabled && cli.persisted.bridge_enabled);
assert(live_usb && !cli.callbacks.live_wifi);
assert(strstr(reply, "saved, but MQTT runtime change failed"));
#endif
}
'''
class LoggingTransactionTest(unittest.TestCase):
def test_companion_reply_survives_input_buffer_reset_during_mode_change(self):
source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
branch = extract_braced(source, 'if (strncmp(command, "set logging.output ", 19) == 0)')
harness = r'''
#include <cassert>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include "helpers/CLICommandUtils.h"
static char input[64];
namespace CompanionMqttSetupPortal { static bool saveEnabled(bool) { return true; } }
namespace mesh { static bool saveUsbLoggingBootPreference(bool) { return true; } }
struct Mesh {
bool _mqtt_enabled = true;
struct { unsigned usb_logging_enabled = 1; } _prefs;
void stopMQTT() {}
bool savePrefs() { return true; }
void applyUsbLoggingState(bool) { memset(input, 0, sizeof(input)); }
bool handle(const char* command, char* reply, size_t reply_size) { @BRANCH@ return false; }
};
int main() {
for (const char* mode : {"off", "usb", "wifi", "both"}) {
Mesh node;
char reply[160], expected[160];
snprintf(input, sizeof(input), "set logging.output %s", mode);
snprintf(expected, sizeof(expected), "OK - logging.output %s (saved)", mode);
assert(node.handle(input, reply, sizeof(reply)));
assert(input[0] == 0 && strcmp(reply, expected) == 0);
}
}
'''.replace('@BRANCH@', branch)
with tempfile.TemporaryDirectory() as directory:
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test.exe'
cpp.write_text(harness)
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-I' + str(ROOT / 'src'), str(cpp), '-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
def test_real_setters_preserve_live_and_saved_state_on_failure(self):
source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
usb = extract_braced(source, 'if (strncmp(config, "usb.logging", 11) == 0')
mqtt = extract_braced(source, 'if (strncmp(config, "logging.output", 14) == 0')
for enabled in (0, 1):
with self.subTest(mqtt=enabled), tempfile.TemporaryDirectory() as directory:
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test.exe'
cpp.write_text(HARNESS.replace('@BRANCHES@', usb + ('\n' + mqtt if enabled else '')))
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-DWITH_MQTT_BRIDGE=' + str(enabled),
'-I' + str(ROOT / 'src'), str(cpp), '-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
if __name__ == '__main__':
unittest.main()
+71 -1
View File
@@ -38,16 +38,20 @@ struct Mesh {
bool terminal = false, stale_input = true;
bool isTerminalMode() const { return terminal; }
void resetUsbHostSessionInput() { stale_input = false; }
void applyUsbLoggingState(bool);
} the_mesh;
struct SerialInterface {
bool passthrough = false, old_frame = true;
uint32_t completed = 7;
void setPassthroughMode(bool value) { passthrough = value; }
bool isPassthroughMode() const { return passthrough; }
void resetSessionState() { old_frame = false; }
uint32_t getCompletedFrameCount() const { return completed; }
} usb_serial_interface;
struct Board { bool isUsbHostConnected() const { return data_connected; } } board;
static bool usb_mota_mode = false, usb_logging_terminal_mode = false;
static bool usb_logging_network_parked = false;
static bool usb_protocol_initialized = true;
static bool usb_host_session_connected = false, usb_terminal_discard_line = true;
static bool usb_terminal_host_reset_completion_pending = false;
static uint32_t usb_terminal_host_reset_retry_at = 0;
@@ -57,13 +61,27 @@ static mesh::UsbAsciiSessionDefault usb_ascii_session_default;
static mesh::UsbHostPresenceDebouncer usb_hwcdc_host_presence;
static constexpr unsigned USB_TRANSPORT_RESET_RETRY_MS = 1000;
static constexpr unsigned USB_HOST_LOSS_EDGE_MS = 100, USB_HOST_LOSS_GRACE_MS = 2000;
struct Stream {
int pending = 0;
int available() const { return pending; }
int read() { return pending-- > 0 ? 'x' : -1; }
void print(const char*) {}
} usb_stream;
static Stream& usbTerminalOutput() { return usb_stream; }
namespace mesh {
Stream& usbCompanionPort() { return usb_stream; }
void discardUsbTerminalOutput() {}
bool takeUsbTerminalSessionReset() { bool value = reset_event; reset_event = false; return value; }
bool resetUsbCompanionTransport() { return transport_ready; }
bool tryCompleteUsbTerminalSessionReset() { return transport_ready; }
bool hasDedicatedUsbLoggingPort() { return dedicated_logging; }
bool isUsbLoggingEnabled() { return logging_enabled; }
void setUsbLoggingEnabled(bool enabled) {
if (enabled && usb_protocol_initialized && !dedicated_logging) {
assert(usb_serial_interface.passthrough); // Must park before gate opens.
}
logging_enabled = enabled;
}
}
static bool isUsbTerminalDataConnected() { return data_connected; }
static bool isNetworkTerminalActive() { return network_active; }
@@ -89,6 +107,7 @@ static void enterUsbLoggingTerminalMode() {
static void service() {
serviceUsbTerminalHostSessionReset();
serviceUsbAsciiSessionDefault();
serviceUsbLoggingOwnership(logging_enabled);
}
static void boundary(bool event = true) {
reset_event = event;
@@ -156,6 +175,41 @@ int main() {
boundary(false);
assert(the_mesh.terminal);
#endif
// A network CLI must retain ownership while USB diagnostics exclude frames.
the_mesh.terminal = false;
usb_serial_interface.passthrough = false;
network_active = true;
logging_enabled = false;
the_mesh.applyUsbLoggingState(true);
assert(network_active && !the_mesh.terminal);
assert(usb_serial_interface.passthrough && usb_logging_network_parked);
usb_stream.pending = 5;
serviceUsbLoggingOwnership(logging_enabled);
assert(usb_stream.pending == 5); // Still log-only; no USB CLI takes ownership.
the_mesh.applyUsbLoggingState(false);
assert(network_active && !the_mesh.terminal);
assert(!usb_serial_interface.passthrough && !usb_logging_network_parked);
assert(usb_stream.pending == 0);
logging_enabled = true;
serviceUsbLoggingOwnership(logging_enabled);
network_active = false;
usb_stream.pending = 5;
serviceUsbLoggingOwnership(logging_enabled);
assert(the_mesh.terminal && usb_logging_terminal_mode);
assert(usb_serial_interface.passthrough && !usb_logging_network_parked);
assert(usb_stream.pending == 0);
// An ASCII-restoring network handoff must not accidentally reenable frames.
logging_enabled = false;
usb_logging_network_parked = true;
usb_logging_terminal_mode = false;
serviceUsbLoggingOwnership(logging_enabled);
assert(the_mesh.terminal && usb_serial_interface.passthrough);
// Remote logging cancels exclusive mOTA before the next framed dispatch.
usb_mota_mode = true;
the_mesh.terminal = false;
logging_enabled = true;
serviceUsbLoggingOwnership(logging_enabled);
assert(!usb_mota_mode && the_mesh.terminal && usb_serial_interface.passthrough);
logging_enabled = true;
beginUsbDefaultSession();
assert(the_mesh.terminal && usb_logging_terminal_mode);
@@ -208,9 +262,11 @@ class CompanionUsbDefaultTest(unittest.TestCase):
self.assertIn("companion_transport_boot_mode =\n CompanionTransportMode::Bluetooth;", MAIN)
def test_startup_and_reconnection_for_each_usb_transport(self):
functions = "\n".join(function("static void " + name + "(") for name in (
functions = "\n".join(function("static void " + name + "() {") for name in (
"resetUsbTerminalHostSession", "serviceUsbTerminalHostSessionReset",
"serviceUsbAsciiSessionDefault", "beginUsbDefaultSession"))
functions += "\n" + function("static void serviceUsbLoggingOwnership(bool logging_enabled) {")
functions += "\n" + function("void MyMesh::applyUsbLoggingState(bool enabled) {").replace("MyMesh::", "Mesh::")
profiles = {
"nrf52": ["NRF52_PLATFORM=1"],
"esp32-tinyusb": ["ESP32=1", "ARDUINO_USB_MODE=0", "ARDUINO_USB_CDC_ON_BOOT=1", "MESH_ESP32_TINYUSB_NONBLOCKING=1"],
@@ -237,8 +293,22 @@ class CompanionUsbDefaultTest(unittest.TestCase):
loop = function("\nvoid loop() {")
self.assertLess(loop.index("serviceUsbTerminalHostSessionReset();"), loop.index("serviceUsbAsciiSessionDefault();"))
self.assertLess(loop.index("serviceUsbAsciiSessionDefault();"), loop.index("the_mesh.loop();"))
self.assertLess(loop.index("serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());"), loop.index("the_mesh.loop();"))
self.assertLess(loop.index("expireUsbBinaryStartupProbeBeforeDispatch();"), loop.index("the_mesh.loop();"))
self.assertNotIn("COMPANION_RADIO_FULL", MAIN)
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
self.assertNotIn("mesh::setUsbLoggingEnabled(", source)
logging_reply = source[source.index('if (strncmp(command, "set logging.output ", 19)'):
source.index('if ((strncmp(command, "get ", 4)', source.index('if (strncmp(command, "set logging.output ", 19)'))]
self.assertIn("mesh::cli::loggingOutputName(usb, wifi)", logging_reply)
terminal = function("static void serviceUsbTerminal() {")
self.assertLess(terminal.index("serviceUsbLoggingOwnership("), terminal.index("if (usb_mota_mode)"))
leave = function("static void leaveUsbTerminalMode(bool acknowledge) {")
self.assertIn("!mesh::hasDedicatedUsbLoggingPort() && mesh::isUsbLoggingEnabled()", leave)
browser = function("bool MyMesh::beginStreamTerminal(Stream& output) {")
self.assertIn("serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());", browser)
tcp = function("static void ota_console_loop() {")
self.assertIn("serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());", tcp)
if __name__ == "__main__":
+103 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Execute the real native-TinyUSB role output pumps with small host stubs."""
"""Execute the real native-USB role output pumps with small host stubs."""
from pathlib import Path
import os
@@ -23,8 +23,9 @@ HARNESS = r'''
#include <vector>
#include <cstdint>
#include <helpers/FileRead.h>
#define MESH_ESP32_TINYUSB_NONBLOCKING 1
@USB_CONFIG@
#define MESH_ESP32_USB_CONSOLE_COOPERATIVE 1
#define PUB_KEY_SIZE 32
#define MAX_ROUTE_HASH_BYTES 3
#define PACKET_LOG_FILE "/packet_log"
static void require(bool ok, const char* what) {
@@ -104,6 +105,15 @@ struct SimpleMeshTables {
return result < static_cast<int>(rows.size()) ? &rows[result] : nullptr;
}
};
struct FakeAcl {
struct Client {
struct { uint8_t pub_key[PUB_KEY_SIZE]; } id{};
uint8_t permissions = 3;
};
std::vector<Client> clients;
int getNumClients() const { return static_cast<int>(clients.size()); }
Client* getClientByIdx(int index) { return &clients.at(index); }
};
class MyMesh {
public:
FakeFS* _fs = &fs;
@@ -114,6 +124,9 @@ class MyMesh {
bool serial_log_active = false;
bool serial_log_eof_pending = false;
bool serial_log_skip_line = false;
int serial_acl_next = -1;
int serial_acl_count = 0;
bool serial_acl_header = false;
int serial_recent_next = -1;
int serial_recent_count = 0;
bool serial_recent_header = false;
@@ -121,8 +134,10 @@ class MyMesh {
SimpleMeshTables::RecentRepeaterInfo serial_recent_cursor{};
int serial_recent_cursor_index = -1;
SimpleMeshTables tables;
FakeAcl acl;
const SimpleMeshTables* getTables() const { return &tables; }
void dumpLogFile();
void printAclSerial();
bool hasPendingSerialOutput() const;
void servicePendingSerialOutput();
void cancelPendingSerialOutput();
@@ -207,6 +222,78 @@ int main() {
drain(radio);
require(console.output.empty(), "canceled old-session output leaked");
}
setupFile("");
{
MyMesh radio;
std::string expected = "ACL:\r\n";
for (int i = 0; i < 32; ++i) {
FakeAcl::Client client;
std::fill(std::begin(client.id.pub_key), std::end(client.id.pub_key), i);
radio.acl.clients.push_back(client);
char key[PUB_KEY_SIZE * 2 + 1], line[80];
mesh::Utils::toHex(key, client.id.pub_key, PUB_KEY_SIZE);
snprintf(line, sizeof(line), "%02X %s\n", client.permissions, key);
expected += line;
}
require(expected.size() > 2048, "ACL test does not exceed admission reserve");
console.capacity = 256; // Accepted with a nearly-full HWCDC/logging FIFO.
radio.printAclSerial();
require(console.output.empty(), "ACL is still emitted synchronously");
radio.acl.clients.emplace_back(); // New arrivals must not extend the snapshot.
console.capacity = 0;
for (int i = 0; i < 1000; ++i) radio.servicePendingSerialOutput();
require(console.output.empty(), "ACL wrote into a stalled host");
require(radio.serial_acl_next == 0, "stalled header skipped an ACL row");
console.fail_once = true;
console.short_limit = 17;
drain(radio, 73);
require(console.output == expected, "full ACL lost or duplicated short-write bytes");
require(!radio.hasPendingSerialOutput(), "ACL blocks the next command after completion");
}
setupFile("");
{
MyMesh radio;
radio.acl.clients.resize(32);
for (auto& client : radio.acl.clients) client.permissions = 0;
radio.printAclSerial();
drain(radio, 73);
require(console.output == "ACL:\r\n", "deleted ACL clients were printed");
}
setupFile("");
{
MyMesh radio;
radio.acl.clients.resize(1);
std::fill(std::begin(radio.acl.clients[0].id.pub_key),
std::end(radio.acl.clients[0].id.pub_key), 0xAA);
radio.printAclSerial();
radio.servicePendingSerialOutput();
console.short_limit = 7;
radio.servicePendingSerialOutput();
require(radio.serial_log_pending_size > 0, "ACL short-write suffix was not retained");
radio.cancelPendingSerialOutput();
require(!radio.hasPendingSerialOutput(), "ACL cancellation leaves a pending job");
console.output.clear();
console.records.clear();
console.short_limit = 0;
radio.acl.clients.clear();
radio.printAclSerial();
drain(radio, 73);
require(console.output == "ACL:\r\n", "old ACL row leaked after reconnect cancellation");
}
setupFile("existing file dump\n");
{
MyMesh radio;
radio.dumpLogFile();
console.capacity = 0;
radio.servicePendingSerialOutput();
const size_t pending = radio.serial_log_pending_size;
radio.printAclSerial();
require(radio.serial_log_pending_size == pending && radio.serial_acl_next == -1,
"overlapping ACL command overwrote a pending file record");
drain(radio);
require(console.output == "existing file dump\n" + eof,
"overlapping ACL command corrupted an existing output job");
}
@RECENT_TEST@
std::cout << "cooperative output checks passed\n";
return 0;
@@ -241,12 +328,23 @@ RECENT_TEST = r'''
class CooperativeOutputTest(unittest.TestCase):
def test_usb_acl_commands_route_to_the_cooperative_pump(self):
for role in ("simple_repeater", "simple_room_server"):
with self.subTest(role=role):
text = (ROOT / f"examples/{role}/MyMesh.cpp").read_text()
start = text.index('strcmp(command, "get acl") == 0) {')
end = text.index("reply[0] = 0;", text.index("#endif", start))
self.assertIn("printAclSerial();", text[start:end])
self.assertNotIn("mesh::usbConsolePort().printf", text[start:end])
def test_real_role_pumps(self):
compiler = shutil.which("g++") or shutil.which("clang++")
if not compiler:
self.skipTest("A host C++ compiler is required for pump execution")
for role in ("simple_repeater", "simple_room_server"):
with self.subTest(role=role), tempfile.TemporaryDirectory() as temporary:
for role, config in ((role, config)
for role in ("simple_repeater", "simple_room_server")
for config in ("MESH_ESP32_TINYUSB_NONBLOCKING", "MESH_ESP32_HWCDC_SESSION_GUARD")):
with self.subTest(role=role, transport=config), tempfile.TemporaryDirectory() as temporary:
text = (ROOT / f"examples/{role}/MyMesh.cpp").read_text()
dump_start = text.index("void MyMesh::dumpLogFile()")
dump_end = text.index("\n#if MESH_ESP32_USB_CONSOLE_COOPERATIVE\nbool MyMesh::hasPendingSerialOutput", dump_start)
@@ -261,7 +359,7 @@ class CooperativeOutputTest(unittest.TestCase):
methods = text[format_start:format_end] + "\n" + text[recent_start:recent_end] + "\n" + methods
program = HARNESS.replace("@METHODS@", methods).replace(
"@RECENT_TEST@", RECENT_TEST if role == "simple_repeater" else ""
)
).replace("@USB_CONFIG@", f"#define {config} 1")
executable = Path(temporary) / ("pump.exe" if os.name == "nt" else "pump")
build = subprocess.run(
[compiler, "-std=c++17", "-O0", "-I", str(ROOT / "src"),
+7 -2
View File
@@ -140,15 +140,19 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase):
def test_sleep_keeps_raw_flush_only_for_other_esp32_transports(self):
text = source("src/helpers/ESP32Board.cpp")
shutdown = text[text.index("void ESP32Board::shutdownPeripherals()")
: text.index("void ESP32Board::powerOff()")]
sleep = text[text.index("void ESP32Board::enterDeepSleep(") :]
guard = re.search(
r"#if MESH_ESP32_USB_CONSOLE_COOPERATIVE\s+"
r"mesh::serviceUsbTerminalPort\(\);\s+"
r"#else\s+Serial\.flush\(\);\s+#endif",
sleep,
shutdown,
)
self.assertIsNotNone(guard)
self.assertEqual(sleep.count("Serial.flush();"), 1)
self.assertEqual(shutdown.count("Serial.flush();"), 1)
self.assertNotIn("Serial.flush();", sleep)
self.assertIn("shutdownPeripherals();", sleep)
def test_large_file_dumps_are_bounded_and_cancelable(self):
for role in ROLES:
@@ -180,6 +184,7 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase):
def test_recent_list_advances_only_after_whole_row_admission(self):
text = source("examples/simple_repeater/MyMesh.cpp")
start = text.index("void MyMesh::servicePendingSerialOutput()")
start = text.index("if (serial_recent_next >= 0)", start)
service = text[start : text.index("if (!serial_log_active)", start)]
self.assertIn("char record[64];", service)
self.assertIn("serial_recent_next < serial_recent_count", service)
+35
View File
@@ -31,6 +31,10 @@ HARNESS = r'''
static bool mounted = true, dtr[2] = {false, false}, dfu = false;
static uint32_t now_ms = 100, rx_count = 0, tx_count = 0;
static unsigned rx_flushes = 0, tx_clears = 0, log_edges = 0;
// Model the endpoint separately from the software FIFO. A FIFO clear must
// leave an armed packet intact; only a real detach + enumeration retires it.
static bool armed_in[2] = {false, false}, attached = true;
static unsigned detaches = 0, attaches = 0;
static bool close_during_rx_sample = false;
static uint32_t millis() { return now_ms; }
static bool tud_mounted() { return mounted; }
@@ -46,6 +50,11 @@ static uint32_t tud_cdc_n_available(uint8_t) {
}
static void tud_cdc_n_read_flush(uint8_t) { ++rx_flushes; rx_count = 0; }
static uint32_t tud_cdc_n_write_clear(uint8_t) { ++tx_clears; tx_count = 0; return 0; }
static bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed_in[n]; }
struct UsbDevice {
void detach() { ++detaches; attached = false; }
void attach() { ++attaches; attached = true; }
} TinyUSBDevice;
static uint32_t tud_cdc_n_write_available(uint8_t) { return 64 - tx_count; }
static uint32_t tud_cdc_n_write(uint8_t, const void*, uint32_t size) {
const auto count = std::min(size, 64 - tx_count); tx_count += count; return count;
@@ -144,6 +153,32 @@ int main() {
// No write waits when the FIFO is full, including with DTR low.
tx_count = 64;
assert(mesh::writeTinyUsbCdcOnce(nullptr, bytes, 4) == 0);
// A previously submitted IN packet survives FIFO clearing. Quarantine the
// entire device immediately, but never wait on the unread endpoint.
tx_count = 4; armed_in[0] = true;
rx_count = 4;
assert(mesh::isUsbCompanionClientConnected());
line(false);
assert(tx_count == 0 && armed_in[0] && !attached && detaches == 1);
assert(!mesh::isUsbCompanionClientConnected());
assert(mesh::writeTinyUsbCdcOnce(nullptr, bytes, 4) == 0);
// Repeated control callbacks cannot cause a detach/attach storm.
meshTinyUsbCdcLineCodingChanged(0);
line(false);
assert(detaches == 1);
now_ms += 100;
mesh::serviceNrf52UsbSessionReenumeration();
assert(attaches == 0); // owner has not consumed unplug yet
mounted = false; armed_in[0] = false; dtr[0] = false;
meshTinyUsbDeviceSessionBoundary();
mesh::serviceNrf52UsbSessionReenumeration();
assert(attaches == 1 && attached);
mounted = true;
meshTinyUsbDeviceSessionBoundary();
settle();
rx_count = 4;
assert(mesh::isUsbCompanionClientConnected());
rx_count = 0;
baud = 1200;
line(false);
assert(dfu);
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Run actual nRF52 USB lifecycle code with independent FIFO/endpoint state."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
USB = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
def function(signature):
start = USB.index(signature)
end = USB.index("{", start) + 1
depth = 1
while depth:
depth += (USB[end] == "{") - (USB[end] == "}")
end += 1
return USB[start:end]
class Nrf52UsbLifecycleTest(unittest.TestCase):
def run_native(self, source):
compiler = shutil.which("g++")
if compiler is None:
self.skipTest("C++ compiler unavailable")
with tempfile.TemporaryDirectory() as directory:
cpp = Path(directory) / "lifecycle.cpp"
binary = Path(directory) / "lifecycle"
cpp.write_text(source)
subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra",
str(cpp), "-o", str(binary)], check=True,
capture_output=True)
subprocess.run([str(binary)], check=True, capture_output=True)
def test_framework_detach_queues_local_unmount_without_vbus_edge(self):
pinned_driver = (ROOT / "test/fixtures/nrf52_usb_power_original.c").read_text()
start = pinned_driver.index("void dcd_disconnect(")
disconnect = pinned_driver[start:pinned_driver.index("\n}\n", start) + 3]
self.run_native(r'''
#include <cassert>
#include <cstdint>
static bool mounted = true, vbus = true;
static unsigned owner_events = 0, reset_count = 0;
static constexpr unsigned DCD_EVENT_UNPLUGGED = 0;
struct Registers { unsigned USBPULLUP = 1; } registers;
#define NRF_USBD (&registers)
static void dcd_event_bus_signal(unsigned port, unsigned event, bool isr) {
assert(port == 0 && event == DCD_EVENT_UNPLUGGED && !isr);
++owner_events; // enqueue, do not automatically fake a VBUS/unmount edge
}
@DISCONNECT@
// Actual usbd.c wrapper and Adafruit_USBD_Device.h inline entry point.
static bool tud_disconnect() { dcd_disconnect(0); return true; }
struct Device { bool detach() { return tud_disconnect(); } } TinyUSBDevice;
static void usbd_reset(unsigned port) {
assert(port == 0); mounted = false; ++reset_count;
}
static void tud_umount_cb() {}
static void owner_task() {
assert(owner_events == 1); --owner_events;
// Exact DCD_EVENT_UNPLUGGED owner branch from the pinned usbd.c.
usbd_reset(0);
tud_umount_cb();
}
int main() {
assert(TinyUSBDevice.detach());
assert(vbus && registers.USBPULLUP == 0 && mounted);
assert(owner_events == 1); // without this, recovery would wait forever
owner_task();
assert(vbus && !mounted && reset_count == 1);
}
'''.replace("@DISCONNECT@", disconnect))
def test_descriptor_mutation_is_detached_even_before_mount(self):
source = r'''
#include <atomic>
#include <cassert>
#include <cstdint>
#define MESH_DUAL_CDC_LOGGING 1
static bool attached = true, mounted = false, descriptor_fetched = false;
static unsigned detach_count = 0, attach_count = 0, descriptor_count = 1;
static unsigned delay_ms = 0;
struct Device {
bool mounted() { return ::mounted; }
void detach() { attached = false; ++detach_count; }
void attach() {
assert(descriptor_count == 2 && delay_ms >= 10);
attached = true; ++attach_count;
// Fresh enumeration replaces a host's old cached one-port descriptor.
descriptor_fetched = false;
}
} TinyUSBDevice;
static void delay(unsigned value) { assert(!attached); delay_ms += value; }
namespace mesh {
static std::atomic<bool> usb_logging_preference_known{false};
static bool enabled = false, dedicated_usb_logging_port_started = false;
static bool dedicated_usb_logging_port_configured = false;
static bool isUsbLoggingEnabled() { return enabled; }
static void setPlatformDebugOutputEnabled(bool) {}
struct Port {
void begin(unsigned baud) {
assert(baud == 115200 && !attached && delay_ms >= 10);
++descriptor_count;
}
} dedicated_usb_logging_port;
@BEGIN@
}
int main() {
// No descriptor or reconnect when the preference is unknown/disabled.
mesh::beginUsbLoggingPort();
mesh::usb_logging_preference_known = true;
mesh::beginUsbLoggingPort();
assert(detach_count == 0);
// Host fetched the old descriptor but has not SET_CONFIGURATION yet.
descriptor_fetched = true;
assert(!mounted);
mesh::enabled = true;
mesh::beginUsbLoggingPort();
assert(detach_count == 1 && attach_count == 1 && attached);
assert(!descriptor_fetched && descriptor_count == 2);
assert(mesh::dedicated_usb_logging_port_configured);
mesh::beginUsbLoggingPort();
assert(detach_count == 1 && attach_count == 1); // idempotent
}
'''.replace("@BEGIN@", function("void beginUsbLoggingPort()"))
self.run_native(source)
def test_armed_logging_packet_and_racing_primary_packet_force_clean_bus(self):
start = USB.index("static std::atomic<uint32_t> primary_usb_reset_generation")
gates = USB[start:USB.index("\n#endif", start)]
source = r'''
#include <atomic>
#include <cassert>
#include <cstdint>
#define ENABLE_USB_INTERFACE 1
static bool mounted = true, attached = true, armed[2] = {false, true};
static uint32_t fifo[2] = {0, 32}, now = UINT32_MAX - 10;
static unsigned detach_count = 0, attach_count = 0, reset_count = 0;
static uint32_t millis() { return now; }
static bool tud_mounted() { return mounted; }
static uint32_t tud_cdc_n_available(uint8_t) { return 0; }
static void tud_cdc_n_read_flush(uint8_t) {}
static bool tud_cdc_n_write_clear(uint8_t n) { fifo[n] = 0; return true; }
static bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed[n]; }
struct Device {
void detach() { ++detach_count; attached = false; }
void attach() { ++attach_count; attached = true; }
} TinyUSBDevice;
namespace mesh {
@GATES@
static std::atomic<bool> dedicated_usb_logging_port_connected{true};
static std::atomic<uint32_t> dedicated_usb_logging_reset_generation{0};
static uint8_t dedicated_usb_logging_quiet_sofs = 0;
static constexpr uint8_t dedicated_usb_logging_host_settle_sofs = 50;
static uint32_t primary_usb_terminal_taken_reset_generation = 0;
static void resetDedicatedUsbLoggingUsbTaskState() { ++reset_count; }
@RESTART@
@COMPLETE@
}
int main() {
// CDC1 old data is in endpoint RAM, not the software FIFO. No 50-SOF
// waiting/clearing policy can retract it without re-enumeration.
mesh::restartDedicatedUsbLoggingHostSession();
assert(fifo[1] == 0 && armed[1] && !attached && detach_count == 1);
assert(!mesh::dedicated_usb_logging_port_connected && reset_count == 1);
assert(!mesh::canAccessPrimaryUsbSession(nullptr)); // both ports gated
mesh::restartDedicatedUsbLoggingHostSession();
assert(detach_count == 1); // repeated control requests do not reset timer
now += 19; mounted = false; armed[1] = false;
mesh::serviceNrf52UsbSessionReenumeration();
assert(attach_count == 0); // wrap-safe bounded detach interval
++now;
mesh::serviceNrf52UsbSessionReenumeration();
assert(attach_count == 1 && attached);
mesh::serviceNrf52UsbSessionReenumeration();
assert(attach_count == 1);
// The final exclusive purge also catches an old producer that submitted
// a primary IN packet after the owner callback's first FIFO clear.
mounted = true; armed[0] = true; fifo[0] = 16;
mesh::primary_usb_terminal_taken_reset_generation =
mesh::primaryUsbSessionGeneration();
mesh::completePrimaryUsbSessionReset(nullptr);
assert(fifo[0] == 0 && armed[0] && !attached && detach_count == 2);
assert(!mesh::canAccessPrimaryUsbSession(nullptr));
}
'''
source = source.replace("@GATES@", gates)
source = source.replace("@RESTART@", function("static void restartDedicatedUsbLoggingHostSession()"))
source = source.replace("@COMPLETE@", function("static void completePrimaryUsbSessionReset("))
self.run_native(source)
if __name__ == "__main__":
unittest.main()
+5 -5
View File
@@ -255,7 +255,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase):
):]
self.assertIn("if (instance == 1)", bridge)
self.assertIn("handleDedicatedUsbLoggingLineState(dtr);", bridge)
self.assertIn("tud_cdc_n_write_clear(1);", source)
self.assertIn("clearNrf52UsbTxForSession(1);", source)
self.assertIn("endPrimaryUsbHostSession(true);", bridge)
self.assertIn("if (instance == 0 && !dtr)", bridge)
self.assertIn("tud_cdc_get_line_coding(&coding);", bridge)
@@ -306,7 +306,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase):
)
self.assertLess(
session_end.index("tud_cdc_n_read_flush(0)"),
session_end.index("tud_cdc_n_write_clear(0)"),
session_end.index("clearNrf52UsbTxForSession(0)"),
)
self.assertIn(
"dedicated_usb_logging_line_state_dtr.exchange(", source
@@ -332,7 +332,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase):
restart_gate = restart.index(
"dedicated_usb_logging_port_connected.store(false"
)
restart_fifo = restart.index("tud_cdc_n_write_clear(1)")
restart_fifo = restart.index("clearNrf52UsbTxForSession(1)")
restart_state = restart.index("resetDedicatedUsbLoggingUsbTaskState()")
restart_quiet = restart.index("dedicated_usb_logging_quiet_sofs =")
restart_generation = restart.index(
@@ -368,7 +368,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase):
self.assertLess(marker_write, queued_drain)
quiet_block = owner_service[quiet_start:positive_gate]
quiet_fifo = quiet_block.index("tud_cdc_n_write_clear(1)")
quiet_fifo = quiet_block.index("clearNrf52UsbTxForSession(1)")
quiet_state = quiet_block.index(
"resetDedicatedUsbLoggingUsbTaskState()"
)
@@ -595,7 +595,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase):
# A second purge after the settle gate would erase a new host's
# immediately sent APP_START (meshcli does not delay after open).
self.assertNotIn("tud_cdc_n_read_flush(0);", completion)
self.assertIn("tud_cdc_n_write_clear(0);", completion)
self.assertIn("clearNrf52UsbTxForSession(0);", completion)
self.assertIn("primary_usb_allowed_generation.store(", completion)
self.assertIn(".tryRunExclusive(", source)
self.assertIn("primary_usb_reset_settle_until.store(", source)
+113 -1
View File
@@ -46,6 +46,116 @@ int main() {
class UsbPowerTests(unittest.TestCase):
def test_cdc_query_tracks_armed_endpoint_not_fifo_bytes(self):
original = r'''
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include "cdc_device.h"
#define CFG_TUD_CDC 2
struct { uint8_t ep_in; unsigned tx_ff; } _cdcd_itf[2];
static bool armed[256];
static bool usbd_edpt_busy(uint8_t port, uint8_t endpoint) {
assert(port == 0); return armed[endpoint];
}
static bool tu_fifo_clear(unsigned* fifo) { *fifo = 0; return true; }
'''+ FIX.CDC_WRITE_CLEAR + r'''
int main(void) {
_cdcd_itf[0].ep_in = 0x81;
_cdcd_itf[1].ep_in = 0x83;
_cdcd_itf[0].tx_ff = 16;
armed[0x81] = true;
assert(mesh_tud_cdc_n_tx_pending(0));
tud_cdc_n_write_clear(0);
assert(_cdcd_itf[0].tx_ff == 0 && mesh_tud_cdc_n_tx_pending(0));
assert(!mesh_tud_cdc_n_tx_pending(1));
assert(!mesh_tud_cdc_n_tx_pending(2));
armed[0x81] = false;
assert(!mesh_tud_cdc_n_tx_pending(0));
_cdcd_itf[1].ep_in = 0;
armed[0] = true; // unopened CDC must not query control endpoint
assert(!mesh_tud_cdc_n_tx_pending(1));
}
'''
patched = FIX.patched_cdc_source(original)
self.assertEqual(patched, FIX.patched_cdc_source(patched))
self.assertEqual(patched, FIX.patched_cdc_source(original.replace("\n", "\r\n")))
compiler = shutil.which("gcc")
if compiler is None:
self.skipTest("C compiler unavailable")
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "cdc.c"
binary = Path(directory) / "cdc"
header = Path(directory) / "class/cdc/cdc_device.h"
header.parent.mkdir(parents=True)
header.write_text("// Only the isolated session-query API is tested here.\n")
source.write_text(patched)
subprocess.run([compiler, "-std=c11", "-Wall", "-Wextra", "-Werror",
"-I", directory, str(source), "-o", str(binary)],
check=True, capture_output=True)
subprocess.run([str(binary)], check=True, capture_output=True)
def test_relocated_cdc_driver_compiles_against_real_tinyusb_headers(self):
# Exercise the middleware's actual relocated file, not just an API
# extraction: cdc_device.h is originally a sibling of cdc_device.c.
# CI without this hardware framework can still run the isolated API
# and relocation guards; a local hardware build runs this full check.
sdk = (Path.home() / ".platformio/packages/framework-arduinoadafruitnrf52/"
"libraries/Adafruit_TinyUSB_Arduino/src")
original = sdk / "class/cdc/cdc_device.c"
compiler = shutil.which("gcc")
if compiler is None or not original.is_file():
self.skipTest("native compiler/nRF52 TinyUSB framework unavailable")
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
config = root / "tusb_config.h"
config.write_text("""
#define CFG_TUSB_MCU OPT_MCU_NONE
#define CFG_TUSB_OS OPT_OS_NONE
#define CFG_TUSB_RHPORT0_MODE OPT_MODE_DEVICE
#define CFG_TUD_ENABLED 1
#define CFG_TUD_CDC 2
#define CFG_TUD_CDC_RX_BUFSIZE 256
#define CFG_TUD_CDC_TX_BUFSIZE 256
#define CFG_TUSB_DEBUG 0
""")
class Node:
def srcnode(self): return self
def get_abspath(self): return str(original)
class Env:
def subst(self, value):
assert value == "$BUILD_DIR"
return str(root / "build")
def File(self, value): return Path(value)
patched = FIX.replace_driver(Env(), Node())
self.assertNotEqual(patched.parent, original.parent)
self.assertIn(FIX.CDC_ROOT_INCLUDE, patched.read_text())
result = subprocess.run(
[compiler, "-std=c11", '-DCFG_TUSB_CONFIG_FILE="' + config.as_posix() + '"',
"-I", str(root), "-I", str(sdk),
"-c", str(patched), "-o", str(root / "cdc.o")],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_changed_cdc_driver_fails_closed(self):
prefix = FIX.CDC_LOCAL_INCLUDE + "\n"
for source in ("", prefix + FIX.CDC_WRITE_CLEAR.replace("tx_ff", "tx_fifo"),
prefix + FIX.CDC_WRITE_CLEAR * 2,
prefix + FIX.CDC_WRITE_CLEAR + FIX.CDC_SESSION_QUERY,
FIX.CDC_ROOT_INCLUDE + "\n" + FIX.CDC_WRITE_CLEAR):
with self.subTest(source=source), self.assertRaises(RuntimeError):
FIX.patched_cdc_source(source)
def test_cdc_query_is_built_from_local_copy(self):
hooks = []
class Env:
def AddBuildMiddleware(self, handler, pattern):
hooks.append((handler, pattern))
FIX.install(Env())
self.assertEqual(len(hooks), 3)
self.assertTrue(any("class*cdc*cdc_device.c" in pattern
for _, pattern in hooks))
def test_actual_handler_and_inherited_hang(self):
exercise(FIX.patched_source(ORIGINAL))
@@ -76,7 +186,9 @@ class UsbPowerTests(unittest.TestCase):
def test_unrecognized_or_partly_fixed_driver_fails_closed(self):
for source in ("", ORIGINAL.replace("hfclk_running()", "clock_running()"),
ORIGINAL + ORIGINAL, ORIGINAL.replace(FIX.OLD_READY, FIX.READY)):
ORIGINAL + ORIGINAL, ORIGINAL.replace(FIX.OLD_READY, FIX.READY),
ORIGINAL.replace("dcd_event_bus_signal(0, DCD_EVENT_UNPLUGGED, false);",
"// removed local disconnect event")):
with self.subTest(source=source[:40]), self.assertRaises(RuntimeError):
FIX.patched_source(source)
@@ -858,9 +858,15 @@ TEST(SerialModeSwitch, SingleTtyLoggingCannotStealNetworkTerminal) {
using Action = mesh::UsbLoggingTerminalAction;
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
false, true, false, false, true, true),
Action::NO_ACTION);
Action::PARK_USB_FOR_NETWORK);
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
false, true, false, false, false, true),
Action::PARK_USB_FOR_NETWORK);
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
true, true, false, false, true, true),
Action::NO_ACTION);
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
false, false, false, false, true, true),
Action::NO_ACTION);
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Exercise Ethernet diagnostics and the production NetworkLink logging macro."""
from pathlib import Path
import os
import re
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "test/fixtures/usb_diagnostic_logging"
class UsbDiagnosticLoggingTests(unittest.TestCase):
def test_runtime_gate_backpressure_and_functional_ethernet_output(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++17 compiler is required")
network = (ROOT / "src/helpers/NetworkLink.cpp").read_text(encoding="utf-8")
start = network.index("// Same \"MQTT: \" prefix")
macro = network[start:network.index("namespace {", start)]
with tempfile.TemporaryDirectory(prefix="meshcore-usb-diagnostics-") as directory:
temporary = Path(directory)
(temporary / "NetworkLoggingMacro.h").write_text(macro, encoding="utf-8")
for debug in (None, 0, 1):
with self.subTest(mqtt_debug=debug):
binary = temporary / "usb-diagnostics.exe"
defines = [] if debug is None else [f"-DMQTT_DEBUG={debug}"]
compiled = subprocess.run(
[compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-DARDUINO=1", "-DETHERNET_ENABLED=1", *defines,
f"-I{FIXTURE / 'mocks'}", f"-I{ROOT / 'src'}",
f"-I{temporary}", str(FIXTURE / "test.cpp"),
"-o", str(binary)],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
checked = subprocess.run(
[str(binary)], capture_output=True, text=True, timeout=10,
)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("PASS: gated USB diagnostics", checked.stdout)
def test_no_raw_serial_diagnostic_writes(self):
for relative in ("src/helpers/nrf52/EthernetCLI.h", "src/helpers/NetworkLink.cpp"):
with self.subTest(source=relative):
source = (ROOT / relative).read_text(encoding="utf-8")
self.assertIn("UsbLogging.h", source)
self.assertIn("mesh::isUsbLoggingEnabled()", source)
self.assertIn("mesh::usbLoggingPort()", source)
self.assertIsNone(re.search(r"\bSerial\.(?:print|println|printf|write)\s*\(", source))
if __name__ == "__main__":
unittest.main()