diff --git a/tools/hil/mercerwood_pi_nrf_inventory.json b/tools/hil/mercerwood_pi_nrf_inventory.json new file mode 100644 index 00000000..2fcc2766 --- /dev/null +++ b/tools/hil/mercerwood_pi_nrf_inventory.json @@ -0,0 +1,34 @@ +{ + "devices": { + "9352162A72082314": { + "board": "Heltec MeshTower V2 SD", + "bootloader_prefixes": ["heltec_mesh_tower_v2_sdcard"], + "firmware_prefixes": ["heltec_tower_v2"] + }, + "651F8E496197F882": { + "board": "Heltec T096", + "bootloader_prefixes": ["heltec_t096"], + "firmware_prefixes": ["heltec_t096"] + }, + "34A9141999729D5D": { + "board": "Seeed T1000-E", + "bootloader_prefixes": ["t1000_e"], + "firmware_prefixes": ["t1000e", "seeed_t1000_e"] + }, + "0B81C9C68D8D01B4": { + "board": "RAK3401", + "bootloader_prefixes": ["wiscore_rak3401_auto"], + "firmware_prefixes": ["rak_3401"] + }, + "9AB3B64C641BA927": { + "board": "RAK4631", + "bootloader_prefixes": ["wiscore_rak4631_auto"], + "firmware_prefixes": ["rak_4631"] + }, + "93DA83ADF7B2092B": { + "board": "Heltec MeshPocket", + "bootloader_prefixes": ["heltec_mesh_pocket"], + "firmware_prefixes": ["mesh_pocket"] + } + } +} diff --git a/tools/hil/test_verified_nrf_flash.py b/tools/hil/test_verified_nrf_flash.py new file mode 100644 index 00000000..a1b30d20 --- /dev/null +++ b/tools/hil/test_verified_nrf_flash.py @@ -0,0 +1,45 @@ +"""The shared Heltec USB product string must never select a flash image.""" + +import json +from pathlib import Path +import unittest + +from tools.hil.verified_nrf_flash import verify + + +INVENTORY = json.loads((Path(__file__).parent / "mercerwood_pi_nrf_inventory.json").read_text()) + + +class VerifiedNrfFlashTest(unittest.TestCase): + def test_tower_accepts_tower_artifacts(self): + for artifact in ( + "heltec_mesh_tower_v2_sdcard_bootloader-OTAFIX2.4.8_s140_6.1.1.zip", + "Heltec_tower_v2_companion_radio_full-v1.17.1.7.zip", + ): + self.assertEqual(verify(INVENTORY, "9352162A72082314", + "Heltec MeshTower V2 SD", artifact), + "Heltec MeshTower V2 SD") + + def test_t114_image_refused_for_tower_serial(self): + with self.assertRaisesRegex(ValueError, "not a Heltec MeshTower V2 SD firmware artifact"): + verify(INVENTORY, "9352162A72082314", "Heltec MeshTower V2 SD", + "Heltec_t114_companion_radio_full-v1.17.1.7.uf2") + + def test_non_sd_bootloader_refused_for_sd_tower(self): + with self.assertRaisesRegex(ValueError, "not a Heltec MeshTower V2 SD bootloader artifact"): + verify(INVENTORY, "9352162A72082314", "Heltec MeshTower V2 SD", + "heltec_mesh_tower_v2_bootloader-OTAFIX2.4.8.zip") + + def test_t114_board_name_refused_for_tower_serial(self): + with self.assertRaisesRegex(ValueError, "is Heltec MeshTower V2 SD, not Heltec T114"): + verify(INVENTORY, "9352162A72082314", "Heltec T114", + "Heltec_t114_companion_radio_full-v1.17.1.7.uf2") + + def test_unknown_serial_refused(self): + with self.assertRaisesRegex(ValueError, "unrecorded USB serial"): + verify(INVENTORY, "UNKNOWN", "Heltec MeshTower V2 SD", + "Heltec_tower_v2_companion_radio_full.uf2") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/hil/verified_nrf_flash.py b/tools/hil/verified_nrf_flash.py new file mode 100644 index 00000000..f27a2eaa --- /dev/null +++ b/tools/hil/verified_nrf_flash.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Check a Pi-connected nRF52's *physical* serial before selecting a flash image. + +USB product names are not board identities: several Heltec nRF52 boards expose +the same ``HT-n5262`` product string. This gate requires a reviewed inventory +entry for the stable chip serial and checks the selected artifact against it. +Run it before either serial DFU or a UF2 copy. +""" + +import argparse +import json +import os +from pathlib import Path + + +def verify(inventory, serial_number, board, artifact_name): + devices = inventory.get("devices", {}) + serial_key = serial_number.upper() + device = next((entry for key, entry in devices.items() + if key.upper() == serial_key), None) + if device is None: + raise ValueError(f"unrecorded USB serial {serial_number}; identify the physical board first") + actual_board = device["board"] + if board != actual_board: + raise ValueError(f"serial {serial_number} is {actual_board}, not {board}") + artifact = Path(artifact_name).name.lower() + if artifact.startswith("update-"): + artifact = artifact[len("update-"):] + kind = "bootloader" if "_bootloader" in artifact else "firmware" + allowed = device.get(f"{kind}_prefixes", []) + if not any(artifact.startswith(prefix.lower() + "_") or + artifact.startswith(prefix.lower() + "-") for prefix in allowed): + raise ValueError(f"{artifact_name} is not a {actual_board} {kind} artifact; expected one of {allowed}") + return actual_board + + +def verify_port(port_path, serial_number): + try: + from serial.tools import list_ports + except ImportError as exc: + raise ValueError("pyserial is required to verify the connected USB port") from exc + expected = os.path.realpath(port_path) + matches = [port for port in list_ports.comports() + if os.path.realpath(port.device) == expected] + if len(matches) != 1: + raise ValueError(f"USB port {port_path} is absent or ambiguous") + observed = (matches[0].serial_number or "").upper() + if observed != serial_number.upper(): + raise ValueError(f"USB port {port_path} has serial {observed!r}, expected {serial_number}") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--inventory", type=Path, required=True) + parser.add_argument("--serial", required=True, help="Stable USB chip serial") + parser.add_argument("--board", required=True, help="Reviewed physical board name") + parser.add_argument("--artifact", type=Path, required=True) + parser.add_argument("--port", required=True, help="Current application or DFU serial port") + args = parser.parse_args(argv) + try: + inventory = json.loads(args.inventory.read_text()) + verify(inventory, args.serial, args.board, args.artifact.name) + verify_port(args.port, args.serial) + if not args.artifact.is_file(): + raise ValueError(f"artifact does not exist: {args.artifact}") + except (ValueError, OSError, KeyError, json.JSONDecodeError) as exc: + parser.exit(1, f"REFUSED: {exc}\n") + print(f"VERIFIED: {args.serial} = {args.board}; {args.artifact.name}; {args.port}") + + +if __name__ == "__main__": + main()