Files
bootloader/tools/pbhw.py
BlackMark b3f41caf6e audit: round three on the port — the hardware scan check fails soft
A rig hiccup mid-walk records the scan check as failed and lets the suite
continue, matching its siblings' envelope; a nonexistent --port path
reports as an error instead of a traceback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:31:32 +02:00

248 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""Hardware acceptance suite for a pureboot deployment.
`tools/check.sh` proves the protocol under simavr on every chip. This proves one
*board*: that the loader actually installed on it answers, that the memories
round-trip over the real link, that the application it flashes runs afterwards,
and that the refusals which keep a 512-byte slot alive still fire. Run it once
when a board is brought up, and again whenever the deployment moves — a new
clock, a new backend, new pins.
Every check derives its bounds from the info block the loader itself reports, so
nothing here is per-chip: the same run covers a 1 KiB tiny whose application
region is 510 usable bytes and a 128 KiB mega whose flash needs a bank in the
selector.
**This overwrites the board's application flash and EEPROM.** Capture them first
with `pbrig.py backup`, which verifies what it captured.
tools/pbhw.py --programmer atmelice_isp --part t13 --port COM6 \
--autobaud --loader build/ab.bin --app build/pbapp.hex \
--marker APP
"""
from __future__ import annotations
import argparse
import pathlib
import sys
import tempfile
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import pbrig # noqa: E402
class Suite:
def __init__(self, rig: pbrig.Rig, work: pathlib.Path):
self.rig = rig
self.work = work
self.results: list[tuple[str, bool, str]] = []
def check(self, name: str, ok: bool, detail: str = "") -> bool:
self.results.append((name, ok, detail))
print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" {detail}" if detail else ""))
return ok
@staticmethod
def _brief(text: str, limit: int = 78) -> str:
return " | ".join(l.strip() for l in text.splitlines() if l.strip())[:limit]
# ----------------------------------------------------------------- checks
def identity(self) -> object | None:
"""The info block, which every later check takes its bounds from."""
module = pbrig.load_pureboot(self.rig.d.pureboot)
self.rig.reset()
port = module.Port(self.rig.d.port, self.rig.d.baud)
try:
loader = module.Loader(port)
if self.rig.d.autobaud:
loader.connect_autobaud(self.rig.d.wait)
else:
loader.connect(self.rig.d.wait)
info = loader.info
self.check("identity read", True, info.describe())
return info
except Exception as error: # noqa: BLE001 — a dead link is a result
self.check("identity read", False, str(error)[:70])
return None
finally:
try:
port.close()
except Exception:
pass
def scan(self) -> None:
"""The --scan walk against real termios and a real oscillator: every
probe rate must open a port (the off-nominal rates exist only through
termios2), and one probe must answer — the nominal on a healthy board,
a neighbor on a drifted one. The rig injects the one reset per probe
the operator supplies in the field; this is the rate physics the
simulator cannot arbitrate (a pty carries bytes at any rate), pinned
on silicon."""
module = pbrig.load_pureboot(self.rig.d.pureboot)
found = None
try:
for pct in module.scan_ratios():
rate = module.scan_rate(self.rig.d.baud, pct)
self.rig.reset()
try:
port = module.Port(self.rig.d.port, rate)
except module.Error as error:
self.check("scan opens every probe rate", False, f"{rate} Bd: {error}")
return
try:
module.Loader(port).connect(min(self.rig.d.wait, 6.0))
found = pct
break
except module.Error:
continue
finally:
port.close()
except Exception as error: # noqa: BLE001 — a rig hiccup is a result
self.check("scan walks the probe ladder", False, str(error)[:70])
return
self.check("scan finds the board's rate", found is not None,
"no probe answered" if found is None else f"{found:+d} % of {self.rig.d.baud} Bd")
def eeprom(self, info) -> None:
size = info.eeprom_size
if not size:
print(" skip EEPROM (this part has none)")
return
# A pattern no erase or partial write could produce by accident.
pattern = bytes((i * 7 + 3) & 0xFF for i in range(size))
image = self.work / "ee.bin"
image.write_bytes(pattern)
rc, out = self.rig.pureboot("--eeprom", str(image), "--verify-eeprom", str(image))
self.check(f"EEPROM write + verify ({size} B)", rc == 0, self._brief(out))
back = self.work / "ee-back.bin"
rc, out = self.rig.pureboot("--read-eeprom", str(back))
got = back.read_bytes() if back.exists() else b""
self.check("EEPROM reads back what was written", got == pattern, f"{len(got)} B")
self.rig.pureboot("--erase-eeprom")
erased = self.work / "ee-erased.bin"
self.rig.pureboot("--read-eeprom", str(erased))
got = erased.read_bytes() if erased.exists() else b""
self.check("EEPROM erase leaves 0xff", got == b"\xff" * size, f"{len(got)} B")
def application(self, info, app: pathlib.Path, marker: str) -> None:
rc, out = self.rig.pureboot("--flash", str(app), "--verify-flash", str(app))
self.check(f"application flash + verify ({app.name})", rc == 0, self._brief(out))
if marker:
# The tool hands over as it ends its session, so the application is
# already running; opening the port does not reset a board whose DTR
# is unwired, so this simply listens.
data = self.rig.capture(seconds=2.5)
seen = marker.encode() in data
sample = "".join(chr(b) if 32 <= b < 127 else "." for b in data[:40])
self.check(f"application runs (emits {marker!r})", seen, f"|{sample}|")
back = self.work / "app-back.bin"
rc, out = self.rig.pureboot("--read-flash", str(back))
got = back.read_bytes() if back.exists() else b""
self.check("application flash reads back", rc == 0 and len(got) == info.base,
f"{len(got)} B of {info.base}")
def erase_and_guard(self, info, loader_image: pathlib.Path | None) -> None:
rc, out = self.rig.pureboot("--erase-flash")
self.check("application region erases", rc == 0, self._brief(out))
# The slot must be untouched by an application erase, which only an
# independent read can show — so this one goes over ISP, not the link.
whole = self.work / "whole.bin"
if not self.rig.read_memory("flash", whole, "r"):
self.check("loader slot survives the erase", False, "ISP read failed")
return
image = whole.read_bytes()
image += b"\xff" * (info.flash_size - len(image))
# Erased application flash, up to the trampoline word the host composes
# on a patched-vector part.
limit = info.base - 2 if info.patch_vector else info.base
self.check("erased application region is 0xff",
set(image[0:limit]) <= {0xFF}, f"0x0000..{limit:#06x}")
if loader_image and loader_image.exists():
want = loader_image.read_bytes()
got = image[info.base:info.base + len(want)]
self.check("loader slot survives the erase", got == want,
f"{len(want)} B at {info.base:#06x}")
else:
print(" skip loader slot comparison (pass --loader <image.bin>)")
def refusals(self, info) -> None:
# One word too many: a patched-vector part spends the slot's last word
# on the trampoline, so its application stops two bytes short.
limit = info.base - 2 if info.patch_vector else info.base
oversized = self.work / "oversized.bin"
oversized.write_bytes(bytes(limit + 2))
rc, out = self.rig.pureboot("--flash", str(oversized))
self.check(f"image over {limit} B refused", rc != 0, self._brief(out))
# ------------------------------------------------------------------- run
def run(self, app: pathlib.Path | None, loader_image: pathlib.Path | None,
marker: str) -> int:
print("identity")
info = self.identity()
if info is None:
print("\nthe loader never answered; nothing below can be trusted")
return 1
if not self.rig.d.autobaud:
print("\nscan")
self.scan()
print("\nEEPROM")
self.eeprom(info)
if app:
print("\napplication")
self.application(info, app, marker)
else:
print("\nskip application checks (pass --app <image.hex>)")
print("\nerase and the write guard")
self.erase_and_guard(info, loader_image)
print("\nrefusals")
self.refusals(info)
passed = sum(1 for _, ok, _ in self.results if ok)
print(f"\n{passed}/{len(self.results)} passed")
return 0 if passed == len(self.results) else 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="hardware acceptance suite for one pureboot deployment",
epilog="overwrites the board's application flash and EEPROM — back them up first")
pbrig.Deployment.add_arguments(parser)
parser.add_argument("--app", type=pathlib.Path,
help="application image to flash (test/pbapp.cpp built for this deployment)")
parser.add_argument("--loader", type=pathlib.Path,
help="the resident loader's .bin, to prove the slot survives an erase")
parser.add_argument("--marker", default="",
help="text the application emits when it runs, e.g. APP")
args = parser.parse_args(argv)
rig = pbrig.Rig(pbrig.Deployment.from_args(args))
print(f"rig: {args.part} on {args.programmer}, link {args.port} at {args.baud} Bd"
f"{' (autobaud)' if args.autobaud else ''}")
print("this overwrites the application flash and EEPROM\n")
with tempfile.TemporaryDirectory(prefix="pbhw-") as temporary:
return Suite(rig, pathlib.Path(temporary)).run(args.app, args.loader, args.marker)
if __name__ == "__main__":
try:
sys.exit(main())
except pbrig.Error as error:
print(f"error: {error}", file=sys.stderr)
sys.exit(2)