--scan's walk was unwalkable on POSIX: probe rates have no termios B-constant, so the first off-nominal probe raised out of the loop. The port speaks termios2 BOTHER now (red-proven on a pty at 9984 Bd), the probe's open lives inside the walk's error handling, an fd no longer leaks on an unmakeable rate, and the swallowed unknown-signature reply is named at timeout instead of reported as silence. CMakePresets.json's generator emits the submodule toolchain path it had drifted from — a hand edit on a generated file, exactly the class rule 10 exists for — and presets.generated gates the pair from here on (the marker CMake rejects at the presets root stayed out; the check is the guard). The over-slot image guard the tsb runner gained reaches the pureboot runner too; the GPIO bridge's delivery comment states the hardware truth (RXC at the stop bit's sampling point); the hardware suite gains the scan check — the one place the rate physics is real; and the libavr pin advances over both audit rounds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
244 lines
10 KiB
Python
Executable File
244 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
|
|
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()
|
|
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)
|