t114-rust-no-brick+

Rewriting Firmware in Rust Without Bricking the Board

● 2026-09-24 · 23 min readdraft

How we ported the firmware of a LoRa coverage tester for the Heltec T114 from C++/Arduino to Rust/Embassy under one hard constraint: not a single step after which the board could only be brought back to life with a programmer.

The danger is not where you expect it

When you sit down to rewrite working firmware in another language, the radio is what scares you: timings, interrupts, an SX1262 driver you’ll have to write from scratch. The radio turned out to be the most predictable part. The most dangerous line in the whole port looks like this:

let p = embassy_nrf::init(Default::default());

With default settings, on some chips it writes to the UICR configuration registers — a non-volatile area that can’t be rolled back without a full erase through a programmer — and then resets the microcontroller. Before the first line of your own code, and without a single warning in the log. For firmware that promises “zero flash writes”, that matters. But let’s take it in order.

What we’re porting and why

The device is a field LoRa coverage tester built from two off-the-shelf mesh nodes; there’s a previous article about it. Here we’re only concerned with the T114: nRF52840 + SX1262, a display, BLE to a phone. Its firmware is a single 1173-line main.cpp: PlatformIO, the Adafruit Arduino core, RadioLib 7.7.1, Bluefruit for BLE. It works in the field, and that is exactly why touching it is scary.

An honest caveat: for us this is still research, not a decision. Our internal assessment recommends a hybrid — the board stays on C++, with the portable logic in a core that is tested on the host — and a full Embassy port only after an experiment on a spare board. This article is that experiment.

The other node, the T1000-E, stays on C++: there’s no Rust driver for its LR1110 radio, and writing one for the sake of an experiment would be overkill.

What “don’t brick it” means

Out of the box, the T114 ships with a bootloader from the Adafruit nRF52 family. The flash is laid out like this:

0x00000  MBR
0x01000  SoftDevice S140 6.1.1        (MBR + SD = 152 KiB)
0x26000  application                   ← this is where we write
0xED000  InternalFS (LittleFS, 28 KiB)
0xF4000  bootloader: UF2, serial DFU, BLE OTA DFU
0xFF000  bootloader settings

Everything outside the application is the emergency exit. A double press of RST catches the bootloader before the application gets control — and the board shows up as a USB drive HT-n5262, onto which you can drop any firmware, including the old C++ one. Even if the Rust image falls into a HardFault on its very first instruction, this path still works.

Hence a simple definition. To “brick” the board means to damage something that lives outside the application area: the MBR, the SoftDevice, the bootloader, its settings, or the UICR. None of that can be restored without a programmer. So the rule of the port reads: the firmware touches only its own flash area and makes not a single NVMC write, and it is flashed only through the bootloader — UF2 or adafruit-nrfutil — and never over SWD with a chip erase.

A bonus of this rule: the C++ firmware keeps the LoRaWAN DevNonce counter in InternalFS. If Rust leaves it alone, then after switching back to C++ the board keeps joining the network server without anyone having to reset the counter on the server side by hand.

The SoftDevice you must not touch

The SoftDevice is Nordic’s closed-source BLE stack. It sits in flash next to the application, and the application calls into it via SVC. On the T114 it’s S140 version 6.1.1.

The standard route to BLE in Rust — the nrf-softdevice crate — only supports S140 v7. Going from 6.1.1 to 7.x means replacing the SoftDevice, the bootloader and the application all at once (and the application moves from 0x26000 to 0x27000 on top of that). There is no official T114 bootloader with S140 v7 — neither from Heltec nor in the community fork that Meshtastic and MeshCore use. A mistake at this step can only be fixed over SWD. In other words, the shortest path to BLE in Rust is exactly the one our rule forbids.

