diff --git a/0x0011a_cb/scripts/lora_console.py b/0x0011a_cb/scripts/lora_console.py index d4d08f4..24310f9 100755 --- a/0x0011a_cb/scripts/lora_console.py +++ b/0x0011a_cb/scripts/lora_console.py @@ -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__": diff --git a/0x0011a_cb/scripts/telemetry_monitor.py b/0x0011a_cb/scripts/telemetry_monitor.py index ca5876e..c10a5d0 100644 --- a/0x0011a_cb/scripts/telemetry_monitor.py +++ b/0x0011a_cb/scripts/telemetry_monitor.py @@ -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} |", diff --git a/0x0014a_cb/0x0014a_cb.bin b/0x0014a_cb/0x0014a_cb.bin new file mode 100755 index 0000000..7b8c655 Binary files /dev/null and b/0x0014a_cb/0x0014a_cb.bin differ diff --git a/0x0014a_cb/0x0014a_cb.uf2 b/0x0014a_cb/0x0014a_cb.uf2 new file mode 100644 index 0000000..47ba2e4 Binary files /dev/null and b/0x0014a_cb/0x0014a_cb.uf2 differ diff --git a/0x0014a_cb/0x0014a_cb_patched.bin b/0x0014a_cb/0x0014a_cb_patched.bin new file mode 100644 index 0000000..dba3eb6 Binary files /dev/null and b/0x0014a_cb/0x0014a_cb_patched.bin differ diff --git a/0x0014a_cb/0x0014a_cb_patched.uf2 b/0x0014a_cb/0x0014a_cb_patched.uf2 new file mode 100644 index 0000000..e0154ca Binary files /dev/null and b/0x0014a_cb/0x0014a_cb_patched.uf2 differ diff --git a/0x0014a_cb/CLASSIFIED-BRIEF-0x02.md b/0x0014a_cb/CLASSIFIED-BRIEF-0x02.md new file mode 100644 index 0000000..8c16e2e --- /dev/null +++ b/0x0014a_cb/CLASSIFIED-BRIEF-0x02.md @@ -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. diff --git a/0x0014a_cb/CLASSIFIED-BRIEF-0x02.pdf b/0x0014a_cb/CLASSIFIED-BRIEF-0x02.pdf new file mode 100644 index 0000000..e40b5de Binary files /dev/null and b/0x0014a_cb/CLASSIFIED-BRIEF-0x02.pdf differ diff --git a/0x0014a_cb/CMakeLists.txt b/0x0014a_cb/CMakeLists.txt new file mode 100644 index 0000000..a1c88a5 --- /dev/null +++ b/0x0014a_cb/CMakeLists.txt @@ -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) diff --git a/0x0014a_cb/include/crypto.h b/0x0014a_cb/include/crypto.h new file mode 100644 index 0000000..2960cb1 --- /dev/null +++ b/0x0014a_cb/include/crypto.h @@ -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 +#include + +#define TOKEN_LEN 8 + +void decrypt_auth_token(uint8_t *dest); + +#endif /* CRYPTO_H */ diff --git a/0x0014a_cb/include/gpio_ctrl.h b/0x0014a_cb/include/gpio_ctrl.h new file mode 100644 index 0000000..55ca92d --- /dev/null +++ b/0x0014a_cb/include/gpio_ctrl.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 + +#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 */ diff --git a/0x0014a_cb/include/lora.h b/0x0014a_cb/include/lora.h new file mode 100644 index 0000000..379ca2d --- /dev/null +++ b/0x0014a_cb/include/lora.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 +#include +#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 diff --git a/0x0014a_cb/include/safe_arm.h b/0x0014a_cb/include/safe_arm.h new file mode 100644 index 0000000..975089d --- /dev/null +++ b/0x0014a_cb/include/safe_arm.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 +#include + +#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 */ diff --git a/0x0014a_cb/pico_sdk_import.cmake b/0x0014a_cb/pico_sdk_import.cmake new file mode 100644 index 0000000..d493cc2 --- /dev/null +++ b/0x0014a_cb/pico_sdk_import.cmake @@ -0,0 +1,121 @@ +# This is a copy of /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}) diff --git a/0x0014a_cb/scripts/lora_console.py b/0x0014a_cb/scripts/lora_console.py new file mode 100755 index 0000000..24310f9 --- /dev/null +++ b/0x0014a_cb/scripts/lora_console.py @@ -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() diff --git a/0x0014a_cb/scripts/patch_binary.py b/0x0014a_cb/scripts/patch_binary.py new file mode 100755 index 0000000..94ae2be --- /dev/null +++ b/0x0014a_cb/scripts/patch_binary.py @@ -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() diff --git a/0x0014a_cb/scripts/uf2conv.py b/0x0014a_cb/scripts/uf2conv.py new file mode 100644 index 0000000..c5a832f --- /dev/null +++ b/0x0014a_cb/scripts/uf2conv.py @@ -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("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(" 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(" 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} ") + + +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()) diff --git a/0x0014a_cb/scripts/uf2families.json b/0x0014a_cb/scripts/uf2families.json new file mode 100644 index 0000000..39373c4 --- /dev/null +++ b/0x0014a_cb/scripts/uf2families.json @@ -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" + } +] \ No newline at end of file diff --git a/0x0014a_cb/src/crypto.c b/0x0014a_cb/src/crypto.c new file mode 100644 index 0000000..28cac39 --- /dev/null +++ b/0x0014a_cb/src/crypto.c @@ -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); + } +} diff --git a/0x0014a_cb/src/gpio_ctrl.c b/0x0014a_cb/src/gpio_ctrl.c new file mode 100644 index 0000000..537a8bc --- /dev/null +++ b/0x0014a_cb/src/gpio_ctrl.c @@ -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); +} diff --git a/0x0014a_cb/src/lora.c b/0x0014a_cb/src/lora.c new file mode 100644 index 0000000..2da0fdf --- /dev/null +++ b/0x0014a_cb/src/lora.c @@ -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 +#include + +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; +} diff --git a/0x0014a_cb/src/main.c b/0x0014a_cb/src/main.c new file mode 100644 index 0000000..f105963 --- /dev/null +++ b/0x0014a_cb/src/main.c @@ -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; +} diff --git a/0x0014a_cb/src/safe_arm.c b/0x0014a_cb/src/safe_arm.c new file mode 100644 index 0000000..5c54f11 --- /dev/null +++ b/0x0014a_cb/src/safe_arm.c @@ -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 +#include + +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(); +} diff --git a/WEEK01/WEEK01.pdf b/WEEK01/WEEK01.pdf index a342dfd..80b71a6 100644 Binary files a/WEEK01/WEEK01.pdf and b/WEEK01/WEEK01.pdf differ diff --git a/WEEK01/WEEK01a.pdf b/WEEK01/WEEK01a.pdf index fd62f23..1715af1 100644 Binary files a/WEEK01/WEEK01a.pdf and b/WEEK01/WEEK01a.pdf differ diff --git a/WEEK02/WEEK02.pdf b/WEEK02/WEEK02.pdf index ee6918f..d5a3529 100644 Binary files a/WEEK02/WEEK02.pdf and b/WEEK02/WEEK02.pdf differ diff --git a/WEEK03/GHIDRA_PATCHING_TUTORIAL.pdf b/WEEK03/GHIDRA_PATCHING_TUTORIAL.pdf index a3e67ba..6a32f0d 100644 Binary files a/WEEK03/GHIDRA_PATCHING_TUTORIAL.pdf and b/WEEK03/GHIDRA_PATCHING_TUTORIAL.pdf differ diff --git a/WEEK03/WEEK03.pdf b/WEEK03/WEEK03.pdf index d933dc6..ad2d1cb 100644 Binary files a/WEEK03/WEEK03.pdf and b/WEEK03/WEEK03.pdf differ diff --git a/WEEK04/WEEK04-BN.pdf b/WEEK04/WEEK04-BN.pdf index 357e90a..0ea48d6 100644 Binary files a/WEEK04/WEEK04-BN.pdf and b/WEEK04/WEEK04-BN.pdf differ diff --git a/WEEK04/WEEK04.pdf b/WEEK04/WEEK04.pdf index 165b329..5d887c2 100644 Binary files a/WEEK04/WEEK04.pdf and b/WEEK04/WEEK04.pdf differ diff --git a/WEEK04/WEEK04a.pdf b/WEEK04/WEEK04a.pdf index 169b4a9..68d606b 100644 Binary files a/WEEK04/WEEK04a.pdf and b/WEEK04/WEEK04a.pdf differ diff --git a/WEEK05/WEEK05-BN.pdf b/WEEK05/WEEK05-BN.pdf index 730a29f..8856166 100644 Binary files a/WEEK05/WEEK05-BN.pdf and b/WEEK05/WEEK05-BN.pdf differ diff --git a/WEEK05/WEEK05.pdf b/WEEK05/WEEK05.pdf index a0cc5b1..6a7cd6a 100644 Binary files a/WEEK05/WEEK05.pdf and b/WEEK05/WEEK05.pdf differ diff --git a/WEEK05/WEEK05a.pdf b/WEEK05/WEEK05a.pdf index 605c7d6..a887865 100644 Binary files a/WEEK05/WEEK05a.pdf and b/WEEK05/WEEK05a.pdf differ diff --git a/WEEK06/WEEK06-BN.pdf b/WEEK06/WEEK06-BN.pdf index c11c48e..014bed2 100644 Binary files a/WEEK06/WEEK06-BN.pdf and b/WEEK06/WEEK06-BN.pdf differ diff --git a/WEEK06/WEEK06.pdf b/WEEK06/WEEK06.pdf index e30339f..e5234b5 100644 Binary files a/WEEK06/WEEK06.pdf and b/WEEK06/WEEK06.pdf differ diff --git a/WEEK07/WEEK07-BN.pdf b/WEEK07/WEEK07-BN.pdf index c8a45ec..50f45a0 100644 Binary files a/WEEK07/WEEK07-BN.pdf and b/WEEK07/WEEK07-BN.pdf differ diff --git a/WEEK07/WEEK07.pdf b/WEEK07/WEEK07.pdf index 57c5535..20f5df3 100644 Binary files a/WEEK07/WEEK07.pdf and b/WEEK07/WEEK07.pdf differ diff --git a/WEEK09/WEEK09-BN.pdf b/WEEK09/WEEK09-BN.pdf index 8fbd2f0..69309f3 100644 Binary files a/WEEK09/WEEK09-BN.pdf and b/WEEK09/WEEK09-BN.pdf differ diff --git a/WEEK09/WEEK09.pdf b/WEEK09/WEEK09.pdf index b901bf2..b64eb10 100644 Binary files a/WEEK09/WEEK09.pdf and b/WEEK09/WEEK09.pdf differ diff --git a/WEEK10/WEEK10-BN.pdf b/WEEK10/WEEK10-BN.pdf index 8a1d407..dd812db 100644 Binary files a/WEEK10/WEEK10-BN.pdf and b/WEEK10/WEEK10-BN.pdf differ diff --git a/WEEK10/WEEK10.pdf b/WEEK10/WEEK10.pdf index cd05955..6aa8025 100644 Binary files a/WEEK10/WEEK10.pdf and b/WEEK10/WEEK10.pdf differ diff --git a/WEEK11/WEEK11-BN.pdf b/WEEK11/WEEK11-BN.pdf index 9b53925..9514fe9 100644 Binary files a/WEEK11/WEEK11-BN.pdf and b/WEEK11/WEEK11-BN.pdf differ diff --git a/WEEK11/WEEK11.pdf b/WEEK11/WEEK11.pdf index 01c037d..0d65c11 100644 Binary files a/WEEK11/WEEK11.pdf and b/WEEK11/WEEK11.pdf differ