mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 01:51:23 +00:00
343 lines
16 KiB
Python
343 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Execute initial HWCDC enumeration with the real ISR and session cleanup.
|
|
|
|
The RTOS event queue, USB pads and RX/ring queues are peripheral stubs. The
|
|
pinned SDK ISR, MeshCore event handler, session gate and cleanup are production
|
|
code. This specifically tests the gap between Serial.begin() and the first
|
|
application command service, including delayed framework event delivery.
|
|
"""
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
import test_hwcdc_tx_backport as backport
|
|
|
|
PATCHED = backport.PATCHED
|
|
body = backport.body
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
EXTRA = r'''
|
|
#include <helpers/UsbAsciiBinarySwitch.h>
|
|
#include <iostream>
|
|
#include <string>
|
|
#define MESH_ESP32_HWCDC_SESSION_GUARD 1
|
|
#define MESH_ESP32_USB_CONSOLE_COOPERATIVE 1
|
|
#define MESH_HWCDC_PINNED_TX_BACKPORT 1
|
|
#define portENTER_CRITICAL(m) portENTER_CRITICAL_SAFE(m)
|
|
#define portEXIT_CRITICAL(m) portEXIT_CRITICAL_SAFE(m)
|
|
using esp_event_base_t = int;
|
|
static bool xPortInIsrContext() { return false; }
|
|
static uint32_t clock_ms;
|
|
static uint32_t millis() { return clock_ms; }
|
|
static void delay(uint32_t ms) { clock_ms += ms; }
|
|
static bool pads_enabled = true;
|
|
static unsigned phy_detaches = 0;
|
|
static std::deque<uint8_t> host_rx;
|
|
static bool detachEsp32HwcdcPads() {
|
|
++phy_detaches;
|
|
const bool previous = pads_enabled;
|
|
pads_enabled = plugged = false;
|
|
return previous;
|
|
}
|
|
static void restoreEsp32HwcdcPads(bool enabled) {
|
|
pads_enabled = plugged = enabled;
|
|
}
|
|
static void setPlatformDebugOutputEnabled(bool) {}
|
|
static bool isUsbDebugLoggingEnabled() { return false; }
|
|
static void clearUsbLoggingClientActivity() {}
|
|
static void noteUsbLoggingTxComplete() {}
|
|
static std::atomic<uint32_t> esp32_hwcdc_access_generation{0};
|
|
static std::atomic<uint32_t> esp32_hwcdc_allowed_generation{0};
|
|
static std::atomic<uint32_t> esp32_hwcdc_bus_reset_generation{0};
|
|
static std::atomic<bool> esp32_hwcdc_rx_queue_ready{true};
|
|
static uint32_t esp32_hwcdc_taken_bus_reset_generation = 0;
|
|
static mesh::UsbSelfResetBurstGuard esp32_hwcdc_self_reset_guard;
|
|
@STARTUP_STATE@
|
|
static std::atomic<bool> esp32_hwcdc_tx_kick_pending{false};
|
|
static std::atomic<bool> esp32_hwcdc_tx_primed{false};
|
|
static portMUX_TYPE esp32_hwcdc_session_mux;
|
|
static std::atomic<size_t> esp32_hwcdc_tx_buffer_capacity{4096};
|
|
static bool esp32_hwcdc_cleanup_pending = false;
|
|
static bool esp32_hwcdc_restore_pad_enabled = false;
|
|
static uint32_t esp32_hwcdc_cleanup_generation = 0;
|
|
static std::atomic<uint32_t> usb_logging_tx_progress{0};
|
|
static std::atomic<bool> usb_logging_tx_waiting{false};
|
|
struct SerialMock {
|
|
unsigned flush_calls = 0;
|
|
int availableForWrite() {
|
|
unsigned waiting = 0;
|
|
vRingbufferGetInfo(&ring, nullptr, nullptr, nullptr, nullptr, &waiting);
|
|
return 4096 - waiting;
|
|
}
|
|
void flush() {
|
|
++flush_calls;
|
|
if (!ring.data.empty()) ring.data.pop_front();
|
|
}
|
|
int read() {
|
|
if (host_rx.empty()) return -1;
|
|
const int byte = host_rx.front(); host_rx.pop_front(); return byte;
|
|
}
|
|
bool isPlugged() { return plugged; }
|
|
operator bool() { return connected && plugged; }
|
|
void setDebugOutput(bool) {}
|
|
} Serial;
|
|
struct Exclusive {
|
|
bool tryRunExclusive(void (*function)(void*), void* opaque) {
|
|
function(opaque); return true;
|
|
}
|
|
} guarded_esp32_hwcdc_port;
|
|
struct Esp32HwcdcPurgeResult { bool tx_empty = false; };
|
|
struct UsbLoggingObservation {
|
|
uint32_t tx_progress = 0;
|
|
bool supported = false, host_connected = false, reader_connected = false;
|
|
bool pending = false;
|
|
};
|
|
@FUNCTIONS@
|
|
@OWNER_RESET_HOOK@
|
|
static void deliverEvents() {
|
|
while (!queued_events.empty()) {
|
|
const int event = queued_events.front(); queued_events.pop_front();
|
|
handleEsp32HwcdcEvent(nullptr, 0, event, nullptr);
|
|
}
|
|
}
|
|
static void enumerate() {
|
|
intr_status = USB_SERIAL_JTAG_INTR_BUS_RESET;
|
|
hw_cdc_isr_handler(nullptr);
|
|
}
|
|
static void sendFirstCommand() {
|
|
host_rx = {'v', 'e', 'r', '\r'};
|
|
queued_events.push_back(ARDUINO_HW_CDC_RX_EVENT);
|
|
}
|
|
static void firstService() {
|
|
// These are the calls the real role makes before processing commands.
|
|
assert(!takeUsbTerminalSessionReset());
|
|
assert(tryCompleteUsbTerminalSessionReset());
|
|
}
|
|
static void requireFirstCommand() {
|
|
assert(phy_detaches == 0 && pads_enabled && canAccessEsp32Hwcdc(nullptr));
|
|
assert((host_rx == std::deque<uint8_t>{'v', 'e', 'r', '\r'}));
|
|
}
|
|
int main(int argc, char** argv) {
|
|
assert(argc == 2);
|
|
const std::string name = argv[1];
|
|
reset(); plugged = true;
|
|
if (name == "early") {
|
|
enumerate(); sendFirstCommand(); deliverEvents(); firstService();
|
|
requireFirstCommand();
|
|
} else if (name == "delayed") {
|
|
enumerate(); sendFirstCommand(); firstService(); deliverEvents();
|
|
assert(tryCompleteUsbTerminalSessionReset()); requireFirstCommand();
|
|
} else if (name == "boot_tx") {
|
|
// Real ISR stages a partial boot diagnostic before the host enumerates.
|
|
ring.data.push_back({'F', 'i', 'r', 'm', 'w', 'a', 'r', 'e', ':', '\r', '\n'});
|
|
pulse(4); assert(mesh_hwcdc_tx_stash_len != 0 && mesh_hwcdc_fifo_pending);
|
|
deliverEvents(); // Boot TX is not application protocol readiness.
|
|
enumerate();
|
|
assert(mesh_hwcdc_tx_stash_len == 0 && !mesh_hwcdc_fifo_pending);
|
|
sendFirstCommand(); firstService(); deliverEvents();
|
|
assert(tryCompleteUsbTerminalSessionReset()); requireFirstCommand();
|
|
} else if (name == "boot_rx") {
|
|
// A browser can submit input before setup finishes. An RX event alone
|
|
// cannot make its subsequent enumeration trigger another PHY detach.
|
|
sendFirstCommand(); deliverEvents(); enumerate();
|
|
firstService(); deliverEvents();
|
|
assert(tryCompleteUsbTerminalSessionReset()); requireFirstCommand();
|
|
} else if (name == "active") {
|
|
firstService(); assert(meshEsp32HwcdcShouldReportBusReset());
|
|
ring.data.push_back({'o', 'l', 'd', '\r', '\n'});
|
|
host_rx = {'o', 'l', 'd', '\r'};
|
|
enumerate(); deliverEvents();
|
|
assert(!canAccessEsp32Hwcdc(nullptr));
|
|
assert(takeUsbTerminalSessionReset());
|
|
assert(tryCompleteUsbTerminalSessionReset());
|
|
assert(phy_detaches == 1 && host_rx.empty() && ring.data.empty());
|
|
assert(canAccessEsp32Hwcdc(nullptr) && mesh_hwcdc_tx_allowed);
|
|
} else if (name == "logging_only") {
|
|
// A BLE-only Companion has no USB parser to close the startup exemption.
|
|
// Its real logging-only service must do so before the next host reset.
|
|
enumerate(); deliverEvents(); serviceUsbLoggingOnlySession();
|
|
assert(meshEsp32HwcdcShouldReportBusReset());
|
|
assert(phy_detaches == 0 && canAccessEsp32Hwcdc(nullptr));
|
|
ring.data.push_back({'o', 'l', 'd', '\r', '\n'});
|
|
host_rx = {'o', 'l', 'd', '\r'};
|
|
enumerate(); deliverEvents();
|
|
assert(!canAccessEsp32Hwcdc(nullptr));
|
|
serviceUsbLoggingOnlySession();
|
|
assert(phy_detaches == 1 && host_rx.empty() && ring.data.empty());
|
|
assert(canAccessEsp32Hwcdc(nullptr));
|
|
} else { assert(false && "Unknown startup case"); }
|
|
std::cout << "PASS " << name << " phy_detaches=" << phy_detaches
|
|
<< " retained_rx=" << host_rx.size() << '\n';
|
|
}
|
|
'''
|
|
|
|
|
|
def application_sources(*, remove_boot_gate=False, remove_owner_hook=False,
|
|
boot_traffic_arms=False, keep_startup_forever=False,
|
|
remove_logging_only_service=False):
|
|
source = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
|
|
signatures = (
|
|
"static bool canAccessEsp32Hwcdc(void*)",
|
|
"static void handleEsp32HwcdcEvent(",
|
|
"static void serviceEsp32HwcdcTxKickExclusive(",
|
|
"static void purgeEsp32HwcdcQueues(",
|
|
"bool resetUsbCompanionTransport(",
|
|
"UsbLoggingObservation observeUsbLoggingTransport(",
|
|
"bool takeUsbTerminalSessionReset()",
|
|
"bool tryCompleteUsbTerminalSessionReset()",
|
|
)
|
|
functions = "\n".join(body(source, signature) for signature in signatures)
|
|
main = (ROOT / "examples/companion_radio/main.cpp").read_text()
|
|
logging_service = body(main, "static void serviceUsbLoggingOnlySession()")
|
|
# The production session methods are extracted into this flat host shell.
|
|
logging_service = logging_service.replace("mesh::", "")
|
|
if remove_logging_only_service:
|
|
logging_service = logging_service.replace(
|
|
" (void)takeUsbTerminalSessionReset();\n", "").replace(
|
|
" (void)tryCompleteUsbTerminalSessionReset();\n", "")
|
|
functions += "\n" + logging_service
|
|
owner = body(source, 'extern "C" bool meshEsp32HwcdcShouldReportBusReset()')
|
|
# The extracted application methods live in a flat test shell. Only their
|
|
# namespace qualification changes; state and the actual owner hook remain.
|
|
owner = owner.replace("mesh::esp32_hwcdc_startup_pending", "esp32_hwcdc_startup_pending")
|
|
state = re.search(r"^static std::atomic<bool> esp32_hwcdc_startup_pending.*;",
|
|
source, re.MULTILINE)
|
|
assert state is not None
|
|
if remove_boot_gate:
|
|
functions = functions.replace(
|
|
" if (esp32_hwcdc_startup_pending.load(std::memory_order_acquire)) return;\n", "")
|
|
owner = owner.replace("!esp32_hwcdc_startup_pending.load(std::memory_order_acquire)", "true")
|
|
if remove_owner_hook:
|
|
owner = owner.replace("!esp32_hwcdc_startup_pending.load(std::memory_order_acquire)", "true")
|
|
if boot_traffic_arms:
|
|
functions = functions.replace(
|
|
" esp32_hwcdc_self_reset_guard.notePostCleanActivity();",
|
|
" esp32_hwcdc_startup_pending.store(false, std::memory_order_release);\n"
|
|
" esp32_hwcdc_self_reset_guard.notePostCleanActivity();")
|
|
if keep_startup_forever:
|
|
functions = functions.replace(
|
|
" esp32_hwcdc_startup_pending.store(false, std::memory_order_release);", "")
|
|
return state[0], functions, owner
|
|
|
|
|
|
def harness(**controls):
|
|
state, functions, owner = application_sources(**controls)
|
|
prefix = backport.BackportTest().harness(PATCHED).split("int main(){", 1)[0]
|
|
prefix = prefix.replace("static std::vector<unsigned> event_lengths;",
|
|
"static std::vector<unsigned> event_lengths;\n"
|
|
"static std::deque<int> queued_events;")
|
|
prefix = prefix.replace(
|
|
" if(event==ARDUINO_HW_CDC_TX_EVENT)event_lengths.push_back(data->tx.len);",
|
|
" queued_events.push_back(event);\n"
|
|
" if(event==ARDUINO_HW_CDC_TX_EVENT)event_lengths.push_back(data->tx.len);")
|
|
return prefix + EXTRA.replace("@STARTUP_STATE@", state).replace(
|
|
"@FUNCTIONS@", functions).replace("@OWNER_RESET_HOOK@", owner)
|
|
|
|
|
|
class HwcdcStartupSessionTests(unittest.TestCase):
|
|
def run_case(self, case, *, expect_success=True, **controls):
|
|
with tempfile.TemporaryDirectory(prefix="hwcdc-startup-") as directory:
|
|
directory = Path(directory)
|
|
cpp, binary = directory / "startup.cpp", directory / "startup"
|
|
cpp.write_text(harness(**controls), encoding="ascii")
|
|
compiled = subprocess.run([
|
|
"g++", "-std=c++17", "-pthread", "-Wall", "-Wextra",
|
|
"-Wno-unused-parameter", "-Wno-sign-compare",
|
|
"-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
|
"-fno-pie", "-no-pie", "-I" + str(ROOT / "src"),
|
|
str(cpp), "-o", str(binary)], capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
|
|
result = subprocess.run([str(binary), case], capture_output=True,
|
|
text=True, timeout=10)
|
|
if expect_success:
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
else:
|
|
self.assertNotEqual(result.returncode, 0, "Negative control did not fail")
|
|
|
|
def test_initial_enumeration_retains_command_and_active_reset_still_purges(self):
|
|
for case in ("early", "delayed", "boot_tx", "boot_rx", "active"):
|
|
with self.subTest(case=case):
|
|
self.run_case(case)
|
|
|
|
def test_logging_only_companion_ends_startup_and_cleans_active_reset(self):
|
|
self.run_case("logging_only")
|
|
|
|
def test_missing_logging_only_service_keeps_startup_pending_negative_control(self):
|
|
self.run_case("logging_only", expect_success=False, remove_logging_only_service=True)
|
|
|
|
def test_logging_only_companion_scope_uses_actual_transport_macros(self):
|
|
main = (ROOT / "examples/companion_radio/main.cpp").read_text()
|
|
helper = body(main, "static void serviceUsbLoggingOnlySession()")
|
|
loop = body(main[main.rindex("\nvoid loop()"):], "void loop()")
|
|
self.assertLess(loop.index("serviceUsbLoggingOnlySession();"),
|
|
loop.index("the_mesh.loop();"))
|
|
self.assertLess(loop.index("serviceUsbLoggingOnlySession();"),
|
|
loop.index("mesh::serviceUsbLoggingPort();"))
|
|
harness = r'''
|
|
#include <helpers/UsbLogging.h>
|
|
#include <cassert>
|
|
namespace mesh {
|
|
static unsigned calls = 0;
|
|
bool takeUsbTerminalSessionReset() { ++calls; return true; }
|
|
bool tryCompleteUsbTerminalSessionReset() { ++calls; return true; }
|
|
}
|
|
@HELPER@
|
|
int main() {
|
|
serviceUsbLoggingOnlySession();
|
|
assert(mesh::calls == EXPECT_SERVICE * 2);
|
|
}
|
|
'''.replace("@HELPER@", helper)
|
|
profiles = {
|
|
"hwcdc": ["ESP32=1", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=1"],
|
|
"tinyusb": ["ESP32=1", "ARDUINO_USB_MODE=0", "ARDUINO_USB_CDC_ON_BOOT=1"],
|
|
"new_uart": ["ESP32=1", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=0"],
|
|
"classic_uart": ["ESP32=1"],
|
|
"nrf52": ["NRF52_PLATFORM=1", "USE_TINYUSB=1"],
|
|
"rp2040": ["RP2040_PLATFORM=1"],
|
|
}
|
|
with tempfile.TemporaryDirectory(prefix="companion-logging-session-") as directory:
|
|
directory = Path(directory)
|
|
cpp = directory / "scope.cpp"
|
|
cpp.write_text(harness, encoding="ascii")
|
|
for profile, flags in profiles.items():
|
|
for usb_protocol in (False, True):
|
|
with self.subTest(profile=profile, usb_protocol=usb_protocol):
|
|
binary = directory / (profile + str(usb_protocol))
|
|
defines = ["-DARDUINO", *("-D" + flag for flag in flags),
|
|
"-DEXPECT_SERVICE=" + str(int(
|
|
profile in ("hwcdc", "tinyusb") and not usb_protocol))]
|
|
if usb_protocol:
|
|
defines.append("-DENABLE_USB_INTERFACE=1")
|
|
compiled = subprocess.run([
|
|
"g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
|
"-isystem", str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"),
|
|
*defines, str(cpp), "-o", str(binary)],
|
|
capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(compiled.returncode, 0,
|
|
compiled.stdout + compiled.stderr)
|
|
executed = subprocess.run([str(binary)], capture_output=True,
|
|
text=True, timeout=10)
|
|
self.assertEqual(executed.returncode, 0,
|
|
executed.stdout + executed.stderr)
|
|
|
|
def test_original_boot_gate_failure_negative_control(self):
|
|
self.run_case("early", expect_success=False, remove_boot_gate=True)
|
|
|
|
def test_delayed_owner_event_negative_control(self):
|
|
self.run_case("delayed", expect_success=False, remove_owner_hook=True)
|
|
|
|
def test_boot_traffic_cannot_end_startup_negative_controls(self):
|
|
for case in ("boot_tx", "boot_rx"):
|
|
with self.subTest(case=case):
|
|
self.run_case(case, expect_success=False, boot_traffic_arms=True)
|
|
|
|
def test_post_ready_quarantine_remains_required_negative_control(self):
|
|
self.run_case("active", expect_success=False, keep_startup_forever=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|