From a99843e61f2035ff3334e1f5c08453bf3e9be610 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:11:48 -0500 Subject: [PATCH] firmware flash: with --artifact and no board, the image's own header names the chip and so the offset --- cli/src/mpftp/firmware.py | 31 +++++++++++++++++++++++ cli/tests/test_app_image_offset.py | 40 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/cli/src/mpftp/firmware.py b/cli/src/mpftp/firmware.py index b9bfcaa..ac8a6f7 100644 --- a/cli/src/mpftp/firmware.py +++ b/cli/src/mpftp/firmware.py @@ -1643,6 +1643,33 @@ def do_clean(ns: argparse.Namespace) -> None: } +# esp_chip_id_t, as an image header carries it (esp_app_format.h). +_MCU_BY_IMAGE_CHIP_ID = { + 0x0000: "esp32", + 0x0002: "esp32s2", + 0x0005: "esp32c3", + 0x0009: "esp32s3", + 0x000C: "esp32c2", + 0x000D: "esp32c6", + 0x0010: "esp32h2", + 0x0012: "esp32p4", + 0x0017: "esp32c5", +} + + +def esp32_image_family(artifact: Path) -> str: + """The chip a merged firmware.bin was built for, from its first image + header (magic 0xE9, chip_id at byte 12); "" when it isn't one.""" + try: + with open(artifact, "rb") as f: + head = f.read(16) + except OSError: + return "" + if len(head) < 14 or head[0] != 0xE9: + return "" + return _MCU_BY_IMAGE_CHIP_ID.get(int.from_bytes(head[12:14], "little"), "") + + def esp32_flash_offset_for_family(family: str) -> str: """Bootloader offset from MCU family string (e.g. Thonny catalog ``family``).""" mcu = (family or "").lower().replace("-", "") @@ -2016,6 +2043,10 @@ def app_image_at_bootloader_error(artifact: Path, offset: Any) -> Optional[str]: def flash_esp32(ns: argparse.Namespace, mp: Optional[Path], artifact: Path) -> None: port_dir = (mp / "ports" / ns.port) if mp else Path(".") family = getattr(ns, "family", "") or "" + if not family and not ns.board: + # An --artifact with no board: the image says which chip it is for. A + # P4 image written at 0x0 instead of 0x2000 boot-loops (2026-10-05). + family = esp32_image_family(artifact) offset = (getattr(ns, "offset", "") or "").strip() or esp32_flash_offset( port_dir, ns.board or "", diff --git a/cli/tests/test_app_image_offset.py b/cli/tests/test_app_image_offset.py index 1dddc37..18cb5e6 100644 --- a/cli/tests/test_app_image_offset.py +++ b/cli/tests/test_app_image_offset.py @@ -93,5 +93,45 @@ def test_flash_refuses_before_touching_the_device(self): self.assertIn("application image", kwargs["error"]) + +def combined(chip_id: int) -> bytes: + """A combined firmware.bin's first header, built for one chip.""" + header = bytearray(BOOTLOADER) + header[12:14] = chip_id.to_bytes(2, "little") + return bytes(header) + + +class OffsetFromImageTests(unittest.TestCase): + """2026-10-05: a P4 firmware.bin flashed with --artifact and no --board + went to 0x0, not 0x2000, and the DEV-KIT boot-looped. The image names + its chip, so the offset follows from it.""" + + def test_the_image_names_its_chip(self): + with tempfile.TemporaryDirectory() as tmp: + for chip_id, mcu in ((0x12, "esp32p4"), (0x09, "esp32s3"), (0x00, "esp32")): + fw = Path(tmp) / f"{mcu}.bin" + fw.write_bytes(combined(chip_id)) + self.assertEqual(firmware.esp32_image_family(fw), mcu) + junk = Path(tmp) / "junk.bin" + junk.write_bytes(b"\x00" * 64) + self.assertEqual(firmware.esp32_image_family(junk), "") + + def test_a_p4_image_with_no_board_goes_to_0x2000(self): + with tempfile.TemporaryDirectory() as tmp: + fw = Path(tmp) / "firmware.bin" + fw.write_bytes(combined(0x12)) + ns = argparse.Namespace( + port="esp32", board="", family="", offset="", device="COM31", + baud=460800, erase=False, before="", after="", board_dir="", + ) + with mock.patch.object(firmware, "emit_result"), \ + mock.patch.object(firmware, "emit_log") as log, \ + mock.patch.object(firmware, "_esptool_cmd", return_value=["esptool"]), \ + mock.patch.object(firmware, "_esp32_layout_check", return_value={}), \ + mock.patch.object(firmware, "stream_process", return_value=0): + firmware.flash_esp32(ns, None, fw) + self.assertIn(mock.call("[mpftp] flash offset 0x2000"), log.call_args_list) + + if __name__ == "__main__": unittest.main()