Files
HaloKeymind/test/test_companion_usb_default.py
T

364 lines
17 KiB
Python

#!/usr/bin/env python3
"""Execute the firmware's startup and host-reset functions on each USB type."""
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
MAIN = (ROOT / "examples/companion_radio/main.cpp").read_text()
def function(signature):
start = MAIN.index(signature)
end = MAIN.index("{", start) + 1
depth = 1
while depth:
depth += (MAIN[end] == "{") - (MAIN[end] == "}")
end += 1
return MAIN[start:end]
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <initializer_list>
#include "helpers/UsbAsciiBinarySwitch.h"
#define COMPANION_FEATURE_USB_MOTA_SOURCE 1
#define COMPANION_FEATURE_NETWORK_TERMINAL 1
#define MESH_USB_LOGGING_AVAILABLE 1
static bool data_connected = true, reset_event = false, transport_ready = true;
static bool network_active = false, logging_enabled = false, dedicated_logging = false;
static uint32_t clock_ms = 2000;
static unsigned cancelled_routes = 0, entered_ascii = 0;
static bool announced_ascii = false;
static uint32_t millis() { return clock_ms; }
struct Mesh {
bool terminal = false, stale_input = true;
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_logging_reply_hold = false, usb_logging_reply_staged = false;
static bool usb_logging_reply_pending = false, usb_logging_reply_state = false;
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;
static unsigned line_length = 5;
static mesh::UsbBinaryStartupProbe usb_binary_startup_probe;
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 {
namespace ota {
struct SerialFolderSource {
bool pending_response = false;
unsigned resets = 0;
bool hasPendingResponse() const { return pending_response; }
void resetSessionState() { ++resets; pending_response = false; }
};
struct OtaContext {
static SerialFolderSource& serialFolderSource() {
static SerialFolderSource source;
return source;
}
};
}
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; }
static void clearUsbTerminalLine() { line_length = 0; }
static void cancelUsbSerialOperations() { ++cancelled_routes; }
static void leaveUsbMotaMode(bool) { usb_mota_mode = false; }
static void leaveUsbTerminalMode(bool) { the_mesh.terminal = false; }
static void enterUsbTerminalMode(bool show_banner = true) {
assert(transport_ready && !network_active);
++entered_ascii;
announced_ascii = show_banner;
the_mesh.terminal = true;
usb_serial_interface.passthrough = true;
usb_binary_startup_probe.cancel();
usb_ascii_session_default.cancel();
usb_logging_terminal_mode = false;
}
static void enterUsbLoggingTerminalMode() {
enterUsbTerminalMode();
usb_logging_terminal_mode = true;
}
@FUNCTIONS@
static void service() {
serviceUsbTerminalHostSessionReset();
serviceUsbAsciiSessionDefault();
serviceUsbLoggingOwnership(logging_enabled);
}
static void boundary(bool event = true) {
reset_event = event;
data_connected = false;
service();
}
int main() {
auto& source = mesh::ota::OtaContext::serialFolderSource();
// No Full macro is required: ordinary USB Companions also boot in ASCII.
beginUsbDefaultSession();
assert(the_mesh.terminal && usb_serial_interface.passthrough);
assert(!announced_ascii);
service(); // observe the open host before it can close
for (bool binary : {false, true}) {
the_mesh.terminal = !binary;
usb_serial_interface.passthrough = !binary;
the_mesh.stale_input = true;
usb_serial_interface.old_frame = true;
line_length = 5;
usb_binary_startup_probe.start(clock_ms, usb_serial_interface.completed);
source.pending_response = true;
const unsigned previous_resets = source.resets;
boundary();
assert(!source.hasPendingResponse() && source.resets == previous_resets + 1);
assert(the_mesh.terminal && usb_serial_interface.passthrough);
assert(!announced_ascii);
assert(!the_mesh.stale_input && !usb_serial_interface.old_frame);
assert(line_length == 0 && !usb_binary_startup_probe.isActive());
data_connected = true;
service();
}
// Close/reopen can happen entirely between polls; the generation event wins.
the_mesh.terminal = false;
reset_event = true;
data_connected = true;
service();
assert(the_mesh.terminal);
// Do not expose ASCII while a native USB queue is still being purged.
transport_ready = false;
the_mesh.terminal = false;
boundary();
assert(!the_mesh.terminal && usb_terminal_host_reset_completion_pending);
transport_ready = true;
clock_ms += USB_TRANSPORT_RESET_RETRY_MS;
service();
assert(the_mesh.terminal && !usb_terminal_host_reset_completion_pending);
// A reset ends mOTA's exclusive USB attachment, but leaves network CLI alone.
usb_mota_mode = true;
the_mesh.terminal = false;
network_active = true;
boundary();
assert(!usb_mota_mode && !the_mesh.terminal);
network_active = false;
service();
assert(the_mesh.terminal);
// A fresh binary client during a network session keeps its selected mode.
the_mesh.terminal = false;
network_active = true;
boundary();
++usb_serial_interface.completed;
service();
network_active = false;
service();
assert(!the_mesh.terminal);
#if defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) || MESH_ESP32_TINYUSB_NONBLOCKING
// DTR fallback also works from Binary when no owner-task event is available.
data_connected = true;
service();
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);
// Software handoff must preserve the late mOTA boundary in both output
// modes; only a physical session reset may abandon the source response.
for (bool logging_after_handoff : {false, true}) {
the_mesh.terminal = false;
usb_serial_interface.passthrough = false;
usb_logging_terminal_mode = false;
network_active = true;
logging_enabled = true;
serviceUsbLoggingOwnership(logging_enabled);
assert(usb_logging_network_parked && usb_serial_interface.passthrough);
source.pending_response = true;
const unsigned previous_resets = source.resets;
usb_stream.pending = 5;
logging_enabled = logging_after_handoff;
network_active = false;
serviceUsbLoggingOwnership(logging_enabled);
assert(source.hasPendingResponse() && source.resets == previous_resets);
assert(usb_stream.pending == 5 && usb_serial_interface.passthrough);
assert(!usb_logging_network_parked);
assert(the_mesh.terminal == logging_after_handoff);
// A boundary-aware consumer completing the response can release the
// software quarantine without pretending it was a physical reset.
source.pending_response = false;
usb_stream.pending = 0;
}
the_mesh.terminal = true;
usb_serial_interface.passthrough = true;
// 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);
assert(announced_ascii);
dedicated_logging = true;
beginUsbDefaultSession();
assert(the_mesh.terminal && !usb_logging_terminal_mode);
assert(!announced_ascii);
assert(cancelled_routes >= 6);
}
'''
class CompanionUsbDefaultTest(unittest.TestCase):
def test_wifi_defaults_preserve_a_reachable_cli(self):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
start = mesh.index(" // Keep WiFi-only compatibility builds reachable")
end = mesh.index(" memset(_prefs.bluetooth_name", start)
defaults = mesh[start:end]
profiles = {
"esp32-full": (["ESP32=1", "WIFI_SSID=1", "BLE_PIN_CODE=123456",
"ENABLE_USB_INTERFACE=1", "COMPANION_RADIO_FULL=1"], 0),
"esp32-ble-wifi": (["ESP32=1", "WIFI_SSID=1", "BLE_PIN_CODE=123456"], 0),
"esp32-usb-wifi": (["ESP32=1", "WIFI_SSID=1", "ENABLE_USB_INTERFACE=1"], 0),
"esp32-wifi-only": (["ESP32=1", "WIFI_SSID=1"], 1),
"pico-usb-ble-wifi": (["RP2040_PLATFORM=1", "WIFI_SSID=1",
"ENABLE_USB_INTERFACE=1", "BLE_PIN_CODE=123456"], 0),
"pico-wifi-only": (["RP2040_PLATFORM=1", "WIFI_SSID=1"], 1),
"ethernet-wifi": (["ESP32=1", "WIFI_SSID=1", "ETHERNET_ENABLED=1"], 0),
"hardware-serial-wifi": (["ESP32=1", "WIFI_SSID=1", "SERIAL_RX=1"], 0),
"indicator-exclusive": (["ESP32=1", "WIFI_SSID=1", "BLE_PIN_CODE=123456",
"ENABLE_USB_INTERFACE=1", "COMPANION_EXCLUSIVE_WIFI_BLE=1"], 0),
"nrf52-ble": (["NRF52_PLATFORM=1", "BLE_PIN_CODE=123456"], 0),
"usb-only": (["ENABLE_USB_INTERFACE=1"], 0),
}
with tempfile.TemporaryDirectory() as directory:
binary = Path(directory) / "wireless-defaults"
for name, (flags, expected) in profiles.items():
with self.subTest(profile=name):
source = ("#include <cassert>\nstruct Prefs { unsigned wifi_enabled = 99; } _prefs;\n"
"int main() {\n" + defaults
+ f"assert(_prefs.wifi_enabled == {expected});\n}}\n")
build = subprocess.run(["g++", "-std=c++17", "-x", "c++",
*["-D" + flag for flag in flags],
"-", "-o", str(binary)], input=source,
text=True, capture_output=True)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
subprocess.run([str(binary)], check=True)
self.assertIn("static bool companion_wifi_requested = false;", MAIN)
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 (
"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"],
"esp32-hwcdc": ["ESP32=1", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=1"],
"rp2040": ["RP2040_PLATFORM=1"],
}
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "usb-default.cpp"
source.write_text(HARNESS.replace("@FUNCTIONS@", functions))
for name, defines in profiles.items():
for full in (False, True):
with self.subTest(transport=name, full=full):
binary = Path(directory) / (name + str(full))
flags = ["-D" + flag for flag in defines]
if full:
flags.append("-DCOMPANION_RADIO_FULL=1")
subprocess.run(["g++", "-std=c++17", "-I", str(ROOT / "src"),
*flags, str(source), "-o", str(binary)], check=True)
subprocess.run([str(binary)], check=True)
def test_startup_and_reconnect_selection_run_before_binary_dispatch(self):
setup = function("void setup() {")
self.assertIn("beginUsbDefaultSession();", setup)
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("serviceUsbLoggingReplyBarrier();"), 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__":
unittest.main()