audit: the port's pass — the scan that could not walk, and the drift a generator ends

--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>
This commit is contained in:
2026-07-28 10:12:17 +02:00
parent 1b18f10f4e
commit 7716e1e291
8 changed files with 193 additions and 53 deletions

View File

@@ -66,16 +66,18 @@ function(add_image_outputs name)
$<TARGET_FILE:${name}> $<TARGET_FILE:${name}>.bin) $<TARGET_FILE:${name}> $<TARGET_FILE:${name}>.bin)
endfunction() endfunction()
# The TinySafeBoot protocol reimplemented on libavr in three variants that trade # The TinySafeBoot protocol reimplemented on libavr in variants that trade
# clarity for size. Each links into the ATmega328P boot section (BOOTSZ selects # clarity for size. Each links into the ATmega328P boot section (BOOTSZ selects
# its size; BOOTRST vectors a reset to its base) with -nostartfiles — a polled # its size; BOOTRST vectors a reset to its base) with -nostartfiles — a polled
# loader has no use for the crt or the vector table. The naked entry sits in # loader has no use for the crt or the vector table. The entry sits in
# .vectors, laid first, and runs. The boot base is FLASHEND+1 minus the section # .vectors, laid first, and runs — avr::startup::entry on the policy tier,
# size; the linker section-start and the source's boot_bytes agree. tsb_app is # the experiment tiers' own naked stubs elsewhere, each documented in its
# source. The boot base is FLASHEND+1 minus the section size; the linker
# section-start and the source's boot_bytes agree. tsb_app is
# the application's reset vector, pinned to 0 here so the loaders jump to a # the application's reset vector, pinned to 0 here so the loaders jump to a
# named function; --pmem-wrap-around lets relaxation turn that absolute jump # named function; --pmem-wrap-around lets relaxation turn that absolute jump
# into the wrapped rjmp AVR's modulo-flash PC actually executes. # into the wrapped rjmp AVR's modulo-flash PC actually executes.
# All three implement the full oracle feature set (see oracle/README.md): # All four implement the full oracle feature set (see oracle/README.md):
# watchdog bail, one-wire half-duplex, config-page activation timeout, password # watchdog bail, one-wire half-duplex, config-page activation timeout, password
# gate, emergency erase, config/flash/EEPROM read-write. They differ only in how, # gate, emergency erase, config/flash/EEPROM read-write. They differ only in how,
# and the size gradient is the cost of that "how" — see dev/lessons.md. # and the size gradient is the cost of that "how" — see dev/lessons.md.
@@ -170,6 +172,11 @@ if(PROJECT_IS_TOP_LEVEL)
add_test(NAME pureboot.scan add_test(NAME pureboot.scan
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_scan.py COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_scan.py
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py) ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
# CMakePresets.json is generated; hand edits drift the moment the
# generator reruns, so the gate holds the pair together.
add_test(NAME presets.generated
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/make_presets.py
--check)
add_test(NAME pureboot.handshake add_test(NAME pureboot.handshake
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_handshake.py) COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_handshake.py)
add_test(NAME pureboot.updatelink add_test(NAME pureboot.updatelink
@@ -509,9 +516,9 @@ if(PROJECT_IS_TOP_LEVEL)
set_tests_properties(pureboot.usart1 PROPERTIES TIMEOUT 180) set_tests_properties(pureboot.usart1 PROPERTIES TIMEOUT 180)
endif() endif()
# The autobaud variants driven end to end over the software-UART bridge (both # The autobaud loader driven end to end over the software-UART bridge:
# under review — pureboot/autobaud.md): the host sends the 0xC0 calibration # the host sends the 0xC0 calibration pulse, the loader times it, locks,
# pulse, the loader times it, locks, and programs. Run on the near-flash 328P # and programs. Run on the near-flash 328P
# and the word-addressed 1284P — the two flash-addressing classes — and each # and the word-addressed 1284P — the two flash-addressing classes — and each
# at two clocks with the one binary, which is the clock-agnostic property # at two clocks with the one binary, which is the clock-agnostic property
# autobaud exists for (test/pbautobaud.py). The fixture application banners # autobaud exists for (test/pbautobaud.py). The fixture application banners

2
libavr

Submodule libavr updated: 43b1f34ed1...d952c7e317

View File

@@ -48,12 +48,14 @@ it carries the calibration machinery and no clock at all.
is 510 bytes, since the slot's last word is the trampoline. is 510 bytes, since the slot's last word is the trampoline.
The tightest fit in the whole space is the 1284s' autobaud build deployed on a The tightest fit in the whole space is the 1284s' autobaud build deployed on a
USART's own pins, 506 of its 512 — they alone carry the far-flash machinery USART's own pins with the `OSCCAL` trim baked, 510 of its 512 — they alone
(ELPM reads, RAMPZ page commands), autobaud alone carries the calibration loop, carry the far-flash machinery (ELPM reads, RAMPZ page commands), autobaud
and a bit-banged link on a USART's pins alone has to release it (below). The alone carries the calibration loop, a bit-banged link on a USART's pins alone
same build on the default pins is 502. The flash bank riding in a transfer's has to release it (below), and the trim adds its one register write. Without
selector byte keeps even those chips' addressing the same 16-bit form every the trim that build is 504; on the default pins, 502. The flash bank riding
other chip uses, which is why they are no longer the outlier they were. in a transfer's selector byte keeps even those chips' addressing the same
16-bit form every other chip uses, which is why they are no longer the
outlier they were.
The software UART enables the RX pull-up; TX idles high. All multi-byte wire The software UART enables the RX pull-up; TX idles high. All multi-byte wire
quantities are little-endian. quantities are little-endian.
@@ -123,12 +125,13 @@ window per reset. Measure with an application in place.
A downstream project brings its usual libavr setup (the `libavr` target, the A downstream project brings its usual libavr setup (the `libavr` target, the
chip via the `LIBAVR_MCU` toolchain preset), consumes this directory, and chip via the `LIBAVR_MCU` toolchain preset), consumes this directory, and
states its deployment — an ATmega328P on its shipped 1 MHz fuses with the states its deployment — an ATmega328P on its shipped 1 MHz fuses with the
software UART on hand-picked pins, say: software UART on hand-picked pins, say. A submodule pins the loader version
(the tags name them; this repo pins its own libavr the same way), where
FetchContent tracks whatever `main` is:
```cmake ```cmake
FetchContent_Declare(bootloader GIT_REPOSITORY git@git.blackmark.me:avr/bootloader.git GIT_TAG main) # git submodule add <forge>/avr/bootloader.git bootloader — or FetchContent
FetchContent_MakeAvailable(bootloader) add_subdirectory(bootloader/pureboot pureboot)
add_subdirectory(${bootloader_SOURCE_DIR}/pureboot pureboot)
pureboot_add_loader(myboot CLOCK 1000000 SERIAL software TX pb1 RX pb5) pureboot_add_loader(myboot CLOCK 1000000 SERIAL software TX pb1 RX pb5)
``` ```
@@ -512,7 +515,12 @@ Per chip preset, `ctest` runs:
too. The timeout is a constant and is no axis; too. The timeout is a constant and is no axis;
- `pureboot_autobaud.size` — the clock-free build, which has no clock or baud - `pureboot_autobaud.size` — the clock-free build, which has no clock or baud
axis of its own: one binary per chip has to serve every point the matrix axis of its own: one binary per chip has to serve every point the matrix
below sweeps; below sweeps. `pureboot*osccal*.size` add the `OSCCAL` trim on the stock
shape and on the tightest image in the space (autobaud on a USART's own
pins), holding both of the trim write's addressing encodings to the budget;
- `pureboot_autobaud.unit` — the measured bit period is the loader's only RAM
object and sits exactly at ram_start, where `--info` reads it: wire
contract, not layout accident;
- `pbm_*.size` — with `PUREBOOT_FULL_MATRIX=1`, the exhaustive cross product - `pbm_*.size` — with `PUREBOOT_FULL_MATRIX=1`, the exhaustive cross product
replacing that compact matrix, on **every** chip: every plausible oscillator replacing that compact matrix, on **every** chip: every plausible oscillator
(the internal ones, the CKDIV8 floor, the plain and the UART crystals) × (the internal ones, the CKDIV8 floor, the plain and the UART crystals) ×
@@ -535,6 +543,13 @@ Per chip preset, `ctest` runs:
recovery properties, the surgery, the staging composition, the boot-fuse recovery properties, the surgery, the staging composition, the boot-fuse
decode, the update preflight over synthetic fuse bytes, and the repairing decode, the update preflight over synthetic fuse bytes, and the repairing
verify against a fake device; verify against a fake device;
- `pureboot.scan``--scan`'s walk and report logic: the probe order, the
rate arithmetic, and the trim advice's direction. A pty carries bytes at
any termios rate, so the rate physics itself belongs to the hardware
harness, and what the wire would arbitrate is pinned as logic;
- `presets.generated` — CMakePresets.json matches its generator
(`tools/make_presets.py --check`), so a hand edit or a generator change
cannot drift the pair apart;
- `pureboot.protocol` — end to end against a simavr device - `pureboot.protocol` — end to end against a simavr device
(`test/pureboot_device.c`: a hardware USART as a pty, or a cycle-timed (`test/pureboot_device.c`: a hardware USART as a pty, or a cycle-timed
GPIO⇄pty bridge for a software-UART build, plus the SPM/NVM module simavr's GPIO⇄pty bridge for a software-UART build, plus the SPM/NVM module simavr's
@@ -567,15 +582,21 @@ Per chip preset, `ctest` runs:
- `pureboot.update` — the full `--update-loader` flow, then every power-fail - `pureboot.update` — the full `--update-loader` flow, then every power-fail
phase: the device is killed mid-write, restarted from its flash dump, and a phase: the device is killed mid-write, restarted from its flash dump, and a
re-run must complete the update with the application intact; re-run must complete the update with the application intact;
- `pureboot.osccal` (328P, t85) — a loader built with the `OSCCAL` axis holds
the trim register at the built byte from its first prompt, observed through
the wire on one chip per addressing encoding (`sts` and low-I/O `out`);
- `pureboot.autobaud` (328P, 1284P) — the clock-free build over the GPIO⇄pty - `pureboot.autobaud` (328P, 1284P) — the clock-free build over the GPIO⇄pty
bridge: the calibration handshake, a flash + EEPROM + fuse round trip against bridge: the calibration handshake, a flash + EEPROM + fuse round trip against
the simulator's own memory, a data-space round trip, the hand-over — then the the simulator's own memory, a data-space round trip, the hand-over — then the
same binary again at double the clock, which is the property the backend same binary again at double the clock, which is the property the backend
exists for. A lone calibration pulse with no knock behind it must still let exists for. The measured clock `--info` prints is asserted against the
simulator's exact clock, inside the unit encoding's own envelope, at both
points. A lone calibration pulse with no knock behind it must still let
the application boot, so no wait in activation can be unbounded. the application boot, so no wait in activation can be unbounded.
`size`, `pi`, `planner` and `handshake` are host logic and run anywhere; the `size`, `unit`, `pi`, `planner`, `scan` and `handshake` are host logic and run
simulator-driven targets need simavr and a pty, so they are POSIX-only. anywhere; the simulator-driven targets need simavr and a pty, so they are
POSIX-only.
## Hardware ## Hardware

View File

@@ -20,6 +20,8 @@ if os.name == "nt":
import ctypes import ctypes
from ctypes import wintypes from ctypes import wintypes
else: else:
import array
import fcntl
import select import select
import termios import termios
@@ -158,36 +160,60 @@ class Progress:
class PosixPort: class PosixPort:
"""A raw serial port with deadline-based reads, over termios.""" """A raw serial port with deadline-based reads, over termios. A rate with
no B-constant — the off-nominal probes `--scan` walks — goes through
Linux's termios2 BOTHER; a platform without that ioctl refuses the rate
by name."""
# The termios2 ioctl pair and cflag bits, and the struct's ispeed/ospeed
# word offsets: four flag words, then a line-discipline byte and 19
# control chars padded to word 9 (include/uapi/asm-generic/termbits.h).
_TCGETS2, _TCSETS2 = 0x802C542A, 0x402C542B
_BOTHER, _CBAUD = 0o010000, 0o010017
_ISPEED, _OSPEED = 9, 10
@staticmethod @staticmethod
def _speed(baud): def _speed(baud):
return getattr(termios, f"B{baud}", None)
def _set_arbitrary(self, baud):
buf = array.array("i", [0] * (self._OSPEED + 1))
try: try:
return getattr(termios, f"B{baud}") fcntl.ioctl(self.fd, self._TCGETS2, buf, True)
except AttributeError: buf[2] = (buf[2] & ~self._CBAUD) | self._BOTHER
raise Error(f"unsupported baud rate {baud}") from None buf[self._ISPEED] = buf[self._OSPEED] = baud
fcntl.ioctl(self.fd, self._TCSETS2, buf)
except OSError:
raise Error(f"this platform cannot set {baud} Bd (no termios2)") from None
def _apply_baud(self, attrs, baud):
speed = self._speed(baud)
attrs[4] = attrs[5] = speed if speed is not None else termios.B38400
termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
if speed is None:
self._set_arbitrary(baud)
self.baud = baud
def __init__(self, path, baud): def __init__(self, path, baud):
self.fd = os.open(path, os.O_RDWR | os.O_NOCTTY) self.fd = os.open(path, os.O_RDWR | os.O_NOCTTY)
attrs = termios.tcgetattr(self.fd) try:
attrs[0] = 0 # iflag attrs = termios.tcgetattr(self.fd)
attrs[1] = 0 # oflag attrs[0] = 0 # iflag
attrs[2] = termios.CREAD | termios.CLOCAL | termios.CS8 # cflag attrs[1] = 0 # oflag
attrs[3] = 0 # lflag attrs[2] = termios.CREAD | termios.CLOCAL | termios.CS8 # cflag
attrs[4] = attrs[5] = self._speed(baud) attrs[3] = 0 # lflag
attrs[6][termios.VMIN] = 0 attrs[6][termios.VMIN] = 0
attrs[6][termios.VTIME] = 0 attrs[6][termios.VTIME] = 0
termios.tcsetattr(self.fd, termios.TCSANOW, attrs) self._apply_baud(attrs, baud)
self.baud = baud except BaseException:
os.close(self.fd)
raise
def set_baud(self, baud): def set_baud(self, baud):
"""Retune the port without closing it — the fd stays open, so no DTR """Retune the port without closing it — the fd stays open, so no DTR
pulse and no reset. That matters: the only caller is mid-session with a pulse and no reset. That matters: the only caller is mid-session with a
loader copy that a reset would throw away.""" loader copy that a reset would throw away."""
attrs = termios.tcgetattr(self.fd) self._apply_baud(termios.tcgetattr(self.fd), baud)
attrs[4] = attrs[5] = self._speed(baud)
termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
self.baud = baud
def close(self): def close(self):
os.close(self.fd) os.close(self.fd)
@@ -531,6 +557,7 @@ class Loader:
drain absorbs whatever they produced.""" drain absorbs whatever they produced."""
deadline = time.monotonic() + wait deadline = time.monotonic() + wait
knocks = 0 knocks = 0
refusal = None
while True: while True:
self.port.flush_input() self.port.flush_input()
self.port.write(knock) self.port.write(knock)
@@ -552,12 +579,18 @@ class Loader:
except Error as failed: except Error as failed:
if "pureboot" in str(failed): if "pureboot" in str(failed):
raise raise
# A malformed or unknown identity is retried as noise, but
# it was an answer: if nothing better ever arrives, naming
# it beats reporting silence.
refusal = failed
self.info = None self.info = None
if self.info is not None: if self.info is not None:
self._expect_prompt() self._expect_prompt()
verbose(f"loader answered {what} {knocks}; identity read") verbose(f"loader answered {what} {knocks}; identity read")
return self.info return self.info
if time.monotonic() > deadline: if time.monotonic() > deadline:
if refusal is not None:
raise Error(f"no usable answer — the last identity reply failed: {refusal}")
raise Error("no answer — reset the device within its activation window") raise Error("no answer — reset the device within its activation window")
def connect(self, wait): def connect(self, wait):
@@ -1408,7 +1441,11 @@ def op_scan(port_path, baud, wait, clock=None):
for pct in scan_ratios(): for pct in scan_ratios():
rate = scan_rate(baud, pct) rate = scan_rate(baud, pct)
print(f"scan: {rate} Bd ({pct:+d} %) — reset the target", flush=True) print(f"scan: {rate} Bd ({pct:+d} %) — reset the target", flush=True)
port = Port(port_path, rate) try:
port = Port(port_path, rate)
except Error as unmakeable:
print(f"scan: {rate} Bd skipped — {unmakeable}")
continue
try: try:
info = Loader(port).connect(wait) info = Loader(port).connect(wait)
except Error: except Error:
@@ -1504,11 +1541,13 @@ def main():
print("device:") print("device:")
for line in info.lines(): for line in info.lines():
print(f" {line}") print(f" {line}")
if args.autobaud: if args.autobaud and info.ram is not None:
# The whole of the loader's RAM is the measured bit period at # The whole of the loader's RAM is the measured bit period at
# ram_start; decoded and times the rate this session drives, # ram_start; decoded and times the rate this session drives,
# that is the true clock — the number to hold an OSCCAL bake # that is the true clock — the number to hold an OSCCAL bake
# or a fixed-baud build against (README.md: deployment). # or a fixed-baud build against (README.md: deployment). The
# autobaud identity path refuses unknown signatures, so ram is
# always known here; the guard states that dependency.
unit = int.from_bytes(loader.read_ram(info.ram, 2), "little") unit = int.from_bytes(loader.read_ram(info.ram, 2), "little")
cycles = unit * UNIT_LOOP_CYCLES + UNIT_DISCOUNT cycles = unit * UNIT_LOOP_CYCLES + UNIT_DISCOUNT
clock = cycles * args.baud clock = cycles * args.baud

View File

@@ -193,10 +193,11 @@ static avr_cycle_count_t tx_sample(avr_t *mcu, avr_cycle_count_t when, void *par
tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0)); tx_shift = (uint8_t)((tx_shift >> 1) | (tx_level ? 0x80 : 0));
if (++tx_bit < 8) if (++tx_bit < 8)
return when + bit_cycles; return when + bit_cycles;
/* The byte is not delivered until its stop bit has passed. A real /* The byte is delivered at the stop bit's sampling point (9.5 bit
* receiver cannot answer sooner, and a host that did would put its * times), where a hardware receiver raises its RXC — not sooner: a
* start bit on the wire while the device is still driving the stop * host answering before the stop bit would put its start bit on the
* bit — which the device, transmitting, is not watching for. */ * wire while the device is still driving, which the device,
* transmitting, is not watching for. */
return when + bit_cycles; return when + bit_cycles;
} }
if (write(pty_master, &tx_shift, 1) != 1) if (write(pty_master, &tx_shift, 1) != 1)
@@ -412,6 +413,14 @@ int main(int argc, char *argv[])
fprintf(stderr, "device: cannot read %s\n", argv[1]); fprintf(stderr, "device: cannot read %s\n", argv[1]);
return 1; return 1;
} }
// An image past flash end would smash the simulator's heap and turn
// into phantom peripheral behavior (lessons: believe the size gate
// first) — refuse it loudly instead.
if (base + fw.flashsize > avr->flashend + 1) {
fprintf(stderr, "device: %u B at 0x%x runs past flash end 0x%x — image does not fit its slot\n",
(unsigned)fw.flashsize, base, avr->flashend);
return 1;
}
memcpy(avr->flash + base, fw.flash, fw.flashsize); memcpy(avr->flash + base, fw.flash, fw.flashsize);
} }
// The boot-sectioned megas enter the loader in hardware (BOOTRST, not // The boot-sectioned megas enter the loader in hardware (BOOTRST, not

