host: one fact per line, progress bars, --verbose

--info prints the decoded info block field by field and --fuses each
byte on its own line plus the BOOTSZ/BOOTRST meaning on boot-sectioned
megas. Transfers that take wire time draw a transient progress bar on
stderr when it is a tty — logs, pipes and the tests see only the
summary lines. -v/--verbose narrates decisions: knock counts, the
programming plan, update state handling and per-phase page counts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 23:42:57 +02:00
parent a977e507f3
commit 9a528f9411

View File

@@ -38,11 +38,57 @@ PROMPT = b"+"
PROTOCOL_VERSION = 1 PROTOCOL_VERSION = 1
SLOT = 512 # the loader slot on byte-addressed chips; word-addressed ones (>64 KiB) use 1 KiB — their own smallest boot sector SLOT = 512 # the loader slot on byte-addressed chips; word-addressed ones (>64 KiB) use 1 KiB — their own smallest boot sector
VERBOSE = False
def verbose(message):
"""Detail printed only under --verbose: decisions and derived facts, not
per-byte chatter — the progress bar carries the bulk transfers."""
if VERBOSE:
print(f" {message}")
class Error(Exception): class Error(Exception):
pass pass
class Progress:
"""A transient in-place bar on stderr for the operations that take wire
time. Drawn only when stderr is a tty — logs, pipes and the test harness
see nothing — and erased once done; the summary line each operation
prints afterwards is the persistent record. A zero total (or no label)
disables it, so callers can pass one through unconditionally."""
def __init__(self, label, total, unit="pages"):
self.label, self.total, self.unit = label, total, unit
self.done = 0
self.width = 0
self.live = bool(label) and total > 0 and sys.stderr.isatty()
self._draw()
def __enter__(self):
return self
def __exit__(self, *exc):
if self.live:
sys.stderr.write("\r" + " " * self.width + "\r")
sys.stderr.flush()
def step(self, n=1):
self.done += n
self._draw()
def _draw(self):
if not self.live:
return
bar = 24 * self.done // self.total
line = (f"{self.label:<16} [{'#' * bar}{'-' * (24 - bar)}] "
f"{100 * self.done // self.total:3d}% {self.done}/{self.total} {self.unit}")
self.width = max(self.width, len(line))
sys.stderr.write("\r" + line)
sys.stderr.flush()
# ---------------------------------------------------------------- serial --- # ---------------------------------------------------------------- serial ---
@@ -295,6 +341,23 @@ class Info:
f"EEPROM {self.eeprom_size} B, {vector}" f"EEPROM {self.eeprom_size} B, {vector}"
) )
def lines(self):
"""The info block as one fact per line — what --info prints."""
if self.patch_vector:
hand_over = f"host-patched reset vector, trampoline at {self.base - 2:#06x}"
else:
hand_over = "hardware boot section, jump to word 0"
return (
f"signature {' '.join(f'{b:02x}' for b in self.signature)}",
f"flash {self.flash_size} B, {self.page} B pages"
+ (", word-addressed wire" if self.word_flash else ""),
f"application 0x0000..{self.base - 1:#06x} ({self.base} B)",
f"loader {self.base:#06x} ({self.slot} B slot)",
f"staging {self.stage:#06x}",
f"EEPROM {self.eeprom_size} B",
f"hand-over {hand_over}",
)
class Loader: class Loader:
"""A pureboot session. Between commands the loader has prompted `+` and """A pureboot session. Between commands the loader has prompted `+` and
@@ -312,8 +375,10 @@ class Loader:
absorbs whatever they produced.""" absorbs whatever they produced."""
self.port.flush_input() self.port.flush_input()
deadline = time.monotonic() + wait deadline = time.monotonic() + wait
knocks = 0
while True: while True:
self.port.write(b"pb") self.port.write(b"pb")
knocks += 1
if PROMPT in self.port.read_available(0.4): if PROMPT in self.port.read_available(0.4):
break break
if time.monotonic() > deadline: if time.monotonic() > deadline:
@@ -323,6 +388,7 @@ class Loader:
self.port.write(b"b") self.port.write(b"b")
self.info = Info(self.port.read_exact(12, 2.0)) self.info = Info(self.port.read_exact(12, 2.0))
self._expect_prompt() self._expect_prompt()
verbose(f"loader answered knock {knocks}; info block read")
return self.info return self.info
def _expect_prompt(self, timeout=2.0): def _expect_prompt(self, timeout=2.0):
@@ -373,7 +439,7 @@ class Loader:
head = bytes((ord("W"), wire & 0xFF, wire >> 8)) head = bytes((ord("W"), wire & 0xFF, wire >> 8))
self._command(head + data, 0, 2.0) self._command(head + data, 0, 2.0)
def write_eeprom(self, address, data): def write_eeprom(self, address, data, progress=None):
offset = 0 offset = 0
while offset < len(data): while offset < len(data):
chunk = data[offset : offset + 256] chunk = data[offset : offset + 256]
@@ -382,6 +448,8 @@ class Loader:
for byte in chunk: for byte in chunk:
self.port.write(bytes((byte,))) self.port.write(bytes((byte,)))
self._expect_prompt() # per-byte ack: the write has begun self._expect_prompt() # per-byte ack: the write has begun
if progress:
progress.step()
self._expect_prompt() # the next command prompt self._expect_prompt() # the next command prompt
address += len(chunk) address += len(chunk)
offset += len(chunk) offset += len(chunk)
@@ -487,6 +555,8 @@ def plan_flash(image, info):
final += bytearray([0xFF] * (trampoline_page + page - len(final))) final += bytearray([0xFF] * (trampoline_page + page - len(final)))
jump = rjmp_to(trampoline_word, entry, flash_words) jump = rjmp_to(trampoline_word, entry, flash_words)
final[info.base - 2], final[info.base - 1] = jump & 0xFF, jump >> 8 final[info.base - 2], final[info.base - 1] = jump & 0xFF, jump >> 8
verbose(f"vector surgery: word 0 -> loader {info.base:#06x}, "
f"trampoline {info.base - 2:#06x} -> entry word {entry:#06x}")
pages = {a: bytes(final[a : a + page]) for a in range(0, len(final), page)} pages = {a: bytes(final[a : a + page]) for a in range(0, len(final), page)}
return pages return pages
@@ -681,17 +751,22 @@ class UpdateState:
os.unlink(self.path) os.unlink(self.path)
def write_differing(loader, base, content, order=None): def write_differing(loader, base, content, order=None, label=None):
"""Program the pages of `content` at `base` that differ from flash — """Program the pages of `content` at `base` that differ from flash —
idempotent, so a resumed phase redoes only what an interruption left.""" idempotent, so a resumed phase redoes only what an interruption left.
A label puts the compare-and-program loop on the progress bar."""
page = loader.info.page page = loader.info.page
offsets = order if order is not None else range(0, len(content), page) offsets = list(order) if order is not None else list(range(0, len(content), page))
written = 0 written = 0
for offset in offsets: with Progress(label, len(offsets)) as bar:
want = content[offset : offset + page] for offset in offsets:
if loader.read_flash(base + offset, page) != want: want = content[offset : offset + page]
loader.write_page(base + offset, want) if loader.read_flash(base + offset, page) != want:
written += 1 loader.write_page(base + offset, want)
written += 1
bar.step()
if label:
verbose(f"{label}: {written} of {len(offsets)} pages differed")
for at in range(0, len(content), 256): for at in range(0, len(content), 256):
if loader.read_flash(base + at, min(256, len(content) - at)) != content[at : at + 256]: if loader.read_flash(base + at, min(256, len(content) - at)) != content[at : at + 256]:
raise Error(f"verify failed at {base + at:#06x} after programming") raise Error(f"verify failed at {base + at:#06x} after programming")
@@ -723,6 +798,10 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
page = info.page page = info.page
state = UpdateState(state_path) state = UpdateState(state_path)
if os.path.exists(state_path):
verbose(f"resuming the update recorded in {state_path}")
else:
verbose(f"saving the staging slot to {state_path}")
state.load_or_save(loader) state.load_or_save(loader)
# Install the staging copy — unless a loader already sits whole in the # Install the staging copy — unless a loader already sits whole in the
@@ -741,7 +820,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
current = loader.read_flash(info.stage, info.slot) current = loader.read_flash(info.stage, info.slot)
staged_loader = image_info(current[:268]) staged_loader = image_info(current[:268])
if staged_loader is not None and staged_loader.raw == info.raw and current == state.staging: if staged_loader is not None and staged_loader.raw == info.raw and current == state.staging:
print(f"staging slot already holds a loader — left in place") print("staging slot already holds a loader — left in place")
else: else:
# On a chip whose staging slot starts at address 0 (the 1 KB # On a chip whose staging slot starts at address 0 (the 1 KB
# tiny13s), its first page carries the reset vector: written last, # tiny13s), its first page carries the reset vector: written last,
@@ -750,30 +829,34 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
order = list(range(0, info.slot, page)) order = list(range(0, info.slot, page))
if info.stage == 0: if info.stage == 0:
order = order[1:] + [0] order = order[1:] + [0]
if write_differing(loader, info.stage, staged, order): if write_differing(loader, info.stage, staged, order, label="staging copy"):
print(f"staging copy installed at {info.stage:#06x}") print(f"staging copy installed at {info.stage:#06x}")
# Enter it and let it rewrite the resident slot. Where a patched reset # Enter it and let it rewrite the resident slot. Where a patched reset
# vector routes through the resident (a tiny with the staging slot away # vector routes through the resident (a tiny with the staging slot away
# from page 0), word 0 is re-aimed at the staging copy around the # from page 0), word 0 is re-aimed at the staging copy around the
# rewrite, so a power failure mid-rewrite still resets into a loader. # rewrite, so a power failure mid-rewrite still resets into a loader.
verbose(f"entering the staging copy at {info.stage:#06x}")
loader.enter_copy(info.stage, wait) loader.enter_copy(info.stage, wait)
redirect = info.patch_vector and info.stage != 0 redirect = info.patch_vector and info.stage != 0
if redirect: if redirect:
verbose("word 0 re-aimed at the staging copy for the rewrite")
patch_word0(loader, state.page0, info.stage) patch_word0(loader, state.page0, info.stage)
if write_differing(loader, info.base, resident): if write_differing(loader, info.base, resident, label="resident"):
print(f"resident loader rewritten at {info.base:#06x}") print(f"resident loader rewritten at {info.base:#06x}")
# Enter the new resident and put the staging region back: page 0 first # Enter the new resident and put the staging region back: page 0 first
# where it lives in that region (word 0 then points at the new resident # where it lives in that region (word 0 then points at the new resident
# for the rest of the restore), the saved trampoline with the rest. # for the rest of the restore), the saved trampoline with the rest.
verbose(f"entering the new resident at {info.base:#06x}")
loader.enter_copy(info.base, wait) loader.enter_copy(info.base, wait)
if redirect: if redirect:
verbose("word 0 restored")
write_differing(loader, 0, state.page0) write_differing(loader, 0, state.page0)
order = list(range(0, info.slot, page)) order = list(range(0, info.slot, page))
if info.stage == 0: if info.stage == 0:
order = [0] + order[1:] order = [0] + order[1:]
write_differing(loader, info.stage, state.staging, order) write_differing(loader, info.stage, state.staging, order, label="staging restore")
state.discard() state.discard()
print(f"loader updated: {len(image)} B at {info.base:#06x}, staging region restored") print(f"loader updated: {len(image)} B at {info.base:#06x}, staging region restored")
@@ -808,39 +891,49 @@ def op_erase_flash(loader):
is erased and the reset walk reaches the loader anyway.""" is erased and the reset walk reaches the loader anyway."""
blank = bytes([0xFF] * loader.info.page) blank = bytes([0xFF] * loader.info.page)
addresses = range(0, loader.info.base, loader.info.page) addresses = range(0, loader.info.base, loader.info.page)
for address in reversed(addresses) if loader.info.patch_vector else addresses: with Progress("erase", len(addresses)) as bar:
loader.write_page(address, blank) for address in reversed(addresses) if loader.info.patch_vector else addresses:
loader.write_page(address, blank)
bar.step()
print(f"erase: {loader.info.base // loader.info.page} pages") print(f"erase: {loader.info.base // loader.info.page} pages")
def op_erase_eeprom(loader): def op_erase_eeprom(loader):
loader.write_eeprom(0, bytes([0xFF] * loader.info.eeprom_size)) with Progress("erase EEPROM", loader.info.eeprom_size, "B") as bar:
loader.write_eeprom(0, bytes([0xFF] * loader.info.eeprom_size), progress=bar)
print(f"erase: {loader.info.eeprom_size} B of EEPROM") print(f"erase: {loader.info.eeprom_size} B of EEPROM")
def op_flash(loader, path, erase, verify, fuse_bytes=None, force=False): def op_flash(loader, path, erase, verify, fuse_bytes=None, force=False):
image = load_image(path) image = load_image(path)
verbose(f"{path}: {len(image)} B image")
pages = plan_flash(image, loader.info) pages = plan_flash(image, loader.info)
check_walk_region(pages, loader.info, fuse_bytes, force) check_walk_region(pages, loader.info, fuse_bytes, force)
if erase: if erase:
op_erase_flash(loader) op_erase_flash(loader)
order = covered(pages, loader.info, skip_blank=erase) order = covered(pages, loader.info, skip_blank=erase)
for address in order: if len(order) != len(pages):
loader.write_page(address, pages[address]) verbose(f"{len(pages) - len(order)} blank pages skipped (erased flash underneath)")
with Progress("flash", len(order)) as bar:
for address in order:
loader.write_page(address, pages[address])
bar.step()
print(f"flash: {path}: {len(order)} pages") print(f"flash: {path}: {len(order)} pages")
if verify: if verify:
verify_pages(loader, pages) verify_pages(loader, pages)
def verify_pages(loader, pages): def verify_pages(loader, pages):
for address in sorted(pages): with Progress("verify", len(pages)) as bar:
got = loader.read_flash(address, loader.info.page) for address in sorted(pages):
if got != pages[address]: got = loader.read_flash(address, loader.info.page)
first = next(i for i in range(len(got)) if got[i] != pages[address][i]) if got != pages[address]:
raise Error( first = next(i for i in range(len(got)) if got[i] != pages[address][i])
f"verify failed at {address + first:#06x}: " raise Error(
f"wrote {pages[address][first]:02x}, read {got[first]:02x}" f"verify failed at {address + first:#06x}: "
) f"wrote {pages[address][first]:02x}, read {got[first]:02x}"
)
bar.step()
print(f"verify: {len(pages)} pages ok") print(f"verify: {len(pages)} pages ok")
@@ -848,8 +941,19 @@ def op_verify_flash(loader, path):
verify_pages(loader, plan_flash(load_image(path), loader.info)) verify_pages(loader, plan_flash(load_image(path), loader.info))
def read_progress(reader, total, label):
"""A bulk read in 256-byte wire chunks under a progress bar."""
data = b""
with Progress(label, total, "B") as bar:
while len(data) < total:
chunk = min(256, total - len(data))
data += reader(len(data), chunk)
bar.step(chunk)
return data
def op_read_flash(loader, path): def op_read_flash(loader, path):
data = loader.read_flash(0, loader.info.base) data = read_progress(loader.read_flash, loader.info.base, "read flash")
open(path, "wb").write(data) open(path, "wb").write(data)
print(f"read flash: {len(data)} B -> {path}") print(f"read flash: {len(data)} B -> {path}")
@@ -860,10 +964,11 @@ def op_eeprom(loader, path, erase, verify):
raise Error(f"EEPROM image is {len(image)} B, device has {loader.info.eeprom_size}") raise Error(f"EEPROM image is {len(image)} B, device has {loader.info.eeprom_size}")
if erase: if erase:
op_erase_eeprom(loader) op_erase_eeprom(loader)
loader.write_eeprom(0, image) with Progress("eeprom", len(image), "B") as bar:
loader.write_eeprom(0, image, progress=bar)
print(f"eeprom: {path}: {len(image)} B") print(f"eeprom: {path}: {len(image)} B")
if verify: if verify:
got = loader.read_eeprom(0, len(image)) got = read_progress(loader.read_eeprom, len(image), "verify EEPROM")
if got != image: if got != image:
first = next(i for i in range(len(got)) if got[i] != image[i]) first = next(i for i in range(len(got)) if got[i] != image[i])
raise Error(f"verify failed at EEPROM {first:#06x}: wrote {image[first]:02x}, read {got[first]:02x}") raise Error(f"verify failed at EEPROM {first:#06x}: wrote {image[first]:02x}, read {got[first]:02x}")
@@ -872,7 +977,7 @@ def op_eeprom(loader, path, erase, verify):
def op_verify_eeprom(loader, path): def op_verify_eeprom(loader, path):
image = load_image(path) image = load_image(path)
got = loader.read_eeprom(0, len(image)) got = read_progress(loader.read_eeprom, len(image), "verify EEPROM")
if got != image: if got != image:
first = next(i for i in range(len(got)) if got[i] != image[i]) first = next(i for i in range(len(got)) if got[i] != image[i])
raise Error(f"verify failed at EEPROM {first:#06x}: expected {image[first]:02x}, read {got[first]:02x}") raise Error(f"verify failed at EEPROM {first:#06x}: expected {image[first]:02x}, read {got[first]:02x}")
@@ -880,15 +985,30 @@ def op_verify_eeprom(loader, path):
def op_read_eeprom(loader, path): def op_read_eeprom(loader, path):
data = loader.read_eeprom(0, loader.info.eeprom_size) data = read_progress(loader.read_eeprom, loader.info.eeprom_size, "read EEPROM")
open(path, "wb").write(data) open(path, "wb").write(data)
print(f"read EEPROM: {len(data)} B -> {path}") print(f"read EEPROM: {len(data)} B -> {path}")
def op_fuses(loader): def op_fuses(loader):
low, lock, extended, high = loader.read_fuses() low, lock, extended, high = loader.read_fuses()
print(f"fuses: low {low:02x} high {high:02x} extended {extended:02x} lock {lock:02x}") print("fuses:")
return bytes((low, lock, extended, high)) print(f" low 0x{low:02x}")
print(f" high 0x{high:02x}")
print(f" extended 0x{extended:02x}")
print(f" lock 0x{lock:02x}")
fuse_bytes = bytes((low, lock, extended, high))
# On a boot-sectioned mega the BOOTSZ/BOOTRST decode is the fuse fact the
# loader's whole deployment hangs on — say it in words.
if not loader.info.patch_vector:
try:
bootrst, bls_start = mega_boot(loader.info, fuse_bytes)
reset = "reset enters it" if bootrst else "reset boots the application"
print(f" boot section at {bls_start:#06x} ({loader.info.flash_size - bls_start} B), "
f"BOOTRST {'programmed' if bootrst else 'unprogrammed'}{reset}")
except Error:
pass # unknown signature: the raw bytes above still stand
return fuse_bytes
# -------------------------------------------------------------------- cli --- # -------------------------------------------------------------------- cli ---
@@ -918,7 +1038,11 @@ def main():
parser.add_argument("--verify-eeprom", metavar="FILE", help="compare EEPROM against an image") parser.add_argument("--verify-eeprom", metavar="FILE", help="compare EEPROM against an image")
parser.add_argument("--force", action="store_true", help="override refusable safety checks") parser.add_argument("--force", action="store_true", help="override refusable safety checks")
parser.add_argument("--stay", action="store_true", help="leave the loader in its session") parser.add_argument("--stay", action="store_true", help="leave the loader in its session")
parser.add_argument("-v", "--verbose", action="store_true",
help="print decisions and derived facts as operations run")
args = parser.parse_args() args = parser.parse_args()
global VERBOSE
VERBOSE = args.verbose
if args.update_loader and (args.flash or args.erase_flash): if args.update_loader and (args.flash or args.erase_flash):
parser.error("--update-loader does not combine with application flash operations") parser.error("--update-loader does not combine with application flash operations")
@@ -931,11 +1055,14 @@ def main():
parser.error("--assume-fuses takes 8 hex digits: low,lock,extended,high") parser.error("--assume-fuses takes 8 hex digits: low,lock,extended,high")
port = Port(args.port, args.baud) port = Port(args.port, args.baud)
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted")
try: try:
loader = Loader(port) loader = Loader(port)
info = loader.connect(args.wait) info = loader.connect(args.wait)
if args.info: if args.info:
print(f"device: {info.describe()}") print("device:")
for line in info.lines():
print(f" {line}")
fuse_bytes = fuse_override fuse_bytes = fuse_override
if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None): if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None):
read = op_fuses(loader) read = op_fuses(loader)