The workaround is to not use the SoftDevice at all. Nordic also ships another BLE controller, the SoftDevice Controller (nrf-sdc), which links directly into the application, and on top of it you can run TrouBLE, a Rust host. S140 6.1.1 stays in flash, untouched. The application doesn’t enable it, while the bootloader keeps enabling it on its own whenever it receives firmware over the air.

The price is about 150 KiB of flash for a stack the application doesn’t use. Out of almost 800 KiB of application area, we can afford that. But let’s call things by their names: there are two BLE stacks on the chip, and only one of them runs. Later, the TrouBLE maintainer pointed this out to us directly — more on that near the end.

The idea isn’t new, and that’s worth saying. The ZMK keyboard firmware on Zephyr does the same thing on the nice!nano: its own controller, with the SoftDevice area reserved. The Rust firmware RMK also moved to nrf-sdc + TrouBLE, but took a different route: it claims the SoftDevice area for itself, and to roll back to firmware that needs a SoftDevice, it suggests reflashing the bootloader. For a keyboard that’s a reasonable trade-off. For us it isn’t: BLE OTA from the bootloader is the only way to update the board from an iPhone.

Linking: four numbers

All of the flash “protection” at build level is a four-line memory.x:

MEMORY
{
  FLASH : ORIGIN = 0x00026000, LENGTH = 0xC7000
  RAM   : ORIGIN = 0x20000008, LENGTH = 256K - 8
}

0x26000 + 0xC7000 = 0xED000 — the application ends exactly where InternalFS begins. That’s the same 815,104 bytes listed as the maximum firmware size in the PlatformIO board definition (taken from Meshtastic).

RAM starts at 0x20000008: the first 8 bytes belong to the MBR. For comparison, the Arduino firmware starts at 0x20006000 — 24 KiB are given over to the enabled SoftDevice. The Rust application gets them for free.

One more detail: cortex-m-rt has the set-vtor feature enabled. The application writes the address of its own vector table into VTOR itself, rather than trusting the MBR and a disabled SoftDevice to forward interrupts to it correctly.

Surprise: init() writes to UICR

Now for that line. We read embassy_nrf::init() from embassy-nrf 0.11 in full before calling it, and found three UICR writes in it.

APPROTECT. By default, Config contains debug: Debug::Allowed. On an nRF52840 with build code F or newer, init() writes a value permitting debug into UICR.APPROTECT and, if the write actually happened, resets the chip.

PSELRESET. Regardless of settings, init() writes pin number 18 into both PSELRESET registers — assigning the standard RESET. The only way to switch this off is the reset-pin-as-gpio feature, which does the opposite.

NFCPINS. Also unconditional, although with default features no real write happens here. An erased bit already means “NFC”, and flash can’t write a 1 over a 0.

All of this goes through uicr_write_masked, which is carefully written:

let curr_val = address.read_volatile();
if curr_val & mask == value & mask {
    return WriteResult::Noop;          // already written
}
if curr_val & value & mask != value & mask {
    return WriteResult::Failed;        // needs a 0 → 1 transition, don't write
}
// ... NVMC WEN, write, needs_reset

So on a board where the bootloader has already set PSELRESET = 18, this does nothing. On a board with an erased UICR, it will write and reset.

Let’s be honest: none of these writes will brick a T114. Pin 18 is the RESET pin on the nRF52840, and APPROTECT with this value permits debugging rather than forbidding it. The problem is something else: the library makes writes to non-volatile configuration that are irreversible without a programmer, and we promised zero writes. The documentation is also slightly misleading: it says Debug::NotConfigured is useful if “you don’t want embassy-nrf to touch UICR”. Yet PSELRESET and NFCPINS get touched regardless of that setting.

What we did:

  • config.debug = Debug::NotConfigured — APPROTECT is left alone.
  • There’s a check before init(). It reads PSELRESET1/2 and NFCPINS and, if init() is about to write anything, doesn’t call it at all: the board just blinks its LED. Better a non-working Rust firmware that a double RST replaces in a minute than a silently modified UICR.
  • The reset-pin-as-gpio and nfc-pins-as-gpio features are not enabled, and the REG0 voltage is not set (otherwise there would be a fourth write — to REGOUT0).

