Skip to content

Latest commit

 

History

History
265 lines (230 loc) · 15.3 KB

AGENTS.md

File metadata and controls

265 lines (230 loc) · 15.3 KB

AGENTS.md

Canonical guidance for AI coding agents and maintainers working in this repo. (CLAUDE.md is a pointer to this file.)

First read

  1. README.md's "How this fits together" — the boot chain (MBR → this bootloader → SoftDevice → application), why this repo is only the bootloader and not the Meshtastic application itself, and the three ways a new image gets installed (UF2, serial DFU, BLE OTA DFU). Read this before touching main.c or usb/ — the Architecture section below assumes it.
  2. The rest of README.md — boards supported, installation, the Meshtastic Android in-app upgrade flow, troubleshooting.
  3. CONTRIBUTING.md — dev setup, PR process, adding a new board.
  4. changelog.md — version history (2.6.0 and the OTAFIX 2.x entries at the top; everything below predates the OTAFIX fork).
  5. The design invariants and gotchas below — do not violate them.

What this is

The Meshtastic nRF52 Bootloader (formerly the OTAFIX bootloader; repo meshtastic/nRF52_Bootloader), org-owned since 2026-08. It is a fork of oltaco's OTAFIX bootloader, itself a fork of adafruit/Adafruit_nRF52_Bootloader with OTA DFU enhancements. It's the bootloader several nRF52-based Meshtastic devices ship with, and the one the Meshtastic Android app can upgrade in-app over USB/serial (feature/firmware there).

Meshtastic's separate Adafruit_nRF52_Bootloader fork is a different lineage without the OTAFIX patches — don't confuse the two.

This is C, built with a bare Makefile. Not PlatformIO.

Build

You need arm-none-eabi-gcc (CI pins 12.3.Rel1 exactly — see the toolchain gotcha below) and Python 3.

git submodule update --init --recursive   # lib/nrfx, lib/tinyusb, lib/uf2 — required, not vendored inline

python3 -m venv .venv && source .venv/bin/activate   # or: uv venv .venv && source .venv/bin/activate
pip install adafruit-nrfutil uritemplate requests intelhex setuptools

make BOARD=wiscore_rak4631_board all              # board = a directory name under src/boards/
make BOARD=wiscore_rak4631_board copy-artifact    # -> _bin/<board>/*.zip, *.hex, update-*.uf2
make BOARD=wiscore_rak4631_board clean

tools/build_all.py   # builds every board in src/boards/, prints pass/fail + flash/RAM size table

