Canonical guidance for AI coding agents and maintainers working in this repo.
(CLAUDE.md is a pointer to this file.)
- 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.corusb/— the Architecture section below assumes it. - The rest of
README.md— boards supported, installation, the Meshtastic Android in-app upgrade flow, troubleshooting. CONTRIBUTING.md— dev setup, PR process, adding a new board.changelog.md— version history (2.6.0and theOTAFIX 2.xentries at the top; everything below predates the OTAFIX fork).- The design invariants and gotchas below — do not violate them.
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.
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 tableNo 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.
Each board directory can carry:
board.h— pin/peripheral defines. DefiningDISPLAY_PIN_SCK(SPI TFT) orDISPLAY_PIN_SDA(I2C OLED), never both, makessrc/boards/boards.hdefineBOARD_HAS_DISPLAY, which gates the display code insrc/screen.candsrc/images.c.BLEDIS_MANUFACTURER/BLEDIS_MODELare the real vendor name, such as RAKWireless — don't rebrand these. DefiningBANNER_TEXToverrides the on-screen DFU banner default insrc/screen.c.board.mk— Makefile board config (MCU_SUB_VARIANT, per-boardCFLAGSlike the BLEDEVICE_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).
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.
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.
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.
- Release tags are bare
x.y.z. The Makefile parses the first dash-separatedx.y.zofgit describeintoMK_BOOTLOADER_VERSION, so avprefix 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'sdevice_bootloader_ota_quirks.jsonmatches boards by the canonicalHardwareModelprotobuf 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 breakingUF2_BOARD_ID/build-artifact filenames for no benefit. linker/nrf_common.ldneeds__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 updatedmdk/nrf_common.ldto get these — it also redefines.bss/.noinitplacement, which would silently fight this repo's board.ldfiles (e.g.linker/nrf52840.ld's fixed-addressNOINITregion 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-AndroidflagsNANO_G2_ULTRAandNOMADSTAR_METEOR_PROas needing bootloader upgrade support with no board here yet (issues #4, #5) — bringing up a new board needs real hardware to getUF2_BOARD_ID/VID-PID/pin defs right; don't fabricate aboard.hwithout one. - A
CURRENT.UF2dump restored byte-for-byte must boot. Two things hold it:CURRENT.UF2is sized off the installed app (ghostfat.c'scurrent_flash_size()), falling back to the max app region (TRUE_USER_FLASH_SIZE) only whenbank_0_sizeis zero, erased or oversized, as after a debug-probe flash, and the UF2 flash completion path records the real app size intobootloader_settings.bank_0_size(msc_uf2.c'supdate_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. Afteradafruit-nrfutil dfu serial --package <board>_bootloader-..._s140_...zipreports "Device programmed", a board on a USB host comes up as a UF2 drive showing NO FIRMWARE; drag the app's.uf2across. 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_eventinmain.c). A cable already present when the wait began does not: double-press reset for the drive, or complete a BLE OTA DFU.