peat-btle 0.3.2

Bluetooth Low Energy mesh transport for Peat Protocol
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
# Changelog

All notable changes to peat-btle will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]


## [0.3.2] - 2026-05-03

ADR-059 Amendment 3 — host-side wiring shifts from UniFFI callback to
**polled struct field** on `DataReceivedResult`. Implementation-time
investigation against 0.3.1 surfaced that UniFFI 0.31's Kotlin backend
wraps `#[uniffi::export(callback_interface)]` traits in
`com.sun.jna.Callback` (verified against the regenerated peat_btle.kt
in 0.3.1's sources jar), and JNA-based Rust→Kotlin callbacks fail
under ATAK's classloader isolation (documented prior art for peat-ffi's
`OutboundFrameCallback`). Amendment 3 bypasses the callback shape
entirely — the plugin reads a new field on the existing
`DataReceivedResult` and forwards via peat-ffi's
`publishDocumentWithOriginJni`.

The two callback paths from 0.3.0 / 0.3.1 stay in the public API as
non-ATAK escape valves. Three independent dispatch paths now coexist:
Rust-trait callback (in-process Rust consumers), UniFFI JSON callback
(non-ATAK Rust integration tests), polled struct field (ATAK / every
UniFFI host).

### Added

- **`DecodedTranslatorFrame { collection, doc_json, peer }` UniFFI
  record.** Defined unconditionally — *not* `#[cfg(feature = "mesh-translator")]`.
  The type itself has no feature-dependent surface; gating it would
  force the `decoded_translator_frame` field to also be gated and
  break the binding-shape stability claim. Population happens only
  when `mesh-translator` is on; without the feature, the field is
  always `None`.

- **`decoded_translator_frame: Option<DecodedTranslatorFrame>` field on
  `DataReceivedResult`** — both the inner `peat_mesh::DataReceivedResult`
  and the UniFFI binding wrapper. Populated by the receive dispatch
  when a 0xB6 translator frame decodes successfully. Hosts forward
  populated entries through their existing publish-with-origin FFI
  surface (e.g. peat-ffi's
  `publishDocumentWithOriginJni(collection, doc_json, "ble")`).

- **`PeatMesh::acknowledge_polled_translator_consumer()` method**  defined unconditionally for binding-shape stability. Hosts that
  read `DataReceivedResult.decoded_translator_frame` per receive and
  forward through their own publish path SHOULD call this once at
  startup. Without the attestation, the receive dispatch fires
  `PeatEvent::TranslatorNoCallback` for every decoded frame because
  it has no way to distinguish a polled-consumer host from a host
  that decoded the frame and dropped it on the floor. Idempotent —
  calling again is a no-op. Backed by `AtomicBool`, also unconditional.

- **`TranslatorMarkerOutcome` enum return** for the internal
  `try_handle_translator_marker` dispatch helper. Replaces the prior
  `bool` return with `NotTranslatorMarker | Decoded(frame) | Handled`.
  Per-call return-threaded — no shared mutable state, no `Mutex`, no
  thread-local. Concurrent receives on different threads stay race-free.

- **Three new regression fixtures** in
  `tests/reserved_marker_silent_drop.rs`:
  - `polled_field_populated_on_decoded_translator_frame` — locks the
    polled-field surfacing contract.
  - `polled_attestation_suppresses_no_callback_event` — locks the
    canonical polled-only deployment shape (peat-atak-plugin
    startup): no callbacks, attestation set, `TranslatorNoCallback`
    must not fire.
  - `polled_attestation_is_idempotent` — locks the spec's "calling
    again is a no-op" guarantee.

### Changed