The debug port is a separate story. On newer nRF52840 revisions (build code F and later), SWD is locked after reset until the firmware explicitly opens it. Since init() no longer does that, the firmware does it itself, with a single write to the volatile register APPROTECT.DISABLE — not to UICR and not to flash. After a reset it’s locked again. This only works if UICR already permits debugging — but it doesn’t change anything permanently either.

Second surprise: 340 bytes inside the SoftDevice

The first build went through, memory.x honestly started at 0x26000, the sections were where they belonged. And yet our own packaging script refused to write the UF2.

The script is small, plain standard-library Python, and checks one thing: every loadable ELF segment (PT_LOAD) must lie entirely within the 0x26000…0xED000 window. Along the way it also checks that the vector table sits at 0x26000, the stack pointer points into RAM, and the reset vector points to Thumb code. If anything is off, no output files are produced.

Here’s what happened: the rust-lld linker page-aligned the first segment and put the ELF headers themselves into it — segment 0x20000…0x20154. That’s inside the SoftDevice. There isn’t a single section there, so a section-based objcopy won’t show anything. But any ELF → UF2/HEX converter that walks segments would have written 340 bytes over S140.

The fix is a single linker flag:

rustflags = [
  "-C", "link-arg=-Tlink.x",
  "-C", "link-arg=--nmagic",
]

You can’t call this a discovery. Exactly this effect was described back in 2020 in a PR to the cortex-m-quickstart template: if the start of flash isn’t a multiple of 0x10000, an extra segment slides downward, and probe-rs flashes it over whatever lives there. The template has had --nmagic ever since. Our memory.x was written by hand, without the template — and the flag wasn’t there.

The moral isn’t about the flag. What saved us wasn’t erudition, but checking “what exactly are we about to write” segment by segment, before writing. If the flag ever gets lost, the script will refuse again.

MPSL shares the hardware

Nordic’s controller has its own scheduler, MPSL, and it takes part of the chip for itself: RADIO, RTC0, TIMER0 at the highest interrupt priority, and the CLOCK/POWER block. Several non-obvious things follow from that:

  • all application interrupts are lowered to priority 2, and the Embassy timer moved to RTC1;
  • the stock HardwareVbusDetect for USB can’t be used — its interrupt lives in the POWER block, which now belongs to someone else. Instead there’s SoftwareVbusDetect, fed by polling POWER.USBREGSTATUS;
  • USB needs the HFXO crystal oscillator, and it’s no longer the application that turns it on: you have to ask MPSL for it (request_hfclk()) and wait for it before declaring USB ready;
  • the critical section comes from nrf-mpsl, not the single-core one from cortex-m.

A couple of decisions off to the side of MPSL. The low-frequency clock is the internal RC with calibration, even though the board has a 32 kHz crystal: this was chosen for reliability. The USB VID:PID and product string (0x239A:0x4405, HT-n5262) are the same as in the C++ build — PlatformIO and scripts find the board just as before. The BLE address, on the other hand, is deliberately different: a static address from FICR with one bit inverted. Otherwise iOS would recognize the “old” device and serve up the cached GATT table of the C++ firmware — and that table is different.

Radio: the half-decibel that justified our own driver

The ecosystem has lora-phy for the SX1262. We looked at it and passed:

let rssi = ((-(pkt_status[0] as i32)) >> 1) as i16;
let snr = (((pkt_status[1] as i8) + 2) >> 2) as i16;

The chip reports packet RSSI in 0.5 dB steps and SNR in 0.25 dB steps. lora-phy rounds both to integers. Our contract carries values ×10, and the halves there are real: in the field report, SNR at the edge of coverage is 7.5 and 3.5 dB. A coverage tester that rounds its own measurements is an odd tester. On top of that, lora-phy 3.0.1 hard-depends on defmt 0.3.

