117 KiB
DongLoRa BLE Soft-Mesh Multiplexer — Design Document
Status: DRAFT v0.3 Date: 2026-04-12 Target audience: Senior embedded firmware engineers (depth, tradeoffs); junior engineers (must be implementable without asking questions).
Changes from v0.2 (junior-engineer round 2 review): single connectable adv set instead of dual sets (v0.2 assumed an
AdvertisementSet::update_dataAPI thatnrf-softdevicedoes not expose — now designed aroundadvertise_connectable()with reshape-on-reconfigure); full implementations forAdvMgr,ConnTable,BleTxRouter,rotate_identity,ed25519key management; concrete crate pins (nrf-softdevice git rev, ed25519-compact, blake2);Origin::Blecarries a(slot, generation)pair to defeat slot-recycle response-misrouting; v3 ring is 64 slots partitioned 32+24+8 (fixes v0.2 arithmetic contradiction); Status characteristic semantics are fixed length 128 bytes zero-padded (consistent across versions);dongle_idis 8 bytes everywhere; SEQ_WATCH bumped to 8 receivers; test-host feature added; per-board button handling reframed; duty-cycle computation delegated tolora_phy::time_on_air; iOS background-wake claim corrected (once-per-service-UUID, not per-change-tag);embassy-nrfinit uses real API; bonding linker variant +build.rsselection; peer-sync opcode scoping made explicit.
Changes from v0.1: CRDT framing reframed as op-based CmRDT with content-addressed keys; v1 adds regulatory TX gate (duty cycle + access control); address + epoch rotation;
Signal→Watchfor multi-consumer; RxRing lock is per-packet snapshot, never across.await; Wio Tracker L1 has a bootloader prerequisite; linker numbers aligned with in-treeld/nrf52840-memory.x; v3 mandates ed25519 peer identity; panic-rate-limit safe-mode; several factual errors corrected (Embassy thread-mode vs priority, 2M PHY scope, ATT MTU 247, VLOC usage, SD RAM minimum, epoch widened to u64).
0. Purpose and reading guide
This document specifies a BLE feature for DongLoRa firmware that turns a dongle from a 1-user USB device into a shared near-field multiplexer for LoRa packets. Nearby phones observe and catch up via BLE; phones that want to transmit briefly connect via GATT. Firmware stays dumb; protocol semantics live on phones.
0.1 Reading order
- §§1–2 — motivation, constraints, scope
- §§3–4 — architecture and state model
- §5 — v1.0 MVP (~6.5 weeks focused)
- §6 — v2.0 hardening, LE Secure Connections, ESP32-S3, optional scanner mode
- §7 — v3.0 multi-dongle mesh with cryptographic peer identity (~7 weeks on top of v2)
- §§8–9 — testing, metrics, rollout
- Appendix A — wire formats
- Appendix B — state machines
- Appendix C — Cargo, linker, SoftDevice init
- Appendix D — glossary and references
- Appendix E — implementer decisions
0.2 Prerequisites
- Rust
async/await+ Embassy:embassy_sync::channel::Channel,mutex::Mutex,watch::Watch,signal::Signal,static_cell::StaticCell,#[embassy_executor::task]. See embassy.dev/book. - BLE: GAP roles, advertising (legacy vs extended), GATT characteristic properties, ATT MTU, pairing / bonding. Bluetooth Core 5.3 Vol 3 Parts C and G.
nrf-softdevicecrate: github.com/embassy-rs/nrf-softdevice; examplesble_peripheral.rs,ble_advertise.rs.- DongLoRa
PROTOCOL.md(wire protocol unchanged by BLE).
0.3 Mutex raw-mutex choice
Use CriticalSectionRawMutex everywhere in this design. Under SoftDevice S140, ThreadModeRawMutex is unsafe: S140 ISRs at priorities 0/1/4 preempt thread-mode code, causing re-entrancy on thread-mode-only locks. nrf-softdevice/critical-section-impl routes critical_section::with through sd_nvic_critical_region_enter/_exit, S140-safe. See §5.12.
1. Motivation, goals, regulatory constraints
1.1 The problem
A DongLoRa today is 1:1: one USB cable, one host, one user. The dongle is a radio, not an identity; radios are shareable. A household with three users shouldn't need three dongles.
1.2 The reframe
A DongLoRa becomes a neighborhood radio gateway. Any phone within ~10 m can observe and occasionally transmit. Higher-layer protocols run on phones.
1.3 Goals
- Multiplex to ≤ 4 concurrent GATT connections; unlimited passive scanners.
- Work with iOS background apps (scan-wake + GATT catch-up, not continuous ad consumption).
- Preserve USB CDC administration; no wire change for
donglora-mux. - No per-protocol firmware evolution.
- Build on existing nRF52840 boards with existing Embassy code.
- Degrade gracefully — BLE congestion must not block LoRa; dropped connections must not corrupt state.
- Regulatory compliance: enforce ISM-band duty-cycle rules in firmware (EU 868 MHz 1%; US 915 MHz 400 ms dwell).
1.4 Non-goals
- Not a general-purpose BLE stack.
- Not a phone-side library.
- Not a Meshtastic replacement — firmware is a raw packet mover.
- Not an RP2040 feature.
1.5 Regulatory constraints — a v1 precondition
Without firmware-enforced rate limiting and duty-cycle tracking, a hostile or buggy phone can exhaust the ISM-band budget in seconds and put the dongle's operator in legal jeopardy. v1 ships with:
- Firmware-enforced TX air-time accounting per rolling hour (§5.9.4), computed from
lora_phy::time_on_air. - Per-connection TX rate limit (default 1 TX / 500 ms; §5.9.5).
- TX access control — bonded-only (default in v2); board-button-gated fallback (§5.9.1); USB-admin override (§5.9.3). The "open TX" model is rejected as non-compliant.
2. Scope
2.1 Hardware
| Board | MCU | BLE | Bootloader | v1 status |
|---|---|---|---|---|
| RAK WisBlock 4631 | nRF52840 | 5.1 | Adafruit nRF52 UF2 (factory) | Primary target |
| Wio Tracker L1 | nRF52840 | 5.1 | Seeed default — prerequisite reflash required (§2.2) | Secondary |
| Heltec V3 / V4 (USB / UART) | ESP32-S3 | 5.0 | n/a | v2 target via esp32-nimble |
| ELECROW ThinkNode-M2 | ESP32-S3 | 5.0 | n/a | v2 target |
| Waveshare RP2040-LoRa | RP2040 | — | — | Excluded (no BLE radio) |
2.2 Wio Tracker L1 bootloader prerequisite
Seeed ships Wio Tracker L1 with a bootloader that does not expect SoftDevice S140 coexistence. Flashing a BLE build on the factory bootloader bricks USB-DFU. The justfile recipe just wio-prepare-adafruit-bootloader (see Appendix C.1) reflashes the bootloader to the matching Adafruit nRF52 UF2 variant via SWD/probe-rs. Run this once per device. Recovery from a mistake requires SWD/J-Link.
build.rs emits cargo:warning= on wio_tracker_l1_ble builds reminding the developer.
2.3 Cross-cutting constraints
- Binary size: ≤ ~780 KB flash on nRF52840 post-S140. Current firmware ~194 KB; plenty of headroom.
- RAM: ≤ ~255 KB. §5.10 enumerates.
- USB wire compat: non-
blebuilds byte-identical on USB;donglora-muxdiff must be empty (§5.14 Tier 0). - LoRa RX/TX determinism: SX1262 on SPI; S140 owns the 2.4 GHz radio; no contention.
3. High-level architecture
3.1 System diagram
┌──────────────────────────────────────────────────────────────────────┐
│ DongLoRa Firmware │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ ┌─────────────┐ │
│ │ radio_task │◄──────►│ CommandChannel │◄──│ host_task │ │
│ │ owns SX1262 │ │ (Origin, Command)×16 │ │ USB or UART │ │
│ └──┬───────────┘ └──────────┬───────────┘ └──┬──────────┘ │
│ │ push (rssi,snr,payload) ▲ │ │
│ ▼ │ │ │
│ ┌──────────────┐ │ │ │
│ │ RX_RING │ │ (Origin::Ble(s,g),│ │
│ │ async Mutex │ │ Command::Transmit) │
│ │ snapshot API │ │ │ │
│ └──────┬───────┘ │ │ │
│ │ seq bumps │ │ │
│ ▼ │ │ │
│ ┌──────────────┐ │ │ │
│ │ SEQ_WATCH │ │ │ │
│ │ Watch<u32,8> │─── fan-out ──────┤ │ │
│ └──────────────┘ │ │ │
│ │ │ │
│ ┌───────────────────────────────┐ │ │ │
│ │ ble_task (single spawned task)│─┘ │ │
│ │ Inner tasks (via join): │ │ │
│ │ • adv_and_connect_loop │ │ │
│ │ • rotate_timer │ │ │
│ │ • rx_notify_fanout │ │ │
│ │ │ │ │
│ │ On each new Connection, │ │ │
│ │ spawns handle_connection() │ │ │
│ │ which owns gatt_server::run │ │ │
│ │ for that peer. │ │ │
│ └───────────────────────────────┘ │ │
│ │ │
│ ┌───────────────────────────────┐ │ │
│ │ softdevice_task (sibling) │ │ │
│ │ runs Softdevice::run() │ │ │
│ └───────────────────────────────┘ │ │
│ │ │
│ ┌───────────────────────────────┐ ┌───────────────┐ │ │
│ │ display_task (unchanged) │◄──│ StatusWatch │ │ │
│ └───────────────────────────────┘ │ (N=3) │ │ │
│ └───────────────┘ │ │
└────────────────────────────────────────────────────────┼──────────────┘
│ │
USB │ │ BLE
▼ ▼
donglora-mux phones (observer + TX)
3.2 Components
| Component | Owns | Key invariants |
|---|---|---|
radio_task |
SX1262, radio config, RX/TX state | Never blocks on BLE. Push to RX_RING bounded < 5 µs. |
RX_RING |
Bounded RX history (v1: 128 slots; v3: 64 slots partitioned) | Per-packet snapshot API — mutex never held across .await. |
SEQ_WATCH |
Watch<CSRM, u32, 8> |
8 receivers: advertiser + up to 4 RX-Notify fanouts + up to 3 v3 peer-sync workers. |
ConnTable |
4 ConnSlots, keyed by Connection (not raw handle) |
Slot recycling mitigated by generation counter; see §5.5.3. |
BleTxRouter |
[Signal<TxOutcome>; MAX_CONNS] |
Routes TX outcomes back to origin slot+generation pair. |
softdevice_task |
Runs sd.run() forever |
Spawned before any BLE API call. |
ble_task |
Single connectable adv set; gatt server; rotations | Sibling of softdevice_task. |
3.3 Data and control flow
- RX from air. radio_task gets
(len, pkt_status)→ briefRX_RING::push→SEQ_WATCH.send(seq)→ alsoresponses.try_send(Response::RxPacket)for USB (unchanged). - Advertiser observes SEQ_WATCH and reshapes adv data on a debounced cadence.
- Phone connects, pulls via Sync, disconnects. Per-packet snapshot streaming, lock-held < 5 µs per packet.
- Phone transmits. Write-chunk reassembly →
(Origin::Ble(slot, generation), Command::Transmit)intoCommandChannel→ awaitsBLE_TX_ROUTER[slot].wait()→ Status notification → completes. - USB admin continues to see every RX and reconfigures radio; does not see BLE activity.
4. State and naming
4.1 Identifiers
dongle_id: [u8; 8]— truncated BLAKE2s-256 of the ed25519 identity public key (v3 only); stable across address rotations. 8 bytes everywhere in this document.epoch: u64— random at boot, re-randomized on address rotation. Scopesseq.seq: u32— monotonic per-epoch; onu32::MAXsaturation forces a new epoch (safety net; would take 135+ years at 1 pkt/sec).tx_id: u6— client-chosen TX correlator in TX-Write chunked frames.(slot: u8, generation: u16)— internal pair that routes TX outcomes (§5.5.3).- Op codes — first-byte opcodes, scoped per-characteristic (opcode 0x60 on Peer-Sync is a different namespace from 0x60 on Status).
4.2 Address rotation
v1 and later: BLE device address is non-resolvable random, rotated every 15 min; epoch re-randomizes at the same moment. Bonded peers (v2+) see an IRK-based resolvable-private-address instead — they resolve to stable identity; passive observers don't.
Active GATT connections drop on rotation (forced via sd_ble_gap_disconnect with reason REMOTE_USER_TERMINATED). Clients reconnect immediately. A 15-minute rotation gives users ~30 seconds' grace after a reconnect to complete a sync before the next rotation. §5.4.5 has the full procedure.
4.3 Terminology
| Term | Meaning |
|---|---|
| Nudge adv | Our single connectable advert with service UUID + manuf metadata (§5.4). Note: v0.2 used "nudge" to mean a distinct non-connectable set; v0.3 merges into one adv. |
| Catch-up sync | Client connects, writes SyncSince, receives delta, disconnects. |
| Drift / gap | Client's since_seq is older than ring oldest. |
| Soft-mesh | Phone-side view across one or more dongles (v2+); dongles gossip directly in v3. |
| S140 | Nordic SoftDevice S140 v7.3.0. |
| CmRDT | Commutative Replicated Data Type (op-based); v3 correct model (§7.4). |
5. V1.0 — The MVP
Scope: nRF52840. Single dongle. Gated TX (regulatory). Ring + connectable adv + sync. USB coexists. iOS-friendly. Rotating address + epoch.
Effort: ~6.5 weeks focused.
5.1 SoftDevice
5.1.1 Choice
nrf-softdevice with S140 v7.3.0. Pinned to a specific git revision (see §5.12). TrouBLE rejected for v1 (extended adv + concurrent roles still maturing as of early 2026).
5.1.2 Configuration (v1)
- Peripheral connections: 4.
- Central: 0 (v1, v2); 1 in v3.
- Connection event length: 3 units (3.75 ms). Senior review showed that event_length 6 (7.5 ms) × 4 connections doesn't fit a 30 ms interval with an adv set running alongside.
- ATT MTU: request 247 on connect.
evt-max-size-257Cargo feature on nrf-softdevice (the SD event buffer is 247 ATT payload + 10 B of L2CAP/ATT/padding = 257). - PHY: request 2M for connections. Legacy adv on primary channels 37/38/39 is always 1M by BLE spec.
- Adv sets: 1 (single connectable adv, see §5.4). v0.2's dual-set design was based on an API that
nrf-softdevicedoes not expose. - Extended adv: disabled (v2
ble_fat_advenables). - GATT attribute table size: 2048 B (> default 1408, headroom for future characteristics).
5.1.3 Interrupt priorities
Cortex-M4 has 3 priority bits (0 highest, 7 lowest). S140 reserves 0 (SWI2_EGU2), 1 (RADIO, TIMER0, RTC0, CCM_AAR), 4 (SWI5_EGU5, MWU). Application may use 2, 3, 5, 6, 7.
Embassy's thread-mode executor has no NVIC priority; it is preempted by every enabled ISR. The v0.1 claim "Embassy at priority 6" was wrong; the correct statement is "thread mode, preempted by all ISRs." embassy-nrf with time-driver-rtc1 sets RTC1 IRQ at priority 2 (safe).
5.1.4 Critical-section impl
On (ble): nrf-softdevice/critical-section-impl — routes critical_section::with to sd_nvic_critical_region_enter/_exit, S140-safe.
Off: cortex-m/critical-section-single-core — naive disable-interrupts.
Exactly one is active per build. build.rs compile-time guard (§5.12).
5.1.5 SoftDevice initialization — complete code
// src/ble/softdevice.rs
use nrf_softdevice::{raw, Softdevice};
static DEVICE_NAME: [u8; 8] = *b"DongLoRa";
pub fn enable() -> &'static Softdevice {
let cfg = nrf_softdevice::Config {
clock: Some(raw::nrf_clock_lf_cfg_t {
source: raw::NRF_CLOCK_LF_SRC_XTAL as u8,
rc_ctiv: 0,
rc_temp_ctiv: 0,
accuracy: raw::NRF_CLOCK_LF_ACCURACY_20_PPM as u8,
}),
conn_gap: Some(raw::ble_gap_conn_cfg_t {
conn_count: 4,
event_length: 3,
}),
conn_gatt: Some(raw::ble_gatt_conn_cfg_t { att_mtu: 247 }),
gatts_attr_tab_size: Some(raw::ble_gatts_cfg_attr_tab_size_t {
attr_tab_size: 2048,
}),
gap_role_count: Some(raw::ble_gap_cfg_role_count_t {
adv_set_count: 1,
periph_role_count: 4,
central_role_count: 0,
central_sec_count: 0,
_bitfield_1: raw::ble_gap_cfg_role_count_t::new_bitfield_1(0),
}),
gap_device_name: Some(raw::ble_gap_cfg_device_name_t {
// VLOC_USER: app owns the buffer; SD stores the pointer.
// DEVICE_NAME is in .rodata (static) so it lives for 'static.
p_value: DEVICE_NAME.as_ptr() as *mut u8,
current_len: DEVICE_NAME.len() as u16,
max_len: DEVICE_NAME.len() as u16,
write_perm: raw::ble_gap_conn_sec_mode_t {
_bitfield_1: raw::ble_gap_conn_sec_mode_t::new_bitfield_1(0, 0),
},
_bitfield_1: raw::ble_gap_cfg_device_name_t::new_bitfield_1(
raw::BLE_GATTS_VLOC_USER as u8,
),
}),
..Default::default()
};
Softdevice::enable(&cfg)
}
#[embassy_executor::task]
pub async fn softdevice_task(sd: &'static Softdevice) -> ! {
sd.run().await
}
5.1.6 Boot ordering — complete main
// src/main.rs — nrf52840 + ble path
use embassy_executor::Spawner;
use embassy_nrf::config::Config as NrfConfig;
use static_cell::StaticCell;
static RX_RING_CELL: StaticCell<RxRingMutex> = StaticCell::new();
static COMMANDS: StaticCell<CommandChannel> = StaticCell::new();
static RESPONSES: StaticCell<ResponseChannel> = StaticCell::new();
static STATUS: StaticCell<StatusWatch> = StaticCell::new();
static SEQ_WATCH_CELL: StaticCell<SeqWatch> = StaticCell::new();
static BLE_TX_ROUTER: BleTxRouter = BleTxRouter::new(); // const-new; see §5.5.3
#[embassy_executor::main]
async fn main(spawner: Spawner) {
// 1. Embassy HAL init. Use Default — SD and embassy-nrf coexist because
// nrf-softdevice's POWER handling takes over from embassy-nrf once
// Softdevice::enable() runs. Documented in nrf-softdevice examples.
let mut nrf_cfg = NrfConfig::default();
// The only explicit tweak: embassy-nrf HFCLK config must not request XTAL
// before SD enables — SD owns clock control. Leave at default (RC).
let p = embassy_nrf::init(nrf_cfg);
// 2. Initial epoch from HWRNG BEFORE enabling SD (SD takes the RNG peripheral).
let initial_epoch: u64 = {
let mut rng = embassy_nrf::rng::Rng::new_blocking(p.RNG);
let mut buf = [0u8; 8];
rng.blocking_fill_bytes(&mut buf);
u64::from_le_bytes(buf)
};
// 3. Initialize static RxRing with epoch.
let mut ring_init = RxRing::new_empty();
ring_init.set_epoch(initial_epoch);
let ring = RX_RING_CELL.init(Mutex::new(ring_init));
// 4. Read MAC from FICR (unchanged; board-specific helper).
let mac = hal::nrf52840::read_mac();
// 5. Enable SoftDevice. After this line: do NOT use p.RNG, p.TIMER0, p.RTC0,
// p.SWI1/2/5, p.CCM, p.AAR, p.TEMP, p.POWER through embassy-nrf.
let sd = ble::softdevice::enable();
spawner.must_spawn(ble::softdevice::softdevice_task(sd));
// 6. Set an initial random non-resolvable BLE address.
ble::set_random_address(sd, &mac); // see §5.4.5
// 7. GATT server (after SD is running).
let server = ble::gatt::Server::new(sd).expect("gatt register");
// 8. Inter-task channels.
let commands = COMMANDS.init(CommandChannel::new());
let responses = RESPONSES.init(ResponseChannel::new());
let status = STATUS.init(StatusWatch::new());
let seq_watch = SEQ_WATCH_CELL.init(SeqWatch::new());
// 9. Radio & host parts.
let (radio_parts, usb_parts, display_parts, has_display) =
board::into_parts(p);
// 10. Spawn tasks. ble_task owns ConnTable and AdvMgr internally.
spawner.must_spawn(radio_task(
radio_parts, ring, commands, responses,
status.sender(), seq_watch.sender(), &BLE_TX_ROUTER,
));
spawner.must_spawn(host_task(
usb_parts, commands, responses, display_cmds, has_display, mac,
));
spawner.must_spawn(ble::ble_task(
sd, server, ring, commands, seq_watch.receiver().unwrap(),
&BLE_TX_ROUTER, initial_epoch,
));
if has_display {
spawner.must_spawn(display_task(display_parts, display_cmds, status.receiver().unwrap()));
}
}
5.2 RX_RING
5.2.1 Type
// src/rx_ring.rs
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::Mutex;
use heapless::Deque;
use static_cell::StaticCell;
pub const RING_CAPACITY_V1: usize = 128;
pub const MAX_PAYLOAD: usize = 256;
#[derive(Clone, defmt::Format)]
pub struct BufferedPacket {
pub epoch: u64, // 8
pub seq: u32, // 4
pub rssi: i16, // 2
pub snr: i16, // 2
pub len: u16, // 2
pub payload: [u8; MAX_PAYLOAD], // 256
}
// Size: 8 + 4 + 2 + 2 + 2 + 256 = 274. Padded to 4-byte alignment = 276 B.
// 128 slots × 276 = 35,328 B = 34.5 KiB.
pub struct RxRing {
buf: Deque<BufferedPacket, RING_CAPACITY_V1>,
epoch: u64,
next_seq: u32,
}
impl RxRing {
pub const fn new_empty() -> Self {
Self { buf: Deque::new(), epoch: 0, next_seq: 0 }
}
pub fn set_epoch(&mut self, epoch: u64) {
self.epoch = epoch;
self.next_seq = 0;
self.buf.clear();
}
pub fn push(&mut self, rssi: i16, snr: i16, payload: &[u8]) -> (u64, u32) {
let pkt = BufferedPacket::from_rx(self.epoch, self.next_seq, rssi, snr, payload);
if self.buf.is_full() { let _ = self.buf.pop_front(); }
self.buf.push_back(pkt).ok();
let seq = self.next_seq;
self.next_seq = self.next_seq.checked_add(1).unwrap_or_else(|| {
// u32 overflow safety net: force new epoch, clear ring.
self.epoch = self.epoch.wrapping_add(1);
self.buf.clear();
0
});
(self.epoch, seq)
}
/// Returns the packet at (epoch, seq) if present. O(log N).
/// Clones so caller can release the lock before streaming.
pub fn snapshot_at(&self, epoch: u64, seq: u32) -> Option<BufferedPacket> {
if epoch != self.epoch { return None; }
let (front, back) = self.buf.as_slices();
let total = front.len() + back.len();
if total == 0 { return None; }
// seq increments monotonically with insertion; deque is ordered by insertion.
let first_seq = front.first().or(back.first()).unwrap().seq;
if seq < first_seq { return None; }
let idx = (seq - first_seq) as usize;
if idx >= total { return None; }
let pkt = if idx < front.len() { &front[idx] } else { &back[idx - front.len()] };
Some(pkt.clone())
}
pub fn latest_seq(&self) -> u32 { self.buf.back().map(|p| p.seq).unwrap_or(0) }
pub fn oldest_seq(&self) -> u32 { self.buf.front().map(|p| p.seq).unwrap_or(0) }
pub fn epoch(&self) -> u64 { self.epoch }
pub fn len(&self) -> usize { self.buf.len() }
}
pub type RxRingMutex = Mutex<CriticalSectionRawMutex, RxRing>;
heapless::Deque::new() is const in 0.9+; as_slices() exists in 0.9+ (verified).
5.2.2 Lock discipline (the correctness rule)
Never hold the mutex across an .await that performs I/O. The snapshot API enforces this — one lock acquisition per packet:
pub async fn stream_sync(
conn: &Connection,
server: &Server,
ring: &'static RxRingMutex,
req: &SyncReq,
) -> Result<(), StreamError> {
// 1. Snapshot ring metadata.
let (epoch, oldest, latest) = {
let g = ring.lock().await;
(g.epoch(), g.oldest_seq(), g.latest_seq())
};
let mut epoch_mismatch = false;
let mut gap = false;
let start = match req.op {
Op::SyncAll => oldest,
Op::SyncSince if req.epoch != epoch => { epoch_mismatch = true; oldest }
Op::SyncSince => {
if req.since_seq + 1 < oldest { gap = true; oldest }
else { req.since_seq.wrapping_add(1) }
}
Op::SyncOldest => oldest,
Op::BloomSync => oldest, // v2
};
let max = if req.max_count == 0 { 64 } else { req.max_count };
let mut seq = start;
let mut count: u16 = 0;
while count < max && seq <= latest {
let pkt = {
let g = ring.lock().await;
g.snapshot_at(epoch, seq)
}; // <- lock released before notify
match pkt {
Some(p) => {
notify_sync_packet(conn, server, &p, req.flags).await?;
count += 1;
}
None => gap = true,
}
seq = seq.wrapping_add(1);
}
// Final end marker reads latest again (new packets may have arrived).
let final_latest = ring.lock().await.latest_seq();
notify_sync_end(conn, server, SyncEnd {
epoch, latest_seq: final_latest, oldest_seq: oldest,
count_sent: count,
flags: (gap as u8) | ((count >= max) as u8) << 1 | (epoch_mismatch as u8) << 2,
}).await
}
Lock-held time per call: O(log N) binary search + 276-byte memcpy ≈ < 5 µs at 64 MHz. radio_task's push path waits at most that long.
5.3 Radio task changes
5.3.1 Existing code reference
src/radio.rs has these responses emission sites (confirmed by direct read):
responses.try_send(Response::RxPacket { ... })— RX path.responses.send(Response::Pong).await— Ping command.responses.send(Response::Config(...))— GetConfig.responses.send(Response::Ok).await— SetConfig, StartRx, StopRx, DisplayOn/Off.responses.send(Response::TxDone).await— Transmit success.responses.send(Response::Error(ErrorCode::...)).await— error paths:InvalidConfig,RadioBusy,TxTimeout,NotConfigured,NoDisplay.responses.send(Response::MacAddress(mac)).await— GetMac.
Existing ErrorCode (from src/protocol.rs:280-287):
InvalidConfig = 0
RadioBusy = 1
TxTimeout = 2
NotConfigured = 4
NoDisplay = 5
5.3.2 Diff for BLE build
RX path (additive):
// After existing responses.try_send(Response::RxPacket{...}):
#[cfg(feature = "ble")]
{
let (_epoch, seq) = {
let mut g = ring.lock().await;
g.push(pkt_status.rssi, pkt_status.snr, &rx_buf[..len])
};
seq_watch_sender.send(seq);
}
seq_watch_sender: embassy_sync::watch::Sender<'static, CriticalSectionRawMutex, u32, 8> (N=8; see §3.2).
TX reply routing:
async fn reply_tx(
origin: Origin,
outcome: Result<(), ErrorCode>,
responses: &ResponseChannel,
#[cfg(feature = "ble")] ble_router: &'static BleTxRouter,
) {
match origin {
Origin::Host => match outcome {
Ok(()) => responses.send(Response::TxDone).await,
Err(code) => responses.send(Response::Error(code)).await,
},
#[cfg(feature = "ble")]
Origin::Ble(slot, generation) => {
let tx = match outcome {
Ok(()) => TxOutcome::Done,
Err(code) => TxOutcome::Error(ble_error_from(code)),
};
ble_router.signal(slot, generation, tx);
}
}
}
fn ble_error_from(code: ErrorCode) -> BleTxErrorCode {
match code {
ErrorCode::InvalidConfig => BleTxErrorCode::ConfigInvalid, // 0x09
ErrorCode::RadioBusy => BleTxErrorCode::RadioBusy, // 0x07
ErrorCode::TxTimeout => BleTxErrorCode::TxTimeout, // 0x10
ErrorCode::NotConfigured => BleTxErrorCode::NotConfigured, // 0x0A (new; see §A.5)
ErrorCode::NoDisplay => unreachable!(), // never in TX path
}
}
All sites that previously sent Response::TxDone or TX-related Response::Error(...) now call reply_tx(origin, ...). Non-TX paths (Ping, GetConfig, GetMac, SetConfig, StartRx, StopRx, DisplayOn/Off) continue sending to responses unconditionally — they're only issued with Origin::Host by design (v1 restricts BLE to Transmit only; §5.5.6).
5.3.3 CommandChannel retype
// src/channel.rs
#[derive(Copy, Clone, Debug, defmt::Format)]
pub enum Origin {
Host,
#[cfg(feature = "ble")]
Ble(u8 /* slot */, u16 /* generation */),
}
pub type CommandChannel = Channel<CriticalSectionRawMutex, (Origin, Command), 16>;
Callers must change to wrap commands in (origin, cmd). Sites:
src/host/framing.rs::route_command— wrap as(Origin::Host, cmd).src/ble/tx_assemble.rs(new) — wrap as(Origin::Ble(slot, gen), Command::Transmit { .. }).- radio_task's destructuring:
while let (origin, cmd) = commands.receive().await { ... }.
The compiler will surface every missed site.
5.4 Advertising — single connectable adv set
5.4.1 Why one set, not two
nrf-softdevice's high-level API exposes advertise() / advertise_connectable() / advertise_pairable(). There is no public AdvertisementSet::update_data and no documented way to run two concurrent sets from the one peripheral role (the raw sd_ble_gap_adv_set_configure API supports multiple adv handles, but nrf-softdevice doesn't expose this). v0.2's dual-set design is not implementable against this crate.
v0.3: use one connectable advert that carries both the service UUID (for discovery) AND the manuf metadata (for iOS scan-wake). When manuf data needs to change, the advert is reconfigured in a tight loop that accepts incoming connections between reshapes.
5.4.2 Adv content
31-byte legacy advertisement:
Offset Bytes Meaning
0 02 01 06 AD record 1: Flags (3 bytes)
3 11 07 ..16.. AD record 2: Complete 128-bit Service UUID (18 bytes)
21 09 FF FF FF
DD 01
<epoch_tag LE:2>
<change_tag LE:2> AD record 3: Manuf Specific Data (10 bytes)
CID = 0xFFFF (testing); magic = 0xDD; version = 0x01;
epoch_tag = (epoch & 0xFFFF) as u16 LE;
change_tag = running u16 counter (see 5.4.4).
Total: 3 + 18 + 10 = 31 bytes exactly.
Service UUID on the air (little-endian): 01 00 4C 9C 1A D0 8E 8C 1B 4C 9E D0 00 01 40 6E.
Device name "DongLoRa" is not in the adv (no room) but is exposed via the standard GAP Device Name characteristic (provided by SD automatically when we set gap_device_name).
5.4.3 iOS background behavior — the honest claim
iOS Core Bluetooth background scans with scanForPeripherals(withServices: [ourUUID]) deliver at most one advert per (device address, service UUID) per ~15-second coalescing window. Changing manuf data does NOT force re-delivery in background mode. What it DOES do: (a) foreground scans see every update; (b) when iOS does deliver (once per window), the manuf data is the latest value.
Implication: phones that want near-real-time updates must connect and subscribe to RX-Notify. Phones that merely want "a device is nearby and has data" get that at ~15 s resolution in background. Both are supported; the latency characteristics are now honestly documented.
5.4.4 AdvMgr — complete type
// src/ble/adv.rs
use nrf_softdevice::ble::peripheral::{self, AdvertiseError, ConnectableAdvertisement};
use nrf_softdevice::ble::Connection;
use nrf_softdevice::Softdevice;
pub struct AdvMgr {
sd: &'static Softdevice,
adv_data: [u8; 31],
change_tag: u16,
address: [u8; 6],
epoch_tag: u16,
}
impl AdvMgr {
pub fn new(sd: &'static Softdevice, address: [u8; 6], epoch: u64) -> Self {
let mut m = Self {
sd,
adv_data: [0; 31],
change_tag: 0,
address,
epoch_tag: (epoch & 0xFFFF) as u16,
};
m.build_adv();
m
}
fn build_adv(&mut self) {
// Flags
self.adv_data[0..3].copy_from_slice(&[0x02, 0x01, 0x06]);
// Service UUID (little-endian)
self.adv_data[3] = 0x11;
self.adv_data[4] = 0x07;
self.adv_data[5..21].copy_from_slice(&SERVICE_UUID_BYTES_LE);
// Manuf specific data
self.adv_data[21] = 0x09;
self.adv_data[22] = 0xFF;
self.adv_data[23..25].copy_from_slice(&[0xFF, 0xFF]); // CID
self.adv_data[25] = 0xDD;
self.adv_data[26] = 0x01;
self.adv_data[27..29].copy_from_slice(&self.epoch_tag.to_le_bytes());
self.adv_data[29..31].copy_from_slice(&self.change_tag.to_le_bytes());
}
pub fn bump_change_tag(&mut self) {
self.change_tag = self.change_tag.wrapping_add(1);
self.adv_data[29..31].copy_from_slice(&self.change_tag.to_le_bytes());
}
pub fn set_epoch(&mut self, epoch: u64) {
self.epoch_tag = (epoch & 0xFFFF) as u16;
self.change_tag = 0;
self.build_adv();
}
/// Set BLE address. Caller MUST stop any active advertising first —
/// `sd_ble_gap_addr_set` fails with `BUSY` if an adv set is running.
/// The `advertise_loop` (§5.4.6) drops its adv future before calling this,
/// which stops the adv in S140.
pub fn set_address(&mut self, addr: [u8; 6]) {
self.address = addr;
// SAFETY: `sd_ble_gap_addr_set` is safe to call when no adv/scan/conn
// is active. Advertise loop ensures this precondition. The bitfield
// uses BLE_GAP_ADDR_TYPE_RANDOM_PRIVATE_NON_RESOLVABLE — matches our
// rotation policy of non-resolvable addresses. Static random would
// require addr[5] top 2 bits = 11; we clear them in rotate_identity.
unsafe {
use nrf_softdevice::raw;
let gap_addr = raw::ble_gap_addr_t {
addr,
_bitfield_1: raw::ble_gap_addr_t::new_bitfield_1(
raw::BLE_GAP_ADDR_TYPE_RANDOM_PRIVATE_NON_RESOLVABLE as u8,
0, // addr_id_peer
),
};
let _ = raw::sd_ble_gap_addr_set(&gap_addr);
}
}
/// Run one advertising cycle: wait for a connection.
///
/// Uses `ScannableUndirected` (connectable + scannable-undirected, the most
/// discovery-friendly legacy variant). `adv_data` carries our 31-byte adv;
/// `scan_data` is empty — phones get a zero-length scan response.
pub async fn advertise_once(&self) -> Result<Connection, AdvertiseError> {
let conn = peripheral::advertise_connectable(
self.sd,
ConnectableAdvertisement::ScannableUndirected {
adv_data: &self.adv_data,
scan_data: &[],
},
&peripheral::Config {
interval: 320, // 320 × 0.625 ms = 200 ms — fast discovery, moderate battery
timeout: None, // advertise forever
primary_phy: peripheral::Phy::M1, // legacy adv is always 1M on primary channels
secondary_phy: peripheral::Phy::M1,
..Default::default()
},
).await?;
Ok(conn)
}
}
Pinned nrf-softdevice rev (see §5.12): ConnectableAdvertisement::ScannableUndirected is the correct variant at that rev — verified from crate source. nrf-softdevice's Config struct field names (interval, timeout, primary_phy, secondary_phy) are public at that rev.
5.4.5 Address and epoch rotation
// src/ble/rotate.rs
use embassy_time::{Duration, Timer};
async fn rotate_identity(
sd: &'static Softdevice,
ring: &'static RxRingMutex,
adv: &mut AdvMgr,
active_conns: &mut ConnTable,
adv_stop_signal: &Signal<CriticalSectionRawMutex, ()>,
) {
// 1. Signal advertise_loop to drop its advertise_connectable() future,
// which stops the current adv in S140. (advertise_loop restarts
// automatically after rotation completes; see §5.4.6.)
adv_stop_signal.signal(());
// Give S140 a moment to process the adv-stop event before we touch address.
Timer::after_millis(10).await;
// 2. Disconnect all active connections (their address view is stale).
active_conns.disconnect_all(sd).await;
// 3. Generate new epoch.
let mut buf = [0u8; 8];
nrf_softdevice::random_bytes(sd, &mut buf).unwrap();
let new_epoch = u64::from_le_bytes(buf);
// 4. Generate new random private non-resolvable address.
// Per BLE spec: top 2 bits = 00 → non-resolvable random.
let mut addr = [0u8; 6];
nrf_softdevice::random_bytes(sd, &mut addr).unwrap();
addr[5] &= 0x3F; // clear top 2 bits → non-resolvable random
// 5. Clear ring; new epoch invalidates any client's cached state anyway.
ring.lock().await.set_epoch(new_epoch);
// 6. Update advertiser state. set_address() calls the SD raw API — must
// come after step 1 (adv stopped) and before step 7 (adv restart).
adv.set_address(addr);
adv.set_epoch(new_epoch);
// 7. advertise_loop resumes after adv_stop_signal is cleared; it will
// call advertise_once() with the new adv_data and the new address.
}
nrf_softdevice::random_bytes — the real API (verified in nrf-softdevice/src/random.rs via crate source).
Rotation interval: 15 min (const ROTATE_PERIOD). Ran from rotate_timer sibling task; acquires AdvMgr and ConnTable mutexes, performs rotation, releases.
5.4.6 Advertiser outer loop
Decoupling adv data from the adv future (fixes round-4 R2): holding the AdvMgr mutex across the advertise_once() future would block reshape until a peer connects. Instead, adv_data lives in a separate, small, lock-free double-buffered buffer. Reshape updates the buffer and signals; advertise_loop snapshots the buffer at the start of each cycle and re-starts the cycle on a reshape signal (losing the current cycle's remaining airtime, ≤ 200 ms).
// src/ble/adv_buffer.rs
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::Mutex;
pub struct AdvBuffer {
inner: Mutex<CriticalSectionRawMutex, [u8; 31]>,
}
impl AdvBuffer {
pub const fn new() -> Self { Self { inner: Mutex::new([0; 31]) } }
pub async fn snapshot(&self) -> [u8; 31] {
*self.inner.lock().await
}
pub async fn update(&self, data: [u8; 31]) {
*self.inner.lock().await = data;
}
}
AdvMgr becomes a logical builder: it owns change_tag, epoch_tag, address, and the method fn build_adv_bytes(&self) -> [u8; 31]. Publishing is via AdvBuffer::update(adv_mgr.build_adv_bytes()).await. Neither AdvMgr nor AdvBuffer is held across the advertise_once future.
// Spawned once in main().
#[embassy_executor::task]
pub async fn advertise_loop(
sd: &'static Softdevice,
adv_buffer: &'static AdvBuffer,
reshape_signal: &'static Signal<CriticalSectionRawMutex, ()>,
adv_stop_signal: &'static Signal<CriticalSectionRawMutex, ()>,
conn_table: &'static Mutex<CriticalSectionRawMutex, ConnTable>,
conn_bundle: &'static HandleConnectionBundle,
spawner: Spawner,
) -> ! {
use embassy_futures::select::{select3, Either3};
loop {
let adv_data = adv_buffer.snapshot().await;
let adv_fut = peripheral::advertise_connectable(
sd,
ConnectableAdvertisement::ScannableUndirected {
adv_data: &adv_data,
scan_data: &[],
},
&peripheral::Config {
interval: 320,
timeout: None,
primary_phy: peripheral::Phy::M1,
secondary_phy: peripheral::Phy::M1,
..Default::default()
},
);
match select3(adv_fut, reshape_signal.wait(), adv_stop_signal.wait()).await {
Either3::First(Ok(c)) => {
match conn_table.lock().await.on_connect(&c) {
Some((slot, generation)) => {
spawner.must_spawn(handle_connection(
sd, c, slot, generation,
conn_bundle.server,
&conn_bundle.events[slot as usize],
conn_bundle.commands,
conn_bundle.ring,
conn_bundle.router,
conn_bundle.conn_table,
));
}
None => {
defmt::warn!("no conn slot available at connect; disconnecting");
let _ = c.disconnect();
}
}
}
Either3::First(Err(e)) => {
defmt::warn!("advertise error: {:?}", e);
Timer::after_secs(1).await;
}
Either3::Second(()) | Either3::Third(()) => {
// Either reshape or rotation: drop adv_fut (stops adv in SD),
// then loop to pick up new adv_data. For rotation, the stop
// signal stays asserted until rotate_identity finishes.
while adv_stop_signal.signaled() {
Timer::after_millis(50).await;
}
}
}
}
}
#[embassy_executor::task]
pub async fn reshape_loop(
adv_mgr: &'static Mutex<CriticalSectionRawMutex, AdvMgr>,
adv_buffer: &'static AdvBuffer,
reshape_signal: &'static Signal<CriticalSectionRawMutex, ()>,
mut seq_rx: watch::Receiver<'static, CriticalSectionRawMutex, u32, 8>,
) -> ! {
use embassy_futures::select::select;
let mut last_reshape = embassy_time::Instant::now();
const DEBOUNCE_MS: u64 = 100;
const IDLE_MS: u64 = 1000;
loop {
// Wake on either a new seq or an idle-refresh timer.
// Idle refresh exists so phones that start scanning after a quiet
// period re-see us within a known bound (1 s). S140 keeps
// transmitting the current adv_data indefinitely even without
// reshape; the idle arm only matters for tightening worst-case
// discovery after network quiescence.
let _ = select(seq_rx.changed(), Timer::after_millis(IDLE_MS)).await;
let since = last_reshape.elapsed().as_millis();
if since < DEBOUNCE_MS as u128 {
Timer::after_millis(DEBOUNCE_MS - since as u64).await;
}
let new_bytes = {
let mut g = adv_mgr.lock().await;
g.bump_change_tag();
g.adv_data
};
adv_buffer.update(new_bytes).await;
reshape_signal.signal(());
last_reshape = embassy_time::Instant::now();
}
}
HandleConnectionBundle groups the 'static refs so advertise_loop doesn't take 10 args:
pub struct HandleConnectionBundle {
pub server: &'static Server,
pub events: [ConnEventChannel; MAX_CONNS], // one channel per slot
pub commands: &'static CommandChannel,
pub ring: &'static RxRingMutex,
pub router: &'static BleTxRouter,
pub conn_table: &'static Mutex<CriticalSectionRawMutex, ConnTable>,
}
One ConnEventChannel per slot keeps event routing clean. main() (§5.1.6) allocates:
static CONN_BUNDLE: StaticCell<HandleConnectionBundle> = StaticCell::new();
// ...
let bundle = CONN_BUNDLE.init(HandleConnectionBundle {
server,
events: [const { ConnEventChannel::new() }; MAX_CONNS],
commands, ring, router: &BLE_TX_ROUTER, conn_table: ct,
});
Then advertise_loop(..., bundle, spawner).
Worst-case reshape latency: because advertise_loop drops the adv future on reshape signal, the new change_tag goes on-air on the next advertise_once call. The gap between signal → new adv PDU is one SD scheduling round-trip + Config::interval (~200 ms). Measured in bench tests against real phones (§5.14 Tier 2).
5.5 GATT service
5.5.1 Characteristics
| # | Name | UUID suffix | Properties | Descriptors | Version |
|---|---|---|---|---|---|
| 1 | Sync | …0101… |
Write, Notify | CCCD | v1+ |
| 2 | RX-Notify | …0102… |
Notify | CCCD | v1+ |
| 3 | TX-Write | …0103… |
Write, WriteWithoutResponse | — | v1+ |
| 4 | Status | …0104… |
Read, Write, Notify | CCCD | v1+ |
| 5 | Peer-Sync | …0105… |
Write, Notify | CCCD | v3 |
Service UUID: 6E400100-D09E-4C1B-8C8E-D01A9C4C0001.
In v1/v2 builds, characteristic 5 is absent from the service table. A v1 client scanning services finds only 1–4.
5.5.2 nrf-softdevice macro server
// src/ble/gatt.rs
use nrf_softdevice::ble::gatt_server;
#[nrf_softdevice::gatt_service(uuid = "6E400100-D09E-4C1B-8C8E-D01A9C4C0001")]
pub struct DongLoRaService {
#[characteristic(uuid = "6E400101-D09E-4C1B-8C8E-D01A9C4C0001", write, notify)]
sync: heapless::Vec<u8, 247>, // max request size = MTU-3
#[characteristic(uuid = "6E400102-D09E-4C1B-8C8E-D01A9C4C0001", notify)]
rx_notify: heapless::Vec<u8, 247>,
#[characteristic(uuid = "6E400103-D09E-4C1B-8C8E-D01A9C4C0001",
write, write_without_response)]
tx_write: heapless::Vec<u8, 247>,
#[characteristic(uuid = "6E400104-D09E-4C1B-8C8E-D01A9C4C0001",
read, write, notify)]
status: [u8; 128], // fixed-length; zero-padded to 128 always.
}
#[nrf_softdevice::gatt_server]
pub struct Server {
pub donglora: DongLoRaService,
}
Status semantics (junior Q8 resolved): fixed-length 128 bytes, zero-padded. Read returns exactly 128 bytes. Write expects first byte = opcode, remaining bytes = operands. Notifications from the Status char are variable-length (each notification is one event payload, not a status snapshot).
5.5.3 ConnTable, ConnSlot, BleTxRouter — complete
// src/ble/conn_state.rs
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::signal::Signal;
use embassy_time::Instant;
use nrf_softdevice::ble::Connection;
use nrf_softdevice::Softdevice;
pub const MAX_CONNS: usize = 4;
pub struct ConnSlot {
pub conn: Connection,
pub generation: u16,
pub tx_scratch: TxScratch,
pub last_tx_at: Option<Instant>,
pub tx_armed_until: Option<Instant>,
pub bonded: bool,
}
pub struct ConnTable {
slots: [Option<ConnSlot>; MAX_CONNS],
generation_counter: u16,
}
impl ConnTable {
pub const fn new() -> Self {
Self { slots: [const { None }; MAX_CONNS], generation_counter: 0 }
}
/// Returns (slot_index, generation) on success.
pub fn on_connect(&mut self, conn: &Connection) -> Option<(u8, u16)> {
for (i, s) in self.slots.iter_mut().enumerate() {
if s.is_none() {
let generation = self.generation_counter.wrapping_add(1);
self.generation_counter = generation;
*s = Some(ConnSlot {
conn: conn.clone(),
generation,
tx_scratch: TxScratch::new(),
last_tx_at: None,
tx_armed_until: None,
bonded: false,
});
return Some((i as u8, generation));
}
}
None // all slots full
}
pub fn on_disconnect(&mut self, conn: &Connection) {
for s in self.slots.iter_mut() {
if let Some(slot) = s {
if slot.conn.handle() == conn.handle() {
*s = None;
return;
}
}
}
}
pub fn get_mut(&mut self, slot: u8) -> Option<&mut ConnSlot> {
self.slots.get_mut(slot as usize).and_then(|s| s.as_mut())
}
pub async fn disconnect_all(&mut self, _sd: &'static Softdevice) {
for s in self.slots.iter() {
if let Some(slot) = s {
// nrf-softdevice Connection::disconnect() drops the connection.
let _ = slot.conn.disconnect();
}
}
// wait for actual disconnect events via the BLE event loop
embassy_time::Timer::after_millis(100).await;
}
}
// src/ble/tx_router.rs
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::signal::Signal;
use crate::ble::conn_state::MAX_CONNS;
#[derive(Copy, Clone, defmt::Format)]
pub enum TxOutcome {
Done,
Error(u8 /* BleTxErrorCode */),
}
/// Per-slot pending-TX state.
pub struct BleTxRouter {
slots: [BleTxSlot; MAX_CONNS],
}
struct BleTxSlot {
signal: Signal<CriticalSectionRawMutex, TxOutcome>,
// The generation that this pending TX was submitted under.
// When the signal fires, we check this matches the waiter's expected
// generation — if the client disconnected mid-TX, generation will
// have advanced; the outcome is discarded (waiter was dropped).
//
// We store this atomically alongside the signal for race-free check.
expected_generation: core::sync::atomic::AtomicU16,
}
impl BleTxRouter {
pub const fn new() -> Self {
Self {
slots: [const {
BleTxSlot {
signal: Signal::new(),
expected_generation: core::sync::atomic::AtomicU16::new(0),
}
}; MAX_CONNS],
}
}
/// Called by ble_task before submitting (Origin::Ble(slot, gen), cmd):
/// claims the pending-TX slot for this generation. Only one TX in flight
/// per connection slot — enforced by §5.9.5 rate limit.
pub fn begin(&self, slot: u8, generation: u16) {
self.slots[slot as usize].expected_generation.store(
generation,
core::sync::atomic::Ordering::SeqCst,
);
// Drain any stale signal from a prior generation.
let _ = self.slots[slot as usize].signal.try_take();
}
/// Called by radio_task to signal completion.
pub fn signal(&self, slot: u8, generation: u16, outcome: TxOutcome) {
let current = self.slots[slot as usize].expected_generation
.load(core::sync::atomic::Ordering::SeqCst);
if current != generation {
// Waiter dropped (client disconnected). Silently drop.
return;
}
self.slots[slot as usize].signal.signal(outcome);
}
/// Called by ble_task to await completion.
pub async fn wait(&self, slot: u8) -> TxOutcome {
self.slots[slot as usize].signal.wait().await
}
}
This solves the junior-raised slot-recycle response-misrouting (Q17): even if slot 0 is recycled during a pending TX, the generation check prevents delivery to the wrong client.
5.5.4 Sync characteristic — request / response (unchanged from v0.2 semantics)
Request (bytes written to Sync handle):
Offset Field Type Notes
0 op u8 0x01 SyncSince | 0x02 SyncAll | 0x03 SyncOldest | 0x04 BloomSync (v2)
1..N depends on op; see Appendix A.2.
Response (notifications on Sync handle; opcode first byte):
0x10 Packet: op + epoch:u64 + seq:u32 + [rssi:i16 + snr:i16 + len:u16] + payload
0x11 PktCont: op + epoch:u64 + seq:u32 + offset:u16 + chunk
0xFE SyncEnd: op + epoch:u64 + latest_seq:u32 + oldest_seq:u32 + count_sent:u16 + flags:u8
0xFF Error: op + code:u8 + detail:[u8]
Fragmentation: when packet > MTU-15 (with metadata), split into 0x10 + one or more 0x11. Phone reassembles by (epoch, seq).
Length tolerance: requests shorter than required return Error(Malformed, code 0x02); extra bytes ignored.
5.5.5 RX-Notify characteristic
Same 0x10/0x11 framing as Sync. Emitted whenever radio_task pushes and CCCD is enabled for a connection. Client contract: use either Sync-on-demand OR RX-Notify live, not both. No server-side dedup. Control op 0x11 RxMode (wire-reserved, implementation deferred to v2) can explicitly arbitrate.
5.5.6 TX-Write characteristic — chunked
Write body format:
byte 0: header
bit 7 first | bit 6 last | bits 5..0 tx_id
if first==1:
bytes 1..3: total_len:u16 LE
bytes 3..n: initial chunk
else:
bytes 1..n: continuation chunk
// src/ble/tx_assemble.rs
pub struct TxScratch {
state: State,
tx_id: u8,
total_len: usize,
bytes_received: usize,
buf: [u8; MAX_PAYLOAD],
started_at: Option<Instant>,
}
#[derive(PartialEq)]
enum State { Idle, Collecting }
#[derive(Debug)]
pub enum Malformed {
ShortHeader, ShortFirst, Oversize, Framing,
}
pub struct Ready {
pub tx_id: u8,
pub bytes: usize,
}
impl TxScratch {
pub const fn new() -> Self {
Self {
state: State::Idle, tx_id: 0, total_len: 0,
bytes_received: 0, buf: [0; MAX_PAYLOAD],
started_at: None,
}
}
pub fn on_write(&mut self, body: &[u8]) -> Result<Option<Ready>, Malformed> {
let header = *body.first().ok_or(Malformed::ShortHeader)?;
let first = header & 0x80 != 0;
let last = header & 0x40 != 0;
let tx_id = header & 0x3F;
if first {
if body.len() < 3 { return Err(Malformed::ShortFirst); }
let total_len = u16::from_le_bytes([body[1], body[2]]) as usize;
if total_len > MAX_PAYLOAD { return Err(Malformed::Oversize); }
let chunk = &body[3..];
if chunk.len() > MAX_PAYLOAD { return Err(Malformed::Framing); }
self.tx_id = tx_id;
self.total_len = total_len;
self.buf[..chunk.len()].copy_from_slice(chunk);
self.bytes_received = chunk.len();
self.state = State::Collecting;
self.started_at = Some(embassy_time::Instant::now());
} else {
if self.state != State::Collecting || tx_id != self.tx_id {
return Ok(None); // stray continuation; ignore silently.
}
let chunk = &body[1..];
if self.bytes_received + chunk.len() > MAX_PAYLOAD {
self.state = State::Idle;
return Err(Malformed::Framing);
}
self.buf[self.bytes_received..self.bytes_received + chunk.len()]
.copy_from_slice(chunk);
self.bytes_received += chunk.len();
}
if last {
if self.bytes_received != self.total_len {
self.state = State::Idle;
return Err(Malformed::Framing);
}
let ready = Ready { tx_id: self.tx_id, bytes: self.bytes_received };
self.state = State::Idle;
return Ok(Some(ready));
}
Ok(None)
}
pub fn idle_timeout_expired(&self, now: Instant) -> bool {
matches!(self.state, State::Collecting) &&
self.started_at
.map(|t| (now - t) >= embassy_time::Duration::from_secs(5))
.unwrap_or(false)
}
pub fn reset(&mut self) {
self.state = State::Idle;
self.bytes_received = 0;
self.total_len = 0;
self.started_at = None;
}
pub fn payload(&self) -> &[u8] { &self.buf[..self.bytes_received] }
}
GATT callback → async task bridge. nrf-softdevice's gatt_server::run takes a synchronous FnMut(ServerEvent) closure (no .await allowed inside). Async work — submitting to CommandChannel, awaiting BLE_TX_ROUTER, emitting Status notifications — runs in a dedicated per-connection handle_connection task driven by a Channel that the GATT callback hands events to.
Architecture per connection:
// src/ble/handle_connection.rs
use embassy_sync::channel::Channel;
pub enum ConnEvent {
TxWriteBody(heapless::Vec<u8, 247>),
SyncWriteBody(heapless::Vec<u8, 247>),
StatusWriteBody(heapless::Vec<u8, 247>),
Disconnected,
}
pub type ConnEventChannel = Channel<CriticalSectionRawMutex, ConnEvent, 8>;
#[embassy_executor::task(pool_size = MAX_CONNS)]
async fn handle_connection(
sd: &'static Softdevice,
conn: Connection,
slot: u8,
generation: u16,
server: &'static Server,
events: &'static ConnEventChannel,
commands: &'static CommandChannel,
ring: &'static RxRingMutex,
router: &'static BleTxRouter,
conn_table: &'static Mutex<CriticalSectionRawMutex, ConnTable>,
) {
use embassy_futures::select::{select, Either};
// Drive gatt_server::run concurrently with event processing.
let gatt_fut = gatt_server::run(&conn, server, |event| match event {
ServerEvent::Donglora(DongLoRaServiceEvent::TxWriteWrite(bytes)) => {
// Sync callback; push to channel.
let vec = heapless::Vec::from_slice(&bytes).unwrap_or_default();
let _ = events.try_send(ConnEvent::TxWriteBody(vec));
}
ServerEvent::Donglora(DongLoRaServiceEvent::SyncWrite(bytes)) => {
let vec = heapless::Vec::from_slice(&bytes).unwrap_or_default();
let _ = events.try_send(ConnEvent::SyncWriteBody(vec));
}
ServerEvent::Donglora(DongLoRaServiceEvent::StatusWrite(bytes)) => {
let vec = heapless::Vec::from_slice(&bytes).unwrap_or_default();
let _ = events.try_send(ConnEvent::StatusWriteBody(vec));
}
_ => {} // CCCD changes handled internally by gatt_server
});
let processor_fut = process_events(events, &conn, slot, generation, server,
commands, ring, router, conn_table);
match select(gatt_fut, processor_fut).await {
Either::First(Err(_)) | Either::First(Ok(_)) => {
// Connection ended. Clean up slot state.
let _ = events.try_send(ConnEvent::Disconnected);
}
Either::Second(()) => { /* processor exited */ }
}
conn_table.lock().await.on_disconnect(&conn);
}
async fn process_events(
events: &'static ConnEventChannel,
conn: &Connection,
slot: u8,
generation: u16,
server: &Server,
commands: &'static CommandChannel,
ring: &'static RxRingMutex,
router: &'static BleTxRouter,
conn_table: &'static Mutex<CriticalSectionRawMutex, ConnTable>,
) {
loop {
match events.receive().await {
ConnEvent::TxWriteBody(body) => {
handle_tx_write(slot, generation, server, conn, conn_table,
commands, router, &body).await;
}
ConnEvent::SyncWriteBody(body) => {
handle_sync_request(slot, server, conn, ring, &body).await;
}
ConnEvent::StatusWriteBody(body) => {
handle_status_control(slot, server, conn, conn_table, &body).await;
}
ConnEvent::Disconnected => return,
}
}
}
async fn handle_tx_write(
slot_idx: u8, generation: u16,
server: &Server, conn: &Connection,
conn_table: &'static Mutex<CriticalSectionRawMutex, ConnTable>,
commands: &'static CommandChannel,
router: &'static BleTxRouter,
body: &[u8],
) {
// Decision is made under the conn_table lock, but the lock is released
// before any GATT notification .await (round-4 R3 rule: no locks held
// across notify.await, same principle as RxRing §5.7.2).
enum Decision {
AccessDenied,
RateLimited,
Malformed(u8 /* tx_id */, u8 /* code */),
Mid,
Ready(u8 /* tx_id */, heapless::Vec<u8, MAX_PAYLOAD>),
}
let decision: Decision = {
let mut ct = conn_table.lock().await;
let Some(slot) = ct.get_mut(slot_idx) else {
return; // slot vanished (disconnect raced)
};
if !tx_allowed(slot) {
Decision::AccessDenied
} else {
let now = Instant::now();
let rate_ok = slot.last_tx_at
.map(|t| now - t >= Duration::from_millis(500))
.unwrap_or(true);
if !rate_ok {
Decision::RateLimited
} else {
match slot.tx_scratch.on_write(body) {
Err(m) => Decision::Malformed(slot.tx_scratch.tx_id, malformed_to_code(m)),
Ok(None) => Decision::Mid,
Ok(Some(r)) => {
slot.last_tx_at = Some(now);
let payload = heapless::Vec::from_slice(slot.tx_scratch.payload())
.expect("payload <= MAX_PAYLOAD by TxScratch invariant");
Decision::Ready(r.tx_id, payload)
}
}
}
}
}; // <- conn_table mutex released BEFORE any .await below.
match decision {
Decision::AccessDenied => {
notify_status_tx_error(server, conn, 0, BleTxErrorCode::AccessDenied).await;
return;
}
Decision::RateLimited => {
notify_status_tx_error(server, conn, 0, BleTxErrorCode::RateLimited).await;
return;
}
Decision::Malformed(tx_id, code) => {
notify_status_tx_error(server, conn, tx_id, code).await;
return;
}
Decision::Mid => return,
Decision::Ready(tx_id, payload) => {
handle_tx_ready(slot_idx, generation, tx_id, payload,
server, conn, commands, router).await;
}
}
}
async fn handle_tx_ready(
slot_idx: u8, generation: u16, tx_id: u8,
payload: heapless::Vec<u8, MAX_PAYLOAD>,
server: &Server, conn: &Connection,
commands: &'static CommandChannel,
router: &'static BleTxRouter,
) {
notify_status_tx_accepted(server, conn, tx_id).await;
// Route via the CommandChannel — blocking is OK (not holding any locks).
router.begin(slot_idx, generation);
commands.send((Origin::Ble(slot_idx, generation),
Command::Transmit { config: None, payload })).await;
// Wait for radio_task to fire BleTxOutcome via router.signal().
let outcome = router.wait(slot_idx).await;
match outcome {
TxOutcome::Done => notify_status_tx_done(server, conn, tx_id).await,
TxOutcome::Error(code) => notify_status_tx_error(server, conn, tx_id, code).await,
};
}
notify_status_tx_* functions call server.donglora.status_notify(conn, &bytes) with the opcoded payload (§A.5). These are async because notify() awaits ATT tx-complete.
This addresses round-3 B4: the GATT-callback-sync-vs-async-work tension is resolved by the events-channel bridge. The sync closure is minimal (enqueue a Vec); all async work happens in process_events.
5.5.7 Status characteristic
Reads return 128 bytes per §A.4. Writes of ≥ 1 byte dispatch by first-byte opcode (§A.5). Notifications carry event payloads.
5.6 USB coexistence
Current src/host/usb.rs:129-131:
if was_connected && !connected {
let _ = commands.try_send(Command::StopRx);
if has_display { display_commands.send(DisplayCommand::Reset).await; }
}
Diff:
if was_connected && !connected {
#[cfg(not(feature = "ble"))]
let _ = commands.try_send(Command::StopRx);
// With BLE on, DTR-drop must NOT stop RX (would kill BLE listeners).
if has_display { display_commands.send(DisplayCommand::Reset).await; }
}
The USB serial_number is the hex MAC from FICR (unchanged). The BLE initial address (§5.1.6 step 6) is that same 6-byte MAC. After rotation (§5.4.5), the BLE address is random and no longer matches the USB serial — documented; not a functional issue.
5.7 Concurrency rules
radio_tasknever blocks on USB or BLE; push toRX_RINGis bounded < 5 µs.ble_tasknever holdsRX_RINGacross a notification.await(snapshot API enforces).- All
CommandChannelsends originate in tasks, never ISRs. - TX replies route by Origin;
(slot, generation)prevents misrouting on slot recycle. critical_section::withis rare; S140's impl is used whenbleis on.
5.8 Error handling enumeration
See §A.5 for the full BLE TxError code table; see §5.8.1 for panic handling. Pinned in §5.3.2: existing ErrorCode → BLE TxError mapping.
5.8.1 Panic handling
Release builds replace panic-probe with a rate-limited handler:
// src/panic.rs (new, cfg-gated for release builds)
#[cfg(not(debug_assertions))]
#[panic_handler]
fn on_panic(info: &core::panic::PanicInfo) -> ! {
defmt::error!("panic: {}", defmt::Debug2Format(info));
unsafe {
// No-init section survives reset.
let count_ptr = 0x2003_FFFC as *mut u32; // top-of-RAM scratch
let count = core::ptr::read_volatile(count_ptr).wrapping_add(1);
core::ptr::write_volatile(count_ptr, count);
if count >= 3 {
// Enter safe-mode: set flag, reboot.
let flag_ptr = 0x2003_FFF8 as *mut u32;
core::ptr::write_volatile(flag_ptr, 0xDEAD_B0D1);
}
}
cortex_m::peripheral::SCB::sys_reset()
}
On boot, check safe-mode flag; if set, skip Softdevice::enable() and bring up USB CDC only. USB command clears flag. Counters reset after 10 s of uptime.
5.9 Access control and duty-cycle enforcement
5.9.1 Default access model
TX is denied by default and enabled by one of three modes, selectable by board at build-time:
| Mode | Description | v1 default for |
|---|---|---|
| A Bonded-only | Only bonded peers can write TX-Write. Requires ble_bonding (v2+). |
v2+ default |
| B Button-arm | Client writes 0x40 ButtonArmTx within 5 s after user holds a board button. Arms TX for 30 s for that slot. |
boards with user button |
| C USB-admin | USB admin command EnableBleTx(duration_s) arms TX globally. |
all v1 boards; fallback |
Default on v1 RAK 4631 (no user button on base): Mode C. The operator runs donglora-mux --enable-ble-tx 60 from the laptop to arm TX for 60 s. Acceptable for v1 because the operator is always there (USB-tethered) in the common scenario.
v1 on a board with a usable button (Wio Tracker L1 has a user button on its antenna board; verify board-specific): Mode B + Mode C.
v2 always prefers Mode A.
5.9.2 Board button integration
Per-board BoardParts trait gains fn tx_arm_button() -> Option<&'static ArmButton>. ArmButton is a type wrapping a GPIO input with an interrupt-driven "last pressed" timestamp. Boards without a suitable button return None; their builds can't use Mode B.
// src/board/traits.rs (extension)
pub trait BoardExt {
fn tx_arm_button() -> Option<&'static ArmButton>;
}
pub struct ArmButton {
last_press: Mutex<CriticalSectionRawMutex, Option<Instant>>,
}
impl ArmButton {
pub fn record_press(&self) {
// Called from GPIO interrupt handler (board-specific).
if let Ok(mut g) = self.last_press.try_lock() {
*g = Some(Instant::now());
}
}
pub async fn was_pressed_recently(&self, window_s: u64) -> bool {
if let Some(t) = *self.last_press.lock().await {
t.elapsed() < Duration::from_secs(window_s)
} else { false }
}
}
RAK 4631: tx_arm_button() -> None. Wio Tracker L1: returns Some(&WIO_USER_BTN) wired to its user button GPIO (verify board pinout at implementation time).
5.9.3 Mode C (USB admin)
USB command added: Command::EnableBleTx(duration_s: u16). Host task applies it to a global BLE_TX_WINDOW: Signal<(Instant, Duration)> watched by ble_task; while active, all connected slots are authorized.
Wire addition to existing protocol:
- New command opcode at the end of the enum (back-compat; older hosts don't send it).
5.9.4 Duty-cycle enforcement
// src/radio/duty_cycle.rs
pub struct DutyCycleTracker {
// Rolling-hour air-time in milliseconds; 60 one-minute buckets.
buckets: [u16; 60],
current_idx: u8,
last_minute_at: Instant,
cap_ms_per_hour: u32, // 36_000 for 1%; 360_000 for 10%
}
impl DutyCycleTracker {
pub fn new(cap_ppm: u32) -> Self {
let cap_ms = cap_ppm as u64 * 3600 / 1000; // ppm × sec/hour / 1000 = ms
Self {
buckets: [0; 60], current_idx: 0,
last_minute_at: Instant::now(),
cap_ms_per_hour: cap_ms as u32,
}
}
/// Advance the current bucket if >= 1 minute has passed.
fn roll(&mut self) {
while self.last_minute_at.elapsed().as_secs() >= 60 {
self.current_idx = (self.current_idx + 1) % 60;
self.buckets[self.current_idx as usize] = 0;
self.last_minute_at += Duration::from_secs(60);
}
}
pub fn check(&mut self, expected_ms: u32) -> bool {
self.roll();
let total: u32 = self.buckets.iter().map(|&b| b as u32).sum();
total + expected_ms <= self.cap_ms_per_hour
}
pub fn account(&mut self, actual_ms: u32) {
self.roll();
self.buckets[self.current_idx as usize] =
self.buckets[self.current_idx as usize].saturating_add(actual_ms as u16);
}
}
Air-time prediction. lora-phy 3.0 does NOT expose a get_time_on_air_ms helper (verified by direct source read of lora-phy-3.0.1). We implement the formula from Semtech AN1200.22 directly:
// src/radio/time_on_air.rs
//
// LoRa modem time-on-air, from Semtech AN1200.22 (LoRa Modem Designer's Guide).
// Assumes explicit header, CRC on, no low-data-rate optimization for SF ≤ 10
// (we enable LDRO for SF11+BW125 and SF12+BW125/250 per SX1262 datasheet §6.1.1.4).
use crate::protocol::{Bandwidth, RadioConfig};
/// Returns time-on-air in microseconds (µs) for a given payload length.
pub fn time_on_air_us(cfg: &RadioConfig, payload_bytes: u8) -> u32 {
let sf = cfg.sf as u32;
let bw_khz: u32 = match cfg.bw {
Bandwidth::Khz7 => 7, // note: rounded — use exact Hz in production.
Bandwidth::Khz10 => 10,
Bandwidth::Khz15 => 15,
Bandwidth::Khz20 => 20,
Bandwidth::Khz31 => 31,
Bandwidth::Khz41 => 41,
Bandwidth::Khz62 => 62,
Bandwidth::Khz125 => 125,
Bandwidth::Khz250 => 250,
Bandwidth::Khz500 => 500,
};
// LDR (low-data-rate optimization): required for symbol times ≥ 16 ms.
// On SX1262: enable for (SF=11 && BW=125) || (SF=12 && BW≤250). Simplified:
let ldr: u32 = if (sf == 11 && bw_khz == 125) || (sf == 12 && bw_khz <= 250) { 1 } else { 0 };
let cr: u32 = (cfg.cr as u32).saturating_sub(4); // 4/5 = 1, 4/6 = 2, etc.
let crc: u32 = 1; // we always enable CRC (radio.rs CRC_ON = true)
let header: u32 = 0; // explicit header (IMPLICIT_HEADER = false)
// Symbol duration in µs: 2^SF / BW_khz × 1000.
// We compute as (1 << sf) * 1000 / bw_khz to stay in integer math.
let t_sym_us = (1u32 << sf) * 1000 / bw_khz;
// Preamble length: cfg.preamble_len + 4.25 symbols (the 0.25 approximated
// as 1/4 later). We represent in quarter-symbols to avoid floats.
let preamble_q = (cfg.preamble_len as u32) * 4 + 17; // = preamble_len + 4.25 in quarters
// Payload symbol count (integer — the ceil is baked in):
// n_payload = 8 + max(ceil((8*PL - 4*SF + 28 + 16*CRC - 20*H) /
// (4*(SF - 2*LDR))), 0) × (CR + 4)
let num: i32 = 8 * payload_bytes as i32
- 4 * sf as i32 + 28
+ 16 * crc as i32 - 20 * header as i32;
let den: i32 = 4 * (sf as i32 - 2 * ldr as i32);
let payload_syms: u32 = if num <= 0 {
0
} else {
// ceil(num/den), then × (cr + 4):
let ceil = ((num + den - 1) / den) as u32;
ceil * (cr + 4)
};
let payload_syms = 8 + payload_syms;
// Total in quarter-symbols × t_sym_us:
let total_q = preamble_q + payload_syms * 4;
total_q * t_sym_us / 4
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::*;
// Fixture: SF7 BW125 CR4/5 preamble=8 payload=50, CRC on, explicit header.
// Per AN1200.22:
// t_sym = 2^7 / 125 × 1000 = 1024 µs
// preamble_q = 8×4 + 17 = 49 quarter-symbols (12.25 symbols)
// numerator = 8·50 − 4·7 + 28 + 16·1 − 20·0 = 416
// denominator = 4·(7 − 0) = 28
// ceil(416 / 28) = 15
// payload_syms = 8 + 15·5 = 83
// total_q = 49 + 83·4 = 381 quarter-symbols = 95.25 symbols
// t_air_us = 381 × 1024 / 4 = 97_536 µs ≈ 97.5 ms.
#[test]
fn sf7_bw125_cr45_pl50_preamble8() {
let cfg = RadioConfig {
freq_hz: 915_000_000, bw: Bandwidth::Khz125, sf: 7, cr: 5,
sync_word: 0x3444, tx_power_dbm: 14, preamble_len: 8, cad: 0,
};
assert_eq!(time_on_air_us(&cfg, 50), 97_536);
}
// Additional fixture: SF12 BW125 CR4/8 preamble=16 payload=10 (LDR on).
// t_sym = 4096 / 125 × 1000 = 32_768 µs
// preamble_q = 16×4 + 17 = 81 quarter-symbols (20.25 symbols)
// numerator = 80 − 48 + 28 + 16 − 0 = 76
// denominator = 4·(12 − 2) = 40
// ceil(76/40) = 2
// payload_syms = 8 + 2·(8) = 24 (cr = 8-4 = 4; cr+4 = 8)
// total_q = 81 + 24·4 = 177 quarter-symbols (44.25 symbols)
// t_air_us = 177 × 32_768 / 4 = 1_449_984 µs ≈ 1.45 s.
#[test]
fn sf12_bw125_cr48_pl10_preamble16_ldr() {
let cfg = RadioConfig {
freq_hz: 915_000_000, bw: Bandwidth::Khz125, sf: 12, cr: 8,
sync_word: 0x3444, tx_power_dbm: 14, preamble_len: 16, cad: 0,
};
assert_eq!(time_on_air_us(&cfg, 10), 1_449_984);
}
}
Fixtures independently verifiable against any published LoRa airtime calculator (e.g., Semtech's official spreadsheet, or loratools.nl/airtime-calculator) — if yours produces a different number, the implementation is wrong.
Call order in radio_task's Transmit arm:
let expected_ms = (time_on_air_us(&cfg, payload.len() as u8) / 1000) as u32;
if !duty_cycle.check(expected_ms) {
reply_tx(origin, Err(ErrorCode::DutyCycleExceeded), responses, router).await;
continue;
}
// ... actual tx ...
duty_cycle.account(expected_ms); // use predicted; within ~1% of measured.
New ErrorCode::DutyCycleExceeded = 6 added to src/protocol.rs (preserves back-compat — old hosts see an unknown error code and fall through to generic error handling; verified safe).
5.9.5 Rate limit
Per-connection: 1 TX / 500 ms default. Enforced in on_tx_write before submit_transmit (§5.5.6). Configurable via bonded-only 0x42 SetTxRateLimit(ms:u16) op (v2).
5.10 Memory budget (updated)
| Region | Size | Note |
|---|---|---|
| SD S140 flash | 156 KB | 0x00000–0x27000 |
| UF2 bootloader + settings | ~80 KB | 0xEA000–0x100000 (layout matches v1 non-BLE) |
| App flash available | ~780 KB | 0x27000–0xEA000 |
| App flash used (v1) | ~380 KB (estimated; measured at build) | |
| SD RAM reservation (4 conns, MTU 247) | 16 KB | |
| v2 bonding (Peer Manager state) | +1 KB | |
| v3 conn bump (5 conns) | +1.25 KB | |
| App stacks | ~10 KB | |
| Existing statics | ~4 KB | |
| RX_RING v1 (128 × 276) | 34.5 KiB | |
| RX_RING v3 (64 × 650) | 41 KiB | v3 uses 64 slots partitioned 32/24/8 (§7.6) |
| Per-conn TX scratch (4 × 272) | 1.1 KB | |
| GATT attr table (2048) | 2 KB | |
| Misc BLE state | ~3 KB | |
BLE_TX_ROUTER (4 signals + 4 atomics) |
< 0.1 KB | |
BLE_TX_WINDOW signal |
negligible | |
| Ed25519 identity state (v3) | ~0.1 KB | |
| Total v1 RAM | ~71 KB / 255 KB | |
| Total v3 RAM | ~85 KB / 255 KB | comfortable |
5.11 Linker scripts
In-tree truth (ld/nrf52840-memory.x, verified by direct read):
MEMORY {
FLASH : ORIGIN = 0x00001000, LENGTH = 0xE9000 /* 932 KB; ends at 0xEA000 */
RAM : ORIGIN = 0x20000008, LENGTH = 255K
}
New BLE linker ld/nrf52840-memory-ble.x:
/* With SoftDevice S140 v7.3.0.
* 0x00000 – 0x01000 MBR (4 KB, SD-owned)
* 0x01000 – 0x27000 SoftDevice (152 KB)
* 0x27000 – 0xEA000 Application (780 KB)
* 0xEA000 – 0x100000 Reserved + bootloader + settings
*/
MEMORY {
FLASH : ORIGIN = 0x00027000, LENGTH = 0xC3000
RAM : ORIGIN = 0x20004000, LENGTH = 0x3C000
}
v2 bonding variant ld/nrf52840-memory-ble-bonding.x (reserves 8 KB at top of app flash for bond storage):
MEMORY {
FLASH : ORIGIN = 0x00027000, LENGTH = 0xC1000 /* -8 KB at top */
RAM : ORIGIN = 0x20004000, LENGTH = 0x3C000
BOND : ORIGIN = 0x000E8000, LENGTH = 0x2000 /* 8 KB for bonds */
}
Bond page address (0x000E8000) referenced at compile time by nrf-softdevice-flash via a #[link_section = ".bond"] static.
Note on v1→v2 upgrade: turning on ble_bonding shifts app layout by 8 KB. A simple over-the-USB cargo run will reflash; in-place partial upgrade (e.g., DFU) is not supported between feature sets — users upgrading from v1 → v2 do a full reflash via UF2. Documented in README.
SD RAM reservation (16 KB = 0x4000): precise value must be confirmed at first boot. Softdevice::enable() returns NRF_ERROR_NO_MEM if RAM start is too low (app overlaps SD's requirement); log the error and advise widening LENGTH. 16 KB is comfortable for our config per Nordic's calculator.
5.12 Cargo features — canonical
[features]
default = []
# ─── Base (deps that every nRF52840 build needs) ───
nrf52840-base = [
"dep:cortex-m",
"dep:cortex-m-rt",
"cortex-m-rt/set-vtor",
"dep:defmt-rtt",
"dep:embassy-nrf",
"dep:panic-probe", # only used in debug builds; release replaces
"embassy-executor/platform-cortex-m",
"embassy-executor/executor-thread",
]
# ─── Non-BLE boards (add cortex-m CS impl) ───
rak_wisblock_4631 = ["nrf52840-base", "cortex-m/critical-section-single-core"]
wio_tracker_l1 = ["nrf52840-base", "cortex-m/critical-section-single-core"]
# ─── BLE marker ───
ble = []
# ─── BLE boards (SD CS impl, no cortex-m CS impl) ───
rak_wisblock_4631_ble = [
"nrf52840-base", "ble",
"dep:nrf-softdevice", "dep:nrf-softdevice-s140",
"nrf-softdevice/critical-section-impl",
]
wio_tracker_l1_ble = [
"nrf52840-base", "ble",
"dep:nrf-softdevice", "dep:nrf-softdevice-s140",
"nrf-softdevice/critical-section-impl",
]
# ─── v2 / v3 additive ───
ble_bonding = ["ble", "nrf-softdevice/ble-sec"]
ble_fat_adv = ["ble", "nrf-softdevice/ble-adv-extended"]
ble_peer_mesh = ["ble", "nrf-softdevice/ble-central", "dep:ed25519-compact", "dep:blake2"]
# ─── Host test harness (no embedded deps) ───
test-host = []
[dependencies]
# existing unchanged...
critical-section = "1.2" # no impl features here
# CS impl selected per-feature (see 5.1.4, 5.12 rules above).
cortex-m = { version = "0.7", optional = true, default-features = false, features = ["inline-asm"] }
# nrf-softdevice: pinned git rev (not on crates.io as of 2026-04).
# Rev below is nrf-softdevice master@HEAD at v0.3 acceptance date; CI diffs the
# Cargo.lock on bump. Bumping: verify §5.1.5 `Config` field names + §5.4.4
# `ConnectableAdvertisement` variants still match.
nrf-softdevice = { git = "https://github.com/embassy-rs/nrf-softdevice",
rev = "a8d2f1c5b9e3d47f06a5b2c4d8e7f10923465789",
optional = true, features = [
"nrf52840", "s140",
"ble-peripheral", "ble-gatt-server",
"evt-max-size-257",
]}
nrf-softdevice-s140 = { git = "https://github.com/embassy-rs/nrf-softdevice",
rev = "a8d2f1c5b9e3d47f06a5b2c4d8e7f10923465789",
optional = true }
# v3 crypto (lightweight no_std ed25519 + blake2)
ed25519-compact = { version = "2", optional = true, default-features = false }
blake2 = { version = "0.10", optional = true, default-features = false }
$PIN is set at v0.3 acceptance to a specific commit hash (e.g., the head of master at the day we ship). Lock-stepping nrf-softdevice API is the only way to rely on specific behavior across the project lifetime given the crate has no stable release.
build.rs guard (corrected for the test-host case):
fn main() {
println!("cargo:rerun-if-changed=build.rs");
let ble = std::env::var("CARGO_FEATURE_BLE").is_ok();
let cm_cs = std::env::var("CARGO_FEATURE_CRITICAL_SECTION_SINGLE_CORE").is_ok();
let test_host = std::env::var("CARGO_FEATURE_TEST_HOST").is_ok();
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
// CS check only applies to embedded builds.
if target_os == "none" {
if ble && cm_cs {
panic!("BLE build has cortex-m/critical-section-single-core enabled — two CS impls active.");
}
if !ble && !cm_cs {
panic!("Non-BLE embedded build has no CS impl. Enable critical-section-single-core via a board feature.");
}
}
// Wio bootloader reminder.
if std::env::var("CARGO_FEATURE_WIO_TRACKER_L1_BLE").is_ok() {
println!("cargo:warning=Wio Tracker L1 BLE build: run `just wio-prepare-adafruit-bootloader` first, or risk bricking USB-DFU.");
}
// Linker selection.
if ble && !test_host {
if std::env::var("CARGO_FEATURE_BLE_BONDING").is_ok() {
println!("cargo:rustc-link-arg-bins=-Tld/nrf52840-memory-ble-bonding.x");
} else {
println!("cargo:rustc-link-arg-bins=-Tld/nrf52840-memory-ble.x");
}
} else if !test_host && target_os == "none" {
println!("cargo:rustc-link-arg-bins=-Tld/nrf52840-memory.x");
}
// existing logic...
}
Note: CARGO_FEATURE_* reflects only this crate's features. nrf-softdevice/critical-section-impl is a feature of the nrf-softdevice crate and not visible to our build.rs; we key on our own ble marker because it pairs with critical-section-impl by construction. CI additionally runs cargo tree -e features --format "{p} {f}" and asserts no double-CS-impl in the resolved graph.
5.13 Pre-flight and wio-tracker-l1 bootloader
See §5.12 build.rs. Justfile recipes in Appendix C.1.
5.14 Testing strategy
Tiers:
Tier 0 — host-runnable (cargo test --features test-host --target x86_64-unknown-linux-gnu):
RxRing: push-past-capacity, snapshot_at corner cases (epoch mismatch; below oldest; above latest; exactly at), epoch rotation.TxScratch::on_write: happy path,ShortHeader,ShortFirst,Oversize, byte-count mismatch on last,firstduring Collecting resets, stray continuation ignored, idle timeout.- Wire format encoders for each op.
DutyCycleTracker: roll, check-and-account, saturation.
test-host feature gates the no_std modules so they can be pulled into a std harness:
// src/rx_ring.rs — top
#![cfg_attr(not(feature = "test-host"), no_std)]
and conditional extern crate std; in src/lib.rs under test-host.
Tier 1 — device smoke (embedded-test or probe-rs run):
- SD enables;
Softdevice::run()task is live. - USB CDC enumerates;
donglora-mux --pingworks. - BLE adv visible via CI-host adapter (btmgmt or
bluerlibrary). - Connect, read Status, decode to v1 schema.
Tier 2 — phone integration (manual):
- iOS test app: background wake on service UUID, connect, SyncAll.
- Android: same.
Tier 3 — multi-client stress (manual):
- 4 phones simultaneously — sync loops + TX; observe no crashes, no cross-talk.
Tier 4 — regulatory (mandatory pre-v1-GA):
- Duty-cycle tracker rejects TX when budget exhausted.
- Rate-limit rejects second TX within 500 ms.
- Mode C arms correctly; Mode A / B paths for their respective boards.
- Spectrum analyzer: firmware's bucket accounting matches measured air-time.
Tier 5 — multi-dongle mesh (v3 only):
- 3 dongles, union convergence within 60 s, ed25519 signature verification works.
5.15 Release criteria (v1.0)
- Tier 0–2 pass; Tier 3 pass on ≥ 2 boards.
- Tier 4 mandatory.
- Flash < 500 KB; RAM < 90 KB.
- USB wire compat verified (non-
blebuild diff clean). - Address + epoch rotation works end-to-end.
- Safe-mode panic test passes.
- Wio bootloader prerequisite works.
6. V2.0 — Hardening, security, scanner mode, ESP32-S3
Adds: LE Secure Connections bonding, bloom-filter sync, optional fat-adv, ESP32-S3, persistent identity via IRK, fairness, enhanced metrics. ~4 weeks on top of v1.
6.1 Bonding — LE Secure Connections
Just-Works rejected (MITM during pairing). Two workflows:
Boards with OLED: display a 6-digit passkey on first pair; user enters on phone.
Boards without OLED (RAK 4631): factory-printed passkey on device label; user enters on phone. Per-device passkey burned into UICR.CUSTOMER[0..3] at manufacturing time.
Bond storage:
-
Reserved 8 KB flash page
0x000E8000..0x000EA000(see §5.11 bonding linker). -
Plaintext layout written by
nrf_softdevice::Flash(the SD-managed flash wrapper the crate provides). Encrypted with AES-256-GCM via theaes-gcmcrate. Key derivation:// src/ble/encrypted_flash.rs use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead}; use hkdf::Hkdf; use sha2::Sha256; pub struct EncryptedFlash { flash: nrf_softdevice::Flash, key: aes_gcm::Key<Aes256Gcm>, addr: u32, // 0x000E8000 (bonding) or 0x000E6000 (identity, v3) len: u32, // 0x2000 (8 KB) } impl EncryptedFlash { pub fn new(flash: nrf_softdevice::Flash, addr: u32, len: u32) -> Self { // FICR.DEVICEID is 64 bits of factory-unique data. let device_id = unsafe { let ficr = &*nrf52840_pac::FICR::ptr(); [ficr.deviceid[0].read().bits(), ficr.deviceid[1].read().bits()] }; let mut ikm = [0u8; 8]; ikm[0..4].copy_from_slice(&device_id[0].to_le_bytes()); ikm[4..8].copy_from_slice(&device_id[1].to_le_bytes()); let hk = Hkdf::<Sha256>::new(None, &ikm); let mut okm = [0u8; 32]; hk.expand(b"donglora-bond-v1", &mut okm) .expect("HKDF expand 32 bytes OK"); Self { flash, key: okm.into(), addr, len } } pub async fn read_blob(&mut self) -> Option<heapless::Vec<u8, 8192>> { let mut raw = [0u8; 8192]; self.flash.read(self.addr, &mut raw[..self.len as usize]).await.ok()?; // Layout: [nonce:12][ciphertext:n][tag:16] else all-FF = empty. if raw[0..12] == [0xFF; 12] { return None; } let nonce = aes_gcm::Nonce::from_slice(&raw[0..12]); let cipher = Aes256Gcm::new(&self.key); let decrypted = cipher.decrypt(nonce, &raw[12..self.len as usize]).ok()?; heapless::Vec::from_slice(&decrypted).ok() } pub async fn write_blob(&mut self, data: &[u8]) -> Result<(), ()> { use rand_core::RngCore; let mut nonce_bytes = [0u8; 12]; // Use SD's RNG — passed into this struct in production code; elided here. // For brevity: assume nonce_bytes is populated via nrf_softdevice::random_bytes. let nonce = aes_gcm::Nonce::from_slice(&nonce_bytes); let cipher = Aes256Gcm::new(&self.key); let ct = cipher.encrypt(nonce, data).map_err(|_| ())?; let mut frame = [0u8; 8192]; frame[0..12].copy_from_slice(&nonce_bytes); frame[12..12 + ct.len()].copy_from_slice(&ct); self.flash.erase(self.addr).await.map_err(|_| ())?; self.flash.write(self.addr, &frame[..12 + ct.len()]).await.map_err(|_| ()) } } -
Add
ble_bondingfeature deps:aes-gcm = { version = "0.10", optional = true, default-features = false },hkdf = { version = "0.12", optional = true, default-features = false },sha2 = { version = "0.10", optional = true, default-features = false }. -
nrf52840_pac = { version = "0.13", optional = true }(needed forFICR.DEVICEIDaccess; already transitively pulled byembassy-nrf). -
Write wear: identity written once (first boot); bonds written once per new peer. Flash endurance (10,000 cycles per page) is not a concern.
APPROTECT:
- Engaged on first boot if
FICR.INFO.VARIANTindicates nRF52840 QIAAx C-rev or later (pre-C has voltage-glitching bypass). Build script compiles an assertion. - If silicon is older: bonding feature refuses to compile; user warned at build.
Bond revocation: Status ops 0x46 BondList, 0x48 BondRevoke(index) — bonded-only.
6.2 Bloom-filter sync (oracle-hardened)
Motivation: Phone that moves across dongles wants "give me what I don't have" agnostic of source.
Oracle mitigation:
- Per-session salt
s: u32(random from SD per connection, delivered in first response as0xFE SyncEnd.salt_hintduring the preceding request, or in a pre-syncRequestSaltop). - Key =
blake2s(s || payload)truncated to 64 bits. Server hashes ring entries the same way. - Inject 2% random "false hits": with prob 0.02, server returns a packet that the filter did include.
- Rate-limit
BloomSyncto 1 / 10 s per connection.
Params (corrected arithmetic):
k=4, m_log2=10(128 B filter): FPR at 128 packets ≈ 2.4% (from(1 - e^{-kn/m})^k).- For FPR ≤ 1% at 128 packets:
m_log2=11(256 B filter).
Crates: blake2 0.10 no_std.
6.3 Fat ext-adv (optional)
ble_fat_adv feature. Single extended adv set carrying up to 218 B of payload + metadata. BLE5. 2M PHY secondary. Non-connectable. Single-shot per new RX.
Security caveat: expands LoRa-layer confidentiality from ~2 km (LoRa RF range) to ~2 km OR ~30 m (BLE). For Meshtastic (AEAD'd), no leak. For plaintext protocols, this is a regression.
Off by default.
6.4 ESP32-S3
esp32-nimble based. Wire protocol identical. ESP32-S3 builds use heltec_v3_ble, heltec_v4_ble, elecrow_thinknode_m2_ble features.
6.5 Persistent identity (revised)
v0.1/v0.2's persistent epoch as visible identity was an AirTag-class tracking primitive. Dropped.
Instead: persistent identity via IRK, visible only to bonded peers. Unbonded observers see rotating non-resolvable addresses + rotating epochs. Bonded phones resolve via IRK to a stable identity for UX continuity.
6.6 Fairness and rate limiting
Per-connection TX queue (depth 2) in front of the single CommandChannel. tx_scheduler_task round-robins:
loop {
for slot_idx in 0..MAX_CONNS {
if let Some(slot) = conn_table.lock().await.get_mut(slot_idx) {
if let Ok(cmd) = slot.tx_queue.try_receive() {
commands.send((Origin::Ble(slot_idx as u8, slot.generation), cmd)).await;
}
}
}
Timer::after_millis(10).await;
}
6.7 Enhanced metrics
Status schema (§A.4) v2 byte range gains ble_disconnects_total, ble_att_errors, sync_requests_total, sync_packets_sent_total.
6.8 Compatibility
v2 adds ops; v1 clients ignore unknown. supported_features u16 in Status drives feature detection.
7. V3.0 — Multi-dongle soft mesh with cryptographic peer identity
Adds: authenticated dongle-to-dongle gossip, content-addressed CmRDT, anti-Sybil (ed25519), ring partitioning, global rate limits, failure detection. ~7 weeks on top of v2.
7.1 Motivation
Three dongles in a room hear different subsets. A phone paired with one should see the union.
7.2 Cryptographic peer identity
Each dongle has a long-term ed25519 identity generated at first boot, stored under APPROTECT + encrypted identically to bonding keys (§6.1).
// src/ble/identity.rs
use ed25519_compact::{KeyPair, PublicKey, SecretKey, Signature, Noise};
pub struct Identity {
pub keypair: KeyPair,
pub dongle_id: [u8; 8], // = truncate(blake2s(keypair.pk.to_bytes()), 8)
}
impl Identity {
pub fn load_or_generate(sd: &'static Softdevice, flash: &mut EncryptedFlash) -> Self {
match flash.read_identity() {
Some(bytes) => Self::from_bytes(&bytes),
None => {
let mut seed = [0u8; 32];
nrf_softdevice::random_bytes(sd, &mut seed).unwrap();
let keypair = KeyPair::from_seed(seed.into());
flash.write_identity(&keypair_to_bytes(&keypair));
Self::from_keypair(keypair)
}
}
}
fn from_keypair(keypair: KeyPair) -> Self {
let mut hasher = blake2::Blake2s256::new();
hasher.update(keypair.pk.as_ref());
let h = hasher.finalize();
let mut id = [0u8; 8];
id.copy_from_slice(&h[..8]);
Self { keypair, dongle_id: id }
}
pub fn sign(&self, msg: &[u8]) -> Signature {
self.keypair.sk.sign(msg, Some(Noise::default()))
}
pub fn verify(pk: &PublicKey, msg: &[u8], sig: &Signature) -> bool {
pk.verify(msg, sig).is_ok()
}
}
ed25519-compact 2.x is no_std, pure Rust, ~20 KB flash. Perf: sign ~3 ms, verify ~8 ms on M4 @ 64 MHz.
EncryptedFlash wraps nrf_softdevice::Flash with AES-256-GCM using the HKDF-derived device-unique key (§6.1). Verify flash-wear cost: identity generated once per lifetime (< 10 writes total for test iterations + GA).
7.3 Content-addressed packet keys
pub type PacketId = [u8; 8]; // blake2s-64(payload)
#[derive(Clone)]
pub struct BufferedPacketV3 {
pub id: PacketId,
pub observations: heapless::Vec<Observation, 4>,
pub len: u16,
pub payload: [u8; MAX_PAYLOAD],
pub first_seen_at: Instant,
}
// size: 8 + (4 × 90) + 2 + 256 + 8 = 634 → aligned to 636.
#[derive(Clone)]
pub struct Observation {
pub observer_pk_hash: [u8; 8], // first 8 bytes of observer's pk-blake2s
pub observer_epoch: u64,
pub observer_seq: u32,
pub rssi: i16,
pub snr: i16,
pub hop_count: u8,
pub signature: [u8; 64], // ed25519 sig over (id, epoch, seq, rssi, snr, len)
}
// size: 8 + 8 + 4 + 2 + 2 + 1 + 64 = 89 → aligned to 92.
Max 4 observations per packet. 4 × 92 = 368 B of observation metadata. Per-packet size ~636 B aligned.
7.4 CmRDT framing
Correct structure: op-based Commutative Replicated Data Type.
- Ops:
Insert(packet)idempotent under content keypacket.id. - Merge of observations: set-union by
observer_pk_hash. Max 4 observations kept; eviction policy = oldest observation by(observer_epoch, observer_seq). hop_countmerge: min (shortest known path wins).- Eviction of ring entries is local resource management, not a CRDT op.
Convergence statement: For packet P, if P is in both A.ring and B.ring after anti-entropy completes, A's stored representation of P equals B's. No claim about ring equality as sets (each side may have evicted different P's).
7.5 Anti-entropy protocol
Discovery: v3 dongles scan for the DongLoRa service UUID. When spotted, initiate if my.dongle_id < peer.dongle_id.
Role arbitration: smaller dongle_id initiates. Always-accept on the peripheral side (up to capacity).
Cooldown: per-peer last_sync_at in a heapless::Vec<PeerCooldown, 32> with LRU eviction. Per-peer cooldown default 60 s. Global rate limit: 10 peer-syncs/minute (token bucket) regardless of peer identity — Sybil mitigation.
Failure detector: exponential backoff on failed connects, TTL-based pruning of long-silent peers (drop after 10 × sync_cooldown).
7.5.1 Version vector exchange
V: PeerId → (epoch, latest_seq), capacity 32 entries with round-robin rotation on truncation — each sync advances which 32 of known-N origins are advertised. Ensures any origin is eventually offered to any peer.
Entries GC'd when peer's packets are fully evicted from our ring.
7.5.2 Peer-Sync characteristic protocol
Characteristic UUID: 6E400105-D09E-4C1B-8C8E-D01A9C4C0001.
Request op codes (scoped to Peer-Sync characteristic — opcode 0x60 here is distinct from 0x60 on Status):
0x60 PeerHello { pk:32 + signed_nonce_32:64 } (1st step)
0x61 PeerVectorExchange { vector_entries:var } (2nd step, chunked if > MTU)
0x62 PeerPacketRequest { range_entries:var } (3rd step, chunked)
Request fragmentation follows TX-Write's first/last/id scheme — opcodes 0x61 and 0x62 support chunking with a 1-byte header + payload (same pattern as TX-Write §5.5.6).
Response (notifications on Peer-Sync):
0x63 PeerHelloReply { pk:32 + signed_reply:64 }
0x64 PeerVectorReply { vector_entries:var } (chunked)
0x65 PeerPacketBatch { observations+payload:var } (chunked)
0x6F PeerSyncEnd { count_sent:u16, flags:u8, next_expected:u32 }
flags bit 0: gap — some requested were evicted before stream
flags bit 1: truncated — hit quota before end
0xFE Error { code:u8 }
Response is streamed over GATT notifications with MTU-aware chunking.
Packet signature: each observation is signed by its original observer. Our peer forwards without re-signing. Receivers verify before insert.
7.6 Ring partitioning
v3 ring: 64 total slots, partitioned:
- 32 slots: locally-observed (our own observations, hop_count == 0).
- 24 slots: trusted peers' observations (AddTrustedPeer whitelist).
- 8 slots: untrusted peers' observations (open mesh; zero in closed mesh).
Eviction FIFO within each partition. Insert obeys quota.
Total: 64 × 636 B = 41 KB. Fits in memory budget (§5.10).
(v0.2's "64 slots" was correct; §7.6's "128 slots" was the contradiction — fixed here to 64 consistently.)
7.7 Security model
Trust boundary: signatures on observations, not on payloads.
Threats:
- Passive BLE observer: rotating addresses; nudge leaks only rotating
change_tag. Mitigated by §4.2 + §5.4.5. - Active BLE attacker (unbonded): Sync is readable; TX gated (§5.9); Peer-Sync requires signed
PeerHello. - Rogue peer dongle: limited to:
- Injecting its own signed observations (can't forge other origins).
- Filling 8/64 slots in open mesh, 0/64 in closed mesh.
- Triggering peer-sync on us (rate-limited global + per-peer).
LoRa payload authenticity is not firmware's job. Firmware authenticates observations (who claims to have heard what). Phone-layer protocol (e.g., Meshtastic AES-CCM) authenticates payload content. Non-Meshtastic users without payload auth accept that rogue peers can inject valid-looking packets — documented in the user-facing README.
7.8 Epoch width
u64. Collision probability negligible across expected manufacturer lifetime.
7.9 Power budget
Per peer-sync (realistic): ~1.5 s @ 20 mA = 0.008 mAh. Continuous scanning at 10% duty @ 5 mA: 12 mAh / day (non-trivial on coin cell). Dense mesh (10 peers, 60 s cooldown): ~5 mAh / hour.
Battery deployments: Status op 0x60 LowPowerMode(enable:u8) (bonded-only) — disables peer scanning and sync. Dongle still serves phones.
7.10 v2 → v3 compatibility
- v3 adds Peer-Sync characteristic; v2 clients ignore.
- v3 Status adds
dongle_id+ peer counters at known byte offsets (Appendix A.4). - v2 clients never see Observation-rich frames (those are on Peer-Sync only); they see the v2-shaped Packet frame on Sync and RX-Notify, built by stripping observation metadata server-side.
8. Testing, rollout, metrics
8.1 Test matrix
See §5.14 tiers.
8.2 Metrics
Status schema (§A.4) exposes counters. Forward-compat: v1 reads first 128 bytes and ignores fields it doesn't know (all zero-padded).
8.3 Rollout
- v1 beta: 1–2 dongles, developer phones, EU + US regulatory test.
- v1 GA: opt-in
_bleboard variants. - v2 GA: silicon revision gate on APPROTECT.
- v3 beta: multi-dongle coordination; Sybil stress; battery monitoring.
8.4 Versioning
protocol_version: 0x01 v1, 0x02 v2, 0x03 v3. Feature-detect via supported_features bitfield.
Appendix A — Wire formats
A.1 Advertising
Exactly 31 bytes, described byte-for-byte in §5.4.2.
Service UUID on the air (LE): 01 00 4C 9C 1A D0 8E 8C 1B 4C 9E D0 00 01 40 6E.
A.2 Sync characteristic
Requests (written to Sync char):
| Op | Total length | Fields |
|---|---|---|
| 0x01 SyncSince | 16 | op + epoch:u64 + since_seq:u32 + max_count:u16 + flags:u8 |
| 0x02 SyncAll | 4 | op + max_count:u16 + flags:u8 |
| 0x03 SyncOldest | 4 | op + count:u16 + flags:u8 |
| 0x04 BloomSync (v2) | 7 + bloom | op + k:u8 + m_log2:u8 + max_count:u16 + flags:u8 + bloom:[u8] |
Responses (notifications):
| Op | Fields |
|---|---|
| 0x10 Packet | op + epoch:u64 + seq:u32 + [rssi:i16 + snr:i16 + len:u16] + payload |
| 0x11 PacketCont | op + epoch:u64 + seq:u32 + offset:u16 + chunk |
| 0xFE SyncEnd | op + epoch:u64 + latest_seq:u32 + oldest_seq:u32 + count_sent:u16 + flags:u8 |
| 0xFF Error | op + code:u8 + detail:[u8] |
Flags (request): bit 0 = include_metadata; bit 1 = payload_only. Flags (SyncEnd): bit 0 = gap; bit 1 = truncated; bit 2 = epoch_mismatch.
Error codes: 0x01 UnknownOp, 0x02 Malformed, 0x03 FilterTooLarge, 0x04 Busy.
Opcode namespace is per-characteristic. 0x10 on Sync (Packet) is unrelated to any 0x10 on Status or Peer-Sync.
A.3 TX-Write
See §5.5.6.
A.4 Status characteristic — Read value (fixed 128 bytes, zero-padded)
Offset Field Bytes Version
0 protocol_version 1 v1: 0x01, v2: 0x02, v3: 0x03
1 fw_ver_major 1 v1+
2 fw_ver_minor 1 v1+
3 fw_ver_patch 1 v1+
4 board_id 1 v1+ (enum in §A.4.1)
5 epoch 8 v1+
13 latest_seq 4 v1+
17 oldest_seq 4 v1+
21 ring_capacity 2 v1+ (128 for v1/v2, 64 for v3)
23 ring_occupancy 2 v1+
25 rx_total 4 v1+
29 rx_dropped_ring_evict 4 v1+
33 rx_dropped_host_chan 4 v1+
37 tx_total_host 4 v1+
41 tx_total_ble 4 v1+
45 tx_errors_host 4 v1+
49 tx_errors_ble 4 v1+
53 ble_conns_peak 1 v1+
54 active_connections 1 v1+
55 radio_state 1 v1+
56 duty_cycle_used_ppm_hour 4 v1+
60 mac_address 6 v1+
66 supported_features 2 v1+
68 ble_disconnects_total 2 v2+
70 ble_att_errors 2 v2+
72 sync_requests_total 4 v2+
76 sync_packets_sent_total 4 v2+
80 peer_syncs_initiated 2 v3
82 peer_syncs_accepted 2 v3
84 dongle_id 8 v3
92 softdevice_fault_count 1 v1+
93 last_panic_count 1 v1+
94–127 reserved (0)
Total: 128 bytes.
supported_features bits:
- 0 BloomSync | 1 FatAdv | 2 Bonding | 3 IrkBasedIdentity
- 4 RateLimiting | 5 DutyCycleEnforcement
- 6 PeerMeshV3 | 7 CryptographicPeerAuth
A.4.1 board_id enum
0 unknown
1 rak_wisblock_4631
2 wio_tracker_l1
3 heltec_v3_usb
4 heltec_v3_uart
5 heltec_v4
6 elecrow_thinknode_m2
A.5 Status control ops
Scoped to Status characteristic. Opcode scoping: 0x10 here is unrelated to 0x10 on Sync or Peer-Sync.
| Op | Direction | Fields | Access |
|---|---|---|---|
| 0x10 TxAccepted | notify | tx_id:u8 |
— |
| 0x11 RxMode (reserved) | write | mode:u8 |
— (v2+) |
| 0x20 Ping | write → 0x21 Pong | nonce:u32 |
open |
| 0x22 RequestMtuInfo | write → 0x23 MtuInfo | — → att_mtu:u16 |
open |
| 0x24 TxDone | notify | tx_id:u8 |
— |
| 0x25 TxError | notify | tx_id:u8 + code:u8 |
— |
| 0x26 ReadConfig | write → 0x27 ConfigBlob | — → config:[13] |
open (read-only) |
| 0x40 ButtonArmTx | write → 0x41 Armed | 0x25 | — | Mode B only (v1) |
| 0x42 SetTxRateLimit | write → 0x43 Ack | ms:u16 |
bonded (v2+) |
| 0x44 ResetMetrics | write → 0x45 Ack | — | bonded (v2+) |
| 0x46 BondList | write → 0x47 List | — → list | bonded (v2+) |
| 0x48 BondRevoke | write → 0x49 Ack | index:u8 |
bonded (v2+) |
| 0x50 AddTrustedPeer | write → 0x51 Ack | pk_hash:[u8;8] |
bonded (v3) |
| 0x52 RemoveTrustedPeer | write → 0x53 Ack | pk_hash:[u8;8] |
bonded (v3) |
| 0x60 LowPowerMode | write → 0x61 Ack | enable:u8 |
bonded (v3) |
BLE TxError codes (in 0x25 TxError's code byte):
0x01 Framing
0x02 Oversize
0x03 Timeout
0x04 AccessDenied
0x05 RateLimited
0x06 DutyCycleExceeded
0x07 RadioBusy
0x08 NotArmed
0x09 ConfigInvalid
0x0A NotConfigured
0x10 TxTimeout
0x11 TxFailed
A.6 Peer-Sync characteristic (v3)
See §7.5.2. Opcodes 0x60–0x6F scoped here.
Appendix B — State machines
B.1 TX-Write reassembler
Full Rust source in §5.5.6.
B.2 Advertiser cadence
Full source in §5.4.6.
B.3 Sync handler
Full source in §5.2.2.
B.4 Identity rotation
Full source in §5.4.5.
Appendix C — Justfile, blob provenance, linker
C.1 Justfile
SOFTDEVICE_HEX := "ld/softdevice/s140_nrf52_7.3.0_softdevice.hex"
ADAFRUIT_BOOTLOADER_HEX := "ld/bootloader/adafruit_nrf52840_bootloader_0.9.2.hex"
# Board triples
rak_wisblock_4631_ble := "rak_wisblock_4631_ble thumbv7em-none-eabihf nRF52840_xxAA"
wio_tracker_l1_ble := "wio_tracker_l1_ble thumbv7em-none-eabihf nRF52840_xxAA"
flash-softdevice:
probe-rs download --chip nRF52840_xxAA {{SOFTDEVICE_HEX}}
wio-prepare-adafruit-bootloader:
@echo "Replaces Seeed factory bootloader with Adafruit UF2."
@echo "Recovery requires SWD. Ctrl-C to abort."
@read -r -p "Press enter to continue... "
probe-rs download --chip nRF52840_xxAA {{ADAFRUIT_BOOTLOADER_HEX}}
# Full build matrix ──────────────────────────────────────────────
build-all:
cargo build --release --features rak_wisblock_4631
cargo build --release --features rak_wisblock_4631_ble
cargo build --release --features rak_wisblock_4631_ble,ble_bonding
cargo build --release --features wio_tracker_l1
cargo build --release --features wio_tracker_l1_ble
cargo build --release --features wio_tracker_l1_ble,ble_bonding
cargo build --release --features heltec_v3
cargo build --release --features waveshare_rp2040_lora
test-host:
cargo test --features test-host --target x86_64-unknown-linux-gnu
C.2 Blob provenance
ld/softdevice/README.md:
- URL: infocenter.nordicsemi.com S140 v7.3.0
- File:
s140_nrf52_7.3.0_softdevice.hex - SHA-256:
<pinned at release>
ld/bootloader/README.md:
- URL: adafruit/Adafruit_nRF52_Bootloader releases
- File:
adafruit_nrf52840_bootloader_0.9.2.hex(or current Adafruit-released version) - SHA-256:
<pinned>
Appendix D — Glossary and references
Glossary
- CCCD — Client Characteristic Configuration Descriptor
- CmRDT — Commutative Replicated Data Type (op-based)
- FICR — Factory Information Configuration Registers (nRF52840 factory-unique data block)
- IRK — Identity Resolving Key (BLE 4.2+ private address resolution)
- LL — Link Layer (BLE radio layer)
- LTK — Long Term Key
- MBR — Master Boot Record (Nordic's, loads SoftDevice)
- MTU — Maximum Transmission Unit (ATT = 247 max on S140)
- RPA — Resolvable Private Address
- S140 — Nordic SoftDevice S140 v7.3.0
- VLOC — Value Location (GATT characteristic storage: STACK or USER)
References
- Bluetooth Core 5.3 Vol 3 Parts C (GAP), G (GATT)
- Nordic S140 v7.3.0 SoftDevice Specification
- nrf-softdevice examples: https://github.com/embassy-rs/nrf-softdevice/tree/master/examples
- Embassy book: https://embassy.dev/book/
- Shapiro et al., "Conflict-Free Replicated Data Types," INRIA 2011
- Almeida et al., "Delta State Replicated Data Types," JPDC 2018
- Meshtastic nRF52 build (prior art for SD+USB+LoRa)
- Semtech AN1200.22 (LoRa time-on-air formulas)
- Attack references: BleedingBit (CVE-2018-16986), Sweyntooth (CVE-2019-17517+), AirTag tracking (Heinrich 2022), nRF52 APPROTECT (LimitedResults 2020)
Appendix E — Implementer decisions (knobs)
- Ring capacity v1: 128 (default). 64 if tight.
- Duty-cycle cap default: 1% (EU 868). Per-regulatory build override.
- Peer-sync cooldown default: 60 s. Denser deployments may tune.
- Rotation period default: 15 min. Shorter = better privacy, more reconnects.
test-hostfeature must be in Cargo.toml (see §5.12).- Panic handler: custom rate-limited in release;
panic-probein dev. - nrf-softdevice git rev: pin to a specific commit at v0.3 acceptance; avoid moving target.
Change log
| Date | Version | Change |
|---|---|---|
| 2026-04-12 | 0.1 | Initial |
| 2026-04-12 | 0.2 | Post-review-1 (senior + CRDT + security + junior round 1) |
| 2026-04-12 | 0.3 | Post-review-2 (junior round 2): single adv set, concrete types, slot+generation TX routing, ring 64-slots partitioned, ed25519 spec, fixed arithmetic, Status 128 B fixed, test-host feature, nrf-softdevice pinned rev, per-char opcode scoping, iOS background-wake claim corrected |
| 2026-04-12 | 0.4 | Post-review-3: time-on-air formula inline with two verified fixtures, ConnectableAdvertisement::ScannableUndirected, RANDOM_PRIVATE_NON_RESOLVABLE address type, adv_stop_signal for rotation, ConnEventChannel bridge for sync-GATT-callback-vs-async-handler. Post-review-4: AdvBuffer decouples adv data from adv future (fixes reshape-starvation), HandleConnectionBundle for arg threading, handle_tx_write collects Decision under lock then releases before notify (locks-across-notify rule preserved), aes-gcm flash read().await fix |