249 lines
13 KiB
Python
249 lines
13 KiB
Python
"""Host harness guards; these do not claim hardware or battery qualification."""
|
|
from contextlib import contextmanager
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import types
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
|
|
PATH = Path(__file__).resolve().parents[1] / "tools/hil/emergency_power_bench.py"
|
|
SPEC = importlib.util.spec_from_file_location("emergency_power_bench", PATH)
|
|
bench = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(bench)
|
|
|
|
|
|
def reply(command, value):
|
|
return command + "\n -> > " + value + "\n"
|
|
|
|
|
|
class EmergencyBench(unittest.TestCase):
|
|
def test_status_requires_every_field_and_seconds(self):
|
|
text = "mode=manual active=on battery=5% clock=rtc window=sleep radio=sleep wake=2699s hold=0s"
|
|
state = bench.status(text)
|
|
self.assertEqual(state["wake"], 2699)
|
|
self.assertEqual(state["hold"], 0)
|
|
self.assertEqual(state["mode"], "manual")
|
|
for bad in (text.replace("2699s", "2699m"), text.replace(" radio=sleep", ""),
|
|
text.replace("mode=manual", "mode=unknown")):
|
|
with self.assertRaises(RuntimeError):
|
|
bench.status(bad)
|
|
|
|
def test_raw_advert_uses_actual_route_path_identity_and_timestamp(self):
|
|
identity = bytes(range(32))
|
|
epoch = 1793000000
|
|
payload = identity + epoch.to_bytes(4, "little") + bytes(64) + b"node"
|
|
for route in range(4):
|
|
for width in (1, 2, 3):
|
|
raw = bytes((16 | route,)) + (bytes(4) if route in (0, 3) else b"")
|
|
raw += bytes((((width - 1) << 6) | 2,)) + b"x" * (width * 2) + payload
|
|
self.assertEqual(bench.advert_timestamp("2026 RAW: " + raw.hex(), identity), epoch)
|
|
self.assertEqual(bench.advert_timestamp("R" + raw.hex(), identity), epoch)
|
|
self.assertIsNone(bench.advert_timestamp("TX RAW? " + raw.hex(), identity))
|
|
self.assertIsNone(bench.advert_timestamp("R" + raw.hex(), bytes(32)))
|
|
self.assertIsNone(bench.advert_timestamp("R" + raw[:10].hex(), identity))
|
|
for header, path in ((16, 192), (16, 127), (20, 0), (80, 0)):
|
|
self.assertIsNone(bench.advert_timestamp("R" + (bytes((header, path)) + payload).hex(), identity))
|
|
|
|
def test_reply_and_identity_require_complete_exact_key(self):
|
|
self.assertEqual(bench.public_key(reply("get public.key", "12" * 32)), b"\x12" * 32)
|
|
for value in ("12" * 31, "12" * 33, "x" * 64, "pending"):
|
|
with self.assertRaises(RuntimeError):
|
|
bench.public_key(reply("get public.key", value))
|
|
with self.assertRaises(RuntimeError):
|
|
bench.reply_value("get radio\n> partial")
|
|
|
|
def test_rxps_restore_preserves_level_or_manual_timings(self):
|
|
self.assertEqual(bench.rxps_restore("off,level=8,preamble=16,rx=39657,sleep=15975"), "set radio.rxps off")
|
|
self.assertEqual(bench.rxps_restore("on,level=8,preamble=16,rx=39657,sleep=15975"), "set radio.rxps level 8 preamble 16")
|
|
self.assertEqual(bench.rxps_restore("on,level=0,preamble=0,rx=65625,sleep=60000"), "set radio.rxps 65625 60000")
|
|
for bad in ("unknown setting", "on", "on,level=8,preamble=16,rx=1"):
|
|
with self.assertRaises(RuntimeError):
|
|
bench.rxps_restore(bad)
|
|
|
|
def test_clock_uses_real_minute_precision_cli(self):
|
|
epoch = bench.clock_epoch(reply("clock", "12:34 - 28/10/2026 UTC"))
|
|
self.assertEqual(epoch % 3600, 34 * 60)
|
|
with self.assertRaises(RuntimeError):
|
|
bench.clock_epoch(reply("clock", "OK"))
|
|
|
|
def test_passive_capture_matches_key_and_minimum_epoch_without_command(self):
|
|
identity = bytes(range(32))
|
|
witness = types.SimpleNamespace(condition=threading.Condition(), failed=False)
|
|
raw = bytes((17, 0)) + identity + (200).to_bytes(4, "little") + bytes(64)
|
|
witness.lines = [(8, "RAW: " + raw.hex()), (12, "RAW: " + raw.hex())]
|
|
observed = bench.await_advert(witness, identity, after=10, minimum_epoch=200, timeout=.1)
|
|
self.assertEqual(observed, {"received_monotonic": 12, "advert_epoch": 200})
|
|
with self.assertRaises(RuntimeError):
|
|
bench.await_advert(witness, identity, after=10, minimum_epoch=201, timeout=.01)
|
|
with self.assertRaises(RuntimeError):
|
|
bench.await_advert(witness, identity, after=10, minimum_epoch=100,
|
|
maximum_epoch=150, timeout=.01)
|
|
|
|
def test_preexisting_manual_mode_is_not_changed_during_failed_preflight(self):
|
|
calls = []
|
|
collector_events = []
|
|
|
|
class FakeConsole:
|
|
def __init__(self, serial_number, path):
|
|
self.serial_number = serial_number
|
|
|
|
def command(self, command, **kwargs):
|
|
calls.append(command)
|
|
values = {"get public.key": "12" * 32, "board": "SolarXiao 33S" if self.serial_number.startswith("9E") else "RAK 3401",
|
|
"ver": "verified build", "get power.emergency": "mode=manual active=on battery=unknown clock=rtc window=sleep radio=sleep wake=200s hold=0s"}
|
|
return reply(command, values[command])
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
@contextmanager
|
|
def collector():
|
|
collector_events.append("pause")
|
|
try:
|
|
yield
|
|
finally:
|
|
collector_events.append("restore")
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
backup = root / "baseline.json"
|
|
backup.write_text(json.dumps({"usb_serial": bench.TARGETS["solarxiao33s"][0],
|
|
"queries": {"get public.key": reply("get public.key", "12" * 32)}}))
|
|
args = types.SimpleNamespace(run_dir=root / "run", identity_backup=backup,
|
|
target="solarxiao33s", witness=None, expected_version="verified build")
|
|
with mock.patch.object(bench, "Console", FakeConsole), mock.patch.object(bench, "paused_collector", collector), \
|
|
mock.patch.object(bench, "snapshot", return_value={}), mock.patch("builtins.print"):
|
|
with self.assertRaisesRegex(RuntimeError, "automatic mode"):
|
|
bench.run_trial(args)
|
|
self.assertFalse(any(command.startswith(("set ", "time ", "clkreboot")) for command in calls))
|
|
self.assertEqual(collector_events, ["pause", "restore"])
|
|
report = json.loads((args.run_dir / "report.json").read_text())
|
|
self.assertFalse(report["passed"])
|
|
self.assertEqual(report["cleanup"], "complete")
|
|
|
|
def test_default_plan_opens_no_port_or_service(self):
|
|
arguments = ["bench", "--target", "rak3401", "--expected-version", "verified", "--identity-backup", "unused.json", "--run-dir", "unused"]
|
|
with mock.patch("sys.argv", arguments), mock.patch.object(bench, "run_trial") as run, mock.patch("builtins.print"):
|
|
bench.main()
|
|
run.assert_not_called()
|
|
|
|
def test_console_initialization_delimits_stale_input_without_reset_pulse(self):
|
|
writes = []
|
|
|
|
class Port:
|
|
def __init__(self, **kwargs):
|
|
self.dtr = self.rts = None
|
|
|
|
def open(self):
|
|
self_values = (self.dtr, self.rts, self.port)
|
|
if self_values != (True, False, "/dev/ttyACM10"):
|
|
raise AssertionError(self_values)
|
|
|
|
def write(self, value):
|
|
writes.append(value)
|
|
|
|
def flush(self):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
serial_module = types.SimpleNamespace(Serial=Port)
|
|
ports = types.SimpleNamespace(comports=lambda: [types.SimpleNamespace(
|
|
serial_number="9EBCE8853076EE86", interface="TinyUSB Serial", device="/dev/ttyACM10")])
|
|
with tempfile.TemporaryDirectory() as directory, \
|
|
mock.patch.dict("sys.modules", {"serial": serial_module, "serial.tools": types.SimpleNamespace(list_ports=ports)}), \
|
|
mock.patch.object(bench.subprocess, "run", return_value=types.SimpleNamespace(returncode=1, stdout=b"", stderr=b"")), \
|
|
mock.patch.object(bench.threading, "Thread"), mock.patch.object(bench.time, "sleep"):
|
|
console = bench.Console("9EBCE8853076EE86", Path(directory) / "private.log")
|
|
console.close()
|
|
self.assertEqual(writes, [b"\r"])
|
|
|
|
def test_snapshot_preserves_disabled_radio2_ownership_annotation(self):
|
|
values = {"radio": "910.5250244,62.5,7,5", "tx": "-6", "repeat": "on", "tempradio": "off",
|
|
"radio.rxps.config": "off,level=0,preamble=0,rx=65625,sleep=60000",
|
|
"usb.logging": "on", "usb.logging.always": "off", "radioat": "-none-",
|
|
"tempradioat": "-none-", "radioat2": "slots:", "tempradioat2": "slots:"}
|
|
console = types.SimpleNamespace(command=lambda command, **kwargs: reply(command, values[command[4:]]))
|
|
for disabled in ("off", "off (manual)", "off (automatic allowed)"):
|
|
values["radio2"] = disabled
|
|
self.assertEqual(bench.snapshot(console)["radio2"], disabled)
|
|
values["radio2"] = "911.150,250,7,5,rxtx,32"
|
|
with self.assertRaisesRegex(RuntimeError, "inactive"):
|
|
bench.snapshot(console)
|
|
values["radio2"] = "off"
|
|
for key in ("radioat", "tempradioat", "radioat2", "tempradioat2"):
|
|
previous = values[key]
|
|
values[key] = "1:911.5,125,7,5@1900000000"
|
|
with self.assertRaisesRegex(RuntimeError, "empty future"):
|
|
bench.snapshot(console)
|
|
values[key] = previous
|
|
|
|
def test_radio_admission_never_confuses_reboot_required_with_live_retune(self):
|
|
calls = []
|
|
def command(text):
|
|
calls.append(text)
|
|
return reply(text, "OK - reboot to apply")
|
|
console = types.SimpleNamespace(command=command)
|
|
with mock.patch.object(bench.time, "sleep") as sleep:
|
|
with self.assertRaisesRegex(RuntimeError, "saved getter"):
|
|
bench.apply_radio(console, bench.BENCH_RADIO)
|
|
self.assertEqual(calls, ["set radio " + bench.BENCH_RADIO])
|
|
sleep.assert_not_called()
|
|
|
|
def test_rf_witness_cannot_share_an_original_main_frequency(self):
|
|
target = {"radio": "910.5250244,62.5,7,5"}
|
|
bench.require_distinct_original_frequencies(target, {"radio": "917,62.5,7,5"})
|
|
with self.assertRaisesRegex(RuntimeError, "different original"):
|
|
bench.require_distinct_original_frequencies(target, {"radio": "910.525,125,8,5"})
|
|
|
|
def test_wireless_snapshot_distinguishes_pending_and_failure(self):
|
|
console = types.SimpleNamespace(command=lambda command: reply(command,
|
|
"wifi=on, bluetooth=unavailable, espnow=off (pending)"))
|
|
self.assertEqual(bench.wireless_snapshot(console),
|
|
{"wifi": "on", "bluetooth": "unavailable", "espnow": "off", "pending": True})
|
|
console.command = lambda command: reply(command,
|
|
"wifi=off, bluetooth=unavailable, espnow=off (failed: retry command)")
|
|
with self.assertRaisesRegex(RuntimeError, "failed transition"):
|
|
bench.wireless_snapshot(console)
|
|
|
|
def test_network_clock_hold_restores_only_original_runtime_subset(self):
|
|
calls = []
|
|
live = {"wifi": "on", "bluetooth": "unavailable", "espnow": "off"}
|
|
refused = [False]
|
|
def command(text, **kwargs):
|
|
calls.append(text)
|
|
if text == "get 2.4ghz":
|
|
return reply(text, ", ".join(key + "=" + value for key, value in live.items()))
|
|
if text == "get power.emergency":
|
|
return reply(text, "mode=manual active=on battery=100% clock=rtc window=awake radio=awake wake=0s hold=60s")
|
|
if text == "set wifi off force":
|
|
if not refused[0]:
|
|
refused[0] = True
|
|
return reply(text, "Error: emergency sleep; use set power.awake <minutes> first")
|
|
live["wifi"] = "off"
|
|
elif text == "set wifi on":
|
|
live["wifi"] = "on"
|
|
elif text not in ("set power.awake 0", "set power.awake 1"):
|
|
raise AssertionError(text)
|
|
return reply(text, "OK")
|
|
console = types.SimpleNamespace(command=command)
|
|
original = bench.wireless_snapshot(console)
|
|
with mock.patch.object(bench.time, "sleep"):
|
|
bench.hold_network_clock(console)
|
|
self.assertEqual(live["wifi"], "off")
|
|
bench.restore_wireless(console, original)
|
|
self.assertEqual(bench.wireless_snapshot(console), original)
|
|
self.assertEqual(calls.count("set wifi off force"), 2)
|
|
self.assertEqual(calls.count("set wifi on"), 1)
|
|
self.assertFalse(any("ssid" in value or "pwd" in value or "password" in value for value in calls))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|