From c6e43c5530a50e67ebaf3660b73d95c44d41fabc Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 15 Oct 2026 21:05:40 -0700 Subject: [PATCH] Add guarded Meshtext, radio2 and multi-hop OTA hardware tests Add identity-bound app flash adapters, exact-receipt radio2 sweeps through preamble 384, measured packet-window goodput, stock-peer delivery probes, Zopfli1000 USB OTA serving and bridge outage/recovery checks. Keep dwell experiments opt-in, production timing unchanged, generated artifacts outside Git, and wire offline safety tests into CI. --- .github/workflows/run-unit-tests.yml | 10 + src/RadioProfiles.h | 14 + test/test_companion_raw_radio2_sweep.py | 265 +++++++ test/test_indicator_repeater_hil_recipe.py | 152 ++++ test/test_mercer_mt_flash.py | 306 ++++++++ test/test_mercer_nrf_app_flash.py | 186 +++++ test/test_meshtext_delivery_hil.py | 672 ++++++++++++++++++ test/test_meshtext_listen_hil.py | 93 +++ test/test_meshtext_speed_hil.py | 288 ++++++++ test/test_mota_espnow_chain_hil.py | 276 ++++++++ test/test_mota_usb_seeder_hil.py | 231 ++++++ tools/hil/companion_raw_radio2_sweep.py | 357 ++++++++++ tools/hil/indicator_repeater_espnow.ini | 92 +++ tools/hil/mercer_mt_flash.sh | 55 +- tools/hil/mercer_nrf_app_flash.sh | 166 +++++ tools/hil/meshtext_delivery.py | 773 +++++++++++++++++++++ tools/hil/meshtext_speed.py | 369 ++++++++++ tools/hil/mota_espnow_chain.py | 437 ++++++++++++ tools/hil/mota_usb_seeder.py | 319 +++++++++ 19 files changed, 5054 insertions(+), 7 deletions(-) create mode 100644 test/test_companion_raw_radio2_sweep.py create mode 100644 test/test_indicator_repeater_hil_recipe.py create mode 100644 test/test_mercer_mt_flash.py create mode 100644 test/test_mercer_nrf_app_flash.py create mode 100644 test/test_meshtext_delivery_hil.py create mode 100644 test/test_meshtext_listen_hil.py create mode 100644 test/test_meshtext_speed_hil.py create mode 100644 test/test_mota_espnow_chain_hil.py create mode 100644 test/test_mota_usb_seeder_hil.py create mode 100644 tools/hil/companion_raw_radio2_sweep.py create mode 100644 tools/hil/indicator_repeater_espnow.ini create mode 100644 tools/hil/mercer_nrf_app_flash.sh create mode 100644 tools/hil/meshtext_delivery.py create mode 100644 tools/hil/meshtext_speed.py create mode 100644 tools/hil/mota_espnow_chain.py create mode 100644 tools/hil/mota_usb_seeder.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 01e232d87..72ce076a6 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -209,6 +209,7 @@ jobs: python3 -B test/test_lr1121_power_merge.py -v python3 -B test/test_radio_interrupt_recovery.py -v python3 -B test/test_bridge_recovery.py -v + python3 -B test/test_bridge_ingress.py -v - name: Verify signed private fleet management run: | @@ -384,6 +385,15 @@ jobs: python3 -B test/test_radio_profile_auto.py -v python3 -B test/test_companion_meshtext.py -v python3 -B test/test_companion_meshtext_storage.py -v + python3 -B test/test_meshtext_delivery_hil.py -v + python3 -B test/test_meshtext_speed_hil.py -v + python3 -B test/test_companion_raw_radio2_sweep.py -v + python3 -B test/test_meshtext_listen_hil.py -v + python3 -B test/test_mercer_mt_flash.py -v + python3 -B test/test_mercer_nrf_app_flash.py -v + python3 -B test/test_indicator_repeater_hil_recipe.py -v + python3 -B test/test_mota_usb_seeder_hil.py -v + python3 -B test/test_mota_espnow_chain_hil.py -v python3 -B test/test_meshtext_auto_radio2_hil.py -v python3 -B test/test_meshtext_auto_radio2_hil_sdk.py -v diff --git a/src/RadioProfiles.h b/src/RadioProfiles.h index 8836e16c5..82e29096c 100644 --- a/src/RadioProfiles.h +++ b/src/RadioProfiles.h @@ -40,6 +40,13 @@ class RadioProfiles { static constexpr double MinListenSymbols = 4.6; static constexpr double SlowListenSymbols = MinListenSymbols; static constexpr double MinFastListenSymbols = MinListenSymbols; +#if defined(MESH_HIL_MESHTEXT_LISTEN_SYMBOLS) + // Opt-in RF experiment only: no production dwell/default changes. Compare + // foreign-network acquisition independently from normal MeshCore scanning. + static_assert(MESH_HIL_MESHTEXT_LISTEN_SYMBOLS > 0 + && MESH_HIL_MESHTEXT_LISTEN_SYMBOLS <= 64, + "MESH_HIL_MESHTEXT_LISTEN_SYMBOLS must be greater than 0 and at most 64"); +#endif static constexpr uint8_t AcquisitionSymbols = 8; static constexpr uint16_t MaxPreamble = 65528; static constexpr uint16_t MaxAutomaticPreamble = 512; @@ -161,7 +168,14 @@ class RadioProfiles { } static uint32_t minimumListenUs(const RadioProfileParams& p, bool slow = false) { const double symbol = symbolUs(p); +#if defined(MESH_HIL_MESHTEXT_LISTEN_SYMBOLS) + const double baseline = slow ? SlowListenSymbols : MinFastListenSymbols; + const double symbols = p.protocol == RadioProfileProtocol::Meshtext + && MESH_HIL_MESHTEXT_LISTEN_SYMBOLS > baseline + ? MESH_HIL_MESHTEXT_LISTEN_SYMBOLS : baseline; +#else const double symbols = slow ? SlowListenSymbols : MinFastListenSymbols; +#endif return isfinite(symbol) && symbol > 0 && symbol <= UINT32_MAX / symbols ? (uint32_t)ceil(symbols * symbol) : 0; } diff --git a/test/test_companion_raw_radio2_sweep.py b/test/test_companion_raw_radio2_sweep.py new file mode 100644 index 000000000..a17a11ee0 --- /dev/null +++ b/test/test_companion_raw_radio2_sweep.py @@ -0,0 +1,265 @@ +"""Pure guards and simulated RAW benchmark tests: no SDK, serial or RF needed.""" + +from contextlib import redirect_stderr, redirect_stdout +import importlib.util +import io +import json +from pathlib import Path +import sys +from types import ModuleType, SimpleNamespace +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("companion_raw_radio2_sweep_test", + ROOT / "tools/hil/companion_raw_radio2_sweep.py") +BENCH = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(BENCH) + + +class FakePair: + def __init__(self, *, loss=False, bad_guard=False, primary_tx=0, conflict=False, inactive=False): + self.clock = 100.0 + self.last_source = None + self.last_payload = None + self.loss = loss + self.total_sent = 0 + owner = self + + class Node: + max_channels = 1 + + def __init__(self, name): + self.name, self.preamble = name, 128 + self.commands, self.requests = [], [] + self.tx = 0 + self.tx_power = 21 + + def channel_name(self, slot): + return "mt-conflict" if conflict else "Public" + + def cli(self, command): + self.commands.append(command) + if command == "get lab.tx": + return "> wrong guard" if bad_guard and self.name == "b" else "> " + BENCH.GUARD + if command == "get radio": + return "> 910.5250244,62.5,7,5" + if command == "get radio2.status": + return f"> RX=0,0 TX={primary_tx},{self.tx}; active=radio2" + if command == "get tx": + return f"> {self.tx_power}" + if command == "set tx 2": + self.tx_power = 2 + if command.startswith("set radio2 "): + self.preamble = int(command.rsplit(",", 1)[1]) + if command == "get radio2": + return f"> 909.500000,500,5,5,rxtx,{self.preamble}; saved" + if command == "get tx.ssh": + return "> tx.ssh radio2; active=" + ("off" if inactive else "radio2") + if command == "get radio2.timing": + return "> synthetic timing only" + return "OK" + + def request(self, command, expected): + self.requests.append(command) + assert command[:2] == b"\x19\x00" + assert expected == (0,) + owner.last_source, owner.last_payload = self.name, command[2:] + owner.total_sent += 1 + self.tx += 1 + return b"\x00" + + self.nodes = {name: Node(name) for name in ("a", "b")} + + def now(self): + self.clock += 0.01 + return self.clock + + def receiver(self, destination, payload, timeout): + assert destination.name != self.last_source + assert payload == self.last_payload + received = not (self.loss and self.total_sent % 3 == 0) + return {"received": received, "completed_at": self.now() if received else None, + "unexpected_raw": 0, "ignored_frames": 0} + + +class CompanionRawRadio2SweepTest(unittest.TestCase): + def run_matrix(self, pair): + checkpoints = [] + with mock.patch.object(BENCH.time, "sleep"), \ + mock.patch.object(BENCH.time, "monotonic", side_effect=pair.now): + result = BENCH.run_matrix(pair.nodes, BENCH.PREAMBLES, (64, 172), count=2, + timeout=1, interval=0, persist=lambda value: checkpoints.append(json.loads(json.dumps(value))), + receiver=pair.receiver, run_id=b"TESTONLY") + return result, checkpoints + + def test_exact_raw_sizes_and_distinct_marker_identity(self): + first = BENCH.make_payload(b"TESTONLY", 1, 0, 172) + self.assertEqual(len(first), 172) + self.assertEqual(first[:4], b"SSH\x01") + self.assertNotEqual(first, BENCH.make_payload(b"TESTONLY", 2, 0, 172)) + self.assertNotEqual(first, BENCH.make_payload(b"TESTONLY", 1, 1, 172)) + with self.assertRaisesRegex(BENCH.Failure, "172"): + BENCH.make_payload(b"TESTONLY", 1, 0, 230) + + def test_guard_primary_and_profile_counters_are_strict(self): + BENCH.prove_guard("> " + BENCH.GUARD) + self.assertEqual(BENCH.parse_primary("> 910.5250244,62.5,7,5")[1:], (62.5, 7, 5)) + self.assertEqual(BENCH.parse_tx("> TX=0,12; active"), [0, 12]) + for function, value in ((BENCH.prove_guard, "> HIL TX lock=910.525 MHz"), + (BENCH.parse_primary, "> 909.5,500,5,5"), + (BENCH.parse_tx, "> no counters")): + with self.subTest(value=value), self.assertRaises(BENCH.Failure): + function(value) + + def test_all_cases_count_opposite_exact_receipt_not_just_command_ok(self): + pair = FakePair() + result, checkpoints = self.run_matrix(pair) + self.assertEqual(result["result"], "PASS", result.get("failure")) + self.assertEqual(len(result["cases"]), 8) + self.assertEqual(pair.total_sent, 32) + self.assertEqual({item["raw_bytes"] for item in result["cases"] if item["preamble"] == 384}, {64, 172}) + self.assertTrue(all(item["measurement"]["received"] == 4 for item in result["cases"])) + self.assertGreater(len(checkpoints), pair.total_sent) + self.assertFalse(result["mtu230_supported"]) + self.assertTrue(all(not any(command.startswith(("set radio ", "set wifi", "set mt.channel")) + for command in node.commands) for node in pair.nodes.values())) + + def test_rf_loss_is_reported_and_not_hidden_by_command_ok(self): + result, _ = self.run_matrix(FakePair(loss=True)) + self.assertEqual(result["result"], "LOSS") + self.assertGreater(sum(item["measurement"]["lost"] for item in result["cases"]), 0) + self.assertTrue(all(packet["command_completed"] for case in result["cases"] + for packet in case["transfers"])) + self.assertEqual(result["retries"], 0) + + def test_guard_failure_primary_tx_or_channel_conflict_prevents_all_setters(self): + for pair in (FakePair(bad_guard=True), FakePair(primary_tx=1), FakePair(conflict=True)): + with self.subTest(pair=pair): + result, _ = self.run_matrix(pair) + self.assertEqual(result["result"], "FAIL") + self.assertEqual(pair.total_sent, 0) + self.assertFalse(any(command.startswith(("set ", "normalradio")) + for node in pair.nodes.values() for command in node.commands)) + + def test_saved_but_inactive_radio2_route_prevents_transmission(self): + pair = FakePair(inactive=True) + result, _ = self.run_matrix(pair) + self.assertEqual(result["result"], "FAIL") + self.assertEqual(pair.total_sent, 0) + self.assertIn("saved and active", result["failure"]["message"]) + + def test_temporary_profile_release_settles_before_tx_power_is_changed(self): + pair = FakePair() + events = [] + for name, node in pair.nodes.items(): + original = node.cli + + def command(text, *, name=name, original=original): + events.append((name, text)) + return original(text) + + node.cli = command + with mock.patch.object(BENCH.time, "sleep", side_effect=lambda value: events.append(("sleep", value))), \ + mock.patch.object(BENCH.time, "monotonic", side_effect=pair.now): + result = BENCH.run_matrix(pair.nodes, (256,), (64,), count=1, + timeout=1, interval=0, persist=lambda report: None, receiver=pair.receiver, run_id=b"TESTONLY") + self.assertEqual(result["result"], "PASS") + first_power = events.index(("a", "set tx 2")) + releases_done = max(events.index((name, "set tempradio2 off")) for name in ("a", "b")) + self.assertIn(("sleep", 2.5), events[releases_done + 1:first_power]) + + def test_plan_and_invalid_230_size_never_load_hardware_helpers(self): + for arguments, expected in ((["--plan"], 0), (["--plan", "--sizes", "230"], 2), ([], 2)): + with self.subTest(arguments=arguments), redirect_stdout(io.StringIO()) as output, \ + redirect_stderr(io.StringIO()), \ + mock.patch.object(BENCH, "safe_identity", side_effect=AssertionError("hardware inventory")): + try: + code = BENCH.main(arguments) + except SystemExit as error: + code = error.code + self.assertEqual(code, expected) + if code == 0: + plan = json.loads(output.getvalue()) + self.assertEqual(plan["total_packets"], 160) + self.assertEqual(plan["preambles"], [128, 256, 384, 512]) + + def test_384_plan_is_accepted_but_unreviewed_or_duplicate_preambles_are_rejected(self): + for preambles, expected in (("384", 0), ("128,384,512", 0), ("300", 2), ("384,384", 2)): + with self.subTest(preambles=preambles), redirect_stdout(io.StringIO()) as output, \ + redirect_stderr(io.StringIO()), \ + mock.patch.object(BENCH, "safe_identity", side_effect=AssertionError("hardware inventory")): + try: + code = BENCH.main(["--plan", "--preambles", preambles]) + except SystemExit as error: + code = error.code + self.assertEqual(code, expected) + if code == 0: + plan = json.loads(output.getvalue()) + self.assertIn(384, plan["preambles"]) + + def test_usb_identity_rejects_mismatched_stale_tty_without_open(self): + listing = ModuleType("serial.tools") + listing.list_ports = SimpleNamespace(comports=lambda: [SimpleNamespace( + device="/dev/SIMULATED", serial_number="ACTUAL", vid=1, pid=2)]) + with mock.patch.dict(sys.modules, {"serial": ModuleType("serial"), "serial.tools": listing}): + with self.assertRaisesRegex(BENCH.Failure, "expected physical serial"): + BENCH.safe_identity("/dev/SIMULATED", "WRONG") + self.assertEqual(BENCH.safe_identity("/dev/SIMULATED", "ACTUAL")["serial"], "ACTUAL") + + def test_raw_push_requires_exact_payload_bytes(self): + class FrameTimeout(Exception): + pass + helper = ModuleType("esp32_companion_serial_stress") + helper.FrameTimeout = FrameTimeout + payload = BENCH.make_payload(b"TESTONLY", 1, 0, 64) + node = SimpleNamespace(port=object(), reader=SimpleNamespace(read_frame=mock.Mock( + side_effect=(b"\x83", b"\x84\x00\x00\xffwrong", b"\x84\x00\x00\xff" + payload)))) + with mock.patch.dict(sys.modules, {"esp32_companion_serial_stress": helper}): + result = BENCH.receive_exact(node, payload, 1) + self.assertTrue(result["received"]) + self.assertEqual(result["unexpected_raw"], 1) + self.assertEqual(result["ignored_frames"], 1) + + def test_existing_low_tx_skips_setter_and_transient_busy_is_bounded(self): + node = SimpleNamespace(cli=mock.Mock(return_value="> 2")) + result = BENCH.ensure_low_tx(node) + self.assertEqual(result["setter_attempts"], 0) + node.cli.assert_called_once_with("get tx") + node = SimpleNamespace(cli=mock.Mock(side_effect=("> 21", + RuntimeError("Companion CLI set tx rejected: Error: radio busy or TX power rejected"), "OK", "> 2"))) + with mock.patch.object(BENCH.time, "sleep") as sleep: + result = BENCH.ensure_low_tx(node) + self.assertEqual(result["setter_attempts"], 2) + sleep.assert_called_once_with(0.25) + self.assertEqual(node.cli.call_args_list[-1], mock.call("get tx")) + + def test_unknown_tx_errors_or_wrong_readback_fail_closed(self): + for responses in (("> 21", RuntimeError("Companion CLI set tx rejected: Error: invalid TX power")), + ("> 21", "OK", "> 21")): + with self.subTest(responses=responses): + node = SimpleNamespace(cli=mock.Mock(side_effect=responses)) + with self.assertRaises(Exception), mock.patch.object(BENCH.time, "sleep") as sleep: + BENCH.ensure_low_tx(node) + sleep.assert_not_called() + def busy(command): + if command == "get tx": + return "> 21" + raise RuntimeError("Companion CLI set tx rejected: Error: radio busy or TX power rejected") + with mock.patch.object(BENCH.time, "sleep"), \ + mock.patch.object(BENCH.time, "monotonic", side_effect=(0, 1, 8)): + with self.assertRaisesRegex(BENCH.Failure, "bounded8-second"): + BENCH.ensure_low_tx(SimpleNamespace(cli=busy)) + + def test_packet_window_rate_excludes_checkpoint_time_but_keeps_loss_timeouts(self): + packets = [{"received": True, "raw_bytes": 64, "elapsed_seconds": 0.1, "direction": "a_to_b"}, + {"received": False, "raw_bytes": 64, "elapsed_seconds": 1, "direction": "b_to_a"}] + value = BENCH.metrics(packets, elapsed=5) + self.assertAlmostEqual(value["application_goodput_kbps"], 60 * 8 / 5 / 1000) + self.assertAlmostEqual(value["packet_window_application_goodput_kbps"], 60 * 8 / 1.1 / 1000) + self.assertAlmostEqual(value["packet_window_seconds"], 1.1) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_indicator_repeater_hil_recipe.py b/test/test_indicator_repeater_hil_recipe.py new file mode 100644 index 000000000..23885bd9c --- /dev/null +++ b/test/test_indicator_repeater_hil_recipe.py @@ -0,0 +1,152 @@ +"""Offline contract for the headless Indicator LoRa+ESP-NOW HIL recipe. + +Does not invoke PlatformIO, a compiler, a serial port or any hardware tool. +These checks bind the recipe to the existing physical variant and partition +contract; actual ELF/EndF/boot/RadioLib qualification remains a separate build. +""" + +import configparser +import csv +import fnmatch +from pathlib import Path +import re +import shlex +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +ENV = "env:SenseCapIndicator-LoRa_repeater_bridge_espnow_hil" + + +class IndicatorRepeaterHilRecipeTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.config = configparser.ConfigParser(interpolation=None, strict=False) + cls.config.read([ROOT / "platformio.ini", + ROOT / "variants/sensecap_indicator-espnow/platformio.ini", + ROOT / "tools/hil/indicator_repeater_espnow.ini"], encoding="utf-8") + + def option(self, section, option, depth=0): + self.assertLess(depth, 30, "recursive recipe interpolation") + if self.config.has_option(section, option): + value = self.config.get(section, option) + return re.sub(r"\$\{([^}]+)\}", lambda match: + self.option(*match[1].split(".", 1), depth=depth + 1), value) + for parent in self.config.get(section, "extends", fallback="").split(","): + if parent.strip(): + try: + return self.option(parent.strip(), option, depth + 1) + except KeyError: + pass + raise KeyError((section, option)) + + def defines(self): + words = shlex.split(self.option(ENV, "build_flags")) + values = {} + index = 0 + while index < len(words): + word = words[index] + if word in ("-D", "-U"): + index += 1 + word += words[index] + if word.startswith("-U"): + values.pop(word[2:], None) + elif word.startswith("-D"): + name, _, value = word[2:].partition("=") + values[name] = value or "1" + index += 1 + return values + + def selected(self, source): + result = False + for action, pattern in re.findall(r"([+-])<([^>]+)>", self.option(ENV, "build_src_filter")): + # Top-level '*.cpp' does not mean recursive matching in PIO. + if "/" not in pattern and "/" in source: + continue + match = fnmatch.fnmatchcase(source, pattern) + if not any(character in pattern for character in "*?["): + match = match or source.startswith(pattern.rstrip("/") + "/") + if match: + result = action == "+" + return result + + def test_exact_physical_lora_variant_and_n8r8_memory_contract(self): + self.assertEqual(self.option(ENV, "extends"), "SenseCapIndicator-LoRa") + self.assertEqual(self.option(ENV, "board"), "esp32-s3-devkitc-1") + self.assertEqual(self.option(ENV, "board_upload.flash_size"), "8MB") + self.assertEqual(self.option(ENV, "board_build.arduino.memory_type"), "qio_opi") + self.assertEqual(self.option(ENV, "board_build.psram_type"), "opi") + definitions = self.defines() + self.assertEqual(definitions["MOTA_HW_ID"], '"SenseCapIndicator-LoRa"') + self.assertEqual(definitions["SENSECAP_INDICATOR_LORA"], "1") + self.assertEqual(definitions["WRAPPER_CLASS"], "IndicatorSX1262Wrapper") + self.assertEqual([definitions[name] for name in ("P_LORA_NSS", "P_LORA_RESET", + "P_LORA_BUSY", "P_LORA_DIO_1")], ["64", "65", "66", "67"]) + + def test_only_lab_frequency_can_transmit_and_boot_is_quiet(self): + definitions = self.defines() + for name, expected in {"LORA_FREQ": "908.5", "MESH_HIL_TX_FREQ_MHZ": "908.5", + "LORA_BW": "500", "LORA_SF": "5", "LORA_CR": "5", "LORA_TX_POWER": "2", + "ENABLE_ADVERT_ON_BOOT": "0", "DEFAULT_ADVERT_INTERVAL_MINUTES": "0", + "DEFAULT_FLOOD_ADVERT_INTERVAL_HOURS": "0"}.items(): + with self.subTest(name=name): + self.assertEqual(definitions[name], expected) + + def test_headless_keeps_the_radio_expander_library_not_font_or_primary_espnow(self): + definitions = self.defines() + for name in ("DISPLAY_CLASS", "MESH_PRIMARY_ESPNOW", "DISABLE_WIFI_OTA", + "WEBCONFIG_DISABLED", "OTA_SEEDER_ONLY"): + self.assertNotIn(name, definitions) + self.assertIn("LovyanGFX", self.option(ENV, "lib_deps")) + for source in ("../variants/sensecap_indicator-espnow/target.cpp", + "../variants/sensecap_indicator-espnow/IndicatorRadioHal.cpp"): + self.assertTrue(self.selected(source), source) + for source in ("helpers/esp32/ESPNOWRadio.cpp", "helpers/ui/LGFXDisplay.cpp", + "../variants/sensecap_indicator-espnow/IndicatorFontClient.cpp"): + self.assertFalse(self.selected(source), source) + + def test_full_repeater_ota_and_bridge_sources_are_not_companion_sources(self): + definitions = self.defines() + for name in ("WITH_ESPNOW_BRIDGE", "ENABLE_OTA", "OTA_FLASH_STORE", + "WIFI_OTA_SEEDER", "MESHCORE_EXPANDED_PARTITION_PROFILE"): + self.assertEqual(definitions[name], "1", name) + for source in ("../examples/simple_repeater/main.cpp", "../examples/simple_repeater/MyMesh.cpp", + "helpers/bridges/ESPNowBridge.cpp", "helpers/esp32/WebConfigServer.cpp", + "helpers/esp32/WiFiOtaSeeder.cpp", "helpers/ota/OtaContext.cpp", + "helpers/ota/detools/detools.c"): + self.assertTrue(self.selected(source), source) + self.assertFalse(self.selected("../examples/companion_radio/main.cpp")) + self.assertFalse(self.selected("../examples/kiss_modem/main.cpp")) + self.assertIn("AsyncElegantOTA", self.option(ENV, "lib_deps")) + scripts = self.option(ENV, "extra_scripts") + for script in ("meshcore_image_identity.py", "pio_endf.py", "check_firmware_ram.py"): + self.assertIn(script, scripts) + + def test_partitions_preserve_settings_and_fit_two_2p5m_slots_inside_8m(self): + table = ROOT / self.option(ENV, "board_build.partitions") + with table.open(encoding="utf-8") as stream: + rows = [row for row in csv.reader(line for line in stream if not line.lstrip().startswith("#")) if row] + partitions = {row[0].strip(): (int(row[3].strip(), 0), int(row[4].strip(), 0)) for row in rows} + self.assertEqual(partitions["nvs"], (0x9000, 0x5000)) + self.assertEqual(partitions["spiffs"], (0x290000, 0x160000)) + self.assertEqual(partitions["app0"], (0x10000, 0x280000)) + self.assertEqual(partitions["app1"], (0x400000, 0x280000)) + self.assertEqual(int(self.option(ENV, "board_upload.maximum_size")), 0x280000) + self.assertLessEqual(max(offset + size for offset, size in partitions.values()), 8 * 1024 * 1024) + spans = sorted(partitions.values()) + self.assertTrue(all(offset + size <= next_offset + for (offset, size), (next_offset, _) in zip(spans, spans[1:]))) + + def test_wrapped_bridge_and_enabled_defaults_are_existing_production_code(self): + source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text(encoding="utf-8") + self.assertIn("_prefs.bridge_format = mesh::bridge::ESPNOW_FORMAT_WRAPPED;", source) + self.assertIn("_prefs.bridge_channel = 1;", source) + self.assertIn("_prefs.bridge_enabled = 1;", source) + recipe = (ROOT / "tools/hil/indicator_repeater_espnow.ini").read_text(encoding="utf-8") + self.assertIn("d8:3b:da:75:23:ac", recipe) + self.assertIn("No production variant", recipe) + self.assertIn("No flashing/erasing", recipe) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mercer_mt_flash.py b/test/test_mercer_mt_flash.py new file mode 100644 index 000000000..0cb16ec89 --- /dev/null +++ b/test/test_mercer_mt_flash.py @@ -0,0 +1,306 @@ +"""Exercise the S3 watchdog-finish adapter against inert flasher functions. + +No serial device is opened. fuser/USB/DFU tools are replaced with explicit +non-hardware stubs; the fake flasher records only phase ordering and failures. +""" + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +ADAPTER = ROOT / "tools/hil/mercer_mt_flash.sh" +PORT = "/dev/ttyACM99" +MAC = "aabbccddeeff" + +FAKE_FUNCTIONS = r''' +esptool_set_variables() { + NORESET=no-reset + WATCHDOGRESET=${SIM_RESET_MODE-watchdog-reset} + READMAC=read-mac + printf 'variables\n' >> "$SIM_LOG" +} +prepare_esp32_flash_session() { + printf 'prepare\n' >> "$SIM_LOG" + ESP32_SESSION_IS_S3=${SIM_S3:-1} + ESP32_FLASH_EXPECTED_MAC=${SIM_ACTUAL_MAC:-aabbccddeeff} +} +selected_flash_serial_port() { + printf '%s\n' "$1" +} +esp32_verified_destructive_port() { + printf 'verify:%s\n' "$2" >> "$SIM_LOG" + if [[ "${SIM_FAIL_PHASE:-}" == "$2" ]]; then return 47; fi + printf '%s\n' "$1" +} +run_esp32_session_esptool() { + printf 'run:%s\n' "$*" >> "$SIM_LOG" + case " $* " in + *' flash-id '*) printf 'Detected flash size: %sMB\n' "${SIM_FLASH_MB:-16}" ;; + *' write-flash '*) + if [[ "${SIM_FAIL_PHASE:-}" == write ]]; then return 42; fi + # Model the production wrapper's positive verification contract: + # a missing verification marker is failure, even after exit zero. + if [[ "${SIM_FAIL_PHASE:-}" == unverified-write ]]; then return 43; fi + printf 'Hash of data verified.\n' + printf 'verified-write\n' >> "$SIM_LOG" ;; + *' write-mem '*) + if [[ "${SIM_FAIL_PHASE:-}" == volatile ]]; then return 44; fi ;; + *' --after watchdog-reset '*) + if [[ "${SIM_FAIL_PHASE:-}" == reset ]]; then return 45; fi ;; + esac +} +finish_esp32_flash_session() { + printf 'ordinary-finish\n' >> "$SIM_LOG" +} +''' + + +@unittest.skipUnless(os.name == "posix" and shutil.which("bash"), + "POSIX Bash required; run on Linux/WSL") +class MercerMtWatchdogFinishTest(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="meshcore-mt-finish-mock-") + self.addCleanup(temporary.cleanup) + self.work = Path(temporary.name) + self.log = self.work / "actions.log" + self.startup = self.work / "FORBIDDEN-STARTUP" + self.flasher = self.work / "mock-mcfirmware.sh" + self.image = self.work / "application.bin" + self.image.write_bytes(b"INERT MOCK IMAGE; NEVER FLASH") + # Startup commands and traps must never be sourced by the adapter. + source = ('#!/usr/bin/env bash\n' + 'printf startup > "$SIM_STARTUP"\n' + 'trap \'printf unwanted-trap >> "$SIM_LOG"\' EXIT\n' + 'exit 98\n') + FAKE_FUNCTIONS + # The adapter deliberately requires a complete definitions inventory. + source += "".join(f"inert_function_{i}() {{\n return 0\n}}\n" + for i in range(100)) + self.flasher.write_text(source, encoding="utf-8") + bindir = self.work / "bin" + bindir.mkdir() + for name in ("python3", "pipx", "timeout", "udevadm"): + path = bindir / name + path.write_text("#!/bin/sh\nprintf 'FORBIDDEN-TOOL\\n' >> \"$SIM_LOG\"\nexit 99\n", + encoding="utf-8") + path.chmod(0o700) + fuser = bindir / "fuser" + fuser.write_text("#!/bin/sh\nprintf 'owner-check:%s\\n' \"$*\" >> \"$SIM_LOG\"\n" + 'if [ "${SIM_BUSY:-0}" = 1 ]; then printf "1234\\n"; exit 0; fi\nexit 1\n', + encoding="utf-8") + fuser.chmod(0o700) + self.env = dict(os.environ, PATH=str(bindir) + os.pathsep + os.environ["PATH"], + SIM_LOG=str(self.log), SIM_STARTUP=str(self.startup)) + for name in ("SIM_RESET_MODE", "SIM_S3", "SIM_ACTUAL_MAC", "SIM_FAIL_PHASE", + "SIM_BUSY", "SIM_FLASH_MB"): + self.env.pop(name, None) + + def invoke(self, *, mode="write-flash", watchdog=True, allow=True, + allow_reset=False, operation_args=None, expected_mac=MAC, + flash_bytes="16777216", environment=None): + command = [shutil.which("bash"), str(ADAPTER), "--flasher", str(self.flasher), + "--port", PORT, "--workdir", str(self.work), + "--expect-mac", expected_mac, "--flash-bytes", flash_bytes] + if watchdog: + command.append("--watchdog-finish") + if allow: + command.extend(("--allow-write", "--allow-erase")) + if allow_reset: + command.append("--allow-reset") + command.extend(("--", mode)) + if mode == "write-flash": + command.extend(("0x10000", str(self.image))) + elif mode == "read-flash": + command.extend(("0x10000", "32", str(self.work / "unused-read.bin"))) + elif mode == "erase-region": + command.extend(("0x10000", "4096")) + if operation_args: + command.extend(operation_args) + result = subprocess.run(command, text=True, capture_output=True, + env=dict(self.env, **(environment or {})), + timeout=10, check=False) + self.assertFalse(self.startup.exists(), "the flasher's global startup ran") + actions = self.log.read_text(encoding="utf-8") if self.log.exists() else "" + self.assertNotIn("FORBIDDEN-TOOL", actions) + self.assertNotIn("unwanted-trap", actions) + return result, actions + + def test_watchdog_refuses_every_nonwrite_mode_before_flasher_or_serial_tools(self): + for mode in ("probe", "read-flash", "erase-flash", "erase-region"): + with self.subTest(mode=mode): + result, actions = self.invoke(mode=mode) + self.assertNotEqual(result.returncode, 0) + self.assertIn("--watchdog-finish requires write-flash", result.stderr) + self.assertEqual(actions, "") + + def test_watchdog_does_not_grant_write_permission(self): + result, actions = self.invoke(allow=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("write-flash requires --allow-write", result.stderr) + self.assertEqual(actions, "") + + def test_s3_and_supported_reset_are_required_before_any_flash_write(self): + for environment, message in (({"SIM_S3": "0"}, "did not identify ESP32-S3"), + ({"SIM_RESET_MODE": ""}, "supported S3 watchdog reset"), + ({"SIM_RESET_MODE": "hard-reset"}, "supported S3 watchdog reset")): + with self.subTest(environment=environment): + self.log.unlink(missing_ok=True) + result, actions = self.invoke(environment=environment) + self.assertNotEqual(result.returncode, 0) + self.assertIn(message, result.stderr) + self.assertIn("prepare", actions) + self.assertNotIn("write-flash", actions) + self.assertNotIn("write-mem", actions) + self.assertNotIn("--after watchdog-reset", actions) + + def test_expected_mac_is_checked_before_write_and_finish(self): + result, actions = self.invoke(environment={"SIM_ACTUAL_MAC": "112233445566"}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("MAC mismatch", result.stderr) + self.assertNotIn("write-flash", actions) + self.assertNotIn("write-mem", actions) + + def test_verified_write_precedes_masked_clear_identity_recheck_and_one_watchdog(self): + result, actions = self.invoke() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + phases = ["verify:write-flash", " write-flash ", "verified-write", + "verify:S3 volatile forced-ROM bit clear", "write-mem 0x6000812c 0 0x1", + "verify:S3 watchdog finish", "--after watchdog-reset --baud 115200 read-mac"] + positions = [actions.index(phase) for phase in phases] + self.assertEqual(positions, sorted(positions), actions) + self.assertEqual(actions.count("--after watchdog-reset"), 1) + self.assertNotIn("ordinary-finish", actions) + self.assertNotIn("erase-flash", actions) + self.assertIn("Application boot still requires an application-level check", result.stdout) + + def test_failed_or_unverified_write_cannot_clear_rom_or_reset(self): + for phase in ("write", "unverified-write"): + with self.subTest(phase=phase): + self.log.unlink(missing_ok=True) + result, actions = self.invoke(environment={"SIM_FAIL_PHASE": phase}) + self.assertNotEqual(result.returncode, 0) + self.assertIn(" write-flash ", actions) + self.assertNotIn("write-mem", actions) + self.assertNotIn("--after watchdog-reset", actions) + self.assertNotIn("ordinary-finish", actions) + + def test_either_finish_identity_failure_prevents_the_next_phase(self): + for phase, clear_expected in (("S3 volatile forced-ROM bit clear", False), + ("S3 watchdog finish", True)): + with self.subTest(phase=phase): + self.log.unlink(missing_ok=True) + result, actions = self.invoke(environment={"SIM_FAIL_PHASE": phase}) + self.assertNotEqual(result.returncode, 0) + self.assertEqual("write-mem" in actions, clear_expected) + self.assertNotIn("--after watchdog-reset", actions) + self.assertNotIn("ordinary-finish", actions) + + def test_volatile_clear_failure_cannot_watchdog_or_fall_back_to_hard_reset(self): + result, actions = self.invoke(environment={"SIM_FAIL_PHASE": "volatile"}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("write-mem", actions) + self.assertNotIn("--after watchdog-reset", actions) + self.assertNotIn("ordinary-finish", actions) + + def test_failed_watchdog_is_not_retried_or_followed_by_another_reset(self): + result, actions = self.invoke(environment={"SIM_FAIL_PHASE": "reset"}) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(actions.count("--after watchdog-reset"), 1) + self.assertNotIn("ordinary-finish", actions) + self.assertNotIn("Application boot still requires", result.stdout) + + def test_without_flag_the_original_finish_is_retained(self): + result, actions = self.invoke(watchdog=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("ordinary-finish", actions) + self.assertNotIn("write-mem", actions) + self.assertNotIn("--after watchdog-reset", actions) + + def test_runtime_reset_requires_its_own_consent_and_zero_arguments_before_tools(self): + for options, message in (({}, "reset-runtime requires --allow-reset"), + ({"allow_reset": True, "operation_args": ["0x10000", str(self.image)]}, + "reset-runtime takes no arguments"), + ({"allow_reset": True, "watchdog": True}, + "--watchdog-finish requires write-flash")): + with self.subTest(options=options): + self.log.unlink(missing_ok=True) + config = {"mode": "reset-runtime", "watchdog": False, "allow": False, **options} + result, actions = self.invoke(**config) + self.assertNotEqual(result.returncode, 0) + self.assertIn(message, result.stderr) + self.assertEqual(actions, "") + + def test_runtime_reset_requires_exact_mac_and_size_and_never_grants_flash_authority(self): + for field in ("expected_mac", "flash_bytes"): + with self.subTest(field=field): + self.log.unlink(missing_ok=True) + result, actions = self.invoke(mode="reset-runtime", watchdog=False, + allow=False, allow_reset=True, **{field: ""}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("supply the verified expected MAC and flash bytes", result.stderr) + self.assertEqual(actions, "") + result, actions = self.invoke(mode="reset-runtime", watchdog=False, + allow=False, allow_reset=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + phases = [" flash-id", "verify:S3 volatile forced-ROM bit clear", + "write-mem 0x6000812c 0 0x1", "verify:S3 watchdog finish", + "--after watchdog-reset --baud 115200 read-mac"] + positions = [actions.index(phase) for phase in phases] + self.assertEqual(positions, sorted(positions), actions) + self.assertEqual(actions.count("--after watchdog-reset"), 1) + for forbidden in ("write-flash", "erase-flash", "erase-region", "efuse", + "reset-runtime", "ordinary-finish"): + self.assertNotIn(forbidden, actions) + + def test_runtime_reset_wrong_identity_chip_or_size_cannot_clear_or_watchdog(self): + for environment, size in (({"SIM_ACTUAL_MAC": "112233445566"}, "16777216"), + ({"SIM_S3": "0"}, "16777216"), + ({"SIM_RESET_MODE": "hard-reset"}, "16777216"), + ({}, "8388608")): + with self.subTest(environment=environment, size=size): + self.log.unlink(missing_ok=True) + result, actions = self.invoke(mode="reset-runtime", watchdog=False, + allow=False, allow_reset=True, flash_bytes=size, environment=environment) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("write-mem", actions) + self.assertNotIn("--after watchdog-reset", actions) + self.assertNotIn("ordinary-finish", actions) + + def test_runtime_reset_rechecks_identity_and_fails_closed_without_fallback(self): + for phase in ("S3 volatile forced-ROM bit clear", "volatile", + "S3 watchdog finish", "reset"): + with self.subTest(phase=phase): + self.log.unlink(missing_ok=True) + result, actions = self.invoke(mode="reset-runtime", watchdog=False, + allow=False, allow_reset=True, environment={"SIM_FAIL_PHASE": phase}) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(actions.count("--after watchdog-reset"), int(phase == "reset")) + self.assertNotIn("ordinary-finish", actions) + self.assertNotIn("write-flash", actions) + self.assertNotIn("erase-flash", actions) + + def test_runtime_reset_busy_owner_is_never_killed_or_reset(self): + result, actions = self.invoke(mode="reset-runtime", watchdog=False, + allow=False, allow_reset=True, environment={"SIM_BUSY": "1"}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("no owner will be killed", result.stderr) + self.assertNotIn("write-mem", actions) + self.assertNotIn("--after watchdog-reset", actions) + self.assertNotIn("ordinary-finish", actions) + + def test_runtime_reset_accepts_verified_eight_mib_without_write_or_erase_consent(self): + result, actions = self.invoke(mode="reset-runtime", watchdog=False, + allow=False, allow_reset=True, flash_bytes="8388608", + environment={"SIM_FLASH_MB": "8"}) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(actions.count("--after watchdog-reset"), 1) + self.assertNotIn("write-flash", actions) + self.assertNotIn("erase-flash", actions) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mercer_nrf_app_flash.py b/test/test_mercer_nrf_app_flash.py new file mode 100644 index 000000000..4a8fc1959 --- /dev/null +++ b/test/test_mercer_nrf_app_flash.py @@ -0,0 +1,186 @@ +"""Exercise application-only DFU adapter guards without any physical USB access. + +Bash integration tests use a mock flasher, mock fuser and mock serial inventory. +They run on native Linux/WSL; the real serial SDK and DFU tool are never invoked. +Inventory/ZIP refusal tests also run on Windows without Bash dependencies. +""" + +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +TOOLS = ROOT / "tools/hil" +SPEC = importlib.util.spec_from_file_location("verified_nrf_flash_adapter_test", TOOLS / "verified_nrf_flash.py") +VERIFIER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(VERIFIER) +SERIAL = "SIMULATED123456" +PORT = "/dev/ttyACM99" +BOARD = "RAK4631" + + +def make_package(path, extra_component=None): + manifest = {"application": {"bin_file": "application.bin", "dat_file": "application.dat"}} + if extra_component: + manifest[extra_component] = {"bin_file": "application.bin", "dat_file": "application.dat"} + with zipfile.ZipFile(path, "w") as package: + package.writestr("manifest.json", json.dumps({"manifest": manifest})) + package.writestr("application.bin", b"NOT-REAL-FIRMWARE") + package.writestr("application.dat", b"NOT-A-REAL-INIT-PACKET") + + +def inventory(): + return {"devices": {SERIAL: {"board": BOARD, "firmware_prefixes": ["RAK_4631"], + "bootloader_prefixes": ["RAK_4631_bootloader"]}}} + + +class MercerNrfAppPackageTest(unittest.TestCase): + def test_unknown_physical_serial_cannot_select_an_image(self): + with self.assertRaisesRegex(ValueError, "unrecorded USB serial"): + VERIFIER.verify(inventory(), "UNREVIEWED123", BOARD, "RAK_4631_companion_test.zip") + + def test_board_mismatch_cannot_reuse_the_reviewed_serial(self): + with self.assertRaisesRegex(ValueError, "not WrongBoard"): + VERIFIER.verify(inventory(), SERIAL, "WrongBoard", "RAK_4631_companion_test.zip") + + def test_bootloader_and_softdevice_components_are_refused(self): + with tempfile.TemporaryDirectory(prefix="meshcore-nrf-package-guard-") as directory: + for component in ("bootloader", "softdevice", "softdevice_bootloader"): + with self.subTest(component=component): + path = Path(directory) / (component + ".zip") + make_package(path, component) + with self.assertRaisesRegex(ValueError, "forbids bootloader, SoftDevice"): + VERIFIER.verify_application_zip(path) + + +@unittest.skipUnless(os.name == "posix" and shutil.which("bash"), "native Bash required; run on Linux/WSL") +class MercerNrfAppShellTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="meshcore-nrf-app-mock-") + self.addCleanup(self.temporary.cleanup) + self.work = Path(self.temporary.name) + self.bin = self.work / "bin" + self.bin.mkdir() + self.adapter = self.work / "mercer_nrf_app_flash.sh" + shutil.copyfile(TOOLS / "mercer_nrf_app_flash.sh", self.adapter) + shutil.copyfile(TOOLS / "verified_nrf_flash.py", self.work / "verified_nrf_flash.py") + self.flasher = self.work / "mock-mcfirmware.sh" + self.package = self.work / "RAK_4631_companion_test.zip" + make_package(self.package) + self.inventory_file = self.work / "inventory.json" + self.inventory_file.write_text(json.dumps(inventory()), encoding="utf-8") + self.startup_marker = self.work / "STARTUP-WAS-SOURCED" + self.dfu_marker = self.work / "MOCK-DFU-ONLY" + self.env = dict(os.environ, SIM_STARTUP_MARKER=str(self.startup_marker), + SIM_DFU_MARKER=str(self.dfu_marker)) + + def executable(name, text): + path = self.bin / name + path.write_text(text, encoding="utf-8") + path.chmod(0o700) + + # These commands cannot inspect, reset, open, or claim a USB endpoint. + executable("fuser", "#!/bin/sh\nexit 1\n") + executable("python3", "#!/bin/sh\nexec " + shlex.quote(sys.executable) + ' "$@"\n') + for command in ("pipx", "udevadm", "socat", "stty"): + executable(command, "#!/bin/sh\necho 'Unexpected external hardware/dependency call' >&2\nexit 89\n") + for command in ("timeout", "stat", "perl", "unzip", "strings"): + if not shutil.which(command): + executable(command, "#!/bin/sh\nexit 89\n") + self.env["PATH"] = str(self.bin) + os.pathsep + os.environ.get("PATH", "") + + # A Python serial package exposing only a synthetic USB inventory. + # It has no Serial constructor, making an accidental physical open fail. + fake_modules = self.work / "python-mocks" + serial_tools = fake_modules / "serial/tools" + serial_tools.mkdir(parents=True) + (serial_tools.parent / "__init__.py").write_text("", encoding="utf-8") + (serial_tools / "__init__.py").write_text("", encoding="utf-8") + (serial_tools / "list_ports.py").write_text( + "from types import SimpleNamespace\n" + f"def comports(): return [SimpleNamespace(device={PORT!r}, serial_number={SERIAL!r})]\n", + encoding="utf-8") + self.env["PYTHONPATH"] = str(fake_modules) + functions = { + "preferred_flash_serial_port": "printf '%s\\n' \"$1\"", + "save_selected_serial_port": "return 0", + "nrf52_validate_rak_board_pair": "return 0", + "nrf52_selected_by_id_path": "printf '%s\\n' '/dev/serial/by-id/SIMULATED-ONLY'", + "udev_device_property": f"if [[ \"$2\" == ID_SERIAL_SHORT ]]; then printf '%s\\n' {SERIAL}; else printf '%s\\n' 'SIMULATED-PATH'; fi", + "nrf52_usb_path_stem": "printf '%s\\n' \"$1\"", + "run_nrf52_dfu_package_buttonless": 'printf \'%s|%s|%s|%s\\n\' "$1" "$2" "$3" "$4" > "$SIM_DFU_MARKER"', + } + text = ('#!/usr/bin/env bash\n' + 'printf \'STARTUP MUST NEVER RUN\\n\' > "$SIM_STARTUP_MARKER"\n' + 'exit 93\n') + for name, body in functions.items(): + text += f"{name}() {{\n {body}\n}}\n" + for index in range(100): + text += f"unused_mock_function_{index}() {{\n return 0\n}}\n" + self.flasher.write_text(text, encoding="utf-8") + + def invoke(self, overrides=None, *, consent=True): + arguments = { + "--flasher": str(self.flasher), "--port": PORT, "--workdir": str(self.work), + "--inventory": str(self.inventory_file), "--serial": SERIAL, "--board": BOARD, + "--artifact": str(self.package), "--sha256": hashlib.sha256(self.package.read_bytes()).hexdigest(), + } + arguments.update(overrides or {}) + command = [shutil.which("bash"), str(self.adapter)] + for option, value in arguments.items(): + command.extend((option, value)) + if consent: + command.append("--allow-write") + result = subprocess.run(command, env=self.env, capture_output=True, text=True, + timeout=15, check=False) + self.assertFalse(self.startup_marker.exists(), "the flasher's startup code was sourced") + return result + + def assert_refused(self, result, reason): + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn(reason, result.stderr) + self.assertFalse(self.dfu_marker.exists(), "DFU was called before all gates passed") + + def test_missing_write_consent_fails_before_any_flasher_call(self): + self.assert_refused(self.invoke(consent=False), "requires --allow-write") + + def test_bad_endpoint_and_hash_syntax_fail_closed(self): + for overrides, reason in (({"--port": "/dev/../ttyACM99"}, "local USB serial tty"), + ({"--port": "/dev/serial/by-id/anything"}, "local USB serial tty"), + ({"--sha256": "abc"}, "exactly 64 hex digits")): + with self.subTest(overrides=overrides): + self.assert_refused(self.invoke(overrides), reason) + + def test_actual_artifact_hash_mismatch_prevents_dfu(self): + self.assert_refused(self.invoke({"--sha256": "0" * 64}), "ZIP SHA256 mismatch") + + def test_unreviewed_physical_serial_prevents_dfu(self): + self.assert_refused(self.invoke({"--serial": "UNREVIEWED123"}), "unrecorded USB serial") + + def test_bootloader_component_prevents_dfu_even_with_matching_hash(self): + make_package(self.package, "bootloader") + self.assert_refused(self.invoke(), "forbids bootloader, SoftDevice") + + def test_definitions_only_source_reaches_only_mock_application_dfu(self): + result = self.invoke() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(self.dfu_marker.exists()) + call = self.dfu_marker.read_text(encoding="utf-8").strip().split("|") + self.assertEqual(call[1:], [PORT, "0", "0"]) + self.assertNotEqual(Path(call[0]), self.package) + self.assertEqual(Path(call[0]).name, self.package.name) + self.assertIn("Application-only DFU completed", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_meshtext_delivery_hil.py b/test/test_meshtext_delivery_hil.py new file mode 100644 index 000000000..d6ccc7667 --- /dev/null +++ b/test/test_meshtext_delivery_hil.py @@ -0,0 +1,672 @@ +"""Pure tests of the Meshtext RF runner, never its physical radio interfaces. + +The simulated peer tests validate orchestration and truthful reporting only; +they do not replace real RF, production codec, or stock-firmware tests. +No PlatformIO, pyserial, stock SDK, or connected hardware is required. + +Real hardware uses a stock SDK supporting SerialInterface(timeout=20), tested +with 2.7.11. An older 2.7.3 environment rejected that constructor argument. +""" + +import base64 +from contextlib import ExitStack, redirect_stderr, redirect_stdout +import importlib.util +import io +import json +from pathlib import Path +import sys +import tempfile +from types import ModuleType, SimpleNamespace +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "meshtext_delivery_hil_under_test", ROOT / "tools/hil/meshtext_delivery.py") +HIL = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(HIL) + + +class FakeSerial: + def __init__(self, chunks=(), short_write=False): + self.chunks = list(chunks) + self.short_write = short_write + self.events = [] + + @property + def rts(self): + return False + + @rts.setter + def rts(self, value): + self.events.append(("rts", value)) + + @property + def dtr(self): + return True + + @dtr.setter + def dtr(self, value): + self.events.append(("dtr", value)) + + @property + def in_waiting(self): + return len(self.chunks[0]) if self.chunks else 0 + + def write(self, data): + self.events.append(("write", data)) + return len(data) - int(self.short_write) + + def flush(self): + self.events.append(("flush",)) + + def read(self, count): + self.events.append(("read", count)) + return self.chunks.pop(0) if self.chunks else b"" + + +class SimulatedRadios: + """Small state model for the runner's policies, not a codec/RF oracle.""" + + def __init__(self, *, missing_nodeinfo=False, duplicate_text=False, + heard_echo=True, primary_frequency=909.5, + stale_renamed_nodeinfo=False, drop_renamed_texts=0, + missing_renamed_nodeinfo=False, duplicate_renamed_nodeinfo=False): + self.missing_nodeinfo = missing_nodeinfo + self.duplicate_text = duplicate_text + self.heard_echo = heard_echo + self.stale_renamed_nodeinfo = stale_renamed_nodeinfo + self.drop_renamed_texts = drop_renamed_texts + self.missing_renamed_nodeinfo = missing_renamed_nodeinfo + self.duplicate_renamed_nodeinfo = duplicate_renamed_nodeinfo + self.primary_frequency = primary_frequency + self.events = [] + self.closed = [] + self.commands = [] + self.role = "CLIENT_MUTE" + self.name = "Original HIL" + self.radio2_writes = 0 + self.busy_reads = 0 + self.sequence = 0 + self.clock = 100.0 + owner = self + + class Companion: + def __init__(self, port): + owner.companion = self + self.max_channels = 3 + self.retry = self.heard = self.exhausted = self.tx = self.rx = 0 + self.count, self.hops = 2, 7 + self.announced = False + + def start(self): + return {"firmware": "SIMULATED-NOT-RF", "primary": {"node_name": owner.name}} + + def close(self): + owner.closed.append("companion") + + def channel_name(self, slot): + return "" + + def mt_node(self): + return 0x12345678 + + def cli(self, command): + owner.commands.append(command) + if command == "get radio": + return f"> {owner.primary_frequency},500,5,5" + if command == "get radio2": + return "> 909.5,500,5,5,rxtx,32" + if command == "get mt.status": + status = "active" + if owner.busy_reads: + owner.busy_reads -= 1 + status = "publication busy" + return (f"> Meshtext !12345678; {status}; manual radio2; " + f"RX={self.rx} TX={self.tx} fail=0") + if command == "get mt.retry": + return (f"> retry={self.retry} heard={self.heard} exhausted={self.exhausted} " + f"pending=0 count={self.count} advert=off") + if command.startswith("set flood.retry.count "): + self.count = int(command.split()[-1]) + if command.startswith("set mt.hops "): + self.hops = int(command.split()[-1]) + if command.startswith("set radio2 "): + owner.radio2_writes += 1 + if owner.radio2_writes > 1: + owner.busy_reads = 2 + if command.startswith("set name "): + name = command[len("set name "):] + if name != owner.name: + owner.name = name + self.announced = False + if command == "get name": + return "> " + owner.name + return "OK" + + def send_text(self, slot, token): + if not self.hops: + self.tx += 1 + elif owner.role == "CLIENT" and owner.heard_echo: + self.tx += 2 + self.retry += 1 + self.heard += 1 + else: + self.tx += 1 + self.count + self.retry += self.count + self.exhausted += 1 + renamed = owner.name != "Original HIL" + drop_text = renamed and owner.drop_renamed_texts > 0 + if drop_text: + owner.drop_renamed_texts -= 1 + else: + owner.append_packet(self.mt_node(), token.encode(), "TEXT_MESSAGE_APP") + if owner.duplicate_text: + owner.events.append(owner.events[-1]) + if not self.announced: + self.announced = True + self.tx += 1 + if not owner.missing_nodeinfo and not (renamed and owner.missing_renamed_nodeinfo): + wire_name = ("Original HIL" if owner.stale_renamed_nodeinfo + and owner.name != "Original HIL" else owner.name) + owner.append_packet(self.mt_node(), b"simulated-user", "NODEINFO_APP", wire_name) + if renamed and owner.duplicate_renamed_nodeinfo: + self.tx += 1 + owner.append_packet(self.mt_node(), b"extra-user", "NODEINFO_APP", wire_name) + + def wait_text(self, slot, sender, token, timeout): + self.rx += 1 + return {"received": True} + + class Stock: + node = 0x87654321 + + def __init__(self, port): + owner.stock = self + self.iface = SimpleNamespace(isConnected=True, + stream=SimpleNamespace(is_open=True)) + + def close(self): + owner.closed.append("stock") + + def configure_lab(self, key, role): + owner.role = role + return {"role": role, "rf": dict(HIL.RF)} + + def packets_since(self, timestamp, sender): + return [packet for when, packet in owner.events + if when >= timestamp and packet["from"] == sender] + + def wait_packet(self, sender, predicate, since, timeout): + return next((packet for packet in self.packets_since(since, sender) + if predicate(packet)), None) + + def send_text(self, token, hops): + return SimpleNamespace(id=123) + + self.classes = Companion, Stock + + def now(self): + self.clock += 0.001 + return self.clock + + def append_packet(self, sender, payload, port, user_name=None): + self.sequence += 1 + self.events.append((self.now(), {"id": self.sequence, "from": sender, + "simulated_long_name": user_name, + "decoded": {"payload": payload, "portnum": port}})) + + +class MeshtextDeliveryGuardrailTest(unittest.TestCase): + def invoke_main(self, arguments): + output, errors = io.StringIO(), io.StringIO() + with mock.patch.object(sys, "argv", ["meshtext_delivery.py"] + arguments), \ + redirect_stdout(output), redirect_stderr(errors): + try: + code = HIL.main() + except SystemExit as error: + code = error.code + return code, output.getvalue(), errors.getvalue() + + def test_plan_does_not_import_hardware_dependencies_or_inspect_ports(self): + with mock.patch.object(HIL, "dependencies", side_effect=AssertionError("dependency import")), \ + mock.patch.object(HIL, "require_idle_port", side_effect=AssertionError("port probe")): + code, output, _ = self.invoke_main(["--plan"]) + self.assertEqual(code, 0) + plan = json.loads(output) + self.assertEqual(plan["rf"]["frequency_mhz"], 909.5) + self.assertEqual(plan["rf"]["bandwidth_khz"], 500) + self.assertEqual((plan["rf"]["sf"], plan["rf"]["cr"]), (5, 5)) + self.assertEqual(plan["echo_retry_budget"], 15) + + def test_consent_is_required_before_run(self): + with mock.patch.object(HIL, "run", side_effect=AssertionError("radio run")): + code, _, error = self.invoke_main([]) + self.assertEqual(code, 2) + self.assertIn("--allow-radio-changes is required", error) + + def test_invalid_timing_and_selection_never_run_hardware(self): + for arguments in (["--timeout", "0"], ["--timeout", "30.01"], + ["--quiet-seconds", "0"], ["--quiet-seconds", "31"], + ["--echo-attempts", "0"], ["--echo-attempts", "6"], + ["--case", "not-a-case"], ["--channel-slot", "256"]): + with self.subTest(arguments=arguments), \ + mock.patch.object(HIL, "run", side_effect=AssertionError("radio run")): + code, _, _ = self.invoke_main(["--allow-radio-changes"] + arguments) + self.assertEqual(code, 2) + + def test_same_port_and_existing_report_are_rejected(self): + with tempfile.TemporaryDirectory(prefix="meshtext-hil-guard-") as directory: + report = Path(directory) / "report.json" + with mock.patch.object(HIL, "run", side_effect=AssertionError("radio run")): + common = ["--allow-radio-changes", "--companion-port", "SIMULATED-A", + "--stock-port", "SIMULATED-A", "--report", str(report)] + code, _, error = self.invoke_main(common) + self.assertEqual(code, 2) + self.assertIn("ports must be distinct", error) + report.write_text("preserve-me", encoding="utf-8") + common[4] = "SIMULATED-B" + code, _, error = self.invoke_main(common) + self.assertEqual(code, 2) + self.assertIn("fresh report", error) + self.assertEqual(report.read_text(encoding="utf-8"), "preserve-me") + + def test_secret_values_never_enter_cli_labels_or_rejection_reasons(self): + for size in (16, 32): + key = bytes(range(size)).hex() + command = "set mt.channel 1 mt-labtest " + key + self.assertEqual(HIL.cli_command_label(command), "set mt.channel") + reply = HIL.safe_cli_reply(command, "Error: echoed " + command) + self.assertNotIn(key, reply) + self.assertIn("", reply) + self.assertEqual(HIL.cli_command_label("normalradio"), "normalradio") + self.assertEqual(HIL.cli_command_label("set tempradio2 off"), "set tempradio2") + self.assertEqual(HIL.safe_cli_reply("normalradio", "Error: invalid preamble"), + "Error: invalid preamble") + + def test_counter_parsing_rejects_missing_fields(self): + self.assertEqual(HIL.retry_stats("> retry=2 heard=1 exhausted=0 pending=0 count=15 advert=off")["heard"], 1) + self.assertEqual(HIL.tx_stats("RX=3 TX=4 fail=0"), {"rx": 3, "tx": 4, "fail": 0}) + for parser, reply in ((HIL.retry_stats, "retry=2 pending=0"), + (HIL.tx_stats, "TX=4 fail=0")): + with self.subTest(reply=reply), self.assertRaises(HIL.Failure): + parser(reply) + + def test_idle_wait_has_a_real_deadline(self): + observed = {"delivery": {"pending": 1}} + with mock.patch.object(HIL, "snapshot", return_value=observed) as snapshot, \ + mock.patch.object(HIL.time, "monotonic", side_effect=(0, 0.1, 0.2, 0.3)), \ + mock.patch.object(HIL.time, "sleep"): + with self.assertRaisesRegex(HIL.Failure, "did not become idle"): + HIL.wait_idle(object(), 0.25) + self.assertEqual(snapshot.call_count, 2) + + def test_publication_poll_is_read_only_and_time_bounded(self): + companion = SimpleNamespace(cli=mock.Mock(return_value="> publication busy; RX=0 TX=0 fail=0")) + with mock.patch.object(HIL.time, "monotonic", side_effect=(0, 0.1, 0.2, 0.3)), \ + mock.patch.object(HIL.time, "sleep"), \ + self.assertRaisesRegex(HIL.Failure, "did not become active"): + HIL.wait_active(companion, 0.25) + self.assertEqual(companion.cli.call_args_list, [mock.call("get mt.status")] * 2) + + +class MeshtextDeliveryUsbModeTest(unittest.TestCase): + def test_explicit_terminal_stop_uses_safe_line_order_and_drains_plaintext(self): + port = FakeSerial((b"> +++MESHCORE-TERM-STOP\r\n", b"Binary mode\r\n")) + with mock.patch.object(HIL.time, "sleep") as sleep: + result = HIL.enter_binary_mode(port) + self.assertEqual(port.events[:4], [("rts", False), ("dtr", True), + ("write", b"+++MESHCORE-TERM-STOP\r"), ("flush",)]) + sleep.assert_called_once_with(0.3) + self.assertEqual(result["discarded_terminal_bytes"], 38) + self.assertNotIn("Binary mode", str(result)) + + def test_already_binary_requires_no_ascii_acknowledgement(self): + with mock.patch.object(HIL.time, "sleep"): + self.assertEqual(HIL.enter_binary_mode(FakeSerial())["discarded_terminal_bytes"], 0) + + def test_short_write_and_logging_owner_fail_explicitly(self): + for port, reason in ((FakeSerial(short_write=True), "incomplete"), + (FakeSerial((b"ERROR: set usb.logging off before Binary mode\r\n> ",)), "USB logging owns")): + with self.subTest(reason=reason), mock.patch.object(HIL.time, "sleep"), \ + self.assertRaisesRegex(HIL.Failure, reason): + HIL.enter_binary_mode(port) + + def test_terminal_drain_is_byte_bounded(self): + port = FakeSerial([b"x" * 4096] * 17) + with mock.patch.object(HIL.time, "sleep"), \ + mock.patch.object(HIL.time, "monotonic", return_value=0), \ + self.assertRaisesRegex(HIL.Failure, "bounded negotiation drain"): + HIL.enter_binary_mode(port) + + def test_terminal_drain_is_time_bounded_even_with_continuous_output(self): + port = FakeSerial([b"x"] * 20) + with mock.patch.object(HIL.time, "sleep"), \ + mock.patch.object(HIL.time, "monotonic", side_effect=(0, 0.1, 0.5, 1.1)): + result = HIL.enter_binary_mode(port) + self.assertEqual(result["discarded_terminal_bytes"], 2) + self.assertEqual(len(port.chunks), 18) + + +class MeshtextDeliveryStockSettlingTest(unittest.TestCase): + class Peer: + def __init__(self, outcomes): + self.outcomes = iter(outcomes) + self.iface = None + self.attempts = 0 + self.closed = 0 + + def close(self): + self.closed += 1 + + def connect(self): + self.attempts += 1 + outcome = next(self.outcomes) + if isinstance(outcome, Exception): + raise outcome + self.iface = SimpleNamespace(localNode=outcome, close=self.close) + + def test_stale_readback_reconnects_then_accepts_committed_settings(self): + peer = self.Peer(["stale", "settled"]) + with mock.patch.object(HIL.time, "sleep") as sleep: + result = HIL.reconnect_stock_readback( + peer, lambda node: ["channel.private_key_match"] if node == "stale" else []) + self.assertEqual(result, {"attempts": 2, "retry_history": [ + {"attempt": 1, "mismatched_fields": ["channel.private_key_match"]}]}) + self.assertEqual(peer.closed, 1) + self.assertIsNotNone(peer.iface) + sleep.assert_called_once_with(2) + + def test_transient_connect_errors_are_retried_without_retaining_secret_text(self): + key = bytes(range(16)).hex() + peer = self.Peer([OSError("unrelated diagnostic " + key), "stale", "settled"]) + with mock.patch.object(HIL.time, "sleep") as sleep: + result = HIL.reconnect_stock_readback( + peer, lambda node: ["lora.override_frequency"] if node == "stale" else []) + self.assertEqual(result["attempts"], 3) + self.assertEqual(result["retry_history"][0]["mismatched_fields"], + ["connect/readback error: OSError"]) + self.assertNotIn(key, json.dumps(result)) + self.assertEqual(sleep.call_args_list, [mock.call(2), mock.call(2)]) + + def test_bad_settings_stop_after_three_attempts_with_only_field_names(self): + peer = self.Peer(["wrong"] * 3) + with mock.patch.object(HIL.time, "sleep") as sleep, \ + self.assertRaisesRegex(HIL.Failure, "three bounded reconnects.*lora.override_frequency"): + HIL.reconnect_stock_readback(peer, lambda node: ["lora.override_frequency"]) + self.assertEqual(peer.attempts, 3) + self.assertEqual(peer.closed, 3) + self.assertIsNone(peer.iface) + self.assertEqual(sleep.call_count, 2) + + def test_old_sdk_constructor_error_is_not_reported_as_rf_success(self): + peer = self.Peer([TypeError("SerialInterface got unexpected keyword argument timeout")] * 3) + with mock.patch.object(HIL.time, "sleep"), \ + self.assertRaisesRegex(HIL.Failure, "connect/readback error: TypeError"): + HIL.reconnect_stock_readback(peer, lambda node: []) + self.assertEqual(peer.attempts, 3) + + def test_quiet_requires_live_sdk_and_serial_indicators(self): + event = mock.Mock() + event.is_set.return_value = True + peer = SimpleNamespace(iface=SimpleNamespace(isConnected=event, + stream=SimpleNamespace(is_open=True))) + self.assertEqual(HIL.require_stock_connected(peer), + {"sdk_connected": True, "serial_open": True}) + event.is_set.return_value = False + with self.assertRaisesRegex(HIL.Failure, "disconnected"): + HIL.require_stock_connected(peer) + event.is_set.return_value = True + peer.iface.stream.is_open = False + with self.assertRaisesRegex(HIL.Failure, "disconnected"): + HIL.require_stock_connected(peer) + + def test_missing_health_indicator_cannot_pass_as_quiet(self): + with self.assertRaisesRegex(HIL.Failure, "no supported connection-health"): + HIL.require_stock_connected(SimpleNamespace(iface=object())) + with self.assertRaisesRegex(HIL.Failure, "disconnected"): + HIL.require_stock_connected(SimpleNamespace(iface=None)) + + def test_real_configuration_method_waits_for_deferred_reboot_before_reconnect(self): + # Execute the runner's real transaction/reconnect code with a tiny SDK + # adapter. Any accidentally opened physical port raises immediately. + events = [] + descriptor = SimpleNamespace(fields_by_name={}) + settings = SimpleNamespace(name="old", psk=b"old", uplink_enabled=True, + downlink_enabled=True) + node = SimpleNamespace(nodeNum=42, + channels=[SimpleNamespace(role=1, settings=settings)], + localConfig=SimpleNamespace(device=SimpleNamespace(DESCRIPTOR=descriptor), + position=SimpleNamespace(DESCRIPTOR=descriptor), lora=SimpleNamespace()), + moduleConfig=SimpleNamespace(telemetry=None), + ensureSessionKey=mock.Mock(), beginSettingsTransaction=mock.Mock(), + writeConfig=mock.Mock(), writeChannel=mock.Mock(), + commitSettingsTransaction=lambda: events.append(("commit",))) + + def interface(): + return SimpleNamespace(localNode=node, + _getOrCreateByNum=lambda number: {"adminSessionPassKey": b"session-ready"}, + close=lambda: events.append(("close",))) + + class StockAdapter: + def __init__(self, port): + self.iface = interface() + + def connect(self): + events.append(("connect",)) + self.iface = interface() + + def identity(self): + return {"node": "!0000002a"} + + def module(name, **attributes): + result = ModuleType(name) + result.__dict__.update(attributes) + return result + + device_config = SimpleNamespace(Role=SimpleNamespace(Value=lambda name: {"CLIENT_MUTE": 1}[name]), + RebroadcastMode=SimpleNamespace(Value=lambda name: {"ALL": 0}[name])) + config = SimpleNamespace(Config=SimpleNamespace(DeviceConfig=device_config, + LoRaConfig=SimpleNamespace(US=1))) + channel = SimpleNamespace(Channel=SimpleNamespace(PRIMARY=1, DISABLED=0)) + hardware_forbidden = mock.Mock(side_effect=AssertionError("physical serial access")) + modules = { + "serial": module("serial", Serial=hardware_forbidden), + "meshtext_auto_radio2": module("meshtext_auto_radio2", Companion=object, StockPeer=StockAdapter), + "esp32_companion_serial_stress": module("esp32_companion_serial_stress", + DeviceFrameReader=mock.Mock(), TransportCounters=mock.Mock()), + "serial_session": module("serial_session", open_configured_session=hardware_forbidden), + "meshtastic": module("meshtastic"), + "meshtastic.protobuf": module("meshtastic.protobuf", channel_pb2=channel, config_pb2=config), + "meshtastic.util": module("meshtastic.util", to_node_num=lambda value: value), + } + with mock.patch.dict(sys.modules, modules), \ + mock.patch.object(HIL.time, "sleep", side_effect=lambda seconds: events.append(("sleep", seconds))): + _, Stock = HIL.dependencies() + result = Stock("SIMULATED-ONLY").configure_lab(bytes(range(16)), "CLIENT_MUTE") + hardware_forbidden.assert_not_called() + committed = events.index(("commit",)) + self.assertEqual(events[committed + 1:committed + 4], + [("sleep", 20), ("close",), ("connect",)]) + self.assertTrue(all(event[1] in (0.75, 20) for event in events if event[0] == "sleep")) + self.assertEqual(result["readback"]["attempts"], 1) + + +class MeshtextDeliveryMockFlowTest(unittest.TestCase): + def run_simulation(self, radios, case="all", key_bits=128): + key = bytes(range(key_bits // 8)) + with tempfile.TemporaryDirectory(prefix="meshtext-hil-simulated-") as directory, ExitStack() as stack: + report = Path(directory) / "report.json" + args = SimpleNamespace(case=case, key_bits=key_bits, report=report, + companion_port="SIMULATED-DUT", stock_port="SIMULATED-STOCK", channel_slot=1, + quiet_seconds=0.01, timeout=1, echo_attempts=3) + stack.enter_context(mock.patch.object(HIL, "dependencies", return_value=radios.classes)) + stack.enter_context(mock.patch.object(HIL, "require_idle_port")) + stack.enter_context(mock.patch.object(HIL.importlib.metadata, "version", return_value="SIMULATED")) + stack.enter_context(mock.patch.object(HIL.secrets, "token_bytes", return_value=key)) + stack.enter_context(mock.patch.object(HIL.secrets, "token_hex", return_value="0123abcd")) + stack.enter_context(mock.patch.object(HIL.time, "sleep")) + stack.enter_context(mock.patch.object(HIL.time, "monotonic", side_effect=radios.now)) + stack.enter_context(mock.patch.object(HIL, "inspect_nodeinfo", + side_effect=lambda packet, node: {"id": "!12345678", + "long_name": packet["simulated_long_name"], + "payload_type": "SIMULATED User; no protobuf/RF claim"})) + stack.enter_context(redirect_stdout(io.StringIO())) + result = HIL.run(args) + saved = report.read_text(encoding="utf-8") + self.assertEqual(result, json.loads(saved)) + self.assertNotIn(key.hex(), saved) + self.assertNotIn(base64.b64encode(key).decode(), saved) + return result + + def test_all_cases_success_and_safe_setup_commands(self): + for bits in (128, 256): + with self.subTest(key_bits=bits): + radios = SimulatedRadios() + result = self.run_simulation(radios, key_bits=bits) + self.assertEqual(result["result"], "PASS", result.get("failure")) + self.assertEqual(len(result["cases"]), 9) + self.assertTrue(all(case["result"] == "PASS" for case in result["cases"])) + self.assertIn("normalradio", radios.commands) + self.assertNotIn("set tempradio off", radios.commands) + self.assertIn("set flood.retry.count 15", radios.commands) + self.assertEqual(sorted(radios.closed), ["companion", "stock"]) + self.assertFalse(result["settings_restored"]) + self.assertEqual(radios.name, "Original HIL") + + def test_quiet_only_never_sends_a_chat(self): + radios = SimulatedRadios() + result = self.run_simulation(radios, "quiet") + self.assertEqual(result["result"], "PASS") + self.assertEqual([case["name"] for case in result["cases"]], ["quiet"]) + self.assertEqual(radios.events, []) + self.assertEqual(radios.companion.tx, 0) + + def test_new_cases_work_as_a_subset_with_read_only_reactivation_and_name_restore(self): + radios = SimulatedRadios() + result = self.run_simulation(radios, "same-tuple,rename") + self.assertEqual(result["result"], "PASS", result.get("failure")) + self.assertEqual([case["name"] for case in result["cases"]], ["first-use", + "same-tuple-publication-recovery", "rename-rearms-first-chat-nodeinfo"]) + same_tuple, rename = result["cases"][-2:] + self.assertGreater(same_tuple["publication"]["polls"], 1) + self.assertEqual(same_tuple["traffic_delta"]["tx"], 0) + self.assertEqual(rename["traffic_delta"]["tx"], 2) + self.assertEqual(rename["nodeinfo"]["long_name"], rename["test_name"]) + self.assertTrue(rename["original_name_restored"]) + self.assertEqual(rename["restore_traffic_delta"]["tx"], 0) + self.assertEqual(radios.name, "Original HIL") + self.assertEqual(radios.companion.hops, 7) + + def test_rename_restores_original_name_even_when_wire_metadata_is_wrong(self): + radios = SimulatedRadios(stale_renamed_nodeinfo=True) + result = self.run_simulation(radios, "rename") + self.assertEqual(result["result"], "FAIL") + self.assertIn("old long name", result["failure"]["message"]) + rename = result["cases"][-1] + self.assertEqual(rename["result"], "FAIL") + self.assertTrue(rename["original_name_restored"]) + self.assertEqual(radios.name, "Original HIL") + self.assertEqual(rename["restore_traffic_delta"]["tx"], 0) + + def test_rename_records_first_chat_loss_and_accepts_its_one_nodeinfo(self): + radios = SimulatedRadios(drop_renamed_texts=1) + result = self.run_simulation(radios, "rename") + self.assertEqual(result["result"], "PASS", result.get("failure")) + rename = result["cases"][-1] + self.assertEqual(rename["attempted_texts"], 2) + self.assertEqual(rename["received_texts"], 1) + self.assertEqual([probe["received"] for probe in rename["probes"]], [False, True]) + self.assertIsNone(rename["probes"][0]["packet_id"]) + self.assertIsNotNone(rename["probes"][1]["packet_id"]) + self.assertEqual(rename["decoded_nodeinfo_events"], 1) + self.assertEqual(rename["packets"][0]["port"], "NODEINFO_APP") + self.assertEqual(rename["traffic_delta"]["tx"], 3) + self.assertTrue(rename["original_name_restored"]) + + def test_rename_all_three_chats_lost_is_still_a_failed_rf_test(self): + radios = SimulatedRadios(drop_renamed_texts=3) + result = self.run_simulation(radios, "rename") + self.assertEqual(result["result"], "FAIL") + self.assertIn("any of three", result["failure"]["message"]) + rename = result["cases"][-1] + self.assertEqual(rename["attempted_texts"], 3) + self.assertEqual(rename["received_texts"], 0) + self.assertEqual(rename["decoded_nodeinfo_events"], 1) + self.assertEqual(rename["traffic_delta"]["tx"], 4) + self.assertTrue(rename["original_name_restored"]) + self.assertTrue(all(not probe["received"] for probe in rename["probes"])) + + def test_rename_missing_nodeinfo_is_not_masked_by_extra_successful_chats(self): + radios = SimulatedRadios(missing_renamed_nodeinfo=True) + result = self.run_simulation(radios, "rename") + self.assertEqual(result["result"], "FAIL") + self.assertIn("exactly one observed", result["failure"]["message"]) + rename = result["cases"][-1] + self.assertEqual(rename["attempted_texts"], 3) + self.assertEqual(rename["received_texts"], 3) + self.assertEqual(rename["decoded_nodeinfo_events"], 0) + self.assertTrue(rename["original_name_restored"]) + + def test_rename_extra_nodeinfo_is_never_accepted_as_one_time_announcement(self): + radios = SimulatedRadios(duplicate_renamed_nodeinfo=True) + result = self.run_simulation(radios, "rename") + self.assertEqual(result["result"], "FAIL") + self.assertIn("exactly one observed", result["failure"]["message"]) + rename = result["cases"][-1] + self.assertEqual(rename["decoded_nodeinfo_events"], 2) + self.assertTrue(rename["original_name_restored"]) + + def test_missing_metadata_fails_and_saves_partial_report(self): + radios = SimulatedRadios(missing_nodeinfo=True) + result = self.run_simulation(radios, "first-use") + self.assertEqual(result["result"], "FAIL") + self.assertIn("automatic NodeInfo", result["failure"]["message"]) + self.assertEqual(result["cases"][-1]["result"], "FAIL") + self.assertIn("finished_utc", result) + self.assertEqual(sorted(radios.closed), ["companion", "stock"]) + + def test_duplicate_user_delivery_fails_exhaustion_case(self): + result = self.run_simulation(SimulatedRadios(duplicate_text=True), "exhaustion") + self.assertEqual(result["result"], "FAIL") + self.assertIn("duplicate user text", result["failure"]["message"]) + self.assertEqual(result["cases"][-1]["decoded_text_events"], 2) + + def test_received_chat_without_heard_echo_never_passes_cancellation(self): + result = self.run_simulation(SimulatedRadios(heard_echo=False), "echo") + self.assertEqual(result["result"], "FAIL") + echo = result["cases"][-1] + self.assertEqual(echo["name"], "heard-repeat-cancellation") + self.assertEqual(len(echo["attempts"]), 3) + self.assertTrue(all(attempt["received_by_stock"] for attempt in echo["attempts"])) + self.assertTrue(all(attempt["delivery_delta"]["heard"] == 0 for attempt in echo["attempts"])) + + def test_wrong_primary_is_rejected_before_any_setting_write(self): + radios = SimulatedRadios(primary_frequency=910.525) + result = self.run_simulation(radios) + self.assertEqual(result["result"], "FAIL") + self.assertIn("preconfigure primary", result["failure"]["message"]) + self.assertFalse(any(command.startswith(("set ", "delete ")) for command in radios.commands)) + self.assertEqual(radios.closed, ["companion"]) + + def test_failure_report_redacts_raw_key_bytes_hex_and_base64(self): + key = bytes(range(16)) + message = f"bad setup {key.hex()} {key!r} {base64.b64encode(key).decode()}" + with tempfile.TemporaryDirectory(prefix="meshtext-hil-secret-") as directory, \ + mock.patch.object(HIL.secrets, "token_bytes", return_value=key), \ + mock.patch.object(HIL.secrets, "token_hex", return_value="0123abcd"), \ + mock.patch.object(HIL, "dependencies", side_effect=RuntimeError(message)), \ + redirect_stdout(io.StringIO()): + args = SimpleNamespace(case="all", key_bits=128, report=Path(directory) / "report.json") + result = HIL.run(args) + saved = args.report.read_text(encoding="utf-8") + self.assertEqual(result["result"], "FAIL") + self.assertNotIn(key.hex(), saved) + self.assertNotIn(repr(key), result["failure"]["message"]) + self.assertNotIn(base64.b64encode(key).decode(), saved) + self.assertIn("", saved) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_meshtext_listen_hil.py b/test/test_meshtext_listen_hil.py new file mode 100644 index 000000000..0057b80e9 --- /dev/null +++ b/test/test_meshtext_listen_hil.py @@ -0,0 +1,93 @@ +"""Compile actual timing policy with an opt-in Meshtext RF dwell experiment. + +No PlatformIO or hardware is used. Default builds retain the measured 4.6-symbol +policy; the HIL override changes only Meshtext, never MeshCore or Ethernet. +""" + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +HARNESS = r''' +#include +#include +#include + +int main() { + mesh::RadioProfileParams p; + p.freq = 909.5f; p.bw = 500; p.sf = 5; p.cr = 5; + for (auto protocol : {mesh::RadioProfileProtocol::MeshCore, + mesh::RadioProfileProtocol::Meshtext, + mesh::RadioProfileProtocol::Ethernet}) { + p.protocol = protocol; + std::printf("%lu %lu\n", + static_cast(mesh::RadioProfiles::minimumListenUs(p, false)), + static_cast(mesh::RadioProfiles::minimumListenUs(p, true))); + } + // Existing invalid-modem rejection must remain intact with the override. + p.protocol = mesh::RadioProfileProtocol::Meshtext; p.bw = 0; + std::printf("%lu\n", static_cast(mesh::RadioProfiles::minimumListenUs(p))); +} +''' + + +class MeshtextListenHilTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not cls.compiler: + raise unittest.SkipTest("a native C++17 compiler is required") + + def compile_case(self, macro=None): + temporary = tempfile.TemporaryDirectory(prefix="meshtext-listen-hil-") + self.addCleanup(temporary.cleanup) + folder = Path(temporary.name) + source = folder / "listen.cpp" + binary = folder / ("listen.exe" if os.name == "nt" else "listen") + source.write_text(HARNESS, encoding="utf-8") + command = [self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-I", str(ROOT / "src"), str(source), "-o", str(binary)] + if macro is not None: + command.append("-DMESH_HIL_MESHTEXT_LISTEN_SYMBOLS=" + str(macro)) + result = subprocess.run(command, text=True, capture_output=True, timeout=30, check=False) + return result, binary + + def run_case(self, macro=None): + compiled, binary = self.compile_case(macro) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True, timeout=10, check=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return [tuple(map(int, line.split())) for line in result.stdout.splitlines()] + + def test_default_keeps_all_protocols_at_4_6_symbols(self): + # SF5/BW500 = 64 us/symbol; ceil(4.6 * 64) = 295 us. + self.assertEqual(self.run_case(), [(295, 295), (295, 295), (295, 295), (0,)]) + + def test_twelve_symbol_hil_changes_only_meshtext_to_768_us(self): + self.assertEqual(self.run_case(12), [(295, 295), (768, 768), (295, 295), (0,)]) + + def test_smaller_override_cannot_shorten_the_existing_listen_window(self): + self.assertEqual(self.run_case(1), [(295, 295), (295, 295), (295, 295), (0,)]) + + def test_fractional_override_is_supported_and_does_not_change_other_protocols(self): + self.assertEqual(self.run_case(8.5), [(295, 295), (544, 544), (295, 295), (0,)]) + + def test_upper_bound_is_accepted_for_meshtext_only(self): + self.assertEqual(self.run_case(64), [(295, 295), (4096, 4096), (295, 295), (0,)]) + + def test_zero_negative_and_over_limit_macros_are_compile_time_errors(self): + for macro in (0, -1, 65): + with self.subTest(macro=macro): + compiled, _ = self.compile_case(macro) + self.assertNotEqual(compiled.returncode, 0) + self.assertIn("MESH_HIL_MESHTEXT_LISTEN_SYMBOLS must be greater than 0 and at most 64", + compiled.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_meshtext_speed_hil.py b/test/test_meshtext_speed_hil.py new file mode 100644 index 000000000..2a50ff11f --- /dev/null +++ b/test/test_meshtext_speed_hil.py @@ -0,0 +1,288 @@ +"""Offline benchmark policy/orchestration tests; never open a radio or SDK.""" +from contextlib import redirect_stdout, redirect_stderr +import importlib.util +import io +import json +from pathlib import Path +import struct +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +TOOLS = ROOT / "tools/hil" +with mock.patch.object(sys, "path", [str(TOOLS), *sys.path]): + SPEC = importlib.util.spec_from_file_location("meshtext_speed_under_test", TOOLS / "meshtext_speed.py") + SPEED = importlib.util.module_from_spec(SPEC) + SPEC.loader.exec_module(SPEED) + + +class Simulation: + def __init__(self, *, guard=SPEED.GUARD, primary=910.525, primary_tx=0, + stock_frequency=909.5, queue_stuck=False, drop_outbound=False, drop_inbound=False): + self.now = 100.0 + self.guard, self.primary, self.primary_tx = guard, primary, primary_tx + self.queue_stuck, self.drop_outbound, self.drop_inbound = queue_stuck, drop_outbound, drop_inbound + self.commands, self.stock_writes, self.sent, self.closed = [], [], [], [] + self.frames, self.events, self.sf, self.name, self.announced = [], [], 5, "", False + self.tx, self.rx, self.stock_sequence = 0, 0, 0 + owner = self + + class Companion: + max_channels = 2 + + def __init__(self, port): + owner.companion = self + + def start(self): + return {"model": "SIMULATED", "primary": {"node_name": "Test"}} + + def close(self): + owner.closed.append("companion") + + def channel_name(self, index): + return owner.name if index == 1 else "" + + def mt_node(self): + return 0x12345678 + + def cli(self, command): + owner.commands.append(command) + if command == "get lab.tx": + return "> " + owner.guard + if command == "get radio": + return f"> {owner.primary},62.5,7,5" + if command == "get radio2.status": + return f"> rxtx; RX=0,{owner.rx} TX={owner.primary_tx},{owner.tx} preamble=32,16" + if command == "get radio2": + return f"> 909.5,500,{owner.sf},5,rxtx,32" + if command == "get mt.hops": + return "> 0" + if command == "get mt.status": + return f"> Meshtext !12345678; active; RX={owner.rx} TX={owner.tx} fail=0" + if command == "get mt.retry": + return "> retry=0 heard=0 exhausted=0 pending=0 count=0 advert=off" + if command.startswith("set radio2 "): + owner.sf = int(command.split()[2].split(",")[2]) + if command.startswith("set mt.channel "): + owner.name, owner.announced = "mt-labtest", False + if command.startswith("delete mt.channel "): + owner.name = "" + return "OK" + + def send_text(self, slot, token): + assert 7 + len(token.encode()) <= 176, "Companion USB payload envelope overflow" + owner.sent.append(("companion", token)) + owner.tx += 1 + if not owner.announced: + owner.announced = True + owner.tx += 1 + owner.events.append((owner.now, {"from": 0x12345678, "id": len(owner.sent), + "decoded": {"payload": token.encode(), "portnum": "TEXT_MESSAGE_APP"}})) + + def request(self, payload, expected, timeout): + assert payload == b"\x0a" + return owner.frames.pop(0) if owner.frames else b"\x0a" + + class Node: + def __init__(self): + self.localConfig = SimpleNamespace( + lora=SimpleNamespace(use_preset=False, override_frequency=stock_frequency, + bandwidth=500, coding_rate=5, spread_factor=5, frequency_offset=0, + hop_limit=0, tx_power=2, tx_enabled=True, channel_num=0), + device=SimpleNamespace(role=1)) + self.channels = [SimpleNamespace(role=1, settings=SimpleNamespace(name="labtest", psk=b"a"*16))] + + def beginSettingsTransaction(self): + owner.stock_writes.append("begin") + + def writeConfig(self, section): + owner.stock_writes.append(section) + + def commitSettingsTransaction(self): + owner.stock_writes.append("commit") + + class Interface: + def __init__(self, node): + self.localNode = node + self.isConnected = True + self.stream = SimpleNamespace(is_open=True) + self.queue = {} + self.queueStatus = SimpleNamespace(free=4, maxlen=4, res=0, mesh_packet_id=0) + + def close(self): + self.isConnected = False + self.stream.is_open = False + + class Stock: + node = 0x11223344 + + def __init__(self, port): + self.saved_node = Node() + self.connect() + owner.stock = self + + def connect(self): + self.iface = Interface(self.saved_node) + + def close(self): + owner.closed.append("stock") + + def configure_lab(self, key, role): + assert role == "CLIENT_MUTE" + self.saved_node.channels[0].settings.psk = key + owner.stock_writes.append("suppression") + return {"role": role, "simulated": True} + + def send_text(self, token, hops): + assert hops == 0 + owner.sent.append(("stock", token)) + owner.stock_sequence += 1 + identity = owner.stock_sequence + if owner.queue_stuck: + self.iface.queue[identity] = object() + self.iface.queueStatus.mesh_packet_id = identity + if not owner.drop_inbound: + prefix = b"!11223344: " + text = (prefix + token.encode())[:165] + owner.frames.append(bytes((17, 0, 0, 0, 1, 0, 0)) + struct.pack("= since and packet["from"] == sender and predicate(packet)), None) + owner.sleep(timeout) + return None + + self.classes = Companion, Stock + + def sleep(self, duration): + self.now += duration + + +class MeshtextSpeedTest(unittest.TestCase): + def run_simulated(self, simulation, extra=()): + directory = tempfile.TemporaryDirectory(prefix="meshtext-speed-offline-") + self.addCleanup(directory.cleanup) + path = Path(directory.name) / "report.json" + with mock.patch.object(SPEED.delivery, "dependencies", return_value=simulation.classes), \ + mock.patch.object(SPEED.delivery, "require_idle_port"), \ + mock.patch.object(sys, "path", [str(TOOLS), *sys.path]), \ + mock.patch.object(SPEED.time, "monotonic", side_effect=lambda: simulation.now), \ + mock.patch.object(SPEED.time, "sleep", side_effect=simulation.sleep), \ + redirect_stdout(io.StringIO()): + status = SPEED.main(["--allow-radio-changes", "--companion-port", "/dev/FAKE-A", + "--stock-port", "/dev/FAKE-B", "--report", str(path), "--sf", "7", "--count", "2", *extra]) + return status, json.loads(path.read_text()) + + def test_plan_has_no_dependencies_or_ports_and_never_uses_preset_frequencies(self): + with mock.patch.object(SPEED.delivery, "dependencies") as deps, \ + mock.patch.object(SPEED.delivery, "require_idle_port") as ports, redirect_stdout(io.StringIO()) as output: + self.assertEqual(SPEED.main(["--plan"]), 0) + deps.assert_not_called() + ports.assert_not_called() + plan = json.loads(output.getvalue()) + self.assertEqual(plan["frequency_mhz"], 909.5) + self.assertEqual(plan["spreading_factors"], [7, 9, 10]) + self.assertEqual(plan["preamble_symbols"], 16) + + def test_consent_invalid_parameters_same_ports_and_existing_report_refuse_before_open(self): + for arguments in ([], ["--plan", "--count", "0"], ["--plan", "--sf", "7", "7"], + ["--plan", "--gap", "0"], ["--plan", "--timeout", "31"], + ["--allow-radio-changes", "--companion-port", "same", "--stock-port", "same", "--report", "new.json"]): + with self.subTest(arguments=arguments), mock.patch.object(SPEED.delivery, "dependencies") as deps, \ + redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + SPEED.main(arguments) + deps.assert_not_called() + + def test_complete_run_measures_both_directions_and_truthful_truncation(self): + simulation = Simulation() + status, data = self.run_simulated(simulation, ["--sf", "7", "9", "10"]) + self.assertEqual(status, 0) + self.assertEqual(data["result"], "MEASURED") + self.assertEqual(len(data["cases"]), 12) + self.assertEqual(set(data["stock_profiles"]), {"7", "9", "10"}) + self.assertEqual(simulation.stock_writes.count("suppression"), 1) + for case in data["cases"]: + self.assertEqual(case["measurement"]["received"], 2) + self.assertEqual(case["measurement"]["loss_fraction"], 0) + self.assertEqual(case["nominal_lora_kbps"], SPEED.nominal_kbps(case["sf"])) + expected = 154 if case["direction"] == "stock-to-companion" and case["wire_bytes"] == 160 else case["wire_bytes"] + self.assertEqual(case["measurement"]["application_bytes_delivered"], 2 * expected) + self.assertEqual(case["safety_after"]["primary_tx"], 0) + self.assertFalse(any(command.startswith(("set radio ", "advert", "reboot", "set freq ")) for command in simulation.commands)) + self.assertEqual(sorted(simulation.closed), ["companion", "stock"]) + + def test_guard_primary_and_counter_fail_before_any_configuration(self): + for simulation in (Simulation(guard="HIL TX lock=909.500 MHz"), Simulation(primary=909.5), Simulation(primary_tx=1)): + with self.subTest(simulation=simulation): + status, data = self.run_simulated(simulation) + self.assertEqual(status, 1) + self.assertEqual(data["result"], "FAIL") + self.assertFalse(simulation.stock_writes or simulation.sent) + self.assertFalse(any(command.startswith("set ") for command in simulation.commands)) + + def test_wrong_initial_stock_frequency_is_not_reconfigured_or_transmitted(self): + simulation = Simulation(stock_frequency=910.525) + status, data = self.run_simulated(simulation) + self.assertEqual(status, 1) + self.assertFalse(simulation.stock_writes or simulation.sent) + self.assertIn("stock must use custom 909.5", data["failure"]["message"]) + + def test_packet_id_queue_depletion_prevents_a_second_sdk_send(self): + simulation = Simulation(queue_stuck=True) + status, data = self.run_simulated(simulation) + self.assertEqual(status, 1) + self.assertEqual(sum(who == "stock" for who, _ in simulation.sent), 1) + self.assertIn("TX completion uncertain", data["failure"]["message"]) + self.assertEqual(data["cases"][-1]["packets"][-1]["tx_completion"], "uncertain") + + def test_loss_is_reported_not_promoted_to_successful_phy_transmission(self): + status, data = self.run_simulated(Simulation(drop_outbound=True, drop_inbound=True)) + self.assertEqual(status, 0) + for case in data["cases"]: + self.assertEqual(case["measurement"]["received"], 0) + self.assertEqual(case["measurement"]["measured_application_goodput_bps"], 0) + if case["direction"] == "stock-to-companion": + self.assertTrue(all("uncertain" in packet["tx_completion"] for packet in case["packets"])) + + def test_sdk_ack_and_empty_queue_are_not_claimed_as_rf_completion(self): + simulation = Simulation() + stock = simulation.classes[1]("not-a-port") + stock.iface.queueStatus.mesh_packet_id = 99 + with mock.patch.object(SPEED.time, "monotonic", side_effect=lambda: simulation.now), \ + mock.patch.object(SPEED.time, "sleep", side_effect=simulation.sleep): + result = SPEED.wait_stock_queue(stock, 1, 99) + self.assertTrue(result["packet_id_depleted"]) + self.assertIn("RF completion unconfirmed", result["meaning"]) + stock.iface.queue = None + with self.assertRaisesRegex(SPEED.delivery.Failure, "no observable packet queue"): + SPEED.wait_stock_queue(stock, 1, 99) + + def test_stale_empty_sdk_queue_cannot_prove_an_unseen_packet_id(self): + simulation = Simulation() + stock = simulation.classes[1]("not-a-port") + with mock.patch.object(SPEED.time, "monotonic", side_effect=lambda: simulation.now), \ + mock.patch.object(SPEED.time, "sleep", side_effect=simulation.sleep): + with self.assertRaisesRegex(SPEED.delivery.Failure, "TX completion uncertain"): + SPEED.wait_stock_queue(stock, 1, 99) + + def test_unique_tokens_differ_between_sizes_directions_and_packets(self): + tokens = {SPEED.token_for("12345678", sf, direction, index, size) + for sf in SPEED.SFS for size in SPEED.SIZES for direction in ("companion", "stock") for index in range(5)} + self.assertEqual(len(tokens), 60) + self.assertTrue(all(len(token) in SPEED.SIZES for token in tokens)) + + def test_nominal_bitrate_is_not_the_measured_goodput(self): + self.assertEqual([SPEED.nominal_kbps(sf) for sf in SPEED.SFS], [21.875, 7.03125, 3.90625]) + measured = SPEED.summary([{"received": True, "wire_bytes": 180, "application_bytes": 154, "truncated": True}], 2) + self.assertEqual(measured["measured_application_goodput_bps"], 616) + self.assertEqual(measured["application_bytes_delivered"], 154) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mota_espnow_chain_hil.py b/test/test_mota_espnow_chain_hil.py new file mode 100644 index 000000000..93efc22ed --- /dev/null +++ b/test/test_mota_espnow_chain_hil.py @@ -0,0 +1,276 @@ +"""Offline guarded four-node transfer controller tests; no serial/RF/PIO.""" +from contextlib import redirect_stdout, redirect_stderr +import importlib.util +import io +import json +from pathlib import Path +import sys +from types import ModuleType, SimpleNamespace +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("chain_under_test", ROOT / "tools/hil/mota_espnow_chain.py") +CHAIN = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CHAIN) +MID = "ee375e77" + + +class Simulation: + def __init__(self, *, off_leak=False, midpoint_leak=False, instantly_done=False, bad_guard=False): + self.now, self.have, self.total = 100.0, 0, 9 + self.off_leak, self.midpoint_leak = off_leak, midpoint_leak + self.instantly_done, self.bad_guard = instantly_done, bad_guard + self.bridge_on, self.active, self.done = True, False, False + self.commands, self.reports, self.resets = [], [], 0 + owner = self + + class Node: + def __init__(self, name): + self.name = name + + def command(self, text, timeout=15): + owner.commands.append((self.name, text)) + frequency = 909.5 if self.name == "a" else 908.5 + if text == "get lab.tx": + value = ("bad" if owner.bad_guard else f"HIL TX lock={frequency:.3f} MHz; off-frequency TX blocked; primary RX-only when off-frequency") + elif text == "get tempradio": + value = f"> {frequency:.3f},500.000,5,5,128" + elif text in ("get radio2", "get tempradio2"): + value = "> off" + elif text == "get repeat": + value = "> on" + elif text in ("get espnow.enabled", "get espnow.running"): + value = "> " + ("on" if self.name == "a" or owner.bridge_on else "off") + elif text.startswith("set espnow.enabled "): + owner.bridge_on = text.endswith(" on") + value = "OK" + elif text == "ota self": + value = "self body=540000 image=540912 base_hash=AB0CA97D3E4D11FE abi=3 codecs=0x5" + elif text.startswith("ota config "): + value = "OK saved" + elif text == "ota config": + value = "ota config: speed=1.1x autofetch=off autoinstall=off hops=3" + elif text == "ota ls": + value = "Catalogue:\n1) EE375E77 target=abcd codec2\n2) DEADBEEF ignored" + elif text == "ota cancel": + owner.have, owner.active, owner.done = 0, False, False + owner.resets += 1 + value = "OK dropped session" + elif text == "ota pull " + MID + " flash": + owner.active = True + value = "OK pulling mid=EE375E77 -> flash (primary traffic)" + elif text == "ota stats": + if not owner.active: + value = "OTA | fw v1 id=deadbeef | fetch idle | af=off hops=3" + else: + if owner.bridge_on: + owner.have = owner.total if owner.instantly_done else min(owner.total, owner.have + 1) + elif owner.off_leak or (owner.midpoint_leak and owner.have): + owner.have = min(owner.total, owner.have + 1) + state = "done" if owner.have == owner.total else "dl" + value = f"OTA | fw v1 id=deadbeef | fetch {state} {owner.have}/{owner.total} 0% id=EE375E77 3s | af=off hops=3" + elif text == "ota dev verify": + value = "verify parsed=1 root=1 payload=1 img=1 signed=0 sig=0 trust=0 | ok=1 auto=0" + else: + raise AssertionError("unexpected command " + text) + # Deliberately truncated echo matches the observed3401 path. + return "o\r\n -> " + value.replace("\n", "\r\n") + "\r\n> " + + self.nodes = {name: Node(name) for name in ("a", "b", "target")} + + def sleep(self, seconds): + self.now += seconds + + def monotonic(self): + return self.now + + def run(self): + with mock.patch.object(CHAIN.time, "monotonic", self.monotonic), mock.patch.object(CHAIN.time, "sleep", self.sleep): + return CHAIN.run_chain(self.nodes, MID, timeout=30, outage=8, poll=.25, + persist=lambda report: self.reports.append(report["result"])) + + +class ChainTests(unittest.TestCase): + def test_plan_and_consent_open_no_hardware(self): + output = io.StringIO() + with redirect_stdout(output), mock.patch.object(CHAIN, "serial_node_factory", side_effect=AssertionError): + self.assertEqual(CHAIN.main(["--plan"]), 0) + plan = json.loads(output.getvalue()) + self.assertFalse(plan["source_port_opened"]) + self.assertFalse(plan["install_commands"]) + with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + CHAIN.main([]) + + def test_multiline_reply_and_truncated_echo_accepted(self): + value = CHAIN.reply("o\r\n -> catalogue:\r\n1) EE375E77\r\n2) DEADBEEF\r\n> ") + self.assertEqual(value, "catalogue:\n1) EE375E77\n2) DEADBEEF") + with self.assertRaises(CHAIN.Failure): + CHAIN.reply("ota stats\n> ") + + def test_ambiguous_arrows_fail_closed_with_safe_bounded_history(self): + raw = "o\r\n -> stale\r\n -> actual\r\n" + with self.assertRaisesRegex(CHAIN.Failure, "ambiguous"): + CHAIN.reply(raw) + node = SimpleNamespace(commands=[{"command": "ota stats", "reply": "old"}] * 3 + + [{"command": "ota stats", "reply": raw}]) + history = CHAIN.failure_history({"target": node})["target"] + self.assertEqual(len(history), 3) + self.assertEqual(history[-1]["arrow_markers"], 2) + self.assertEqual(history[-1]["reply"], raw) + secret = "aa" * 32 + record = CHAIN.diagnostic_record("ota stats", " -> " + secret + "x" * 4000) + self.assertLess(len(record["reply"]), 1600) + self.assertNotIn(secret, record["reply"]) + self.assertNotIn("reply", CHAIN.diagnostic_record("get prv.key", secret)) + + def test_fresh_marker_reader_does_not_require_echo(self): + simulation = Simulation() + + class Port: + fragments = [b"o\r\n -> fetch", b" dl 1/9\r\n", b"> "] + in_waiting = 1 + + def read(self, count): + simulation.now += .01 + return self.fragments.pop(0) if self.fragments else b"" + + with mock.patch.object(CHAIN.time, "monotonic", simulation.monotonic): + self.assertIn("fetch dl 1/9", CHAIN.read_fresh_reply(Port(), 1)) + + def test_classic_no_prompt_truncated_echo_multiline_quiet(self): + simulation = Simulation() + + class Port: + fragments = [b"o\r\n -> catalogue:\r\n", b"1) EE375E77\r\n", b"2) DEADBEEF\r\n"] + in_waiting = 1 + + def read(self, count): + simulation.now += .04 + return self.fragments.pop(0) if self.fragments else b"" + + with mock.patch.object(CHAIN.time, "monotonic", simulation.monotonic): + text = CHAIN.read_fresh_reply(Port(), 2) + self.assertEqual(CHAIN.reply(text), "catalogue:\n1) EE375E77\n2) DEADBEEF") + + def test_classic_open_no_term_tokens_lf_ignored_cr_executes(self): + simulation = Simulation() + + class Port: + def __init__(self, **kwargs): + self.writes, self.line, self.incoming, self.executed = [], bytearray(), bytearray(), [] + self.closed, self.dtr, self.rts = False, False, False + + @property + def in_waiting(self): + return len(self.incoming) + + def write(self, value): + self.writes.append(value) + for byte in value: + if byte == 10: + continue + if byte == 13: + text = self.line.decode() + self.line.clear() + if text: + self.executed.append(text) + self.incoming.extend(b"o\r\n -> answer\r\n") + else: + self.line.append(byte) + return len(value) + + def read(self, count): + simulation.now += .04 + value = bytes(self.incoming[:count]) + del self.incoming[:count] + return value + + def flush(self): + pass + + def close(self): + self.closed = True + + class Base: + def __init__(self, path): + raise AssertionError("Companion constructor must never run") + + def read(self, timeout, quiet): + simulation.now += timeout + result = bytes(self.port.incoming) + self.port.incoming.clear() + return result.decode() + + modules = {name: ModuleType(name) for name in ("serial", "serial.tools", "serial.tools.list_ports", + "serial_session", "ethernet_primary_probe")} + modules["serial"].Serial = Port + modules["serial.tools"].list_ports = modules["serial.tools.list_ports"] + modules["serial.tools.list_ports"].comports = lambda: [SimpleNamespace(device="/dev/test", serial_number="exact", + vid=0x1a86, pid=0x7523, location="physical")] + modules["serial_session"].assert_port_idle = lambda path: None + modules["serial_session"].open_configured_session = lambda port: None + modules["ethernet_primary_probe"].SerialNode = Base + with mock.patch.dict(sys.modules, modules), mock.patch.object(CHAIN.time, "monotonic", simulation.monotonic): + node = CHAIN.serial_node_factory()("/dev/test") + self.assertEqual(node.port.writes, [b"\r"]) + self.assertFalse(node.port.dtr) + self.assertFalse(node.port.rts) + self.assertEqual(CHAIN.reply(node.command("get lab.tx")), "answer") + self.assertEqual(node.port.executed, ["get lab.tx"]) + self.assertEqual(node.port.writes[-1], b"get lab.tx\r") + node.close() + self.assertFalse(any(b"MESHCORE-TERM" in value for value in node.port.writes)) + self.assertTrue(node.port.closed) + + def test_full_counterfactual_midpoint_resume_hash_and_integrity_proof(self): + simulation = Simulation() + result = simulation.run() + self.assertEqual(result["result"], "PASS", result.get("failure")) + self.assertTrue(result["initial_off_no_completed_blocks"]) + self.assertGreater(result["midpoint_off_stalled"]["retained_blocks"], 0) + self.assertTrue(result["bridge_b_restored"]) + self.assertEqual(result["final_progress"]["have"], 9) + self.assertEqual(result["running_before"], result["running_after"]) + self.assertFalse(result["rf_capture_independent"]) + self.assertFalse(any(command.startswith(("ota install", "ota rescue", "ota dev apply")) for _, command in simulation.commands)) + self.assertTrue(all(not command.startswith(("set radio", "tempradio ", "reboot")) for _, command in simulation.commands)) + + def test_off_path_data_fails_and_restores_bridge(self): + simulation = Simulation(off_leak=True) + result = simulation.run() + self.assertEqual(result["result"], "FAIL") + self.assertIn("while required bridge", result["failure"]["message"]) + self.assertTrue(result["bridge_b_restored"]) + + def test_instant_complete_is_not_claimed_midpoint_pass_retries_once(self): + simulation = Simulation(instantly_done=True) + result = simulation.run() + self.assertEqual(result["result"], "TRANSFER_VERIFIED_MIDPOINT_NOT_PROVEN", result.get("failure")) + self.assertEqual(simulation.resets, 2) + self.assertNotIn("midpoint_off_stalled", result) + self.assertTrue(result["bridge_b_restored"]) + + def test_failed_guard_no_setters_or_stage_clear(self): + simulation = Simulation(bad_guard=True) + result = simulation.run() + self.assertEqual(result["result"], "FAIL") + self.assertTrue(all(command.startswith("get ") for _, command in simulation.commands)) + self.assertEqual(simulation.resets, 0) + + def test_progress_rejects_other_mid_truncated_and_invalid_counts(self): + class Node: + value = "" + + def command(self, command, timeout): + return "o\n -> " + self.value + "\n> " + + node = Node() + for value in ("fetch dl 1/9 0% id=DEADBEEF", "fetch dl 1/9 0% id=EE37", "fetch dl 10/9 0% id=EE375E77"): + node.value = value + with self.assertRaises(CHAIN.Failure): + CHAIN.progress(node, MID) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mota_usb_seeder_hil.py b/test/test_mota_usb_seeder_hil.py new file mode 100644 index 000000000..ee6c7e36f --- /dev/null +++ b/test/test_mota_usb_seeder_hil.py @@ -0,0 +1,231 @@ +"""Offline tests for the existing-protocol USB mOTA adapter; no radio access.""" +from contextlib import redirect_stdout, redirect_stderr +import importlib.util +import io +import json +from pathlib import Path +import struct +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock +import zlib + +ROOT = Path(__file__).resolve().parents[1] +TOOLS = ROOT / "tools/hil" +sys.path.insert(0, str(TOOLS)) +SPEC = importlib.util.spec_from_file_location("mota_usb_under_test", TOOLS / "mota_usb_seeder.py") +USB = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(USB) + + +def request(op, args=b""): + return b"MS" + bytes([op]) + args + bytes([USB.xor(bytes([op]) + args)]) + + +def stats(): + return {"requests": {}, "usb_received_bytes": 0, "usb_sent_bytes": 0, + "error_responses": 0, "discarded_serial_bytes": 0, + "bad_request_checksums": 0, "detach_sent": False} + + +class Clock: + now = 100.0 + + def monotonic(self): + return self.now + + def sleep(self, seconds): + self.now += seconds + + +class MotaUsbTests(unittest.TestCase): + def test_plan_without_dependencies_or_ports(self): + output = io.StringIO() + with redirect_stdout(output), mock.patch.object(USB, "prepare_catalog", side_effect=AssertionError): + self.assertEqual(USB.main(["--plan"]), 0) + report = json.loads(output.getvalue()) + self.assertFalse(report["install_commands"]) + self.assertEqual(report["zopfli_iterations"], 1000) + + def test_consent_and_fresh_report_required(self): + with redirect_stderr(io.StringIO()), mock.patch.object(USB, "prepare_catalog", side_effect=AssertionError): + with self.assertRaises(SystemExit): + USB.main([]) + with self.assertRaises(SystemExit): + USB.main(["--plan", "--duration", "nan"]) + + def test_fragmented_resynchronization_exact_four_wire_requests(self): + parser = USB.RequestParser() + frames = [request(1), request(2, b"\x00"), request(3, b"\x00" + struct.pack("\s*)?(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+),(\d+)\s*", reply) + require(match is not None, "primary radio tuple is missing or ambiguous") + result = (float(match[1]), float(match[2]), int(match[3]), int(match[4])) + require(abs(result[0] - PRIMARY[0]) <= 0.00005 and result[1:] == PRIMARY[1:], + "primary must already be USA 910.525,62.5,7,5; reboot after preconfiguration") + return result + + +def parse_tx(reply): + found = re.findall(r"\bTX=(\d+),(\d+)(?=\s|;|$)", reply) + require(len(found) == 1, "per-profile TX counters are missing or ambiguous") + return list(map(int, found[0])) + + +def prove_guard(reply): + require(reply.strip().removeprefix("> ") == GUARD, + "immutable compiled 909.5 MHz TX lock is not confirmed; refusing all setters/TX") + + +def make_payload(run_id, sequence, direction, size): + require(len(run_id) == 8 and 0 <= sequence <= 0xffffffff and direction in (0, 1), + "invalid raw probe identity") + require(type(size) is int and 17 <= size <= RAW_LIMIT, "raw payload must fit17..172 bytes") + header = b"SSH\x01" + run_id + struct.pack("= 8 and frame[0] == 0x84 else None + + +def safe_identity(path, expected_serial): + """Enumerate metadata only; do not open a mismatched/stale tty.""" + from serial.tools import list_ports + require(expected_serial and not any(c in expected_serial for c in "\r\n\0"), + "an exact stable USB serial is required") + resolved = os.path.normcase(os.path.realpath(path)) + matches = [port for port in list_ports.comports() + if os.path.normcase(os.path.realpath(port.device)) == resolved] + require(len(matches) == 1 and (matches[0].serial_number or "").upper() == expected_serial.upper(), + "selected USB port does not match its expected physical serial") + info = matches[0] + return {"selected_path": path, "port": info.device, "serial": info.serial_number, + "vid": info.vid, "pid": info.pid} + + +def safety(nodes): + result = {} + for name, node in nodes.items(): + guard, primary = node.cli("get lab.tx"), node.cli("get radio") + prove_guard(guard) + parse_primary(primary) + status = node.cli("get radio2.status") + counters = parse_tx(status) + require(counters[0] == 0, "primary TX counter is nonzero; aborting before any further test TX") + result[name] = {"guard": guard, "primary": primary, "status": status, "tx": counters} + return result + + +def receive_exact(node, payload, timeout): + from esp32_companion_serial_stress import FrameTimeout + deadline = time.monotonic() + timeout + unexpected_raw = ignored = 0 + while time.monotonic() < deadline: + try: + frame = node.reader.read_frame(node.port, min(0.1, max(0.001, deadline - time.monotonic()))) + except FrameTimeout: + continue + observed = raw_payload(frame) + if observed == payload: + return {"received": True, "completed_at": time.monotonic(), + "unexpected_raw": unexpected_raw, "ignored_frames": ignored} + if observed is not None: + unexpected_raw += 1 + else: + ignored += 1 + return {"received": False, "unexpected_raw": unexpected_raw, "ignored_frames": ignored} + + +def ensure_low_tx(node, timeout=8): + """Avoid needless radio handoffs; retry only the exact transient rejection.""" + def read_tx(): + reply = node.cli("get tx") + match = re.fullmatch(r"\s*(?:>\s*)?([+-]?\d+)\s*", reply) + require(match is not None, "TX power readback is missing or ambiguous") + return int(match[1]) + + initial = read_tx() + if initial == RF["tx_dbm"]: + return {"initial_dbm": initial, "verified_dbm": initial, "setter_attempts": 0} + deadline = time.monotonic() + timeout + attempts = 0 + while True: + attempts += 1 + try: + reply = node.cli("set tx 2") + except Exception as error: + if not re.fullmatch(r"Companion CLI set tx rejected: Error: radio busy or TX power rejected", + str(error)): + raise + busy = True + else: + busy = reply.strip() == "Error: radio busy or TX power rejected" + require(busy or not re.match(r"(?:Error|ERR|Err)\b", reply), "TX power setter rejected") + if not busy: + require(read_tx() == RF["tx_dbm"], "TX power is not verified at2dBm; refusing transmission") + return {"initial_dbm": initial, "verified_dbm": RF["tx_dbm"], "setter_attempts": attempts} + remaining = deadline - time.monotonic() + require(remaining > 0 and attempts < 33, "TX power stayed busy for the bounded8-second setup window") + time.sleep(min(0.25, remaining)) + + +def metrics(transfers, elapsed): + received = [packet for packet in transfers if packet["received"]] + total = sum(packet["raw_bytes"] for packet in received) + app = sum(packet["raw_bytes"] - 4 for packet in received) + packet_seconds = sum(packet["elapsed_seconds"] for packet in transfers) + directions = {} + for name in ("a_to_b", "b_to_a"): + records = [packet for packet in transfers if packet["direction"] == name] + ok = [packet for packet in records if packet["received"]] + seconds = sum(packet["elapsed_seconds"] for packet in records) + directions[name] = {"attempted": len(records), "received": len(ok), + "lost": len(records) - len(ok), + "raw_goodput_kbps": sum(packet["raw_bytes"] for packet in ok) * 8 / seconds / 1000 if seconds else 0} + return {"attempted": len(transfers), "received": len(received), + "lost": len(transfers) - len(received), + "loss_fraction": 1 - len(received) / len(transfers) if transfers else None, + "elapsed_seconds": elapsed, "raw_goodput_kbps": total * 8 / elapsed / 1000 if elapsed else 0, + "application_goodput_kbps": app * 8 / elapsed / 1000 if elapsed else 0, + "packet_window_seconds": packet_seconds, + "packet_window_raw_goodput_kbps": total * 8 / packet_seconds / 1000 if packet_seconds else 0, + "packet_window_application_goodput_kbps": app * 8 / packet_seconds / 1000 if packet_seconds else 0, + "directions": directions, + "definition": "wall-clock rates include command ACK, loss timeouts, pacing and report checkpoints; packet-window rates sum send-through-receipt/timeout windows excluding pacing/checkpoints; application excludes4-byte SSH routing tag; not SSH throughput"} + + +def run_matrix(nodes, preambles, sizes, *, count, timeout, interval, persist, + receiver=receive_exact, run_id=None): + run_id = secrets.token_bytes(8) if run_id is None else run_id + report = {"schema": 1, "started_utc": datetime.now(timezone.utc).isoformat(), + "result": "RUNNING", "rf": RF, "primary_rx_only": list(PRIMARY), + "run_id": run_id.hex(), "retries": 0, "raw_payload_limit": RAW_LIMIT, + "probe_parameters": {"count_per_direction": count, "timeout_seconds": timeout, + "interval_seconds": interval}, + "mtu230_supported": False, "settings_restored": False, "cases": [], + "note": "No primary/USA TX; no claim of actual USA receive qualification; no TCP/SSH connection tested"} + sequence = 0 + try: + report["initial_safety"] = safety(nodes) + for node in nodes.values(): + for slot in range(node.max_channels): + require(not node.channel_name(slot).lstrip("#").startswith(("mt-", "r2-")), + "remove conflicting mt-/r2- channels explicitly before the raw benchmark") + for node in nodes.values(): + for command in ("normalradio", "set tempradio2 off"): + node.cli(command) + # Cancelling a scheduled/temporary profile also publishes settings. + # Complete that bounded handoff before touching radio TX power. + time.sleep(2.5) + report["tx_power_setup"] = {name: ensure_low_tx(node) for name, node in nodes.items()} + for node in nodes.values(): + for command in ("set radio2.cross off", "set tx.ssh radio2"): + node.cli(command) + persist(report) + for preamble in preambles: + for size in sizes: + item = {"preamble": preamble, "raw_bytes": size, "application_bytes": size - 4, + "result": "RUNNING", "transfers": []} + report["cases"].append(item) + persist(report) + item["safety_before"] = safety(nodes) + for node in nodes.values(): + node.cli(f"set radio2 909.5,500,5,5,rxtx,{preamble}") + # Saved setting publication owns a bounded lease. Do not let + # that control-plane wait pollute data-plane goodput. + time.sleep(2.5) + configured = {} + for name, node in nodes.items(): + radio2 = node.cli("get radio2") + expected = rf"(?:^|>\s*)909\.5(?:0*),500(?:\.0*)?,5,5,rxtx,{preamble}(?=\s|;|$)" + require(re.search(expected, radio2) is not None, "radio2 RF/preamble readback mismatch") + route = node.cli("get tx.ssh") + require(re.search(r"\btx\.ssh radio2; active=radio2(?:\s|$)", route) is not None, + "SSH-tagged raw radio2 policy is not saved and active") + configured[name] = {"radio2": radio2, "tx_ssh": route, + "timing": node.cli("get radio2.timing"), "status": node.cli("get radio2.status")} + item["configured"] = configured + item["safety_before_tx"] = safety(nodes) + persist(report) + started = time.monotonic() + for repetition in range(count): + for direction, (source, destination) in enumerate((("a", "b"), ("b", "a"))): + sequence += 1 + payload = make_payload(run_id, sequence, direction, size) + sent = time.monotonic() + # A valid direct path of zero hops. No advert/contact + # dependency, no flooding or packet-pool fragmentation. + nodes[source].request(b"\x19\x00" + payload, (0,)) + observation = receiver(nodes[destination], payload, timeout) + elapsed = time.monotonic() - sent + completed = observation.pop("completed_at", None) + packet = {"direction": source + "_to_" + destination, "repetition": repetition, + "sequence": sequence, "raw_bytes": size, "command_completed": True, + "sha256": hashlib.sha256(payload).hexdigest(), **observation, + "elapsed_seconds": elapsed, + "latency_ms": (completed - sent) * 1000 if completed is not None else None} + item["transfers"].append(packet) + persist(report) + if interval: + time.sleep(interval) + item["measurement"] = metrics(item["transfers"], time.monotonic() - started) + item["safety_after"] = safety(nodes) + item["result"] = "PASS" if all(packet["received"] for packet in item["transfers"]) else "LOSS" + persist(report) + # A loss is a measured result, not a setup failure. Rank useful delivered + # bytes/time while preserving every individual loss and direction. + ranked = sorted(report["cases"], key=lambda item: item["measurement"]["packet_window_application_goodput_kbps"], reverse=True) + report["ranking_metric"] = "packet_window_application_goodput_kbps" + report["ranked_measured_cases"] = [{"preamble": item["preamble"], "raw_bytes": item["raw_bytes"], + "application_goodput_kbps": item["measurement"]["application_goodput_kbps"], + "packet_window_application_goodput_kbps": item["measurement"]["packet_window_application_goodput_kbps"], + "loss_fraction": item["measurement"]["loss_fraction"]} for item in ranked] + report["result"] = "PASS" if all(item["result"] == "PASS" for item in report["cases"]) else "LOSS" + except Exception as error: + report["result"] = "FAIL" + report["failure"] = {"type": type(error).__name__, "message": str(error)[:1000]} + if report["cases"] and report["cases"][-1]["result"] == "RUNNING": + report["cases"][-1]["result"] = "FAIL" + report["finished_utc"] = datetime.now(timezone.utc).isoformat() + persist(report) + return report + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + for option in ("a-port", "a-serial", "b-port", "b-serial"): + parser.add_argument("--" + option) + parser.add_argument("--preambles", default=",".join(map(str, PREAMBLES))) + parser.add_argument("--sizes", default="64,172", help="total RAW bytes including4-byte SSH tag; max172") + parser.add_argument("--count", type=int, default=10, help="packets per direction/size/preamble") + parser.add_argument("--timeout", type=float, default=1) + parser.add_argument("--interval", type=float, default=0.01) + parser.add_argument("--report", type=Path) + parser.add_argument("--allow-radio-changes", action="store_true") + parser.add_argument("--plan", action="store_true") + args = parser.parse_args(argv) + try: + preambles, sizes = ([int(value) for value in text.split(",")] + for text in (args.preambles, args.sizes)) + require(preambles and len(set(preambles)) == len(preambles) and set(preambles) <= set(PREAMBLES), + "select unique preambles128,256,384,512") + require(sizes and len(set(sizes)) == len(sizes) and all(17 <= size <= RAW_LIMIT for size in sizes), + "select unique raw sizes17..172; 230-byte MTU requires KISS modem firmware") + require(1 <= args.count <= 100 and 0 < args.timeout <= 5 and 0 <= args.interval <= 1 + and math.isfinite(args.timeout) and math.isfinite(args.interval), "invalid count/timing bounds") + if args.plan: + print(json.dumps({"rf": RF, "preambles": preambles, "raw_sizes": sizes, + "count_per_direction": args.count, "primary_rx_only": list(PRIMARY), "required_guard": GUARD, + "total_packets": 2 * len(preambles) * len(sizes) * args.count, + "raw_payload_limit": RAW_LIMIT, "mtu230_supported": False}, indent=2)) + return 0 + require(args.allow_radio_changes, "--allow-radio-changes is required before opening ports") + require(all((args.a_port, args.a_serial, args.b_port, args.b_serial, args.report)), + "both ports, exact USB serials, and --report are required") + require(os.path.realpath(args.a_port) != os.path.realpath(args.b_port) + and args.a_serial.upper() != args.b_serial.upper(), "select two distinct physical radios") + require(not args.report.exists() and args.report.parent.is_dir(), "select a fresh report in an existing directory") + except (Failure, ValueError) as error: + parser.error(str(error)) + nodes, usb = {}, {} + with args.report.open("x", encoding="utf-8") as stream: + try: + os.chmod(args.report, 0o600) + except OSError: + pass + + def persist(report): + report["usb"] = usb + stream.seek(0) + json.dump(report, stream, indent=2) + stream.write("\n") + stream.truncate() + stream.flush() + + try: + from meshtext_delivery import dependencies, require_idle_port + Companion, _ = dependencies() + for name, port, serial in (("a", args.a_port, args.a_serial), ("b", args.b_port, args.b_serial)): + usb[name] = safe_identity(port, serial) + require_idle_port(port) + nodes[name] = Companion(port) + safe_identity(port, serial) # Recheck enumeration after opening. + usb[name]["companion"] = nodes[name].start() + report = run_matrix(nodes, preambles, sizes, count=args.count, timeout=args.timeout, + interval=args.interval, persist=persist) + except (Exception, KeyboardInterrupt) as error: + report = {"result": "FAIL", "failure": {"type": type(error).__name__, "message": str(error)[:1000]}} + persist(report) + finally: + for node in nodes.values(): + node.close() + print(json.dumps({"result": report["result"], "report": str(args.report), + "failure": report.get("failure")})) + return 0 if report["result"] in ("PASS", "LOSS") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hil/indicator_repeater_espnow.ini b/tools/hil/indicator_repeater_espnow.ini new file mode 100644 index 000000000..f1d1e711e --- /dev/null +++ b/tools/hil/indicator_repeater_espnow.ini @@ -0,0 +1,92 @@ +; HIL ONLY: SenseCAP Indicator D1 LoRa / ESP32-S3 N8R8 (8 MiB flash, OPI PSRAM). +; Reviewed Mercerwood unit MAC d8:3b:da:75:23:ac. Flash only after the normal +; mcfirmware adapter independently confirms that MAC and 8,388,608 flash bytes. +; No production variant or default environment is changed by this file. +; +; In the isolated build checkout, include this through platformio.local.ini: +; [platformio] +; extra_configs = variants/*/platformio.ini +; tools/hil/indicator_repeater_espnow.ini +; +; Validate offline (does not invoke PlatformIO): +; python3 -m unittest discover -s test -p test_indicator_repeater_hil_recipe.py +; Build directly, one PlatformIO process only: +; pio run -e SenseCapIndicator-LoRa_repeater_bridge_espnow_hil +; +; Deliberately do not use build.sh's release/full promotion for this test-only +; environment: its hardware partition/role identity and quiet RF defaults are +; specified here. This is a full-feature LoRa repeater plus ESP-NOW bridge, not +; the primary-ESP-NOW Companion variant. No flashing/erasing runs from this file. + +[hil_indicator_espnow] +lora_frequency = 908.5 +lora_bandwidth = 500 +lora_sf = 5 +lora_cr = 5 +tx_power = 2 +firmware_version = v1.18.0.1.hil-indicator-espnow-9085 + +[env:SenseCapIndicator-LoRa_repeater_bridge_espnow_hil] +extends = SenseCapIndicator-LoRa +board_build.partitions = variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv +board_upload.maximum_size = 2621440 +build_unflags = + -D DISPLAY_CLASS=SCIndicatorDisplay + -D MESH_PRIMARY_ESPNOW=1 + -D DISABLE_WIFI_OTA=1 + -D ENABLE_ADVERT_ON_BOOT=1 + -D LORA_FREQ=869.618 + -D LORA_BW=62.5 + -D LORA_SF=8 + -D LORA_TX_POWER=22 +build_flags = + ${SenseCapIndicator-LoRa.build_flags} + -UDISPLAY_CLASS + -UMESH_PRIMARY_ESPNOW + -UDISABLE_WIFI_OTA + -UWEBCONFIG_DISABLED + -D WITH_ESPNOW_BRIDGE=1 + -D ADVERT_NAME='"Indicator ESPNow HIL"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=254 + -D ENABLE_ADVERT_ON_BOOT=0 + -D DEFAULT_ADVERT_INTERVAL_MINUTES=0 + -D DEFAULT_FLOOD_ADVERT_INTERVAL_HOURS=0 + -D LORA_FREQ=${hil_indicator_espnow.lora_frequency} + -D LORA_BW=${hil_indicator_espnow.lora_bandwidth} + -D LORA_SF=${hil_indicator_espnow.lora_sf} + -D LORA_CR=${hil_indicator_espnow.lora_cr} + -D LORA_TX_POWER=${hil_indicator_espnow.tx_power} + -D MESH_HIL_TX_FREQ_MHZ=${hil_indicator_espnow.lora_frequency} + -D MESHCORE_EXPANDED_PARTITION_PROFILE=1 + -D WIFI_OTA_SEEDER=1 + -D CONFIG_ASYNC_TCP_STACK_SIZE=4096 + -D CONFIG_ASYNC_TCP_RUNNING_CORE=1 + -D ASYNCWEBSERVER_USE_CHUNK_INFLIGHT=0 + -D MESH_DEBUG=1 + -D MESH_PACKET_LOGGING=1 + -D FIRMWARE_VERSION='"${hil_indicator_espnow.firmware_version}"' + -D OTA_VARIANT='"SenseCapIndicator-LoRa_repeater_bridge_espnow_hil"' + -D MOTA_HW_ID='"SenseCapIndicator-LoRa"' +build_src_filter = + ${SenseCapIndicator-LoRa.build_src_filter} + - + -<../variants/sensecap_indicator-espnow/IndicatorFontClient.cpp> + + + + + - + +<../examples/simple_repeater> +lib_deps = + ${SenseCapIndicator-LoRa.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +; Fresh prefs already select bridge.format=wrapped, bridge.channel=1, bridge +; forwarding of RX packets, and the existing lab bridge secret. LoRa remains +; primary. Saved prefs override all compiled defaults: root must explicitly +; verify get radio/get lab.tx/get bridge.format/get espnow.enabled after flash. +; This preserves the existing settings map, not an automatic partition-table +; migration. Installing the 2.5 MiB map requires a checked merged-image flash +; or the established in-place partition migration workflow. diff --git a/tools/hil/mercer_mt_flash.sh b/tools/hil/mercer_mt_flash.sh index b998972e5..c1be3a14a 100644 --- a/tools/hil/mercer_mt_flash.sh +++ b/tools/hil/mercer_mt_flash.sh @@ -11,17 +11,21 @@ adapter_usage() { printf '%s\n' \ 'Usage: mercer_mt_flash.sh --flasher PATH --port /dev/tty... --workdir DIR' \ ' [--board LABEL] [--expect-mac XX:XX:XX:XX:XX:XX --flash-bytes N]' \ - ' [--read-baud 115200|460800] [--allow-write] [--allow-erase] -- MODE ARGS' \ + ' [--read-baud 115200|460800] [--allow-write] [--allow-erase] [--allow-reset] -- MODE ARGS' \ + ' [--watchdog-finish] (write-flash only; verified ESP32-S3 session)' \ 'Modes: probe; read-flash OFFSET LENGTH NEW_OUTPUT; write-flash OFFSET FILE ...;' \ - ' erase-flash; erase-region OFFSET LENGTH' \ + ' erase-flash; erase-region OFFSET LENGTH; reset-runtime' \ 'All non-probe modes require an expected MAC and flash size. Reads must write' \ 'new files inside workdir; writes/erases require their explicit allow flag.' \ - 'probe also enters ROM and finishes using the original identity-safe reset.' + 'probe also enters ROM and finishes using the original identity-safe reset.' \ + '--watchdog-finish clears only the volatile S3 forced-ROM bit before watchdog reset.' \ + 'reset-runtime requires --allow-reset, an exact MAC and flash size; it never writes flash.' } adapter_flasher='' adapter_port='' adapter_workdir='' adapter_board='ESP32-S3' adapter_expected_mac='' adapter_expected_bytes='' adapter_read_baud=115200 -adapter_allow_write=0 adapter_allow_erase=0 adapter_session_active=0 +adapter_allow_write=0 adapter_allow_erase=0 adapter_session_active=0 adapter_watchdog_finish=0 +adapter_allow_reset=0 while (($#)); do case "$1" in --flasher|--port|--workdir|--board|--expect-mac|--flash-bytes|--read-baud) @@ -38,6 +42,8 @@ while (($#)); do shift 2 ;; --allow-write) adapter_allow_write=1; shift ;; --allow-erase) adapter_allow_erase=1; shift ;; + --allow-reset) adapter_allow_reset=1; shift ;; + --watchdog-finish) adapter_watchdog_finish=1; shift ;; --help|-h) adapter_usage; exit 0 ;; --) shift; break ;; *) adapter_die "Unknown option: $1" ;; @@ -59,7 +65,7 @@ if [[ -n "$adapter_expected_mac" ]]; then fi if [[ -n "$adapter_expected_bytes" ]]; then [[ "$adapter_expected_bytes" =~ ^[0-9]+$ ]] || adapter_die 'Flash bytes must be decimal.' - [[ "$adapter_expected_bytes" == 8388608 || "$adapter_expected_bytes" == 16777216 ]] || adapter_die 'Only confirmed Heltec 8 MiB/16 MiB boards are in scope.' + [[ "$adapter_expected_bytes" == 8388608 || "$adapter_expected_bytes" == 16777216 ]] || adapter_die 'Only confirmed 8 MiB/16 MiB ESP32-S3 devices are in scope.' fi case "$adapter_mode" in probe) ((${#adapter_operation_args[@]} == 0)) || adapter_die 'probe takes no arguments.' ;; @@ -73,8 +79,17 @@ case "$adapter_mode" in erase-region) ((adapter_allow_erase)) || adapter_die 'erase-region requires --allow-erase.' ((${#adapter_operation_args[@]} == 2)) || adapter_die 'erase-region requires OFFSET LENGTH.' ;; + reset-runtime) + ((adapter_allow_reset)) || adapter_die 'reset-runtime requires --allow-reset.' + ((${#adapter_operation_args[@]} == 0)) || adapter_die 'reset-runtime takes no arguments.' ;; *) adapter_die "Unsupported mode: $adapter_mode" ;; esac +if ((adapter_watchdog_finish)); then + [[ "$adapter_mode" == write-flash ]] || adapter_die '--watchdog-finish requires write-flash.' +fi +if ((adapter_allow_reset)); then + [[ "$adapter_mode" == reset-runtime ]] || adapter_die '--allow-reset requires reset-runtime.' +fi if [[ "$adapter_mode" != probe ]]; then [[ -n "$adapter_expected_mac" && -n "$adapter_expected_bytes" ]] || adapter_die 'Probe first, then supply the verified expected MAC and flash bytes.' fi @@ -210,6 +225,9 @@ ESP32_OPERATION_BEFORE=$NORESET adapter_session_active=1 prepare_esp32_flash_session "$adapter_port" "$adapter_board" [[ "$ESP32_SESSION_IS_S3" == 1 ]] || adapter_die 'Probe did not identify ESP32-S3.' +if ((adapter_watchdog_finish)) || [[ "$adapter_mode" == reset-runtime ]]; then + [[ "${WATCHDOGRESET:-}" == watchdog-reset ]] || adapter_die 'A supported S3 watchdog reset is required before writing or resetting.' +fi if [[ -n "$adapter_expected_mac" && "$ESP32_FLASH_EXPECTED_MAC" != "$adapter_expected_mac" ]]; then adapter_die "MAC mismatch: typed $adapter_expected_mac, probe $ESP32_FLASH_EXPECTED_MAC." fi @@ -223,7 +241,7 @@ if [[ -n "$adapter_expected_bytes" && "$adapter_detected_bytes" != "$adapter_exp adapter_die "Flash size mismatch: expected $adapter_expected_bytes, detected $adapter_detected_bytes." fi printf 'VERIFIED mac=%s chip=ESP32-S3 flash_bytes=%s port=%s\n' "$ESP32_FLASH_EXPECTED_MAC" "$adapter_detected_bytes" "$adapter_live_port" -if [[ "$adapter_mode" != probe ]]; then +if [[ "$adapter_mode" != probe && "$adapter_mode" != reset-runtime ]]; then # Re-resolve by-id and re-probe MAC for READS too. Destructive commands also # repeat this inside the untouched run_esp32_session_esptool implementation. adapter_live_port=$(esp32_verified_destructive_port "$DEVICE_PORT" "$adapter_mode") @@ -236,6 +254,29 @@ if [[ "$adapter_mode" != probe ]]; then sha256sum -- "$adapter_output" fi fi -finish_esp32_flash_session "$DEVICE_PORT" +if ((adapter_watchdog_finish)) || [[ "$adapter_mode" == reset-runtime ]]; then + # In write-flash mode the write above must already have succeeded, including + # the real flasher's positive flash-hash verification. reset-runtime instead + # reaches here without any write/erase operation or corresponding authority. + # A USB/JTAG bootloader entry + # can retain FORCE_DOWNLOAD_BOOT, so a watchdog alone would return to ROM. + # Clear only bit 0 of volatile RTC_CNTL_OPTION1, as in the S3 ROM helper; + # this is not a flash, eFuse, erase or persistent bootloader modification. + # Re-prove the bound USB identity and chip MAC before EACH finish phase. + adapter_live_port=$(esp32_verified_destructive_port "$DEVICE_PORT" 'S3 volatile forced-ROM bit clear') + adapter_assert_unowned "$adapter_live_port" + run_esp32_session_esptool "$adapter_live_port" --after "$NORESET" --baud 115200 \ + write-mem 0x6000812c 0 0x1 + adapter_live_port=$(esp32_verified_destructive_port "$DEVICE_PORT" 'S3 watchdog finish') + adapter_assert_unowned "$adapter_live_port" + # Once reset may have started the application, no automatic ROM recovery + # or second reset is authorized. Any lost final reply is an explicit error. + BOOTLOADER_PROBE_ACTIVE=0 + BOOTLOADER_PROBE_PORT='' + run_esp32_session_esptool "$adapter_live_port" --after "$WATCHDOGRESET" --baud 115200 "$READMAC" + ESP32_NATIVE_ROM_READY=0 +else + finish_esp32_flash_session "$DEVICE_PORT" +fi adapter_session_active=0 printf 'Session finished. Application boot still requires an application-level check. State: %s\n' "$adapter_state_dir" diff --git a/tools/hil/mercer_nrf_app_flash.sh b/tools/hil/mercer_nrf_app_flash.sh new file mode 100644 index 000000000..11f86035b --- /dev/null +++ b/tools/hil/mercer_nrf_app_flash.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# Identity-bound, application-only adapter for the actual mcfirmware.sh DFU. +# No startup code, erase package, bootloader/SoftDevice update, service stop, +# privilege escalation, unmatched-device override or automatic recovery runs. +# Take any required logical backup separately, before entering the bootloader. +set -euo pipefail +umask 077 + +nrf_adapter_die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } +nrf_adapter_usage() { + printf '%s\n' \ + 'Usage: mercer_nrf_app_flash.sh --flasher PATH --port /dev/tty...' \ + ' --workdir DIR --inventory JSON --serial USB_SERIAL --board LABEL' \ + ' --artifact APPLICATION.zip --sha256 HEX --allow-write' \ + 'Only an inventory-matched application-only DFU ZIP is accepted.' \ + 'The sibling verified_nrf_flash.py helper and installed dependencies are required.' \ + 'Successful DFU still requires a separate application-level boot/version check.' +} + +nrf_adapter_flasher='' nrf_adapter_port='' nrf_adapter_workdir='' +nrf_adapter_inventory='' nrf_adapter_serial='' nrf_adapter_board='' +nrf_adapter_artifact='' nrf_adapter_sha256='' nrf_adapter_allow_write=0 +nrf_adapter_session_active=0 +while (($#)); do + case "$1" in + --flasher|--port|--workdir|--inventory|--serial|--board|--artifact|--sha256) + (($# >= 2)) || nrf_adapter_die "Missing value for $1" + case "$1" in + --flasher) nrf_adapter_flasher=$2 ;; + --port) nrf_adapter_port=$2 ;; + --workdir) nrf_adapter_workdir=$2 ;; + --inventory) nrf_adapter_inventory=$2 ;; + --serial) nrf_adapter_serial=$2 ;; + --board) nrf_adapter_board=$2 ;; + --artifact) nrf_adapter_artifact=$2 ;; + --sha256) nrf_adapter_sha256=$2 ;; + esac + shift 2 ;; + --allow-write) nrf_adapter_allow_write=1; shift ;; + --help|-h) nrf_adapter_usage; exit 0 ;; + *) nrf_adapter_die "Unknown option: $1" ;; + esac +done +((nrf_adapter_allow_write)) || nrf_adapter_die 'An application update requires --allow-write.' +[[ "$nrf_adapter_port" =~ ^/dev/tty(ACM|USB)[0-9]+$ ]] || nrf_adapter_die 'Select a local USB serial tty.' +[[ "$nrf_adapter_serial" =~ ^[[:alnum:]]{6,64}$ ]] || nrf_adapter_die 'Provide the reviewed stable USB chip serial.' +[[ -n "$nrf_adapter_board" && "$nrf_adapter_board" != *$'\n'* ]] || nrf_adapter_die 'Provide the reviewed board label.' +[[ "$nrf_adapter_sha256" =~ ^[[:xdigit:]]{64}$ ]] || nrf_adapter_die 'Expected SHA256 must contain exactly 64 hex digits.' +nrf_adapter_sha256=${nrf_adapter_sha256,,} +[[ -d "$nrf_adapter_workdir" ]] || nrf_adapter_die 'Create a dedicated workdir first.' +nrf_adapter_workdir=$(realpath -e -- "$nrf_adapter_workdir") +[[ "$nrf_adapter_workdir" != / && "$nrf_adapter_workdir" != /home \ + && "$nrf_adapter_workdir" != "${HOME:-}" ]] || nrf_adapter_die 'Use a dedicated run directory.' +for nrf_adapter_command in awk bash python3 pipx timeout udevadm fuser realpath sha256sum stat cp chmod stty socat perl unzip strings; do + command -v "$nrf_adapter_command" >/dev/null || nrf_adapter_die "Required command is unavailable: $nrf_adapter_command" +done +nrf_adapter_flasher=$(realpath -e -- "$nrf_adapter_flasher") +nrf_adapter_inventory=$(realpath -e -- "$nrf_adapter_inventory") +nrf_adapter_artifact=$(realpath -e -- "$nrf_adapter_artifact") +[[ -f "$nrf_adapter_flasher" && -s "$nrf_adapter_flasher" ]] || nrf_adapter_die 'Provide the complete local mcfirmware.sh.' +[[ -f "$nrf_adapter_inventory" && -s "$nrf_adapter_inventory" ]] || nrf_adapter_die 'Provide the reviewed physical-board inventory.' +[[ -f "$nrf_adapter_artifact" && -s "$nrf_adapter_artifact" \ + && "${nrf_adapter_artifact,,}" == *.zip ]] || nrf_adapter_die 'Provide an application DFU ZIP.' +nrf_adapter_helper="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/verified_nrf_flash.py" +[[ -f "$nrf_adapter_helper" ]] || nrf_adapter_die 'Keep verified_nrf_flash.py beside this adapter.' +python3 -c 'import serial, zipfile' || nrf_adapter_die 'Install the required Python dependencies separately.' + +nrf_adapter_check_hash() { + local observed + observed=$(sha256sum -- "$1") + [[ "${observed%% *}" == "$nrf_adapter_sha256" ]] || nrf_adapter_die 'Application ZIP SHA256 mismatch.' +} +nrf_adapter_assert_unowned() { + local output='' status=0 + # Some Pi psmisc versions do not accept '--'. The port was restricted above. + [[ "$1" =~ ^/dev/tty(ACM|USB)[0-9]+$ ]] || nrf_adapter_die 'Unsafe serial endpoint.' + if output=$(fuser "$1" 2>&1); then + printf '%s\n' "$output" >&2 + nrf_adapter_die "A process owns $1; no owner will be killed." + else status=$?; fi + [[ "$status" == 1 && -z "$output" ]] || nrf_adapter_die "Cannot prove $1 is unowned: $output" +} +nrf_adapter_check_hash "$nrf_adapter_artifact" +nrf_adapter_state_dir=$(mktemp -d "$nrf_adapter_workdir/.mcfirmware-nrf-app.XXXXXX") +nrf_adapter_definitions="$nrf_adapter_state_dir/functions.sh" +# Same definitions-only extraction convention as mercer_mt_flash.sh. Syntax +# failure is fatal; no flasher global assignments, traps or startup are sourced. +awk ' + { sub(/\r$/, "") } + /^[A-Za-z_][A-Za-z0-9_]*\(\)[[:space:]]*\{[[:space:]]*$/ { + if (capture) exit 2 + capture=1; count++ + } + capture { print } + capture && /^}[[:space:]]*$/ { capture=0 } + END { if (capture || count < 100) exit 2 } +' "$nrf_adapter_flasher" > "$nrf_adapter_definitions" +bash -n "$nrf_adapter_definitions" +# shellcheck source=/dev/null +source "$nrf_adapter_definitions" +for nrf_adapter_function in preferred_flash_serial_port save_selected_serial_port \ + nrf52_validate_rak_board_pair nrf52_selected_by_id_path udev_device_property \ + nrf52_usb_path_stem run_nrf52_dfu_package_buttonless; do + declare -F "$nrf_adapter_function" >/dev/null || nrf_adapter_die "Flasher lacks $nrf_adapter_function." +done + +# Stage a private, hash-verified snapshot, so the build output may not change +# beneath a DFU operation. Keep the original board-qualified artifact basename. +nrf_adapter_staged="$nrf_adapter_state_dir/$(basename -- "$nrf_adapter_artifact")" +cp -- "$nrf_adapter_artifact" "$nrf_adapter_staged" +chmod 0400 -- "$nrf_adapter_staged" +nrf_adapter_check_hash "$nrf_adapter_staged" +cp -- "$nrf_adapter_inventory" "$nrf_adapter_state_dir/inventory.json" +chmod 0400 -- "$nrf_adapter_state_dir/inventory.json" +sha256sum -- "$nrf_adapter_flasher" "$nrf_adapter_definitions" "$nrf_adapter_staged" + +MCFIRMWARE_NO_SUDO=1 +MCFIRMWARE_NRF52_ACK_TIMEOUT_SECONDS=10 +NRF52_DFU_REENUMERATE_TIMEOUT_SECONDS=30 +NRF52_DFU_REENUMERATE_POLL_SECONDS=0.25 +NRF52_BOARD_GUARD_PASSED=0 +NRF52_SELECTED_BY_ID='' NRF52_RUNTIME_SERIAL='' NRF52_RUNTIME_PATH_STEM='' +NRF52_LAST_DFU_PORT='' NRF52_LAST_DFU_INSTANCE='' +USB_RESET_FAILED=0 +DEVICE_PORT_FILE="$nrf_adapter_state_dir/selected-port" +DEVICE_PORT_NAME_FILE="$nrf_adapter_state_dir/selected-by-id" +DEVICE_PORT=$nrf_adapter_port +BAUD=115200 +DEFAULT_BAUDS=(115200) +SERIAL_BAUD_CACHE=115200 +# These fail-closed boundaries never alter actual identity matching or DFU. +nrf52_confirm_board_override() { printf 'Board override is forbidden.\n' >&2; return 1; } +nrf52_confirm_unmatched_dfu_override() { printf 'Unmatched DFU override is forbidden.\n' >&2; return 1; } +ensure_sudo_session() { printf 'Privilege escalation is forbidden.\n' >&2; return 1; } +install_packages() { printf 'Package installation is forbidden.\n' >&2; return 1; } +nrf52_serial_port_access() { + nrf_adapter_assert_unowned "$1" + [[ -r "$1" && -w "$1" ]] || { printf 'Serial access must already be granted.\n' >&2; return 1; } +} + +nrf_adapter_assert_unowned "$nrf_adapter_port" +nrf_adapter_port=$(preferred_flash_serial_port "$nrf_adapter_port") +nrf_adapter_assert_unowned "$nrf_adapter_port" +python3 "$nrf_adapter_helper" --inventory "$nrf_adapter_state_dir/inventory.json" \ + --serial "$nrf_adapter_serial" --board "$nrf_adapter_board" \ + --artifact "$nrf_adapter_staged" --application-only --port "$nrf_adapter_port" +save_selected_serial_port "$nrf_adapter_port" +nrf52_validate_rak_board_pair "$nrf_adapter_staged" "$nrf_adapter_port" "$nrf_adapter_board" +NRF52_BOARD_GUARD_PASSED=1 +NRF52_SELECTED_BY_ID=$(nrf52_selected_by_id_path) +NRF52_RUNTIME_SERIAL=$(udev_device_property "$nrf_adapter_port" ID_SERIAL_SHORT) +NRF52_RUNTIME_PATH_STEM=$(nrf52_usb_path_stem "$(udev_device_property "$nrf_adapter_port" ID_PATH)") +[[ -n "$NRF52_SELECTED_BY_ID" && -n "$NRF52_RUNTIME_SERIAL" ]] || nrf_adapter_die 'No stable USB identity was captured.' +[[ "${NRF52_RUNTIME_SERIAL^^}" == "${nrf_adapter_serial^^}" ]] || nrf_adapter_die 'USB identity changed after validation.' +nrf_adapter_check_hash "$nrf_adapter_staged" +nrf_adapter_assert_unowned "$nrf_adapter_port" +nrf_adapter_exit_notice() { + if ((nrf_adapter_session_active)); then + printf 'DFU interrupted or failed. No recovery/reset was attempted; reselect the same physical radio. State: %s\n' "$nrf_adapter_state_dir" >&2 + fi +} +trap nrf_adapter_exit_notice EXIT +nrf_adapter_session_active=1 +run_nrf52_dfu_package_buttonless "$nrf_adapter_staged" "$nrf_adapter_port" 0 0 +nrf_adapter_session_active=0 +printf 'Application-only DFU completed; boot/version verification is still required. State: %s\n' "$nrf_adapter_state_dir" diff --git a/tools/hil/meshtext_delivery.py b/tools/hil/meshtext_delivery.py new file mode 100644 index 000000000..4184b5bf8 --- /dev/null +++ b/tools/hil/meshtext_delivery.py @@ -0,0 +1,773 @@ +#!/usr/bin/env python3 +"""Exercise Meshtext first-chat metadata and heard-repeat delivery on two radios. + +CHANGES BOTH RADIOS' SETTINGS AND TRANSMITS LoRa. Requires explicit consent. +The fixed private lab channel uses 909.5 MHz / 500 kHz / SF5 / CR5, TX 2 dBm. +Keys remain in memory and are never included in the report. This does not flash +firmware, back up settings, or restore them. Run --plan without dependencies or +ports. A stock peer's decoded events are not an independent raw RF capture. + +Example: + python meshtext_delivery.py --allow-radio-changes --companion-port /dev/ttyACM9 \ + --stock-port /dev/ttyUSB1 --report /tmp/meshtext-delivery-new.json +""" + +from __future__ import annotations + +import argparse +import base64 +from datetime import datetime, timezone +import importlib.metadata +import json +import os +from pathlib import Path +import re +import secrets +import shutil +import subprocess +import sys +import time + + +RF = {"frequency_mhz": 909.5, "bandwidth_khz": 500, "sf": 5, "cr": 5, + "tx_power_dbm": 2, "stock_preamble_symbols": 16} +CASES = ("quiet", "first-use", "exhaustion", "once", "echo", "inbound", "hops-zero", + "same-tuple", "rename") + + +class Failure(RuntimeError): + pass + + +def check(condition, message): + if not condition: + raise Failure(message) + + +def retry_stats(reply): + values = dict(re.findall(r"\b(retry|heard|exhausted|pending|count)=(\d+)", reply)) + check(len(values) == 5, "get mt.retry did not provide all five counters") + return {name: int(value) for name, value in values.items()} + + +def tx_stats(reply): + match = re.search(r"\bRX=(\d+) TX=(\d+) fail=(\d+)", reply) + check(match is not None, "get mt.status did not provide RF completion counters") + return dict(zip(("rx", "tx", "fail"), map(int, match.groups()))) + + +def delta(after, before): + return {name: after[name] - before[name] for name in after} + + +def port_number(packet): + value = packet.get("decoded", {}).get("portnum") + # The Python SDK normally emits enum names, but accept numeric SDK output. + return {1: "TEXT_MESSAGE_APP", 3: "POSITION_APP", 4: "NODEINFO_APP"}.get(value, value) + + +def packet_summary(packet): + return {"id": packet.get("id"), "port": port_number(packet), + "hop_limit": packet.get("hopLimit"), "hop_start": packet.get("hopStart")} + + +def cli_command_label(command): + words = command.split() + # Identify the setting, never its value (especially mt.channel keys). + return " ".join(words[:2] if words and words[0] in ("get", "set", "send", "delete") else words[:1]) + + +def safe_cli_reply(command, reply): + # Ordinary firmware replies do not echo the key; still redact it if a + # diagnostic implementation does so. Do not save the full command itself. + words = command.split() + if len(words) >= 5 and words[:2] == ["set", "mt.channel"]: + reply = reply.replace(words[-1], "") + return re.sub(r"(?", reply)[:500] + + +def require_idle_port(path): + # Do not reset/steal a serial session held by a flasher or client. BusyBox + # fuser lacks '--', so the inspected absolute path is a separate argument. + inspector = shutil.which("fuser") if sys.platform.startswith("linux") else None + if inspector: + result = subprocess.run([inspector, os.path.realpath(path)], capture_output=True, timeout=3) + check(result.returncode == 1, "selected serial port is busy or ownership inspection failed") + + +def enter_binary_mode(port): + """Explicitly release a Full Companion's default ASCII USB terminal. + + The startup probe is intentionally not assumed to be a permanent Binary + mode. Assert host presence only after opening with RTS/DTR safely released, + then drain the terminal's acknowledgement before creating framed requests. + Never retain/log the discarded plaintext (it may include unrelated logs). + """ + port.rts = False + port.dtr = True + token = b"+++MESHCORE-TERM-STOP\r" + check(port.write(token) == len(token), "incomplete USB Binary-mode negotiation write") + port.flush() + time.sleep(0.3) + deadline = time.monotonic() + 1 + discarded = 0 + tail = b"" + while time.monotonic() < deadline: + chunk = port.read(min(port.in_waiting or 1, 4096)) + if not chunk: + break + discarded += len(chunk) + check(discarded <= 65536, "USB terminal output exceeded bounded negotiation drain") + tail = (tail + chunk)[-512:] + check(b"set usb.logging off before Binary mode" not in tail, + "USB logging owns this port; set usb.logging off in the terminal before this test") + return {"explicit_stop_token": True, "discarded_terminal_bytes": discarded, "dtr": True} + + +def reconnect_stock_readback(peer, validate): + """Allow a committed stock configuration to settle across its reboot. + + Each SDK connect has the existing helper's 20-second timeout. Only static + field names/error types enter this history, never the observed secret key. + """ + history = [] + for attempt in range(1, 4): + try: + peer.connect() + mismatches = validate(peer.iface.localNode) + if not mismatches: + return {"attempts": attempt, "retry_history": history} + except Exception as error: + mismatches = ["connect/readback error: " + type(error).__name__] + history.append({"attempt": attempt, "mismatched_fields": mismatches}) + if peer.iface is not None: + try: + peer.iface.close() + finally: + peer.iface = None + if attempt < 3: + time.sleep(2) + raise Failure("stock configuration did not settle after three bounded reconnects; " + "last mismatched fields: " + ", ".join(history[-1]["mismatched_fields"])) + + +def require_stock_connected(peer): + """Silence is evidence only while the stock receive path is connected.""" + iface = getattr(peer, "iface", None) + check(iface is not None, "stock SDK disconnected; quiet/RX observations are invalid") + state = {} + connected = getattr(iface, "isConnected", None) + if hasattr(connected, "is_set"): + state["sdk_connected"] = bool(connected.is_set()) + elif isinstance(connected, bool): + state["sdk_connected"] = connected + stream = getattr(iface, "stream", None) + if stream is not None and hasattr(stream, "is_open"): + state["serial_open"] = bool(stream.is_open) + check(state, "stock SDK exposes no supported connection-health indicator") + check(all(state.values()), "stock SDK disconnected; quiet/RX observations are invalid") + return state + + +def dependencies(): + """Delay hardware dependencies so --plan also works on a clean computer.""" + import serial + from meshtext_auto_radio2 import Companion, StockPeer + from esp32_companion_serial_stress import DeviceFrameReader, TransportCounters + from serial_session import open_configured_session + + class SafeCompanion(Companion): + def __init__(self, port): + self.port = serial.Serial(port=None, baudrate=115200, timeout=0.05, + write_timeout=2, exclusive=True) + self.port.port, self.port.dtr, self.port.rts = port, False, False + open_configured_session(self.port) + self.reader = DeviceFrameReader(TransportCounters()) + self.max_channels = 0 + + def start(self): + negotiation = enter_binary_mode(self.port) + # The reader has not received any framed traffic yet; the ASCII + # acknowledgement must not become a stale framed response. + self.reader = DeviceFrameReader(TransportCounters()) + return {**super().start(), "usb_binary_negotiation": negotiation} + + def cli(self, command): + label = cli_command_label(command) + try: + frame = self.request(b"\x42" + command.encode("utf-8"), (0x1d,)) + reply = frame[1:].decode("utf-8", "strict").strip() + except Exception as error: + raise Failure("Companion CLI " + label + " failed: " + + safe_cli_reply(command, str(error))) from error + check(not re.match(r"(?:Error|ERR|Err)\b", reply), + "Companion CLI " + label + " rejected: " + safe_cli_reply(command, reply)) + return reply + + class LabStock(StockPeer): + def configure_lab(self, key, role_name, hops=7): + from meshtastic.protobuf import channel_pb2, config_pb2 + from meshtastic.util import to_node_num + + node = self.iface.localNode + node.ensureSessionKey() + deadline = time.monotonic() + 10 + while not self.iface._getOrCreateByNum(to_node_num(node.nodeNum)).get("adminSessionPassKey"): + check(time.monotonic() < deadline, "stock admin session setup timed out") + time.sleep(0.05) + node.beginSettingsTransaction() + time.sleep(0.75) + suppressed = [] + + def optional(message, field, value): + if field in message.DESCRIPTOR.fields_by_name: + setattr(message, field, value) + suppressed.append(field) + + device = node.localConfig.device + device.role = config_pb2.Config.DeviceConfig.Role.Value(role_name) + device.rebroadcast_mode = config_pb2.Config.DeviceConfig.RebroadcastMode.Value("ALL") + optional(device, "node_info_broadcast_secs", 604800) + node.writeConfig("device") + time.sleep(0.75) + position = node.localConfig.position + optional(position, "position_broadcast_secs", 604800) + optional(position, "position_broadcast_smart_enabled", False) + optional(position, "fixed_position", False) + gps_field = position.DESCRIPTOR.fields_by_name.get("gps_mode") + if gps_field and "DISABLED" in gps_field.enum_type.values_by_name: + optional(position, "gps_mode", gps_field.enum_type.values_by_name["DISABLED"].number) + node.writeConfig("position") + time.sleep(0.75) + telemetry = getattr(node.moduleConfig, "telemetry", None) + if telemetry is not None: + for field in telemetry.DESCRIPTOR.fields: + if field.name.endswith("_enabled"): + optional(telemetry, field.name, False) + elif field.name.endswith("_interval"): + optional(telemetry, field.name, 604800) + node.writeConfig("telemetry") + time.sleep(0.75) + primary = node.channels[0] + primary.role = channel_pb2.Channel.PRIMARY + primary.settings.name, primary.settings.psk = "labtest", key + primary.settings.uplink_enabled = primary.settings.downlink_enabled = False + lora = node.localConfig.lora + lora.region = config_pb2.Config.LoRaConfig.US + lora.use_preset = False + lora.bandwidth, lora.spread_factor, lora.coding_rate = 500, 5, 5 + lora.channel_num, lora.override_frequency, lora.frequency_offset = 0, 909.5, 0 + lora.hop_limit, lora.tx_power, lora.tx_enabled = hops, 2, True + lora.config_ok_to_mqtt, lora.ignore_mqtt = False, True + node.writeConfig("lora") + time.sleep(0.75) + node.writeChannel(0) + time.sleep(0.75) + # Disable other channels rather than altering their secret keys. + disabled = [] + for index, channel in enumerate(node.channels[1:], 1): + if channel.role != channel_pb2.Channel.DISABLED: + channel.role = channel_pb2.Channel.DISABLED + node.writeChannel(index) + disabled.append(index) + time.sleep(0.75) + node.commitSettingsTransaction() + # A successful commit/readback can precede the actual stock reboot. + # Reopening too early gives a convincing config followed by a dead + # receive stream. Allow that deferred reboot to finish first. + time.sleep(20) + self.iface.close() + self.iface = None + + def validate(observed_node): + observed, channel = observed_node.localConfig.lora, observed_node.channels[0] + checks = { + "channel.role": channel.role == channel_pb2.Channel.PRIMARY, + "channel.name": channel.settings.name == "labtest", + "channel.private_key_match": channel.settings.psk == key, + "channel.mqtt_off": not channel.settings.uplink_enabled and not channel.settings.downlink_enabled, + "lora.region": observed.region == config_pb2.Config.LoRaConfig.US, + "lora.custom_mode": not observed.use_preset, + "lora.bandwidth": observed.bandwidth == 500, + "lora.spread_factor": observed.spread_factor == 5, + "lora.coding_rate": observed.coding_rate == 5, + "lora.override_frequency": abs(observed.override_frequency - 909.5) < 0.001, + "lora.frequency_offset": observed.frequency_offset == 0, + "lora.channel_num": observed.channel_num == 0, + "lora.tx_power": observed.tx_power == 2, + "lora.tx_enabled": observed.tx_enabled, + "lora.hop_limit": observed.hop_limit == hops, + "lora.mqtt_off": not observed.config_ok_to_mqtt and observed.ignore_mqtt, + "device.role": observed_node.localConfig.device.role + == config_pb2.Config.DeviceConfig.Role.Value(role_name), + "device.rebroadcast_mode": observed_node.localConfig.device.rebroadcast_mode + == config_pb2.Config.DeviceConfig.RebroadcastMode.Value("ALL"), + } + return [field for field, matched in checks.items() if not matched] + + settled = reconnect_stock_readback(self, validate) + return {**self.identity(), "role": role_name, "rebroadcast": "ALL", "rf": RF, + "periodic_suppression_requested": suppressed, + "secondary_channel_slots_disabled": disabled, "readback": settled} + + return SafeCompanion, LabStock + + +class Report: + def __init__(self, path, data): + # Reserve the destination before any radio setting is changed. + self.stream = path.open("x", encoding="utf-8") + try: + os.chmod(path, 0o600) + except OSError: + pass + self.data = data + self.save() + + def save(self): + self.stream.seek(0) + json.dump(self.data, self.stream, indent=2) + self.stream.write("\n") + self.stream.truncate() + self.stream.flush() + os.fsync(self.stream.fileno()) + + def case(self, name): + result = {"name": name, "result": "RUNNING"} + self.data["cases"].append(result) + self.save() + print("Testing " + name, flush=True) + return result + + def finish_case(self, case): + case["result"] = "PASS" + self.save() + print("PASS " + case["name"], flush=True) + + +def snapshot(companion): + status, retry = companion.cli("get mt.status"), companion.cli("get mt.retry") + return {"status": status, "retry_status": retry, + "traffic": tx_stats(status), "delivery": retry_stats(retry)} + + +def wait_idle(companion, timeout): + deadline = time.monotonic() + timeout + latest = None + while time.monotonic() < deadline: + latest = snapshot(companion) + if latest["delivery"]["pending"] == 0: + return latest + time.sleep(0.1) + raise Failure("Meshtext delivery queue did not become idle; last=" + str(latest)) + + +def wait_active(companion, timeout): + deadline = time.monotonic() + timeout + first = last = "" + polls = 0 + while time.monotonic() < deadline: + last = companion.cli("get mt.status") + if not first: + first = last + polls += 1 + if "; active;" in last: + return {"first_status": first, "active_status": last, "polls": polls} + time.sleep(0.1) + raise Failure("Meshtext did not become active after settings publication; last=" + last) + + +def inspect_nodeinfo(packet, node_id): + from meshtastic.protobuf import mesh_pb2 + user = mesh_pb2.User() + user.ParseFromString(packet.get("decoded", {}).get("payload", b"")) + check(user.id == f"!{node_id:08x}", "NodeInfo sender ID mismatch") + check(user.role == 1 and user.is_unmessagable, "NodeInfo is not a receive-only hidden client") + check(1 <= len(user.short_name.encode()) <= 4 and len(user.long_name.encode()) <= 24, + "NodeInfo names exceed supported bounds") + check(not user.public_key, "unexpected public encryption key in NodeInfo") + raw = packet.get("raw") + if raw is not None: + check(not raw.want_ack and not raw.decoded.want_response, "NodeInfo requests a response") + return {"id": user.id, "short_name": user.short_name, "long_name": user.long_name, + "role": user.role, "is_unmessagable": user.is_unmessagable, + "payload_type": "User (no position fields)", "packet": packet_summary(packet)} + + +def run(args): + key = secrets.token_bytes(args.key_bits // 8) + run_id = secrets.token_hex(4) + selected = set(CASES if args.case == "all" else args.case.split(",")) + data = {"schema": 1, "started_utc": datetime.now(timezone.utc).isoformat(), + "result": "RUNNING", "run_id": run_id, "rf": RF, "key_bits": args.key_bits, + "cases_requested": sorted(selected), "settings_restored": False, + "restore_owner": "operator", "cases": [], + "limitations": ["Stock decoded delivery events are not an independent raw RF capture.", + "Exact retry ciphertext identity is covered by host tests, not inspected by this runner.", + "No POSITION_APP means none observed during bounded windows, not a proof of future behavior.", + "The stock device may still send boot metadata despite long periodic intervals."]} + report = Report(args.report, data) + companion = stock = None + try: + Companion, Stock = dependencies() + data["packages"] = {name: importlib.metadata.version(name) + for name in ("pyserial", "meshtastic", "pypubsub")} + require_idle_port(args.companion_port) + require_idle_port(args.stock_port) + companion = Companion(args.companion_port) + data["companion"] = companion.start() + check(args.channel_slot < companion.max_channels, "Companion channel slot out of range") + # The primary is intentionally not changed here: CLI 'set radio' is + # reboot-only. Preconfigure it, and preferably use the lab TX guard too. + data["primary"] = companion.cli("get radio") + check(abs(float(data["primary"].lstrip("> ").split(",")[0]) - 909.5) < 0.001, + "preconfigure primary to 909.5 MHz (or flash a lab profile) before this runner") + for slot in range(companion.max_channels): + if slot != args.channel_slot: + name = companion.channel_name(slot).lstrip("#") + check(not name.startswith(("mt-", "r2-")), + "another Meshtext/radio2 channel conflicts; remove it explicitly first") + target_name = companion.channel_name(args.channel_slot).lstrip("#") + if target_name.startswith("mt-"): + companion.cli(f"delete mt.channel {args.channel_slot}") + # Clear temporary leases before installing a manual lab-only profile. + for command in ("normalradio", "set tempradio2 off", "set mt.region us", + "set mt.slot 0", "set mt.hops 7", "set tx 2", "set radio2.cross off", + "set flood.retry.count 2", "set flood.retry.advert off", + "set radio2 909.5,500,5,5,rxtx,32"): + companion.cli(command) + stock = Stock(args.stock_port) + data["stock_silent"] = stock.configure_lab(key, "CLIENT_MUTE") + stock_role = "CLIENT_MUTE" + node_id = companion.mt_node() + check(stock.node != node_id, "stock and Companion sender IDs collide") + data["companion"]["mt_node"] = f"!{node_id:08x}" + # Installing a new random key resets first-use identity announcement. + companion.cli(f"set mt.channel {args.channel_slot} mt-labtest {key.hex()}") + data["initial"] = snapshot(companion) + data["radio2"] = companion.cli("get radio2") + check("; active;" in data["initial"]["status"], "Meshtext profile is not active") + report.save() + + if "quiet" in selected: + case = report.case("quiet") + case["stock_connected_before"] = require_stock_connected(stock) + before, since = snapshot(companion), time.monotonic() + time.sleep(args.quiet_seconds) + case["stock_connected_after"] = require_stock_connected(stock) + after = snapshot(companion) + case.update(before=before, after=after, observed_seconds=args.quiet_seconds, + packets=[packet_summary(p) for p in stock.packets_since(since, node_id)]) + report.save() + check(after["traffic"]["tx"] == before["traffic"]["tx"], + "Companion transmitted Meshtext before any user chat") + check(not case["packets"], "stock observed unsolicited DUT traffic") + report.finish_case(case) + if selected == {"quiet"}: + data["final"] = after + data["result"] = "PASS" + return data + + # The first send is also needed to prime the once/echo cases. Report it + # even when only a dependent test was requested. + case = report.case("first-use") + token = f"MT-delivery {run_id} first" + before, since = snapshot(companion), time.monotonic() + companion.send_text(args.channel_slot, token) + text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload") + == token.encode(), since, args.timeout) + check(text is not None, "stock did not receive first chat") + info = stock.wait_packet(node_id, lambda p: port_number(p) == "NODEINFO_APP", since, args.timeout) + check(info is not None, "stock did not receive automatic NodeInfo after first chat") + case["nodeinfo"] = inspect_nodeinfo(info, node_id) + after = wait_idle(companion, args.timeout) + packets = stock.packets_since(since, node_id) + texts = [p for p in packets if p.get("decoded", {}).get("payload") == token.encode()] + infos = [p for p in packets if port_number(p) == "NODEINFO_APP"] + case.update(before=before, after=after, traffic_delta=delta(after["traffic"], before["traffic"]), + delivery_delta=delta(after["delivery"], before["delivery"]), + packets=[packet_summary(p) for p in packets], decoded_text_events=len(texts), + decoded_nodeinfo_events=len(infos)) + report.save() + check(not any(port_number(p) == "POSITION_APP" for p in packets), + "DUT emitted a position packet after first chat") + check(len(infos) == 1, "automatic NodeInfo was not observed exactly once") + check(packets.index(text) < packets.index(info), "automatic NodeInfo arrived before user text") + report.finish_case(case) + + if "exhaustion" in selected: + case = report.case("silent-peer-exhaustion") + case.update(evidence_from="first-use", **{name: report.data["cases"][-2][name] + for name in ("traffic_delta", "delivery_delta", "decoded_text_events")}) + report.save() + check(case["delivery_delta"]["retry"] == 2 + and case["delivery_delta"]["exhausted"] == 1 + and case["delivery_delta"]["heard"] == 0, + "silent peer did not cause exactly two retries and one exhaustion") + check(case["traffic_delta"]["tx"] == 4 and case["traffic_delta"]["fail"] == 0, + "expected completed RF TX: original chat + two retries + one NodeInfo") + check(case["decoded_text_events"] == 1, + "stock delivered duplicate user text for the same retried packet ID") + report.finish_case(case) + + if "once" in selected: + case = report.case("second-chat-no-new-nodeinfo") + token = f"MT-delivery {run_id} second" + before, since = snapshot(companion), time.monotonic() + companion.send_text(args.channel_slot, token) + text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload") + == token.encode(), since, args.timeout) + after = wait_idle(companion, args.timeout) + packets = stock.packets_since(since, node_id) + case.update(received=text is not None, before=before, after=after, + packets=[packet_summary(p) for p in packets]) + report.save() + check(text is not None, "stock did not receive second chat") + check(not any(port_number(p) in ("NODEINFO_APP", "POSITION_APP") for p in packets), + "second chat emitted extra NodeInfo or position") + check(after["traffic"]["tx"] - before["traffic"]["tx"] == 3, + "second silent-peer chat did not complete precisely three text TXs") + report.finish_case(case) + + if "echo" in selected: + case = report.case("heard-repeat-cancellation") + case["stock"] = stock.configure_lab(key, "CLIENT") + stock_role = "CLIENT" + # At SF5 the DUT's first two attempts can finish before a stock + # CLIENT's relay backoff (~2 s). Leave a genuine observation window. + companion.cli("set flood.retry.count 15") + case["retry_budget"] = 15 + case["attempts"] = [] + report.save() + passed = False + for attempt in range(args.echo_attempts): + token = f"MT-delivery {run_id} echo {attempt}" + before, since = snapshot(companion), time.monotonic() + companion.send_text(args.channel_slot, token) + text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload") + == token.encode(), since, args.timeout) + after = wait_idle(companion, args.timeout) + delivery = delta(after["delivery"], before["delivery"]) + observation = {"received_by_stock": text is not None, "before": before, "after": after, + "delivery_delta": delivery, + "traffic_delta": delta(after["traffic"], before["traffic"]), + "packets": [packet_summary(p) for p in stock.packets_since(since, node_id)]} + case["attempts"].append(observation) + report.save() + if text is not None and delivery["heard"] >= 1 and delivery["exhausted"] == 0 \ + and delivery["retry"] < 15: + passed = True + break + check(passed, "no progressed stock relay echo cancelled a retry before exhaustion; RF/scanning needs investigation") + case["proof"] = "DUT heard counter advanced on a matching progressed frame; pending=0 before budget exhaustion" + report.finish_case(case) + + if "inbound" in selected: + case = report.case("stock-to-companion") + token = f"MT-delivery {run_id} inbound" + sent = stock.send_text(token, 7) + received = companion.wait_text(args.channel_slot, stock.node, token, args.timeout) + case.update(packet_id=sent.id, **received) + report.save() + check(received["received"], "Companion did not decode the stock private-channel chat") + report.finish_case(case) + + if "hops-zero" in selected: + case = report.case("zero-hops-no-useless-retries") + companion.cli("set mt.hops 0") + companion.cli("set flood.retry.count 2") + before, since = snapshot(companion), time.monotonic() + token = f"MT-delivery {run_id} zero" + companion.send_text(args.channel_slot, token) + text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload") + == token.encode(), since, args.timeout) + after = wait_idle(companion, args.timeout) + time.sleep(1) + final = snapshot(companion) + case.update(received=text is not None, before=before, after=after, final=final, + delivery_delta=delta(final["delivery"], before["delivery"]), + traffic_delta=delta(final["traffic"], before["traffic"])) + report.save() + check(text is not None, "stock did not receive zero-hop text") + check(case["traffic_delta"]["tx"] == 1 and case["delivery_delta"]["retry"] == 0, + "zero-hop text caused a useless retransmission or identity announcement") + report.finish_case(case) + + if selected & {"same-tuple", "rename"}: + # Previous all-case hops-zero tests intentionally leave hops=0. + # Restore the baseline before observing either new configuration. + wait_idle(companion, args.timeout) + companion.cli("set mt.hops 7") + companion.cli("set flood.retry.count 0") + companion.cli("set flood.retry.advert off") + + if "same-tuple" in selected: + case = report.case("same-tuple-publication-recovery") + before, since = snapshot(companion), time.monotonic() + companion.cli("set radio2 909.5,500,5,5,rxtx,32") + # Do not repair/reapply any setting while the publication lease + # expires: only read-only status polls may cause this test to pass. + case["publication"] = wait_active(companion, args.timeout) + after = snapshot(companion) + case.update(before=before, after=after, + traffic_delta=delta(after["traffic"], before["traffic"]), + primary_after=companion.cli("get radio"), + packets=[packet_summary(p) for p in stock.packets_since(since, node_id)], + public_safety="primary unchanged; no Public transmission requested") + report.save() + check(case["traffic_delta"]["tx"] == 0, "same-tuple configuration caused unsolicited Meshtext TX") + check(case["primary_after"] == data["primary"], "same-tuple radio2 update changed primary radio") + check(not case["packets"], "stock observed unsolicited DUT traffic after same-tuple update") + report.finish_case(case) + + if "rename" in selected: + case = report.case("rename-rearms-first-chat-nodeinfo") + original_name = data["companion"].get("primary", {}).get("node_name") + check(isinstance(original_name, str) and original_name + and not any(character in original_name for character in "\r\n\0"), + "APP_START did not provide a safely restorable original node name") + if stock_role != "CLIENT_MUTE": + case["stock"] = stock.configure_lab(key, "CLIENT_MUTE") + stock_role = "CLIENT_MUTE" + require_stock_connected(stock) + new_name = "MT-HIL-" + run_id + check(len(new_name.encode()) < 24 and new_name != original_name, "invalid unique rename test name") + case.update(original_name=original_name, test_name=new_name, original_name_restored=False) + renamed = False + try: + before = snapshot(companion) + companion.cli("set name " + new_name) + renamed = True + time.sleep(0.4) + case["after_rename_quiet"] = snapshot(companion) + check(case["after_rename_quiet"]["traffic"]["tx"] == before["traffic"]["tx"], + "renaming the node transmitted Meshtext before user chat") + since = time.monotonic() + tokens = [] + case["probes"] = [] + case["probe_timeout_seconds"] = min(5, args.timeout) + # A zero-retry first chat can be lost over RF even though it + # correctly triggered the one identity TX. Use distinct new + # chats, retain all misses, and observe NodeInfo from before + # the first send rather than demanding it after a received one. + for probe in range(3): + require_stock_connected(stock) + token = f"MT-delivery {run_id} renamed {probe}" + tokens.append(token) + companion.send_text(args.channel_slot, token) + text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload") + == token.encode(), since, min(5, args.timeout)) + info = stock.wait_packet(node_id, lambda p: port_number(p) == "NODEINFO_APP", + since, min(5, args.timeout)) + case["probes"].append({"index": probe, "received": text is not None, + "received_in_probe_window": text is not None, + "packet_id": text.get("id") if text else None}) + report.save() + if text is not None and info is not None: + break + after = wait_idle(companion, args.timeout) + require_stock_connected(stock) + packets = stock.packets_since(since, node_id) + for token, probe in zip(tokens, case["probes"]): + text = next((packet for packet in packets + if packet.get("decoded", {}).get("payload") == token.encode()), None) + probe["received"] = text is not None + probe["packet_id"] = text.get("id") if text else None + infos = [packet for packet in packets if port_number(packet) == "NODEINFO_APP"] + case.update(before=before, after=after, + attempted_texts=len(tokens), + received_texts=sum(probe["received"] for probe in case["probes"]), + decoded_nodeinfo_events=len(infos), + traffic_delta=delta(after["traffic"], before["traffic"]), + packets=[packet_summary(p) for p in packets]) + report.save() + check(case["received_texts"] >= 1, "stock did not receive any of three distinct chats after rename") + check(len(infos) == 1, "rename did not produce exactly one observed automatic NodeInfo") + case["nodeinfo"] = inspect_nodeinfo(infos[0], node_id) + report.save() + check(case["nodeinfo"]["long_name"] == new_name, "automatic NodeInfo retained the old long name") + check(case["traffic_delta"]["tx"] == len(tokens) + 1 and case["traffic_delta"]["fail"] == 0, + "renamed chat probes did not complete precisely attempted texts + one NodeInfo TX") + check(not any(port_number(p) == "POSITION_APP" for p in packets), + "renamed first chat emitted position data") + finally: + if renamed: + before_restore = snapshot(companion) + companion.cli("set name " + original_name) + time.sleep(0.4) + after_restore = snapshot(companion) + restored_name = companion.cli("get name") + check(restored_name in (original_name, "> " + original_name), "original node name was not restored") + case["original_name_restored"] = True + case["restore_traffic_delta"] = delta(after_restore["traffic"], before_restore["traffic"]) + report.save() + check(case["restore_traffic_delta"]["tx"] == 0, + "restoring the original name caused unsolicited Meshtext TX") + report.finish_case(case) + data["final"] = snapshot(companion) + data["result"] = "PASS" + except (Exception, KeyboardInterrupt) as error: + message = str(error) + for secret in (key.hex(), str(key), repr(key), base64.b64encode(key).decode()): + message = message.replace(secret, "") + data["result"] = "FAIL" + data["failure"] = {"type": type(error).__name__, "message": message[:1500]} + if data["cases"] and data["cases"][-1]["result"] == "RUNNING": + data["cases"][-1]["result"] = "FAIL" + finally: + for name, device in (("stock", stock), ("companion", companion)): + if device is not None: + try: + device.close() + except Exception as error: + data.setdefault("close_failures", []).append({"device": name, "type": type(error).__name__}) + data["result"] = "FAIL" + data["finished_utc"] = datetime.now(timezone.utc).isoformat() + report.save() + report.stream.close() + return data + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--companion-port") + parser.add_argument("--stock-port") + parser.add_argument("--report", type=Path) + parser.add_argument("--channel-slot", type=int, default=1) + parser.add_argument("--key-bits", type=int, choices=(128, 256), default=128) + parser.add_argument("--case", default="all", help="all or comma-separated: " + ",".join(CASES)) + parser.add_argument("--timeout", type=float, default=25, help="each bounded observation, at most 30 s") + parser.add_argument("--quiet-seconds", type=float, default=5) + parser.add_argument("--echo-attempts", type=int, default=3) + parser.add_argument("--allow-radio-changes", action="store_true") + parser.add_argument("--plan", action="store_true") + args = parser.parse_args() + try: + check(args.case == "all" or set(args.case.split(",")) <= set(CASES), "unknown case") + check(0 <= args.channel_slot <= 255, "channel-slot must be 0..255") + check(0 < args.timeout <= 30 and 0 < args.quiet_seconds <= 30, + "each wait must be positive and at most 30 s") + check(1 <= args.echo_attempts <= 5, "echo-attempts must be 1..5") + if args.plan: + print(json.dumps({"rf": RF, "cases": CASES, "key_bits": args.key_bits, + "consent_required": "--allow-radio-changes", + "primary_precondition": "DUT primary already 909.5 MHz; use lab TX-frequency guard", + "silent_role": "CLIENT_MUTE", "echo_role": "CLIENT", + "echo_retry_budget": 15, "settings_restored": False}, indent=2)) + return 0 + check(args.allow_radio_changes, "--allow-radio-changes is required before opening ports") + check(args.companion_port and args.stock_port and args.report, + "--companion-port, --stock-port and --report are required") + check(os.path.realpath(args.companion_port) != os.path.realpath(args.stock_port), "ports must be distinct") + check(not args.report.exists() and args.report.parent.is_dir(), + "choose a fresh report in an existing directory") + result = run(args) + print(json.dumps({"result": result["result"], "report": str(args.report), + "failure": result.get("failure"), "settings_restored": False}, indent=2)) + return 0 if result["result"] == "PASS" else 1 + except Failure as error: + parser.error(str(error)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hil/meshtext_speed.py b/tools/hil/meshtext_speed.py new file mode 100644 index 000000000..0ec517f89 --- /dev/null +++ b/tools/hil/meshtext_speed.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""Guarded custom-PHY Meshtext goodput/loss benchmark, never a preset sweep. + +Changes both selected radios and transmits ONLY on 909.5 MHz. The Companion +must already have the immutable lab TX lock and USA primary RX configuration. +No flashing, SSH, USB reset, primary change, or settings restoration is done. +Use --plan without hardware dependencies. Keys exist in RAM only, not reports. +Stock queue depletion is serial/device admission evidence, not proof of RF TX. +""" +from __future__ import annotations + +import argparse +from collections.abc import Mapping +from datetime import datetime, timezone +from decimal import Decimal +import json +import os +from pathlib import Path +import re +import secrets +import time + +import meshtext_delivery as delivery + +GUARD = "HIL TX lock=909.500 MHz; off-frequency TX blocked; primary RX-only when off-frequency" +PRIMARY = (Decimal("910.525"), Decimal("62.5"), 7, 5) +SFS = (7, 9, 10) +SIZES = (32, 160) + + +def check(condition, message): + delivery.check(condition, message) + + +def radio_tuple(reply): + match = re.fullmatch(r">?\s*(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+),(\d+)(?:[,; ].*)?", reply.strip()) + check(match, "missing or ambiguous primary radio tuple") + return Decimal(match[1]), Decimal(match[2]), int(match[3]), int(match[4]) + + +def primary_tx(reply): + matches = re.findall(r"\bTX=(\d+),(\d+)(?=\s|;|$)", reply) + check(len(matches) == 1, "missing or ambiguous per-profile TX counters") + return int(matches[0][0]) + + +def safety(companion): + guard = companion.cli("get lab.tx") + check(guard.removeprefix(">").strip() == GUARD, "exact immutable 909.500 MHz TX lock is required") + primary = companion.cli("get radio") + actual = radio_tuple(primary) + check(abs(actual[0] - PRIMARY[0]) <= Decimal("0.00005") and actual[1:] == PRIMARY[1:], + "preconfigure and reboot the Companion with USA primary 910.525,62.5,7,5") + status = companion.cli("get radio2.status") + check(primary_tx(status) == 0, "primary TX counter must remain zero; aborting benchmark") + return {"guard": guard, "primary": primary, "profile_status": status, "primary_tx": 0} + + +def nominal_kbps(sf): + return sf * 4 * 500 / (5 * (2 ** sf)) + + +def check_stock_rf(stock, sf=None, key=None): + delivery.require_stock_connected(stock) + config = stock.iface.localNode.localConfig.lora + check(not config.use_preset and abs(config.override_frequency - 909.5) < 0.00005 + and config.bandwidth == 500 and config.coding_rate == 5 + and abs(config.frequency_offset) < 0.00005, "stock must use custom 909.5 MHz / 500 kHz / CR5") + check(config.spread_factor in (5, *SFS) and (sf is None or config.spread_factor == sf), + "stock spreading factor mismatch") + if key is not None: + channel = stock.iface.localNode.channels[0] + check(channel.role == 1 and channel.settings.name == "labtest" + and channel.settings.psk == key, "stock private lab channel readback mismatch") + check(config.hop_limit == 0 and config.tx_power == 2 and config.tx_enabled, + "stock must have hops=0, TX=2 dBm, and TX enabled") + + +def configure_stock_sf(stock, key, sf): + """Change only the fixed lab PHY, then wait for its deferred reboot.""" + check_stock_rf(stock) + node = stock.iface.localNode + node.beginSettingsTransaction() + time.sleep(0.75) + config = node.localConfig.lora + config.use_preset = False + config.bandwidth, config.spread_factor, config.coding_rate = 500, sf, 5 + config.override_frequency, config.frequency_offset, config.channel_num = 909.5, 0, 0 + config.hop_limit, config.tx_power, config.tx_enabled = 0, 2, True + node.writeConfig("lora") + time.sleep(0.75) + node.commitSettingsTransaction() + time.sleep(20) + stock.iface.close() + stock.iface = None + + def validate(observed): + cfg, channel = observed.localConfig.lora, observed.channels[0] + checks = {"custom_phy": not cfg.use_preset and cfg.bandwidth == 500 + and cfg.spread_factor == sf and cfg.coding_rate == 5, + "frequency": abs(cfg.override_frequency - 909.5) < 0.00005 + and abs(cfg.frequency_offset) < 0.00005 and cfg.channel_num == 0, + "tx": cfg.hop_limit == 0 and cfg.tx_power == 2 and cfg.tx_enabled, + "private_channel": channel.role == 1 and channel.settings.name == "labtest" + and channel.settings.psk == key, + "muted_role": observed.localConfig.device.role == 1} + return [name for name, matched in checks.items() if not matched] + + result = delivery.reconnect_stock_readback(stock, validate) + check_stock_rf(stock, sf, key) + return result + + +def check_companion_rf(companion, sf): + from meshtext_auto_radio2 import parse_profile + actual = parse_profile(companion.cli("get radio2")) + check(abs(actual[0] - 909.5) < 0.00005 and actual[1:5] == (500, sf, 5, "rxtx"), + "Companion radio2 does not match the requested custom lab PHY") + status = companion.cli("get radio2.status") + check(re.search(r"\bpreamble=\d+,16(?:\s|;|$)", status), "radio2 must use the fixed 16-symbol MT preamble") + check(companion.cli("get mt.hops").removeprefix(">").strip() == "0", "Companion must use hops=0") + check(delivery.snapshot(companion)["delivery"]["count"] == 0, "Companion retries must be disabled") + return {"saved_radio2": actual, "status": status} + + +def queue_snapshot(iface, packet_id=None): + """Observe the SDK's documented queue without modifying it or resending. + + SDK reference: github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py + QueueStatus/queue describe serial admission, not a physical TX-done event. + """ + queue = getattr(iface, "queue", None) + status = getattr(iface, "queueStatus", None) + supported = isinstance(queue, Mapping) + state = {"supported": supported, "packet_pending": None, "pending_entries": None} + if supported: + # Capture primitive items; the SDK reader can change the dictionary. + try: + items = list(queue.items()) + except RuntimeError: + return state + state["pending_entries"] = sum(bool(value) for _, value in items) + if packet_id is not None: + state["packet_pending"] = any(identity == packet_id and bool(value) for identity, value in items) + if status is not None: + state.update(free=int(status.free), maxlen=int(status.maxlen), + result=int(status.res), last_id=int(status.mesh_packet_id)) + return state + + +def wait_stock_queue(stock, timeout, packet_id=None): + deadline = time.monotonic() + timeout + latest = {} + observed_packet = False + while time.monotonic() < deadline: + delivery.require_stock_connected(stock) + latest = queue_snapshot(stock.iface, packet_id) + check(latest["supported"], "stock SDK exposes no observable packet queue; TX completion uncertain") + if packet_id is not None and latest.get("last_id") == packet_id: + check(not latest.get("result"), "stock rejected the queued packet; no additional TX requested") + observed_packet = True + if packet_id is not None and latest["packet_pending"] is True: + observed_packet = True + if latest["pending_entries"] == 0 and (packet_id is None or latest["packet_pending"] is False) \ + and latest.get("free", 0) > 0 and (packet_id is None or observed_packet): + return {**latest, "packet_id_depleted": packet_id is not None, + "packet_id_observed": observed_packet, + "meaning": "SDK/device admission only; RF completion unconfirmed without peer receipt"} + time.sleep(0.02) + raise delivery.Failure("stock packet queue did not drain before deadline; TX completion uncertain") + + +def wait_companion_text(companion, slot, sender, token, timeout): + from meshtext_auto_radio2 import decode_channel_frame + prefix = f"!{sender:08x}: ".encode() + payload = token.encode("ascii") + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + frame = companion.request(b"\x0a", (7, 8, 10, 16, 17), + timeout=min(5, max(0.1, deadline - time.monotonic()))) + decoded = decode_channel_frame(frame) + if decoded and decoded["channel"] == slot and decoded["text"].startswith(prefix): + observed = decoded["text"][len(prefix):] + # A V3 event can truncate 160 wire bytes to its bounded app envelope. + # Match a unique leading token, never claim the discarded tail arrived. + if len(observed) >= 24 and observed == payload[:len(observed)]: + return {"received": True, "application_bytes": len(observed), + "truncated": len(observed) < len(payload)} + time.sleep(0.02) + return {"received": False, "application_bytes": 0, "truncated": False} + + +def token_for(run_id, sf, direction, index, size): + prefix = f"MS{run_id}:{sf}:{size}:{direction[0]}:{index:02d}:" + check(len(prefix) <= size, "message size cannot contain the unique benchmark token") + return prefix + "x" * (size - len(prefix)) + + +def summary(rows, elapsed): + received = [row for row in rows if row.get("received")] + count = len(rows) + delivered = sum(row["application_bytes"] for row in received) + return {"attempted": count, "received": len(received), "lost": count - len(received), + "loss_fraction": (count - len(received)) / count if count else None, + "wire_payload_bytes_requested": sum(row["wire_bytes"] for row in rows), + "application_bytes_delivered": delivered, "elapsed_seconds": round(elapsed, 6), + "measured_application_goodput_bps": delivered * 8 / elapsed if elapsed else 0, + "truncated_messages": sum(row.get("truncated", False) for row in received), + "definition": "sequential host-observed goodput including receipt waits and pacing; not maximum PHY throughput"} + + +def benchmark(companion, stock, *, sf, size, direction, count, timeout, gap, slot, run_id, node_id, case, persist): + rows = case["packets"] + started = time.monotonic() + for index in range(count): + safety(companion) + check_companion_rf(companion, sf) + check_stock_rf(stock, sf) + token = token_for(run_id, sf, direction, index, size) + row = {"index": index, "wire_bytes": size, "received": False, + "application_bytes": 0, "truncated": False, "tx_completion": "uncertain"} + rows.append(row) + before = time.monotonic() + if direction == "companion-to-stock": + original = delivery.snapshot(companion) + companion.send_text(slot, token) + packet = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload") == token.encode(), + before, timeout) + completed = delivery.wait_idle(companion, timeout) + row.update(received=packet is not None, application_bytes=size if packet else 0, + packet_id=packet.get("id") if packet else None, + tx_completion="DUT RF completion counter" if completed["traffic"]["tx"] > original["traffic"]["tx"] else "uncertain") + check(row["tx_completion"] != "uncertain", "DUT TX completion uncertain; stopping benchmark") + else: + row["queue_before"] = wait_stock_queue(stock, timeout) + packet = stock.send_text(token, 0) + row["packet_id"] = int(packet.id) + persist() + row["queue_after"] = wait_stock_queue(stock, timeout, packet.id) + row.update(wait_companion_text(companion, slot, stock.node, token, timeout)) + row["tx_completion"] = "peer receipt" if row["received"] else "uncertain: SDK queue drained, no RF receipt" + row["elapsed_seconds"] = round(time.monotonic() - before, 6) + persist() + time.sleep(gap) + case["measurement"] = summary(rows, time.monotonic() - started) + + +def run(args): + key = secrets.token_bytes(args.key_bits // 8) + data = {"schema": 1, "started_utc": datetime.now(timezone.utc).isoformat(), "result": "RUNNING", + "run_id": secrets.token_hex(4), "key_bits": args.key_bits, "settings_restored": False, + "phy": {"frequency_mhz": 909.5, "bandwidth_khz": 500, "cr": 5, "preamble_symbols": 16}, + "count_per_size_direction": args.count, "gap_seconds": args.gap, "cases": [], + "limitations": ["Stock decoded events are not raw RF capture or independent TX-done evidence.", + "Nominal LoRa bitrate and measured application goodput are different quantities.", + "Inbound 160-byte wire messages may be truncated by the Companion app envelope.", + "Missed stock sends without RF receipt remain TX-completion-uncertain, even after queue depletion."]} + report = delivery.Report(args.report, data) + companion = stock = None + try: + Companion, Stock = delivery.dependencies() + delivery.require_idle_port(args.companion_port) + delivery.require_idle_port(args.stock_port) + companion = Companion(args.companion_port) + data["companion"] = companion.start() + data["initial_safety"] = safety(companion) + check(args.channel_slot < companion.max_channels, "Companion channel slot out of range") + for index in range(companion.max_channels): + if index != args.channel_slot: + check(not companion.channel_name(index).lstrip("#").startswith(("mt-", "r2-")), + "remove conflicting radio2/MT channels explicitly before this benchmark") + stock = Stock(args.stock_port) + check_stock_rf(stock) # Refuse an initially preset/USA stock radio before any writes. + data["stock_suppression"] = stock.configure_lab(key, "CLIENT_MUTE") + node_id = companion.mt_node() + check(node_id != stock.node, "radio sender IDs collide") + for sf in args.sf: + data["current_sf"] = sf + safety(companion) + delivery.wait_idle(companion, args.timeout) + if companion.channel_name(args.channel_slot).lstrip("#").startswith("mt-"): + companion.cli(f"delete mt.channel {args.channel_slot}") + for command in ("set tempradio2 off", "set mt.region us", "set mt.hops 0", "set tx 2", + "set radio2.cross off", "set flood.retry.count 0", "set flood.retry.advert off", + f"set radio2 909.5,500,{sf},5,rxtx,32"): + companion.cli(command) + data.setdefault("stock_profiles", {})[str(sf)] = configure_stock_sf(stock, key, sf) + companion.cli(f"set mt.channel {args.channel_slot} mt-labtest {key.hex()}") + delivery.wait_active(companion, args.timeout) + data.setdefault("companion_profiles", {})[str(sf)] = check_companion_rf(companion, sf) + safety(companion) + companion.send_text(args.channel_slot, f"MS{data['run_id']}:warmup:{sf}") + time.sleep(0.4) + delivery.wait_idle(companion, args.timeout) + time.sleep(0.3) + for size in SIZES: + for direction in ("companion-to-stock", "stock-to-companion"): + case = report.case(f"SF{sf}-{size}-{direction}") + case.update(sf=sf, wire_bytes=size, direction=direction, + nominal_lora_kbps=nominal_kbps(sf), packets=[]) + benchmark(companion, stock, sf=sf, size=size, direction=direction, count=args.count, + timeout=args.timeout, gap=args.gap, slot=args.channel_slot, + run_id=data["run_id"], node_id=node_id, case=case, persist=report.save) + case["safety_after"] = safety(companion) + case["result"] = "MEASURED" # Packet loss is measured, not relabelled as a pass. + report.save() + data["final_safety"] = safety(companion) + data["result"] = "MEASURED" + except (Exception, KeyboardInterrupt) as error: + message = str(error) + import base64 + for secret in (key.hex(), str(key), repr(key), base64.b64encode(key).decode()): + message = message.replace(secret, "") + data["result"] = "FAIL" + data["failure"] = {"type": type(error).__name__, "message": message[:1000]} + finally: + for device in (stock, companion): + if device is not None: + try: + device.close() + except Exception as error: + data.setdefault("close_errors", []).append(type(error).__name__) + data["result"] = "FAIL" + data["finished_utc"] = datetime.now(timezone.utc).isoformat() + report.save() + report.stream.close() + return data + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--companion-port") + parser.add_argument("--stock-port") + parser.add_argument("--report", type=Path) + parser.add_argument("--channel-slot", type=int, default=1) + parser.add_argument("--key-bits", type=int, choices=(128, 256), default=128) + parser.add_argument("--sf", type=int, choices=SFS, nargs="+", default=list(SFS)) + parser.add_argument("--count", type=int, default=5) + parser.add_argument("--timeout", type=float, default=3) + parser.add_argument("--gap", type=float, default=0.25) + parser.add_argument("--allow-radio-changes", action="store_true") + parser.add_argument("--plan", action="store_true") + args = parser.parse_args(argv) + try: + check(0 <= args.channel_slot <= 255 and 1 <= args.count <= 20, "invalid slot/count (count 1..20)") + check(0 < args.timeout <= 30 and 0.25 <= args.gap <= 5, "timeout 0..30 s; pacing gap 0.25..5 s") + check(len(set(args.sf)) == len(args.sf), "duplicate SF selection") + if args.plan: + print(json.dumps({"frequency_mhz": 909.5, "bandwidth_khz": 500, "cr": 5, + "spreading_factors": args.sf, "preamble_symbols": 16, + "sizes": SIZES, "count_per_direction_size": args.count, + "primary_required": "910.525,62.5,7,5; TX counter zero; immutable 909.500 MHz HIL guard", + "hops": 0, "retries": 0, "key_bits": args.key_bits, + "consent_required": "--allow-radio-changes", "settings_restored": False}, indent=2)) + return 0 + check(args.allow_radio_changes, "--allow-radio-changes is required before opening ports") + check(args.companion_port and args.stock_port and args.report, "ports and a fresh report are required") + check(os.path.realpath(args.companion_port) != os.path.realpath(args.stock_port), "ports must be distinct") + check(not args.report.exists() and args.report.parent.is_dir(), "use a new report in an existing directory") + result = run(args) + print(json.dumps({"result": result["result"], "report": str(args.report), + "failure": result.get("failure"), "settings_restored": False}, indent=2)) + return 0 if result["result"] == "MEASURED" else 1 + except delivery.Failure as error: + parser.error(str(error)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hil/mota_espnow_chain.py b/tools/hil/mota_espnow_chain.py new file mode 100644 index 000000000..eb732a835 --- /dev/null +++ b/tools/hil/mota_espnow_chain.py @@ -0,0 +1,437 @@ +#!/usr/bin/env python3 +"""Guarded four-node mOTA LoRa -> ESP-NOW -> LoRa transfer-only HIL control. + +Controls only three explicit USB ASCII devices: bridge A909.5, bridge B908.5, +and the908.5 receiver. The separately owned909.5 USB seeder is never opened. +Requires already active500/SF5/CR5/preamble128 primary TempRadio, radio2off, +and immutable matching-frequency TX guards. May TRANSMIT and changes OTA +policy plus bridge BESP-NOW; clears ONLY the receiver's staged OTA slot. +Never flashes, reboots, changes RF tuples, enables automatic fetching/install, +or installs firmware. BESP-NOW is restored on exit. --plan opens no ports. +""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import json +import math +import os +from pathlib import Path +import re +import time + + +class Failure(RuntimeError): + pass + + +def require(condition, message): + if not condition: + raise Failure(message) + + +def diagnostic_record(text, raw="", status="reply"): + allowed = re.fullmatch(r"(?:get (?:lab\.tx|tempradio2?|radio2|repeat|espnow\.(?:enabled|running))" + r"|set espnow\.enabled (?:on|off)|ota (?:self|stats|ls|cancel|config|dev verify)" + r"|ota config (?:autofetch off|autoinstall off|hops 3)|ota pull [\da-fA-F]{8} flash)", text) + if not allowed: + return {"command": "unrecognized command; diagnostic omitted", "status": status} + # No secret-reading commands are permitted in this controller. Still scrub + # potential unrelated long keys if unsolicited logging enters the capture. + safe = re.sub(r"(?", raw) + safe = re.sub(r"(?im)\b(?:password|pwd|psk|private.?key)\s*[:=].*", "", safe) + if len(safe) > 1536: + safe = safe[:768] + "\n\n" + safe[-768:] + return {"command": text, "status": status, "reply_characters": len(raw), + "arrow_markers": len(re.findall(r"(?:^|\n)[ \t]*->[ \t]*", raw)), "reply": safe} + + +def failure_history(nodes): + return {name: [diagnostic_record(item.get("command", ""), item.get("reply", ""), item.get("status", "reply")) + for item in getattr(node, "commands", [])[-3:]] for name, node in nodes.items()} + + +def reply(raw): + # The classic reader supplies a fresh reply-marker/completed-line boundary. + # Preserve ALL reply lines, not merely a160-byte first-line approximation. + matches = list(re.finditer(r"(?:^|\n)[ \t]*->[ \t]*", raw)) + require(len(matches) == 1, "missing or ambiguous ASCII reply marker") + text = raw[matches[0].end():].replace("\r", "") + text = re.sub(r"\n>[ \t]*\Z", "", text).strip() + require(text and len(text) <= 65536, "empty or overlong completed ASCII reply") + return re.sub(r"^>[ \t]*", "", text) + + +def command(node, text, *, allow_error=False): + value = reply(node.command(text, timeout=15)) + require(allow_error or not re.match(r"(?:ERR|Error|Err)\b", value), + "command rejected: " + " ".join(text.split()[:2])) + return value + + +def read_fresh_reply(port, timeout): + deadline, data = time.monotonic() + timeout, bytearray() + last_data, first_complete = None, None + while time.monotonic() < deadline: + chunk = port.read(min(port.in_waiting or 1, 4096)) + now = time.monotonic() + if chunk: + data.extend(chunk) + last_data = now + require(len(data) <= 65536, "ASCII capture exceeded bounded limit") + text = data.decode("utf-8", "replace") + marker = re.search(r"(?:^|\n)[ \t]*->[ \t]*", text) + if marker: + body = text[marker.end():] + if re.search(r"(?:^|\n)>[ \t]+\Z", body): + return text + if "\n" in body: + if first_complete is None: + first_complete = now + # Classic repeaters print no terminal prompt. A completed + # reply line plus150ms quiet captures bounded multiline output. + if not chunk and now - last_data >= .15: + return text + require(now - first_complete <= 1, "ASCII reply did not become quiet within1second") + error = Failure("fresh ASCII reply marker/completed line not received") + error.partial_reply = data.decode("utf-8", "replace") + raise error + + +def serial_node_factory(): + from ethernet_primary_probe import SerialNode + + class ChainSerialNode(SerialNode): + def __init__(self, path): + import serial + from serial.tools import list_ports + from serial_session import assert_port_idle, open_configured_session + matches = [item for item in list_ports.comports() + if os.path.realpath(item.device) == os.path.realpath(path)] + require(len(matches) == 1, "selected classic USB identity absent or ambiguous") + info = matches[0] + self.identity = {"port": info.device, "usb_serial": info.serial_number, + "vid": info.vid, "pid": info.pid, "location": info.location} + self.commands = [] + self.port = serial.Serial(port=None, baudrate=115200, timeout=.04, + write_timeout=2, exclusive=True) + self.port.port, self.port.rts = info.device, False + self.port.dtr = info.vid in (0x303A, 0x2886, 0x239A) + assert_port_idle(info.device) + try: + open_configured_session(self.port) + # These are classic repeater consoles, not a Companion binary + # transport. Never put TERM tokens in their command buffer. + require(self.port.write(b"\r") == 1, "short classic CLI boundary write") + self.port.flush() + self.hello = self.read(.5, quiet=.15) + except BaseException: + self.port.close() + raise + + def command(self, text, timeout=15): + require(text and not any(value in text for value in "\r\n\0"), "one ASCII command required") + # Full command echo can be truncated on the3401 UART. Drain old + # output, then allow only one outstanding request and require a + # fresh real reply marker plus completed line, not its echo. + self.read(.3, quiet=.15) + # Classic repeater main ignores LF; only CR executes the command. + encoded = (text + "\r").encode("ascii") + require(self.port.write(encoded) == len(encoded), "short ASCII command write") + self.port.flush() + try: + value = read_fresh_reply(self.port, timeout) + except Exception as error: + self.commands.append({"command": text, "reply": getattr(error, "partial_reply", ""), + "status": type(error).__name__}) + raise + self.commands.append({"command": text, "reply": value, "status": "reply"}) + return value + + def close(self): + # No Companion TERM-STOP token on the classic line editor either. + self.port.close() + + return ChainSerialNode + + +def identity(path, *, serial=None, location=None): + from serial.tools import list_ports + matches = [item for item in list_ports.comports() + if os.path.realpath(item.device) == os.path.realpath(path)] + require(len(matches) == 1, "selected USB path absent or ambiguous") + info = matches[0] + if serial: + require((info.serial_number or "").upper() == serial.upper(), "USB serial mismatch") + else: + require(location and info.location == location and info.vid == 0x1A86 and info.pid == 0x7523 + and not info.serial_number, "unserialed Indicator must match exact CH340 VID/PID/location") + return {"port": info.device, "serial": info.serial_number, "vid": info.vid, + "pid": info.pid, "location": info.location} + + +def safety(nodes): + report = {} + for name, frequency in (("a", 909.5), ("b", 908.5), ("target", 908.5)): + node = nodes[name] + guard = command(node, "get lab.tx") + expected = f"HIL TX lock={frequency:.3f} MHz; off-frequency TX blocked; primary RX-only when off-frequency" + require(guard == expected, name + ": immutable matching-frequency TX lock required") + live = command(node, "get tempradio") + match = re.fullmatch(r"([\d.]+),([\d.]+),(\d+),(\d+),(\d+)", live) + require(match is not None and tuple(float(value) for value in match.groups()) + == (frequency, 500, 5, 5, 128), name + ": primary TempRadio tuple/preamble mismatch") + secondary, temporary = command(node, "get radio2"), command(node, "get tempradio2") + require(re.fullmatch(r"off(?:;[^\r\n]*)?", secondary) is not None and temporary == "off", + name + ": radio2 must already be off") + require(command(node, "get repeat") == "on", name + ": repeater forwarding must be on") + report[name] = {"guard": guard, "live_primary": live, "radio2": secondary, + "tempradio2": temporary, "repeat": "on"} + if name != "target": + enabled, running = command(node, "get espnow.enabled"), command(node, "get espnow.running") + require(enabled == running == "on", name + ": ESP-NOW must already be on and running") + report[name].update(espnow_enabled=enabled, espnow_running=running) + return report + + +def configure_manual(nodes): + result = {} + for name, node in nodes.items(): + for setting in ("autofetch off", "autoinstall off", "hops 3"): + require(command(node, "ota config " + setting).startswith("OK"), "OTA policy was not acknowledged") + value = command(node, "ota config") + require(all(token in value for token in ("autofetch=off", "autoinstall=off", "hops=3")), + "manual-only three-hop OTA policy did not read back") + result[name] = value + return result + + +def progress(node, mid): + value = command(node, "ota stats") + if "fetch idle" in value: + return {"state": "idle", "have": 0, "total": 0, "mid": None, "reply": value} + match = re.search(r"\bfetch ([^|]*?) (\d+)/(\d+)(?: [^|]*?)?\bid=([\da-fA-F]{8})\b", value) + require(match is not None, "OTA stats lost complete fetch fields; refusing truncated/ambiguous progress") + require(match[4].lower() == mid.lower(), "receiver is fetching a different manifest ID") + have, total = int(match[2]), int(match[3]) + require(0 <= have <= total <= 65536, "invalid OTA block counters") + return {"state": match[1], "have": have, "total": total, "mid": match[4].lower(), "reply": value} + + +def body_identity(node): + value = command(node, "ota self") + match = re.search(r"\bself body=(\d+) image=(\d+) base_hash=([\da-fA-F]{16})\b", value) + require(match is not None, "running firmware identity unavailable") + return {"body_bytes": int(match[1]), "image_bytes": int(match[2]), "hash": match[3].lower()} + + +def bridge_b(node, enabled): + require(command(node, "set espnow.enabled " + ("on" if enabled else "off")) == "OK", + "bridge B toggle was not acknowledged") + desired = "on" if enabled else "off" + require(command(node, "get espnow.enabled") == desired + and command(node, "get espnow.running") == desired, "bridge B state did not read back") + + +def fresh_pull(target, mid): + require(command(target, "ota cancel").startswith("OK"), "could not clear receiver OTA staging slot") + require(progress(target, mid)["state"] == "idle", "OTA staging clear did not leave idle") + value = command(target, "ota pull " + mid + " flash") + require(value.startswith("OK pulling") and "mid=" + mid.lower() in value.lower(), + "fresh selected manifest pull was not acknowledged") + return value + + +def observe(target, mid, seconds, poll, record): + deadline = time.monotonic() + seconds + observations = [] + while True: + sample = progress(target, mid) + sample["at"] = time.monotonic() + observations.append(sample) + record(sample) + remaining = deadline - time.monotonic() + if remaining <= 0: + return observations + time.sleep(min(poll, remaining)) + + +def run_chain(nodes, mid, *, timeout, outage, poll, persist): + report = {"schema": 1, "result": "RUNNING", "manifest_id": mid, + "started_utc": datetime.now(timezone.utc).isoformat(), "events": [], + "no_install_command": True, "rf_capture_independent": False, + "proof_scope": "receiver block/hash verification with bridge-B off/on counterfactual; not independent RF capture", + "settings_restored": False, "bridge_b_restored": False} + restore = False + target, b = nodes["target"], nodes["b"] + + def event(kind, **values): + report["events"].append({"kind": kind, "at": time.monotonic(), **values}) + persist(report) + + try: + report["safety_before"] = safety(nodes) + report["running_before"] = {name: body_identity(node) for name, node in nodes.items()} + report["manual_policy"] = configure_manual(nodes) + persist(report) + # Discover with both bridges enabled, before breaking the required link. + deadline = time.monotonic() + min(timeout, 90) + while True: + catalog = command(target, "ota ls") + if re.search(r"(? 0 and sample["have"] == sample["total"], "done did not include all blocks") + break + if sample["state"].startswith("failed"): + require(not retried, "chain fetch failed again after one same-ID resume") + require(command(target, "ota pull " + mid + " flash").startswith("OK"), "same-ID resume was not acknowledged") + retried = True + event("same_id_resume_after_failure", previous_have=sample["have"]) + elif not mid_proven and 0 < sample["have"] < sample["total"]: + bridge_b(b, False) + event("midpoint_bridge_off", attempt=attempt, before=sample) + # Already transmitted frames may still finish. Ignore the + # first2s, then demand no new blocks for the remaining gap. + off = observe(target, mid, outage, poll, lambda current: event("midpoint_off_progress", **current)) + settled = [current for current in off if current["at"] >= off[0]["at"] + min(2, outage / 3)] + require(settled, "no settled midpoint outage observation") + if settled[-1]["state"] == "done": + event("midpoint_not_proven", reason="in-flight bytes finished before settled outage") + else: + require(settled[0]["have"] > 0 and settled[0]["have"] < settled[0]["total"], "midpoint did not retain a partial image") + require(all(current["have"] == settled[0]["have"] for current in settled), "new blocks arrived during settled bridge-off gap") + mid_proven = True + report["midpoint_off_stalled"] = {"retained_blocks": settled[0]["have"], "total": settled[0]["total"], + "settled_seconds": settled[-1]["at"] - settled[0]["at"]} + bridge_b(b, True) + event("midpoint_bridge_on") + time.sleep(min(poll, .05) if attempt == 2 else poll) + if mid_proven: + break + report["final_progress"] = progress(target, mid) + verified = command(target, "ota dev verify") + require(all(re.search(r"\b" + name + r"=1\b", verified) for name in ("parsed", "root", "payload", "ok")), + "staged container did not verify hashes/Merkle/integrity") + report["staged_verification"] = verified + report["running_after"] = {name: body_identity(node) for name, node in nodes.items()} + require(report["running_after"] == report["running_before"], "running firmware changed during transfer-only test") + report["safety_after"] = safety(nodes) + report["result"] = "PASS" if mid_proven else "TRANSFER_VERIFIED_MIDPOINT_NOT_PROVEN" + except (Exception, KeyboardInterrupt) as error: + report["result"] = "FAIL" + report["failure"] = {"type": type(error).__name__, "message": str(error)[:500]} + report["failure"]["recent_commands"] = failure_history(nodes) + finally: + if restore: + try: + bridge_b(b, True) + report["bridge_b_restored"] = True + except Exception as error: + report["restore_failure"] = type(error).__name__ + report["result"] = "FAIL" + report["finished_utc"] = datetime.now(timezone.utc).isoformat() + persist(report) + return report + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("a", "b", "target"): + parser.add_argument("--" + name + "-port") + parser.add_argument("--a-serial") + parser.add_argument("--b-location", help="exact already ROM-MAC-verified unserialed CH340 physical location") + parser.add_argument("--target-serial") + parser.add_argument("--mid") + parser.add_argument("--timeout", type=float, default=300, help="per-attempt transfer bound; max1800") + parser.add_argument("--outage", type=float, default=8) + parser.add_argument("--poll", type=float, default=.25) + parser.add_argument("--report", type=Path) + parser.add_argument("--allow-transfer-and-clear-stage", action="store_true") + parser.add_argument("--plan", action="store_true") + args = parser.parse_args(argv) + try: + require(all(math.isfinite(value) for value in (args.timeout, args.outage, args.poll)) + and 15 <= args.timeout <= 1800 and 6 <= args.outage <= 15 and .05 <= args.poll <= 2, "invalid bounded timing") + if args.plan: + print(json.dumps({"topology": "source909.5 -> bridgeA909.5 -> ESP-NOW -> bridgeB908.5 -> target908.5", + "source_port_opened": False, "rf": "500/SF5/CR5/preamble128", "outage_seconds": args.outage, + "auto_fetch_install": False, "install_commands": False, "clears_target_ota_stage": True}, indent=2)) + return 0 + require(args.allow_transfer_and_clear_stage, "explicit --allow-transfer-and-clear-stage consent required") + require(all((args.a_port, args.b_port, args.target_port, args.a_serial, args.b_location, + args.target_serial, args.mid, args.report)), "all USB identities, MID and fresh report required") + require(re.fullmatch(r"[\da-fA-F]{8}", args.mid) is not None, "MID must be exactly8hex digits") + require(len({os.path.realpath(path) for path in (args.a_port, args.b_port, args.target_port)}) == 3, + "select three distinct physical USB paths") + require(args.a_serial.upper() != args.target_serial.upper(), "bridge and receiver USB serials must differ") + require(not args.report.exists() and args.report.parent.is_dir(), "fresh report in existing directory required") + except Failure as error: + parser.error(str(error)) + nodes, usb = {}, {} + with args.report.open("x", encoding="utf-8") as stream: + try: + os.chmod(args.report, 0o600) + except OSError: + pass + + def persist(data): + data["usb"] = usb + stream.seek(0) + json.dump(data, stream, indent=2) + stream.write("\n") + stream.truncate() + stream.flush() + + try: + SerialNode = serial_node_factory() + specs = (("a", args.a_port, {"serial": args.a_serial}), ("b", args.b_port, {"location": args.b_location}), + ("target", args.target_port, {"serial": args.target_serial})) + for name, port, expected in specs: + usb[name] = identity(port, **expected) + for name, port, expected in specs: + nodes[name] = SerialNode(port) + identity(port, **expected) + result = run_chain(nodes, args.mid.lower(), timeout=args.timeout, outage=args.outage, poll=args.poll, persist=persist) + except (Exception, KeyboardInterrupt) as error: + result = {"result": "FAIL", "failure": {"type": type(error).__name__, "message": str(error)[:500]}} + persist(result) + finally: + for node in nodes.values(): + try: + node.close() + except Exception: + pass + print(json.dumps({"result": result["result"], "report": str(args.report), "failure": result.get("failure")})) + return 0 if result["result"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hil/mota_usb_seeder.py b/tools/hil/mota_usb_seeder.py new file mode 100644 index 000000000..13e4438de --- /dev/null +++ b/tools/hil/mota_usb_seeder.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Serve one verified mOTA over a guarded Full Companion's existing USB source. + +Enables a source which MAY TRANSMIT LoRa, with explicit consent. No flashing, +install, radio setters, network access, or new RF protocol. The source must +already be in live 909.5/500/SF5/CR5 primary TempRadio with an immutable HIL +909.5-MHz TX lock. Other nodes and install decisions remain operator-owned. +All container bytes and Zopfli1000 blocks are prepared in host RAM BEFORE USB +opens. Only the existing MS/ms COUNT/DESCRIBE/READ/DEFLATE operations are served. +Run --plan without hardware dependencies. Settings are not otherwise changed. +""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import io +import json +import math +import os +from pathlib import Path +import re +import struct +import sys +import time +from types import SimpleNamespace +import zlib + +ROOT = Path(__file__).resolve().parents[2] +GUARD = "HIL TX lock=909.500 MHz; off-frequency TX blocked; primary RX-only when off-frequency" +RF = [909.5, 500, 5, 5] +MAX_CONTAINER = 4 * 1024 * 1024 +LENGTHS = {1: 4, 2: 5, 3: 11, 9: 11} + + +class Failure(RuntimeError): + pass + + +def require(condition, message): + if not condition: + raise Failure(message) + + +def xor(data): + result = 0 + for byte in data: + result ^= byte + return result + + +class MemoryPath: + """Read-only path facade: existing Catalog READ never rereads mutable disk.""" + def __init__(self, blob): + self.blob = blob + + def stat(self): + return SimpleNamespace(st_size=len(self.blob)) + + def open(self, mode): + require(mode == "rb", "memory catalog is read-only") + return io.BytesIO(self.blob) + + +class CachedFile: + def __init__(self, blob, descriptor, compressor): + self.path, self.size, self.descriptor = MemoryPath(blob), len(blob), descriptor + self.blocks = {} + count, offset, length = struct.unpack_from(" ") == GUARD, "compiled909.5MHz HIL TX lock not confirmed") + live = node.cli("get tempradio") + match = re.fullmatch(r"\s*(?:>\s*)?TempRadio active: ([\d.]+),([\d.]+),(\d+),(\d+) (\d+)s left(?:[ ,;].*)?", live) + require(match is not None, "source must already have an active primary TempRadio; pending/inactive is unsafe") + require(abs(float(match[1]) - RF[0]) <= 0.00005 + and (float(match[2]), int(match[3]), int(match[4])) == tuple(RF[1:]), + "live primary TempRadio must be909.5/500/SF5/CR5") + lease = int(match[5]) + require(lease >= duration + 30, "source TempRadio lease must cover duration plus30seconds") + return {"guard": guard, "live_tempradio": live, "lease_seconds": lease, + "saved_primary": node.cli("get radio"), "status": node.cli("get radio2.status")} + + +class RequestParser: + """Bounded resync for the four fixed-size existing source requests only.""" + def __init__(self): + self.buffer = bytearray() + self.discarded = self.bad_checksum = self.unsupported = 0 + self.text_tail = b"" + + def noise(self, value): + self.discarded += len(value) + self.text_tail = (self.text_tail + value)[-512:] + + def feed(self, data): + require(len(data) <= 4096, "serial read exceeded bounded chunk") + self.buffer.extend(data) + frames = [] + while self.buffer: + start = self.buffer.find(b"MS") + if start < 0: + keep = 1 if self.buffer[-1:] == b"M" else 0 + self.noise(self.buffer[:-keep] if keep else self.buffer) + self.buffer[:] = self.buffer[-1:] if keep else b"" + break + if start: + self.noise(self.buffer[:start]) + del self.buffer[:start] + if len(self.buffer) < 3: + break + op = self.buffer[2] + if op not in LENGTHS: + self.unsupported += 1 + raise Failure("unsupported source op; storage/write requests are not accepted") + length = LENGTHS[op] + if len(self.buffer) < length: + break + frame = bytes(self.buffer[:length]) + if xor(frame[2:]) != 0: + self.bad_checksum += 1 + self.discarded += 1 + del self.buffer[0] + continue + del self.buffer[:length] + frames.append(frame) + require(self.discarded <= 65536 and self.bad_checksum <= 100, + "bounded serial resynchronization budget exceeded") + return frames + + +def write_exact(port, data): + require(port.write(data) == len(data), "short USB source write") + port.flush() + + +def pump(port, catalog, seconds, stats, *, attaching=False, parser=None, persist=lambda: None): + parser = RequestParser() if parser is None else parser + started, deadline = time.monotonic(), time.monotonic() + seconds + saved_at = started + while time.monotonic() < deadline: + chunk = port.read(min(port.in_waiting or 1, 4096)) + if not chunk: + continue + stats["usb_received_bytes"] += len(chunk) + for frame in parser.feed(chunk): + response = catalog.handle_request(frame) + require(response is not None and len(response) <= 197, + "catalog returned an invalid source response") + write_exact(port, response) + key = str(frame[2]) + stats["requests"][key] = stats["requests"].get(key, 0) + 1 + stats["usb_sent_bytes"] += len(response) + stats["error_responses"] += int(response[3] != 0) + if attaching: + tail = parser.text_tail + require(b"ERR" not in tail and b"Error" not in tail, "source rejected USB folder attach") + if b"OK folder attached" in tail or b"OK folder refreshed" in tail: + require(stats["requests"].get("1", 0) > 0, "attach ACK did not include a serviced COUNT") + stats["attach_seconds"] = time.monotonic() - started + return + if time.monotonic() - saved_at >= 1: + stats["discarded_serial_bytes"] = parser.discarded + stats["bad_request_checksums"] = parser.bad_checksum + persist() + saved_at = time.monotonic() + stats["discarded_serial_bytes"] = parser.discarded + stats["bad_request_checksums"] = parser.bad_checksum + require(not attaching, "USB folder attach timed out") + + +def serve(node, catalog, duration, stats, persist=lambda: None): + # Explicitly switch the already-initialized framed USB transport to ASCII, + # without closing, resetting, or changing DTR. Do not wait for folder ACK: + # its synchronous COUNT must be answered before the node can print it. + port = node.port + write_exact(port, b"+++MESHCORE-TERM-START\r") + time.sleep(0.3) + # Bounded ASCII drain, never retained/logged (may contain unrelated logs). + discarded, deadline = 0, time.monotonic() + 1 + while time.monotonic() < deadline: + chunk = port.read(min(port.in_waiting or 1, 4096)) + if not chunk: + break + discarded += len(chunk) + require(discarded <= 65536, "terminal transition drain exceeded bound") + stats["discarded_terminal_bytes"] = discarded + requests = RequestParser() + try: + write_exact(port, b"ota folder on\r\n") + pump(port, catalog, 12, stats, attaching=True, parser=requests, persist=persist) + persist() + print("USB mOTA folder attached; serving existing protocol", flush=True) + pump(port, catalog, duration, stats, parser=requests, persist=persist) + finally: + # No outstanding response remains in our synchronous write pump. Even + # after an error, this is only the standard source detach, not install. + write_exact(port, b"ota folder off\r\n") + stats["detach_sent"] = True + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-port") + parser.add_argument("--source-serial") + parser.add_argument("--mota", type=Path) + parser.add_argument("--duration", type=float, default=300, help="bounded serving seconds1..3600") + parser.add_argument("--report", type=Path) + parser.add_argument("--allow-source-transmit", action="store_true") + parser.add_argument("--plan", action="store_true") + args = parser.parse_args(argv) + try: + require(math.isfinite(args.duration) and 1 <= args.duration <= 3600, "duration must be1..3600seconds") + if args.plan: + print(json.dumps({"rf": RF, "required_guard": GUARD, "existing_ops": LENGTHS, + "duration_seconds": args.duration, "zopfli_iterations": 1000, + "prepares_all_blocks_before_usb": True, "install_commands": False}, indent=2)) + return 0 + require(args.allow_source_transmit, "--allow-source-transmit is required before preparing/opening") + require(all((args.source_port, args.source_serial, args.mota, args.report)), + "source port, exact USB serial, mOTA and report are required") + require(not args.report.exists() and args.report.parent.is_dir(), "select a fresh report in an existing directory") + except Failure as error: + parser.error(str(error)) + report = {"schema": 1, "result": "RUNNING", "rf": RF, + "started_utc": datetime.now(timezone.utc).isoformat(), + "no_install_command": True, "duration_seconds": args.duration, + "stats": {"requests": {}, "usb_received_bytes": 0, "usb_sent_bytes": 0, + "error_responses": 0, "discarded_serial_bytes": 0, + "bad_request_checksums": 0, "detach_sent": False}} + node = None + with args.report.open("x", encoding="utf-8") as stream: + try: + os.chmod(args.report, 0o600) + except OSError: + pass + + def save(): + stream.seek(0) + json.dump(report, stream, indent=2) + stream.write("\n") + stream.truncate() + stream.flush() + + save() + try: + print("Verifying and preparing all Zopfli1000 blocks before opening USB", flush=True) + catalog, report["catalog"] = prepare_catalog(args.mota) + save() + from companion_raw_radio2_sweep import safe_identity + from meshtext_delivery import dependencies, require_idle_port + report["usb"] = safe_identity(args.source_port, args.source_serial) + require_idle_port(args.source_port) + Companion, _ = dependencies() + node = Companion(args.source_port) + safe_identity(args.source_port, args.source_serial) + report["companion"] = node.start() + report["safety"] = source_safety(node, args.duration + 12) + save() + serve(node, catalog, args.duration, report["stats"], persist=save) + report["result"] = "SERVED" + except (Exception, KeyboardInterrupt) as error: + report["result"] = "FAIL" + # Do not retain arbitrary device text or packet bytes in errors. + report["failure"] = {"type": type(error).__name__, "message": str(error)[:500]} + finally: + if node is not None: + node.close() + report["finished_utc"] = datetime.now(timezone.utc).isoformat() + save() + print(json.dumps({"result": report["result"], "report": str(args.report), "stats": report["stats"]})) + return 0 if report["result"] == "SERVED" else 1 + + +if __name__ == "__main__": + raise SystemExit(main())