No lint, no test suite — this is a bootloader; the only correctness signal is "does it compile for every board" (CI's job) and real hardware testing.

Architecture

Board abstraction: src/boards/<board>/

Each board directory can carry:

  • board.h — pin/peripheral defines. Defining DISPLAY_PIN_SCK (SPI TFT) or DISPLAY_PIN_SDA (I2C OLED), never both, makes src/boards/boards.h define BOARD_HAS_DISPLAY, which gates the display code in src/screen.c and src/images.c. BLEDIS_MANUFACTURER/BLEDIS_MODEL are the real vendor name, such as RAKWireless — don't rebrand these. Defining BANNER_TEXT overrides the on-screen DFU banner default in src/screen.c.
  • board.mk — Makefile board config (MCU_SUB_VARIANT, per-board CFLAGS like the BLE DEVICE_NAME).
  • pinconfig.c — present on some boards, board-specific pin table.

The Makefile takes the board from BOARD: it includes src/boards/$(BOARD)/board.mk, puts src/boards/$(BOARD) on the include path for board.h, and compiles pinconfig.c on every MCU but the nRF52832. Other sources are listed explicitly, so a new file in a board directory is not compiled until the Makefile names it. The build and CI discover boards from the directory, but four hand-kept lists do not: the README's board table and BLE names table, the board dropdown in .github/ISSUE_TEMPLATE/bug_report.yml, and branch protection's required checks (see CI below).

Core bootloader (src/)

main.c is the entry point (MBR/SoftDevice handoff, DFU state machine dispatch). dfu_magic.h holds the NRF_POWER->GPREGRET magics an application (or the bootloader itself) writes to pick the next boot mode. ble_dfu_cfg.h holds the ATT MTU (247), the connection event length (6) and the high-bandwidth connection config tag that main.c's SoftDevice config and dfu_transport_ble.c must agree on.

The cable routing for a board with no valid application lives in three places. check_dfu_mode() in main.c takes a settled VBUSDETECT read (vbus_detected_settled(), up to 500 ms, because the bit can read 0 briefly after a reset that followed usb_teardown()) and picks the UF2 drive with the 3 s host timeout when VBUS is present, the BLE OTA wait otherwise. In the OTA wait, ota_vbus_* in main.c watches the SoftDevice's USB power events for a 0-to-1 VBUS transition held 250 ms with no central connected, and ends the session so the gate runs again; the watch seeds from the settled read so a cable already present does not count. In UF2 mode, tud_umount_cb in usb/usb.c arms a 250 ms timer when VBUS is gone and no application is valid, and ends the session so main() resets into the OTA wait. main() resets plain when a host mounted the drive this session or the cable watch fired, and with DFU_MAGIC_OTA_RESET otherwise; a plain reset lets the gate decide again, the magic forces the wait.

bootloader_dfu_start() (lib/sdk11/.../bootloader.c) arms a 1 s tick that boots a valid application after DFU_INACTIVITY_TIMEOUT_MS (6 minutes) of idle. Idle is the absence of dfu_activity() calls: the BLE transport calls it on connect and on every DFU event, the serial transport on every packet, msc_uf2.c on every MSC read and write, and usb.c on USB mount. A new transport or activity source must call it, or the timer boots the application mid-session. The tick keeps running with no valid application, but the exit is skipped, so the wait continues.

src/fault_record.h and app_error_fault_handler() in main.c record a SoftDevice or SDK fault (id, pc, info; for an SDK error info is the error code) into a 16-byte FAULT_RECORD region at 0x20008000 that every linker script places above the SoftDevice's RAM and outside .data/.bss, so the reset that follows keeps it. ghostfat.c's uf2_init() prints it as the Last-Fault: line of INFO_UF2.TXT and clears the magic. __sd_ram_end__ is derived from that region's origin, so moving it moves the SoftDevice's RAM base.

lib/sdk11/.../pstorage/pstorage_raw.c re-issues a flash operation the SoftDevice reports as timed out (NRF_EVT_FLASH_OPERATION_ERROR) from an app_timer, every FLASH_RETRY_DELAY_MS (100 ms) up to FLASH_RETRY_LIMIT (20) times, and reports NRF_ERROR_TIMEOUT after that, which the DFU callbacks treat as fatal; a NRF_ERROR_BUSY rejection re-issues through the scheduler on its own bound, and any other rejection reports its own code.

dfu_transport_ble.c resets the DFU session state on an unexpected disconnect (session_reset()) and restarts advertising, so a central that dropped mid-transfer can send a fresh START_DFU without the board being reset. screen.c/images.c render the DFU UF2/BLE-OTA screens, active only when the board names a display bus (DISPLAY_PIN_SCK or DISPLAY_PIN_SDA, see board.h above). The I2C OLED path probes 0x3C and 0x3D and reads the controller type off the panel, so a missing panel only means no picture. screen_set_title() replaces the board name on the first line (NO FIRMWARE, WRONG FILE, BOOTLOADER, ERASE) and screen_set_progress() turns the banner line into a bar; both are no-ops on a board with no display. dfu_ble_svc.c/dfu_init.c are the BLE DFU service; flash_nrf5x.c wraps flash writes and splits a write that crosses a 4 KB page. usb/ is the USB MSC (UF2 drive) + CDC stack on top of the vendored lib/tinyusb. ghostfat.c's write_block() marks bank_0 invalid on the first application block it accepts (so a half-written image never boots) and rejects, for the rest of the file, an app-family image whose blocks start below the application start without a SoftDevice block at USER_FLASH_START (one built for the other SoftDevice); the staged bootloader write marks an application that reaches into the staging area invalid first. A UF2 block with family CFG_UF2_MESHTASTIC_ERASE_ID (usb/uf2/uf2cfg.h) is a factory-erase request: ghostfat.c flags it, msc_uf2.c erases the whole App Data reservation (USER_FLASH_END..BOOTLOADER_REGION_START) once the host has acked the write, then arms a 500 ms app_timer (or an eject from the host) to leave the DFU loop so check_dfu_mode() can tear USB down before it resets into UF2 mode — resetting inside the MSC callback would drop the host's trailing FAT writes. The reservation holds two things on nRF52840 — LittleFS in the top 7 pages and firmware's WarmNodeStore node-DB ring in the 3 below it at 0xEA000 — so the loop must cover the bootloader's bound, never just the filesystem. tools/make_factory_erase_uf2.py emits the board-agnostic file, checked in as tools/meshtastic_factory_erase.uf2. cmsis/ is ARM CMSIS headers.

Vendored submodules (lib/)

lib/nrfx, lib/tinyusb, lib/uf2 are git submodules, not vendored copies — git submodule update --init --recursive is required before any build. renovate.json surfaces bump PRs on a daily schedule, but nothing bumps them automatically — these are boot-critical, and a version jump needs real hardware testing before merge, not a bot auto-merge.

lib/nrfx is deliberately pinned to v3.14.0, not the latest commit Renovate will keep proposing. nrfx 4.0 restructured the entire repo layout (mdk/ → bsp/stable/mdk/, among others) — a jump to it would need this repo's Makefile IPATHs and every board's linker script reworked, not just a digest bump. v3.14.0 is the last tag on the pre-4.0 layout, so it's the practical ceiling for a same-day bump; going past it is a real project, not a Renovate merge. renovate.json disables lib/nrfx updates for exactly this reason, so no nrfx bump PR should appear at all; if one does, that rule has broken and the PR still must not be merged without the rework. Drop the rule when the rework lands. lib/tinyusb and lib/uf2 are unaffected.

lib/tinyusb's nrf5x USB port (dcd_nrf5x.c) calls nrfx's chip-specific errata functions (e.g. nrf52_errata_199()) — bumping tinyusb alone, without nrfx at a version new enough to define them, fails to compile. The two submodules move together, not independently, and a Renovate PR that bumps either one alone fails CI; see the linker/nrf_common.ld gotcha below for the other half of what that joint bump needed.

lib/softdevice/ vendors Nordic's SoftDevice binaries directly (not a submodule) — SD_NAME/SD_VERSION in Makefile select which one.

CI (.github/workflows/githubci.yml)

A set-matrix job lists src/boards/*/ and fans out a build job per board, on every PR and on release: created. On release events a single release job downloads every board's artifacts and uploads .zip/.hex/update-*.uf2 per board plus tools/meshtastic_factory_erase.uf2 as release assets; PR runs just validate the compile and get 1-day artifact retention (release runs keep 90). Only release holds contents: write — every job that runs repo code (make, tools/) is read-only with persist-credentials: false, so a PR cannot borrow a write token.

Branch protection on master requires set-matrix and one build (<board>) context per board, by literal name. Adding, removing, or renaming a board changes those context names — branch protection does not update itself; a human has to edit it too.

Gotchas

  • Release tags are bare x.y.z. The Makefile parses the first dash-separated x.y.z of git describe into MK_BOOTLOADER_VERSION, so a v prefix breaks the build.
  • ARM GCC version matters. CI pins exactly 12.3.Rel1; 13.3.Rel1 also compiles clean and is the closest verified-working version if you can't get the exact CI-pinned one. GCC 15 is unverified here.
  • MeshCore/Ripple content has been deliberately removed from the docs (README, changelog) — this is Meshtastic's own branded fork now, not a place to re-add other companion-firmware documentation. If you're porting docs changes from upstream oltaco, check for this before copying anything verbatim.
  • This repo's src/boards/* folder names are internal-only. Nothing cross-repo reads them — Meshtastic-Android's device_bootloader_ota_quirks.json matches boards by the canonical HardwareModel protobuf enum (hwModel/hwModelSlug) and by firmware's PlatformIO target names, never by this repo's directory names. Renaming a board directory here for "consistency" achieves nothing functionally and risks breaking UF2_BOARD_ID/build-artifact filenames for no benefit.
  • linker/nrf_common.ld needs __data_start/__sdata_*/__tdata_*/__fast_* symbols since nrfx 3.x. lib/nrfx/mdk/gcc_startup_<mcu>.S's data-copy loop changed from this repo's __data_start__-style (double underscore) names to plain __data_start, plus three new RAM-loaded regions (.sdata/.tdata/ .fast) nothing in this codebase actually uses. Do not pull in nrfx's own updated mdk/nrf_common.ld to get these — it also redefines .bss/.noinit placement, which would silently fight this repo's board .ld files (e.g. linker/nrf52840.ld's fixed-address NOINIT region used for BLE bond exchange across a DFU reset). The fix already applied is a handful of alias assignments plus zero-length filler for the three unused regions — extend that pattern, don't replace the file.
  • Board gaps are tracked as issues, not guessed at. Meshtastic-Android flags NANO_G2_ULTRA and NOMADSTAR_METEOR_PRO as needing bootloader upgrade support with no board here yet (issues #4, #5) — bringing up a new board needs real hardware to get UF2_BOARD_ID/VID-PID/pin defs right; don't fabricate a board.h without one.
  • A CURRENT.UF2 dump restored byte-for-byte must boot. Two things hold it: CURRENT.UF2 is sized off the installed app (ghostfat.c's current_flash_size()), falling back to the max app region (TRUE_USER_FLASH_SIZE) only when bank_0_size is zero, erased or oversized, as after a debug-probe flash, and the UF2 flash completion path records the real app size into bootloader_settings.bank_0_size (msc_uf2.c's update_status.app_size); otherwise only serial DFU sets it. Lose either and the restored device hangs on boot. If you change either file, re-run dump-and-restore on hardware.
  • Flashing the bootloader+SoftDevice package over serial DFU zeros bank_0 — the device then has no app and does not boot on its own. After adafruit-nrfutil dfu serial --package <board>_bootloader-..._s140_...zip reports "Device programmed", a board on a USB host comes up as a UF2 drive showing NO FIRMWARE; drag the app's .uf2 across. On battery (no VBUS) the gate's 500 ms settle fails and it goes straight to the BLE-OTA wait; VBUS with no host (a charger or power bank) gets the drive for 3 s first, and unplugging the cable while the drive is up goes to the wait too. Plugging a cable into a no-app board that is waiting for BLE, with no phone connected, brings the drive up (VBUS 0 to 1 during the wait, held 250 ms; ota_vbus_event in main.c). A cable already present when the wait began does not: double-press reset for the drive, or complete a BLE OTA DFU.