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:
1 parent
03b660e06f
commit
c6e43c5530
19 files changed
+5054
-7
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user