So we’re writing a small driver tailored exactly to our protocol: one channel, explicit header, TX and continuous RX. Its initialization mirrors SX1262::begin from RadioLib 7.7.1, so that both firmwares put the same signal on the air: TCXO 1.8 V via DIO3, DIO2 as the antenna switch, private sync word, the PA clamp fix from the errata, a 140 mA current limit.

Code review caught one bug in the driver — there was no hardware at hand. In the async code, reception is wrapped in a timeout: with_timeout / select around receive(). If the timeout fires in the middle of an SPI transaction, the future is simply dropped, and the radio chip’s NSS pin stays low. The next SX1262 command receives garbage. Fixed with a guard object that raises NSS in Drop. A classic cancellation trap in async embedded: cancellation isn’t an exception, it’s a silent “don’t run any further”, and everything that must always happen has to live in a destructor.

A core that gets tested on a laptop

Everything that doesn’t touch hardware lives in a no_std library that builds and is tested on the host with plain cargo test. That covers the over-the-air frames, Control command parsing, the BLE structures of the contract, the over-the-air SF switching logic, and the battery discharge curve. The firmware dependencies are only pulled in for target_os = "none", so they don’t get in the way of the host build.

An example — converting the raw packet status into the contract format:

// SX126x RssiPkt: -raw/2 dBm. raw 179 -> -89.5 dBm.
assert_eq!(to_tenths(-179, 2), -895);
// SnrPkt: raw(i8)/4 dB. raw -7 -> -1.75 dB.
assert_eq!(to_tenths(-7, 4), -18);

In C++ the same structures are guarded by static_assert on their size, but there was no way to check the parsing without a board.

There are 31 tests right now. One paid for itself on its very first run: the battery ADC conversion raw * 3000 * 4916 (3.0 V reference, 4.916 divider) overflowed a u32. A 12-bit reading up to 4095 gives about 6·10¹⁰ — 14 times more than fits. Fixed by switching to u64. On the board this would have looked like “the battery is lying”, and we’d have spent a long time hunting for it.

The escape hatch

Besides the double RST, the device has two more ways into the bootloader, and both carry over unchanged.

Control cmd 8 over BLE. The phone app asks the board to go into OTA DFU. The firmware puts 0xA8 into the GPREGRET register and resets — the bootloader sees the marker and enables BLE firmware reception. Specifically 0xA8, not Adafruit’s standard path that saves the phone’s address: by that point iOS has already rotated its random address, and the bootloader becomes invisible. That’s a separate story; we worked it out in the contract back in the C++ days.

In the Rust version, the reset lives in a separate task. If the radio task hangs, the exit to the bootloader still works. The Rust firmware’s GATT has no standard BLEDfu service at all: cmd 8 is the only door, and it’s enough.

1200 baud over USB. Opening the CDC port at 1200 baud is an old Arduino ritual: the firmware writes 0x4E into GPREGRET and goes into serial DFU. Rust does the same thing, so adafruit-nrfutil and PlatformIO flash the board just as before.

Risk matrix

What could break the board How it’s covered
Upgrading S140 to v7 for nrf-softdevice We don’t touch the SoftDevice: TrouBLE + nrf-sdc
The application spills into MBR/SD/bootloader memory.x: only 0x26000…0xED000
The linker put ELF headers into the SD area --nmagic + segment check before packaging
Overwriting InternalFS with the DevNonce The area ends at 0xED000, no NVMC writes
init() writes UICR Debug::NotConfigured + check before init()
Flash writes at runtime (bonding, settings) No storage, TrouBLE security disabled
Features that write UICR (reset/NFC as GPIO, REGOUT0) Not enabled
Firmware crashes on startup Double RST → bootloader runs before the application
Bootloader left USB state behind, firmware hangs after DFU One reset at the start of main, never more than one in a row
Exit to DFU from the phone breaks Separate task; the USB path is independent
Interrupts don’t arrive with the SD disabled set-vtor
Flashed over SWD “out of habit” Rule: only through the bootloader

