feat(flash): pure-Python firmware flasher over the OLD-frame protocol

Drives the full flash flow over the existing USB-serial link — enter
bootloader, arm window, stream the ELF block-by-block, reset — with no
external pm3-flash/DFU/JTAG.

- core/flash.py: Flasher (device_info, auto OS->bootloader handover,
  flash_image block loop with ACK/NACK+EFC status, reset, end-to-end flash);
  ELF32-ARM PT_LOAD parsing; 0xFF-padded block alignment; image resolver.
- core/transport.py: OLD 544-byte frame encode/decode, send_old /
  send_old_no_response, magic-branching frame reader, reopen() for USB
  re-enumeration.
- core/protocol.py: DEVICE_INFO flags, START_FLASH_MAGIC, flash geometry,
  BL version helpers.
- Wired as pm3.flasher; reachable through the sync proxy.

Safety: fullimage-only by default; bootrom region refused unless
allow_bootrom=True; every block bounds-checked against the flash window.
Verification is per-block ACK/NACK (as the C flasher does); read-back verify
and hardware validation are follow-ups. 22 tests; suite 1026 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-05 21:33:01 -07:00
parent 42a9146e6c
commit 762e305dd9
5 changed files with 687 additions and 0 deletions

272
tests/test_flash.py Normal file
View File

