The pin crosses libavr's phase 6 - the renamed system surface, the named serial configs, the receiver-tolerance table, the paged SPM receipts - and every loader image comes out size-identical: the full matrix on six representative chips (the exhaustive cross product on three of them), the stock and autobaud columns untouched, the four tsb tiers back on their recorded floors at 510/526/638/836. Byte parity was not free, and the two libavr defects it surfaced were fixed there rather than absorbed here. The EEPROM write procedure's step 2 - the SPMEN spin - had landed unconditionally and cost every build six bytes for a wait a polled loader can never take; it is scoped now, and the loaders state the datasheet's own omission clause (spm_interlock::omitted, DS40002061B 8.6.3). The blocking page erase/write grew an internal wait the tiers' settle() already provides, so the tiers issue the command form and pureboot keeps its host-driven sp_spm path. What the port states rather than inherits: the stock 115200 at 16 MHz sits +2.1 % past the receiver-tolerance table libavr now holds rates to, so the hardware links say .allow_baud_error = true - the same 2.5 % envelope pureboot_baud_feasible() has always enforced, proven on silicon across the fleet. rx_ready() reads readable() now. Alongside the pin: rule 33's ASCII sweep over every source (docs keep their typography), rule 34's InsertBraces in .clang-format with the tree reformatted, std::array over the simavr runners' raw buffers, and the stale Studio size in ide/README.md replaced by the claim its check-flags gate actually holds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
213 lines
7.4 KiB
Python
213 lines
7.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Host-tool activation handshake: bounded against a line that misbehaves.
|
|
|
|
`_handshake` drains the line after it sees a prompt, to absorb a real loader's
|
|
trailing bytes before it asks for the identity. That drain must be bounded: a
|
|
target that never falls quiet - a board stuck in a reset loop presents exactly
|
|
this, ~60 reboots/s of UART-reset garbage in which a stray 0x2b reads as a
|
|
prompt - otherwise spins the tool forever. Regression for that hang, plus a
|
|
control that a well-behaved loader still connects.
|
|
|
|
The handshake must also survive its own leftovers: after `--stay` the loader's
|
|
final prompt can still be in the USB pipeline when the next invocation opens
|
|
the port, and on a board wired to reset on open, that opening starts a fresh
|
|
activation window the stale prompt then betrays - the tool commits to an
|
|
identity read against a device that never heard its knock, and what it finally
|
|
collects is the application's banner. StaleDTRPort is that moment as a port.
|
|
|
|
Stdlib only, no device: host-tool logic, so it runs on every chip's preset
|
|
beside pureboot.planner.
|
|
"""
|
|
import importlib.util
|
|
import pathlib
|
|
import threading
|
|
import time
|
|
|
|
PB = pathlib.Path(__file__).resolve().parents[1] / "pureboot" / "pureboot.py"
|
|
_spec = importlib.util.spec_from_file_location("pureboot", PB)
|
|
pb = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(pb)
|
|
|
|
P = F = 0
|
|
|
|
|
|
def check(name, ok):
|
|
global P, F
|
|
P, F = P + (1 if ok else 0), F + (0 if ok else 1)
|
|
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
|
|
|
|
|
|
class FloodPort:
|
|
"""A line that never falls quiet: read_available always returns bytes, and
|
|
they contain a prompt. No identity ever completes."""
|
|
|
|
def flush_input(self):
|
|
pass
|
|
|
|
def write(self, data):
|
|
pass
|
|
|
|
def read_available(self, wait):
|
|
time.sleep(0.01) # a real read waits; keep the busy loop off a core
|
|
return b"+\x00\xff"
|
|
|
|
def read_exact(self, count, timeout):
|
|
raise pb.Error("no identity")
|
|
|
|
|
|
class LoaderPort:
|
|
"""A well-behaved pureboot 5: a prompt to the knock, then quiet, then the
|
|
slim identity (version 5 + m328p signature) and a closing prompt."""
|
|
|
|
def __init__(self):
|
|
self.pending = b""
|
|
self.exacts = 0
|
|
|
|
def flush_input(self):
|
|
self.pending = b""
|
|
|
|
def write(self, data):
|
|
if b"p" in data:
|
|
self.pending = b"+" # the prompt answers the knock, nothing else
|
|
|
|
def read_available(self, wait):
|
|
data, self.pending = self.pending, b""
|
|
return data
|
|
|
|
def read_exact(self, count, timeout):
|
|
self.exacts += 1
|
|
return b"\x05\x1e\x95\x0f" if self.exacts == 1 else b"+" # identity, then prompt
|
|
|
|
|
|
class StaleDTRPort:
|
|
"""`--stay`, then a fresh invocation on a board that resets when its port
|
|
opens. Three facts of that moment, all timed from the open: the previous
|
|
session's final prompt is still in transit and lands only after the
|
|
opening flush has already run; the reset holds the device off the line
|
|
at first, eating anything written before it completes; and the fresh
|
|
window is finite - once it expires the application boots and prints a
|
|
banner whose bytes are what a pending identity read collects. A
|
|
handshake that trusts the stale prompt spends the whole window waiting
|
|
on a device that never heard its knock; one that drains the line first
|
|
knocks into the real window and connects."""
|
|
|
|
STALE_AT = 0.02 # the leftover prompt becomes visible (post-flush)
|
|
READY_AT = 0.05 # reset complete, activation window opens
|
|
WINDOW = 1.0 # window length; expiry boots the application
|
|
|
|
def __init__(self):
|
|
self.t0 = time.monotonic()
|
|
# (visible-from, bytes): the line as a timed queue.
|
|
self.queue = [(self.t0 + self.STALE_AT, b"+")]
|
|
self.armed = False # a 'p' heard inside the window arms 'b'
|
|
self.booted = False
|
|
|
|
def _boot_check(self):
|
|
if not self.booted and time.monotonic() > self.t0 + self.READY_AT + self.WINDOW:
|
|
self.booted = True
|
|
self.queue.append((self.t0 + self.READY_AT + self.WINDOW,
|
|
b"W r libavr tempmon\r\n"))
|
|
|
|
def _visible(self):
|
|
self._boot_check()
|
|
now = time.monotonic()
|
|
return b"".join(d for t, d in self.queue if t <= now)
|
|
|
|
def _consume(self, n):
|
|
now = time.monotonic()
|
|
left = []
|
|
for t, d in self.queue:
|
|
if t <= now and n:
|
|
take = min(n, len(d))
|
|
d = d[take:]
|
|
n -= take
|
|
if d:
|
|
left.append((t, d))
|
|
self.queue = left
|
|
|
|
def flush_input(self):
|
|
self._consume(len(self._visible()))
|
|
|
|
def write(self, data):
|
|
self._boot_check()
|
|
now = time.monotonic()
|
|
if now < self.t0 + self.READY_AT or self.booted:
|
|
return # still in reset, or the application owns the line
|
|
if b"p" in data:
|
|
self.armed = True
|
|
self.queue.append((now + 0.01, b"+"))
|
|
if b"b" in data and self.armed:
|
|
# The slim identity (version 5 + m328p signature) and a prompt.
|
|
self.queue.append((now + 0.01, b"\x05\x1e\x95\x0f+"))
|
|
|
|
def read_available(self, wait):
|
|
deadline = time.monotonic() + wait
|
|
while True:
|
|
data = self._visible()
|
|
if data:
|
|
self._consume(len(data))
|
|
return data
|
|
if time.monotonic() >= deadline:
|
|
return b""
|
|
time.sleep(0.005)
|
|
|
|
def read_exact(self, count, timeout):
|
|
deadline = time.monotonic() + timeout
|
|
data = b""
|
|
while len(data) < count:
|
|
visible = self._visible()
|
|
if visible:
|
|
take = visible[:count - len(data)]
|
|
self._consume(len(take))
|
|
data += take
|
|
elif time.monotonic() >= deadline:
|
|
raise pb.Error(f"timeout: got {len(data)} of {count} bytes")
|
|
else:
|
|
time.sleep(0.005)
|
|
return data
|
|
|
|
|
|
def terminates(port, wait, budget):
|
|
"""Run connect_autobaud in a thread; True if it returns/raises within
|
|
`budget` seconds rather than hanging."""
|
|
done = threading.Event()
|
|
|
|
def run():
|
|
try:
|
|
pb.Loader(port).connect_autobaud(wait)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
done.set()
|
|
|
|
threading.Thread(target=run, daemon=True).start()
|
|
return done.wait(budget)
|
|
|
|
|
|
def main():
|
|
# the hang: a flooding target must not spin the drain forever. With wait=0.5
|
|
# the whole handshake has to give up well inside a few seconds.
|
|
check("flooding target: handshake terminates, drain is bounded",
|
|
terminates(FloodPort(), wait=0.5, budget=4.0))
|
|
|
|
# the control: a real loader still connects and reads identity.
|
|
info = pb.Loader(LoaderPort()).connect_autobaud(2.0)
|
|
check("well-behaved loader still connects (version 5)", info.version == 5)
|
|
|
|
# the stale prompt: a --stay leftover plus reset-on-open must not burn the
|
|
# fresh window - the pre-knock drain absorbs it and the first real knock
|
|
# lands inside the window.
|
|
try:
|
|
stale_ok = pb.Loader(StaleDTRPort()).connect(2.5).version == 5
|
|
except pb.Error as failed:
|
|
print(f" ({failed})")
|
|
stale_ok = False
|
|
check("stale --stay prompt + reset-on-open: connects in the fresh window", stale_ok)
|
|
|
|
print(f"\n {P} passed, {F} failed")
|
|
return 1 if F else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|