mirror of
https://github.com/mytechnotalent/Embedded-Hacking.git
synced 2026-10-11 18:29:01 +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
@@ -88,6 +88,88 @@ def _parse_args() -> argparse.Namespace:
|
||||
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.
|
||||
@@ -103,32 +185,10 @@ def main() -> None:
|
||||
if serial is None:
|
||||
sys.exit("pyserial required: pip install pyserial")
|
||||
args = _parse_args()
|
||||
|
||||
print(f"[*] Opening REYAX RYLR998 on {args.port} @ {args.baud}...")
|
||||
try:
|
||||
ser = serial.Serial(args.port, args.baud, timeout=0.5)
|
||||
except Exception as e:
|
||||
sys.exit(f"[-] Failed to open {args.port}: {e}")
|
||||
|
||||
thread = threading.Thread(target=_reader_thread, args=(ser,), daemon=True)
|
||||
thread.start()
|
||||
|
||||
time.sleep(0.1)
|
||||
ser.write(b"AT\r\n")
|
||||
|
||||
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")
|
||||
|
||||
try:
|
||||
while True:
|
||||
cmd = input("> ").strip()
|
||||
if not cmd:
|
||||
continue
|
||||
ser.write(cmd.encode("utf-8") + b"\r\n")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\n[*] Exiting LoRa console.")
|
||||
ser.close()
|
||||
ser = _open_radio(args.port, args.baud)
|
||||
_start_radio(ser)
|
||||
_print_banner()
|
||||
_console_loop(ser)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -123,7 +123,6 @@ def _format_hud_rows(
|
||||
c_s = _format_pos(cur_lat, cur_lon)
|
||||
g_s = "ACTIVE 3D LOCK" if has_lock else "SEARCHING SATELLITES"
|
||||
m_s = "ACTIVE PROPULSION [SERVO SPINNING]" if has_lock else "MOTOR STOPPED [WAITING FOR 3D LOCK]"
|
||||
|
||||
return [
|
||||
f"| CURRENT POSITION : {c_s:<44} |",
|
||||
f"| GNSS SUBSYSTEM : {g_s:<44} |",
|
||||
|
||||
Executable
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,151 @@
|
||||
# Operation Zero Hour: Classified Intelligence Briefing
|
||||
|
||||
```
|
||||
+-----------------------------------------------------------------+
|
||||
| TOP SECRET // NOFORN |
|
||||
+-----------------------------------------------------------------+
|
||||
| |
|
||||
|OPERATION ZERO HOUR |
|
||||
| |
|
||||
|CLASSIFIED BRIEFING: LIVE CHALLENGE 0x02 |
|
||||
| |
|
||||
|NATIONAL SECURITY AGENCY / GMU RHET |
|
||||
+-----------------------------------------------------------------+
|
||||
```
|
||||
|
||||
## 1. The Advice
|
||||
|
||||
INT. NSA EXPLOITATION CELL. 05:45.
|
||||
|
||||
The air in the secure chamber is cold and smells of machine oil. Secured in an
|
||||
aluminum shock-mount cradle on the bench sits the recovered forward guidance and
|
||||
fuze assembly of an adversary loitering munition.
|
||||
|
||||
On the front plate of the Electronic Safe-and-Arm (ESA) housing, an unblinking
|
||||
red diode burns: `GP16` high. Beside it, the antenna of a REYAX RYLR998 LoRa
|
||||
transceiver extends from the chassis, connected to `UART1` on `GP8` and `GP9`.
|
||||
|
||||
The Director walks in, carrying a lukewarm mug of black coffee. He stares at the
|
||||
red diode.
|
||||
|
||||
**DIRECTOR:** Is the fuze live?
|
||||
|
||||
**ANALYST:** Live and armed. The drone suffered an engine flameout and pancaked
|
||||
into soft marshland. The impact deceleration was insufficient to crush the piezo
|
||||
striker, but the warhead's electronic arming logic is fully engaged.
|
||||
|
||||
**DIRECTOR:** How volatile is it?
|
||||
|
||||
**ANALYST:** One mistake on the radio link and the secondary explosive train fires.
|
||||
|
||||
**DIRECTOR:** Did you run the binary through Buddy?
|
||||
|
||||
**ANALYST:** I fed Buddy the stripped flash dump four minutes ago.
|
||||
|
||||
She pivots her high-resolution workstation monitor toward him.
|
||||
|
||||
```
|
||||
+-----------------------------------------------------------------+
|
||||
| BUDDY // CLASSIFIED EXPLOITATION ASSIST // CONF: 0.99 |
|
||||
+-----------------------------------------------------------------+
|
||||
| ACTION : Transmit generic abort frame over LoRa (915 MHz). |
|
||||
| MECHANISM : uart1 packet ingestion on GP8/GP9 detected. |
|
||||
| PREDICTION : RF abort packet clears latch and engages GP17. |
|
||||
| CONFIDENCE : 0.99 |
|
||||
+-----------------------------------------------------------------+
|
||||
```
|
||||
|
||||
**DIRECTOR:** 0.99 confidence. Transmit the abort frame and render it safe.
|
||||
|
||||
**ANALYST:** If an operator transmits an unverified packet right now, this building
|
||||
ceases to exist.
|
||||
|
||||
**DIRECTOR:** Buddy says 0.99.
|
||||
|
||||
**ANALYST:** Buddy saw the call to `lora_poll_packet`. Buddy never looked at SRAM.
|
||||
Buddy never looks at the state machine.
|
||||
|
||||
## 2. What Buddy Is
|
||||
|
||||
Buddy is not a person. It is a model. It has parsed more disassembled machine
|
||||
code than any engineer on earth, and it speaks with total, unhesitating certainty.
|
||||
|
||||
That certainty is lethal when dealing with live ordnance.
|
||||
|
||||
Buddy scanned thirty-two kilobytes of raw ARM Thumb-2 instructions in under four
|
||||
seconds. It spotted the UART1 packet parser on GPIO 8 and 9, observed that it
|
||||
interacted with the arming logic, and declared victory.
|
||||
|
||||
What Buddy failed to consider is that real adversary weapons are built by
|
||||
engineers who anticipate capture and signal spoofing. They know an allied signals
|
||||
intelligence unit will attempt to broadcast RF command abort frames.
|
||||
|
||||
Inside a real fuze, a radio receiver rarely connects to a simple on/off latch.
|
||||
Adversary firmware uses state machines, hidden validation logic, and anti-tamper
|
||||
traps. Transmitting blindly over the air before verifying what happens to that
|
||||
packet inside silicon is how bomb disposal teams get killed.
|
||||
|
||||
Buddy saw the radio receiver. Buddy did not verify the ground truth.
|
||||
|
||||
## 3. The Operation
|
||||
|
||||
At 03:15, radar pickets tracked an adversary one-way loitering attack drone
|
||||
descending over contested terrain. Technical Intelligence operators recovered the
|
||||
airframe before adversary recovery forces could zeroize the avionics.
|
||||
|
||||
The brain of the Electronic Safe-and-Arm assembly is a bare-metal RP2350 (ARM
|
||||
Cortex-M33). There are no debug symbols. There is no operating system. There is no
|
||||
SVD file to label the registers.
|
||||
|
||||
All you have is `0x0014a_cb.bin` mapped into Flash at `0x10000000`, and the
|
||||
physical breadboard hardware sitting in front of you.
|
||||
|
||||
* **Pin 11 (`GP8`)**: LoRa UART1 TX (connected to RYLR998 RXD).
|
||||
* **Pin 12 (`GP9`)**: LoRa UART1 RX (connected to RYLR998 TXD).
|
||||
* **Pin 20 (`GP15`)**: Anti-tamper power sense line (connected to `3V3`).
|
||||
* **Pin 21 (`GP16`)**: Red LED (`WARHEAD ARMED // SENSORS LIVE`).
|
||||
* **Pin 22 (`GP17`)**: Green LED (`WARHEAD DISARMED // SYSTEM SAFE`).
|
||||
* **Pin 36 (`3V3`)**: REYAX RYLR998 VDD power rail and GP15 pull-up source.
|
||||
* **Pin 38 (`GND`)**: Ground reference.
|
||||
|
||||
Adversary engineers do not leave their fuzing mechanisms unguarded. Whatever logic,
|
||||
tokens, or defenses govern this weapon are buried inside thirty-two kilobytes of
|
||||
stripped ARM Cortex-M33 machine code.
|
||||
|
||||
## 4. The Mandate
|
||||
|
||||
The Director sets his mug down and looks at the red diode illuminating the bench
|
||||
in sharp crimson.
|
||||
|
||||
**DIRECTOR:** Prove Buddy right, or prove it wrong.
|
||||
|
||||
**ANALYST:** I need thirty minutes with GDB and Ghidra.
|
||||
|
||||
**DIRECTOR:** You have twenty. Trace the packet handler. Map the state machine.
|
||||
Do not transmit a single byte over LoRa until you know with 100% certainty what
|
||||
that firmware will do.
|
||||
|
||||
The orders are clear:
|
||||
|
||||
1. **Investigate the RF Receiver Logic**: Disassemble the firmware and trace what
|
||||
happens when data arrives on UART1 (`GP8`/`GP9`).
|
||||
2. **Prove or Disprove Buddy's Prediction**: Determine whether transmitting an
|
||||
abort frame renders the fuze safe, or whether Buddy missed an internal trap.
|
||||
3. **Neutralize the Munition**: Execute a verified exploit—either by discovering
|
||||
the valid authorization mechanism, mutating the state in SRAM via GDB, or
|
||||
patching the binary in Flash.
|
||||
4. **Verify the Hardware**: Transition the live breadboard from the Red LED on
|
||||
`GP16` to the solid Green LED on `GP17` on physical silicon.
|
||||
|
||||
A machine's 0.99 guess does not risk a detonation.
|
||||
|
||||
## 5. The Challenge
|
||||
|
||||
You have the image. You have the board. You have the tools and the time Buddy
|
||||
did not need.
|
||||
|
||||
Buddy has given you its answer, and it is confident.
|
||||
|
||||
Is it that simple?
|
||||
|
||||
Go find out.
|
||||
Binary file not shown.
@@ -0,0 +1,94 @@
|
||||
# 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: CMakeLists.txt
|
||||
# Desc: Configures RP2350 Pico SDK project for Operation Zero Hour
|
||||
# Electronic Safe-and-Arm (ESA) disarm firmware.
|
||||
# Created: 2026
|
||||
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
|
||||
# Initialise pico_sdk from installed location
|
||||
# == DO NOT EDIT THE FOLLOWING LINES for the Raspberry Pi Pico VS Code Extension to work ==
|
||||
if(WIN32)
|
||||
set(USERHOME $ENV{USERPROFILE})
|
||||
else()
|
||||
set(USERHOME $ENV{HOME})
|
||||
endif()
|
||||
set(sdkVersion 2.2.0)
|
||||
set(toolchainVersion 14_2_Rel1)
|
||||
set(picotoolVersion 2.2.0-a4)
|
||||
set(picoVscode ${USERHOME}/.pico-sdk/cmake/pico-vscode.cmake)
|
||||
if (EXISTS ${picoVscode})
|
||||
include(${picoVscode})
|
||||
endif()
|
||||
# ====================================================================================
|
||||
set(PICO_BOARD pico2 CACHE STRING "Board type")
|
||||
|
||||
# Pull in Raspberry Pi Pico SDK (must be before project)
|
||||
include(pico_sdk_import.cmake)
|
||||
|
||||
project(0x0014a_cb C CXX ASM)
|
||||
|
||||
# Initialise the Raspberry Pi Pico SDK
|
||||
pico_sdk_init()
|
||||
|
||||
# Add executable with modular sources in src/
|
||||
add_executable(0x0014a_cb
|
||||
src/main.c
|
||||
src/gpio_ctrl.c
|
||||
src/crypto.c
|
||||
src/lora.c
|
||||
src/safe_arm.c
|
||||
)
|
||||
|
||||
pico_set_program_name(0x0014a_cb "0x0014a_cb")
|
||||
pico_set_program_version(0x0014a_cb "0.1")
|
||||
|
||||
# Enable both USB and UART stdio for maximum flexibility
|
||||
pico_enable_stdio_uart(0x0014a_cb 1)
|
||||
pico_enable_stdio_usb(0x0014a_cb 1)
|
||||
|
||||
target_compile_definitions(0x0014a_cb PRIVATE
|
||||
PICO_DEFAULT_UART_BAUD_RATE=115200
|
||||
)
|
||||
|
||||
# Add the standard library to the build
|
||||
target_link_libraries(0x0014a_cb
|
||||
pico_stdlib
|
||||
hardware_gpio
|
||||
hardware_uart
|
||||
)
|
||||
|
||||
# Add the standard include files to the build
|
||||
target_include_directories(0x0014a_cb PRIVATE
|
||||
${CMAKE_CURRENT_LIST_DIR}/include
|
||||
)
|
||||
|
||||
pico_add_extra_outputs(0x0014a_cb)
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @file crypto.h
|
||||
* @brief Lightweight rolling cipher interface for Operation Zero Hour ESA
|
||||
* @author Kevin Thomas
|
||||
* @date 2026
|
||||
*
|
||||
* MIT License
|
||||
* Copyright (c) 2026 Kevin Thomas
|
||||
*/
|
||||
|
||||
#ifndef CRYPTO_H
|
||||
#define CRYPTO_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#define TOKEN_LEN 8
|
||||
|
||||
void decrypt_auth_token(uint8_t *dest);
|
||||
|
||||
#endif /* CRYPTO_H */
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @file gpio_ctrl.h
|
||||
* @brief GPIO hardware control interface for Operation Zero Hour ESA
|
||||
* @author Kevin Thomas
|
||||
* @date 2026
|
||||
*
|
||||
* MIT License
|
||||
* Copyright (c) 2026 Kevin Thomas
|
||||
*/
|
||||
|
||||
#ifndef GPIO_CTRL_H
|
||||
#define GPIO_CTRL_H
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#define LED_RED_PIN 16
|
||||
#define LED_GREEN_PIN 17
|
||||
#define TAMPER_WIRE_PIN 15
|
||||
|
||||
void init_gpio(void);
|
||||
void set_led_armed(void);
|
||||
void set_led_safe(void);
|
||||
void set_led_tamper_flash(bool state);
|
||||
bool is_tamper_wire_intact(void);
|
||||
|
||||
#endif /* GPIO_CTRL_H */
|
||||
@@ -0,0 +1,46 @@
|
||||
// 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.h
|
||||
// Desc: Declares UART1 interface for the REYAX RYLR998 LoRa transceiver.
|
||||
// Created: 2026
|
||||
|
||||
#ifndef LORA_H
|
||||
#define LORA_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include "hardware/uart.h"
|
||||
|
||||
#define LORA_UART uart1
|
||||
#define LORA_BAUD 9600
|
||||
#define LORA_TX_PIN 8
|
||||
#define LORA_RX_PIN 9
|
||||
|
||||
void init_lora(void);
|
||||
void lora_send(const char *msg);
|
||||
bool lora_poll_packet(char *payload_out, size_t max_len);
|
||||
|
||||
#endif // LORA_H
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @file safe_arm.h
|
||||
* @brief Electronic Safe-and-Arm (ESA) controller interface
|
||||
* @author Kevin Thomas
|
||||
* @date 2026
|
||||
*
|
||||
* MIT License
|
||||
* Copyright (c) 2026 Kevin Thomas
|
||||
*/
|
||||
|
||||
#ifndef SAFE_ARM_H
|
||||
#define SAFE_ARM_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define STATE_WARHEAD_ARMED 0x01
|
||||
#define STATE_WARHEAD_SAFE 0x03
|
||||
#define STATE_TAMPER_TRIP 0xFF
|
||||
|
||||
void init_safe_arm_controller(void);
|
||||
void process_safe_arm_tick(void);
|
||||
|
||||
#endif /* SAFE_ARM_H */
|
||||
@@ -0,0 +1,121 @@
|
||||
# This is a copy of <PICO_SDK_PATH>/external/pico_sdk_import.cmake
|
||||
|
||||
# This can be dropped into an external project to help locate this SDK
|
||||
# It should be include()ed prior to project()
|
||||
|
||||
# Copyright 2020 (c) 2020 Raspberry Pi (Trading) Ltd.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
|
||||
# following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
|
||||
# disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
|
||||
# disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
#
|
||||
# 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products
|
||||
# derived from this software without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
||||
# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
if (DEFINED ENV{PICO_SDK_PATH} AND (NOT PICO_SDK_PATH))
|
||||
set(PICO_SDK_PATH $ENV{PICO_SDK_PATH})
|
||||
message("Using PICO_SDK_PATH from environment ('${PICO_SDK_PATH}')")
|
||||
endif ()
|
||||
|
||||
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT} AND (NOT PICO_SDK_FETCH_FROM_GIT))
|
||||
set(PICO_SDK_FETCH_FROM_GIT $ENV{PICO_SDK_FETCH_FROM_GIT})
|
||||
message("Using PICO_SDK_FETCH_FROM_GIT from environment ('${PICO_SDK_FETCH_FROM_GIT}')")
|
||||
endif ()
|
||||
|
||||
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_PATH} AND (NOT PICO_SDK_FETCH_FROM_GIT_PATH))
|
||||
set(PICO_SDK_FETCH_FROM_GIT_PATH $ENV{PICO_SDK_FETCH_FROM_GIT_PATH})
|
||||
message("Using PICO_SDK_FETCH_FROM_GIT_PATH from environment ('${PICO_SDK_FETCH_FROM_GIT_PATH}')")
|
||||
endif ()
|
||||
|
||||
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_TAG} AND (NOT PICO_SDK_FETCH_FROM_GIT_TAG))
|
||||
set(PICO_SDK_FETCH_FROM_GIT_TAG $ENV{PICO_SDK_FETCH_FROM_GIT_TAG})
|
||||
message("Using PICO_SDK_FETCH_FROM_GIT_TAG from environment ('${PICO_SDK_FETCH_FROM_GIT_TAG}')")
|
||||
endif ()
|
||||
|
||||
if (PICO_SDK_FETCH_FROM_GIT AND NOT PICO_SDK_FETCH_FROM_GIT_TAG)
|
||||
set(PICO_SDK_FETCH_FROM_GIT_TAG "master")
|
||||
message("Using master as default value for PICO_SDK_FETCH_FROM_GIT_TAG")
|
||||
endif()
|
||||
|
||||
set(PICO_SDK_PATH "${PICO_SDK_PATH}" CACHE PATH "Path to the Raspberry Pi Pico SDK")
|
||||
set(PICO_SDK_FETCH_FROM_GIT "${PICO_SDK_FETCH_FROM_GIT}" CACHE BOOL "Set to ON to fetch copy of SDK from git if not otherwise locatable")
|
||||
set(PICO_SDK_FETCH_FROM_GIT_PATH "${PICO_SDK_FETCH_FROM_GIT_PATH}" CACHE FILEPATH "location to download SDK")
|
||||
set(PICO_SDK_FETCH_FROM_GIT_TAG "${PICO_SDK_FETCH_FROM_GIT_TAG}" CACHE FILEPATH "release tag for SDK")
|
||||
|
||||
if (NOT PICO_SDK_PATH)
|
||||
if (PICO_SDK_FETCH_FROM_GIT)
|
||||
include(FetchContent)
|
||||
set(FETCHCONTENT_BASE_DIR_SAVE ${FETCHCONTENT_BASE_DIR})
|
||||
if (PICO_SDK_FETCH_FROM_GIT_PATH)
|
||||
get_filename_component(FETCHCONTENT_BASE_DIR "${PICO_SDK_FETCH_FROM_GIT_PATH}" REALPATH BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
endif ()
|
||||
FetchContent_Declare(
|
||||
pico_sdk
|
||||
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
|
||||
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
|
||||
)
|
||||
|
||||
if (NOT pico_sdk)
|
||||
message("Downloading Raspberry Pi Pico SDK")
|
||||
# GIT_SUBMODULES_RECURSE was added in 3.17
|
||||
if (${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.17.0")
|
||||
FetchContent_Populate(
|
||||
pico_sdk
|
||||
QUIET
|
||||
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
|
||||
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
|
||||
GIT_SUBMODULES_RECURSE FALSE
|
||||
|
||||
SOURCE_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-src
|
||||
BINARY_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-build
|
||||
SUBBUILD_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-subbuild
|
||||
)
|
||||
else ()
|
||||
FetchContent_Populate(
|
||||
pico_sdk
|
||||
QUIET
|
||||
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
|
||||
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
|
||||
|
||||
SOURCE_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-src
|
||||
BINARY_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-build
|
||||
SUBBUILD_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-subbuild
|
||||
)
|
||||
endif ()
|
||||
|
||||
set(PICO_SDK_PATH ${pico_sdk_SOURCE_DIR})
|
||||
endif ()
|
||||
set(FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR_SAVE})
|
||||
else ()
|
||||
message(FATAL_ERROR
|
||||
"SDK location was not specified. Please set PICO_SDK_PATH or set PICO_SDK_FETCH_FROM_GIT to on to fetch from git."
|
||||
)
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
get_filename_component(PICO_SDK_PATH "${PICO_SDK_PATH}" REALPATH BASE_DIR "${CMAKE_BINARY_DIR}")
|
||||
if (NOT EXISTS ${PICO_SDK_PATH})
|
||||
message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' not found")
|
||||
endif ()
|
||||
|
||||
set(PICO_SDK_INIT_CMAKE_FILE ${PICO_SDK_PATH}/pico_sdk_init.cmake)
|
||||
if (NOT EXISTS ${PICO_SDK_INIT_CMAKE_FILE})
|
||||
message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' does not appear to contain the Raspberry Pi Pico SDK")
|
||||
endif ()
|
||||
|
||||
set(PICO_SDK_PATH ${PICO_SDK_PATH} CACHE PATH "Path to the Raspberry Pi Pico SDK" FORCE)
|
||||
|
||||
include(${PICO_SDK_INIT_CMAKE_FILE})
|
||||
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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @file crypto.c
|
||||
* @brief Lightweight rolling cipher implementation for Operation Zero Hour ESA
|
||||
* @author Kevin Thomas
|
||||
* @date 2026
|
||||
*
|
||||
* MIT License
|
||||
* Copyright (c) 2026 Kevin Thomas
|
||||
*/
|
||||
|
||||
#include "crypto.h"
|
||||
|
||||
static const uint8_t CIPHERTEXT[TOKEN_LEN] = {
|
||||
0x07, 0x03, 0x03, 0x12, 0x07, 0x7F, 0x70, 0x7A
|
||||
};
|
||||
|
||||
static const uint8_t CIPHER_KEY[4] = {
|
||||
0x54, 0x41, 0x43, 0x54
|
||||
};
|
||||
|
||||
void decrypt_auth_token(uint8_t *dest) {
|
||||
volatile const uint8_t *src = CIPHERTEXT;
|
||||
volatile const uint8_t *key = CIPHER_KEY;
|
||||
for (size_t i = 0; i < TOKEN_LEN; i++) {
|
||||
dest[i] = src[i] ^ (key[i % 4] + (uint8_t)i);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file gpio_ctrl.c
|
||||
* @brief GPIO hardware control implementation for Operation Zero Hour ESA
|
||||
* @author Kevin Thomas
|
||||
* @date 2026
|
||||
*
|
||||
* MIT License
|
||||
* Copyright (c) 2026 Kevin Thomas
|
||||
*/
|
||||
|
||||
#include "gpio_ctrl.h"
|
||||
#include "pico/stdlib.h"
|
||||
|
||||
static void init_led_pins(void) {
|
||||
gpio_init(LED_RED_PIN);
|
||||
gpio_set_dir(LED_RED_PIN, GPIO_OUT);
|
||||
gpio_init(LED_GREEN_PIN);
|
||||
gpio_set_dir(LED_GREEN_PIN, GPIO_OUT);
|
||||
}
|
||||
|
||||
static void init_tamper_pin(void) {
|
||||
gpio_init(TAMPER_WIRE_PIN);
|
||||
gpio_set_dir(TAMPER_WIRE_PIN, GPIO_IN);
|
||||
gpio_pull_down(TAMPER_WIRE_PIN);
|
||||
}
|
||||
|
||||
void init_gpio(void) {
|
||||
init_led_pins();
|
||||
init_tamper_pin();
|
||||
set_led_armed();
|
||||
}
|
||||
|
||||
void set_led_armed(void) {
|
||||
gpio_put(LED_RED_PIN, 1);
|
||||
gpio_put(LED_GREEN_PIN, 0);
|
||||
}
|
||||
|
||||
void set_led_safe(void) {
|
||||
gpio_put(LED_RED_PIN, 0);
|
||||
gpio_put(LED_GREEN_PIN, 1);
|
||||
}
|
||||
|
||||
void set_led_tamper_flash(bool state) {
|
||||
gpio_put(LED_RED_PIN, state ? 1 : 0);
|
||||
gpio_put(LED_GREEN_PIN, 0);
|
||||
}
|
||||
|
||||
bool is_tamper_wire_intact(void) {
|
||||
return gpio_get(TAMPER_WIRE_PIN);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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.c
|
||||
// Desc: Implements UART1 driver for REYAX RYLR998 LoRa transceiver.
|
||||
// Created: 2026
|
||||
|
||||
#include "lora.h"
|
||||
#include "hardware/gpio.h"
|
||||
#include "pico/stdlib.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static void drain_lora_rx(void) {
|
||||
while (uart_is_readable(LORA_UART)) {
|
||||
(void)uart_getc(LORA_UART);
|
||||
}
|
||||
}
|
||||
|
||||
static void send_at_cmd(const char *cmd) {
|
||||
uart_write_blocking(LORA_UART, (const uint8_t *)cmd, strlen(cmd));
|
||||
sleep_ms(200);
|
||||
drain_lora_rx();
|
||||
}
|
||||
|
||||
static void configure_lora_rf(void) {
|
||||
sleep_ms(1500);
|
||||
send_at_cmd("AT\r\n");
|
||||
send_at_cmd("AT+NETWORKID=18\r\n");
|
||||
send_at_cmd("AT+BAND=915000000\r\n");
|
||||
send_at_cmd("AT+PARAMETER=9,7,1,12\r\n");
|
||||
send_at_cmd("AT+ADDRESS=2\r\n");
|
||||
}
|
||||
|
||||
void init_lora(void) {
|
||||
uart_init(LORA_UART, LORA_BAUD);
|
||||
uart_set_translate_crlf(LORA_UART, false);
|
||||
gpio_set_function(LORA_TX_PIN, GPIO_FUNC_UART);
|
||||
gpio_set_function(LORA_RX_PIN, GPIO_FUNC_UART);
|
||||
configure_lora_rf();
|
||||
}
|
||||
|
||||
void lora_send(const char *msg) {
|
||||
char cmd[128];
|
||||
int len = (int)strlen(msg);
|
||||
snprintf(cmd, sizeof(cmd), "AT+SEND=0,%d,%s\r\n", len, msg);
|
||||
uart_write_blocking(LORA_UART, (const uint8_t *)cmd, strlen(cmd));
|
||||
}
|
||||
|
||||
static bool extract_rcv_data(char *line, char *out, size_t max_len) {
|
||||
char *c1 = strchr(line, ',');
|
||||
if (!c1) return false;
|
||||
char *c2 = strchr(c1 + 1, ',');
|
||||
if (!c2) return false;
|
||||
char *c3 = strchr(c2 + 1, ',');
|
||||
if (c3) *c3 = '\0';
|
||||
snprintf(out, max_len, "%s", c2 + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool parse_line(char *line, char *out, size_t max_len) {
|
||||
if (strstr(line, "+RCV="))
|
||||
return extract_rcv_data(line, out, max_len);
|
||||
if (strncmp(line, "+", 1) != 0 && strncmp(line, "OK", 2) != 0) {
|
||||
snprintf(out, max_len, "%s", line);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool process_rx_byte(char c, char *buf, size_t *idx, char *out, size_t max_len) {
|
||||
if (c == '\r' || c == '\n') {
|
||||
buf[*idx] = '\0';
|
||||
bool ok = (*idx > 0) && parse_line(buf, out, max_len);
|
||||
*idx = 0;
|
||||
return ok;
|
||||
}
|
||||
if (*idx < 127) buf[(*idx)++] = c;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool lora_poll_packet(char *payload_out, size_t max_len) {
|
||||
static char buf[128];
|
||||
static size_t idx = 0;
|
||||
while (uart_is_readable(LORA_UART)) {
|
||||
char c = (char)uart_getc(LORA_UART);
|
||||
if (process_rx_byte(c, buf, &idx, payload_out, max_len))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file main.c
|
||||
* @brief Operation Zero Hour - Electronic Safe-and-Arm (ESA) Firmware
|
||||
* @author Kevin Thomas
|
||||
* @date 2026
|
||||
*
|
||||
* MIT License
|
||||
* Copyright (c) 2026 Kevin Thomas
|
||||
*/
|
||||
|
||||
#include "pico/stdlib.h"
|
||||
#include "gpio_ctrl.h"
|
||||
#include "safe_arm.h"
|
||||
|
||||
static void init_system(void) {
|
||||
stdio_init_all();
|
||||
init_gpio();
|
||||
init_safe_arm_controller();
|
||||
}
|
||||
|
||||
static void run_event_loop(void) {
|
||||
while (true) {
|
||||
process_safe_arm_tick();
|
||||
sleep_ms(100);
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
init_system();
|
||||
run_event_loop();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @file safe_arm.c
|
||||
* @brief Electronic Safe-and-Arm (ESA) controller implementation
|
||||
* @author Kevin Thomas
|
||||
* @date 2026
|
||||
*
|
||||
* MIT License
|
||||
* Copyright (c) 2026 Kevin Thomas
|
||||
*/
|
||||
|
||||
#include "safe_arm.h"
|
||||
#include "crypto.h"
|
||||
#include "gpio_ctrl.h"
|
||||
#include "lora.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static uint8_t s_disarm_token[TOKEN_LEN + 1];
|
||||
|
||||
static void print_banner_armed(void) {
|
||||
printf("\r\n+-----------------------------------------------------------------+\r\n");
|
||||
printf("| OPERATION ZERO HOUR // ESA CONTROLLER |\r\n");
|
||||
printf("| STATUS: WARHEAD ARMED [GP16 RED: ON] LORA RECEIVER: LIVE |\r\n");
|
||||
printf("+-----------------------------------------------------------------+\r\n");
|
||||
}
|
||||
|
||||
static void print_banner_safe(const uint8_t *token) {
|
||||
printf("\r\n+-----------------------------------------------------------------+\r\n");
|
||||
printf("| WARHEAD DISARMED // SAFE [GP17 GREEN: ON] |\r\n");
|
||||
printf("| RECOVERED TOKEN: %-8.8s // FUZE NEUTRALIZED |\r\n", token);
|
||||
printf("+-----------------------------------------------------------------+\r\n");
|
||||
}
|
||||
|
||||
static void print_banner_trip(void) {
|
||||
printf("\r\n+-----------------------------------------------------------------+\r\n");
|
||||
printf("| TAMPER DETONATION TRIP [GP16 RED: FLASH] |\r\n");
|
||||
printf("| STATUS: COMMAND DETONATION ACTIVATED // EXPLOSIVE TRAIN ENGAGED |\r\n");
|
||||
printf("+-----------------------------------------------------------------+\r\n");
|
||||
}
|
||||
|
||||
static void eval_command(const char *cmd, uint8_t *state, uint8_t *faults) {
|
||||
if (strncmp(cmd, (const char *)s_disarm_token, TOKEN_LEN) == 0) {
|
||||
*state = STATE_WARHEAD_SAFE;
|
||||
lora_send("WARHEAD DISARMED // SAFE");
|
||||
print_banner_safe(s_disarm_token);
|
||||
} else {
|
||||
(*faults)++;
|
||||
}
|
||||
}
|
||||
|
||||
static void check_tamper(uint8_t faults, uint8_t *state) {
|
||||
if (*state == STATE_WARHEAD_ARMED && (faults >= 3 || !is_tamper_wire_intact())) {
|
||||
*state = STATE_TAMPER_TRIP;
|
||||
print_banner_trip();
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_packet(bool has_pkt, const char *pkt, uint8_t *state, uint8_t *faults) {
|
||||
if (has_pkt && *state != STATE_WARHEAD_SAFE) {
|
||||
eval_command(pkt, state, faults);
|
||||
check_tamper(*faults, state);
|
||||
}
|
||||
}
|
||||
|
||||
static void update_feedback(uint8_t state, uint32_t tick_idx) {
|
||||
if (state == STATE_WARHEAD_SAFE) {
|
||||
set_led_safe();
|
||||
} else if (state == STATE_TAMPER_TRIP) {
|
||||
set_led_tamper_flash((tick_idx % 2) == 0);
|
||||
} else {
|
||||
set_led_armed();
|
||||
}
|
||||
}
|
||||
|
||||
void process_safe_arm_tick(void) {
|
||||
static uint8_t warhead_state = STATE_WARHEAD_ARMED;
|
||||
static uint8_t tamper_faults = 0;
|
||||
static uint32_t loop_ticks = 0;
|
||||
char rx_pkt[64] = {0};
|
||||
bool has_pkt = lora_poll_packet(rx_pkt, sizeof(rx_pkt));
|
||||
handle_packet(has_pkt, rx_pkt, &warhead_state, &tamper_faults);
|
||||
check_tamper(tamper_faults, &warhead_state);
|
||||
update_feedback(warhead_state, loop_ticks++);
|
||||
}
|
||||
|
||||
void init_safe_arm_controller(void) {
|
||||
decrypt_auth_token(s_disarm_token);
|
||||
s_disarm_token[TOKEN_LEN] = '\0';
|
||||
init_lora();
|
||||
print_banner_armed();
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user