What the build showed

The image occupies 0x26000…0x60CF0 — 240,880 bytes, about 30 % of the application window. Static RAM usage is about 4.2 KB of .data and 18.5 KB of .bss. All in all — about 2,900 lines of Rust and the packaging script, 24 host tests (later 31), and zero clippy warnings in pedantic mode.

The UF2 and the DFU package for adafruit-nrfutil are built with the same parameters PlatformIO uses for the C++ firmware (--sd-req 0x00B6), and carry byte-for-byte the same image.

Now the comparison with C++, and this is where numbers make lying easy. If you compare only the application images, Rust loses: 240,880 bytes versus 219,140 for C++ (P2P build), i.e. roughly 10 % more. With opt-level = "z" instead of "s" — 233,072 bytes, a difference of about 6 %.

But C++ gets its BLE stack for free: it lives in S140 outside the image, and the MBR plus SoftDevice are another 152 KiB. If you count the flash that actually executes, the picture flips:

C++ Rust
Application image 219,140 B 240,880 B
Flash for running code ~366 KiB (image + MBR and S140) ~235 KiB (S140 is there but doesn’t run)
Static RAM ~41 KB (16,908 B + 24 KiB for the SoftDevice) 22,712 B

What the 240 KB Rust image is made of: about 176 KB of Rust code, into which fat LTO merged Embassy, TrouBLE, USB, the display, fonts, formatting and our own code, plus a 44,010-byte boot logo — the C++ version has it too. Another 34 KB is the Nordic controller (peripheral role only), 13 KB is MPSL, 4 KB is compiler_builtins.

Two caveats. The Rust version has no LoRaWAN mode, so the comparison is against the C++ P2P build. And if S140 is ever removed from flash, Rust frees up roughly another 150 KB, whereas C++ doesn’t work at all without the SoftDevice. But that would require replacing the bootloader — the very step the whole port was set up to avoid.

What the board showed: everything works until you flash it

The first run on a real T114 ended in a hang. Boot logo on the screen, then nothing: USB doesn’t appear, BLE services are dead. And yet an ordinary press of RST — and the same firmware starts normally.

So what broke wasn’t the code but the path by which we got into it. After serial DFU (and after UF2), the Adafruit bootloader hands control to the application without resetting the chip. And it leaves two traces of its USB session behind:

  • USB interrupts stay enabled in POWER.INTENSET (they were enabled by the nrfx power driver);
  • a latched crystal-startup event sits in CLOCK.EVENTS_HFCLKSTARTED (from TinyUSB).

This didn’t bother the C++ firmware. It does bother the Rust firmware: the CLOCK/POWER block is now owned by MPSL, and MPSL doesn’t expect either of these. The result: the executor freezes on the boot logo.

Later we confirmed this with a controlled experiment on the same T114: we disabled the protection from the next listing and flashed via serial DFU. With unmodified nrf-mpsl 0.4.0, USB still hadn’t appeared after 20 s, while a single RST started the same image normally. With an nrf-mpsl that cleans up these traces itself before initialization, the board started right after DFU, with USB logging and a BLE connection.

And an important detail: the bare ble_bas_peripheral example from TrouBLE, which has no USB, starts fine after the same DFU even without the fix. So the hang requires two conditions at once: USB in the application, and MPSL.

Which of the two traces was to blame was revealed not by a debugger but by a question in code review. The author of RMK — keyboard firmware on the same nrf-sdc and with the same Adafruit bootloader — asked in our PR: why does this never happen to him — is it because he updates via UF2? According to the bootloader sources, UF2 has nothing to do with it: after writing a UF2 it jumps into the application without a reset just the same. The difference was in RMK’s sources. There, two handlers are bound to the CLOCK_POWER interrupt: MPSL’s, and the VBUS handler from embassy, which processes and clears the POWER USB events. In our case VBUS was polled on a timer (SoftwareVbusDetect), and only the MPSL handler was bound to CLOCK_POWER — and those events are none of its business.

