"""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( " 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_merges_shared_page(): # Two adjacent segments sharing the flash page at 0x102200: seg1 ends at # 0x1022AC, seg2 starts there. The shared page must keep seg1's real tail — # writing it twice (seg2's 0xFF head last) would wipe it (the on-hardware bug). seg1 = (0x102000, b"\x11" * 0x2AC) # ends 0x1022AC (unaligned) seg2 = (0x1022AC, b"\x22" * 0x100) # ends 0x1023AC blocks = dict(build_blocks([seg1, seg2])) shared = blocks[0x102200] # 0x102200..0x102400 assert shared[0x00:0xAC] == b"\x11" * 0xAC # seg1 tail preserved, NOT 0xFF assert shared[0xAC:0x100] == b"\x22" * 0x54 # seg2 head # no duplicate block addresses (would mean a double-write of a page) addrs = [a for a, _ in build_blocks([seg1, seg2])] assert len(addrs) == len(set(addrs)) def test_build_blocks_separate_when_far_apart(): # A real gap between segments -> separate groups, no giant fill between them. blocks = build_blocks([(0x102000, b"\x11" * 16), (0x120000, b"\x22" * 16)]) assert {a for a, _ in blocks} == {0x102000, 0x120000} 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)