tools: a hardware harness, so a board can be proven and not just a protocol

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>
This commit is contained in:
2026-07-27 16:05:45 +02:00
committed by BlackMark
parent e89000f73e
commit 433bec3e58
5 changed files with 697 additions and 1 deletions

210
tools/pbhw.py Executable file
View File

@@ -0,0 +1,210 @@
#!/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)

427
tools/pbrig.py Executable file
View File

@@ -0,0 +1,427 @@
#!/usr/bin/env python3
"""Hardware rig driver for pureboot: an ISP programmer beside a serial link.
The simulated suites (`test/pb*.py`) prove the protocol; this drives the same
loader on real silicon, where the things a cycle-exact simulator cannot model
live — an RC oscillator off its nominal, a reset edge that has to come from
somewhere, a serial bridge with its own idea of what a baud is.
Nothing here knows a port name, a part or a programmer. Every deployment fact
arrives from the command line or the environment, so the same script serves any
board: see `Deployment`. As a module it is the reset/flash/talk primitives that
`pbhw.py` builds its acceptance suite from; as a command it is the handful of
one-shot operations worth having on a rig — most importantly `backup`, which is
the only thing standing between a fuse experiment and an unrecoverable part.
Two rig facts are encoded here because they are not guessable and cost a
session each to learn:
* **An ISP access resets the part**, and it runs again the moment the programmer
releases it. That is the only reset edge available when the serial adapter's
DTR is not wired to reset — so a loader session begins with an ISP touch and
knocks immediately after, which is what `Rig.pureboot()` does.
* **avrdude splits `-U memory:op:file:format` on colons**, so a Windows path's
drive letter breaks the spec. Every file argument is therefore passed as a
bare filename with avrdude run in that file's own directory.
"""
from __future__ import annotations
import argparse
import dataclasses
import importlib.util
import os
import pathlib
import subprocess
import sys
import time
HERE = pathlib.Path(__file__).resolve().parent
DEFAULT_PUREBOOT = HERE.parent / "pureboot" / "pureboot.py"
# Memories worth capturing before an experiment, and the format each is read in.
# Fuses and lock are per-part: a part without an extended fuse simply fails that
# one read, which `backup` reports and steps over rather than aborting on.
BACKUP_MEMORIES = (
("flash", "i", "hex"),
("flash", "r", "bin"),
("eeprom", "i", "hex"),
("eeprom", "r", "bin"),
("lfuse", "h", "hex"),
("hfuse", "h", "hex"),
("efuse", "h", "hex"),
("lock", "h", "hex"),
("calibration", "h", "hex"),
)
class Error(Exception):
pass
def bitclock_for(hz: int) -> str:
"""A safe ISP bitclock for a part *currently running* at `hz`.
SCK must stay under a quarter of the target clock, so the bitclock follows
the clock in force — not the one about to be fused in. Halving that ceiling
again costs nothing on a link that moves a few hundred bytes and buys margin
against an oscillator that is already known to be off its nominal.
"""
ceiling = hz // 8
for candidate in (1000, 4000, 8000, 32000, 125000, 400000):
if candidate <= ceiling:
best = candidate
else:
break
else:
best = 400000
if ceiling < 1000:
raise Error(f"a part at {hz} Hz is too slow to reach over ISP safely")
return f"{best // 1000}kHz"
@dataclasses.dataclass
class Deployment:
"""Everything about one board. No default names a real device."""
port: str = "" # serial device the loader speaks on
baud: int = 57600 # host rate; for autobaud, the rate to drive
autobaud: bool = False # send the calibration pulse instead of p+b
programmer: str = "" # avrdude -c
part: str = "" # avrdude -p
avrdude: str = "avrdude"
bitclock: str = "125kHz" # see bitclock_for()
pureboot: pathlib.Path = DEFAULT_PUREBOOT
wait: int = 12 # seconds the host keeps knocking
@classmethod
def from_env(cls) -> "Deployment":
"""Environment defaults, so a rig's facts live in one place per machine."""
return cls(
port=os.environ.get("PUREBOOT_PORT", ""),
baud=int(os.environ.get("PUREBOOT_BAUD", "57600")),
autobaud=os.environ.get("PUREBOOT_AUTOBAUD", "") not in ("", "0"),
programmer=os.environ.get("PUREBOOT_PROGRAMMER", ""),
part=os.environ.get("PUREBOOT_PART", ""),
avrdude=os.environ.get("AVRDUDE", "avrdude"),
bitclock=os.environ.get("PUREBOOT_BITCLOCK", "125kHz"),
pureboot=pathlib.Path(os.environ.get("PUREBOOT_TOOL", str(DEFAULT_PUREBOOT))),
)
@staticmethod
def add_arguments(parser: argparse.ArgumentParser) -> None:
"""Deployment flags, shared by this tool and pbhw.py."""
env = Deployment.from_env()
parser.add_argument("--port", default=env.port, help="serial device the loader speaks on")
parser.add_argument("--baud", type=int, default=env.baud,
help="host rate (for autobaud, the rate to drive)")
parser.add_argument("--autobaud", action="store_true", default=env.autobaud,
help="send the calibration pulse instead of the p+b knock")
parser.add_argument("--programmer", default=env.programmer, help="avrdude -c, e.g. atmelice_isp")
parser.add_argument("--part", default=env.part, help="avrdude -p, e.g. t13 or m328p")
parser.add_argument("--avrdude", default=env.avrdude, help="path to avrdude")
parser.add_argument("--bitclock", default=env.bitclock, help="ISP bitclock, e.g. 125kHz or 8kHz")
parser.add_argument("--pureboot", type=pathlib.Path, default=env.pureboot,
help="path to pureboot.py")
parser.add_argument("--wait", type=int, default=env.wait, help="seconds to keep knocking")
@classmethod
def from_args(cls, args: argparse.Namespace) -> "Deployment":
return cls(port=args.port, baud=args.baud, autobaud=args.autobaud,
programmer=args.programmer, part=args.part, avrdude=args.avrdude,
bitclock=args.bitclock, pureboot=args.pureboot, wait=args.wait)
def load_pureboot(path: pathlib.Path = DEFAULT_PUREBOOT):
"""The host tool as a module — its Port and Loader, not a subprocess.
Used where a subprocess cannot express what is needed: a poke followed by a
peek in the *same* session, or a raw read at an arbitrary baud.
"""
spec = importlib.util.spec_from_file_location("pureboot", path)
if spec is None or spec.loader is None:
raise Error(f"cannot load the host tool from {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class Rig:
"""One board: its programmer on one side, its serial link on the other."""
def __init__(self, deployment: Deployment):
self.d = deployment
if not deployment.programmer or not deployment.part:
raise Error("a rig needs --programmer and --part")
# ------------------------------------------------------------- programmer
def avrdude(self, *args: str, cwd: pathlib.Path | None = None,
bitclock: str | None = None, timeout: int = 300) -> subprocess.CompletedProcess:
command = [self.d.avrdude, "-c", self.d.programmer, "-p", self.d.part,
"-B", bitclock or self.d.bitclock, *args]
return subprocess.run(command, capture_output=True, text=True,
cwd=None if cwd is None else str(cwd), timeout=timeout)
@staticmethod
def _ok(result: subprocess.CompletedProcess) -> bool:
return result.returncode == 0
def reset(self, bitclock: str | None = None) -> None:
"""An ISP access, which resets the part; it runs when avrdude exits."""
self.avrdude("-U", "signature:r:-:h", bitclock=bitclock)
def signature(self, bitclock: str | None = None) -> str:
result = self.avrdude("-U", "signature:r:-:h", bitclock=bitclock)
for line in reversed(result.stdout.splitlines()):
if line.strip().startswith("0x"):
return line.strip()
raise Error(f"no signature read: {(result.stderr or result.stdout).strip()[:200]}")
def read_memory(self, memory: str, destination: pathlib.Path, fmt: str = "r",
bitclock: str | None = None) -> bool:
"""Read `memory` into `destination`, whose directory avrdude runs in."""
destination = pathlib.Path(destination).resolve()
destination.parent.mkdir(parents=True, exist_ok=True)
result = self.avrdude("-U", f"{memory}:r:{destination.name}:{fmt}",
cwd=destination.parent, bitclock=bitclock)
# A memory the part does not have (a tiny's extended fuse) leaves avrdude
# happy and the file empty. An empty capture is a miss, not a backup.
return self._ok(result) and destination.exists() and destination.stat().st_size > 0
def write_memory(self, memory: str, source: pathlib.Path, fmt: str = "i",
erase: bool = False, bitclock: str | None = None) -> bool:
source = pathlib.Path(source).resolve()
args = ["-U", f"{memory}:w:{source.name}:{fmt}"]
if erase:
args.insert(0, "-e")
result = self.avrdude(*args, cwd=source.parent, bitclock=bitclock)
return "verified" in (result.stdout + result.stderr)
def flash_hex(self, image: pathlib.Path, erase: bool = True,
bitclock: str | None = None) -> bool:
return self.write_memory("flash", image, "i", erase=erase, bitclock=bitclock)
def read_fuses(self, bitclock: str | None = None) -> dict[str, str]:
out: dict[str, str] = {}
for fuse in ("lfuse", "hfuse", "efuse", "lock"):
result = self.avrdude("-U", f"{fuse}:r:-:h", bitclock=bitclock)
values = [l.strip() for l in result.stdout.splitlines() if l.strip().startswith("0x")]
if values:
out[fuse] = values[-1]
return out
def write_fuses(self, bitclock: str | None = None, **fuses: str) -> bool:
"""Write named fuses. A fuse change moves the clock the *next* access is
timed against, so pass a bitclock safe for both sides of the change."""
args: list[str] = []
for name, value in fuses.items():
args += ["-U", f"{name}:w:{value}:m"]
if not args:
return True
result = self.avrdude(*args, bitclock=bitclock)
text = result.stdout + result.stderr
return "verified" in text or "written" in text
# ------------------------------------------------------------ backup
def backup(self, directory: pathlib.Path, prefix: str = "") -> dict[str, bool]:
"""Capture every memory worth keeping, then prove it by a second read.
A backup nobody verified is a guess. Each memory is read twice and the
two reads compared; a mismatch is reported rather than quietly stored.
"""
directory = pathlib.Path(directory).resolve()
directory.mkdir(parents=True, exist_ok=True)
stem = prefix or self.d.part
status: dict[str, bool] = {}
for memory, fmt, extension in BACKUP_MEMORIES:
name = f"{stem}-{memory}.{extension}"
if not self.read_memory(memory, directory / name, fmt):
status[f"{memory}.{extension}"] = False
continue
if extension == "bin": # only the raw form is worth comparing byte-wise
again = directory / f".{name}.again"
self.read_memory(memory, again, fmt)
same = again.exists() and again.read_bytes() == (directory / name).read_bytes()
again.unlink(missing_ok=True)
status[f"{memory}.{extension}"] = same
else:
status[f"{memory}.{extension}"] = True
return status
# ------------------------------------------------------------ serial link
def pureboot(self, *args: str, reset_first: bool = True, baud: int | None = None,
autobaud: bool | None = None, timeout: int = 300,
bitclock: str | None = None) -> tuple[int, str]:
"""Reset, then knock immediately — see the module docstring.
Returns the host tool's exit status and its combined output, so a caller
can assert on what it printed as well as on whether it succeeded.
"""
if reset_first:
self.reset(bitclock=bitclock)
command = [sys.executable, str(self.d.pureboot), "--port", self.d.port,
"--baud", str(self.d.baud if baud is None else baud),
"--wait", str(self.d.wait)]
if self.d.autobaud if autobaud is None else autobaud:
command.append("--autobaud")
command += [str(a) for a in args]
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired as expired:
return 99, f"TIMEOUT after {timeout}s\n{expired.stdout or ''}{expired.stderr or ''}"
return result.returncode, (result.stdout or "") + (result.stderr or "")
def capture(self, seconds: float = 2.0, baud: int | None = None) -> bytes:
"""Listen to whatever the board is saying, at an arbitrary rate.
Opening the port does not reset a board whose DTR is unwired, so this can
sample a running application repeatedly without disturbing it — which is
what makes the rate sweep below possible.
"""
module = load_pureboot(self.d.pureboot)
port = module.Port(self.d.port, self.d.baud if baud is None else baud)
try:
data = b""
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
chunk = port.read_available(0.2)
if chunk:
data += chunk
return data
finally:
try:
port.close()
except Exception:
pass
def measure_rate(rig: Rig, marker: bytes, built_baud: int, nominal_hz: int | None = None,
span_percent: float = 12.0, step_percent: float = 0.5,
seconds: float = 0.75) -> dict:
"""Find a transmitting board's true bit rate, using only the serial port.
The board must be emitting something recognisable at a *fixed* cycles-per-bit
— `test/pbapp.cpp` built with PUREBOOT_HEARTBEAT does. Since its bit timing is
a cycle count, its wire rate scales with its actual clock, so the host rates
at which `marker` still decodes bracket that rate; the centre of the band is
the answer, and with the clock the image was built for it gives the real one.
This is the measurement that turns "the loader is silent, so the wiring must
be wrong" into a number, and it needs no instrument beyond the adapter
already attached.
"""
steps = int(span_percent / step_percent)
clean: list[int] = []
samples: list[tuple[int, int, bool]] = []
for index in range(-steps, steps + 1):
baud = int(round(built_baud * (1 + index * step_percent / 100.0)))
if baud <= 0:
continue
data = rig.capture(seconds=seconds, baud=baud)
hit = marker in data
samples.append((baud, len(data), hit))
if hit:
clean.append(baud)
result: dict = {"samples": samples, "clean": clean, "built_baud": built_baud}
if clean:
low, high = min(clean), max(clean)
centre = (low + high) / 2.0
result |= {"low": low, "high": high, "centre": centre,
"half_width_percent": (high - low) / 2.0 / centre * 100.0,
"error_percent": (centre / built_baud - 1.0) * 100.0}
if nominal_hz:
result["measured_hz"] = nominal_hz * centre / built_baud
return result
# ------------------------------------------------------------------- command
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="pureboot hardware rig: ISP reset/flash beside the serial link")
Deployment.add_arguments(parser)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("signature", help="read the part signature over ISP")
sub.add_parser("reset", help="reset the part (an ISP access) and let it run")
sub.add_parser("fuses", help="read the fuse and lock bytes")
p = sub.add_parser("flash", help="program a hex image over ISP")
p.add_argument("image", type=pathlib.Path)
p.add_argument("--no-erase", action="store_true", help="do not chip-erase first")
p = sub.add_parser("backup", help="capture and verify every memory")
p.add_argument("directory", type=pathlib.Path)
p.add_argument("--prefix", default="", help="filename stem (default: the part name)")
p = sub.add_parser("rate", help="measure the board's true bit rate and clock")
p.add_argument("--marker", default="APP", help="text the board emits (default: APP)")
p.add_argument("--built-baud", type=int, required=True,
help="the baud the running image was built for")
p.add_argument("--nominal-hz", type=int, default=0,
help="the clock the image was built for, to report the real one")
p.add_argument("--span", type=float, default=12.0, help="sweep +-this many percent")
p.add_argument("--step", type=float, default=0.5, help="sweep step in percent")
p.add_argument("--verbose", action="store_true", help="print every step")
p = sub.add_parser("bitclock", help="a safe ISP bitclock for a clock in force")
p.add_argument("hz", type=int)
args = parser.parse_args(argv)
if args.command == "bitclock":
print(bitclock_for(args.hz))
return 0
rig = Rig(Deployment.from_args(args))
if args.command == "signature":
print(rig.signature())
elif args.command == "reset":
rig.reset()
print("reset")
elif args.command == "fuses":
for name, value in rig.read_fuses().items():
print(f"{name:<6} {value}")
elif args.command == "flash":
ok = rig.flash_hex(args.image, erase=not args.no_erase)
print(f"{args.image.name}: {'verified' if ok else 'FAILED'}")
return 0 if ok else 1
elif args.command == "backup":
status = rig.backup(args.directory, args.prefix)
for name, ok in status.items():
print(f" {'ok ' if ok else 'FAIL'} {name}")
missing = [n for n, ok in status.items() if not ok]
# Fuses a part does not have are expected misses, not failures.
fatal = [n for n in missing if not n.startswith(("efuse", "calibration"))]
print(f"\n{len(status) - len(missing)}/{len(status)} captured into {args.directory}")
return 1 if fatal else 0
elif args.command == "rate":
result = measure_rate(rig, args.marker.encode(), args.built_baud,
args.nominal_hz or None, args.span, args.step)
if args.verbose:
for baud, size, hit in result["samples"]:
print(f" {baud:7d} Bd {size:5d} B {'MARKER' if hit else ''}")
if not result["clean"]:
print(f"no capture contained {args.marker!r} at any rate — is the board "
f"transmitting, and on the pin this port is wired to?")
return 1
print(f"clean band {result['low']}..{result['high']} Bd")
print(f"centre {result['centre']:.0f} Bd "
f"(+-{result['half_width_percent']:.1f} %)")
print(f"vs built {result['built_baud']} Bd ({result['error_percent']:+.1f} %)")
if "measured_hz" in result:
print(f"true clock {result['measured_hz'] / 1e6:.3f} MHz")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Error as error:
print(f"error: {error}", file=sys.stderr)
sys.exit(2)