290 lines
14 KiB
Python
290 lines
14 KiB
Python
"""Host-only full KISS configuration tests; never open a serial device."""
|
|
|
|
from collections import deque
|
|
from pathlib import Path
|
|
import struct
|
|
import sys
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/hil"))
|
|
import ethernet_kiss_modem_probe as probe
|
|
|
|
|
|
def fake_serial_modules():
|
|
serial = SimpleNamespace(Serial=Mock())
|
|
list_ports = SimpleNamespace(comports=Mock())
|
|
modules = {"serial": serial, "serial.tools": SimpleNamespace(list_ports=list_ports)}
|
|
return serial, list_ports, modules
|
|
|
|
|
|
def response(code, body=b""):
|
|
return probe.encode_frame(6, bytes((code,)) + body)
|
|
|
|
|
|
class Clock:
|
|
def __init__(self):
|
|
self.now = 0
|
|
|
|
def __call__(self):
|
|
self.now += 0.01
|
|
return self.now
|
|
|
|
|
|
class FakePort:
|
|
def __init__(self):
|
|
self.decoder = probe.Decoder()
|
|
self.input = deque()
|
|
self.writes = []
|
|
self.override = {}
|
|
self.tx = 0
|
|
|
|
def write(self, data):
|
|
for frame in self.decoder.feed(data):
|
|
self.writes.append(frame)
|
|
if frame[0] != 6:
|
|
continue
|
|
command = frame[1]
|
|
body = {0x21: struct.pack("<I", probe.GUARD_HZ), 0x11: b"\2\0",
|
|
0x01: bytes(range(32)), 0x17: b"",
|
|
0x12: struct.pack("<III", 10, self.tx, 0),
|
|
0x0B: probe.PRIMARY_STRUCT.pack(*probe.PRIMARY),
|
|
0x20: probe.SECONDARY_STRUCT.pack(*probe.SECONDARY), 0x0C: b"\0"}
|
|
result = self.override.get(command, response(command | 0x80, body[command])
|
|
if command in body else response(0xF0))
|
|
self.input.extend(result[i:i + 1] for i in range(len(result)))
|
|
return len(data)
|
|
|
|
def flush(self):
|
|
pass
|
|
|
|
def read(self, size):
|
|
return self.input.popleft() if self.input else b""
|
|
|
|
|
|
class ProbeTest(unittest.TestCase):
|
|
def link(self, port=None):
|
|
return probe.ControlLink(port or FakePort(), timeout=1, clock=Clock())
|
|
|
|
def test_full_configuration_exact_tuple_no_data_or_crypto(self):
|
|
port = FakePort()
|
|
result = probe.configure_modem(self.link(port))
|
|
self.assertTrue(result["success"])
|
|
self.assertEqual(result["radio2"]["preamble"], 512)
|
|
self.assertEqual(result["radio2"]["protocol"], 2)
|
|
self.assertEqual(port.writes[0], b"\6\x21")
|
|
self.assertIn(b"\6\x1f" + probe.SECONDARY_STRUCT.pack(*probe.SECONDARY), port.writes)
|
|
self.assertIn(b"\1\0", port.writes)
|
|
self.assertIn(b"\2\xff", port.writes)
|
|
self.assertTrue(all(frame[0] in (1, 2, 6) for frame in port.writes))
|
|
self.assertEqual(result["stats_before"], result["stats_after"])
|
|
self.assertNotIn(bytes(range(32)).hex(), str(result))
|
|
|
|
def test_bandwidth125_preamble128_exact_setter_and_readback_without_global_changes(self):
|
|
port = FakePort()
|
|
expected = (probe.GUARD_HZ, 125000, 5, 5, 2, 128, 2)
|
|
port.override[0x20] = response(0xA0, probe.SECONDARY_STRUCT.pack(*expected))
|
|
report = probe.configure_modem(self.link(port), bandwidth=125, preamble=128)
|
|
self.assertTrue(report["success"])
|
|
self.assertEqual(report["radio2"], dict(zip(("frequency_hz", "bandwidth_hz", "sf", "cr",
|
|
"mode", "preamble", "protocol"), expected)))
|
|
self.assertIn(b"\6\x1f" + probe.SECONDARY_STRUCT.pack(*expected), port.writes)
|
|
self.assertEqual(probe.SECONDARY, (probe.GUARD_HZ, 500000, 5, 5, 2, 512, 2))
|
|
self.assertEqual(probe.secondary_tuple(), probe.SECONDARY)
|
|
self.assertTrue(all(frame[0] in (1, 2, 6) for frame in port.writes))
|
|
|
|
def test_selected_secondary_readback_cannot_silently_use_default_width_or_preamble(self):
|
|
for observed in (probe.SECONDARY, (probe.GUARD_HZ, 125000, 5, 5, 2, 512, 2),
|
|
(probe.GUARD_HZ, 500000, 5, 5, 2, 128, 2)):
|
|
port = FakePort()
|
|
port.override[0x20] = response(0xA0, probe.SECONDARY_STRUCT.pack(*observed))
|
|
with self.assertRaisesRegex(probe.ProtocolError, "Secondary"):
|
|
probe.configure_modem(self.link(port), bandwidth=125, preamble=128)
|
|
|
|
def test_invalid_bandwidth_or_preamble_fails_before_any_hardware_request(self):
|
|
for options in ([dict(bandwidth=value) for value in (250, 125.0, True, "125", None)] +
|
|
[dict(preamble=value) for value in (31, 65, 1025, 128.0, True, "128", None)]):
|
|
port = FakePort()
|
|
with self.assertRaises(ValueError): probe.configure_modem(self.link(port), **options)
|
|
self.assertEqual(port.writes, [])
|
|
for preamble in probe.PREAMBLES:
|
|
self.assertEqual(probe.secondary_tuple(bandwidth=125, preamble=preamble),
|
|
(probe.GUARD_HZ, 125000, 5, 5, 2, preamble, 2))
|
|
|
|
def test_cli_passes_explicit_width_and_preamble_and_rejects_invalid_before_open(self):
|
|
from contextlib import redirect_stdout, redirect_stderr
|
|
from io import StringIO
|
|
port = FakePort(); port.close = Mock()
|
|
info = SimpleNamespace(serial_number="known", device="/dev/example", vid=0x239A, pid=0x8029)
|
|
with patch.object(probe, "open_verified_port", return_value=(port, info)), \
|
|
patch.object(probe, "configure_modem", return_value={"success": True}) as configure, \
|
|
redirect_stdout(StringIO()):
|
|
result = probe.main(["--port", "/dev/example", "--expect-serial", "known",
|
|
"--bandwidth", "125", "--preamble", "128"])
|
|
self.assertEqual(result, 0)
|
|
self.assertEqual(configure.call_args.kwargs, dict(bandwidth=125, preamble=128))
|
|
port.close.assert_called_once()
|
|
for option, value in (("--bandwidth", "250"), ("--preamble", "65")):
|
|
with patch.object(probe, "open_verified_port") as opened, redirect_stderr(StringIO()), \
|
|
self.assertRaises(SystemExit):
|
|
probe.main(["--port", "/dev/example", "--expect-serial", "known", option, value])
|
|
opened.assert_not_called()
|
|
|
|
def test_guard_unknown_fails_before_any_setter(self):
|
|
port = FakePort()
|
|
port.override[0x21] = response(0xF1, b"\5")
|
|
with self.assertRaisesRegex(probe.ProtocolError, "failed: 0x05"):
|
|
probe.configure_modem(self.link(port))
|
|
self.assertEqual(port.writes, [b"\6\x21"])
|
|
|
|
def test_wrong_guard_never_sets_anything(self):
|
|
port = FakePort()
|
|
port.override[0x21] = response(0xA1, struct.pack("<I", 910_525_000))
|
|
with self.assertRaisesRegex(probe.ProtocolError, "TX lock"):
|
|
probe.configure_modem(self.link(port))
|
|
self.assertEqual(len(port.writes), 1)
|
|
|
|
def test_bad_version_fails_before_setters(self):
|
|
port = FakePort()
|
|
port.override[0x11] = response(0x91, b"\1\0")
|
|
with self.assertRaisesRegex(probe.ProtocolError, "version"):
|
|
probe.configure_modem(self.link(port))
|
|
self.assertEqual(len(port.writes), 2)
|
|
|
|
def test_primary_float_rounding_only_frequency(self):
|
|
port = FakePort()
|
|
port.override[0x0B] = response(0x8B, probe.PRIMARY_STRUCT.pack(910_525_024, 62500, 7, 5))
|
|
self.assertEqual(probe.configure_modem(self.link(port))["primary_rx_only"]["frequency_hz"],
|
|
910_525_024)
|
|
for bad in ((910_526_000, 62500, 7, 5), (910_525_000, 125000, 7, 5)):
|
|
port = FakePort()
|
|
port.override[0x0B] = response(0x8B, probe.PRIMARY_STRUCT.pack(*bad))
|
|
with self.assertRaisesRegex(probe.ProtocolError, "Primary"):
|
|
probe.configure_modem(self.link(port))
|
|
|
|
def test_secondary_legacy_truncated_wrong_protocol_or_preamble_rejected(self):
|
|
for body in (probe.SECONDARY_STRUCT.pack(*probe.SECONDARY)[:-1],
|
|
probe.SECONDARY_STRUCT.pack(*probe.SECONDARY[:-1], 0),
|
|
probe.SECONDARY_STRUCT.pack(*probe.SECONDARY[:5], 32, 2)):
|
|
port = FakePort()
|
|
port.override[0x20] = response(0xA0, body)
|
|
with self.assertRaises(probe.ProtocolError):
|
|
probe.configure_modem(self.link(port))
|
|
|
|
def test_unknown_or_malformed_error_is_immediate(self):
|
|
for frame in (response(0xF1, b"\5"), response(0xF1), response(0xF1, b"\5\0")):
|
|
port = FakePort()
|
|
port.override[0x21] = frame
|
|
with self.assertRaises(probe.ProtocolError):
|
|
self.link(port).request(0x21, size=4)
|
|
|
|
def test_timeout_bounded_and_no_retries(self):
|
|
port = FakePort()
|
|
port.override[0x21] = b""
|
|
with self.assertRaisesRegex(probe.ProtocolError, "Timed out"):
|
|
self.link(port).request(0x21, size=4)
|
|
self.assertEqual(len(port.writes), 1)
|
|
|
|
def test_metadata_and_rx_data_allowed_but_tx_done_fails(self):
|
|
port = FakePort()
|
|
port.override[0x21] = (response(0xF9, b"\0\0") + b"\xc0\x10rx\xc0" +
|
|
response(0xA1, struct.pack("<I", probe.GUARD_HZ)))
|
|
link = self.link(port)
|
|
self.assertEqual(len(link.request(0x21, size=4)), 4)
|
|
self.assertEqual((link.rx_metadata_frames, link.ignored_data_frames), (1, 1))
|
|
port = FakePort()
|
|
port.override[0x21] = response(0xF8, b"\1")
|
|
with self.assertRaisesRegex(probe.ProtocolError, "TX_DONE"):
|
|
self.link(port).request(0x21, size=4)
|
|
|
|
def test_escape_split_and_overflow(self):
|
|
decoder = probe.Decoder()
|
|
wire = response(0x81, b"\xc0\xdb")
|
|
frames = []
|
|
for value in wire:
|
|
frames.extend(decoder.feed(bytes((value,))))
|
|
self.assertEqual(frames, [b"\6\x81\xc0\xdb"])
|
|
for wire in (b"\xc0\6\xdb\0", b"\xc0\6\xdb\xc0",
|
|
b"\xc0" + b"x" * 515):
|
|
with self.assertRaises(probe.ProtocolError):
|
|
probe.Decoder().feed(wire)
|
|
|
|
def test_encoder_refuses_data_and_nonzero_port(self):
|
|
for command in (0, 0x10, -1):
|
|
with self.assertRaises(ValueError):
|
|
probe.encode_frame(command, b"test")
|
|
|
|
def test_tx_counter_change_is_not_reported_as_success(self):
|
|
with patch.object(probe, "_stats", side_effect=[{"rx": 1, "tx": 0, "errors": 0},
|
|
{"rx": 2, "tx": 1, "errors": 0}]):
|
|
with self.assertRaisesRegex(probe.ProtocolError, "TX counter changed"):
|
|
probe.configure_modem(self.link())
|
|
|
|
def test_busy_or_wrong_usb_identity_is_never_opened(self):
|
|
serial, list_ports, modules = fake_serial_modules()
|
|
info = SimpleNamespace(device="/dev/example", serial_number="wrong", vid=0x239A)
|
|
with patch.dict(sys.modules, modules), \
|
|
patch.object(list_ports, "comports", return_value=[info]), \
|
|
patch.object(serial, "Serial") as constructor:
|
|
with self.assertRaisesRegex(probe.ProtocolError, "USB serial"):
|
|
probe.open_verified_port("/dev/example", "9AB3B64C641BA927")
|
|
constructor.assert_not_called()
|
|
info.serial_number = "9AB3B64C641BA927"
|
|
with patch.dict(sys.modules, modules), \
|
|
patch.object(list_ports, "comports", return_value=[info]), \
|
|
patch.object(probe, "assert_port_idle", side_effect=RuntimeError("busy")), \
|
|
patch.object(serial, "Serial") as constructor:
|
|
with self.assertRaisesRegex(RuntimeError, "busy"):
|
|
probe.open_verified_port("/dev/example", info.serial_number)
|
|
constructor.assert_not_called()
|
|
|
|
def test_native_nrf_usb_open_is_exclusive_dtr_on_rts_off(self):
|
|
serial, list_ports, modules = fake_serial_modules()
|
|
info = SimpleNamespace(device="/dev/example", serial_number="9AB3B64C641BA927", vid=0x239A)
|
|
port = SimpleNamespace(close=Mock())
|
|
with patch.dict(sys.modules, modules), \
|
|
patch.object(list_ports, "comports", return_value=[info]), \
|
|
patch.object(probe, "assert_port_idle"), \
|
|
patch.object(serial, "Serial", return_value=port) as constructor, \
|
|
patch.object(probe, "open_configured_session") as opener:
|
|
selected, observed = probe.open_verified_port("/dev/example", info.serial_number)
|
|
self.assertIs(selected, port)
|
|
self.assertIs(observed, info)
|
|
self.assertTrue(port.dtr)
|
|
self.assertFalse(port.rts)
|
|
self.assertTrue(constructor.call_args.kwargs["exclusive"])
|
|
self.assertEqual(constructor.call_args.kwargs["baudrate"], 115200)
|
|
opener.assert_called_once_with(port)
|
|
|
|
def test_post_open_usb_identity_change_closes_before_commands(self):
|
|
serial, list_ports, modules = fake_serial_modules()
|
|
info = SimpleNamespace(device="/dev/example", serial_number="9AB3B64C641BA927", vid=0x239A)
|
|
wrong = SimpleNamespace(device="/dev/example", serial_number="different", vid=0x239A)
|
|
port = SimpleNamespace(close=Mock())
|
|
with patch.dict(sys.modules, modules), \
|
|
patch.object(list_ports, "comports", side_effect=[[info], [wrong]]), \
|
|
patch.object(probe, "assert_port_idle"), \
|
|
patch.object(serial, "Serial", return_value=port), \
|
|
patch.object(probe, "open_configured_session"):
|
|
with self.assertRaisesRegex(probe.ProtocolError, "identity changed"):
|
|
probe.open_verified_port("/dev/example", info.serial_number)
|
|
port.close.assert_called_once()
|
|
|
|
def test_cli_releases_port_on_protocol_failure(self):
|
|
from contextlib import redirect_stdout
|
|
from io import StringIO
|
|
port = FakePort()
|
|
port.close = Mock()
|
|
info = SimpleNamespace(serial_number="9AB3B64C641BA927", device="/dev/example", vid=0x239A, pid=0x8029)
|
|
with patch.object(probe, "open_verified_port", return_value=(port, info)), \
|
|
patch.object(probe, "configure_modem", side_effect=probe.ProtocolError("rejected")), \
|
|
redirect_stdout(StringIO()):
|
|
result = probe.main(["--port", "/dev/example", "--expect-serial", info.serial_number])
|
|
self.assertEqual(result, 1)
|
|
port.close.assert_called_once()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|