Files
firmware/BLE_MUX_DESIGN_DOC.md
T

117 KiB
Raw Blame History

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_data API that nrf-softdevice does not expose — now designed around advertise_connectable() with reshape-on-reconfigure); full implementations for AdvMgr, ConnTable, BleTxRouter, rotate_identity, ed25519 key management; concrete crate pins (nrf-softdevice git rev, ed25519-compact, blake2); Origin::Ble carries 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_id is 8 bytes everywhere; SEQ_WATCH bumped to 8 receivers; test-host feature added; per-board button handling reframed; duty-cycle computation delegated to lora_phy::time_on_air; iOS background-wake claim corrected (once-per-service-UUID, not per-change-tag); embassy-nrf init uses real API; bonding linker variant + build.rs selection; 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 → Watch for multi-consumer; RxRing lock is per-packet snapshot, never across .await; Wio Tracker L1 has a bootloader prerequisite; linker numbers aligned with in-tree ld/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-softdevice crate: github.com/embassy-rs/nrf-softdevice; examples ble_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

  1. Multiplex to ≤ 4 concurrent GATT connections; unlimited passive scanners.
  2. Work with iOS background apps (scan-wake + GATT catch-up, not continuous ad consumption).
  3. Preserve USB CDC administration; no wire change for donglora-mux.
  4. No per-protocol firmware evolution.
  5. Build on existing nRF52840 boards with existing Embassy code.
  6. Degrade gracefully — BLE congestion must not block LoRa; dropped connections must not corrupt state.
  7. 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-ble builds byte-identical on USB; donglora-mux diff 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

  1. RX from air. radio_task gets (len, pkt_status) → brief RX_RING::push → SEQ_WATCH.send(seq) → also responses.try_send(Response::RxPacket) for USB (unchanged).
  2. Advertiser observes SEQ_WATCH and reshapes adv data on a debounced cadence.
  3. Phone connects, pulls via Sync, disconnects. Per-packet snapshot streaming, lock-held < 5 µs per packet.
  4. Phone transmits. Write-chunk reassembly → (Origin::Ble(slot, generation), Command::Transmit) into CommandChannel → awaits BLE_TX_ROUTER[slot].wait() → Status notification → completes.
  5. 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. Scopes seq.
  • seq: u32 — monotonic per-epoch; on u32::MAX saturation 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-257 Cargo 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-softdevice does not expose.
  • Extended adv: disabled (v2 ble_fat_adv enables).
  • 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

  1. radio_task never blocks on USB or BLE; push to RX_RING is bounded < 5 µs.
  2. ble_task never holds RX_RING across a notification .await (snapshot API enforces).
  3. All CommandChannel sends originate in tasks, never ISRs.
  4. TX replies route by Origin; (slot, generation) prevents misrouting on slot recycle.
  5. critical_section::with is rare; S140's impl is used when ble is 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, first during 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 --ping works.
  • BLE adv visible via CI-host adapter (btmgmt or bluer library).
  • 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-ble build 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 the aes-gcm crate. 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_bonding feature 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 for FICR.DEVICEID access; already transitively pulled by embassy-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.VARIANT indicates 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:

  1. Per-session salt s: u32 (random from SD per connection, delivered in first response as 0xFE SyncEnd.salt_hint during the preceding request, or in a pre-sync RequestSalt op).
  2. Key = blake2s(s || payload) truncated to 64 bits. Server hashes ring entries the same way.
  3. Inject 2% random "false hits": with prob 0.02, server returns a packet that the filter did include.
  4. Rate-limit BloomSync to 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 key packet.id.
  • Merge of observations: set-union by observer_pk_hash. Max 4 observations kept; eviction policy = oldest observation by (observer_epoch, observer_seq).
  • hop_count merge: 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 _ble board 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:

ld/bootloader/README.md:


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-host feature must be in Cargo.toml (see §5.12).
  • Panic handler: custom rate-limited in release; panic-probe in 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