This document covers everything the companion app needs to know to implement a correct, robust
BLE connection to a Rivr node running a client_<board>_ble firmware build.
The Rivr BLE transport is not a custom protocol — it is a thin bridge that carries the same binary Rivr packet frames that normally travel over LoRa. The companion app receives live mesh traffic and can inject frames into the mesh exactly as if it were another mesh node.
Phone / Companion app
│
│ BLE (NUS GATT service)
│
Rivr node (client_*_ble firmware)
│
│ LoRa (SX1262 / SX1276)
│
rest of the mesh
The node acts as a transparent bridge: frames written by the phone are pushed into the node's receive ring buffer and processed identically to frames arriving from LoRa (protocol decode, dedupe, routing, RIVR engine). Frames received from the mesh (before dedupe) are forwarded to the connected phone via BLE notify.
The node uses the Nordic UART Service UUIDs. These are widely supported in Flutter BLE
packages (flutter_reactive_ble, flutter_blue_plus, etc.) and are recognised by nRF Connect.
| Role | Characteristic | UUID | Properties |
|---|---|---|---|
| Service | — | 6E400001-B5A3-F393-E0A9-E50E24DCCA9E |
— |
| RX (phone → node) | Write | 6E400002-B5A3-F393-E0A9-E50E24DCCA9E |
Write / Write Without Response |
| TX (node → phone) | Notify | 6E400003-B5A3-F393-E0A9-E50E24DCCA9E |
Notify |
Memory aid: From the phone's perspective, you write to RX and subscribe to TX. The name comes from the node's perspective (RX = what the node receives, TX = what it transmits).
Every Rivr node advertises as:
RIVR-XXXX
Where XXXX is the lower 16 bits of the node's 32-bit ID in uppercase hex.
Examples:
- Node ID
0xAB12CD34→ advertises asRIVR-CD34 - Node ID
0x00000001→ advertises asRIVR-0001
The advertisement payload includes the full 128-bit service UUID (6E400001-...).
The local name is placed in the scan response packet, so the app should use
active scanning when filtering by name.
The node advertises at 100–200 ms intervals during an active window. Typical scan discovery time is under 1 s with active scanning.
Filter on either:
// By service UUID (preferred — future-proof)
filterServiceUuid: Uuid.parse('6E400001-B5A3-F393-E0A9-E50E24DCCA9E')
// Or by name prefix
filterLocalNameStartsWith: 'RIVR-'BLE is not always on — the node manages an activation window to save power. The app must connect within the active window, or trigger a new one.
| Mode | Duration | Trigger |
|---|---|---|
| BOOT_WINDOW | 120 seconds after boot | Automatic (default) |
| BUTTON | 300 seconds (5 minutes) | User presses a hardware button on the node |
| APP_REQUESTED | Indefinite, until deactivated | Mesh command (future) |
- After the node boots, connect within 120 s — or instruct the user to press the button.
- If the connection drops and the window is still open, the node restarts advertising automatically. Reconnect without any user action required.
- If the window expired, the user must press the button (or reboot the node) to reopen BLE.
- The app cannot detect whether the window is open before connecting. Scan timeouts imply the window is closed.
UX suggestion: Show a "searching…" state for 5–8 s. If no device is found, prompt: "Press the button on your Rivr node or restart it to enable Bluetooth."
The node firmware targets an ATT MTU of 247 on both ESP32 and nRF52 builds.
Always request a higher MTU after connecting:
// flutter_reactive_ble example
await _ble.requestMtu(deviceId: deviceId, mtu: 247);After negotiation, the effective payload per write/notify is:
effective_payload = negotiated_mtu - 3 (ATT header overhead)
| Negotiated MTU | Max bytes per write/notify |
|---|---|
| 23 (default, no negotiation) | 20 bytes — too small for most Rivr frames |
| 128 | 125 bytes |
| 247 (recommended) | 244 bytes |
⚠ Do not skip MTU negotiation. The minimum Rivr frame is 25 bytes and the typical CHAT frame is 30–60 bytes. The default 20-byte ATT payload will truncate most frames.
| Constant | Value |
|---|---|
| Minimum frame size | 25 bytes (23-byte header + 2-byte CRC) |
| Maximum frame size | 255 bytes (LoRa hardware limit) |
| Maximum BLE-carriable frame | 244 bytes at MTU 247 |
| Maximum payload inside a frame | 230 bytes |
The node rejects writes that exceed the negotiated BLE payload limit or 255 bytes, whichever is lower.
All communication uses the binary Rivr frame format, identical to LoRa frames. The app must encode frames it sends and decode frames it receives using this layout.
Offset Len Field Type Notes
────── ─── ────────────── ─────── ────────────────────────────────────────
0 2 magic u16 LE Always 0x5256 ("RV"). Reject if wrong.
2 1 version u8 Protocol version. Currently 1.
3 1 pkt_type u8 See packet type table below.
4 1 flags u8 Bitmask: 0x01=ACK_REQ, 0x02=RELAYED, 0x04=FALLBACK, 0x08=CHANNEL (CHAT), 0x10=HAS_POS (BEACON)
5 1 ttl u8 Hops remaining. Default 7. Decrement on relay.
6 1 hop u8 Hops taken so far. 0 at origin.
7 2 net_id u16 LE Network partition ID. 0x0000 = default.
9 4 src_id u32 LE Originating node ID.
13 4 dst_id u32 LE Destination node ID. 0x00000000 = broadcast.
17 2 seq u16 LE Per-source sequence counter (message ordering).
19 2 pkt_id u16 LE Deduplication fingerprint (do not set — firmware fills).
21 1 payload_len u8 Number of application payload bytes that follow.
22 1 loop_guard u8 OR-accumulating relay fingerprint. Set to 0 on originate.
23 [N] payload bytes Application-specific bytes (payload_len bytes).
23+N 2 crc u16 LE CRC-16/CCITT over bytes [0 .. 23+N-1].
| Value | Constant | Description |
|---|---|---|
| 1 | PKT_CHAT |
Text message (@CHT JSON log) |
| 2 | PKT_BEACON |
Periodic node-presence advertisement |
| 3 | PKT_ROUTE_REQ |
Route request (routing control) |
| 4 | PKT_ROUTE_RPL |
Route reply (routing control) |
| 5 | PKT_ACK |
Acknowledgement |
| 6 | PKT_DATA |
Generic sensor / application data |
| 7 | PKT_PROG_PUSH |
OTA RIVR program push (Ed25519-signed) |
| 8 | PKT_TELEMETRY |
Structured telemetry payload |
| 9 | PKT_MAILBOX |
Store-and-forward message |
| 10 | PKT_ALERT |
High-priority alert |
| 11 | PKT_METRICS |
Firmware diagnostics snapshot (BLE-only, TTL=1, never LoRa-relayed) |
CRC-16/CCITT (CRC-16/CCITT-FALSE)
Width : 16
Poly : 0x1021
Init : 0xFFFF
RefIn : false
RefOut : false
XorOut : 0x0000
The CRC covers all bytes from offset 0 through the last byte of the payload (i.e. the CRC itself is excluded from the calculation).
Offset Len Field
0 1 (reserved, 0x00)
1 1 (reserved, 0x00)
2 2 msg_seq u16 LE per-origin chat message sequence
4 3 (reserved, 0x00)
7 [N] text UTF-8 message body (N = payload_len − 7)
- Subscribe to notifications on the TX characteristic (
6E400003-...) after connecting. - Each notification carries one complete binary Rivr frame (no framing layer, no length prefix — the BLE notification length is the frame length).
- The app receives all mesh traffic seen by the node before deduplication — including frames from other nodes that the client node forwarded.
- Validate the frame: check magic (
0x5256), verify CRC. Drop silently on failure. - Inspect
pkt_typeto route to the appropriate UI handler.
// flutter_blue_plus example
txChar.onValueReceived.listen((data) {
final bytes = Uint8List.fromList(data);
if (!validateRivrFrame(bytes)) return; // check magic + CRC
final pktType = bytes[3];
switch (pktType) {
case PKT_CHAT: handleChat(bytes); break;
case PKT_BEACON: handleBeacon(bytes); break;
case PKT_TELEMETRY: handleTelemetry(bytes); break;
// ...
}
});
await txChar.setNotifyValue(true);- Build a complete binary Rivr frame including magic, all header fields, payload, and CRC.
- Set
src_idto the phone's virtual node ID — pick a fixed random 32-bit value and persist it locally (do not generate a new ID each session). - Set
dst_idto0x00000000for broadcast CHAT, or the target's node ID for unicast. - Set
ttlto 7 (default). The node will decrement on relay. - Set
seqto an incrementing counter persrc_id. - Leave
pkt_idas 0 — the node's firmware does not use the phone'spkt_idfor its own dedup fingerprint on frames that originate from the phone. - Write to the RX characteristic (
6E400002-...):- Use Write Without Response for lower latency (
BLE_GATT_CHR_F_WRITE_NO_RSP). - Use Write With Response if you want delivery confirmation from the node.
- Use Write Without Response for lower latency (
- Maximum write size:
negotiated_mtu - 3bytes per write. For Rivr frames ≤ 125 bytes, a single write is sufficient after MTU negotiation to 128. Larger payloads are fragmented automatically by the BLE transport and reassembled on the peer.
BLE fragmentation uses a 6-byte Rivr transport header and only activates when a complete Rivr frame or companion packet does not fit in one ATT write/notify. The reassembler keeps a small set of concurrent fragment streams so overlapping companion and mesh traffic can complete cleanly.
App Rivr node (BLE active)
│ │
│── scan (filter: RIVR- or UUID) ──►│ (advertising 100–200 ms)
│ │
│── connect ────────────────────────►│ GAP CONNECT event
│ │ g_metrics.ble_connections++
│ │ (new peer) triggers MITM pairing
│◄── passkey notification ──────────│ node displays 6-digit PIN
│ [user enters PIN on phone] │
│── pairing complete ───────────────►│ bond persisted on both sides
│ │
│── request MTU (247) ──────────────►│ MTU event logged
│ │
│── subscribe TX notify ────────────►│ SUBSCRIBE event logged
│ │
│◄── notify (mesh frame) ───────────│ for each frame from mesh
│── write RX (Rivr frame) ──────────►│ pushed into rf_rx_ringbuf → main loop
│ │
│── disconnect (or window expires) ──►│ GAP DISCONNECT event
│ │ restarts advertising (if window open)
│ │ g_metrics.ble_connections not bumped again
Reconnection: on disconnect, the node restarts advertising immediately (if the activation window is still open). The app should attempt reconnection with exponential back-off (e.g. 1 s → 2 s → 5 s) before prompting the user.
| Property | Current _ble builds |
|---|---|
| Encryption | ✅ Link encryption required after pairing |
| Pairing / bonding | ✅ LE Secure Connections bonding |
| Authentication | ✅ MITM passkey entry |
| Filter by address | ❌ Single-client only; no allow-list |
Current _ble environments in this repo ship with RIVR_BLE_PASSKEY != 0, so the
phone must complete passkey pairing before the link is treated as data-ready. Bonded
phones reconnect silently and the bond is persisted on-device. Open BLE builds are
still possible only if a board environment explicitly sets RIVR_BLE_PASSKEY=0.
On the first connection to a new node, pairing must complete before the app can subscribe to notifications or send frames:
- Immediately after the GAP connect event, the node triggers MITM authentication.
- The node generates and displays a random 6-digit passkey on its screen (logged as
BLE pairing: show passkey XXXXXX). - On Android the OS presents a passkey entry dialog; the user must type the 6-digit code shown on the node within 60 seconds.
- On success the bond is persisted; future reconnections are silent (no PIN needed).
- On failure or timeout, the connection is dropped.
App-side implementation: block all GATT operations (MTU request, discoverServices,
setNotifyValue) until the Android bond state reaches bonded. Only start normal
operations (subscribe, send frames) after bonding completes. A 60-second timeout is
recommended. Once the bond exists, reconnections require no extra delay.
The node emits an @MET JSON snapshot on request (metrics CLI command) or continuously
on the @MET serial log channel. The following counters are BLE-specific:
| Key | Meaning |
|---|---|
ble_conn |
Cumulative successful BLE connections |
ble_rx |
Frames received from the phone and injected into the mesh |
ble_tx |
Frames forwarded to the phone via TX notify |
ble_err |
BLE stack errors (mbuf alloc failure, write out of range) |
The app can request a snapshot by sending the ASCII string metrics\r\n as a PKT_DATA or
serial CLI message (not a BLE write — the serial CLI is separate from the BLE interface).
Alternatively, if connected via USB serial concurrently, the metrics command is available on
UART0.
| Package | Notes |
|---|---|
flutter_blue_plus |
Used by Rivr Companion. Android, iOS, macOS, Linux, Windows. |
flutter_reactive_ble |
Alternative. Reactive streams, Android + iOS only. |
Both packages support Nordic NUS UUIDs. The companion app uses flutter_blue_plus:
const kServiceUuid = '6E400001-B5A3-F393-E0A9-E50E24DCCA9E';
const kRxCharUuid = '6E400002-B5A3-F393-E0A9-E50E24DCCA9E';
const kTxCharUuid = '6E400003-B5A3-F393-E0A9-E50E24DCCA9E';
final svc = device.servicesList.firstWhere((s) => s.uuid == Guid(kServiceUuid));
final rxChar = svc.characteristics.firstWhere((c) => c.uuid == Guid(kRxCharUuid));
final txChar = svc.characteristics.firstWhere((c) => c.uuid == Guid(kTxCharUuid));
// Subscribe to incoming mesh frames
txChar.onValueReceived.listen(onFrame);
await txChar.setNotifyValue(true);
// Send a frame to the mesh
await rxChar.write(frameBytes, withoutResponse: true);<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<!-- API level < 31: -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /><key>NSBluetoothAlwaysUsageDescription</key>
<string>Rivr uses Bluetooth to communicate with LoRa mesh nodes.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>Rivr uses Bluetooth to communicate with LoRa mesh nodes.</string>Requires BlueZ ≥ 5.50 and the bluetooth system service running. No additional permissions
beyond adding the user to the bluetooth group.
Requires Windows 10 1703 (Creators Update) or later. Use flutter_blue_plus for Windows BLE
support (flutter_reactive_ble does not support Windows as of 2026).
When the companion app sends RIVR_CP_CMD_APP_START (0x01) after connecting,
the node switches into companion mode: raw frame bridging is disabled and a
structured command/response protocol is used instead. All companion writes are
framed as [type u8][length u8][payload…].
| Value | Constant | Payload | Description |
|---|---|---|---|
| 0x01 | APP_START |
none | Activate companion mode. Must be the first write after subscribing. |
| 0x02 | DEVICE_QUERY |
none | Request DEVICE_INFO response from the node. |
| 0x03 | SET_CALLSIGN |
UTF-8 string (max 10 bytes) | Persist a new callsign on the node. |
| 0x04 | GET_NEIGHBORS |
none | Trigger a stream of NODE_INFO packets followed by NODE_LIST_DONE. |
| 0x05 | SET_POSITION |
lat_e7 i32 LE + lon_e7 i32 LE (8 bytes total) | Set and persist the node's GPS position (degrees × 1e7). |
| 0x06 | CLEAR_POSITION |
none | Clear the persisted GPS position (sets to INT32_MIN = unknown). |
APP_STARTmust be sent before any other command; sending other commands beforeAPP_STARTresults in anERRresponse.
| Value | Constant | Payload | Triggered by |
|---|---|---|---|
| 0x80 | OK |
1-byte echo of the acknowledged command | Any successful command |
| 0x81 | ERR |
1-byte command + error string | Failed command |
| 0x82 | DEVICE_INFO |
JSON object (see §14.3) | DEVICE_QUERY command |
| 0x83 | NODE_INFO |
30 bytes (see §14.4) | GET_NEIGHBORS command, or unsolicited on beacon arrival |
| 0x84 | NODE_LIST_DONE |
none | End of GET_NEIGHBORS stream |
| 0x85 | CHAT_RX |
src_id u32 LE + channel_id u16 LE + UTF-8 text | Chat message received from the mesh |
| 0x86 | TELEMETRY |
15 bytes (see §14.5) | Telemetry reading received from the mesh |
{"node_id":"0xAABBCCDD","callsign":"CALL","net_id":"0x0000",
"env":"client_lilygo_lora32_v21_ble","role":"client","radio":"SX1262"}When a GPS position is stored, lat and lon fields are included (decimal degrees):
{"node_id":"0xAABBCCDD","callsign":"CALL","net_id":"0x0000",
"env":"client_lilygo_lora32_v21_ble","role":"client","radio":"SX1262",
"lat":52.3740300,"lon":4.8896900}Offset Len Field Type Notes
────── ─── ────────── ─────── ────────────────────────────────────────
0 4 node_id u32 LE
4 1 rssi i8 dBm
5 1 snr i8 dB
6 1 hop_count u8 1 = direct neighbour
7 1 link_score u8 0–100 composite quality
8 1 role u8 rivr_node_role_t (0=unknown,1=client,2=repeater,3=gateway)
9 1 (pad) u8 0x00
10 12 callsign bytes ASCII, NUL-padded, max 11 chars
22 4 lat_e7 i32 LE degrees × 1e7; INT32_MIN = unknown
26 4 lon_e7 i32 LE degrees × 1e7; INT32_MIN = unknown
Offset Len Field Type Notes
────── ─── ────────── ─────── ────────────────────────────────────────
0 4 src_id u32 LE Originating sensor node ID
4 2 sensor_id u16 LE Application-defined sensor index
6 4 value i32 LE Scaled integer (unit-dependent)
10 1 unit_code u8 UNIT_* constant (see services.md §4.1)
11 4 timestamp u32 LE Seconds since node boot (0 = unset)
See services.md for built-in sensor IDs (DS18B20, AM2302, VBAT).
| Limitation | Detail |
|---|---|
| One client at a time | Rivr currently tracks a single active BLE connection. A second phone cannot connect while one is already connected. |
| Fragmentation overhead | Oversize payloads are fragmented automatically, but each fragment carries a 6-byte Rivr BLE transport header. |
| Activation window | BLE is not always on. See Section 4. |
| First-connection PIN entry | First-time pairing requires entering a 6-digit PIN shown on the node's display. Subsequent connections are silent. See Section 10. |
| No phone↔phone relay | Frames injected via BLE are processed by the connected node only; they do not bypass the node's relay policy. A PKT_CHAT written by the phone is subject to the same duty-cycle and relay rules as any LoRa frame. |
| ESP32-S3 BLE stability | Heltec V3 and LilyGo T3-S3 use ESP32-S3. Static-random BLE addressing is used on these boards; advertising and reconnection are stable from IDF 5.5.0. |