check.sh proves the protocol under simavr on every chip; it cannot prove a board. Two things live only on silicon — an RC oscillator that is not on its nominal, and a reset edge that has to come from somewhere — and until now the scripts that reached them were per-session scratch on the machine holding the programmer, which is where the ATtiny13A run's findings nearly stayed. pbrig.py is the primitives, knowing nothing per-board: every deployment fact is a flag or a PUREBOOT_* variable. Two rig facts are encoded in it because neither is guessable and each cost a session to learn: an ISP access *is* the reset edge where the adapter's DTR is unwired, so a session begins with an ISP touch and knocks immediately after; and avrdude splits -U on colons, so a Windows drive letter breaks the spec and every file goes as a bare name with avrdude run in its own directory. Its `rate` subcommand is the one that turns "the loader is silent, so the wiring must be wrong" into a number, by sweeping the host rate against a fixed cycles-per-bit transmitter — PUREBOOT_HEARTBEAT makes the existing fixture into one, software link only, since the hardware-link idle owes the self-update tests its command loop. pbhw.py takes every bound from the info block the loader reports, so one run covers a 1 KiB tiny and a 128 KiB mega alike. Both are exercised on an ATtiny13A: backup verified against a known-good capture, the clock measured at 9.048 MHz against a 9.6 MHz nominal, and the suite 11/11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
8.8 KiB
Python
Executable File
211 lines
8.8 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 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
|
|
|
|
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)
|