@@ -0,0 +1,272 @@
"""Tests for the pure-Python firmware flasher (no hardware)."""
import asyncio
import struct
import pytest
from unittest.mock import AsyncMock
from pm3py.core.protocol import (
Cmd,
OLD_FRAME_SIZE, START_FLASH_MAGIC,
FLASH_START, BOOTLOADER_END, FLASH_END_512K, BL_VERSION_1_0_0,
DEVICE_INFO_FLAG_BOOTROM_PRESENT, DEVICE_INFO_FLAG_OSIMAGE_PRESENT,
DEVICE_INFO_FLAG_CURRENT_MODE_BOOTROM, DEVICE_INFO_FLAG_CURRENT_MODE_OS,
)
from pm3py.core.transport import PM3Response, encode_old_frame, decode_old_frame
from pm3py.core.flash import (
Flasher, FlashError, parse_elf_segments, build_blocks, resolve_image,
)
OS_FLAGS = (DEVICE_INFO_FLAG_BOOTROM_PRESENT | DEVICE_INFO_FLAG_OSIMAGE_PRESENT
| DEVICE_INFO_FLAG_CURRENT_MODE_OS)
BOOTROM_FLAGS = (DEVICE_INFO_FLAG_BOOTROM_PRESENT | DEVICE_INFO_FLAG_OSIMAGE_PRESENT
| DEVICE_INFO_FLAG_CURRENT_MODE_BOOTROM)
def run(coro):
# Match the rest of the suite's idiom — asyncio.run() would close and null the
# shared global loop, breaking later tests that use get_event_loop().
return asyncio.get_event_loop().run_until_complete(coro)
def old_resp(cmd, arg0=0, arg1=0, arg2=0):
return PM3Response(cmd=int(cmd), status=0, reason=0, ng=False,
data=b"\x00" * 512, oldarg=[arg0, arg1, arg2])
def make_elf(paddr, data, *, machine=40, etype=2, cls=1, endian=1):
"""Build a minimal single-PT_LOAD ELF32 image for tests."""
ident = b"\x7fELF" + bytes([cls, endian, 1, 0]) + bytes(8)
e_phoff = 52
e_phentsize = 32
header = ident + struct.pack(
"<HHIIIIIHHHHHH",
etype, machine, 1, paddr, e_phoff, 0, 0,
52, e_phentsize, 1, 0, 0, 0,
)
data_off = e_phoff + e_phentsize
phdr = struct.pack("<IIIIIIII",
1, # PT_LOAD
data_off, # p_offset
paddr, paddr,
len(data), len(data),
5, 0x10000)
return header + phdr + data
# --- OLD frame encoding ---
def test_old_frame_size_and_roundtrip():
frame = encode_old_frame(Cmd.FINISH_WRITE, 0x102000, 1, 2, b"\xAB\xCD")
assert len(frame) == OLD_FRAME_SIZE == 544
d = decode_old_frame(frame)
assert d["cmd"] == Cmd.FINISH_WRITE
assert d["oldarg"] == [0x102000, 1, 2]
assert d["data"][:2] == b"\xAB\xCD"
assert d["data"][2:] == b"\x00" * 510 # zero-padded to 512
def test_old_frame_rejects_oversized_data():
with pytest.raises(ValueError):
encode_old_frame(Cmd.FINISH_WRITE, data=b"\x00" * 513)
# --- ELF parsing ---
def test_parse_elf_single_segment():
segs = parse_elf_segments(make_elf(0x102000, b"hello world"))
assert segs == [(0x102000, b"hello world")]
def test_parse_elf_rejects_bad_magic():
with pytest.raises(FlashError):
parse_elf_segments(b"NOTELF" + bytes(100))
def test_parse_elf_rejects_non_arm():
with pytest.raises(FlashError):
parse_elf_segments(make_elf(0x102000, b"x", machine=3)) # EM_386
def test_parse_elf_rejects_non_exec():
with pytest.raises(FlashError):
parse_elf_segments(make_elf(0x102000, b"x", etype=1)) # ET_REL
# --- block building ---
def test_build_blocks_aligns_and_pads():
# 700 bytes at an aligned address -> 2 blocks, second 0xFF-padded
blocks = build_blocks([(0x102000, b"\x11" * 700)])
assert len(blocks) == 2
assert blocks[0][0] == 0x102000
assert blocks[1][0] == 0x102200
assert all(len(b) == 512 for _, b in blocks)
# tail padding is erased-flash 0xFF
assert blocks[1][1][-100:] == b"\xFF" * 100
def test_build_blocks_pads_low_side():
# unaligned start -> leading 0xFF padding down to the block boundary
blocks = build_blocks([(0x102010, b"\x22" * 16)])
assert len(blocks) == 1
addr, block = blocks[0]
assert addr == 0x102000
assert block[:0x10] == b"\xFF" * 0x10
assert block[0x10:0x20] == b"\x22" * 16
# --- resolve_image ---
def test_resolve_image_bytes_passthrough():
assert resolve_image(b"abc") == b"abc"
def test_resolve_image_path(tmp_path):
p = tmp_path / "fw.elf"
p.write_bytes(b"\x7fELFdata")
assert resolve_image(str(p)) == b"\x7fELFdata"
def test_resolve_image_missing_path_errors(tmp_path):
with pytest.raises(FlashError):
resolve_image(str(tmp_path / "nope.elf"))
def test_resolve_image_none_no_build(tmp_path, monkeypatch):
monkeypatch.delenv("PM3PY_FIRMWARE", raising=False)
monkeypatch.chdir(tmp_path) # no firmware/ build output here
with pytest.raises(FlashError):
resolve_image(None)
# --- Flasher: probing ---
def test_device_info_parses_flags():
t = AsyncMock()
t.send_old.return_value = old_resp(Cmd.DEVICE_INFO, arg0=BOOTROM_FLAGS)
info = run(Flasher(t).device_info())
assert info["in_bootrom"] is True
assert info["in_os"] is False
assert info["bootrom_present"] is True
# --- Flasher: enter_bootloader ---
def test_enter_bootloader_already_in_bootrom():
t = AsyncMock()
t.send_old.return_value = old_resp(Cmd.DEVICE_INFO, arg0=BOOTROM_FLAGS)
info = run(Flasher(t).enter_bootloader())
assert info["in_bootrom"] is True
t.send_old_no_response.assert_not_called() # no handover needed
t.reopen.assert_not_called()
def test_enter_bootloader_from_os_handover():
t = AsyncMock()
t.send_old.side_effect = [
old_resp(Cmd.DEVICE_INFO, arg0=OS_FLAGS), # first probe: in OS
old_resp(Cmd.DEVICE_INFO, arg0=BOOTROM_FLAGS), # after handover: in bootrom
]
run(Flasher(t).enter_bootloader())
# handover = START_FLASH with no args, then reopen the re-enumerated port
t.send_old_no_response.assert_awaited_once_with(Cmd.START_FLASH)
t.reopen.assert_awaited_once()
def test_enter_bootloader_fails_if_not_entered():
t = AsyncMock()
t.send_old.side_effect = [
old_resp(Cmd.DEVICE_INFO, arg0=OS_FLAGS),
old_resp(Cmd.DEVICE_INFO, arg0=OS_FLAGS), # still in OS after handover
]
with pytest.raises(FlashError):
run(Flasher(t).enter_bootloader())
# --- Flasher: flash_image ---
def test_flash_image_full_sequence():
t = AsyncMock()
t.send_old.side_effect = [
old_resp(Cmd.BL_VERSION, arg0=BL_VERSION_1_0_0), # bl_version
old_resp(Cmd.ACK), # START_FLASH ack
old_resp(Cmd.ACK), # block 0
old_resp(Cmd.ACK), # block 1
]
image = make_elf(0x102000, b"\x11" * 700) # 2 blocks
result = run(Flasher(t).flash_image(image))
assert result == {"success": True, "blocks": 2, "bytes": 1024,
"flash_end": FLASH_END_512K}
calls = t.send_old.call_args_list
# START_FLASH: fullimage-only window (low=BOOTLOADER_END, no magic)
assert calls[1].args == (Cmd.START_FLASH, BOOTLOADER_END, FLASH_END_512K, 0)
# FINISH_WRITE per block at the right addresses, 512-byte payloads
assert calls[2].args[0] == Cmd.FINISH_WRITE
assert calls[2].args[1] == 0x102000
assert len(calls[2].args[4]) == 512
assert calls[3].args[1] == 0x102200
def test_flash_image_refuses_bootrom_region():
t = AsyncMock()
image = make_elf(FLASH_START, b"\x00" * 16) # 0x100000 = bootrom region
with pytest.raises(FlashError, match="bootrom"):
run(Flasher(t).flash_image(image))
t.send_old.assert_not_called()
def test_flash_image_allow_bootrom_uses_magic():
t = AsyncMock()
t.send_old.side_effect = [
old_resp(Cmd.BL_VERSION, arg0=BL_VERSION_1_0_0),
old_resp(Cmd.ACK), # START_FLASH
old_resp(Cmd.ACK), # 1 block
]
image = make_elf(FLASH_START, b"\x00" * 16)
run(Flasher(t).flash_image(image, allow_bootrom=True))
# window opens at FLASH_START and carries the unlock magic
assert t.send_old.call_args_list[1].args == (
Cmd.START_FLASH, FLASH_START, FLASH_END_512K, START_FLASH_MAGIC)
def test_flash_image_nack_raises_with_status():
t = AsyncMock()
t.send_old.side_effect = [
old_resp(Cmd.BL_VERSION, arg0=BL_VERSION_1_0_0),
old_resp(Cmd.ACK), # START_FLASH
old_resp(Cmd.NACK, arg0=0x8), # write NACK, EFC PROGE
]
image = make_elf(0x102000, b"\x11" * 16)
with pytest.raises(FlashError, match="0x8"):
run(Flasher(t).flash_image(image))
def test_flash_image_start_flash_not_acked():
t = AsyncMock()
t.send_old.side_effect = [
old_resp(Cmd.BL_VERSION, arg0=BL_VERSION_1_0_0),
old_resp(Cmd.NACK), # window arm rejected
]
image = make_elf(0x102000, b"\x11" * 16)
with pytest.raises(FlashError, match="START_FLASH"):
run(Flasher(t).flash_image(image))
# --- Flasher: end-to-end flash() ---
def test_flash_end_to_end_resets():
t = AsyncMock()
t.send_old.side_effect = [
old_resp(Cmd.DEVICE_INFO, arg0=BOOTROM_FLAGS), # enter_bootloader probe
old_resp(Cmd.BL_VERSION, arg0=BL_VERSION_1_0_0),
old_resp(Cmd.ACK), # START_FLASH
old_resp(Cmd.ACK), # block 0
old_resp(Cmd.ACK), # block 1
]
image = make_elf(0x102000, b"\x11" * 700)
result = run(Flasher(t).flash(image))
assert result["success"] is True
# reboot into the freshly written OS
t.send_old_no_response.assert_awaited_with(Cmd.HARDWARE_RESET)