View File

@@ -46,6 +46,24 @@ def main():
if "9984000" not in report: if "9984000" not in report:
fail(f"the absolute clock must scale with the found ratio:\n{report}") fail(f"the absolute clock must scale with the found ratio:\n{report}")
# The walk's rates mostly have no termios B-constant, so the POSIX port
# must set them through termios2 — probed on a pty, which accepts the
# ioctl without caring about the speed. Without this every off-nominal
# probe would abort the walk on the platform --scan matters most on.
if os.name == "posix":
import pty
master, slave = pty.openpty()
try:
port = pb.Port(os.ttyname(slave), pb.scan_rate(9600, 4))
port.set_baud(pb.scan_rate(9600, -4))
port.close()
except pb.Error as error:
fail(f"PosixPort refused an off-nominal probe rate: {error}")
finally:
os.close(master)
os.close(slave)
print("OK") print("OK")

View File

@@ -7,11 +7,14 @@ port's TUs compile identically; the sims prove nothing new there) exist for
libavr's reflect spot set only, mirroring its rule: the full reflect matrix libavr's reflect spot set only, mirroring its rule: the full reflect matrix
is never built, one chip per hardware class and pack vintage is. is never built, one chip per hardware class and pack vintage is.
Run from the repo root: tools/make_presets.py Run from the repo root: tools/make_presets.py — or with --check, which
verifies the committed file matches this generator and edits nothing (the
ctest entry `presets.generated` runs that, so drift reds the gate).
""" """
import json import json
import os import os
import sys
CHIPS = [ CHIPS = [
"attiny13", "attiny13a", "attiny25", "attiny45", "attiny85", "attiny13", "attiny13a", "attiny25", "attiny45", "attiny85",
@@ -41,7 +44,7 @@ def main():
"hidden": True, "hidden": True,
"generator": "Ninja", "generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}", "binaryDir": "${sourceDir}/build/${presetName}",
"toolchainFile": "$env{LIBAVR_ROOT}/cmake/avr-toolchain.cmake", "toolchainFile": "${sourceDir}/libavr/cmake/avr-toolchain.cmake",
"cacheVariables": { "cacheVariables": {
"CMAKE_BUILD_TYPE": "Release", "CMAKE_BUILD_TYPE": "Release",
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
@@ -72,6 +75,9 @@ def main():
for chip in REFLECT_SPOT: for chip in REFLECT_SPOT:
add(chip, "reflect") add(chip, "reflect")
# CMake rejects unknown fields in the presets root, $comment included, so
# the file cannot carry a generated-file marker; the --check ctest is the
# whole of rule 10's guard here.
presets = { presets = {
"version": 8, "version": 8,
"configurePresets": configure, "configurePresets": configure,
@@ -79,12 +85,19 @@ def main():
"testPresets": test, "testPresets": test,
"workflowPresets": workflows, "workflowPresets": workflows,
} }
rendered = json.dumps(presets, indent=1) + "\n"
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "CMakePresets.json") path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "CMakePresets.json")
if "--check" in sys.argv[1:]:
current = open(path).read() if os.path.exists(path) else ""
if current != rendered:
print("CMakePresets.json does not match its generator — run tools/make_presets.py")
return 1
return 0
with open(path, "w") as f: with open(path, "w") as f:
json.dump(presets, f, indent=1) f.write(rendered)
f.write("\n")
print(f"{len(CHIPS)} chips, {len(REFLECT_SPOT)} reflect: {os.path.normpath(path)}") print(f"{len(CHIPS)} chips, {len(REFLECT_SPOT)} reflect: {os.path.normpath(path)}")
return 0
if __name__ == "__main__": if __name__ == "__main__":
main() sys.exit(main())

View File

@@ -72,6 +72,35 @@ class Suite:
except Exception: except Exception:
pass 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: def eeprom(self, info) -> None:
size = info.eeprom_size size = info.eeprom_size
if not size: if not size:
@@ -161,6 +190,10 @@ class Suite:
print("\nthe loader never answered; nothing below can be trusted") print("\nthe loader never answered; nothing below can be trusted")
return 1 return 1
if not self.rig.d.autobaud:
print("\nscan")
self.scan()
print("\nEEPROM") print("\nEEPROM")
self.eeprom(info) self.eeprom(info)