diff --git a/uf2conv.py b/uf2conv.py index 529dd96..c5a832f 100644 --- a/uf2conv.py +++ b/uf2conv.py @@ -1,174 +1,426 @@ #!/usr/bin/env python3 -import sys -import struct -import subprocess -import re -import os -import os.path +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 +# --------------------------------------------------------------------------- -UF2_MAGIC_START0 = 0x0A324655 # "UF2\n" -UF2_MAGIC_START1 = 0x9E5D5157 # Randomly selected -UF2_MAGIC_END = 0x0AB16F30 # Ditto +#: 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 -INFO_FILE = "/INFO_UF2.TXT" +#: 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 -appstartaddr = 0x2000 -familyid = 0x0 +#: 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("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: - w = buf[0:30].decode("utf-8") + text = buf[:30].decode("utf-8") except UnicodeDecodeError: return False - if w[0] == ":" and re.match(rb"^[:0-9a-fA-F\r\n]+$", buf): - return True - return False + if not text.startswith(":"): + return False + return re.match(rb"^[:0-9a-fA-F\r\n]+$", buf) is not None -def convert_from_uf2(buf): - global appstartaddr - global familyid - numblocks = len(buf) // 512 - curraddr = None - currfamilyid = None - families_found = {} - prev_flag = 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 = [] + outp: list[bytes] = [] + for blockno in range(numblocks): - ptr = blockno * 512 - block = buf[ptr : ptr + 512] - hd = struct.unpack(b" 476: - assert False, "Invalid UF2 data size at " + ptr - newaddr = hd[3] - if (hd[2] & 0x2000) and (currfamilyid == None): - currfamilyid = hd[7] - if curraddr == None or ((hd[2] & 0x2000) and hd[7] != currfamilyid): - currfamilyid = hd[7] - curraddr = newaddr - if familyid == 0x0 or familyid == hd[7]: - appstartaddr = newaddr - padding = newaddr - curraddr + + 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: - assert False, "Block out of order at " + ptr + msg = f"Block out of order at {ptr:#x}: {target_addr:#x} < {curraddr:#x}" + raise Uf2Error(msg) if padding > 10 * 1024 * 1024: - assert False, "More than 10M of padding needed at " + ptr + msg = f"More than 10M of padding needed at {ptr:#x}" + raise Uf2Error(msg) if padding % 4 != 0: - assert False, "Non-word padding size at " + ptr + 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 familyid == 0x0 or ((hd[2] & 0x2000) and familyid == hd[7]): - outp.append(block[32 : 32 + datalen]) - curraddr = newaddr + datalen - if hd[2] & 0x2000: - if hd[7] in families_found.keys(): - if families_found[hd[7]] > newaddr: - families_found[hd[7]] = newaddr - else: - families_found[hd[7]] = newaddr - if prev_flag == None: - prev_flag = hd[2] - if prev_flag != hd[2]: + + 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 File Header Info ---") - families = load_families() - for family_hex in families_found.keys(): - family_short_name = "" - for name, value in families.items(): - if value == family_hex: - family_short_name = name - print( - "Family ID is {:s}, hex value is 0x{:08x}".format( - family_short_name, family_hex - ) - ) - print("Target Address is 0x{:08x}".format(families_found[family_hex])) - if all_flags_same: - print("All block flag values consistent, 0x{:04x}".format(hd[2])) - else: - print("Flags were not all the same") - print("----------------------------") - if len(families_found) > 1 and familyid == 0x0: + + 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 = [] - appstartaddr = 0x0 + ctx.appstartaddr = 0x0 + return b"".join(outp) -def convert_to_carray(file_content): - outp = "const unsigned long bindata_len = %d;\n" % len(file_content) - outp += "const unsigned char bindata[] __attribute__((aligned(16))) = {" - for i in range(len(file_content)): - if i % 16 == 0: - outp += "\n" - outp += "0x%02x, " % file_content[i] - outp += "\n};\n" - return bytes(outp, "utf-8") +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("----------------------------") -def convert_to_uf2(file_content): - global familyid - datapadding = b"" - while len(datapadding) < 512 - 256 - 32 - 4: - datapadding += b"\x00\x00\x00\x00" +# --------------------------------------------------------------------------- +# 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 - outp = [] + 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] - flags = 0x0 - if familyid: - flags |= 0x2000 - hd = struct.pack( - b" 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: - def __init__(self, addr, default_data=0xFF): - self.addr = addr - self.bytes = bytearray([default_data] * 256) + """A single 256-byte-aligned region of an image under construction. - def encode(self, blockno, numblocks): - global familyid - flags = 0x0 - if familyid: - flags |= 0x2000 - hd = struct.pack( - " 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, @@ -176,263 +428,434 @@ class Block: 256, blockno, numblocks, - familyid, + ctx.familyid, ) - hd += self.bytes[0:256] - while len(hd) < 512 - 4: - hd += b"\x00" - hd += struct.pack(" 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 = None - blocks = [] - for line in buf.split("\n"): - if line[0] != ":": + currblock: Block | None = None + blocks: list[Block] = [] + + for line in text.split("\n"): + if not line.startswith(":"): continue - i = 1 - rec = [] - while i < len(line) - 1: - rec.append(int(line[i : i + 2], 16)) - i += 2 - tp = rec[3] - if tp == 4: - upper = ((rec[4] << 8) | rec[5]) << 16 - elif tp == 2: - upper = ((rec[4] << 8) | rec[5]) << 4 - elif tp == 1: + 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 tp == 0: - addr = upper + ((rec[1] << 8) | rec[2]) - if appstartaddr == None: - appstartaddr = addr - i = 4 - while i < len(rec) - 1: - if not currblock or currblock.addr & ~0xFF != addr & ~0xFF: + 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.bytes[addr & 0xFF] = rec[i] + currblock.data[addr & 0xFF] = record[index] addr += 1 - i += 1 + index += 1 + numblocks = len(blocks) - resfile = b"" - for i in range(0, numblocks): - resfile += blocks[i].encode(i, numblocks) - return resfile + return b"".join(blocks[i].encode(i, numblocks, ctx) for i in range(numblocks)) -def to_str(b): - return b.decode("utf-8") +# --------------------------------------------------------------------------- +# Drive discovery +# --------------------------------------------------------------------------- -def get_drives(): - drives = [] +def _iter_candidate_dirs() -> list[Path]: + """Return the directories that may contain mounted removable volumes.""" if sys.platform == "win32": - r = subprocess.check_output( - [ - "powershell", - "-Command", - "(Get-WmiObject Win32_LogicalDisk -Filter \"VolumeName='RPI-RP2'\").DeviceID", - ] - ) - drive = to_str(r).strip() - if drive: - drives.append(drive) - else: - searchpaths = ["/mnt", "/media"] - if sys.platform == "darwin": - searchpaths = ["/Volumes"] - elif sys.platform == "linux": + 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 += [ - "/media/" + os.environ["USER"], - "/run/media/" + os.environ["USER"], + Path("/media") / sudo_user, + Path("/run/media") / sudo_user, ] - if "SUDO_USER" in os.environ.keys(): - searchpaths += ["/media/" + os.environ["SUDO_USER"]] - searchpaths += ["/run/media/" + os.environ["SUDO_USER"]] + return searchpaths - for rootpath in searchpaths: - if os.path.isdir(rootpath): - for d in os.listdir(rootpath): - if os.path.isdir(os.path.join(rootpath, d)): - drives.append(os.path.join(rootpath, d)) - def has_info(d): +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: - return os.path.isfile(d + INFO_FILE) - except: - return False + 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 - return list(filter(has_info, 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): - with open(path + INFO_FILE, mode="r") as file: - file_content = file.read() - return re.search(r"Board-ID: ([^\r\n]*)", file_content).group(1) +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(): - for d in get_drives(): - print(d, board_id(d)) +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} ") -def write_file(name, buf): - with open(name, "wb") as f: - f.write(buf) - print("Wrote %d bytes to %s" % (len(buf), name)) +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(): - # The expectation is that the `uf2families.json` file is in the same - # directory as this script. Make a path that works using `__file__` - # which contains the full path to this script. - filename = "uf2families.json" - pathname = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename) - with open(pathname) as f: - raw_families = json.load(f) +def load_families() -> dict[str, int]: + """Load the family-name to family-ID table. - families = {} - for family in raw_families: - families[family["short_name"]] = int(family["id"], 0) + ``uf2families.json`` is read from the directory containing this script, so + the converter works regardless of the caller's working directory. - return families + 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} -def main(): - global appstartaddr, familyid +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- - def error(msg): - print(msg, file=sys.stderr) - sys.exit(1) - parser = argparse.ArgumentParser(description="Convert to UF2 or flash directly.") +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", - type=str, nargs="?", - help="input file (HEX, BIN or UF2)", + help="input file (HEX, BIN or UF2); omit when using --list", ) parser.add_argument( "-b", "--base", - dest="base", - type=str, - default="0x2000", - help="set base address of application for BIN format (default: 0x2000)", + 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", - dest="family", - type=str, - default="0x0", - help="specify familyID - number or name (default: 0x0)", + 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", - dest="output", - type=str, - help='write output to named file; defaults to "flash.uf2" or "flash.bin" where sensible', + help='write output to FILE; defaults to "flash.uf2" or "flash.bin"', ) parser.add_argument( - "-d", "--device", dest="device_path", help="select a device path to flash" + "-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 devices" + "-l", + "--list", + action="store_true", + help="list connected Pico boot drives and exit", ) parser.add_argument( - "-c", "--convert", action="store_true", help="do not flash, just convert" + "-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="just flash, do not convert" + "-D", + "--deploy", + action="store_true", + help="deploy the input file unchanged; do not convert", ) parser.add_argument( - "-w", "--wait", action="store_true", help="wait for device to flash" + "-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="convert binary file to a C array, not UF2", + help="emit a C array initialiser instead of a UF2 file", ) parser.add_argument( "-i", "--info", action="store_true", - help="display header information from UF2, do not convert", + help="print UF2 header information and exit without converting", ) - args = parser.parse_args() - appstartaddr = int(args.base, 0) + 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 - if args.family.upper() in families: - familyid = families[args.family.upper()] - else: - try: - familyid = int(args.family, 0) - except ValueError: - error( - "Family ID needs to be a number or one of: " - + ", ".join(families.keys()) - ) + +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() - else: - if not args.input: - error("Need input file") - with open(args.input, mode="rb") as f: - inpbuf = f.read() - from_uf2 = is_uf2(inpbuf) - ext = "uf2" + 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) + outbuf = convert_from_uf2(inpbuf, ctx) ext = "bin" elif from_uf2 and args.info: - outbuf = "" - convert_from_uf2(inpbuf) + outbuf = b"" + convert_from_uf2(inpbuf, ctx) elif is_hex(inpbuf): - outbuf = convert_from_hex_to_uf2(inpbuf.decode("utf-8")) + 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) - if not args.deploy and not args.info: - print( - "Converted to %s, output size: %d, start address: 0x%x" - % (ext, len(outbuf), appstartaddr) - ) - if args.convert or ext != "uf2": - if args.output == None: - args.output = "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 len(drives) == 0: - if args.wait: - print("Waiting for drive to deploy...") - while len(drives) == 0: - sleep(0.1) - drives = get_drives() - elif not args.output: - error("No drive to deploy.") - for d in drives: - print("Flashing %s (%s)" % (d, board_id(d))) - write_file(d + "/NEW.UF2", outbuf) + 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__": - main() + sys.exit(main())