We tested it by changing a single variable: the same hanging image, without the fix and without the protection, with exactly one added line —

CLOCK_POWER => mpsl::ClockInterruptHandler, usb::vbus_detect::InterruptHandler;

The board came up on its own two seconds after DFU. So the culprit is the POWER USB interrupts that the bootloader left enabled and that nobody clears. The latched HFCLKSTARTED has nothing to do with it — the new handler doesn’t even touch it. After that, the fix in the PR became half as long, and we verified the short version on the board too.

The fix is the first line of main:

pub fn reset_if_bootloader_left_usb_state() {
    let power = pac::POWER;
    if power.gpregret2().read().gpregret() == CLEAN_RESET_MARK {
        power.gpregret2().write(|w| w.set_gpregret(0));
        return;
    }
    let dirty = power.intenset().read().0 != 0
        || pac::CLOCK.events_hfclkstarted().read() != 0;
    if dirty {
        power.gpregret2().write(|w| w.set_gpregret(CLEAN_RESET_MARK));
        cortex_m::peripheral::SCB::sys_reset();
    }
}

After any real reset, both registers are zero. If either one isn’t, we were started “on the run”, and the firmware resets the chip itself, once. The 0xC5 marker in GPREGRET2 (the bootloader only uses GPREGRET) limits this to one reset in a row. If the signature turns out to be wrong, the result is one extra reboot, not an endless loop. For firmware that promises not to brick the board, a reset loop would be the worst possible fix.

Verified on the T114: after serial DFU the board boots straight into the working firmware.

At first it looked like we weren’t the first. The nrf-sdc tracker has issue #103 (from 2026-04-28): nrf-mpsl crashes on a LilyGo T-Echo with a bootloader from the same family if the application isn’t linked at zero. The symptom is similar: the board drops off after UF2 and only comes back after a reconnect and a double RST. The author suspects interrupt forwarding through the MBR; we suspected the USB/CLOCK state left behind by the bootloader. We posted our diagnosis and workaround there, and hastily added “you have USB too”. Then we re-read it: his reproduction is the stock ble_bas_peripheral without USB, and that very example starts normally on our board at address 0x26000 after DFU. So he most likely has a different problem (his layout is different too: the application at 0x1000, no SoftDevice). We added a correction to the issue before anyone had a chance to rely on our conclusion. The fix for our case went out as a separate PR (below), referencing the issue as related rather than as the same bug.

The main takeaway of this section: what you need to check isn’t “the firmware works” but “the firmware works after every path by which you get into it” — after a reset, after UF2, after serial DFU, after OTA.

LoRaWAN in Rust and a region that doesn’t exist

Later a LoRaWAN build appeared as well — the lorawan feature, 273,504 bytes (about a third of the application window) versus 241,712 for the P2P build. The stack is lorawan-device from lora-rs. The crates.io version (0.12.2) doesn’t fit: it’s too old for embassy-time 0.5 and doesn’t support ADR. So lora-rs is vendored into the project from the main branch.

lora-rs has no RU864 region, just as RadioLib didn’t. We described it ourselves from RP002-1.0.4 § 2.14: join on 868.9 and 869.1 MHz, DR0–5, RX2 on 869.1 MHz DR0, max EIRP 16 dBm — and covered it with a test. Before us there was no issue or PR about RU864 in lora-rs; now there’s our PR (below).

The DevNonce here is random on every join — a deliberate compromise for the sake of the same “zero flash writes” rule. The 1.0.4 specification requires something else: DevNonce must be a persistent, incrementing counter, and lora-rs has issue #335 open about it. Whether the network server will accept such nonces has not yet been verified.

