mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-22 16:31:15 +00:00
364 lines
17 KiB
Python
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()
|