LNode Firmware: Building and Flashing

There are two ways to put our firmware on a board, and they exist for different people.

lnflashjust flash*
foranyone with a boarddevelopers and CI
needsthe bundle, and rootthis checkout and the embedded toolchain
builds firmwareno, it carries ityes, from the working tree
identifies the boardfrom its bootloaderfrom the USB id you configure
boards todayT114, RAK4631T114, RAK4631

If you just want our firmware on a board, use lnflash. If you are changing the firmware and want your build on a board, use just flash.

Physical-device steps. The author of this page cannot flash a board, so any step that writes to or resets real hardware is marked derived from source — requires the physical device. The commands themselves are quoted verbatim from the Justfile and leviculum-nrf/README.md; only the outcome on hardware is un-verified here.

lnflash, the distributable flasher

lnflash is a single static binary with the firmware beside it. It needs no toolchain, no Python, no network, and nothing installed: the point of the bundle is that a stranger can unpack it and run it.

wget https://codeberg.org/Lew_Palm/leviculum/releases/download/nightly/lnflash-nightly-amd64.tar.gz
tar xzf lnflash-nightly-amd64.tar.gz
cd lnflash-*
sudo ./lnflash

(Justfile:51-52)

That URL is the whole answer to "how do I get your firmware onto my board" and it is the one this page previously left out: it described the bundle without saying where it comes from, so the only path a reader could follow was a build from source (Codeberg #295). The rolling nightly carries one image per board in the list at scripts/lnflash-bundle.sh, and just check-firmware-images keeps that list, the README's board table and the release body from disagreeing about it.

It works out what the board is, rather than being told. That matters because a board arrives carrying whatever its last owner put on it: stock firmware, Meshtastic, MeshCore, RNode firmware, ours, or a build that crashes before it reaches USB. Each of those picks its own USB identity, so the running firmware cannot be trusted to say what the hardware is. lnflash therefore finds candidates on the USB bus, brings each into its bootloader, and only there asks what the board actually is, from the bootloader's own INFO_UF2.TXT. The identity that a write rests on can only come from that reading, which is enforced in the type system rather than by convention (lnflash/src/lib.rs:15-21). Then it checks the SoftDevice precondition, installs a matching SoftDevice first if needed, writes the firmware, and reads the board's debug port back to confirm what is now running.

Nothing is written before all of that has been shown and confirmed.

Root is required. The bootloader's drive is a root:disk block device, and lnflash mounts it itself rather than assuming a desktop automounter that a headless host does not have. Without root it will identify the attached boards and then stop. (lnflash/src/main.rs:37-38)

One key press is sometimes unavoidable. Getting into the bootloader by software has to be implemented by whatever firmware is currently running. Ours implements it, so every re-flash is touch-free. Stock Meshtastic does not, so a first flash away from it needs a physical double-tap of RESET, the second press within about half a second of the first. lnflash detects that case and asks for it in plain words. There is no universal software trigger, and a tool that claimed otherwise would be lying.

Options

--dry-run reports what is attached and what would happen, changing nothing at all, not even rebooting a board into its bootloader. --check-bundle verifies the bundle's own checksums and exits. --board NAME refuses to write if what is attached is a different board. --yes skips confirmation for automation and fails rather than waits when a board needs the manual double-tap. Radio settings can be given at flash time with --radio-preset (eu868, us915, au915) or the individual --radio-freq, --radio-bw, --radio-sf, --radio-cr and --radio-txpower flags; --no-radio leaves the board's stored configuration alone. (lnflash/src/main.rs:42-402. The board keeps what it is given across resets and across the next flash, so this is part of the flash rather than a later configuration step.)

The bundle is looked for in this order: --bundle PATH, then $LNFLASH_BUNDLE, then the directory holding the binary, then /usr/share/lnflash. (lnflash/src/main.rs:42-45)

The full user-facing text ships inside the bundle as its README (lnflash/payload/README-bundle.md), including what the alarming but harmless "the drive went away mid-flush" message means.

Building a bundle

just lnflash-bundle

Cross-compiles the firmware, converts it to UF2, builds the musl-static binary, stages Nordic's SoftDevice next to Nordic's own licence file, generates a manifest with checksums, and verifies the result. Output lands under target/lnflash/. The first run takes minutes because of the firmware build; SKIP_FIRMWARE=1 reuses an existing ELF while iterating on the bundle itself. (Justfile:53-62)

Everything in the bundle comes from this checkout. A bundle built out of a foreign tree would be exactly the hidden dependency our clone-and-deploy policy forbids. (Justfile:55-57)

Which boards the bundle carries

Today: the T114 and the RAK4631 (WisMesh Pocket V2 and every other carrier built around the RAK4630 module). Boards are data rather than code, so a new board is a catalogue entry plus a firmware build, not a new binary — and an entry without a firmware build is an empty promise, so the shipped bundle carries what we actually build.

The RAK4631 image is the bsp-rak4631,rak-baseboard build, the same one just flash-rak4631-pocket produces. Not because it is the richer build, but because How far one firmware build reaches already decided it: one build serves a pinout family, and everything the Pocket V2 baseboard adds degrades harmlessly on a bare module — the display is found by an I2C probe and its task exits when nothing answers, the button is Pull::Up so an absent one reads as not pressed, the GNSS task parks on a silent UART, and the battery task publishes to a subscriber that is not running. The bundle therefore does not ask which RAK you have, and the manifest has no way to express two images for one Board-ID.

scripts/lnflash-bundle.sh walks a board list rather than naming boards in its steps, so a third board is one more line in that list: the firmware build, the UF2 conversion, the staging, the manifest sections and the licence assertions against the finished tarball all derive from it.

The SenseCAP Solar Node is known but not flashed here (Codeberg #233). lnflash talks to it like any other board — --watch, --announce, --set-time, --set-name, the --radio-* flags — because those reach a board that is up and identifying itself. Writing firmware to it is a different question and the answer is no: the Board-ID its bootloader publishes, nRF52840-SeeedXiao-v1, belongs to the XIAO module rather than to this product, and a DIY XIAO with the radio wired elsewhere reports the same string. So the bundle carries no image for it, --board solarnode is refused, and a flash session that finds it on the bus names it, says why, and leaves it alone. It is flashed from this checkout with just flash-solarnode, by a person who can see which board is on the bench.

The SoftDevice carve-out. The T114 entry ships Nordic's S140 7.3.0 beside its licence, so a factory board carrying 6.1.1 is repaired and then flashed. The RAK4631 entry ships no SoftDevice. It states the same >=7.0.1, <8.0.0 constraint, but whether a factory Pocket V2 carries something that constraint refuses is unmeasured — our only RAK has run 7.3.0 since we first flashed it. A board that violates the constraint with no remedy in the bundle is refused with Nothing was written rather than written blind. The full reasoning, and what one reading of a stock board would take to close it, is under "The SoftDevice carve-out" in Flashing an LNode.

First flash on a Pocket V2 needs the pinhole. That board has no externally accessible RESET, so when the 1200-baud touch does not take — which is every board still running stock Meshtastic — lnflash asks for a needle double-tap in the hidden pinhole beside the USB socket by name, and points at Recovery. The bundle does not depend on the meshtastic CLI for this; just dfu-rak4631 below stays available in this checkout, but a stranger with the tarball needs only a needle.

The design behind all of this, including why the bootloader rather than the application is the board's identity, is in Flashing an LNode.

The developer path: building from this checkout

The rest of this page covers building the firmware here and flashing it with the just flash* recipes.

Prerequisites

Install the Rust embedded toolchain, the ARM cross-compiler (needed by nrf-sdc for C-header bindgen), flip-link, and add your user to the dialout group for serial-port access. Log out and back in after the usermod so the new group membership takes effect.

rustup target add thumbv7em-none-eabihf
rustup component add llvm-tools
cargo install flip-link
sudo apt install gcc-arm-none-eabi
sudo usermod -aG dialout $USER

flip-link is the firmware linker. It relocates the stack to the bottom of RAM so a stack overflow faults cleanly against the RAM floor instead of silently corrupting memory. It is link-time only, with zero runtime cost.

(leviculum-nrf/README.md:12-19)

--release is mandatory

Always build and flash with --release. The debug profile does not fit the nRF52840 flash — the image overflows FLASH by several hundred KB at link time.

The debug profile does not fit the nRF52840 flash (the image overflows FLASH by several hundred KB at link time) — always build and flash with --release; all just flash-* recipes already do. (leviculum-nrf/README.md:65-67)

Every just flash* recipe already passes --release, so following the recipes below keeps you safe. The release profile is size-optimized (opt-level = "z", lto = true, codegen-units = 1); DWARF debug info is kept in the .elf (strip = "none", debug = true) for HardFault post-mortem analysis, but the UF2 only carries loadable sections, so the debug info does not bloat what lands on the device. (leviculum-nrf/Cargo.toml:399-409)

The build/flash workflow

The firmware crate leviculum-nrf is its own Cargo workspace, separate from the repo-root workspace, and is cross-compiled. The flash recipes therefore cd leviculum-nrf before invoking cargo. (Justfile:1884-1885)

A plain build (no flash) is:

cargo build --release

(leviculum-nrf/README.md:23)

Flashing wraps cargo run: the runner builds the release binary, then copies the resulting UF2 onto each board's UF2 bootloader drive. The UF2 conversion and copy happen inside the cargo run step — a bare cargo build produces only the ELF.

Build the firmware with cargo build --release. Flash with just flash (from the repo root), which wraps cargo run --release --bin t114. (leviculum-nrf/README.md:23)

Touch-free vs. manual double-tap

For the T114, flashing is touch-free in the common case: the host opens the board's transport CDC port at 1200 baud, the firmware intercepts the line-coding change, writes a retained-register magic, and soft-resets into the Adafruit UF2 bootloader. No button press. (leviculum-nrf/README.md:27)

A physical double-tap of RESET is still needed when the firmware on a specific T114 has crashed or never reached USB init (panic before the handler is installed, stack overflow, hardware fault). The runner detects this per device via a UF2-drive-polling timeout and prompts for that specific board only; the rest of the batch keeps flashing touch-free. (leviculum-nrf/README.md:38)

The WisMesh Pocket V2 (RAK4631) running stock Meshtastic has no 1200-baud-touch handler and no externally accessible RESET pin, so its first flash needs either just dfu-rak4631 (a Meshtastic admin command, below) or the manual needle double-tap in the hidden pinhole. Once our firmware is on the board, subsequent flashes use the touch path automatically. (Justfile:1911-1913, Justfile:1963-1972. See Recovery for the pinhole detail.)

The flash recipes

Each recipe below is quoted from the Justfile. The cargo invocation is derived from source — requires the physical device to actually write firmware (it builds the same on any host, but only does something useful with a board attached).

just flash — every T114

Flashes every attached T114 sequentially. Flashing all of them is deliberate: if only one were flashed, a later multi-node test could run against mixed firmware versions. Use this as your default for T114s.

cd leviculum-nrf && cargo run --release --bin t114 --features bsp-t114

(Justfile:1886-1888; rationale leviculum-nrf/README.md:25)

just flash-one PORT — a single T114

Flashes one T114 by port path or udev symlink. Use it for A/B firmware testing (one board on a new build, one on the old).

just flash-one /dev/leviculum-transport
just flash-one /dev/ttyACM3

Expands to:

cd leviculum-nrf && LEVICULUM_FLASH_ONLY=<PORT> cargo run --release --bin t114 --features bsp-t114

(Justfile:1895-1900; usage forms leviculum-nrf/README.md:31-36)

just flash-rak4631 — every RAK4631 (bare module)

Flashes every attached RAK4631 / WisMesh Pocket V2 with the bare-module build (no baseboard peripherals).

cd leviculum-nrf && LEVICULUM_USB_PID=0002 LEVICULUM_BOARD_NAME=RAK4631 \
  LEVICULUM_UF2_BOARD_ID=WisBlock-RAK4631-Board \
  cargo run --release --bin rak4631 --features bsp-rak4631

(Justfile:1914-1916)

just flash-rak4631-one PORT — a single RAK4631

Flashes one RAK4631 by port path or udev symlink.

just flash-rak4631-one /dev/ttyACM0
just flash-rak4631-one /dev/leviculum-rak-transport

Expands to:

cd leviculum-nrf && LEVICULUM_FLASH_ONLY=<PORT> LEVICULUM_USB_PID=0002 \
  LEVICULUM_BOARD_NAME=RAK4631 LEVICULUM_UF2_BOARD_ID=WisBlock-RAK4631-Board \
  cargo run --release --bin rak4631 --features bsp-rak4631

(Justfile:1918-1922)

just flash-rak4631-pocket — WisMesh Pocket V2, full baseboard

Flashes with all RAK19026 baseboard peripherals enabled (display, GNSS, battery). --features rak-baseboard aggregates the three baseboard features. Use this for a complete WisMesh Pocket V2.

cd leviculum-nrf && LEVICULUM_USB_PID=0002 LEVICULUM_BOARD_NAME=RAK4631 \
  LEVICULUM_UF2_BOARD_ID=WisBlock-RAK4631-Board \
  cargo run --release --bin rak4631 --features bsp-rak4631,rak-baseboard

(Justfile:1924-1930; rak-baseboard aggregate leviculum-nrf/Cargo.toml:382)

just dfu-rak4631 PORT — DFU entry for stock Meshtastic

Triggers the Adafruit UF2 bootloader on a stock-Meshtastic WisMesh Pocket V2 in software. Stock Meshtastic has no 1200-bps-touch handler and the device has no externally accessible RESET pin, so this firmware-side admin command is the only software-only DFU entry. Needed only for the first flash from Meshtastic; after our firmware lands, just flash-rak4631 uses the touch path and this recipe is no longer needed. Requires the meshtastic CLI on PATH (pip install meshtastic).

just dfu-rak4631 /dev/ttyACM0

Runs:

meshtastic --port /dev/ttyACM0 --enter-dfu

(Justfile:1963-1972)

A note on disconnecting consumers

Flashing a board takes over its transport serial port. Any running consumer of that port (for example an active lnsd pointed at it) loses its connection when the board is flashed. The flash action is explicit and active; no persistence is promised across it. (leviculum-nrf/README.md:40)

The device keeps its Reticulum identity in internal flash and preserves it across firmware updates, so re-flashing does not change the node's address. (leviculum-nrf/README.md:42. More in Recovery.)

Verifying the build before you flash

cargo build --release (above) confirms the image links and fits flash. If you want to lint the firmware as CI does:

just lint-nrf

(Builds both BSP feature sets under clippy with -D warnings: Justfile:75-77.)

Next: Serial ports for wiring the flashed board into lnsd.