# 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](https://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](https://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 │─── 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` | 8 receivers: advertiser + up to 4 RX-Notify fanouts + up to 3 v3 peer-sync workers. | | `ConnTable` | 4 `ConnSlot`s, keyed by `Connection` (not raw handle) | Slot recycling mitigated by generation counter; see §5.5.3. | | `BleTxRouter` | `[Signal; 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 ```rust // 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` ```rust // 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 = StaticCell::new(); static COMMANDS: StaticCell = StaticCell::new(); static RESPONSES: StaticCell = StaticCell::new(); static STATUS: StaticCell = StaticCell::new(); static SEQ_WATCH_CELL: StaticCell = 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 ```rust // 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, 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 { 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; ``` `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: ```rust 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): ```rust // 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: ```rust 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 ```rust // 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; ``` 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 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 ```rust // 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 { 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 ```rust // 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, ) { // 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). ```rust // src/ble/adv_buffer.rs use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::mutex::Mutex; pub struct AdvBuffer { inner: Mutex, } 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. ```rust // Spawned once in main(). #[embassy_executor::task] pub async fn advertise_loop( sd: &'static Softdevice, adv_buffer: &'static AdvBuffer, reshape_signal: &'static Signal, adv_stop_signal: &'static Signal, conn_table: &'static Mutex, 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, adv_buffer: &'static AdvBuffer, reshape_signal: &'static Signal, 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: ```rust 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, } ``` One `ConnEventChannel` per slot keeps event routing clean. `main()` (§5.1.6) allocates: ```rust static CONN_BUNDLE: StaticCell = 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 ```rust // 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, // max request size = MTU-3 #[characteristic(uuid = "6E400102-D09E-4C1B-8C8E-D01A9C4C0001", notify)] rx_notify: heapless::Vec, #[characteristic(uuid = "6E400103-D09E-4C1B-8C8E-D01A9C4C0001", write, write_without_response)] tx_write: heapless::Vec, #[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 ```rust // 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, pub tx_armed_until: Option, pub bonded: bool, } pub struct ConnTable { slots: [Option; 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; } } ``` ```rust // 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, // 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 ``` ```rust // 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, } #[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, 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: ```rust // src/ble/handle_connection.rs use embassy_sync::channel::Channel; pub enum ConnEvent { TxWriteBody(heapless::Vec), SyncWriteBody(heapless::Vec), StatusWriteBody(heapless::Vec), Disconnected, } pub type ConnEventChannel = Channel; #[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, ) { 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, ) { 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, 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), } 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, 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`: ```rust if was_connected && !connected { let _ = commands.try_send(Command::StopRx); if has_display { display_commands.send(DisplayCommand::Reset).await; } } ``` Diff: ```rust 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: ```rust // 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. ```rust // src/board/traits.rs (extension) pub trait BoardExt { fn tx_arm_button() -> Option<&'static ArmButton>; } pub struct ArmButton { last_press: Mutex>, } 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 ```rust // 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: ```rust // 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: ```rust 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 ```toml [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): ```rust 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: ```rust // 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: ```rust // 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, 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::::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> { 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: ```rust 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). ```rust // 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 ```rust pub type PacketId = [u8; 8]; // blake2s-64(payload) #[derive(Clone)] pub struct BufferedPacketV3 { pub id: PacketId, pub observations: heapless::Vec, 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` 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 ```makefile SOFTDEVICE_HEX := "ld/softdevice/s140_nrf52_7.3.0_softdevice.hex" ADAFRUIT_BOOTLOADER_HEX := "ld/bootloader/adafruit_nrf52840_bootloader_0.9.2.hex" # Board triples rak_wisblock_4631_ble := "rak_wisblock_4631_ble thumbv7em-none-eabihf nRF52840_xxAA" wio_tracker_l1_ble := "wio_tracker_l1_ble thumbv7em-none-eabihf nRF52840_xxAA" flash-softdevice: probe-rs download --chip nRF52840_xxAA {{SOFTDEVICE_HEX}} wio-prepare-adafruit-bootloader: @echo "Replaces Seeed factory bootloader with Adafruit UF2." @echo "Recovery requires SWD. Ctrl-C to abort." @read -r -p "Press enter to continue... " probe-rs download --chip nRF52840_xxAA {{ADAFRUIT_BOOTLOADER_HEX}} # Full build matrix ────────────────────────────────────────────── build-all: cargo build --release --features rak_wisblock_4631 cargo build --release --features rak_wisblock_4631_ble cargo build --release --features rak_wisblock_4631_ble,ble_bonding cargo build --release --features wio_tracker_l1 cargo build --release --features wio_tracker_l1_ble cargo build --release --features wio_tracker_l1_ble,ble_bonding cargo build --release --features heltec_v3 cargo build --release --features waveshare_rp2040_lora test-host: cargo test --features test-host --target x86_64-unknown-linux-gnu ``` ### C.2 Blob provenance `ld/softdevice/README.md`: - URL: [infocenter.nordicsemi.com S140 v7.3.0](https://www.nordicsemi.com/Products/Development-software/s140/download) - File: `s140_nrf52_7.3.0_softdevice.hex` - SHA-256: `` `ld/bootloader/README.md`: - URL: [adafruit/Adafruit_nRF52_Bootloader releases](https://github.com/adafruit/Adafruit_nRF52_Bootloader/releases) - File: `adafruit_nrf52840_bootloader_0.9.2.hex` (or current Adafruit-released version) - SHA-256: `` --- ## 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 |