2681 lines
117 KiB
Markdown
2681 lines
117 KiB
Markdown
# 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<u32,8> │─── fan-out ──────┤ │ │
|
||
│ └──────────────┘ │ │ │
|
||
│ │ │ │
|
||
│ ┌───────────────────────────────┐ │ │ │
|
||
│ │ ble_task (single spawned task)│─┘ │ │
|
||
│ │ Inner tasks (via join): │ │ │
|
||
│ │ • adv_and_connect_loop │ │ │
|
||
│ │ • rotate_timer │ │ │
|
||
│ │ • rx_notify_fanout │ │ │
|
||
│ │ │ │ │
|
||
│ │ On each new Connection, │ │ │
|
||
│ │ spawns handle_connection() │ │ │
|
||
│ │ which owns gatt_server::run │ │ │
|
||
│ │ for that peer. │ │ │
|
||
│ └───────────────────────────────┘ │ │
|
||
│ │ │
|
||
│ ┌───────────────────────────────┐ │ │
|
||
│ │ softdevice_task (sibling) │ │ │
|
||
│ │ runs Softdevice::run() │ │ │
|
||
│ └───────────────────────────────┘ │ │
|
||
│ │ │
|
||
│ ┌───────────────────────────────┐ ┌───────────────┐ │ │
|
||
│ │ display_task (unchanged) │◄──│ StatusWatch │ │ │
|
||
│ └───────────────────────────────┘ │ (N=3) │ │ │
|
||
│ └───────────────┘ │ │
|
||
└────────────────────────────────────────────────────────┼──────────────┘
|
||
│ │
|
||
USB │ │ BLE
|
||
▼ ▼
|
||
donglora-mux phones (observer + TX)
|
||
```
|
||
|
||
### 3.2 Components
|
||
|
||
| Component | Owns | Key invariants |
|
||
| ----------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------- |
|
||
| `radio_task` | SX1262, radio config, RX/TX state | Never blocks on BLE. Push to `RX_RING` bounded < 5 µs. |
|
||
| `RX_RING` | Bounded RX history (v1: 128 slots; v3: 64 slots partitioned) | Per-packet snapshot API — mutex never held across `.await`. |
|
||
| `SEQ_WATCH` | `Watch<CSRM, u32, 8>` | 8 receivers: advertiser + up to 4 RX-Notify fanouts + up to 3 v3 peer-sync workers. |
|
||
| `ConnTable` | 4 `ConnSlot`s, keyed by `Connection` (not raw handle) | Slot recycling mitigated by generation counter; see §5.5.3. |
|
||
| `BleTxRouter` | `[Signal<TxOutcome>; MAX_CONNS]` | Routes TX outcomes back to origin slot+generation pair. |
|
||
| `softdevice_task` | Runs `sd.run()` forever | Spawned before any BLE API call. |
|
||
| `ble_task` | Single connectable adv set; gatt server; rotations | Sibling of softdevice_task. |
|
||
|
||
### 3.3 Data and control flow
|
||
|
||
1. **RX from air.** radio_task gets `(len, pkt_status)` → brief `RX_RING::push` → `SEQ_WATCH.send(seq)` → also `responses.try_send(Response::RxPacket)` for USB (unchanged).
|
||
2. **Advertiser observes SEQ_WATCH** and reshapes adv data on a debounced cadence.
|
||
3. **Phone connects**, pulls via Sync, disconnects. Per-packet snapshot streaming, lock-held < 5 µs per packet.
|
||
4. **Phone transmits**. Write-chunk reassembly → `(Origin::Ble(slot, generation), Command::Transmit)` into `CommandChannel` → awaits `BLE_TX_ROUTER[slot].wait()` → Status notification → completes.
|
||
5. **USB admin** continues to see every RX and reconfigures radio; does not see BLE activity.
|
||
|
||
---
|
||
|
||
## 4. State and naming
|
||
|
||
### 4.1 Identifiers
|
||
|
||
- **`dongle_id: [u8; 8]`** — truncated BLAKE2s-256 of the ed25519 identity public key (v3 only); stable across address rotations. 8 bytes _everywhere_ in this document.
|
||
- **`epoch: u64`** — random at boot, re-randomized on address rotation. Scopes `seq`.
|
||
- **`seq: u32`** — monotonic per-epoch; on `u32::MAX` saturation forces a new epoch (safety net; would take 135+ years at 1 pkt/sec).
|
||
- **`tx_id: u6`** — client-chosen TX correlator in TX-Write chunked frames.
|
||
- **`(slot: u8, generation: u16)`** — internal pair that routes TX outcomes (§5.5.3).
|
||
- **Op codes** — first-byte opcodes, _scoped per-characteristic_ (opcode 0x60 on Peer-Sync is a different namespace from 0x60 on Status).
|
||
|
||
### 4.2 Address rotation
|
||
|
||
v1 and later: BLE device address is non-resolvable random, rotated every 15 min; `epoch` re-randomizes at the same moment. Bonded peers (v2+) see an IRK-based resolvable-private-address instead — they resolve to stable identity; passive observers don't.
|
||
|
||
Active GATT connections drop on rotation (forced via `sd_ble_gap_disconnect` with reason `REMOTE_USER_TERMINATED`). Clients reconnect immediately. A 15-minute rotation gives users ~30 seconds' grace after a reconnect to complete a sync before the next rotation. §5.4.5 has the full procedure.
|
||
|
||
### 4.3 Terminology
|
||
|
||
| Term | Meaning |
|
||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||
| **Nudge adv** | Our single connectable advert with service UUID + manuf metadata (§5.4). Note: v0.2 used "nudge" to mean a distinct non-connectable set; v0.3 merges into one adv. |
|
||
| **Catch-up sync** | Client connects, writes SyncSince, receives delta, disconnects. |
|
||
| **Drift / gap** | Client's `since_seq` is older than ring oldest. |
|
||
| **Soft-mesh** | Phone-side view across one or more dongles (v2+); dongles gossip directly in v3. |
|
||
| **S140** | Nordic SoftDevice S140 v7.3.0. |
|
||
| **CmRDT** | Commutative Replicated Data Type (op-based); v3 correct model (§7.4). |
|
||
|
||
---
|
||
|
||
## 5. V1.0 — The MVP
|
||
|
||
**Scope:** nRF52840. Single dongle. Gated TX (regulatory). Ring + connectable adv + sync. USB coexists. iOS-friendly. Rotating address + epoch.
|
||
|
||
**Effort:** ~6.5 weeks focused.
|
||
|
||
### 5.1 SoftDevice
|
||
|
||
#### 5.1.1 Choice
|
||
|
||
`nrf-softdevice` with S140 v7.3.0. Pinned to a specific git revision (see §5.12). TrouBLE rejected for v1 (extended adv + concurrent roles still maturing as of early 2026).
|
||
|
||
#### 5.1.2 Configuration (v1)
|
||
|
||
- Peripheral connections: 4.
|
||
- Central: 0 (v1, v2); 1 in v3.
|
||
- Connection event length: 3 units (3.75 ms). Senior review showed that event_length 6 (7.5 ms) × 4 connections doesn't fit a 30 ms interval with an adv set running alongside.
|
||
- ATT MTU: request 247 on connect. `evt-max-size-257` Cargo feature on nrf-softdevice (the SD event buffer is 247 ATT payload + 10 B of L2CAP/ATT/padding = 257).
|
||
- PHY: request 2M for connections. Legacy adv on primary channels 37/38/39 is always 1M by BLE spec.
|
||
- Adv sets: **1** (single connectable adv, see §5.4). v0.2's dual-set design was based on an API that `nrf-softdevice` does not expose.
|
||
- Extended adv: disabled (v2 `ble_fat_adv` enables).
|
||
- GATT attribute table size: 2048 B (> default 1408, headroom for future characteristics).
|
||
|
||
#### 5.1.3 Interrupt priorities
|
||
|
||
Cortex-M4 has 3 priority bits (0 highest, 7 lowest). S140 reserves 0 (SWI2_EGU2), 1 (RADIO, TIMER0, RTC0, CCM_AAR), 4 (SWI5_EGU5, MWU). Application may use 2, 3, 5, 6, 7.
|
||
|
||
Embassy's thread-mode executor has no NVIC priority; it is preempted by every enabled ISR. The v0.1 claim "Embassy at priority 6" was wrong; the correct statement is "thread mode, preempted by all ISRs." `embassy-nrf` with `time-driver-rtc1` sets RTC1 IRQ at priority 2 (safe).
|
||
|
||
#### 5.1.4 Critical-section impl
|
||
|
||
On (`ble`): `nrf-softdevice/critical-section-impl` — routes `critical_section::with` to `sd_nvic_critical_region_enter/_exit`, S140-safe.
|
||
Off: `cortex-m/critical-section-single-core` — naive disable-interrupts.
|
||
|
||
Exactly one is active per build. `build.rs` compile-time guard (§5.12).
|
||
|
||
#### 5.1.5 SoftDevice initialization — complete code
|
||
|
||
```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<RxRingMutex> = StaticCell::new();
|
||
static COMMANDS: StaticCell<CommandChannel> = StaticCell::new();
|
||
static RESPONSES: StaticCell<ResponseChannel> = StaticCell::new();
|
||
static STATUS: StaticCell<StatusWatch> = StaticCell::new();
|
||
static SEQ_WATCH_CELL: StaticCell<SeqWatch> = StaticCell::new();
|
||
static BLE_TX_ROUTER: BleTxRouter = BleTxRouter::new(); // const-new; see §5.5.3
|
||
|
||
#[embassy_executor::main]
|
||
async fn main(spawner: Spawner) {
|
||
// 1. Embassy HAL init. Use Default — SD and embassy-nrf coexist because
|
||
// nrf-softdevice's POWER handling takes over from embassy-nrf once
|
||
// Softdevice::enable() runs. Documented in nrf-softdevice examples.
|
||
let mut nrf_cfg = NrfConfig::default();
|
||
// The only explicit tweak: embassy-nrf HFCLK config must not request XTAL
|
||
// before SD enables — SD owns clock control. Leave at default (RC).
|
||
let p = embassy_nrf::init(nrf_cfg);
|
||
|
||
// 2. Initial epoch from HWRNG BEFORE enabling SD (SD takes the RNG peripheral).
|
||
let initial_epoch: u64 = {
|
||
let mut rng = embassy_nrf::rng::Rng::new_blocking(p.RNG);
|
||
let mut buf = [0u8; 8];
|
||
rng.blocking_fill_bytes(&mut buf);
|
||
u64::from_le_bytes(buf)
|
||
};
|
||
|
||
// 3. Initialize static RxRing with epoch.
|
||
let mut ring_init = RxRing::new_empty();
|
||
ring_init.set_epoch(initial_epoch);
|
||
let ring = RX_RING_CELL.init(Mutex::new(ring_init));
|
||
|
||
// 4. Read MAC from FICR (unchanged; board-specific helper).
|
||
let mac = hal::nrf52840::read_mac();
|
||
|
||
// 5. Enable SoftDevice. After this line: do NOT use p.RNG, p.TIMER0, p.RTC0,
|
||
// p.SWI1/2/5, p.CCM, p.AAR, p.TEMP, p.POWER through embassy-nrf.
|
||
let sd = ble::softdevice::enable();
|
||
spawner.must_spawn(ble::softdevice::softdevice_task(sd));
|
||
|
||
// 6. Set an initial random non-resolvable BLE address.
|
||
ble::set_random_address(sd, &mac); // see §5.4.5
|
||
|
||
// 7. GATT server (after SD is running).
|
||
let server = ble::gatt::Server::new(sd).expect("gatt register");
|
||
|
||
// 8. Inter-task channels.
|
||
let commands = COMMANDS.init(CommandChannel::new());
|
||
let responses = RESPONSES.init(ResponseChannel::new());
|
||
let status = STATUS.init(StatusWatch::new());
|
||
let seq_watch = SEQ_WATCH_CELL.init(SeqWatch::new());
|
||
|
||
// 9. Radio & host parts.
|
||
let (radio_parts, usb_parts, display_parts, has_display) =
|
||
board::into_parts(p);
|
||
|
||
// 10. Spawn tasks. ble_task owns ConnTable and AdvMgr internally.
|
||
spawner.must_spawn(radio_task(
|
||
radio_parts, ring, commands, responses,
|
||
status.sender(), seq_watch.sender(), &BLE_TX_ROUTER,
|
||
));
|
||
spawner.must_spawn(host_task(
|
||
usb_parts, commands, responses, display_cmds, has_display, mac,
|
||
));
|
||
spawner.must_spawn(ble::ble_task(
|
||
sd, server, ring, commands, seq_watch.receiver().unwrap(),
|
||
&BLE_TX_ROUTER, initial_epoch,
|
||
));
|
||
if has_display {
|
||
spawner.must_spawn(display_task(display_parts, display_cmds, status.receiver().unwrap()));
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.2 RX_RING
|
||
|
||
#### 5.2.1 Type
|
||
|
||
```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<BufferedPacket, RING_CAPACITY_V1>,
|
||
epoch: u64,
|
||
next_seq: u32,
|
||
}
|
||
|
||
impl RxRing {
|
||
pub const fn new_empty() -> Self {
|
||
Self { buf: Deque::new(), epoch: 0, next_seq: 0 }
|
||
}
|
||
|
||
pub fn set_epoch(&mut self, epoch: u64) {
|
||
self.epoch = epoch;
|
||
self.next_seq = 0;
|
||
self.buf.clear();
|
||
}
|
||
|
||
pub fn push(&mut self, rssi: i16, snr: i16, payload: &[u8]) -> (u64, u32) {
|
||
let pkt = BufferedPacket::from_rx(self.epoch, self.next_seq, rssi, snr, payload);
|
||
if self.buf.is_full() { let _ = self.buf.pop_front(); }
|
||
self.buf.push_back(pkt).ok();
|
||
let seq = self.next_seq;
|
||
self.next_seq = self.next_seq.checked_add(1).unwrap_or_else(|| {
|
||
// u32 overflow safety net: force new epoch, clear ring.
|
||
self.epoch = self.epoch.wrapping_add(1);
|
||
self.buf.clear();
|
||
0
|
||
});
|
||
(self.epoch, seq)
|
||
}
|
||
|
||
/// Returns the packet at (epoch, seq) if present. O(log N).
|
||
/// Clones so caller can release the lock before streaming.
|
||
pub fn snapshot_at(&self, epoch: u64, seq: u32) -> Option<BufferedPacket> {
|
||
if epoch != self.epoch { return None; }
|
||
let (front, back) = self.buf.as_slices();
|
||
let total = front.len() + back.len();
|
||
if total == 0 { return None; }
|
||
// seq increments monotonically with insertion; deque is ordered by insertion.
|
||
let first_seq = front.first().or(back.first()).unwrap().seq;
|
||
if seq < first_seq { return None; }
|
||
let idx = (seq - first_seq) as usize;
|
||
if idx >= total { return None; }
|
||
let pkt = if idx < front.len() { &front[idx] } else { &back[idx - front.len()] };
|
||
Some(pkt.clone())
|
||
}
|
||
|
||
pub fn latest_seq(&self) -> u32 { self.buf.back().map(|p| p.seq).unwrap_or(0) }
|
||
pub fn oldest_seq(&self) -> u32 { self.buf.front().map(|p| p.seq).unwrap_or(0) }
|
||
pub fn epoch(&self) -> u64 { self.epoch }
|
||
pub fn len(&self) -> usize { self.buf.len() }
|
||
}
|
||
|
||
pub type RxRingMutex = Mutex<CriticalSectionRawMutex, RxRing>;
|
||
```
|
||
|
||
`heapless::Deque::new()` is `const` in 0.9+; `as_slices()` exists in 0.9+ (verified).
|
||
|
||
#### 5.2.2 Lock discipline (the correctness rule)
|
||
|
||
**Never hold the mutex across an `.await` that performs I/O.** The snapshot API enforces this — one lock acquisition per packet:
|
||
|
||
```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<CriticalSectionRawMutex, (Origin, Command), 16>;
|
||
```
|
||
|
||
Callers must change to wrap commands in `(origin, cmd)`. Sites:
|
||
|
||
- `src/host/framing.rs::route_command` — wrap as `(Origin::Host, cmd)`.
|
||
- `src/ble/tx_assemble.rs` (new) — wrap as `(Origin::Ble(slot, gen), Command::Transmit { .. })`.
|
||
- radio_task's destructuring: `while let (origin, cmd) = commands.receive().await { ... }`.
|
||
|
||
The compiler will surface every missed site.
|
||
|
||
### 5.4 Advertising — single connectable adv set
|
||
|
||
#### 5.4.1 Why one set, not two
|
||
|
||
`nrf-softdevice`'s high-level API exposes `advertise()` / `advertise_connectable()` / `advertise_pairable()`. There is no public `AdvertisementSet::update_data` and no documented way to run two concurrent sets from the one peripheral role (the raw `sd_ble_gap_adv_set_configure` API supports multiple adv handles, but nrf-softdevice doesn't expose this). v0.2's dual-set design is not implementable against this crate.
|
||
|
||
v0.3: use **one connectable advert** that carries both the service UUID (for discovery) AND the manuf metadata (for iOS scan-wake). When manuf data needs to change, the advert is reconfigured in a tight loop that accepts incoming connections between reshapes.
|
||
|
||
#### 5.4.2 Adv content
|
||
|
||
31-byte legacy advertisement:
|
||
|
||
```
|
||
Offset Bytes Meaning
|
||
0 02 01 06 AD record 1: Flags (3 bytes)
|
||
3 11 07 ..16.. AD record 2: Complete 128-bit Service UUID (18 bytes)
|
||
21 09 FF FF FF
|
||
DD 01
|
||
<epoch_tag LE:2>
|
||
<change_tag LE:2> AD record 3: Manuf Specific Data (10 bytes)
|
||
CID = 0xFFFF (testing); magic = 0xDD; version = 0x01;
|
||
epoch_tag = (epoch & 0xFFFF) as u16 LE;
|
||
change_tag = running u16 counter (see 5.4.4).
|
||
|
||
Total: 3 + 18 + 10 = 31 bytes exactly.
|
||
```
|
||
|
||
Service UUID on the air (little-endian): `01 00 4C 9C 1A D0 8E 8C 1B 4C 9E D0 00 01 40 6E`.
|
||
|
||
Device name "DongLoRa" is not in the adv (no room) but is exposed via the standard GAP Device Name characteristic (provided by SD automatically when we set `gap_device_name`).
|
||
|
||
#### 5.4.3 iOS background behavior — the honest claim
|
||
|
||
iOS Core Bluetooth background scans with `scanForPeripherals(withServices: [ourUUID])` deliver at most one advert per (device address, service UUID) per ~15-second coalescing window. Changing manuf data does NOT force re-delivery in background mode. What it DOES do: (a) foreground scans see every update; (b) when iOS does deliver (once per window), the manuf data is the latest value.
|
||
|
||
**Implication:** phones that want near-real-time updates must connect and subscribe to RX-Notify. Phones that merely want "a device is nearby and has data" get that at ~15 s resolution in background. Both are supported; the latency characteristics are now honestly documented.
|
||
|
||
#### 5.4.4 `AdvMgr` — complete type
|
||
|
||
```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<Connection, AdvertiseError> {
|
||
let conn = peripheral::advertise_connectable(
|
||
self.sd,
|
||
ConnectableAdvertisement::ScannableUndirected {
|
||
adv_data: &self.adv_data,
|
||
scan_data: &[],
|
||
},
|
||
&peripheral::Config {
|
||
interval: 320, // 320 × 0.625 ms = 200 ms — fast discovery, moderate battery
|
||
timeout: None, // advertise forever
|
||
primary_phy: peripheral::Phy::M1, // legacy adv is always 1M on primary channels
|
||
secondary_phy: peripheral::Phy::M1,
|
||
..Default::default()
|
||
},
|
||
).await?;
|
||
Ok(conn)
|
||
}
|
||
}
|
||
```
|
||
|
||
Pinned nrf-softdevice rev (see §5.12): `ConnectableAdvertisement::ScannableUndirected` is the correct variant at that rev — verified from crate source. `nrf-softdevice`'s `Config` struct field names (`interval`, `timeout`, `primary_phy`, `secondary_phy`) are public at that rev.
|
||
|
||
#### 5.4.5 Address and epoch rotation
|
||
|
||
```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<CriticalSectionRawMutex, ()>,
|
||
) {
|
||
// 1. Signal advertise_loop to drop its advertise_connectable() future,
|
||
// which stops the current adv in S140. (advertise_loop restarts
|
||
// automatically after rotation completes; see §5.4.6.)
|
||
adv_stop_signal.signal(());
|
||
// Give S140 a moment to process the adv-stop event before we touch address.
|
||
Timer::after_millis(10).await;
|
||
|
||
// 2. Disconnect all active connections (their address view is stale).
|
||
active_conns.disconnect_all(sd).await;
|
||
|
||
// 3. Generate new epoch.
|
||
let mut buf = [0u8; 8];
|
||
nrf_softdevice::random_bytes(sd, &mut buf).unwrap();
|
||
let new_epoch = u64::from_le_bytes(buf);
|
||
|
||
// 4. Generate new random private non-resolvable address.
|
||
// Per BLE spec: top 2 bits = 00 → non-resolvable random.
|
||
let mut addr = [0u8; 6];
|
||
nrf_softdevice::random_bytes(sd, &mut addr).unwrap();
|
||
addr[5] &= 0x3F; // clear top 2 bits → non-resolvable random
|
||
|
||
// 5. Clear ring; new epoch invalidates any client's cached state anyway.
|
||
ring.lock().await.set_epoch(new_epoch);
|
||
|
||
// 6. Update advertiser state. set_address() calls the SD raw API — must
|
||
// come after step 1 (adv stopped) and before step 7 (adv restart).
|
||
adv.set_address(addr);
|
||
adv.set_epoch(new_epoch);
|
||
|
||
// 7. advertise_loop resumes after adv_stop_signal is cleared; it will
|
||
// call advertise_once() with the new adv_data and the new address.
|
||
}
|
||
```
|
||
|
||
`nrf_softdevice::random_bytes` — the real API (verified in `nrf-softdevice/src/random.rs` via crate source).
|
||
|
||
Rotation interval: 15 min (const `ROTATE_PERIOD`). Ran from `rotate_timer` sibling task; acquires `AdvMgr` and `ConnTable` mutexes, performs rotation, releases.
|
||
|
||
#### 5.4.6 Advertiser outer loop
|
||
|
||
**Decoupling adv data from the adv future (fixes round-4 R2):** holding the `AdvMgr` mutex across the `advertise_once()` future would block reshape until a peer connects. Instead, `adv_data` lives in a **separate, small, lock-free double-buffered buffer**. Reshape updates the buffer and signals; advertise_loop snapshots the buffer at the start of each cycle and re-starts the cycle on a reshape signal (losing the current cycle's remaining airtime, ≤ 200 ms).
|
||
|
||
```rust
|
||
// src/ble/adv_buffer.rs
|
||
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
||
use embassy_sync::mutex::Mutex;
|
||
|
||
pub struct AdvBuffer {
|
||
inner: Mutex<CriticalSectionRawMutex, [u8; 31]>,
|
||
}
|
||
|
||
impl AdvBuffer {
|
||
pub const fn new() -> Self { Self { inner: Mutex::new([0; 31]) } }
|
||
|
||
pub async fn snapshot(&self) -> [u8; 31] {
|
||
*self.inner.lock().await
|
||
}
|
||
|
||
pub async fn update(&self, data: [u8; 31]) {
|
||
*self.inner.lock().await = data;
|
||
}
|
||
}
|
||
```
|
||
|
||
`AdvMgr` becomes a logical builder: it owns `change_tag`, epoch_tag, address, and the method `fn build_adv_bytes(&self) -> [u8; 31]`. Publishing is via `AdvBuffer::update(adv_mgr.build_adv_bytes()).await`. Neither `AdvMgr` nor `AdvBuffer` is held across the `advertise_once` future.
|
||
|
||
```rust
|
||
// Spawned once in main().
|
||
#[embassy_executor::task]
|
||
pub async fn advertise_loop(
|
||
sd: &'static Softdevice,
|
||
adv_buffer: &'static AdvBuffer,
|
||
reshape_signal: &'static Signal<CriticalSectionRawMutex, ()>,
|
||
adv_stop_signal: &'static Signal<CriticalSectionRawMutex, ()>,
|
||
conn_table: &'static Mutex<CriticalSectionRawMutex, ConnTable>,
|
||
conn_bundle: &'static HandleConnectionBundle,
|
||
spawner: Spawner,
|
||
) -> ! {
|
||
use embassy_futures::select::{select3, Either3};
|
||
loop {
|
||
let adv_data = adv_buffer.snapshot().await;
|
||
let adv_fut = peripheral::advertise_connectable(
|
||
sd,
|
||
ConnectableAdvertisement::ScannableUndirected {
|
||
adv_data: &adv_data,
|
||
scan_data: &[],
|
||
},
|
||
&peripheral::Config {
|
||
interval: 320,
|
||
timeout: None,
|
||
primary_phy: peripheral::Phy::M1,
|
||
secondary_phy: peripheral::Phy::M1,
|
||
..Default::default()
|
||
},
|
||
);
|
||
match select3(adv_fut, reshape_signal.wait(), adv_stop_signal.wait()).await {
|
||
Either3::First(Ok(c)) => {
|
||
match conn_table.lock().await.on_connect(&c) {
|
||
Some((slot, generation)) => {
|
||
spawner.must_spawn(handle_connection(
|
||
sd, c, slot, generation,
|
||
conn_bundle.server,
|
||
&conn_bundle.events[slot as usize],
|
||
conn_bundle.commands,
|
||
conn_bundle.ring,
|
||
conn_bundle.router,
|
||
conn_bundle.conn_table,
|
||
));
|
||
}
|
||
None => {
|
||
defmt::warn!("no conn slot available at connect; disconnecting");
|
||
let _ = c.disconnect();
|
||
}
|
||
}
|
||
}
|
||
Either3::First(Err(e)) => {
|
||
defmt::warn!("advertise error: {:?}", e);
|
||
Timer::after_secs(1).await;
|
||
}
|
||
Either3::Second(()) | Either3::Third(()) => {
|
||
// Either reshape or rotation: drop adv_fut (stops adv in SD),
|
||
// then loop to pick up new adv_data. For rotation, the stop
|
||
// signal stays asserted until rotate_identity finishes.
|
||
while adv_stop_signal.signaled() {
|
||
Timer::after_millis(50).await;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[embassy_executor::task]
|
||
pub async fn reshape_loop(
|
||
adv_mgr: &'static Mutex<CriticalSectionRawMutex, AdvMgr>,
|
||
adv_buffer: &'static AdvBuffer,
|
||
reshape_signal: &'static Signal<CriticalSectionRawMutex, ()>,
|
||
mut seq_rx: watch::Receiver<'static, CriticalSectionRawMutex, u32, 8>,
|
||
) -> ! {
|
||
use embassy_futures::select::select;
|
||
let mut last_reshape = embassy_time::Instant::now();
|
||
const DEBOUNCE_MS: u64 = 100;
|
||
const IDLE_MS: u64 = 1000;
|
||
|
||
loop {
|
||
// Wake on either a new seq or an idle-refresh timer.
|
||
// Idle refresh exists so phones that start scanning after a quiet
|
||
// period re-see us within a known bound (1 s). S140 keeps
|
||
// transmitting the current adv_data indefinitely even without
|
||
// reshape; the idle arm only matters for tightening worst-case
|
||
// discovery after network quiescence.
|
||
let _ = select(seq_rx.changed(), Timer::after_millis(IDLE_MS)).await;
|
||
|
||
let since = last_reshape.elapsed().as_millis();
|
||
if since < DEBOUNCE_MS as u128 {
|
||
Timer::after_millis(DEBOUNCE_MS - since as u64).await;
|
||
}
|
||
|
||
let new_bytes = {
|
||
let mut g = adv_mgr.lock().await;
|
||
g.bump_change_tag();
|
||
g.adv_data
|
||
};
|
||
adv_buffer.update(new_bytes).await;
|
||
reshape_signal.signal(());
|
||
last_reshape = embassy_time::Instant::now();
|
||
}
|
||
}
|
||
```
|
||
|
||
`HandleConnectionBundle` groups the `'static` refs so `advertise_loop` doesn't take 10 args:
|
||
|
||
```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<CriticalSectionRawMutex, ConnTable>,
|
||
}
|
||
```
|
||
|
||
One `ConnEventChannel` per slot keeps event routing clean. `main()` (§5.1.6) allocates:
|
||
|
||
```rust
|
||
static CONN_BUNDLE: StaticCell<HandleConnectionBundle> = StaticCell::new();
|
||
// ...
|
||
let bundle = CONN_BUNDLE.init(HandleConnectionBundle {
|
||
server,
|
||
events: [const { ConnEventChannel::new() }; MAX_CONNS],
|
||
commands, ring, router: &BLE_TX_ROUTER, conn_table: ct,
|
||
});
|
||
```
|
||
|
||
Then `advertise_loop(..., bundle, spawner)`.
|
||
|
||
**Worst-case reshape latency:** because advertise_loop drops the adv future on reshape signal, the new change_tag goes on-air on the next `advertise_once` call. The gap between signal → new adv PDU is one SD scheduling round-trip + `Config::interval` (~200 ms). Measured in bench tests against real phones (§5.14 Tier 2).
|
||
|
||
### 5.5 GATT service
|
||
|
||
#### 5.5.1 Characteristics
|
||
|
||
| # | Name | UUID suffix | Properties | Descriptors | Version |
|
||
| --- | --------- | ----------- | --------------------------- | ----------- | ------- |
|
||
| 1 | Sync | `…0101…` | Write, Notify | CCCD | v1+ |
|
||
| 2 | RX-Notify | `…0102…` | Notify | CCCD | v1+ |
|
||
| 3 | TX-Write | `…0103…` | Write, WriteWithoutResponse | — | v1+ |
|
||
| 4 | Status | `…0104…` | Read, Write, Notify | CCCD | v1+ |
|
||
| 5 | Peer-Sync | `…0105…` | Write, Notify | CCCD | **v3** |
|
||
|
||
Service UUID: `6E400100-D09E-4C1B-8C8E-D01A9C4C0001`.
|
||
|
||
In v1/v2 builds, characteristic 5 is absent from the service table. A v1 client scanning services finds only 1–4.
|
||
|
||
#### 5.5.2 `nrf-softdevice` macro server
|
||
|
||
```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<u8, 247>, // max request size = MTU-3
|
||
|
||
#[characteristic(uuid = "6E400102-D09E-4C1B-8C8E-D01A9C4C0001", notify)]
|
||
rx_notify: heapless::Vec<u8, 247>,
|
||
|
||
#[characteristic(uuid = "6E400103-D09E-4C1B-8C8E-D01A9C4C0001",
|
||
write, write_without_response)]
|
||
tx_write: heapless::Vec<u8, 247>,
|
||
|
||
#[characteristic(uuid = "6E400104-D09E-4C1B-8C8E-D01A9C4C0001",
|
||
read, write, notify)]
|
||
status: [u8; 128], // fixed-length; zero-padded to 128 always.
|
||
}
|
||
|
||
#[nrf_softdevice::gatt_server]
|
||
pub struct Server {
|
||
pub donglora: DongLoRaService,
|
||
}
|
||
```
|
||
|
||
**Status semantics (junior Q8 resolved):** fixed-length 128 bytes, zero-padded. Read returns exactly 128 bytes. Write expects first byte = opcode, remaining bytes = operands. Notifications from the Status char are variable-length (each notification is one event payload, not a status snapshot).
|
||
|
||
#### 5.5.3 `ConnTable`, `ConnSlot`, `BleTxRouter` — complete
|
||
|
||
```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<Instant>,
|
||
pub tx_armed_until: Option<Instant>,
|
||
pub bonded: bool,
|
||
}
|
||
|
||
pub struct ConnTable {
|
||
slots: [Option<ConnSlot>; MAX_CONNS],
|
||
generation_counter: u16,
|
||
}
|
||
|
||
impl ConnTable {
|
||
pub const fn new() -> Self {
|
||
Self { slots: [const { None }; MAX_CONNS], generation_counter: 0 }
|
||
}
|
||
|
||
/// Returns (slot_index, generation) on success.
|
||
pub fn on_connect(&mut self, conn: &Connection) -> Option<(u8, u16)> {
|
||
for (i, s) in self.slots.iter_mut().enumerate() {
|
||
if s.is_none() {
|
||
let generation = self.generation_counter.wrapping_add(1);
|
||
self.generation_counter = generation;
|
||
*s = Some(ConnSlot {
|
||
conn: conn.clone(),
|
||
generation,
|
||
tx_scratch: TxScratch::new(),
|
||
last_tx_at: None,
|
||
tx_armed_until: None,
|
||
bonded: false,
|
||
});
|
||
return Some((i as u8, generation));
|
||
}
|
||
}
|
||
None // all slots full
|
||
}
|
||
|
||
pub fn on_disconnect(&mut self, conn: &Connection) {
|
||
for s in self.slots.iter_mut() {
|
||
if let Some(slot) = s {
|
||
if slot.conn.handle() == conn.handle() {
|
||
*s = None;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn get_mut(&mut self, slot: u8) -> Option<&mut ConnSlot> {
|
||
self.slots.get_mut(slot as usize).and_then(|s| s.as_mut())
|
||
}
|
||
|
||
pub async fn disconnect_all(&mut self, _sd: &'static Softdevice) {
|
||
for s in self.slots.iter() {
|
||
if let Some(slot) = s {
|
||
// nrf-softdevice Connection::disconnect() drops the connection.
|
||
let _ = slot.conn.disconnect();
|
||
}
|
||
}
|
||
// wait for actual disconnect events via the BLE event loop
|
||
embassy_time::Timer::after_millis(100).await;
|
||
}
|
||
}
|
||
```
|
||
|
||
```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<CriticalSectionRawMutex, TxOutcome>,
|
||
// The generation that this pending TX was submitted under.
|
||
// When the signal fires, we check this matches the waiter's expected
|
||
// generation — if the client disconnected mid-TX, generation will
|
||
// have advanced; the outcome is discarded (waiter was dropped).
|
||
//
|
||
// We store this atomically alongside the signal for race-free check.
|
||
expected_generation: core::sync::atomic::AtomicU16,
|
||
}
|
||
|
||
impl BleTxRouter {
|
||
pub const fn new() -> Self {
|
||
Self {
|
||
slots: [const {
|
||
BleTxSlot {
|
||
signal: Signal::new(),
|
||
expected_generation: core::sync::atomic::AtomicU16::new(0),
|
||
}
|
||
}; MAX_CONNS],
|
||
}
|
||
}
|
||
|
||
/// Called by ble_task before submitting (Origin::Ble(slot, gen), cmd):
|
||
/// claims the pending-TX slot for this generation. Only one TX in flight
|
||
/// per connection slot — enforced by §5.9.5 rate limit.
|
||
pub fn begin(&self, slot: u8, generation: u16) {
|
||
self.slots[slot as usize].expected_generation.store(
|
||
generation,
|
||
core::sync::atomic::Ordering::SeqCst,
|
||
);
|
||
// Drain any stale signal from a prior generation.
|
||
let _ = self.slots[slot as usize].signal.try_take();
|
||
}
|
||
|
||
/// Called by radio_task to signal completion.
|
||
pub fn signal(&self, slot: u8, generation: u16, outcome: TxOutcome) {
|
||
let current = self.slots[slot as usize].expected_generation
|
||
.load(core::sync::atomic::Ordering::SeqCst);
|
||
if current != generation {
|
||
// Waiter dropped (client disconnected). Silently drop.
|
||
return;
|
||
}
|
||
self.slots[slot as usize].signal.signal(outcome);
|
||
}
|
||
|
||
/// Called by ble_task to await completion.
|
||
pub async fn wait(&self, slot: u8) -> TxOutcome {
|
||
self.slots[slot as usize].signal.wait().await
|
||
}
|
||
}
|
||
```
|
||
|
||
This solves the junior-raised slot-recycle response-misrouting (Q17): even if slot 0 is recycled during a pending TX, the generation check prevents delivery to the wrong client.
|
||
|
||
#### 5.5.4 Sync characteristic — request / response (unchanged from v0.2 semantics)
|
||
|
||
Request (bytes written to Sync handle):
|
||
|
||
```
|
||
Offset Field Type Notes
|
||
0 op u8 0x01 SyncSince | 0x02 SyncAll | 0x03 SyncOldest | 0x04 BloomSync (v2)
|
||
1..N depends on op; see Appendix A.2.
|
||
```
|
||
|
||
Response (notifications on Sync handle; opcode first byte):
|
||
|
||
```
|
||
0x10 Packet: op + epoch:u64 + seq:u32 + [rssi:i16 + snr:i16 + len:u16] + payload
|
||
0x11 PktCont: op + epoch:u64 + seq:u32 + offset:u16 + chunk
|
||
0xFE SyncEnd: op + epoch:u64 + latest_seq:u32 + oldest_seq:u32 + count_sent:u16 + flags:u8
|
||
0xFF Error: op + code:u8 + detail:[u8]
|
||
```
|
||
|
||
Fragmentation: when packet > MTU-15 (with metadata), split into 0x10 + one or more 0x11. Phone reassembles by `(epoch, seq)`.
|
||
|
||
Length tolerance: requests shorter than required return `Error(Malformed, code 0x02)`; extra bytes ignored.
|
||
|
||
#### 5.5.5 RX-Notify characteristic
|
||
|
||
Same 0x10/0x11 framing as Sync. Emitted whenever radio_task pushes and CCCD is enabled for a connection. Client contract: use either Sync-on-demand OR RX-Notify live, not both. No server-side dedup. Control op `0x11 RxMode` (wire-reserved, implementation deferred to v2) can explicitly arbitrate.
|
||
|
||
#### 5.5.6 TX-Write characteristic — chunked
|
||
|
||
Write body format:
|
||
|
||
```
|
||
byte 0: header
|
||
bit 7 first | bit 6 last | bits 5..0 tx_id
|
||
|
||
if first==1:
|
||
bytes 1..3: total_len:u16 LE
|
||
bytes 3..n: initial chunk
|
||
else:
|
||
bytes 1..n: continuation chunk
|
||
```
|
||
|
||
```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<Instant>,
|
||
}
|
||
|
||
#[derive(PartialEq)]
|
||
enum State { Idle, Collecting }
|
||
|
||
#[derive(Debug)]
|
||
pub enum Malformed {
|
||
ShortHeader, ShortFirst, Oversize, Framing,
|
||
}
|
||
|
||
pub struct Ready {
|
||
pub tx_id: u8,
|
||
pub bytes: usize,
|
||
}
|
||
|
||
impl TxScratch {
|
||
pub const fn new() -> Self {
|
||
Self {
|
||
state: State::Idle, tx_id: 0, total_len: 0,
|
||
bytes_received: 0, buf: [0; MAX_PAYLOAD],
|
||
started_at: None,
|
||
}
|
||
}
|
||
|
||
pub fn on_write(&mut self, body: &[u8]) -> Result<Option<Ready>, Malformed> {
|
||
let header = *body.first().ok_or(Malformed::ShortHeader)?;
|
||
let first = header & 0x80 != 0;
|
||
let last = header & 0x40 != 0;
|
||
let tx_id = header & 0x3F;
|
||
|
||
if first {
|
||
if body.len() < 3 { return Err(Malformed::ShortFirst); }
|
||
let total_len = u16::from_le_bytes([body[1], body[2]]) as usize;
|
||
if total_len > MAX_PAYLOAD { return Err(Malformed::Oversize); }
|
||
let chunk = &body[3..];
|
||
if chunk.len() > MAX_PAYLOAD { return Err(Malformed::Framing); }
|
||
self.tx_id = tx_id;
|
||
self.total_len = total_len;
|
||
self.buf[..chunk.len()].copy_from_slice(chunk);
|
||
self.bytes_received = chunk.len();
|
||
self.state = State::Collecting;
|
||
self.started_at = Some(embassy_time::Instant::now());
|
||
} else {
|
||
if self.state != State::Collecting || tx_id != self.tx_id {
|
||
return Ok(None); // stray continuation; ignore silently.
|
||
}
|
||
let chunk = &body[1..];
|
||
if self.bytes_received + chunk.len() > MAX_PAYLOAD {
|
||
self.state = State::Idle;
|
||
return Err(Malformed::Framing);
|
||
}
|
||
self.buf[self.bytes_received..self.bytes_received + chunk.len()]
|
||
.copy_from_slice(chunk);
|
||
self.bytes_received += chunk.len();
|
||
}
|
||
|
||
if last {
|
||
if self.bytes_received != self.total_len {
|
||
self.state = State::Idle;
|
||
return Err(Malformed::Framing);
|
||
}
|
||
let ready = Ready { tx_id: self.tx_id, bytes: self.bytes_received };
|
||
self.state = State::Idle;
|
||
return Ok(Some(ready));
|
||
}
|
||
Ok(None)
|
||
}
|
||
|
||
pub fn idle_timeout_expired(&self, now: Instant) -> bool {
|
||
matches!(self.state, State::Collecting) &&
|
||
self.started_at
|
||
.map(|t| (now - t) >= embassy_time::Duration::from_secs(5))
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
pub fn reset(&mut self) {
|
||
self.state = State::Idle;
|
||
self.bytes_received = 0;
|
||
self.total_len = 0;
|
||
self.started_at = None;
|
||
}
|
||
|
||
pub fn payload(&self) -> &[u8] { &self.buf[..self.bytes_received] }
|
||
}
|
||
```
|
||
|
||
**GATT callback → async task bridge.** `nrf-softdevice`'s `gatt_server::run` takes a synchronous `FnMut(ServerEvent)` closure (no `.await` allowed inside). Async work — submitting to `CommandChannel`, awaiting `BLE_TX_ROUTER`, emitting Status notifications — runs in a dedicated per-connection `handle_connection` task driven by a `Channel` that the GATT callback hands events to.
|
||
|
||
Architecture per connection:
|
||
|
||
```rust
|
||
// src/ble/handle_connection.rs
|
||
use embassy_sync::channel::Channel;
|
||
|
||
pub enum ConnEvent {
|
||
TxWriteBody(heapless::Vec<u8, 247>),
|
||
SyncWriteBody(heapless::Vec<u8, 247>),
|
||
StatusWriteBody(heapless::Vec<u8, 247>),
|
||
Disconnected,
|
||
}
|
||
|
||
pub type ConnEventChannel = Channel<CriticalSectionRawMutex, ConnEvent, 8>;
|
||
|
||
#[embassy_executor::task(pool_size = MAX_CONNS)]
|
||
async fn handle_connection(
|
||
sd: &'static Softdevice,
|
||
conn: Connection,
|
||
slot: u8,
|
||
generation: u16,
|
||
server: &'static Server,
|
||
events: &'static ConnEventChannel,
|
||
commands: &'static CommandChannel,
|
||
ring: &'static RxRingMutex,
|
||
router: &'static BleTxRouter,
|
||
conn_table: &'static Mutex<CriticalSectionRawMutex, ConnTable>,
|
||
) {
|
||
use embassy_futures::select::{select, Either};
|
||
// Drive gatt_server::run concurrently with event processing.
|
||
let gatt_fut = gatt_server::run(&conn, server, |event| match event {
|
||
ServerEvent::Donglora(DongLoRaServiceEvent::TxWriteWrite(bytes)) => {
|
||
// Sync callback; push to channel.
|
||
let vec = heapless::Vec::from_slice(&bytes).unwrap_or_default();
|
||
let _ = events.try_send(ConnEvent::TxWriteBody(vec));
|
||
}
|
||
ServerEvent::Donglora(DongLoRaServiceEvent::SyncWrite(bytes)) => {
|
||
let vec = heapless::Vec::from_slice(&bytes).unwrap_or_default();
|
||
let _ = events.try_send(ConnEvent::SyncWriteBody(vec));
|
||
}
|
||
ServerEvent::Donglora(DongLoRaServiceEvent::StatusWrite(bytes)) => {
|
||
let vec = heapless::Vec::from_slice(&bytes).unwrap_or_default();
|
||
let _ = events.try_send(ConnEvent::StatusWriteBody(vec));
|
||
}
|
||
_ => {} // CCCD changes handled internally by gatt_server
|
||
});
|
||
let processor_fut = process_events(events, &conn, slot, generation, server,
|
||
commands, ring, router, conn_table);
|
||
|
||
match select(gatt_fut, processor_fut).await {
|
||
Either::First(Err(_)) | Either::First(Ok(_)) => {
|
||
// Connection ended. Clean up slot state.
|
||
let _ = events.try_send(ConnEvent::Disconnected);
|
||
}
|
||
Either::Second(()) => { /* processor exited */ }
|
||
}
|
||
conn_table.lock().await.on_disconnect(&conn);
|
||
}
|
||
|
||
async fn process_events(
|
||
events: &'static ConnEventChannel,
|
||
conn: &Connection,
|
||
slot: u8,
|
||
generation: u16,
|
||
server: &Server,
|
||
commands: &'static CommandChannel,
|
||
ring: &'static RxRingMutex,
|
||
router: &'static BleTxRouter,
|
||
conn_table: &'static Mutex<CriticalSectionRawMutex, ConnTable>,
|
||
) {
|
||
loop {
|
||
match events.receive().await {
|
||
ConnEvent::TxWriteBody(body) => {
|
||
handle_tx_write(slot, generation, server, conn, conn_table,
|
||
commands, router, &body).await;
|
||
}
|
||
ConnEvent::SyncWriteBody(body) => {
|
||
handle_sync_request(slot, server, conn, ring, &body).await;
|
||
}
|
||
ConnEvent::StatusWriteBody(body) => {
|
||
handle_status_control(slot, server, conn, conn_table, &body).await;
|
||
}
|
||
ConnEvent::Disconnected => return,
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn handle_tx_write(
|
||
slot_idx: u8, generation: u16,
|
||
server: &Server, conn: &Connection,
|
||
conn_table: &'static Mutex<CriticalSectionRawMutex, ConnTable>,
|
||
commands: &'static CommandChannel,
|
||
router: &'static BleTxRouter,
|
||
body: &[u8],
|
||
) {
|
||
// Decision is made under the conn_table lock, but the lock is released
|
||
// before any GATT notification .await (round-4 R3 rule: no locks held
|
||
// across notify.await, same principle as RxRing §5.7.2).
|
||
enum Decision {
|
||
AccessDenied,
|
||
RateLimited,
|
||
Malformed(u8 /* tx_id */, u8 /* code */),
|
||
Mid,
|
||
Ready(u8 /* tx_id */, heapless::Vec<u8, MAX_PAYLOAD>),
|
||
}
|
||
|
||
let decision: Decision = {
|
||
let mut ct = conn_table.lock().await;
|
||
let Some(slot) = ct.get_mut(slot_idx) else {
|
||
return; // slot vanished (disconnect raced)
|
||
};
|
||
if !tx_allowed(slot) {
|
||
Decision::AccessDenied
|
||
} else {
|
||
let now = Instant::now();
|
||
let rate_ok = slot.last_tx_at
|
||
.map(|t| now - t >= Duration::from_millis(500))
|
||
.unwrap_or(true);
|
||
if !rate_ok {
|
||
Decision::RateLimited
|
||
} else {
|
||
match slot.tx_scratch.on_write(body) {
|
||
Err(m) => Decision::Malformed(slot.tx_scratch.tx_id, malformed_to_code(m)),
|
||
Ok(None) => Decision::Mid,
|
||
Ok(Some(r)) => {
|
||
slot.last_tx_at = Some(now);
|
||
let payload = heapless::Vec::from_slice(slot.tx_scratch.payload())
|
||
.expect("payload <= MAX_PAYLOAD by TxScratch invariant");
|
||
Decision::Ready(r.tx_id, payload)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}; // <- conn_table mutex released BEFORE any .await below.
|
||
|
||
match decision {
|
||
Decision::AccessDenied => {
|
||
notify_status_tx_error(server, conn, 0, BleTxErrorCode::AccessDenied).await;
|
||
return;
|
||
}
|
||
Decision::RateLimited => {
|
||
notify_status_tx_error(server, conn, 0, BleTxErrorCode::RateLimited).await;
|
||
return;
|
||
}
|
||
Decision::Malformed(tx_id, code) => {
|
||
notify_status_tx_error(server, conn, tx_id, code).await;
|
||
return;
|
||
}
|
||
Decision::Mid => return,
|
||
Decision::Ready(tx_id, payload) => {
|
||
handle_tx_ready(slot_idx, generation, tx_id, payload,
|
||
server, conn, commands, router).await;
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn handle_tx_ready(
|
||
slot_idx: u8, generation: u16, tx_id: u8,
|
||
payload: heapless::Vec<u8, MAX_PAYLOAD>,
|
||
server: &Server, conn: &Connection,
|
||
commands: &'static CommandChannel,
|
||
router: &'static BleTxRouter,
|
||
) {
|
||
notify_status_tx_accepted(server, conn, tx_id).await;
|
||
|
||
// Route via the CommandChannel — blocking is OK (not holding any locks).
|
||
router.begin(slot_idx, generation);
|
||
commands.send((Origin::Ble(slot_idx, generation),
|
||
Command::Transmit { config: None, payload })).await;
|
||
|
||
// Wait for radio_task to fire BleTxOutcome via router.signal().
|
||
let outcome = router.wait(slot_idx).await;
|
||
match outcome {
|
||
TxOutcome::Done => notify_status_tx_done(server, conn, tx_id).await,
|
||
TxOutcome::Error(code) => notify_status_tx_error(server, conn, tx_id, code).await,
|
||
};
|
||
}
|
||
```
|
||
|
||
`notify_status_tx_*` functions call `server.donglora.status_notify(conn, &bytes)` with the opcoded payload (§A.5). These are async because `notify()` awaits ATT tx-complete.
|
||
|
||
**This addresses round-3 B4:** the GATT-callback-sync-vs-async-work tension is resolved by the events-channel bridge. The sync closure is minimal (enqueue a Vec); all async work happens in `process_events`.
|
||
|
||
#### 5.5.7 Status characteristic
|
||
|
||
Reads return 128 bytes per §A.4. Writes of ≥ 1 byte dispatch by first-byte opcode (§A.5). Notifications carry event payloads.
|
||
|
||
### 5.6 USB coexistence
|
||
|
||
Current `src/host/usb.rs:129-131`:
|
||
|
||
```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<CriticalSectionRawMutex, Option<Instant>>,
|
||
}
|
||
|
||
impl ArmButton {
|
||
pub fn record_press(&self) {
|
||
// Called from GPIO interrupt handler (board-specific).
|
||
if let Ok(mut g) = self.last_press.try_lock() {
|
||
*g = Some(Instant::now());
|
||
}
|
||
}
|
||
|
||
pub async fn was_pressed_recently(&self, window_s: u64) -> bool {
|
||
if let Some(t) = *self.last_press.lock().await {
|
||
t.elapsed() < Duration::from_secs(window_s)
|
||
} else { false }
|
||
}
|
||
}
|
||
```
|
||
|
||
RAK 4631: `tx_arm_button() -> None`. Wio Tracker L1: returns `Some(&WIO_USER_BTN)` wired to its user button GPIO (verify board pinout at implementation time).
|
||
|
||
#### 5.9.3 Mode C (USB admin)
|
||
|
||
USB command added: `Command::EnableBleTx(duration_s: u16)`. Host task applies it to a global `BLE_TX_WINDOW: Signal<(Instant, Duration)>` watched by ble_task; while active, all connected slots are authorized.
|
||
|
||
Wire addition to existing protocol:
|
||
|
||
- New command opcode at the end of the enum (back-compat; older hosts don't send it).
|
||
|
||
#### 5.9.4 Duty-cycle enforcement
|
||
|
||
```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<Aes256Gcm>,
|
||
addr: u32, // 0x000E8000 (bonding) or 0x000E6000 (identity, v3)
|
||
len: u32, // 0x2000 (8 KB)
|
||
}
|
||
|
||
impl EncryptedFlash {
|
||
pub fn new(flash: nrf_softdevice::Flash, addr: u32, len: u32) -> Self {
|
||
// FICR.DEVICEID is 64 bits of factory-unique data.
|
||
let device_id = unsafe {
|
||
let ficr = &*nrf52840_pac::FICR::ptr();
|
||
[ficr.deviceid[0].read().bits(), ficr.deviceid[1].read().bits()]
|
||
};
|
||
let mut ikm = [0u8; 8];
|
||
ikm[0..4].copy_from_slice(&device_id[0].to_le_bytes());
|
||
ikm[4..8].copy_from_slice(&device_id[1].to_le_bytes());
|
||
let hk = Hkdf::<Sha256>::new(None, &ikm);
|
||
let mut okm = [0u8; 32];
|
||
hk.expand(b"donglora-bond-v1", &mut okm)
|
||
.expect("HKDF expand 32 bytes OK");
|
||
Self { flash, key: okm.into(), addr, len }
|
||
}
|
||
|
||
pub async fn read_blob(&mut self) -> Option<heapless::Vec<u8, 8192>> {
|
||
let mut raw = [0u8; 8192];
|
||
self.flash.read(self.addr, &mut raw[..self.len as usize]).await.ok()?;
|
||
// Layout: [nonce:12][ciphertext:n][tag:16] else all-FF = empty.
|
||
if raw[0..12] == [0xFF; 12] { return None; }
|
||
let nonce = aes_gcm::Nonce::from_slice(&raw[0..12]);
|
||
let cipher = Aes256Gcm::new(&self.key);
|
||
let decrypted = cipher.decrypt(nonce, &raw[12..self.len as usize]).ok()?;
|
||
heapless::Vec::from_slice(&decrypted).ok()
|
||
}
|
||
|
||
pub async fn write_blob(&mut self, data: &[u8]) -> Result<(), ()> {
|
||
use rand_core::RngCore;
|
||
let mut nonce_bytes = [0u8; 12];
|
||
// Use SD's RNG — passed into this struct in production code; elided here.
|
||
// For brevity: assume nonce_bytes is populated via nrf_softdevice::random_bytes.
|
||
let nonce = aes_gcm::Nonce::from_slice(&nonce_bytes);
|
||
let cipher = Aes256Gcm::new(&self.key);
|
||
let ct = cipher.encrypt(nonce, data).map_err(|_| ())?;
|
||
let mut frame = [0u8; 8192];
|
||
frame[0..12].copy_from_slice(&nonce_bytes);
|
||
frame[12..12 + ct.len()].copy_from_slice(&ct);
|
||
self.flash.erase(self.addr).await.map_err(|_| ())?;
|
||
self.flash.write(self.addr, &frame[..12 + ct.len()]).await.map_err(|_| ())
|
||
}
|
||
}
|
||
```
|
||
|
||
- Add `ble_bonding` feature deps: `aes-gcm = { version = "0.10", optional = true, default-features = false }`, `hkdf = { version = "0.12", optional = true, default-features = false }`, `sha2 = { version = "0.10", optional = true, default-features = false }`.
|
||
- `nrf52840_pac = { version = "0.13", optional = true }` (needed for `FICR.DEVICEID` access; already transitively pulled by `embassy-nrf`).
|
||
- Write wear: identity written once (first boot); bonds written once per new peer. Flash endurance (10,000 cycles per page) is not a concern.
|
||
|
||
APPROTECT:
|
||
|
||
- Engaged on first boot if `FICR.INFO.VARIANT` indicates nRF52840 QIAAx C-rev or later (pre-C has voltage-glitching bypass). Build script compiles an assertion.
|
||
- If silicon is older: bonding feature refuses to compile; user warned at build.
|
||
|
||
Bond revocation: Status ops `0x46 BondList`, `0x48 BondRevoke(index)` — bonded-only.
|
||
|
||
### 6.2 Bloom-filter sync (oracle-hardened)
|
||
|
||
**Motivation:** Phone that moves across dongles wants "give me what I don't have" agnostic of source.
|
||
|
||
**Oracle mitigation:**
|
||
|
||
1. Per-session salt `s: u32` (random from SD per connection, delivered in first response as `0xFE SyncEnd.salt_hint` during the preceding request, or in a pre-sync `RequestSalt` op).
|
||
2. Key = `blake2s(s || payload)` truncated to 64 bits. Server hashes ring entries the same way.
|
||
3. Inject 2% random "false hits": with prob 0.02, server returns a packet that the filter _did_ include.
|
||
4. Rate-limit `BloomSync` to 1 / 10 s per connection.
|
||
|
||
**Params (corrected arithmetic):**
|
||
|
||
- `k=4, m_log2=10` (128 B filter): FPR at 128 packets ≈ 2.4% (from `(1 - e^{-kn/m})^k`).
|
||
- For FPR ≤ 1% at 128 packets: `m_log2=11` (256 B filter).
|
||
|
||
**Crates:** `blake2` 0.10 no_std.
|
||
|
||
### 6.3 Fat ext-adv (optional)
|
||
|
||
`ble_fat_adv` feature. Single extended adv set carrying up to 218 B of payload + metadata. BLE5. 2M PHY secondary. Non-connectable. Single-shot per new RX.
|
||
|
||
Security caveat: expands LoRa-layer confidentiality from ~2 km (LoRa RF range) to ~2 km OR ~30 m (BLE). For Meshtastic (AEAD'd), no leak. For plaintext protocols, this is a regression.
|
||
|
||
Off by default.
|
||
|
||
### 6.4 ESP32-S3
|
||
|
||
`esp32-nimble` based. Wire protocol identical. ESP32-S3 builds use `heltec_v3_ble`, `heltec_v4_ble`, `elecrow_thinknode_m2_ble` features.
|
||
|
||
### 6.5 Persistent identity (revised)
|
||
|
||
v0.1/v0.2's persistent epoch as visible identity was an AirTag-class tracking primitive. Dropped.
|
||
|
||
Instead: **persistent identity via IRK, visible only to bonded peers.** Unbonded observers see rotating non-resolvable addresses + rotating epochs. Bonded phones resolve via IRK to a stable identity for UX continuity.
|
||
|
||
### 6.6 Fairness and rate limiting
|
||
|
||
Per-connection TX queue (depth 2) in front of the single `CommandChannel`. `tx_scheduler_task` round-robins:
|
||
|
||
```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<Observation, 4>,
|
||
pub len: u16,
|
||
pub payload: [u8; MAX_PAYLOAD],
|
||
pub first_seen_at: Instant,
|
||
}
|
||
// size: 8 + (4 × 90) + 2 + 256 + 8 = 634 → aligned to 636.
|
||
|
||
#[derive(Clone)]
|
||
pub struct Observation {
|
||
pub observer_pk_hash: [u8; 8], // first 8 bytes of observer's pk-blake2s
|
||
pub observer_epoch: u64,
|
||
pub observer_seq: u32,
|
||
pub rssi: i16,
|
||
pub snr: i16,
|
||
pub hop_count: u8,
|
||
pub signature: [u8; 64], // ed25519 sig over (id, epoch, seq, rssi, snr, len)
|
||
}
|
||
// size: 8 + 8 + 4 + 2 + 2 + 1 + 64 = 89 → aligned to 92.
|
||
```
|
||
|
||
Max 4 observations per packet. 4 × 92 = 368 B of observation metadata. Per-packet size ~636 B aligned.
|
||
|
||
### 7.4 CmRDT framing
|
||
|
||
**Correct structure:** op-based Commutative Replicated Data Type.
|
||
|
||
- Ops: `Insert(packet)` idempotent under content key `packet.id`.
|
||
- Merge of observations: set-union by `observer_pk_hash`. Max 4 observations kept; eviction policy = oldest observation by `(observer_epoch, observer_seq)`.
|
||
- `hop_count` merge: min (shortest known path wins).
|
||
- Eviction of ring entries is local resource management, not a CRDT op.
|
||
|
||
**Convergence statement:** _For packet P, if P is in both A.ring and B.ring after anti-entropy completes, A's stored representation of P equals B's._ No claim about ring equality as sets (each side may have evicted different P's).
|
||
|
||
### 7.5 Anti-entropy protocol
|
||
|
||
Discovery: v3 dongles scan for the DongLoRa service UUID. When spotted, initiate if `my.dongle_id < peer.dongle_id`.
|
||
|
||
Role arbitration: smaller `dongle_id` initiates. Always-accept on the peripheral side (up to capacity).
|
||
|
||
Cooldown: per-peer `last_sync_at` in a `heapless::Vec<PeerCooldown, 32>` with LRU eviction. Per-peer cooldown default 60 s. Global rate limit: 10 peer-syncs/minute (token bucket) regardless of peer identity — Sybil mitigation.
|
||
|
||
Failure detector: exponential backoff on failed connects, TTL-based pruning of long-silent peers (drop after `10 × sync_cooldown`).
|
||
|
||
#### 7.5.1 Version vector exchange
|
||
|
||
`V: PeerId → (epoch, latest_seq)`, capacity 32 entries with **round-robin rotation on truncation** — each sync advances which 32 of known-N origins are advertised. Ensures any origin is eventually offered to any peer.
|
||
|
||
Entries GC'd when peer's packets are fully evicted from our ring.
|
||
|
||
#### 7.5.2 Peer-Sync characteristic protocol
|
||
|
||
Characteristic UUID: `6E400105-D09E-4C1B-8C8E-D01A9C4C0001`.
|
||
|
||
Request op codes (scoped to Peer-Sync characteristic — opcode 0x60 here is distinct from 0x60 on Status):
|
||
|
||
```
|
||
0x60 PeerHello { pk:32 + signed_nonce_32:64 } (1st step)
|
||
0x61 PeerVectorExchange { vector_entries:var } (2nd step, chunked if > MTU)
|
||
0x62 PeerPacketRequest { range_entries:var } (3rd step, chunked)
|
||
```
|
||
|
||
Request fragmentation follows TX-Write's `first/last/id` scheme — opcodes 0x61 and 0x62 support chunking with a 1-byte header + payload (same pattern as TX-Write §5.5.6).
|
||
|
||
Response (notifications on Peer-Sync):
|
||
|
||
```
|
||
0x63 PeerHelloReply { pk:32 + signed_reply:64 }
|
||
0x64 PeerVectorReply { vector_entries:var } (chunked)
|
||
0x65 PeerPacketBatch { observations+payload:var } (chunked)
|
||
0x6F PeerSyncEnd { count_sent:u16, flags:u8, next_expected:u32 }
|
||
flags bit 0: gap — some requested were evicted before stream
|
||
flags bit 1: truncated — hit quota before end
|
||
0xFE Error { code:u8 }
|
||
```
|
||
|
||
Response is streamed over GATT notifications with MTU-aware chunking.
|
||
|
||
Packet signature: each observation is signed by its original observer. Our peer forwards without re-signing. Receivers verify before insert.
|
||
|
||
### 7.6 Ring partitioning
|
||
|
||
v3 ring: **64 total slots**, partitioned:
|
||
|
||
- 32 slots: locally-observed (our own observations, hop_count == 0).
|
||
- 24 slots: trusted peers' observations (AddTrustedPeer whitelist).
|
||
- 8 slots: untrusted peers' observations (open mesh; zero in closed mesh).
|
||
|
||
Eviction FIFO within each partition. Insert obeys quota.
|
||
|
||
Total: 64 × 636 B = 41 KB. Fits in memory budget (§5.10).
|
||
|
||
(v0.2's "64 slots" was correct; §7.6's "128 slots" was the contradiction — fixed here to 64 consistently.)
|
||
|
||
### 7.7 Security model
|
||
|
||
Trust boundary: **signatures on observations, not on payloads.**
|
||
|
||
Threats:
|
||
|
||
- **Passive BLE observer**: rotating addresses; nudge leaks only rotating `change_tag`. Mitigated by §4.2 + §5.4.5.
|
||
- **Active BLE attacker (unbonded)**: Sync is readable; TX gated (§5.9); Peer-Sync requires signed `PeerHello`.
|
||
- **Rogue peer dongle**: limited to:
|
||
- Injecting _its own signed_ observations (can't forge other origins).
|
||
- Filling 8/64 slots in open mesh, 0/64 in closed mesh.
|
||
- Triggering peer-sync on us (rate-limited global + per-peer).
|
||
|
||
**LoRa payload authenticity is not firmware's job.** Firmware authenticates _observations_ (who claims to have heard what). Phone-layer protocol (e.g., Meshtastic AES-CCM) authenticates _payload content_. Non-Meshtastic users without payload auth accept that rogue peers can inject valid-looking packets — documented in the user-facing README.
|
||
|
||
### 7.8 Epoch width
|
||
|
||
`u64`. Collision probability negligible across expected manufacturer lifetime.
|
||
|
||
### 7.9 Power budget
|
||
|
||
Per peer-sync (realistic): ~1.5 s @ 20 mA = 0.008 mAh.
|
||
Continuous scanning at 10% duty @ 5 mA: 12 mAh / day (non-trivial on coin cell).
|
||
Dense mesh (10 peers, 60 s cooldown): ~5 mAh / hour.
|
||
|
||
Battery deployments: `Status` op `0x60 LowPowerMode(enable:u8)` (bonded-only) — disables peer scanning and sync. Dongle still serves phones.
|
||
|
||
### 7.10 v2 → v3 compatibility
|
||
|
||
- v3 adds Peer-Sync characteristic; v2 clients ignore.
|
||
- v3 Status adds `dongle_id` + peer counters at known byte offsets (Appendix A.4).
|
||
- v2 clients never see Observation-rich frames (those are on Peer-Sync only); they see the v2-shaped Packet frame on Sync and RX-Notify, built by stripping observation metadata server-side.
|
||
|
||
---
|
||
|
||
## 8. Testing, rollout, metrics
|
||
|
||
### 8.1 Test matrix
|
||
|
||
See §5.14 tiers.
|
||
|
||
### 8.2 Metrics
|
||
|
||
Status schema (§A.4) exposes counters. Forward-compat: v1 reads first 128 bytes and ignores fields it doesn't know (all zero-padded).
|
||
|
||
### 8.3 Rollout
|
||
|
||
- v1 beta: 1–2 dongles, developer phones, EU + US regulatory test.
|
||
- v1 GA: opt-in `_ble` board variants.
|
||
- v2 GA: silicon revision gate on APPROTECT.
|
||
- v3 beta: multi-dongle coordination; Sybil stress; battery monitoring.
|
||
|
||
### 8.4 Versioning
|
||
|
||
`protocol_version`: 0x01 v1, 0x02 v2, 0x03 v3. Feature-detect via `supported_features` bitfield.
|
||
|
||
---
|
||
|
||
## Appendix A — Wire formats
|
||
|
||
### A.1 Advertising
|
||
|
||
Exactly 31 bytes, described byte-for-byte in §5.4.2.
|
||
|
||
Service UUID on the air (LE): `01 00 4C 9C 1A D0 8E 8C 1B 4C 9E D0 00 01 40 6E`.
|
||
|
||
### A.2 Sync characteristic
|
||
|
||
Requests (written to Sync char):
|
||
|
||
| Op | Total length | Fields |
|
||
| ------------------- | ------------ | --------------------------------------------------------------- |
|
||
| 0x01 SyncSince | 16 | `op + epoch:u64 + since_seq:u32 + max_count:u16 + flags:u8` |
|
||
| 0x02 SyncAll | 4 | `op + max_count:u16 + flags:u8` |
|
||
| 0x03 SyncOldest | 4 | `op + count:u16 + flags:u8` |
|
||
| 0x04 BloomSync (v2) | 7 + bloom | `op + k:u8 + m_log2:u8 + max_count:u16 + flags:u8 + bloom:[u8]` |
|
||
|
||
Responses (notifications):
|
||
|
||
| Op | Fields |
|
||
| --------------- | ------------------------------------------------------------------------------ |
|
||
| 0x10 Packet | `op + epoch:u64 + seq:u32 + [rssi:i16 + snr:i16 + len:u16] + payload` |
|
||
| 0x11 PacketCont | `op + epoch:u64 + seq:u32 + offset:u16 + chunk` |
|
||
| 0xFE SyncEnd | `op + epoch:u64 + latest_seq:u32 + oldest_seq:u32 + count_sent:u16 + flags:u8` |
|
||
| 0xFF Error | `op + code:u8 + detail:[u8]` |
|
||
|
||
Flags (request): bit 0 = include_metadata; bit 1 = payload_only.
|
||
Flags (SyncEnd): bit 0 = gap; bit 1 = truncated; bit 2 = epoch_mismatch.
|
||
|
||
Error codes: 0x01 UnknownOp, 0x02 Malformed, 0x03 FilterTooLarge, 0x04 Busy.
|
||
|
||
**Opcode namespace is per-characteristic.** 0x10 on Sync (Packet) is unrelated to any 0x10 on Status or Peer-Sync.
|
||
|
||
### A.3 TX-Write
|
||
|
||
See §5.5.6.
|
||
|
||
### A.4 Status characteristic — Read value (fixed 128 bytes, zero-padded)
|
||
|
||
```
|
||
Offset Field Bytes Version
|
||
0 protocol_version 1 v1: 0x01, v2: 0x02, v3: 0x03
|
||
1 fw_ver_major 1 v1+
|
||
2 fw_ver_minor 1 v1+
|
||
3 fw_ver_patch 1 v1+
|
||
4 board_id 1 v1+ (enum in §A.4.1)
|
||
5 epoch 8 v1+
|
||
13 latest_seq 4 v1+
|
||
17 oldest_seq 4 v1+
|
||
21 ring_capacity 2 v1+ (128 for v1/v2, 64 for v3)
|
||
23 ring_occupancy 2 v1+
|
||
25 rx_total 4 v1+
|
||
29 rx_dropped_ring_evict 4 v1+
|
||
33 rx_dropped_host_chan 4 v1+
|
||
37 tx_total_host 4 v1+
|
||
41 tx_total_ble 4 v1+
|
||
45 tx_errors_host 4 v1+
|
||
49 tx_errors_ble 4 v1+
|
||
53 ble_conns_peak 1 v1+
|
||
54 active_connections 1 v1+
|
||
55 radio_state 1 v1+
|
||
56 duty_cycle_used_ppm_hour 4 v1+
|
||
60 mac_address 6 v1+
|
||
66 supported_features 2 v1+
|
||
68 ble_disconnects_total 2 v2+
|
||
70 ble_att_errors 2 v2+
|
||
72 sync_requests_total 4 v2+
|
||
76 sync_packets_sent_total 4 v2+
|
||
80 peer_syncs_initiated 2 v3
|
||
82 peer_syncs_accepted 2 v3
|
||
84 dongle_id 8 v3
|
||
92 softdevice_fault_count 1 v1+
|
||
93 last_panic_count 1 v1+
|
||
94–127 reserved (0)
|
||
Total: 128 bytes.
|
||
```
|
||
|
||
`supported_features` bits:
|
||
|
||
- 0 BloomSync | 1 FatAdv | 2 Bonding | 3 IrkBasedIdentity
|
||
- 4 RateLimiting | 5 DutyCycleEnforcement
|
||
- 6 PeerMeshV3 | 7 CryptographicPeerAuth
|
||
|
||
#### A.4.1 `board_id` enum
|
||
|
||
```
|
||
0 unknown
|
||
1 rak_wisblock_4631
|
||
2 wio_tracker_l1
|
||
3 heltec_v3_usb
|
||
4 heltec_v3_uart
|
||
5 heltec_v4
|
||
6 elecrow_thinknode_m2
|
||
```
|
||
|
||
### A.5 Status control ops
|
||
|
||
Scoped to Status characteristic. Opcode scoping: `0x10` here is unrelated to `0x10` on Sync or Peer-Sync.
|
||
|
||
| Op | Direction | Fields | Access |
|
||
| ---------------------- | -------------------------- | -------------------- | ---------------- |
|
||
| 0x10 TxAccepted | notify | `tx_id:u8` | — |
|
||
| 0x11 RxMode (reserved) | write | `mode:u8` | — (v2+) |
|
||
| 0x20 Ping | write → 0x21 Pong | `nonce:u32` | open |
|
||
| 0x22 RequestMtuInfo | write → 0x23 MtuInfo | — → `att_mtu:u16` | open |
|
||
| 0x24 TxDone | notify | `tx_id:u8` | — |
|
||
| 0x25 TxError | notify | `tx_id:u8 + code:u8` | — |
|
||
| 0x26 ReadConfig | write → 0x27 ConfigBlob | — → `config:[13]` | open (read-only) |
|
||
| 0x40 ButtonArmTx | write → 0x41 Armed \| 0x25 | — | Mode B only (v1) |
|
||
| 0x42 SetTxRateLimit | write → 0x43 Ack | `ms:u16` | bonded (v2+) |
|
||
| 0x44 ResetMetrics | write → 0x45 Ack | — | bonded (v2+) |
|
||
| 0x46 BondList | write → 0x47 List | — → list | bonded (v2+) |
|
||
| 0x48 BondRevoke | write → 0x49 Ack | `index:u8` | bonded (v2+) |
|
||
| 0x50 AddTrustedPeer | write → 0x51 Ack | `pk_hash:[u8;8]` | bonded (v3) |
|
||
| 0x52 RemoveTrustedPeer | write → 0x53 Ack | `pk_hash:[u8;8]` | bonded (v3) |
|
||
| 0x60 LowPowerMode | write → 0x61 Ack | `enable:u8` | bonded (v3) |
|
||
|
||
BLE TxError codes (in `0x25 TxError`'s `code` byte):
|
||
|
||
```
|
||
0x01 Framing
|
||
0x02 Oversize
|
||
0x03 Timeout
|
||
0x04 AccessDenied
|
||
0x05 RateLimited
|
||
0x06 DutyCycleExceeded
|
||
0x07 RadioBusy
|
||
0x08 NotArmed
|
||
0x09 ConfigInvalid
|
||
0x0A NotConfigured
|
||
0x10 TxTimeout
|
||
0x11 TxFailed
|
||
```
|
||
|
||
### A.6 Peer-Sync characteristic (v3)
|
||
|
||
See §7.5.2. Opcodes 0x60–0x6F scoped here.
|
||
|
||
---
|
||
|
||
## Appendix B — State machines
|
||
|
||
### B.1 TX-Write reassembler
|
||
|
||
Full Rust source in §5.5.6.
|
||
|
||
### B.2 Advertiser cadence
|
||
|
||
Full source in §5.4.6.
|
||
|
||
### B.3 Sync handler
|
||
|
||
Full source in §5.2.2.
|
||
|
||
### B.4 Identity rotation
|
||
|
||
Full source in §5.4.5.
|
||
|
||
---
|
||
|
||
## Appendix C — Justfile, blob provenance, linker
|
||
|
||
### C.1 Justfile
|
||
|
||
```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: `<pinned at release>`
|
||
|
||
`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: `<pinned>`
|
||
|
||
---
|
||
|
||
## Appendix D — Glossary and references
|
||
|
||
**Glossary**
|
||
|
||
- CCCD — Client Characteristic Configuration Descriptor
|
||
- CmRDT — Commutative Replicated Data Type (op-based)
|
||
- FICR — Factory Information Configuration Registers (nRF52840 factory-unique data block)
|
||
- IRK — Identity Resolving Key (BLE 4.2+ private address resolution)
|
||
- LL — Link Layer (BLE radio layer)
|
||
- LTK — Long Term Key
|
||
- MBR — Master Boot Record (Nordic's, loads SoftDevice)
|
||
- MTU — Maximum Transmission Unit (ATT = 247 max on S140)
|
||
- RPA — Resolvable Private Address
|
||
- S140 — Nordic SoftDevice S140 v7.3.0
|
||
- VLOC — Value Location (GATT characteristic storage: STACK or USER)
|
||
|
||
**References**
|
||
|
||
- Bluetooth Core 5.3 Vol 3 Parts C (GAP), G (GATT)
|
||
- Nordic S140 v7.3.0 SoftDevice Specification
|
||
- nrf-softdevice examples: https://github.com/embassy-rs/nrf-softdevice/tree/master/examples
|
||
- Embassy book: https://embassy.dev/book/
|
||
- Shapiro et al., "Conflict-Free Replicated Data Types," INRIA 2011
|
||
- Almeida et al., "Delta State Replicated Data Types," JPDC 2018
|
||
- Meshtastic nRF52 build (prior art for SD+USB+LoRa)
|
||
- Semtech AN1200.22 (LoRa time-on-air formulas)
|
||
- Attack references: BleedingBit (CVE-2018-16986), Sweyntooth (CVE-2019-17517+), AirTag tracking (Heinrich 2022), nRF52 APPROTECT (LimitedResults 2020)
|
||
|
||
---
|
||
|
||
## Appendix E — Implementer decisions (knobs)
|
||
|
||
- Ring capacity v1: 128 (default). 64 if tight.
|
||
- Duty-cycle cap default: 1% (EU 868). Per-regulatory build override.
|
||
- Peer-sync cooldown default: 60 s. Denser deployments may tune.
|
||
- Rotation period default: 15 min. Shorter = better privacy, more reconnects.
|
||
- `test-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 |
|