- **Receive-entry-point return contract for 0xB6 frames.** In 0.3.0 /
  0.3.1, `on_ble_data_received*` returned `None` for any
  successfully-handled translator frame (the old "if
  try_handle_translator_marker { return None; }" branch). In 0.3.2
  the same path now returns `Some(DataReceivedResult { source_node,
  decoded_translator_frame: Some(..), .. /* other fields default */
  })`. Hosts updating to 0.3.2 **MUST** early-branch on
  `result.decoded_translator_frame.is_some()` before reading any
  other fields on the returned result — counter_changed,
  total_count, callsign, etc. are all defaulted on the polled-only
  path, and `source_node` is `NodeId::new(0)` for the anonymous
  receive entry point (no PeatDocument header to extract from).
  Hosts that gated on `result.is_some()` as "real sync data
  arrived" without further inspection will see that branch fire
  on every successfully-decoded translator frame in 0.3.2 — read
  the new field first, treat it as the polled-output path, and
  skip the legacy CRDT-field interpretation when populated.

- **`PeatEvent::TranslatorNoCallback` suppression rule extended to
  include the polled-consumer attestation.** Event fires when **all
  three** conditions hold: no Rust-trait callback installed, no
  UniFFI JSON callback installed, and
  `acknowledge_polled_translator_consumer` was never called. Any one
  of the three suppresses it. Preserves the 0.3.1 contract (the event
  still surfaces "no Rust-side consumer registered interest") while
  allowing the canonical 0.3.2 polled-only deployment to run without
  per-frame events.

### Wire format

Unchanged. 0xB6 marker, collection-code byte, postcard payload — same
as 0.3.0+. The in-process API contract did change for the 0xB6 receive
path (see §Changed); the on-the-wire contract did not.

### Apple FFI scope (iOS / macOS via `peat-apple-ffi`)

`peat-apple-ffi` (at `ios/peat-apple-ffi/`) is a separate Cargo crate
with its own narrower `DataReceivedResult` and field-by-field
translation. It does not yet expose Amendment 3:
`decoded_translator_frame` is not in the Apple-side struct, no
`acknowledge_polled_translator_consumer` re-export, and the Apple
build does not enable the `mesh-translator` feature. The
binding-shape stability claim above scopes to peat-btle's direct
UniFFI bindings (the AAR consumed by peat-atak-plugin and other
Kotlin / Android UniFFI hosts). Swift / iOS consumers compiling
against `peat-apple-ffi` won't see Amendment 3 until the Apple FFI
crate is updated; tracked as a follow-up rather than blocking the
0.3.2 release since no Apple host actively consumes peat-btle's
translator path today.

### Android AAR

`android/build.gradle.kts` now passes `--features android,mesh-translator`
to `cargo build` (was `--features android`) so the published AAR
contains the receive-dispatch + polled-field code path. Required for
peat-atak-plugin and other UniFFI host consumers; standalone BLE
consumers (Bitchat-style apps, embedded sensors) compiling against
the Rust crate without the `mesh-translator` Cargo feature still see
no peat-mesh in their dep graph.

The Android AAR `version` field in `android/build.gradle.kts` also
bumps to 0.3.2 (was 0.1.1, never tracked the Cargo version). Maven
Central / Maven local artifacts now match the crates.io version.


## [0.3.1] - 2026-05-02

ADR-059 Amendment 2 (Slice 1.b.4 host-side wiring) — adds the
UniFFI-exported `DecodedDocumentJsonCallback` so Kotlin / Swift hosts
can install a callback for inbound translator frames and forward each
decoded doc into peat-ffi's `publishDocument` with `origin="ble"`.

Amendment 2 corrected Amendment 1's "Lands in peat-ffi … Registered at
PeatNode construction" claim: peat-ffi never owns a `peat_btle::PeatMesh`
instance, so the wiring lives in the host (peat-atak-plugin et al.)
where the existing UniFFI / JNI split already holds handles to both
peat-btle's BLE stack and peat-ffi's `PeatNode`.

### Added

- **`crate::DecodedDocumentJsonCallback` UniFFI callback trait**
  (`#[uniffi::export(callback_interface)]`, gated on
  `mesh-translator` + `uniffi`). Same signature as the Rust-only
  `DecodedDocumentCallback` from 0.3.0 except the
  `peat_mesh::sync::Document` payload is serialized to JSON before
  invocation. Kotlin / Swift hosts implement this trait; the
  receive-dispatch fires it alongside the Rust-trait callback so both
  can be installed independently.

- **`PeatMesh::set_decoded_document_json_callback(Box<dyn DecodedDocumentJsonCallback>)`**
  on both the inner `peat_mesh::PeatMesh` and the UniFFI `PeatMesh`
  wrapper. Idempotent — replaces any prior installation. Internally
  stores as `Arc<dyn ...>` so the receive dispatch can snapshot-clone
  the handle out of the lock without holding it during user-supplied
  dispatch (same pattern as `decoded_document_callback`).

- **Receive-dispatch dual-firing**: `try_handle_translator_marker`
  invokes both callbacks (Rust-trait and UniFFI-JSON) when both are
  installed, each independently of the other. JSON serialization
  failures log + skip only the JSON path (the Rust-trait callback,
  if installed, still fires on the typed `Document`).

- **`PeatEvent::TranslatorNoCallback` suppression rule**: the event
  fires only when **both** callback slots are empty. Installing
  either callback suppresses it. Documents the release-skew window
  remains operator-observable while the JSON path is being rolled out.

- **Regression test**: `tests/reserved_marker_silent_drop.rs::json_callback_fires_and_suppresses_no_callback_event`
  installs a UniFFI JSON callback, sends a 0xB6 platforms frame, and
  asserts (a) the JSON callback received the doc with the correct
  collection name + peer, (b) the JSON payload round-trips through
  `serde_json::Value`, and (c) `TranslatorNoCallback` did not fire.
  Locks the both-callbacks-fire-independently and
  installed-suppresses-no-callback-event guarantees so a future
  refactor can't silently drop them.

### Operational notes

- Hosts upgrading from 0.3.0 to 0.3.1 do not need to re-flash any
  legacy peers — the wire format is unchanged. 0.3.1 is purely an
  additive API surface release: existing `PeatMesh` consumers see no
  behavioral change; consumers that install the new callback gain
  cross-transport receive-side ingest.

- Slice 1.b.4 host work (peat-atak-plugin's `BleDecodedDocumentBridge`)
  unblocks once 0.3.1 is on crates.io.


## [0.3.0] - 2026-05-01

ADR-059 Amendment 1 (Slice 1.b.3) — BLE receive-side wire format,
`DecodedDocumentCallback` trait, and translator-frame dispatch.
Required by peat-ffi Slice 1.b.4 to install the callback and thread
`Node::publish_with_origin(.., Some("ble"))` into the doc-store ingest
path. Closes the receive-side gap that ADR-059's "Inbound transport
bridge" pseudocode glossed over and unblocks the peat-atak-plugin
migration queue (#65 / #66 / #69 / #26 in that repo).

### Added

- **`mesh-translator` feature** (default-off, ADR-059): ships
  `BleTranslator` + the `peat_mesh::transport::Translator` impl so a
  peat-mesh-using consumer can plug peat-btle into
  `peat_mesh::transport::TransportManager` for cross-transport document
  bridging. Standalone BLE consumers (Bitchat-style apps, embedded
  sensors) compile peat-btle without the feature and never pull
  peat-mesh, serde_json, postcard, anyhow, or tracing into their dep
  graph. **peat-mesh is exact-pinned to `=0.9.0-rc.4`** to match the
  peat workspace pin — this pin must move in lockstep with the next
  peat-mesh release (peat-mesh release → peat workspace bump → peat-btle
  Cargo.toml bump → peat-btle release), otherwise downstream consumers
  pulling both peat and peat-btle with `mesh-translator` enabled will
  hit a resolver conflict.

- **ADR-059 Amendment 1 wire format** (`mesh-translator` feature):
  `TRANSLATOR_FRAME_MARKER = 0xB6` on the BLE wire, with a 1-byte
  collection code (`COLLECTION_CODE_TRACKS = 0x01`,
  `COLLECTION_CODE_PLATFORMS = 0x02`, `COLLECTION_CODE_ALERTS = 0x03`,
  `COLLECTION_CODE_CANNED_MESSAGES = 0x04`) and a postcard-encoded
  payload. Codes are immutable. 0xB6 sits above the ADR-001
  trust-architecture reservations (0xB3–0xB5 IDENTITY_ATTESTATION /
  REVOCATION / KEY_ROTATION) and below the 0xC0–0xCF `DocumentType`
  registry range. 0xB7–0xBF are reserved for future translator-frame
  variants and are silent-dropped on receive.

- **Symmetric encode/decode**: `BleTranslator::encode_outbound` now
  emits the fully-framed wire bytes; `decode_inbound` (and the new
  sync helper `decode_inbound_sync`) take payload-only bytes after
  peat-btle's receive dispatch strips marker + code. peat-btle is the
  single source of truth for the wire format in both directions —
  hosts (peat-ffi → peat-atak-plugin / wearos-tak-civ / ...) own no
  per-translator-frame logic and forward bytes through unchanged.

- **`DecodedDocumentCallback` trait** + `MeshDocument` re-export:
  invoked by peat-btle's GATT receive after a 0xB6 frame decodes
  successfully. peat-ffi (Slice 1.b.4) installs the callback and calls
  `Node::publish_with_origin(collection, doc, Some("ble"))` from inside
  it so origin threading prevents BLE echo in cross-transport fan-out.

- **`PeatMesh::set_decoded_document_callback`** + **`set_translator_config`**:
  setters for the receive-side callback and translator config (all
  gated by `mesh-translator`). The callback is `None` until installed —
  the deliberate Slice 1.b.3-ships-before-1.b.4 release-skew window.

- **`PeatEvent::TranslatorNoCallback { collection, peer }`**: emitted
  on the existing observer channel when a 0xB6 frame decodes
  successfully but no `DecodedDocumentCallback` is installed. Lets
  operators staging the rollout see the no-callback gap in real time
  without depending on `log::debug!` (disabled at default log levels).

- **Reserved-marker rejection** in `on_ble_data_received_anonymous`
  and identified-peer siblings: first-byte ∈ 0xB7..=0xBF is silent-
  dropped *before* fall-through to `merge_document`. Required to
  prevent the GCounter-pollution hazard documented in ADR-059
  Amendment 1 §"Backwards compatibility… 2" (a 0xB6+ payload whose
  bytes 8..11 fall ≤ `MAX_COUNTER_ENTRIES = 256` would otherwise be
  accepted as a valid-but-garbage `PeatDocument`; the (0,0)+None+None
  position broadcast is the concrete reachable instance).

  **Operational mandate**: deployments must roll out a peat-btle
  version with this rejection check to every BLE peer **before** any
  peer is upgraded to a version that emits 0xB6 frames. Pre-0.3.0
  peers do not provide a guaranteed silent-drop; the rejection check
  closes the hazard once rolled out.

- **Public marker constants** (`mesh-translator` feature):
  `TRANSLATOR_FRAME_MARKER`, `TRANSLATOR_RESERVED_MARKER_START`,
  `TRANSLATOR_RESERVED_MARKER_END` for in-tree dual-emit gating logic
  and tests. **Not** for host use — outbound framing lives entirely
  inside `BleTranslator::encode_outbound` per ADR-059 Amendment 1
  §"Outbound framing".

### Fixed

- **`mesh-translator` tracks decode**: missing or unparsable
  `ctx.local_wire_id` now returns `Err` instead of silently
  substituting `peripheral_id = 0`. The previous fallback collapsed
  every unknown-source BLE track onto `ble-00000000` — invisible from
  the receiver's perspective. The Err lets the inbound bridge's
  parse-error metric surface the misconfiguration loudly.

### Wire-format coordination

- 0.3.0 is the first peat-btle release that emits and recognizes 0xB6
  translator frames. Mixed-fleet rollouts see the dual-emit transition
  the amendment specifies: hosts that publish via the
  `mesh-translator` path emit *both* the 0xB6 translator frame and the
  matching legacy `PeatDocument`-shaped frame for the same logical
  doc, and Automerge merges the second receiver-side ingest as a
  no-op. Pre-0.3.0 peers see legacy frames as before; post-0.3.0
  peers see both. Operator-facing dual-emit toggle (`enable_legacy_emit`)
  ships in a follow-up release once the orchestrator-in-loop test
  fixtures land.


## [0.1.0] - 2026-02-12

### Added
- **Android**: High-priority sync mode for time-critical state updates
- **Android**: WearOS reliability improvements (reconnect on re-discovery, stale peer cleanup, address rotation handling, auto-reconnect with exponential backoff)
- **feather-sense**: Peat GATT server for WearTAK connectivity on nRF52840
- **feather-sense**: Pure Rust BLE advertising with nrf-sdc
- **feather-sense**: probe-rs build support and BLE target in Makefile
- **macOS**: GATT client with bidirectional sync
- BLE connection management infrastructure with UniFFI exports
- Range test node for WearTAK field testing
- Adafruit Feather Sense (nRF52840) example support
- CannedMessage CRDT document sync via delta mechanism
- Extensible document registry for CRDT sync
- nRF52840 sensor beacon example

### Fixed
- **Android**: Reconnect to peers when re-discovered after disconnect
- **Android**: Clean up stale connected peers and improve display names
- **Android**: Peer tracking with name-based deduplication
- **Android**: Handle WearOS BLE address rotation for stable mesh state
- **Android**: Prevent GATT server registration leaks
- **Android**: Prevent unwanted BLE pairing requests on Samsung devices
- **Apple**: CoreBluetooth memory management segfault
- **Linux**: BLE connection handling and advertisement improvements
- Relay deduplication and callsign cache
- CI checkout and format/clippy/macOS example gating fixes

### Changed
- **feather-sense**: Corrected GPIO P1 base address for raw_blinky

## [0.1.0-rc.30] - 2026-01-29

### Added
- `MembershipToken`: Lightweight authority-signed tokens (128 bytes) for constrained devices
  - Binds public_key to callsign with mesh_id and expiration
  - Wire format: `[pubkey:32][mesh_id:4][callsign:12][issued:8][expires:8][sig:64]`
- `SignedPayload`: Transport-agnostic signing utilities for BLE and WiFi/IP
  - Wire format: `[marker:1][payload:N][signature:64]`
- `IdentityRegistry` extended with callsign support:
  - `register_member()` validates and stores membership tokens
  - `get_callsign()` / `find_by_callsign()` for lookups
  - Persistence format v2 with backwards compatibility

## [0.1.0-rc.29] - 2026-01-27

### Added
- Functional BLE loopback test automation (kitlab ↔ Pi)
- `ble_responder` and `ble_test_client` example binaries
- Delta sync auto-registration on peer connect/disconnect
- `decrypt_only()` API for transport-only decryption

### Fixed
- UInt formatting in Android logs (`.toLong()` for `String.format`)
- Added `updatePeripheralState()` convenience method (ATAK team contribution)

### Changed
- CI now runs functional BLE test via SSH to Raspberry Pi

## [0.1.0-rc.28] - 2026-01-26

### Changed
- **BREAKING**: Migrated Android bindings from manual JNI to UniFFI
  - All Rust types now accessed via `uniffi.peat_btle` package
  - PeatMesh construction uses `newFromGenesis()` or `newWithPeripheral()` factory methods
  - Method parameters now use Kotlin unsigned types (UInt, ULong, UByte)
  - BLE callback timestamps require `.toULong()` conversion

### Added
- UniFFI bindings module (`src/uniffi_bindings.rs`) with full PeatMesh API
- Generated Kotlin bindings (`android/src/main/kotlin/uniffi/peat_btle/peat_btle.kt`)
- Chat methods exposed via UniFFI: `sendChat`, `sendChatReply`, `chatCount`, `getAllChatMessages`, `getChatMessagesSince`
- `updatePeripheralState` method for efficient encrypted state updates
- `deriveNodeIdFromMac` standalone function
- Peer state types via UniFFI: `ConnectionState`, `PeerConnectionState`, `StateCountSummary`, `FullStateCountSummary`, `IndirectPeer`, `ViaPeerRoute`
- Peer state methods: `getPeerConnectionState`, `getDegradedPeers`, `getLostPeers`, `getConnectionStateCounts`, `getIndirectPeers`, `getFullStateCounts`

### Fixed
- Removed JNI native method calls from callback proxies (`ScanCallbackProxy`, `GattCallbackProxy`, `AdvertiseCallbackProxy`) that caused `UnsatisfiedLinkError` at runtime

### Removed
- Manual JNI bridge (`src/platform/android/jni_bridge.rs`)
- JNI-based Kotlin files: `PeatMesh.kt`, `DeviceIdentity.kt`, `MeshGenesis.kt`, `IdentityAttestation.kt`
- JNI native method declarations from callback proxy classes
- `System.loadLibrary("peat_btle")` calls (UniFFI/JNA handles library loading automatically)
- `jni` and `ndk` crate dependencies

### Migration Guide
See `docs/UNIFFI_MIGRATION.md` for Android integration updates.

## [0.0.12] - 2026-01-19

### Added
- ADR-002: Mesh Provisioning and Node Onboarding architecture
- Codex.md with Radicle workflow guide and CI documentation
- Security implementation roadmap with 8 tracked issues

### Fixed
- Clippy warnings in linux adapter (derivable_impls, type_complexity, manual_strip, clone_on_copy)
- Range contains checks in CRDT validation
- linux_scanner example (rand dependency, callback types)
- Code formatting across multiple files

## [0.0.11] - 2026-01-18

### Added
- ChatCRDT for persistent mesh chat with reply threading
- Chat message deduplication in Android bindings
- MTU overflow protection (CHAT_SYNC_LIMIT=8)
- Profiling stress test example

### Fixed
- BLE MTU overflow crash with large chat histories
- Duplicate chat notifications in Android

## [0.1.0] - 2024-12-01

### Added
- Initial release
- Linux platform support with BlueZ/bluer
- Core BLE transport architecture
- BleAdapter trait for platform abstraction
- Peat beacon format and discovery protocol
- GATT service definition (0xF47A)
- Power profile management (Aggressive, Balanced, LowPower, UltraLow)
- Lightweight CRDT implementations (GCounter, LWWRegister, ORSet)
- Hierarchical mesh topology support
- Delta-based synchronization protocol
- Coded PHY support for extended range (BLE 5.0+)

### Platform Support
- Linux (BlueZ 5.48+) - Complete
- macOS (CoreBluetooth) - Complete
- iOS (CoreBluetooth) - Complete
- ESP32 (NimBLE) - Complete
- Android - In Progress
- Windows - Planned