Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions cli/src/mpftp/firmware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("-", "")
Expand Down Expand Up @@ -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 "",
Expand Down
40 changes: 40 additions & 0 deletions cli/tests/test_app_image_offset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading