diff --git a/libavr b/libavr index 8840327..1dacc75 160000 --- a/libavr +++ b/libavr @@ -1 +1 @@ -Subproject commit 88403275ac975a2793fdbd6e6086db4c39fe75d8 +Subproject commit 1dacc75c670d9c03e780aa9ce364e0dcb0f3bf8d diff --git a/pureboot/README.md b/pureboot/README.md index fe61f58..c5b4ecd 100644 --- a/pureboot/README.md +++ b/pureboot/README.md @@ -470,3 +470,43 @@ Per chip preset, `ctest` runs: `size`, `pi`, `planner` and `handshake` are host logic and run anywhere; the simulator-driven targets need simavr and a pty, so they are POSIX-only. + +## Hardware + +The suite above proves the protocol 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. `tools/pbrig.py` and +`tools/pbhw.py` cover that, and know nothing per-board — every deployment fact +is a flag or a `PUREBOOT_*` environment variable. + +```sh +export PUREBOOT_PROGRAMMER=atmelice_isp PUREBOOT_PART=t13 PUREBOOT_PORT=COM6 +tools/pbrig.py backup rig-backup/ # verified, before anything is written +tools/pbhw.py --autobaud --loader build/ab.bin --app build/pbapp.hex --marker APP +``` + +`pbrig.py` is the primitives — `signature`, `reset`, `flash`, `fuses`, `backup`, +`rate` — and the module `pbhw.py` builds on. Two rig facts are encoded in it +because neither is guessable: an **ISP access is the reset edge** (the part runs +the moment the programmer releases it, which is the only edge available when the +adapter's DTR is not wired to reset, so a session begins with an ISP touch and +knocks immediately after), and **avrdude splits `-U` on colons**, so a Windows +path's drive letter breaks the spec and every file is passed as a bare name with +avrdude run in its own directory. + +`pbrig.py rate` is the one that turns "the loader is silent, so the wiring must +be wrong" into a number. Against a fixture built with `PUREBOOT_HEARTBEAT` — a +*fixed* cycles-per-bit transmitter — it sweeps the host rate, and the band where +the marker still decodes brackets the part's true bit rate; with the clock the +image was built for, that is the clock the part is really running at. No +instrument beyond the adapter already attached. An ATtiny13A measured this way +came out at 9.072 MHz against its 9.6 MHz nominal, −5.5 % — inside the +datasheet's ±10 % and outside what an 8N1 frame survives, which is the whole +case for the autobaud backend on such a part. + +`pbhw.py` takes its bounds from the info block the loader reports, so one run +covers a 1 KiB tiny and a 128 KiB mega alike: identity, the EEPROM round trip +and erase, an application flashed and verified and then *seen running*, the +application region read and erased, the loader slot proven intact across that +erase by an independent ISP read, and an oversized image refused. It overwrites +the application flash and EEPROM, which is why `backup` comes first. diff --git a/test/pbapp.cpp b/test/pbapp.cpp index e79c426..0031238 100644 --- a/test/pbapp.cpp +++ b/test/pbapp.cpp @@ -117,8 +117,27 @@ struct link { } [[noreturn]] static void idle() { +#if defined(PUREBOOT_HEARTBEAT) + // Repeat the banner forever, which turns the fixture into a fixed + // cycles-per-bit transmitter: `tools/pbrig.py rate` sweeps the host rate + // against it to find the part's true bit rate, and from that the clock + // its RC oscillator is really running at. Only the *bit* timing carries + // the measurement — the delay merely spaces the lines out, so its own + // error does not matter. Software link only: the hardware-link idle owes + // the self-update tests a command loop, and a crystal deployment has + // nothing to measure. + while (true) { + tx('A'); + tx('P'); + tx('P'); + tx('\r'); + tx('\n'); + dev::delay<50_ms>(); + } +#else while (true) { } +#endif } }; diff --git a/tools/pbhw.py b/tools/pbhw.py new file mode 100755 index 0000000..09eff88 --- /dev/null +++ b/tools/pbhw.py @@ -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 )") + + 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 )") + + 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) diff --git a/tools/pbrig.py b/tools/pbrig.py new file mode 100755 index 0000000..2a11edf --- /dev/null +++ b/tools/pbrig.py @@ -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)