diff --git a/BLE_MUX_DESIGN_DOC.md b/BLE_MUX_DESIGN_DOC.md new file mode 100644 index 0000000..7ff95d7 --- /dev/null +++ b/BLE_MUX_DESIGN_DOC.md @@ -0,0 +1,2680 @@ +# 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 |