mirror of
https://github.com/mytechnotalent/Embedded-Hacking.git
synced 2026-10-12 02:37:11 +02:00
Add Classified Brief 0x02 and remove hardcoded toolchains
This commit is contained in:
1 parent
ea7fa10403
commit
9d307322d5
44 files changed
+2065
-27
No files matched your search
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
# MIT License
|
||||
#
|
||||
# Copyright (c) 2026 Kevin Thomas
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in all
|
||||
# copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
#
|
||||
# Author: Kevin Thomas
|
||||
# Email: kevin@mytechnotalent.com
|
||||
# GitHub: https://github.com/mytechnotalent
|
||||
# File: lora_console.py
|
||||
# Desc: Interactive REYAX RYLR998 terminal for the FT232RL ground station.
|
||||
# Created: 2026
|
||||
|
||||
"""
|
||||
Interactive LoRa terminal for the REYAX RYLR998 ground station.
|
||||
|
||||
Sends AT commands as complete bursts terminated with a carriage return and line
|
||||
feed to avoid the RYLR998 inter-character timeout error, and prints incoming
|
||||
packets from the airborne node as they arrive.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
serial = None
|
||||
|
||||
|
||||
def _reader_thread(ser: "serial.Serial") -> None:
|
||||
"""
|
||||
Continuously read and display incoming packets from the radio.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ser : serial.Serial
|
||||
Open serial connection to the ground RYLR998.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
line = ser.readline().decode("utf-8", errors="ignore").strip()
|
||||
if line:
|
||||
print(f"\n[LORA RX] {line}\n> ", end="", flush=True)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
"""
|
||||
Parse command line arguments for the LoRa console.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
None
|
||||
|
||||
Returns
|
||||
-------
|
||||
argparse.Namespace
|
||||
Parsed arguments with the serial port and baud rate.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="REYAX RYLR998 console")
|
||||
parser.add_argument("--port", default="/dev/cu.usbserial-A50285BI")
|
||||
parser.add_argument("--baud", type=int, default=115200)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _open_radio(port: str, baud: int) -> "serial.Serial":
|
||||
"""
|
||||
Open serial connection to the REYAX transceiver.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
port : str
|
||||
Serial device path or COM port identifier.
|
||||
baud : int
|
||||
Baud rate for serial communication.
|
||||
|
||||
Returns
|
||||
-------
|
||||
serial.Serial
|
||||
Open serial interface to the radio.
|
||||
"""
|
||||
print(f"[*] Opening REYAX RYLR998 on {port} @ {baud}...")
|
||||
try:
|
||||
return serial.Serial(port, baud, timeout=0.5)
|
||||
except Exception as e:
|
||||
sys.exit(f"[-] Failed to open {port}: {e}")
|
||||
|
||||
|
||||
def _start_radio(ser: "serial.Serial") -> None:
|
||||
"""
|
||||
Start listener thread and send initial AT ping.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ser : serial.Serial
|
||||
Open serial connection to the ground RYLR998.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
thread = threading.Thread(target=_reader_thread, args=(ser,), daemon=True)
|
||||
thread.start()
|
||||
time.sleep(0.1)
|
||||
ser.write(b"AT\r\n")
|
||||
|
||||
|
||||
def _print_banner() -> None:
|
||||
"""
|
||||
Print console connection info and operator command instructions.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
None
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
print("[+] Connected. Type AT commands (e.g. AT, AT+BAND?, AT+NETWORKID?).")
|
||||
print("[+] Incoming airborne packets print as [LORA RX] +RCV=...")
|
||||
print("[+] Press Ctrl-C or Ctrl-D to exit.\n")
|
||||
|
||||
|
||||
def _console_loop(ser: "serial.Serial") -> None:
|
||||
"""
|
||||
Forward operator input lines to the serial transceiver.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ser : serial.Serial
|
||||
Open serial connection to the ground RYLR998.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
cmd = input("> ").strip()
|
||||
if cmd:
|
||||
ser.write(cmd.encode("utf-8") + b"\r\n")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\n[*] Exiting LoRa console.")
|
||||
ser.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Open the ground station radio and forward operator input.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
None
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
if serial is None:
|
||||
sys.exit("pyserial required: pip install pyserial")
|
||||
args = _parse_args()
|
||||
ser = _open_radio(args.port, args.baud)
|
||||
_start_radio(ser)
|
||||
_print_banner()
|
||||
_console_loop(ser)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
# MIT License
|
||||
#
|
||||
# Copyright (c) 2026 Kevin Thomas
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in all
|
||||
# copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
#
|
||||
# Author: Kevin Thomas
|
||||
# Email: kevin@mytechnotalent.com
|
||||
# GitHub: https://github.com/mytechnotalent
|
||||
# File: patch_binary.py
|
||||
# Desc: Patch script for Operation Zero Hour (0x0014a_cb).
|
||||
# Created: 2026
|
||||
|
||||
"""
|
||||
Patch script for Operation Zero Hour (0x0014a_cb).
|
||||
|
||||
Patches the conditional check in 0x0014a_cb.bin to force the WARHEAD DISARMED
|
||||
// SAFE state on boot.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
PATCH_OFFSET = 0x688
|
||||
EXPECTED_OPCODE = b"\x03\x2c"
|
||||
PATCHED_BYTE = 0x01
|
||||
|
||||
|
||||
def _apply_patch(data: bytearray) -> None:
|
||||
"""
|
||||
Verify opcode and apply single-byte conditional branch patch.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : bytearray
|
||||
Mutable binary image buffer.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
if data[PATCH_OFFSET:PATCH_OFFSET + 2] != EXPECTED_OPCODE:
|
||||
actual = data[PATCH_OFFSET:PATCH_OFFSET + 2].hex()
|
||||
raise ValueError(f"Expected 03 2c at offset 0x{PATCH_OFFSET:x}, got {actual}")
|
||||
data[PATCH_OFFSET] = PATCHED_BYTE
|
||||
|
||||
|
||||
def patch_binary(in_bin: str, out_bin: str) -> None:
|
||||
"""
|
||||
Read target firmware binary, apply patch, and write modified image.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
in_bin : str
|
||||
Path to source binary file.
|
||||
out_bin : str
|
||||
Path to destination patched binary file.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
with open(in_bin, "rb") as f:
|
||||
data = bytearray(f.read())
|
||||
_apply_patch(data)
|
||||
with open(out_bin, "wb") as f:
|
||||
f.write(data)
|
||||
print(f"[+] Successfully patched {in_bin} -> {out_bin} at offset 0x{PATCH_OFFSET:x}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Parse command line arguments and execute binary patching.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
None
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
in_file = sys.argv[1] if len(sys.argv) > 1 else "0x0014a_cb.bin"
|
||||
out_file = sys.argv[2] if len(sys.argv) > 2 else "0x0014a_cb_patched.bin"
|
||||
patch_binary(in_file, out_file)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,861 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"short_name": "RP2040",
|
||||
"id": "0xe48bff56",
|
||||
"description": "Raspberry Pi RP2040"
|
||||
},
|
||||
{
|
||||
"short_name": "RP2350-ARM-S",
|
||||
"id": "0xe48bff59",
|
||||
"description": "Raspberry Pi RP2350, ARM, Secure"
|
||||
},
|
||||
{
|
||||
"short_name": "RP2350-ARM-NS",
|
||||
"id": "0xe48bff5a",
|
||||
"description": "Raspberry Pi RP2350, ARM, Non-Secure"
|
||||
},
|
||||
{
|
||||
"short_name": "RP2350-RISCV",
|
||||
"id": "0xe48bff5b",
|
||||
"description": "Raspberry Pi RP2350, RISC-V"
|
||||
}
|
||||
]
|
||||
Reference in new issue
Block a user