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.
This commit is contained in:
mikecarper committed 2026-10-15 21:05:40 -07:00
1 parent 03b660e06f
commit c6e43c5530
19 files changed
+5054 -7

No files matched your search

+10
View File
@@ -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
+14
View File
@@ -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;
}
+265
View File
@@ -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()
+152
View File
@@ -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()
+306
View File
@@ -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()
+186
View File
@@ -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()
+672
View File
@@ -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("<redacted>", 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("<redacted>", saved)
if __name__ == "__main__":
unittest.main()
+93
View File
@@ -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 <RadioProfiles.h>
#include <cstdio>
#include <initializer_list>
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<unsigned long>(mesh::RadioProfiles::minimumListenUs(p, false)),
static_cast<unsigned long>(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<unsigned long>(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()
+288
View File
@@ -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("<I", 0) + text)
owner.rx += 1
return SimpleNamespace(id=identity)
def wait_packet(self, sender, predicate, since, timeout):
if not owner.drop_outbound:
return next((packet for when, packet in owner.events
if when >= 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()
+276
View File
@@ -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()
+231
View File
@@ -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("<IH", 8, 192)),
request(9, b"\x00" + struct.pack("<HHH", 19, 0, 0))]
result = []
for byte in b"hello\r\nM" + b"".join(frames):
result += parser.feed(bytes([byte]))
self.assertEqual(result, frames)
self.assertLessEqual(len(parser.buffer), 11)
def test_bad_checksum_resync_and_no_storage_write_support(self):
parser = USB.RequestParser()
self.assertEqual(parser.feed(b"MS\x01\x00" + request(1)), [request(1)])
self.assertEqual(parser.bad_checksum, 1)
with self.assertRaisesRegex(USB.Failure, "storage/write"):
parser.feed(b"MS\x06")
with self.assertRaises(USB.Failure):
USB.RequestParser().feed(b"a" * 4097)
def test_noise_budget_bounded(self):
parser = USB.RequestParser()
with self.assertRaisesRegex(USB.Failure, "budget"):
for _ in range(18):
parser.feed(b"a" * 4096)
def test_cached_snapshot_retains_more_than16_blocks_without_recompression(self):
offset, raw, count = 205, bytes(range(64)) * 20, 20
descriptor = bytearray(38)
struct.pack_into("<III", descriptor, 22, count, offset, len(raw))
descriptor[34] = 6
blob = b"h" * offset + raw + b"vk496"
calls = []
def compress(value, *, numiterations):
calls.append(numiterations)
return zlib.compress(value)[2:-4]
cached = USB.CachedFile(blob, bytes(descriptor), compress)
self.assertEqual(len(cached.blocks), 20)
for index in (0, 19, 0, 18, 0):
cached.deflated_block(index)
self.assertEqual(calls, [1000] * 20)
self.assertEqual(cached.path.open("rb").read(), blob)
with self.assertRaises(USB.Failure):
cached.path.open("wb")
def test_real_catalog_golden_requests_verifies_and_ignores_mutated_original(self):
sys.path.insert(0, str(ROOT / "tools/mota"))
import motalib
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "fixture.mota"
raw = b"USB source transport fixture. " * 40
manifest = motalib.build_manifest(target_id=0x12345678, fw_version=0x01120001,
image_size=len(raw), payload=raw, block_size=64, image_hash=motalib.mh32(raw),
codec_id=motalib.CODEC_FULL, is_full=True)
blob = motalib.build_container(manifest, raw)
path.write_bytes(blob)
catalog, details = USB.prepare_catalog(path)
self.assertGreater(details["blocks"], 16)
self.assertEqual(details["zopfli_iterations"], 1000)
path.write_bytes(b"broken same-source replacement")
count = catalog.handle_request(request(1))
self.assertEqual(count, b"ms\x01\x00\x01" + bytes([USB.xor(b"ms\x01\x00\x01")]))
desc = catalog.handle_request(request(2, b"\x00"))
self.assertEqual(len(desc), 43)
response = catalog.handle_request(request(3, b"\x00" + struct.pack("<IH", 0, 192)))
self.assertEqual(response[4:-1], blob[:192])
self.assertEqual(USB.xor(response), 0)
# Every block is retained, so revisiting first after block19 is RAM-only.
last = catalog.handle_request(request(9, b"\x00" + struct.pack("<HHH", details["blocks"] - 1, 0, 0)))
first = catalog.handle_request(request(9, b"\x00" + struct.pack("<HHH", 0, 0, 0)))
self.assertEqual(last[3], 0)
self.assertEqual(first[3], 0)
def test_invalid_container_refuses_before_usb(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "bad.mota"
path.write_bytes(b"mOTA invalid")
with self.assertRaises((ValueError, USB.Failure)):
USB.prepare_catalog(path)
def test_guard_live_primary_and_lease_all_fail_closed_without_setters(self):
class Node:
guard = USB.GUARD
live = "> TempRadio active: 909.500,500.00,5,5 100s left,preamble=32"
def __init__(self):
self.commands = []
def cli(self, command):
self.commands.append(command)
return {"get lab.tx": self.guard, "get tempradio": self.live,
"get radio": "910.525,62.5,7,5", "get radio2.status": "TX=0,0"}[command]
node = Node()
self.assertEqual(USB.source_safety(node, 60)["lease_seconds"], 100)
for guard, live in (("unlocked", node.live), (USB.GUARD, "TempRadio pending: 909.5,500,5,5"),
(USB.GUARD, "TempRadio active: 910.525,500,5,5 100s left"),
(USB.GUARD, "TempRadio active: 909.5,500,7,5 100s left"),
(USB.GUARD, "TempRadio active: 909.5,500,5,5 89s left")):
node.guard, node.live = guard, live
with self.assertRaises(USB.Failure):
USB.source_safety(node, 60)
self.assertTrue(all(command.startswith("get ") for command in node.commands))
def test_attach_services_count_before_ack_then_serves_and_detaches(self):
clock, state = Clock(), stats()
class Catalog:
def handle_request(self, frame):
self.frame = frame
return b"ms\x01\x00\x01\x1f"
class Port:
def __init__(self):
self.incoming, self.writes = bytearray(), []
@property
def in_waiting(self):
return len(self.incoming)
def write(self, value):
self.writes.append(value)
if value.startswith(b"ota folder on"):
self.incoming.extend(request(1))
elif value.startswith(b"ms"):
self.incoming.extend(b"\r\nOK folder attached (serial): advertising1/1\r\n")
return len(value)
def flush(self):
pass
def read(self, count):
clock.now += .01
value = bytes(self.incoming[:count])
del self.incoming[:count]
return value
node = SimpleNamespace(port=Port())
with mock.patch.object(USB.time, "monotonic", clock.monotonic), mock.patch.object(USB.time, "sleep", clock.sleep), redirect_stdout(io.StringIO()):
USB.serve(node, Catalog(), .1, state)
self.assertEqual(state["requests"], {"1": 1})
self.assertTrue(state["detach_sent"])
self.assertEqual(node.port.writes[0], b"+++MESHCORE-TERM-START\r")
self.assertEqual(node.port.writes[-1], b"ota folder off\r\n")
self.assertNotIn(b"install", b"".join(node.port.writes))
def test_no_attach_ack_times_out_and_still_detaches(self):
clock = Clock()
class Port:
in_waiting = 0
writes = []
def read(self, count):
clock.now += .1
return b""
def write(self, value):
self.writes.append(value)
return len(value)
def flush(self):
pass
with mock.patch.object(USB.time, "monotonic", clock.monotonic), mock.patch.object(USB.time, "sleep", clock.sleep):
with self.assertRaisesRegex(USB.Failure, "attach timed out"):
USB.serve(SimpleNamespace(port=Port()), None, .1, stats())
self.assertEqual(Port.writes[-1], b"ota folder off\r\n")
if __name__ == "__main__":
unittest.main()
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env python3
"""Identity-bound USB RAW_CUSTOM radio2 benchmark for two Full Companions.
TRANSMITS LoRa and changes radio2, tx.ssh and TX power, with explicit consent.
Requires both primary radios already rebooted onto USA 910.525/62.5/SF7/CR5
and an immutable compiled 909.5 MHz HIL TX lock. Never changes primary, WiFi,
channels, keys, bootloaders or firmware. Remove mt-/r2- channels beforehand.
The 176-byte Companion host response limits raw application payload to172B;
230-byte direct MTU requires the separate KISS modem path, not this API.
Sizes include the four-byte SSH routing tag. Measures exact opposite-radio
RAW reception and host stop/wait goodput, not an established SSH/TCP session.
No retries conceal RF losses. Run --plan without dependencies or hardware.
"""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import hashlib
import json
import math
import os
from pathlib import Path
import re
import secrets
import struct
import time
GUARD = "HIL TX lock=909.500 MHz; off-frequency TX blocked; primary RX-only when off-frequency"
PREAMBLES = (128, 256, 384, 512)
SIZES = (64, 172)
PRIMARY = (910.525, 62.5, 7, 5)
RF = {"frequency_mhz": 909.5, "bandwidth_khz": 500, "sf": 5, "cr": 5, "tx_dbm": 2}
RAW_LIMIT = 172
class Failure(RuntimeError):
pass
def require(condition, message):
if not condition:
raise Failure(message)
def parse_primary(reply):
match = re.fullmatch(r"\s*(?:>\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("<IB", sequence, direction)
return header + (bytes(range(256)) * 2)[:size - len(header)]
def raw_payload(frame):
return frame[4:] if len(frame) >= 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())
+92
View File
@@ -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}
-<helpers/ui/LGFXDisplay.cpp>
-<../variants/sensecap_indicator-espnow/IndicatorFontClient.cpp>
+<helpers/bridges/ESPNowBridge.cpp>
+<helpers/esp32/*.cpp>
-<helpers/esp32/ESPNOWRadio.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.
+48 -7
View File
@@ -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"
+166
View File
@@ -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"
+773
View File
@@ -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], "<redacted>")
return re.sub(r"(?<![0-9a-fA-F])[0-9a-fA-F]{32,64}(?![0-9a-fA-F])", "<redacted>", 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, "<redacted>")
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())
+369
View File
@@ -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, "<redacted>")
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())
+437
View File
@@ -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"(?<![\da-fA-F])[\da-fA-F]{32,}(?![\da-fA-F])", "<redacted>", raw)
safe = re.sub(r"(?im)\b(?:password|pwd|psk|private.?key)\s*[:=].*", "<redacted>", safe)
if len(safe) > 1536:
safe = safe[:768] + "\n<bounded middle omitted>\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"(?<![\da-fA-F])" + re.escape(mid) + r"(?![\da-fA-F])", catalog, re.I):
event("catalog_visible", reply=catalog)
break
require(time.monotonic() < deadline, "selected manifest did not reach target catalog")
time.sleep(1)
restore = True # Include partial-toggle failure in cleanup.
bridge_b(b, False)
event("initial_bridge_off")
fresh_pull(target, mid)
blocked = observe(target, mid, outage, poll, lambda sample: event("initial_off_progress", **sample))
require(all(sample["have"] == 0 for sample in blocked), "received OTA blocks while required bridge B was off")
report["initial_off_no_completed_blocks"] = True
bridge_b(b, True)
event("initial_bridge_on")
if blocked[-1]["state"].startswith("failed"):
require(command(target, "ota pull " + mid + " flash").startswith("OK"), "could not retry selected pull after initial outage")
event("retry_after_initial_manifest_timeout")
mid_proven = False
for attempt in (1, 2):
deadline, retried = time.monotonic() + timeout, False
if attempt == 2:
# A very fast first transfer can finish before one counter poll.
# One bounded fresh refetch tries again with faster polling.
event("fresh_refetch_for_missed_midpoint")
fresh_pull(target, mid)
while True:
require(time.monotonic() < deadline, "bounded chain transfer deadline expired")
sample = progress(target, mid)
event("on_progress", attempt=attempt, **sample)
if sample["state"] == "done":
require(sample["total"] > 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())
+319
View File
@@ -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("<III", descriptor, 22)
block_size = 1 << descriptor[34]
require(2 <= block_size <= 2048 and 0 < count <= 65536,
"mOTA transport requires blocks2..2048 and at most65536 blocks")
require(offset + length + 5 == len(blob), "invalid cached payload geometry")
for index in range(count):
raw = blob[offset + index * block_size:offset + min(length, (index + 1) * block_size)]
require(raw, "empty cached payload block")
encoded = compressor(raw, numiterations=1000)
require(zlib.decompress(encoded, -15) == raw, "compressed block failed roundtrip")
self.blocks[index] = encoded if 0 < len(encoded) < len(raw) else None
def deflated_block(self, index):
return self.blocks.get(index)
def prepare_catalog(path):
# Imports remain behind --plan/consent; BLE itself is not needed.
sys.path.insert(0, str(ROOT / "tools/ble_mota"))
sys.path.insert(0, str(ROOT / "tools/mota"))
import ble_mota_seeder as protocol
import motalib
require(path.is_file() and 0 < path.stat().st_size <= MAX_CONTAINER,
"select one regular mOTA container no larger than4MiB")
blob = path.read_bytes()
parsed = motalib.parse_container(blob)
problems = motalib.verify(parsed)
require(not problems, "mOTA verification failed: " + "; ".join(problems))
require(not parsed.manifest.is_bootloader, "this HIL source accepts application mOTA only")
original = protocol.MotaFile.load(path)
require(path.read_bytes() == blob, "mOTA changed during verification; refusing source")
cached = CachedFile(blob, original.descriptor, protocol.raw_deflate_compress)
return protocol.Catalog([cached], False), {
"container_bytes": len(blob), "sha256": hashlib.sha256(blob).hexdigest(),
"manifest_id": original.descriptor[:4].hex(),
"target_id": f"{parsed.manifest.target_id:08x}",
"blocks": len(cached.blocks), "block_bytes": 1 << original.descriptor[34],
"deflated_blocks": sum(value is not None for value in cached.blocks.values()),
"cached_deflate_bytes": sum(len(value) for value in cached.blocks.values() if value),
"zopfli_iterations": 1000, "immutable_ram_snapshot": True,
"verification": "container hashes/Merkle/signature if present; delta result requires receiver's base",
}
def source_safety(node, duration):
guard = node.cli("get lab.tx")
require(guard.strip().removeprefix("> ") == 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())