peat-btle 0.3.3

Bluetooth Low Energy mesh transport for Peat Protocol
Documentation
# Range Test Node - Development Notes

## Current State (2026-02-05)

### What Works (macOS - PRIMARY)
- **CoreBluetooth Adapter**: Successfully initialized (waits for PoweredOn state)
- **BLE Advertising**: Advertising as `PEAT-BA5E0001` with Peat service UUID
- **GATT Server**: 5 characteristics registered (node_info, sync_state, sync_data, command, status)
- **BLE Discovery**: Working! Discovers all nearby BLE devices
- **NSRunLoop Integration**: Pumps run loop to deliver CoreBluetooth callbacks
- **Service UUID Filtering**: Supports both 128-bit and 16-bit expanded forms
- **Self-Discovery Filtering**: Ignores own advertisements
- **Mesh Integration**: Using same WEARTAK genesis as watches (mesh ID: 29C916FA)
- **GATT Client Connections**: Full outbound connection support to discovered watches
- **Service/Characteristic Discovery**: Discovers Peat service (F47AC10B) and characteristics
- **Sync Data Read**: Reads sync_data (F47A0003) from connected watches
- **Document Decryption**: Decrypts received documents via PeatMesh with shared encryption key
- **Data Extraction**: Extracts node_id, callsign, location, battery, heart rate, emergency status
- **Bidirectional Sync**: Writes our sync document back to watches after receiving theirs

### Discovered Devices (Test Results 2026-02-05)
- `WEAROS-4059`, `WEAROS-3301` - WearOS watches visible but not advertising as Peat
- Various other BLE devices (TV, soundbar, HomePods, etc.)
- Self: `PEAT-BA5E0` (name truncated by CoreBluetooth)

**Note**: WearTAK watches only advertise Peat service UUID when actively syncing. To test:
1. Open WearTAK app on watch
2. Trigger a sync or ensure BLE advertising is enabled
3. Watch should then appear as `HIVE_WEARTAK-xxxx`

### What Works (Linux)
- **Linux BLE Advertising**: Successfully advertising as `PEAT-BA5E0001` with GATT service
- **GATT Server**: 5 characteristics registered
- **Encrypted Documents**: Initial sync_state populated with 81-byte encrypted document
- **Discovery**: Successfully discovering WearTAK watches (e.g., `PEAT-C8E32F88`)

### What Doesn't Work (Linux/BlueZ)
- **Outbound Connections**: BlueZ consistently fails with `le-connection-abort-by-local`
  - Tried: Stopping scan before connect, retries, address type hints
  - Root cause unclear - may be BlueZ 5.64 limitation or adapter-specific issue
- **Inbound Connections**: Watches discover us but don't connect
  - Passive mode implemented but watches not initiating GATT connections
  - May need mesh ID in advertisement (currently omitted to fit 31-byte limit)

### Files Changed
- `src/platform/linux/adapter.rs`: Added helper methods for device access, discovery control, adapter alias, MTU tracking
- `src/platform/linux/connection.rs`: Added MTU discovery via GATT operations, better default MTU (185 bytes)
- `src/platform/apple/adapter.rs`: Fixed scan filter to use Peat service UUID
- `examples/range_test_node.rs`: Range test orchestrator with active/passive connection modes (Linux)
- `examples/range_test_node_macos.rs`: Range test orchestrator for macOS using CoreBluetooth

## macOS Range Test Orchestrator (DONE)

The macOS range test node is implemented and working:
- **File**: `examples/range_test_node_macos.rs`
- **Build**: `cargo build --features macos --example range_test_node_macos`
- **Run**: `cargo run --features macos --example range_test_node_macos`

### What Works (macOS)
- CoreBluetooth adapter initialization
- GATT service registration with 5 characteristics
- Advertising as `PEAT-BA5E0001` with Peat service UUID
- Scanning filtered by Peat service UUID
- Discovery callback for found devices
- Periodic status updates
- **Full GATT Client (NEW)**:
  - Connect to discovered Peat watches via `connect_by_identifier()`
  - Discover services and characteristics
  - Read sync_data from watches (F47A0003)
  - Decrypt documents via `mesh.on_ble_data_received_anonymous()`
  - Extract: node_id, callsign, lat/lon, battery%, heart rate, emergency
  - Write our sync document back for bidirectional sync

### Usage
```bash
# Basic run
cargo run --features macos --example range_test_node_macos

# With custom callsign and output file
cargo run --features macos --example range_test_node_macos -- --callsign RANGER --output test.log
```

### MTU Implementation (2026-02-05)
Implemented proper MTU handling to match Android behavior:

**Server-side (when watches connect to us):**
- GATT callbacks now capture MTU from `CharacteristicReadRequest.mtu` and `CharacteristicWriteRequest.mtu`
- Per-peer MTU tracked in `GattState.peer_mtu` HashMap
- MTU logged in GATT operations: "GATT read sync_state from XX:XX:XX:XX: 81 bytes (MTU=185)"
- Query via `adapter.get_peer_mtu(&address)` or `adapter.get_all_peer_mtus()`

**Client-side (when we connect to watches):**
- Default MTU increased from 23 to 185 bytes (matches WearTAK's request)
- Added `connection.discover_mtu()` to get actual negotiated value via `AcquireWrite`/`AcquireNotify`

**Constants:**
- `DEFAULT_BLE_MTU = 185` - Conservative default for BLE 4.2+ devices
- `MIN_BLE_MTU = 23` - ATT_MTU_MIN per Bluetooth spec

### Write Queue Implementation (2026-02-05)
Implemented per-connection write queue to serialize BLE writes (BLE only allows one pending write per connection):

**Key components:**
- `WriteQueueState` - Contains `VecDeque<QueuedWrite>` and `write_in_progress` flag
- `QueuedWrite` - Holds service UUID, char UUID, data, and completion oneshot sender

**Methods added to BluerConnection:**
- `write_characteristic_queued(service_uuid, char_uuid, data)` - Safe concurrent writes via queue
- `process_write_queue()` - Internal method to process queue items serially
- `write_queue_depth()` - Check pending write count (for backpressure monitoring)
- `write_in_progress()` - Check if a write is currently executing
- `clear_write_queue()` - Cancel all pending writes (called on disconnect)

**Usage:**
```rust
// Safe for concurrent calls - writes are serialized automatically
connection.write_characteristic_queued(service_uuid, char_uuid, &data).await?;

// Check queue depth for backpressure
if connection.write_queue_depth().await > 10 {
    log::warn!("Write queue backing up");
}
```

**Implementation notes:**
- Uses tokio::sync::Mutex for queue synchronization
- Each queued write gets a oneshot channel for completion notification
- Queue is cleared on disconnect, pending writes receive error
- Direct `write_characteristic()` still available but warns about concurrent use

### Auto-Reconnection Implementation (2026-02-05)
Implemented `ReconnectionManager` for automatic reconnection with exponential backoff:

**Configuration (`ReconnectionConfig`):**
- `base_delay` - Initial delay (default: 2 seconds)
- `max_delay` - Maximum delay cap (default: 60 seconds)
- `max_attempts` - Give up after N attempts (default: 10)
- `check_interval` - How often to check for peers to reconnect (default: 5 seconds)

**Key methods:**
- `track_disconnection(address)` - Start tracking a peer for reconnection
- `get_peers_to_reconnect()` - Get list of peers ready for attempt
- `record_attempt(address)` - Record that an attempt was made
- `on_connection_success(address)` - Clear tracking on successful reconnect
- `get_status(address)` - Check status (Ready, Waiting, Exhausted, NotTracked)

**Backoff formula:**
```
delay = min(base_delay * 2^attempts, max_delay)
```
With defaults: 2s, 4s, 8s, 16s, 32s, 60s, 60s, 60s, 60s, 60s (then exhausted)

**Usage:**
```rust
let mut manager = ReconnectionManager::with_defaults();

// On disconnect
manager.track_disconnection(peer_address.clone());

// Periodic check (every 5 seconds)
for peer in manager.get_peers_to_reconnect() {
    manager.record_attempt(&peer);
    if try_connect(&peer).await.is_ok() {
        manager.on_connection_success(&peer);
    }
}
```

### Peer Lifetime Management Implementation (2026-02-05)
Implemented `PeerLifetimeManager` for stale peer cleanup:

**Configuration (`PeerLifetimeConfig`):**
- `disconnected_timeout` - Remove disconnected peers after this (default: 30 seconds)
- `connected_timeout` - Remove "connected" peers with no activity (default: 60 seconds)
- `cleanup_interval` - How often to check for stale peers (default: 10 seconds)

**Key methods:**
- `on_peer_activity(address, connected)` - Update last seen time
- `on_peer_disconnected(address)` - Mark peer as disconnected (doesn't update last_seen)
- `get_stale_peers()` - Get list of stale peers with reasons
- `cleanup_stale_peers()` - Remove and return stale peers
- `stats()` - Get counts of connected/disconnected peers

**Stale detection:**
- Disconnected peers: stale after `disconnected_timeout` since last activity
- Connected peers: stale after `connected_timeout` since last activity (handles ghost connections)

**Usage:**
```rust
let mut manager = PeerLifetimeManager::with_defaults();

// On discovery/connection/data received
manager.on_peer_activity(&address, is_connected);

// On disconnect (note: doesn't update last_seen intentionally)
manager.on_peer_disconnected(&address);

// Periodic cleanup (every 10 seconds)
for stale in manager.cleanup_stale_peers() {
    log::info!("Removing stale peer {}: {:?}", stale.address, stale.reason);
    // Clean up your resources for this peer
}
```

### BLE Address Rotation Implementation (2026-02-05)
Implemented `AddressRotationHandler` for WearOS address rotation handling:

**The problem:**
WearOS devices rotate their BLE MAC addresses for privacy. The same device can appear with different addresses over time, causing duplicate peers.

**The solution:**
Use device name (which is stable) as a secondary key for identifying devices:

**Device patterns detected:**
- `WT-WEAROS-*` - WearTAK on WearOS (rotates addresses)
- `WEAROS-*` - Generic WearOS device (rotates addresses)
- `PEAT_*` / `PEAT-*` - Peat mesh devices

**Key methods:**
- `register_device(name, address, node_id)` - Register a new device
- `on_device_discovered(name, address)` - Handle discovery with rotation detection
- `lookup_by_name(name)` / `lookup_by_address(address)` - Find known devices
- `update_address(name, new_address)` - Update after rotation detected
- `remove_device(node_id)` - Clean up all mappings

**Usage:**
```rust
let mut handler = AddressRotationHandler::new();

// On device discovery
if let Some(result) = handler.on_device_discovered(&name, &address) {
    // Known device
    if result.address_changed {
        log::info!("Address rotated: {} -> {}",
            result.previous_address.unwrap(), result.current_address);
        // Update your connection to use new address
    }
    // Use result.node_id for the existing peer
} else {
    // New device - register it
    let node_id = /* extract from advertisement */;
    handler.register_device(&name, &address, node_id);
}
```

**Helper functions:**
- `detect_device_pattern(name)` - Returns `DevicePattern::WearTak/WearOs/Hive/Unknown`
- `is_weartak_device(name)` - Quick check for WearTAK/WearOS
- `normalize_weartak_name(name)` - Strips "WT-" prefix for consistency

## Next Steps

### macOS - COMPLETE
The macOS GATT client is fully operational:
- Discovers watches advertising Peat service UUID
- Connects, discovers services/characteristics, reads sync_data
- Decrypts via PeatMesh, extracts all CRDT data
- Writes back for bidirectional sync

**Note**: WearTAK watches expose sync_data (F47A0003) but NOT node_info (F47A0001). Node ID is extracted from the decrypted document header instead.

### Production Enhancements (Optional)
1. **Auto-reconnection**: Handle disconnections gracefully with exponential backoff
2. **Multiple simultaneous connections**: Connect to several watches in parallel
3. **RSSI tracking per connection**: Monitor signal strength over time
4. **Notification subscription**: Subscribe to sync_data notifications for push updates

### Linux BLE Investigation (Background)
Continue debugging BlueZ issues:
1. Try on Raspberry Pi (different BlueZ version/config)
2. Investigate btleplug as alternative to bluer crate
3. Check if mesh ID in advertisement is required for watch auto-connect

## Technical Details

### Advertisement Format
Current Linux advertisement:
- Service UUID: 0xF47A (16-bit alias)
- Service Data: [nodeId: 4 bytes BE]
- Local Name: PEAT-BA5E0001
- Total: ~30 bytes (fits 31-byte legacy limit)

Note: Mesh ID omitted from service data. Android code shows `matchesMesh()` returns true for null meshId (legacy compatibility), so this should work.

### WearTAK Genesis
```rust
const WEARTAK_GENESIS_BYTES: &[u8] = &[
    0x07, 0x00, 0x57, 0x45, 0x41, 0x52, 0x54, 0x41, 0x4B, ...
];
// Mesh ID: 29C916FA
// Mesh Name: WEARTAK
```

### Connection Flow (Expected)
1. Linux advertises as PEAT-BA5E0001 with GATT service
2. Watch scans, sees PEAT-BA5E0001, recognizes as Peat device
3. Watch connects as GATT client, reads sync_state
4. Watch writes to sync_data with its document
5. Linux receives via sync_data_callback, processes with mesh

### Error Reference
- `le-connection-abort-by-local`: BlueZ internally aborting connection attempt
- `ConnectDevice method doesn't exist`: BlueZ 5.64 doesn't support adapter-level connect