mirror of
https://github.com/mytechnotalent/Embedded-Hacking.git
synced 2026-10-03 22:47:04 +02:00
Single canonical copy at the repo root: full type annotations and Google-style docstrings, pathlib instead of os.path, explicit except clauses, upstream error-path bugs fixed, and module globals replaced with an explicit ConverterContext.
862 lines
27 KiB
Python
862 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
r"""Convert firmware images between Intel HEX, raw BIN, and Raspberry Pi UF2.
|
|
|
|
This is the course's single canonical copy of the converter. Every week that
|
|
patches a binary refers to the copy at the repository root:
|
|
|
|
python ../uf2conv.py build/image-h.bin --base 0x10000000 \\
|
|
--family 0xe48bff59 --output build/hacked.uf2
|
|
|
|
It descends from the upstream Raspberry Pi ``uf2conv.py``, with the following
|
|
changes made for this course:
|
|
|
|
* Full type annotations and Google-style docstrings on every public callable.
|
|
* ``pathlib`` instead of ``os.path``, and explicit ``except`` clauses.
|
|
* Latent crash bugs in the upstream error paths fixed (see
|
|
:func:`convert_from_uf2`).
|
|
* Module globals replaced with an explicit :class:`ConverterContext`, so the
|
|
base address and family ID are threaded through rather than mutated behind
|
|
the caller's back.
|
|
|
|
UF2 format, for reference
|
|
-------------------------
|
|
A UF2 file is a flat sequence of 512-byte blocks. Each block is::
|
|
|
|
offset size field
|
|
0x00 4 magicStart0 = 0x0A324655 ("UF2\\n")
|
|
0x04 4 magicStart1 = 0x9E5D5157 (arbitrary constant)
|
|
0x08 4 flags
|
|
0x0C 4 targetAddr
|
|
0x10 4 payloadSize (max 476)
|
|
0x14 4 blockNo
|
|
0x18 4 numBlocks
|
|
0x1C 4 familyID
|
|
0x20 476 data
|
|
0x1FC 4 magicEnd = 0x0AB16F30
|
|
|
|
Flags that matter here:
|
|
|
|
* ``0x00000001`` -- "do not flash"; this block is a no-flash marker.
|
|
* ``0x00002000`` -- "family ID present"; ``familyID`` is meaningful.
|
|
|
|
For the RP2350 the family ID is ``0xe48bff59``, and it is what tells the
|
|
boot ROM that this UF2 targets RP2350 hardware rather than, say, an RP2040 or
|
|
a Pico W.
|
|
|
|
See Also:
|
|
--------
|
|
* ``uf2families.json`` -- the family-name to family-ID table, loaded from the
|
|
directory containing this file.
|
|
* ``flash.sh`` / ``flash.ps1`` -- program a raw ``.bin`` over SWD instead.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from time import sleep
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# UF2 format constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
#: First magic word, ``"UF2\n"`` read as a little-endian uint32.
|
|
UF2_MAGIC_START0 = 0x0A324655
|
|
#: Second magic word. The value is arbitrary but must be constant.
|
|
UF2_MAGIC_START1 = 0x9E5D5157
|
|
#: Trailing magic word closing every 512-byte block.
|
|
UF2_MAGIC_END = 0x0AB16F30
|
|
|
|
#: Size of one UF2 block in bytes.
|
|
UF2_BLOCK_SIZE = 512
|
|
#: Offset of the 32-byte block header within a block.
|
|
UF2_HEADER_SIZE = 32
|
|
#: Largest payload a single block may carry (476 = 512 - 32 header - 4 magic).
|
|
UF2_MAX_PAYLOAD = UF2_BLOCK_SIZE - UF2_HEADER_SIZE - 4
|
|
|
|
#: Flag bit marking a block the bootloader must NOT program.
|
|
UF2_FLAG_NOFLASH = 0x00000001
|
|
#: Flag bit announcing that ``familyID`` carries a meaningful value.
|
|
UF2_FLAG_FAMILY_ID = 0x00002000
|
|
|
|
#: Name of the marker file the boot ROM exposes on its flash-drive volume.
|
|
INFO_FILE = "INFO_UF2.TXT"
|
|
|
|
#: Struct format for the 32-byte UF2 block header.
|
|
_HEADER_STRUCT = struct.Struct("<IIIIIIII")
|
|
|
|
#: The default family ID assumed when the caller does not pass ``--family``.
|
|
DEFAULT_FAMILY_ID = 0x0
|
|
#: The default base address assumed when the caller does not pass ``--base``.
|
|
#: Matches the upstream default; RP2350 firmware always overrides this.
|
|
DEFAULT_BASE_ADDRESS = 0x2000
|
|
|
|
|
|
class Uf2Error(Exception):
|
|
"""Raised when an input file is malformed or internally inconsistent."""
|
|
|
|
|
|
@dataclass
|
|
class ConverterContext:
|
|
"""Mutable conversion state shared across a single CLI invocation.
|
|
|
|
Attributes:
|
|
appstartaddr: Base address that payload offsets are relative to. Set
|
|
from ``--base``; ``0`` means "derive it from the input".
|
|
familyid: Family ID written into every UF2 block. ``0`` means "no
|
|
family flag", which is the correct behaviour for a plain BIN->UF2
|
|
conversion of RP2040-era images, and the wrong behaviour for
|
|
RP2350, which is why the course always passes ``--family``.
|
|
"""
|
|
|
|
appstartaddr: int | None = DEFAULT_BASE_ADDRESS
|
|
familyid: int = DEFAULT_FAMILY_ID
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Format detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def is_uf2(buf: bytes) -> bool:
|
|
"""Report whether a buffer begins with the UF2 start magic.
|
|
|
|
Args:
|
|
buf: Raw file contents.
|
|
|
|
Returns:
|
|
``True`` if the first two uint32 words are the UF2 start magic.
|
|
"""
|
|
if len(buf) < 8:
|
|
return False
|
|
word0, word1 = _HEADER_STRUCT.unpack_from(buf, 0)[:2]
|
|
return word0 == UF2_MAGIC_START0 and word1 == UF2_MAGIC_START1
|
|
|
|
|
|
def is_hex(buf: bytes) -> bool:
|
|
"""Report whether a buffer looks like an Intel HEX file.
|
|
|
|
A file qualifies when it decodes as UTF-8, begins with a record marker
|
|
``:``, and contains only characters legal in Intel HEX records.
|
|
|
|
Args:
|
|
buf: Raw file contents.
|
|
|
|
Returns:
|
|
``True`` if the buffer should be treated as Intel HEX.
|
|
"""
|
|
try:
|
|
text = buf[:30].decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
return False
|
|
if not text.startswith(":"):
|
|
return False
|
|
return re.match(rb"^[:0-9a-fA-F\r\n]+$", buf) is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# UF2 -> BIN
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def convert_from_uf2(buf: bytes, ctx: ConverterContext) -> bytes:
|
|
"""Decode a UF2 file back into a flat binary image.
|
|
|
|
Blocks are visited in file order. Gaps between consecutive block addresses
|
|
are filled with zero words, and any trailing gap is dropped so that the
|
|
result ends at the last byte actually programmed.
|
|
|
|
A UF2 file may legitimately interleave blocks from several families (this
|
|
is how a multicore image ships both cores in one download). When more than
|
|
one family is present and the caller did not constrain the family with
|
|
``--family``, the conversion is ambiguous, so the payload is emptied and
|
|
``appstartaddr`` reset to ``0``.
|
|
|
|
Args:
|
|
buf: Complete UF2 file contents.
|
|
ctx: Conversion state; updated in place with the discovered base
|
|
address.
|
|
|
|
Returns:
|
|
The decoded payload.
|
|
|
|
Raises:
|
|
Uf2Error: If a block declares an oversized payload, the blocks are out
|
|
of order, or the implied padding exceeds 10 MiB.
|
|
|
|
Note:
|
|
The upstream Raspberry Pi version of this function crashes on its own
|
|
error paths: it builds the message with ``"..." + ptr`` where ``ptr``
|
|
is an ``int``, which raises :class:`TypeError` and masks the real
|
|
problem. The messages here use f-strings so the diagnostic survives.
|
|
"""
|
|
numblocks = len(buf) // UF2_BLOCK_SIZE
|
|
curraddr: int | None = None
|
|
currfamilyid: int | None = None
|
|
families_found: dict[int, int] = {}
|
|
prev_flag: int | None = None
|
|
all_flags_same = True
|
|
outp: list[bytes] = []
|
|
|
|
for blockno in range(numblocks):
|
|
ptr = blockno * UF2_BLOCK_SIZE
|
|
block = buf[ptr : ptr + UF2_BLOCK_SIZE]
|
|
(
|
|
magic0,
|
|
magic1,
|
|
flags,
|
|
target_addr,
|
|
datalen,
|
|
_blockno,
|
|
_numblocks,
|
|
block_family,
|
|
) = _HEADER_STRUCT.unpack_from(block, 0)
|
|
|
|
if magic0 != UF2_MAGIC_START0 or magic1 != UF2_MAGIC_START1:
|
|
print(f"Skipping block at {ptr:#x}; bad magic")
|
|
continue
|
|
|
|
if flags & UF2_FLAG_NOFLASH:
|
|
continue
|
|
|
|
if datalen > UF2_MAX_PAYLOAD:
|
|
msg = f"Invalid UF2 data size at {ptr:#x}: {datalen}"
|
|
raise Uf2Error(msg)
|
|
|
|
if flags & UF2_FLAG_FAMILY_ID and currfamilyid is None:
|
|
currfamilyid = block_family
|
|
|
|
# A new contiguous run starts when the address jumps or the family
|
|
# changes. appstartaddr tracks the start of the current run, which is
|
|
# what makes the reported start address the image's true base rather
|
|
# than the address of its last block.
|
|
if curraddr is None or (
|
|
flags & UF2_FLAG_FAMILY_ID and block_family != currfamilyid
|
|
):
|
|
currfamilyid = block_family
|
|
curraddr = target_addr
|
|
if ctx.familyid == DEFAULT_FAMILY_ID or ctx.familyid == block_family:
|
|
ctx.appstartaddr = target_addr
|
|
|
|
padding = target_addr - curraddr
|
|
if padding < 0:
|
|
msg = f"Block out of order at {ptr:#x}: {target_addr:#x} < {curraddr:#x}"
|
|
raise Uf2Error(msg)
|
|
if padding > 10 * 1024 * 1024:
|
|
msg = f"More than 10M of padding needed at {ptr:#x}"
|
|
raise Uf2Error(msg)
|
|
if padding % 4 != 0:
|
|
msg = f"Non-word padding size at {ptr:#x}: {padding}"
|
|
raise Uf2Error(msg)
|
|
|
|
while padding > 0:
|
|
padding -= 4
|
|
outp.append(b"\x00\x00\x00\x00")
|
|
|
|
if ctx.familyid == DEFAULT_FAMILY_ID or (
|
|
flags & UF2_FLAG_FAMILY_ID and ctx.familyid == block_family
|
|
):
|
|
outp.append(
|
|
block[UF2_HEADER_SIZE : UF2_HEADER_SIZE + datalen],
|
|
)
|
|
|
|
curraddr = target_addr + datalen
|
|
|
|
if flags & UF2_FLAG_FAMILY_ID:
|
|
existing = families_found.get(block_family)
|
|
if existing is None or existing > target_addr:
|
|
families_found[block_family] = target_addr
|
|
|
|
if prev_flag is None:
|
|
prev_flag = flags
|
|
elif prev_flag != flags:
|
|
all_flags_same = False
|
|
|
|
if blockno == numblocks - 1:
|
|
_print_uf2_header_info(families_found, all_flags_same, flags)
|
|
if len(families_found) > 1 and ctx.familyid == DEFAULT_FAMILY_ID:
|
|
outp = []
|
|
ctx.appstartaddr = 0x0
|
|
|
|
return b"".join(outp)
|
|
|
|
|
|
def _print_uf2_header_info(
|
|
families_found: dict[int, int],
|
|
all_flags_same: bool,
|
|
flags: int,
|
|
) -> None:
|
|
"""Print the family summary block that follows a UF2->BIN conversion.
|
|
|
|
Args:
|
|
families_found: Mapping of family ID to the lowest target address seen
|
|
for that family.
|
|
all_flags_same: Whether every block carried an identical flag word.
|
|
flags: The flag word from the final block, used for display.
|
|
"""
|
|
print("--- UF2 File Header Info ---")
|
|
families = load_families()
|
|
name_by_id = {value: key for key, value in families.items()}
|
|
for family_hex, address in families_found.items():
|
|
short_name = name_by_id.get(family_hex, "")
|
|
print(f"Family ID is {short_name}, hex value is {family_hex:#010x}")
|
|
print(f"Target Address is {address:#010x}")
|
|
if all_flags_same:
|
|
print(f"All block flag values consistent, {flags:#06x}")
|
|
else:
|
|
print("Flags were not all the same")
|
|
print("----------------------------")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BIN -> UF2
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def convert_to_uf2(file_content: bytes, ctx: ConverterContext) -> bytes:
|
|
"""Wrap a raw binary in UF2 blocks.
|
|
|
|
The binary is split into 256-byte payloads; the final block is zero-padded.
|
|
Each block's ``targetAddr`` is the byte offset within the image plus
|
|
:attr:`ConverterContext.appstartaddr`, so for RP2350 firmware built at
|
|
``0x10000000`` the caller passes ``--base 0x10000000``.
|
|
|
|
Args:
|
|
file_content: The raw image.
|
|
ctx: Conversion state supplying the base address and family ID.
|
|
|
|
Returns:
|
|
The UF2 file contents, always a whole number of 512-byte blocks.
|
|
"""
|
|
datapadding = bytes(UF2_BLOCK_SIZE - 256 - UF2_HEADER_SIZE - 4)
|
|
numblocks = (len(file_content) + 255) // 256
|
|
flags = UF2_FLAG_FAMILY_ID if ctx.familyid else 0x0
|
|
base = ctx.appstartaddr or 0
|
|
|
|
blocks: list[bytes] = []
|
|
for blockno in range(numblocks):
|
|
ptr = 256 * blockno
|
|
chunk = file_content[ptr : ptr + 256]
|
|
header = _HEADER_STRUCT.pack(
|
|
UF2_MAGIC_START0,
|
|
UF2_MAGIC_START1,
|
|
flags,
|
|
ptr + base,
|
|
256,
|
|
blockno,
|
|
numblocks,
|
|
ctx.familyid,
|
|
)
|
|
payload = chunk + bytes(256 - len(chunk))
|
|
block = header + payload + datapadding + struct.pack("<I", UF2_MAGIC_END)
|
|
blocks.append(block)
|
|
return b"".join(blocks)
|
|
|
|
|
|
def convert_to_carray(file_content: bytes) -> bytes:
|
|
"""Render a binary as a C array initialiser for embedding in firmware.
|
|
|
|
Args:
|
|
file_content: The raw image.
|
|
|
|
Returns:
|
|
UTF-8 bytes of a C translation unit fragment.
|
|
"""
|
|
parts = [
|
|
f"const unsigned long bindata_len = {len(file_content)};\n",
|
|
"const unsigned char bindata[] __attribute__((aligned(16))) = {",
|
|
]
|
|
for index, value in enumerate(file_content):
|
|
if index % 16 == 0:
|
|
parts.append("\n")
|
|
parts.append(f"{value:#04x}, ")
|
|
parts.append("\n};\n")
|
|
return "".join(parts).encode()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Intel HEX -> UF2
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class Block:
|
|
"""A single 256-byte-aligned region of an image under construction.
|
|
|
|
Attributes:
|
|
addr: Base address of the block, always 256-byte aligned.
|
|
data: The block's bytes, initialised to ``default_data``.
|
|
"""
|
|
|
|
addr: int
|
|
data: bytearray
|
|
|
|
def __init__(self, addr: int, default_data: int = 0xFF) -> None:
|
|
"""Initialise an erased 256-byte block.
|
|
|
|
Args:
|
|
addr: 256-byte-aligned base address of the block.
|
|
default_data: Fill byte; ``0xFF`` matches erased flash.
|
|
"""
|
|
self.addr = addr
|
|
self.data = bytearray([default_data] * 256)
|
|
|
|
def encode(self, blockno: int, numblocks: int, ctx: ConverterContext) -> bytes:
|
|
"""Serialise this block into a 512-byte UF2 block.
|
|
|
|
Args:
|
|
blockno: Index of this block within the file.
|
|
numblocks: Total number of blocks in the file.
|
|
ctx: Conversion state supplying the family ID.
|
|
|
|
Returns:
|
|
Exactly 512 bytes.
|
|
"""
|
|
flags = UF2_FLAG_FAMILY_ID if ctx.familyid else 0x0
|
|
out = _HEADER_STRUCT.pack(
|
|
UF2_MAGIC_START0,
|
|
UF2_MAGIC_START1,
|
|
flags,
|
|
self.addr,
|
|
256,
|
|
blockno,
|
|
numblocks,
|
|
ctx.familyid,
|
|
)
|
|
out += self.data[0:256]
|
|
out += bytes(UF2_BLOCK_SIZE - 4 - len(out))
|
|
out += struct.pack("<I", UF2_MAGIC_END)
|
|
return out
|
|
|
|
|
|
def convert_from_hex_to_uf2(text: str, ctx: ConverterContext) -> bytes:
|
|
"""Assemble Intel HEX records into a UF2 file.
|
|
|
|
Only record types 0x00 (data), 0x01 (EOF), 0x02 (extended segment address)
|
|
and 0x04 (extended linear address) are handled; any other type is ignored,
|
|
matching the upstream behaviour.
|
|
|
|
Args:
|
|
text: Decoded Intel HEX file contents.
|
|
ctx: Conversion state; ``appstartaddr`` is set to the first data
|
|
address seen.
|
|
|
|
Returns:
|
|
The UF2 file contents.
|
|
"""
|
|
ctx.appstartaddr = None
|
|
upper = 0
|
|
currblock: Block | None = None
|
|
blocks: list[Block] = []
|
|
|
|
for line in text.split("\n"):
|
|
if not line.startswith(":"):
|
|
continue
|
|
record = [int(line[i : i + 2], 16) for i in range(1, len(line) - 1, 2)]
|
|
rec_type = record[3]
|
|
if rec_type == 4:
|
|
upper = ((record[4] << 8) | record[5]) << 16
|
|
elif rec_type == 2:
|
|
upper = ((record[4] << 8) | record[5]) << 4
|
|
elif rec_type == 1:
|
|
break
|
|
elif rec_type == 0:
|
|
addr = upper + ((record[1] << 8) | record[2])
|
|
if ctx.appstartaddr is None:
|
|
ctx.appstartaddr = addr
|
|
index = 4
|
|
while index < len(record) - 1:
|
|
if currblock is None or currblock.addr & ~0xFF != addr & ~0xFF:
|
|
currblock = Block(addr & ~0xFF)
|
|
blocks.append(currblock)
|
|
currblock.data[addr & 0xFF] = record[index]
|
|
addr += 1
|
|
index += 1
|
|
|
|
numblocks = len(blocks)
|
|
return b"".join(blocks[i].encode(i, numblocks, ctx) for i in range(numblocks))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Drive discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _iter_candidate_dirs() -> list[Path]:
|
|
"""Return the directories that may contain mounted removable volumes."""
|
|
if sys.platform == "win32":
|
|
return []
|
|
|
|
searchpaths = [Path("/mnt"), Path("/media")]
|
|
if sys.platform == "darwin":
|
|
searchpaths = [Path("/Volumes")]
|
|
elif sys.platform == "linux":
|
|
user = environ_user()
|
|
if user:
|
|
searchpaths += [Path("/media") / user, Path("/run/media") / user]
|
|
sudo_user = os.environ.get("SUDO_USER")
|
|
if sudo_user:
|
|
searchpaths += [
|
|
Path("/media") / sudo_user,
|
|
Path("/run/media") / sudo_user,
|
|
]
|
|
return searchpaths
|
|
|
|
|
|
def environ_user() -> str | None:
|
|
"""Return the current user name from the environment.
|
|
|
|
Tried under several variable names so the search works on macOS, Linux and
|
|
Windows shells alike.
|
|
|
|
Returns:
|
|
The user name, or ``None`` if it cannot be determined.
|
|
"""
|
|
for var in ("USER", "USERNAME", "LOGNAME"):
|
|
value = os.environ.get(var)
|
|
if value:
|
|
return value
|
|
return None
|
|
|
|
|
|
def get_drives() -> list[Path]:
|
|
"""Find mounted Pico boot drives.
|
|
|
|
A drive qualifies when it contains the ``INFO_UF2.TXT`` marker the boot ROM
|
|
writes when it presents the flash as a USB mass-storage device.
|
|
|
|
Returns:
|
|
Every qualifying mount point.
|
|
"""
|
|
drives: list[Path] = []
|
|
|
|
if sys.platform == "win32":
|
|
command = (
|
|
"(Get-Volume | Where-Object { $_.FileSystemLabel -match 'RP2350|RPI-RP2' })"
|
|
".DriveLetter"
|
|
)
|
|
try:
|
|
raw = subprocess.check_output(
|
|
["powershell", "-Command", command],
|
|
text=True,
|
|
)
|
|
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
|
return []
|
|
for letter in raw.split():
|
|
if len(letter) == 1 and letter.isalpha():
|
|
drives.append(Path(f"{letter.upper()}:\\"))
|
|
return drives
|
|
|
|
for rootpath in _iter_candidate_dirs():
|
|
if not rootpath.is_dir():
|
|
continue
|
|
try:
|
|
entries = list(rootpath.iterdir())
|
|
except OSError:
|
|
continue
|
|
for entry in entries:
|
|
if not entry.is_dir():
|
|
continue
|
|
if (entry / INFO_FILE).is_file():
|
|
drives.append(entry)
|
|
return drives
|
|
|
|
|
|
def board_id(path: Path) -> str:
|
|
"""Read the ``Board-ID`` field from a boot drive's info file.
|
|
|
|
Args:
|
|
path: Mount point of the boot drive.
|
|
|
|
Returns:
|
|
The board ID string, for example ``RP2350``.
|
|
|
|
Raises:
|
|
Uf2Error: If the info file is missing or has no ``Board-ID`` field.
|
|
"""
|
|
info = path / INFO_FILE
|
|
if not info.is_file():
|
|
msg = f"Not a Pico boot drive: {path} has no {INFO_FILE}"
|
|
raise Uf2Error(msg)
|
|
match = re.search(r"Board-ID: ([^\r\n]*)", info.read_text())
|
|
if match is None:
|
|
msg = f"No Board-ID field in {info}"
|
|
raise Uf2Error(msg)
|
|
return match.group(1)
|
|
|
|
|
|
def list_drives() -> None:
|
|
"""Print every connected Pico boot drive and its board ID."""
|
|
for drive in get_drives():
|
|
try:
|
|
print(drive, board_id(drive))
|
|
except Uf2Error as exc:
|
|
print(f"{drive} <unreadable: {exc}>")
|
|
|
|
|
|
def write_file(name: Path | str, buf: bytes) -> None:
|
|
"""Write a buffer to disk and report the size written.
|
|
|
|
Args:
|
|
name: Destination path.
|
|
buf: Bytes to write.
|
|
"""
|
|
path = Path(name)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(buf)
|
|
print(f"Wrote {len(buf)} bytes to {path}")
|
|
|
|
|
|
def load_families() -> dict[str, int]:
|
|
"""Load the family-name to family-ID table.
|
|
|
|
``uf2families.json`` is read from the directory containing this script, so
|
|
the converter works regardless of the caller's working directory.
|
|
|
|
Returns:
|
|
Mapping of upper-case short name to family ID.
|
|
|
|
Raises:
|
|
Uf2Error: If the JSON file is missing or malformed.
|
|
"""
|
|
pathname = Path(__file__).resolve().parent / "uf2families.json"
|
|
try:
|
|
raw_families = json.loads(pathname.read_text())
|
|
except FileNotFoundError as exc:
|
|
msg = f"Required family table not found: {pathname}"
|
|
raise Uf2Error(msg) from exc
|
|
except json.JSONDecodeError as exc:
|
|
msg = f"Malformed family table {pathname}: {exc}"
|
|
raise Uf2Error(msg) from exc
|
|
|
|
return {fam["short_name"]: int(fam["id"], 0) for fam in raw_families}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_parser() -> argparse.ArgumentParser:
|
|
"""Construct the command-line argument parser.
|
|
|
|
Returns:
|
|
A configured :class:`argparse.ArgumentParser`.
|
|
"""
|
|
parser = argparse.ArgumentParser(
|
|
prog="uf2conv.py",
|
|
description="Convert firmware between Intel HEX, raw BIN and UF2.",
|
|
epilog=(
|
|
"Course example (RP2350 raw image built for XIP flash):\n"
|
|
" python ../uf2conv.py build/image-h.bin "
|
|
"--base 0x10000000 \\\n"
|
|
" --family 0xe48bff59 --output build/hacked.uf2\n"
|
|
),
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"input",
|
|
metavar="INPUT",
|
|
nargs="?",
|
|
help="input file (HEX, BIN or UF2); omit when using --list",
|
|
)
|
|
parser.add_argument(
|
|
"-b",
|
|
"--base",
|
|
default=hex(DEFAULT_BASE_ADDRESS),
|
|
help=(
|
|
"base address of the application for BIN input "
|
|
f"(default: {hex(DEFAULT_BASE_ADDRESS)}; use 0x10000000 for RP2350 XIP)"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"-f",
|
|
"--family",
|
|
default=hex(DEFAULT_FAMILY_ID),
|
|
help=(
|
|
"family ID as a number or a name from uf2families.json "
|
|
f"(default: {hex(DEFAULT_FAMILY_ID)}; RP2350 is 0xe48bff59)"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"-o",
|
|
"--output",
|
|
metavar="FILE",
|
|
help='write output to FILE; defaults to "flash.uf2" or "flash.bin"',
|
|
)
|
|
parser.add_argument(
|
|
"-d",
|
|
"--device",
|
|
dest="device_path",
|
|
help="select a specific device path to flash (reserved; unused)",
|
|
)
|
|
parser.add_argument(
|
|
"-l",
|
|
"--list",
|
|
action="store_true",
|
|
help="list connected Pico boot drives and exit",
|
|
)
|
|
parser.add_argument(
|
|
"-c",
|
|
"--convert",
|
|
action="store_true",
|
|
help="convert only; do not deploy to a mounted boot drive",
|
|
)
|
|
parser.add_argument(
|
|
"-D",
|
|
"--deploy",
|
|
action="store_true",
|
|
help="deploy the input file unchanged; do not convert",
|
|
)
|
|
parser.add_argument(
|
|
"-w",
|
|
"--wait",
|
|
action="store_true",
|
|
help="poll for a boot drive to appear instead of failing immediately",
|
|
)
|
|
parser.add_argument(
|
|
"-C",
|
|
"--carray",
|
|
action="store_true",
|
|
help="emit a C array initialiser instead of a UF2 file",
|
|
)
|
|
parser.add_argument(
|
|
"-i",
|
|
"--info",
|
|
action="store_true",
|
|
help="print UF2 header information and exit without converting",
|
|
)
|
|
return parser
|
|
|
|
|
|
def _resolve_family(spec: str) -> int:
|
|
"""Resolve a ``--family`` value to a numeric family ID.
|
|
|
|
Args:
|
|
spec: Either a family short name (case-insensitive) or an integer
|
|
literal such as ``0xe48bff59``.
|
|
|
|
Returns:
|
|
The numeric family ID.
|
|
|
|
Raises:
|
|
Uf2Error: If the value is neither a known name nor a valid integer.
|
|
"""
|
|
families = load_families()
|
|
if spec.upper() in families:
|
|
return families[spec.upper()]
|
|
try:
|
|
return int(spec, 0)
|
|
except ValueError as exc:
|
|
msg = "Family ID needs to be a number or one of: " + ", ".join(families)
|
|
raise Uf2Error(msg) from exc
|
|
|
|
|
|
def _wait_for_drive() -> Path | None:
|
|
"""Poll for a boot drive to be mounted.
|
|
|
|
Returns:
|
|
The first drive found, or ``None`` if the wait was interrupted.
|
|
"""
|
|
print("Waiting for drive to deploy...")
|
|
while True:
|
|
drives = get_drives()
|
|
if drives:
|
|
return drives[0]
|
|
sleep(0.1)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Run the command-line converter.
|
|
|
|
Args:
|
|
argv: Argument list, defaulting to :data:`sys.argv` ``[1:]``.
|
|
|
|
Returns:
|
|
``0`` on success, ``1`` on a user or input error.
|
|
"""
|
|
parser = _build_parser()
|
|
args = parser.parse_args(argv)
|
|
|
|
ctx = ConverterContext()
|
|
try:
|
|
ctx.familyid = _resolve_family(args.family)
|
|
ctx.appstartaddr = int(args.base, 0)
|
|
except (Uf2Error, ValueError) as exc:
|
|
print(exc, file=sys.stderr)
|
|
return 1
|
|
|
|
if args.list:
|
|
list_drives()
|
|
return 0
|
|
|
|
if not args.input:
|
|
print("Need an input file (or use --list)", file=sys.stderr)
|
|
return 1
|
|
|
|
source = Path(args.input)
|
|
if not source.is_file():
|
|
print(f"error: file not found: {source}", file=sys.stderr)
|
|
return 1
|
|
|
|
inpbuf = source.read_bytes()
|
|
from_uf2 = is_uf2(inpbuf)
|
|
ext = "uf2"
|
|
|
|
try:
|
|
if args.deploy:
|
|
outbuf = inpbuf
|
|
elif from_uf2 and not args.info:
|
|
outbuf = convert_from_uf2(inpbuf, ctx)
|
|
ext = "bin"
|
|
elif from_uf2 and args.info:
|
|
outbuf = b""
|
|
convert_from_uf2(inpbuf, ctx)
|
|
elif is_hex(inpbuf):
|
|
outbuf = convert_from_hex_to_uf2(inpbuf.decode("utf-8"), ctx)
|
|
elif args.carray:
|
|
outbuf = convert_to_carray(inpbuf)
|
|
ext = "h"
|
|
else:
|
|
outbuf = convert_to_uf2(inpbuf, ctx)
|
|
except Uf2Error as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
if not args.deploy and not args.info:
|
|
print(
|
|
f"Converted to {ext}, output size: {len(outbuf)}, "
|
|
f"start address: {ctx.appstartaddr or 0:#x}",
|
|
)
|
|
|
|
if (args.convert or ext != "uf2") and args.output is None:
|
|
args.output = f"flash.{ext}"
|
|
if args.output:
|
|
write_file(args.output, outbuf)
|
|
|
|
if ext == "uf2" and not args.convert and not args.info:
|
|
drives = get_drives()
|
|
if not drives:
|
|
if args.wait:
|
|
found = _wait_for_drive()
|
|
drives = [found] if found else []
|
|
elif not args.output:
|
|
print("error: no drive to deploy", file=sys.stderr)
|
|
return 1
|
|
for drive in drives:
|
|
print(f"Flashing {drive} ({board_id(drive)})")
|
|
write_file(drive / "NEW.UF2", outbuf)
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|