One more detail: in LoRaWAN mode, downlink RSSI/SNR come in whole decibels. The radio there is driven by lora-phy, and our own full-resolution driver only works in P2P.

What got accepted upstream, and what didn’t

Everything we found along the way, we took upstream. As of this writing — 2026-10-05 — here’s where things stand:

What Where Status
Diagnosis of the post-DFU hang and workaround comment in nrf-sdc#103 published, with a correction (the case in #103 is apparently different) and a one-line workaround
nrf-mpsl clears the POWER USB interrupts left by the bootloader before mpsl_init nrf-sdc#119 open; narrowed after review, the maintainer is choosing between a fix in the library and documenting the workaround
RU864 region for lorawan-device, standalone, parameters cross-checked against RegionRU864 from LoRaMac-node lora-rs#493 merged
API for a persistent DevNonce comment in lora-rs#335 withdrawn: the maintainer was already working on it (lora-rs#486)
Fractional RSSI/SNR in PacketStatus lora-rs#494 design agreed: Rssi(i16)/Snr(i16) wrappers in quarter-dB units; PR to follow
Config::configure_uicr_pins: true by default; with false, init() doesn’t write PSELRESET/NFCPINS, only warns embassy#7139 open
adafruit-bootloader feature for TrouBLE’s nrf52 examples trouble#694 rejected, closed by us

The DevNonce one was awkward, and that’s a lesson too. We read the comments in #335, saw only a question from a volunteer back in 2025, and posted our own design. But the timeline of that same issue showed that it was assigned to a maintainer and that a week earlier a draft PR, #486, had been linked to it — with storage, JoinNonce replay protection, and an honest error on counter exhaustion instead of our saturation. The PR author politely posted a link. We apologized, withdrew our proposal, and offered to run #486 on our board with RU864. The takeaway is simple: before writing in someone else’s issue, read not just the comments but the timeline too — who it’s assigned to and which PRs are already linked.

We nearly made a second mistake there as well: we were about to write that the first commit of #486 fixes “our” bug with a panic on an unsupported DR. We compared the lines — the bugs are different: #486 checks for an index out of the table’s bounds, while ours is a panic on unwrap() of an empty table row (DR6/DR7 in RU864), which #486 doesn’t touch. We removed the sentence before posting.

With fractional RSSI/SNR, on the other hand, things went exactly as intended: first an issue with a question, not a PR. The maintainer suggested wrapper types instead of two new fields, which is better than our version, and the PR will follow his design.

The most instructive one is the last row. The TrouBLE maintainer declined like this:

Thanks for the PR, but I don’t think this example belongs here. It should be in the nrf-softdevice repo if anything. We can’t maintain examples for different boards with different bootloader/flash layouts that doesn’t demonstrate something about trouble BLE host or controller setup specific to a platform. Also, having 2 bluetooth stacks in firmware doesn’t really make sense.

My first impulse was to push back on the “two stacks” point: the SoftDevice belongs to the bootloader, after all, and the application never starts it. Then it sank in that he was right. There really are two BLE stacks on the chip: S140 in flash, about 150 KB, which the application doesn’t use, and nrf-sdc with TrouBLE in the application itself. Only one runs, but both take up space. That is the real price of the “don’t brick it” approach, and there’s no reason to hide it.

We agreed and closed the PR ourselves. In the comment we explained why the SoftDevice stays (the bootloader needs it for BLE DFU, and nrf-softdevice requires S140 7.x), but didn’t argue. Takeaways:

  • board-specific flash layout recipes aren’t the job of a BLE host library. They belong in documentation next to nrf-sdc, in a template repository, or in an article like this one;
  • a maintainer’s “no” about project scope is something you close politely, not something you contest;
  • if you feel like “correcting” a maintainer, first check whether he might be right.

Internal identifiers have been omitted from this text: server addresses, DevEUI, EUI and gateway coordinates, serial numbers.