From ed625d4f0f81a9acf5423a0cc79084241c35c36c Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Wed, 31 Dec 2025 14:01:54 -0500 Subject: [PATCH 1/6] fix: add address-based fallback for peer interface cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLE peer interfaces weren't being cleaned up when connections dropped if the identity-to-address mapping wasn't available at disconnect time. This caused orphaned interfaces to persist (peer interfaces shown with zero active connections). Changes: - Add address_to_interface mapping for direct address-based cleanup - Update _device_disconnected_callback with dual-index approach: try identity lookup first, fall back to address_to_interface - Update handle_central_disconnected with same dual-index approach - Add _validate_spawned_interfaces() periodic validation (every 30s) that cross-checks interfaces against driver.connected_peers - Update _cleanup_stale_interface and _address_changed_callback to maintain the new mapping - Clear address_to_interface on detach() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- BLE_PROTOCOL_v0.3.0.md | 230 ++++++++++++++++++++++++++++++ src/ble_reticulum/BLEInterface.py | 203 +++++++++++++++++++++----- 2 files changed, 394 insertions(+), 39 deletions(-) create mode 100644 BLE_PROTOCOL_v0.3.0.md diff --git a/BLE_PROTOCOL_v0.3.0.md b/BLE_PROTOCOL_v0.3.0.md new file mode 100644 index 0000000..a45fab4 --- /dev/null +++ b/BLE_PROTOCOL_v0.3.0.md @@ -0,0 +1,230 @@ +# BLE-Reticulum Protocol Specification v0.3.0 + +**Version**: 0.3.0 +**Date**: December 2025 +**Status**: Draft +**Backwards Compatible With**: v2.2 + +## 1. Overview + +This document specifies the v0.3.0 extension to the BLE-Reticulum protocol. This version adds **capability advertisement** to support devices that can only operate in peripheral mode (e.g., ESP32-S3). + +### 1.1 Problem Statement + +The v2.2 protocol uses MAC address sorting to determine connection direction: the device with the numerically lower MAC address initiates the connection (acts as BLE central). However, some hardware platforms (notably ESP32-S3) cannot reliably operate as BLE central due to stack limitations. + +When such a device has a lower MAC address than a peer, neither device initiates a connection: +- The peripheral-only device cannot initiate (hardware limitation) +- The peer waits for the lower-MAC device to initiate (per v2.2 protocol) + +### 1.2 Solution + +v0.3.0 introduces **capability flags** in the advertising packet via BLE manufacturer-specific data. Devices advertise their role capabilities, allowing the connection direction logic to be overridden when one device is peripheral-only. + +## 2. Manufacturer-Specific Data Format + +### 2.1 Advertising Data Structure + +v0.3.0 devices include manufacturer-specific data in their advertising packet: + +``` +AD Type: 0xFF (Manufacturer Specific Data) +Length: 5 bytes (1 type + 4 data) + +Data Format (4 bytes): +┌─────────┬─────────┬─────────┬─────────┐ +│ Byte 0 │ Byte 1 │ Byte 2 │ Byte 3 │ +├─────────┼─────────┼─────────┼─────────┤ +│ CID Low │ CID High│ Version │ Flags │ +└─────────┴─────────┴─────────┴─────────┘ + +CID (Bytes 0-1): Company ID, little-endian + 0xFFFF = Reserved for testing (Bluetooth SIG) + +Version (Byte 2): Protocol version + 0x03 = v0.3.0 + +Flags (Byte 3): Capability flags + Bit 0: PERIPHERAL_ONLY (1 = cannot act as central) + Bit 1: Reserved (CENTRAL_ONLY, future use) + Bits 2-7: Reserved (must be 0) +``` + +### 2.2 Example Values + +| Device Type | CID | Version | Flags | Raw Bytes | +|-------------|-----|---------|-------|-----------| +| Dual-mode (full capability) | 0xFFFF | 0x03 | 0x00 | `FF FF 03 00` | +| Peripheral-only (ESP32-S3) | 0xFFFF | 0x03 | 0x01 | `FF FF 03 01` | + +### 2.3 Advertising Packet Layout + +The v0.3.0 advertising packet extends v2.2: + +``` +Main Advertising Packet (31 bytes max): +├── Flags (3 bytes) +├── Complete 128-bit Service UUID (18 bytes) +│ └── 37145b00-442d-4a94-917f-8f42c5da28e3 +├── Manufacturer Data (5 bytes) ← NEW in v0.3.0 +│ ├── AD Type 0xFF (1 byte) +│ └── Data (4 bytes): CID + Version + Flags +└── Remaining: 5 bytes available + +Scan Response Packet (31 bytes max): +└── Device Name: "RNS-{identity}" (variable) +``` + +## 3. Connection Direction Logic + +### 3.1 Decision Algorithm + +``` +FUNCTION shouldInitiateConnection(local_device, peer_device): + + local_peripheral_only = local_device.flags & PERIPHERAL_ONLY + peer_peripheral_only = peer_device.flags & PERIPHERAL_ONLY + + # Case 1: Peer is peripheral-only, we are not + IF peer_peripheral_only AND NOT local_peripheral_only: + RETURN TRUE # We MUST initiate (peer cannot) + + # Case 2: We are peripheral-only, peer is not + IF local_peripheral_only AND NOT peer_peripheral_only: + RETURN FALSE # Peer MUST initiate (we cannot) + + # Case 3: Both are peripheral-only (deadlock) + IF peer_peripheral_only AND local_peripheral_only: + LOG_WARNING("Both devices peripheral-only, connection impossible") + RETURN FALSE # Neither can initiate + + # Case 4: Both have full capability - use v2.2 MAC sorting + RETURN local_device.mac < peer_device.mac +``` + +### 3.2 Capability Detection + +When a device does not advertise manufacturer data (v2.2 device): +- Assume `has_capability_data = false` +- Assume `capability_flags = 0x00` (full capability) +- Fall back to v2.2 MAC sorting + +When manufacturer data is present: +- Verify Company ID = 0xFFFF +- Verify Version >= 0x03 +- Extract capability flags from byte 3 + +## 4. Backwards Compatibility + +### 4.1 Compatibility Matrix + +| Our Device | Peer Device | Connection Decision | Result | +|------------|-------------|---------------------|--------| +| v0.3.0 dual | v0.3.0 dual | MAC sorting | Works | +| v0.3.0 dual | v0.3.0 P-only | We initiate | Works | +| v0.3.0 P-only | v0.3.0 dual | Peer initiates | Works | +| v0.3.0 dual | v0.2.x | MAC sorting | Works | +| v0.3.0 P-only | v0.2.x (lower MAC) | v0.2.x initiates | Works | +| v0.3.0 P-only | v0.2.x (higher MAC) | Neither initiates | **Fails** | +| v0.3.0 P-only | v0.3.0 P-only | Neither initiates | **Fails** | + +### 4.2 Known Limitations + +1. **v0.3.0 peripheral-only ↔ v0.2.x with higher MAC**: No connection possible. The v0.2.x device uses MAC sorting and waits for the lower-MAC device (the v0.3.0 P-only) to initiate. + + **Mitigation**: Upgrade the v0.2.x device to v0.3.0. + +2. **Two peripheral-only devices**: Connection impossible as neither can initiate. + + **Mitigation**: Ensure at least one device in the mesh has full capability. + +## 5. GATT Service (Unchanged from v2.2) + +The GATT service structure remains unchanged: + +``` +Reticulum Service: 37145b00-442d-4a94-917f-8f42c5da28e3 +├── RX Characteristic: 37145b00-442d-4a94-917f-8f42c5da28e5 +│ └── Properties: WRITE, WRITE_WITHOUT_RESPONSE +├── TX Characteristic: 37145b00-442d-4a94-917f-8f42c5da28e4 +│ └── Properties: READ, NOTIFY +│ └── CCCD: 00002902-0000-1000-8000-00805f9b34fb +└── Identity Characteristic: 37145b00-442d-4a94-917f-8f42c5da28e6 + └── Properties: READ + └── Value: 16-byte identity hash +``` + +## 6. Implementation Notes + +### 6.1 NimBLE (ESP32) + +```cpp +// Setting manufacturer data +uint8_t mfr_data[4] = {0xFF, 0xFF, 0x03, peripheral_only ? 0x01 : 0x00}; +advertising->setManufacturerData(mfr_data, sizeof(mfr_data)); + +// Parsing manufacturer data +if (device->haveManufacturerData()) { + std::string data = device->getManufacturerData(); + if (data.size() >= 4) { + uint16_t cid = (uint8_t)data[0] | ((uint8_t)data[1] << 8); + if (cid == 0xFFFF && data[2] >= 0x03) { + capability_flags = data[3]; + } + } +} +``` + +### 6.2 Android + +```kotlin +// Setting manufacturer data (note: Android API excludes CID in the byte array) +val mfrData = byteArrayOf(0x03.toByte(), flags.toByte()) +advertiseData.addManufacturerData(0xFFFF, mfrData) + +// Parsing manufacturer data +val mfrData = scanRecord.getManufacturerSpecificData(0xFFFF) +if (mfrData != null && mfrData.size >= 2 && mfrData[0] >= 0x03.toByte()) { + capabilityFlags = mfrData[1].toInt() and 0xFF +} +``` + +### 6.3 Python (BlueZ/Bleak) + +```python +# Setting manufacturer data in advertisement +# BlueZ uses D-Bus ManufacturerData property +manufacturer_data = {0xFFFF: bytes([0x03, 0x01 if peripheral_only else 0x00])} + +# Parsing manufacturer data from scan +mfr_data = device.metadata.get("manufacturer_data", {}).get(0xFFFF) +if mfr_data and len(mfr_data) >= 2 and mfr_data[0] >= 0x03: + capability_flags = mfr_data[1] +``` + +## 7. Future Extensions + +Reserved capability flag bits for potential future use: + +| Bit | Name | Description | +|-----|------|-------------| +| 0 | PERIPHERAL_ONLY | Cannot act as BLE central | +| 1 | CENTRAL_ONLY | Cannot act as BLE peripheral | +| 2 | HIGH_BANDWIDTH | Supports extended MTU/PHY | +| 3 | RELAY_CAPABLE | Can relay packets in mesh | +| 4-7 | Reserved | Must be 0 | + +## 8. Version History + +| Version | Date | Changes | +|---------|------|---------| +| v2.0 | Oct 2025 | Identity characteristic for peer identification | +| v2.1 | Oct 2025 | (Deprecated) Identity in device name | +| v2.2 | Nov 2025 | Identity handshake protocol, identity-based keying | +| v0.3.0 | Dec 2025 | Capability advertisement for peripheral-only devices | + +## 9. References + +- [BLE-Reticulum Protocol v2.2](BLE_PROTOCOL_v2.2.md) - Full protocol specification +- [Bluetooth Core Specification](https://www.bluetooth.com/specifications/specs/core-specification/) - BLE advertising data format +- [Bluetooth Assigned Numbers](https://www.bluetooth.com/specifications/assigned-numbers/) - Company IDs diff --git a/src/ble_reticulum/BLEInterface.py b/src/ble_reticulum/BLEInterface.py index 1f67909..1cdf78c 100644 --- a/src/ble_reticulum/BLEInterface.py +++ b/src/ble_reticulum/BLEInterface.py @@ -376,6 +376,7 @@ class BLEInterface(Interface): self.spawned_interfaces = {} # identity_hash (16 hex chars) -> BLEPeerInterface self.address_to_identity = {} # address -> peer_identity (16-byte identity) self.identity_to_address = {} # identity_hash -> address (for reverse lookup) + self.address_to_interface = {} # address -> BLEPeerInterface (for cleanup fallback) # Cache for recently disconnected identities (address -> (identity, timestamp)) # Used to restore identity when peer reconnects before cache expiry (60s) self._identity_cache = {} @@ -681,12 +682,13 @@ class BLEInterface(Interface): def _periodic_cleanup_task(self): """ - Periodically clean up stale reassembly buffers (CRITICAL #2: prevent memory leak) + Periodically clean up stale reassembly buffers and orphaned interfaces. - This task runs every 30 seconds to remove incomplete packet reassembly buffers - that have timed out. Without this, failed transmissions would leave buffers in - memory indefinitely, leading to memory exhaustion on long-running instances - (especially critical on Pi Zero with only 512MB RAM). + This task runs every 30 seconds to: + 1. Remove incomplete packet reassembly buffers that have timed out + (prevents memory exhaustion on long-running instances) + 2. Validate spawned interfaces against actual connections + (catches orphaned interfaces from race conditions) """ if not self.online: return # Don't reschedule if interface is offline @@ -704,9 +706,70 @@ class BLEInterface(Interface): RNS.log(f"{self} periodic cleanup: removed {total_cleaned} stale reassembly buffer(s) total", RNS.LOG_INFO) + # Validate spawned interfaces against actual connections + self._validate_spawned_interfaces() + # Reschedule for next cleanup cycle self._start_cleanup_timer() + def _validate_spawned_interfaces(self): + """ + Validate that all spawned interfaces have actual underlying connections. + + Cleans up orphaned interfaces where the BLE connection is gone but the + interface remains (race condition protection). This is a safety net for + cases where cleanup in disconnect callbacks fails due to timing issues. + """ + try: + # Get list of actually connected peers from driver + connected_addresses = set(self.driver.connected_peers) + + # Check all address_to_interface entries + orphaned = [] + for address, peer_if in list(self.address_to_interface.items()): + if address not in connected_addresses: + # Connection is gone but interface remains + orphaned.append((address, peer_if)) + + # Clean up orphaned interfaces + for address, peer_if in orphaned: + RNS.log(f"{self} cleaning up orphaned interface for {address} (no active connection)", RNS.LOG_WARNING) + + # Get identity info from interface + peer_identity = None + identity_hash = None + if peer_if.peer_identity: + peer_identity = peer_if.peer_identity + identity_hash = self._compute_identity_hash(peer_identity) + + # Detach the interface + peer_if.detach() + + # Remove from all tracking dicts + if address in self.address_to_interface: + del self.address_to_interface[address] + if identity_hash and identity_hash in self.spawned_interfaces: + del self.spawned_interfaces[identity_hash] + if address in self.address_to_identity: + del self.address_to_identity[address] + if identity_hash and identity_hash in self.identity_to_address: + del self.identity_to_address[identity_hash] + + # Clean up fragmentation state + if peer_identity: + frag_key = self._get_fragmenter_key(peer_identity, address) + with self.frag_lock: + if frag_key in self.fragmenters: + del self.fragmenters[frag_key] + if frag_key in self.reassemblers: + del self.reassemblers[frag_key] + + if orphaned: + RNS.log(f"{self} periodic validation: cleaned up {len(orphaned)} orphaned interface(s)", RNS.LOG_INFO) + + except Exception as e: + RNS.log(f"{self} error during interface validation (non-fatal): {e}", RNS.LOG_WARNING) + def _device_discovered_callback(self, device: BLEDevice): """ Driver callback: Handle discovered BLE device. @@ -978,6 +1041,8 @@ class BLEInterface(Interface): Driver callback: Handle device disconnection. Cleans up peer state, interfaces, and fragmentation buffers. + Uses dual-index approach: tries identity lookup first, falls back to + address_to_interface for reliable cleanup even when identity unavailable. """ RNS.log(f"{self} disconnected from {address}", RNS.LOG_INFO) @@ -986,8 +1051,11 @@ class BLEInterface(Interface): if address in self.peers: del self.peers[address] - # Detach interface + # Try identity-based lookup first peer_identity = self.address_to_identity.get(address) + peer_if = None + identity_hash = None + if peer_identity: identity_hash = self._compute_identity_hash(peer_identity) @@ -996,19 +1064,41 @@ class BLEInterface(Interface): self._identity_cache[address] = (peer_identity, time.time()) RNS.log(f"{self} cached identity for {address} (TTL {self._identity_cache_ttl}s)", RNS.LOG_DEBUG) - if identity_hash in self.spawned_interfaces: - peer_if = self.spawned_interfaces[identity_hash] - peer_if.detach() - del self.spawned_interfaces[identity_hash] - RNS.log(f"{self} detached interface for {address}", RNS.LOG_DEBUG) + # Get interface via identity + peer_if = self.spawned_interfaces.get(identity_hash) - # Clean up identity mappings to prevent stale connections - if address in self.address_to_identity: - del self.address_to_identity[address] - RNS.log(f"{self} cleaned up address_to_identity for {address}", RNS.LOG_DEBUG) - if identity_hash in self.identity_to_address: - del self.identity_to_address[identity_hash] - RNS.log(f"{self} cleaned up identity_to_address for {identity_hash}", RNS.LOG_DEBUG) + # Fallback: if no identity or interface not found via identity, try direct address lookup + if peer_if is None: + peer_if = self.address_to_interface.get(address) + if peer_if: + RNS.log(f"{self} using address-based fallback for cleanup of {address}", RNS.LOG_DEBUG) + # Get identity from the interface itself + if peer_if.peer_identity: + peer_identity = peer_if.peer_identity + identity_hash = self._compute_identity_hash(peer_identity) + + # Detach interface if found + if peer_if: + peer_if.detach() + RNS.log(f"{self} detached interface for {address}", RNS.LOG_DEBUG) + + # Clean up spawned_interfaces dict + if identity_hash and identity_hash in self.spawned_interfaces: + del self.spawned_interfaces[identity_hash] + else: + RNS.log(f"{self} no interface found for disconnected {address} (may have been cleaned already)", RNS.LOG_DEBUG) + + # Always clean up address_to_interface mapping + if address in self.address_to_interface: + del self.address_to_interface[address] + + # Clean up identity mappings + if address in self.address_to_identity: + del self.address_to_identity[address] + RNS.log(f"{self} cleaned up address_to_identity for {address}", RNS.LOG_DEBUG) + if identity_hash and identity_hash in self.identity_to_address: + del self.identity_to_address[identity_hash] + RNS.log(f"{self} cleaned up identity_to_address for {identity_hash}", RNS.LOG_DEBUG) # Clean up fragmenter/reassembler if peer_identity: @@ -1049,6 +1139,8 @@ class BLEInterface(Interface): del self.identity_to_address[identity_hash] if old_address in self.address_to_identity: del self.address_to_identity[old_address] + if old_address in self.address_to_interface: + del self.address_to_interface[old_address] # Clean up fragmenter/reassembler for old address if peer_identity: @@ -1102,6 +1194,11 @@ class BLEInterface(Interface): computed_hash = self._compute_identity_hash(peer_identity) self.identity_to_address[computed_hash] = new_address + # Migrate address_to_interface mapping + if old_address in self.address_to_interface: + interface = self.address_to_interface.pop(old_address) + self.address_to_interface[new_address] = interface + # Migrate fragmenter/reassembler from old to new key old_frag_key = self._get_fragmenter_key(peer_identity, old_address) new_frag_key = self._get_fragmenter_key(peer_identity, new_address) @@ -1548,10 +1645,13 @@ class BLEInterface(Interface): # Compute lookup key using identity hash identity_hash = self._compute_identity_hash(peer_identity) - # Check if interface already exists (MAC sorting should prevent this) + # Check if interface already exists (MAC rotation causes same identity at different addresses) if identity_hash in self.spawned_interfaces: - RNS.log(f"{self} interface already exists for {name} ({identity_hash[:8]}), reusing", RNS.LOG_WARNING) - return self.spawned_interfaces[identity_hash] + existing_if = self.spawned_interfaces[identity_hash] + # Update address_to_interface for the new address (critical for cleanup) + self.address_to_interface[address] = existing_if + RNS.log(f"{self} interface already exists for {name} ({identity_hash[:8]}), reusing (added address mapping for {address})", RNS.LOG_DEBUG) + return existing_if # Create new peer interface peer_if = BLEPeerInterface(self, address, name, peer_identity) @@ -1565,8 +1665,9 @@ class BLEInterface(Interface): # Register with transport RNS.Transport.interfaces.append(peer_if) - # Store in tracking dict + # Store in tracking dicts (dual-indexed for reliable cleanup) self.spawned_interfaces[identity_hash] = peer_if + self.address_to_interface[address] = peer_if RNS.log(f"{self} created peer interface for {name} ({identity_hash[:8]}), type={connection_type}", RNS.LOG_INFO) @@ -1830,35 +1931,58 @@ class BLEInterface(Interface): """ Handle a central device disconnecting from our GATT server. + Uses dual-index approach: tries identity lookup first, falls back to + address_to_interface for reliable cleanup even when identity unavailable. + Args: address: BLE address of the central device """ RNS.log(f"{self} central disconnected: {address}", RNS.LOG_INFO) - # Look up peer identity + # Try identity-based lookup first peer_identity = self.address_to_identity.get(address, None) + peer_if = None + identity_hash = None - if not peer_identity: - RNS.log(f"{self} no identity for disconnected central {address}", RNS.LOG_WARNING) - return + if peer_identity: + identity_hash = self._compute_identity_hash(peer_identity) + peer_if = self.spawned_interfaces.get(identity_hash) - # Find and detach interface - identity_hash = self._compute_identity_hash(peer_identity) - if identity_hash in self.spawned_interfaces: - peer_if = self.spawned_interfaces[identity_hash] + # Fallback: if no identity or interface not found via identity, try direct address lookup + if peer_if is None: + peer_if = self.address_to_interface.get(address) + if peer_if: + RNS.log(f"{self} using address-based fallback for cleanup of central {address}", RNS.LOG_DEBUG) + # Get identity from the interface itself + if peer_if.peer_identity: + peer_identity = peer_if.peer_identity + identity_hash = self._compute_identity_hash(peer_identity) + + # Detach interface if found + if peer_if: peer_if.detach() - del self.spawned_interfaces[identity_hash] RNS.log(f"{self} detached interface for {address}", RNS.LOG_DEBUG) - # Clean up identity mappings to prevent stale connections - if address in self.address_to_identity: - del self.address_to_identity[address] - RNS.log(f"{self} cleaned up address_to_identity for {address}", RNS.LOG_DEBUG) - if identity_hash in self.identity_to_address: - del self.identity_to_address[identity_hash] - RNS.log(f"{self} cleaned up identity_to_address for {identity_hash}", RNS.LOG_DEBUG) + # Clean up spawned_interfaces dict + if identity_hash and identity_hash in self.spawned_interfaces: + del self.spawned_interfaces[identity_hash] + else: + RNS.log(f"{self} no interface found for disconnected central {address} (may have been cleaned already)", RNS.LOG_DEBUG) - # Clean up fragmenter/reassembler + # Always clean up address_to_interface mapping + if address in self.address_to_interface: + del self.address_to_interface[address] + + # Clean up identity mappings + if address in self.address_to_identity: + del self.address_to_identity[address] + RNS.log(f"{self} cleaned up address_to_identity for {address}", RNS.LOG_DEBUG) + if identity_hash and identity_hash in self.identity_to_address: + del self.identity_to_address[identity_hash] + RNS.log(f"{self} cleaned up identity_to_address for {identity_hash}", RNS.LOG_DEBUG) + + # Clean up fragmenter/reassembler + if peer_identity: frag_key = self._get_fragmenter_key(peer_identity, address) with self.frag_lock: if frag_key in self.reassemblers: @@ -1926,6 +2050,7 @@ class BLEInterface(Interface): for peer_if in list(self.spawned_interfaces.values()): peer_if.detach() self.spawned_interfaces.clear() + self.address_to_interface.clear() # Clear fragmentation state with self.frag_lock: From f21382acd52cd6924e97f19ef905dc80b6bdc8c5 Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Wed, 31 Dec 2025 22:57:36 -0500 Subject: [PATCH 2/6] fix: add identity handshake timeout for non-Reticulum connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connections from non-Reticulum BLE devices (AirTags, BLE scanners, etc.) that connect to our GATT server but never complete the identity handshake are now automatically disconnected after 30 seconds. Changes: - Track pending identity connections with timestamps in _pending_identity_connections - Add _cleanup_pending_identity_connections() to disconnect stale connections - Remove from pending tracking when identity is provided in callback - Add debug logging for cleanup timer operations This prevents non-protocol devices from appearing indefinitely in the BLE connections list. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/ble_reticulum/BLEInterface.py | 58 +++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/ble_reticulum/BLEInterface.py b/src/ble_reticulum/BLEInterface.py index 1cdf78c..459235e 100644 --- a/src/ble_reticulum/BLEInterface.py +++ b/src/ble_reticulum/BLEInterface.py @@ -382,6 +382,11 @@ class BLEInterface(Interface): self._identity_cache = {} self._identity_cache_ttl = 60 # seconds + # Pending connections awaiting identity handshake (address -> timestamp) + # If identity not received within timeout, connection is closed + self._pending_identity_connections = {} + self._pending_identity_timeout = 30 # seconds + # Fragmentation self.fragmenters = {} # address -> BLEFragmenter (per MTU) self.reassemblers = {} # address -> BLEReassembler @@ -679,6 +684,7 @@ class BLEInterface(Interface): self.cleanup_timer = threading.Timer(30.0, self._periodic_cleanup_task) self.cleanup_timer.daemon = True self.cleanup_timer.start() + RNS.log(f"{self} cleanup timer started (30s interval)", RNS.LOG_DEBUG) def _periodic_cleanup_task(self): """ @@ -693,6 +699,8 @@ class BLEInterface(Interface): if not self.online: return # Don't reschedule if interface is offline + RNS.log(f"{self} periodic cleanup running, pending identity connections: {len(self._pending_identity_connections)}", RNS.LOG_DEBUG) + with self.frag_lock: total_cleaned = 0 for peer_address, reassembler in list(self.reassemblers.items()): @@ -709,9 +717,42 @@ class BLEInterface(Interface): # Validate spawned interfaces against actual connections self._validate_spawned_interfaces() + # Check for pending connections that never received identity (timeout) + self._cleanup_pending_identity_connections() + # Reschedule for next cleanup cycle self._start_cleanup_timer() + def _cleanup_pending_identity_connections(self): + """ + Disconnect connections that never completed identity handshake. + + This handles cases like non-Reticulum BLE devices (AirTags, scanners) + that connect to our GATT server but never send the identity handshake. + These connections are tracked when established and disconnected if + identity is not received within the timeout period. + """ + now = time.time() + timed_out = [] + + for address, connect_time in list(self._pending_identity_connections.items()): + elapsed = now - connect_time + if elapsed > self._pending_identity_timeout: + timed_out.append(address) + RNS.log( + f"{self} connection from {address} timed out waiting for identity " + f"({elapsed:.1f}s > {self._pending_identity_timeout}s), disconnecting", + RNS.LOG_WARNING + ) + + # Disconnect timed-out connections + for address in timed_out: + del self._pending_identity_connections[address] + try: + self.driver.disconnect(address) + except Exception as e: + RNS.log(f"{self} error disconnecting timed-out connection {address}: {e}", RNS.LOG_ERROR) + def _validate_spawned_interfaces(self): """ Validate that all spawned interfaces have actual underlying connections. @@ -849,6 +890,10 @@ class BLEInterface(Interface): RNS.log(f"{self} connected to {address} as {role_str}, received identity: {identity_hash}", RNS.LOG_INFO) self._record_connection_success(address) + # Remove from pending identity tracking if it was tracked + if address in self._pending_identity_connections: + del self._pending_identity_connections[address] + # Check for pending MTU (race condition: MTU negotiated before identity) if address in self.pending_mtu: pending_mtu = self.pending_mtu.pop(address) @@ -862,11 +907,15 @@ class BLEInterface(Interface): elif role == "peripheral": # Peripheral mode: identity will arrive via handshake RNS.log(f"{self} connected to {address} as PERIPHERAL, waiting for identity handshake...", RNS.LOG_INFO) + # Track pending connection - will timeout if identity never arrives + self._pending_identity_connections[address] = time.time() # The identity will be received in `_data_received_callback` else: - RNS.log(f"{self} connected to {address}, but identity not provided and role is {role}. Disconnecting.", RNS.LOG_WARNING) - self.driver.disconnect(address) + # Connection without identity from non-peripheral role + # Track as pending - could be a device that connects but never handshakes + RNS.log(f"{self} connected to {address} (role={role}) without identity, tracking for timeout", RNS.LOG_INFO) + self._pending_identity_connections[address] = time.time() def _check_duplicate_identity(self, address: str, peer_identity: bytes) -> bool: """ @@ -1016,6 +1065,11 @@ class BLEInterface(Interface): ) RNS.log(f"{self} identity handshake complete for {address}", RNS.LOG_INFO) + + # Remove from pending identity tracking (no longer waiting for handshake) + if address in self._pending_identity_connections: + del self._pending_identity_connections[address] + return True # Handshake processed successfully except Exception as e: From c76cfc1dd01964d889f940f5ae7d29cfd102bf6a Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Thu, 1 Jan 2026 13:23:43 -0500 Subject: [PATCH 3/6] fix(ble): prevent interface/fragmenter loss during MAC rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite _validate_spawned_interfaces() with 3-pass approach: - Pass 1: Collect orphaned addresses - Pass 2: Clean up address mappings, track interfaces to detach - Pass 3: Only detach interfaces with zero connected addresses - Fragmenters only cleaned up when interface fully detached - Enhance _spawn_peer_interface() reuse logic: - Update address_to_identity and identity_to_address when reusing - Cancel pending detach for the identity - Mark interface as online - Fix disconnect callbacks to preserve fragmenters: - _device_disconnected_callback: defer fragmenter cleanup to grace period - handle_central_disconnected: same fragmenter preservation - _process_pending_detaches: clean up fragmenters on actual detach - Rename _cleanup_stale_interface() to _cleanup_stale_address(): - No longer detaches interface during MAC rotation - Only cleans up stale address-specific mappings - Interface preserved for reuse with new address Fixes orphaned peer interfaces and "No fragmenter for peer" warnings during BLE MAC address rotation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/ble_reticulum/BLEInterface.py | 345 +++++++++++++++++++----------- 1 file changed, 224 insertions(+), 121 deletions(-) diff --git a/src/ble_reticulum/BLEInterface.py b/src/ble_reticulum/BLEInterface.py index 459235e..d52f055 100644 --- a/src/ble_reticulum/BLEInterface.py +++ b/src/ble_reticulum/BLEInterface.py @@ -387,6 +387,11 @@ class BLEInterface(Interface): self._pending_identity_connections = {} self._pending_identity_timeout = 30 # seconds + # Pending interface detachments with grace period (identity_hash -> timestamp) + # Allows new connections to establish before detaching the interface + self._pending_detach = {} + self._pending_detach_grace_period = 2.0 # seconds + # Fragmentation self.fragmenters = {} # address -> BLEFragmenter (per MTU) self.reassemblers = {} # address -> BLEReassembler @@ -684,7 +689,7 @@ class BLEInterface(Interface): self.cleanup_timer = threading.Timer(30.0, self._periodic_cleanup_task) self.cleanup_timer.daemon = True self.cleanup_timer.start() - RNS.log(f"{self} cleanup timer started (30s interval)", RNS.LOG_DEBUG) + RNS.log(f"{self} cleanup timer started (30s interval)", RNS.LOG_WARNING) def _periodic_cleanup_task(self): """ @@ -699,7 +704,7 @@ class BLEInterface(Interface): if not self.online: return # Don't reschedule if interface is offline - RNS.log(f"{self} periodic cleanup running, pending identity connections: {len(self._pending_identity_connections)}", RNS.LOG_DEBUG) + RNS.log(f"{self} periodic cleanup running, pending identity connections: {len(self._pending_identity_connections)}", RNS.LOG_WARNING) with self.frag_lock: total_cleaned = 0 @@ -720,6 +725,9 @@ class BLEInterface(Interface): # Check for pending connections that never received identity (timeout) self._cleanup_pending_identity_connections() + # Process pending interface detachments (after grace period) + self._process_pending_detaches() + # Reschedule for next cleanup cycle self._start_cleanup_timer() @@ -753,6 +761,60 @@ class BLEInterface(Interface): except Exception as e: RNS.log(f"{self} error disconnecting timed-out connection {address}: {e}", RNS.LOG_ERROR) + def _process_pending_detaches(self): + """ + Process pending interface detachments after grace period. + + When an address disconnects, we schedule the interface for delayed detachment + to allow new connections with the same identity to establish first (MAC rotation). + After the grace period, we check again if any addresses are connected with that + identity - if not, we detach the interface. + """ + now = time.time() + to_detach = [] + + for identity_hash, scheduled_time in list(self._pending_detach.items()): + elapsed = now - scheduled_time + if elapsed >= self._pending_detach_grace_period: + # Grace period expired - check if any addresses now have this identity + has_connected_address = False + for addr, identity in self.address_to_identity.items(): + if self._compute_identity_hash(identity) == identity_hash: + has_connected_address = True + break + + if has_connected_address: + # New connection arrived during grace period - cancel detach + RNS.log(f"{self} cancelled detach for {identity_hash[:8]} - address reconnected during grace period", RNS.LOG_DEBUG) + else: + # No connections - safe to detach + to_detach.append(identity_hash) + + # Actually detach interfaces + for identity_hash in to_detach: + del self._pending_detach[identity_hash] + peer_if = self.spawned_interfaces.get(identity_hash) + if peer_if: + # Get peer_identity for fragmenter cleanup before detaching + peer_identity = peer_if.peer_identity + peer_if.detach() + RNS.log(f"{self} detached interface for {identity_hash[:8]} after grace period", RNS.LOG_DEBUG) + if identity_hash in self.spawned_interfaces: + del self.spawned_interfaces[identity_hash] + if identity_hash in self.identity_to_address: + del self.identity_to_address[identity_hash] + # Clean up fragmenter/reassembler now that interface is fully detached + if peer_identity: + frag_key = self._get_fragmenter_key(peer_identity, "") # Address unused in key computation + with self.frag_lock: + if frag_key in self.fragmenters: + del self.fragmenters[frag_key] + RNS.log(f"{self} cleaned up fragmenter for {identity_hash[:8]}", RNS.LOG_DEBUG) + if frag_key in self.reassemblers: + del self.reassemblers[frag_key] + else: + RNS.log(f"{self} pending detach for {identity_hash[:8]} but interface already gone", RNS.LOG_DEBUG) + def _validate_spawned_interfaces(self): """ Validate that all spawned interfaces have actual underlying connections. @@ -765,48 +827,73 @@ class BLEInterface(Interface): # Get list of actually connected peers from driver connected_addresses = set(self.driver.connected_peers) - # Check all address_to_interface entries - orphaned = [] - for address, peer_if in list(self.address_to_interface.items()): + # First pass: collect orphaned address mappings (addresses not in connected_addresses) + orphaned_addresses = [] + for address in list(self.address_to_interface.keys()): if address not in connected_addresses: - # Connection is gone but interface remains - orphaned.append((address, peer_if)) + orphaned_addresses.append(address) - # Clean up orphaned interfaces - for address, peer_if in orphaned: - RNS.log(f"{self} cleaning up orphaned interface for {address} (no active connection)", RNS.LOG_WARNING) + # Second pass: for each orphaned address, clean up mappings and check if interface should be detached + interfaces_to_detach = set() # Use set to avoid detaching same interface multiple times - # Get identity info from interface + for address in orphaned_addresses: + peer_if = self.address_to_interface.get(address) + if not peer_if: + continue + + RNS.log(f"{self} cleaning up orphaned address mapping for {address}", RNS.LOG_DEBUG) + + # Get identity info peer_identity = None identity_hash = None if peer_if.peer_identity: peer_identity = peer_if.peer_identity identity_hash = self._compute_identity_hash(peer_identity) - # Detach the interface - peer_if.detach() - - # Remove from all tracking dicts + # Remove address-specific mappings if address in self.address_to_interface: del self.address_to_interface[address] - if identity_hash and identity_hash in self.spawned_interfaces: - del self.spawned_interfaces[identity_hash] if address in self.address_to_identity: del self.address_to_identity[address] - if identity_hash and identity_hash in self.identity_to_address: - del self.identity_to_address[identity_hash] - # Clean up fragmentation state + # NOTE: Do NOT clean up fragmenters here - they are keyed by identity, not address + # Fragmenters are only cleaned up when the interface is fully detached (third pass) + + # Check if ANY other addresses still use this identity/interface + if identity_hash: + other_addresses_connected = False + for other_addr in list(self.address_to_interface.keys()): + if other_addr in connected_addresses: + other_if = self.address_to_interface.get(other_addr) + if other_if == peer_if: + other_addresses_connected = True + # Update identity_to_address to point to a connected address + self.identity_to_address[identity_hash] = other_addr + break + + if not other_addresses_connected: + # No other addresses connected with this identity - mark for detach + interfaces_to_detach.add((peer_if, identity_hash, peer_identity)) + + # Third pass: detach interfaces that have no connected addresses + for peer_if, identity_hash, peer_identity in interfaces_to_detach: + RNS.log(f"{self} detaching orphaned interface for {identity_hash[:8]} (no active connections)", RNS.LOG_WARNING) + peer_if.detach() + if identity_hash in self.spawned_interfaces: + del self.spawned_interfaces[identity_hash] + if identity_hash in self.identity_to_address: + del self.identity_to_address[identity_hash] + # Clean up fragmenter/reassembler only when interface is fully detached if peer_identity: - frag_key = self._get_fragmenter_key(peer_identity, address) + frag_key = self._get_fragmenter_key(peer_identity, "") # Address unused in key computation with self.frag_lock: if frag_key in self.fragmenters: del self.fragmenters[frag_key] if frag_key in self.reassemblers: del self.reassemblers[frag_key] - if orphaned: - RNS.log(f"{self} periodic validation: cleaned up {len(orphaned)} orphaned interface(s)", RNS.LOG_INFO) + if orphaned_addresses: + RNS.log(f"{self} periodic validation: cleaned up {len(orphaned_addresses)} orphaned address(es), detached {len(interfaces_to_detach)} interface(s)", RNS.LOG_INFO) except Exception as e: RNS.log(f"{self} error during interface validation (non-fatal): {e}", RNS.LOG_WARNING) @@ -882,6 +969,11 @@ class BLEInterface(Interface): if len(peer_identity) == 16: identity_hash = self._compute_identity_hash(peer_identity) + # Cancel any pending detach for this identity (new connection arrived in time) + if identity_hash in self._pending_detach: + del self._pending_detach[identity_hash] + RNS.log(f"{self} cancelled pending detach for {identity_hash[:8]} (new connection from {address})", RNS.LOG_DEBUG) + # Store identity mappings self.address_to_identity[address] = peer_identity self.identity_to_address[identity_hash] = address @@ -1131,85 +1223,80 @@ class BLEInterface(Interface): peer_identity = peer_if.peer_identity identity_hash = self._compute_identity_hash(peer_identity) - # Detach interface if found - if peer_if: - peer_if.detach() - RNS.log(f"{self} detached interface for {address}", RNS.LOG_DEBUG) - - # Clean up spawned_interfaces dict - if identity_hash and identity_hash in self.spawned_interfaces: - del self.spawned_interfaces[identity_hash] - else: - RNS.log(f"{self} no interface found for disconnected {address} (may have been cleaned already)", RNS.LOG_DEBUG) - - # Always clean up address_to_interface mapping + # Clean up address-specific mappings first (before checking for other addresses) if address in self.address_to_interface: del self.address_to_interface[address] - # Clean up identity mappings if address in self.address_to_identity: del self.address_to_identity[address] RNS.log(f"{self} cleaned up address_to_identity for {address}", RNS.LOG_DEBUG) - if identity_hash and identity_hash in self.identity_to_address: - del self.identity_to_address[identity_hash] - RNS.log(f"{self} cleaned up identity_to_address for {identity_hash}", RNS.LOG_DEBUG) - # Clean up fragmenter/reassembler - if peer_identity: - frag_key = self._get_fragmenter_key(peer_identity, address) - with self.frag_lock: - if frag_key in self.fragmenters: - del self.fragmenters[frag_key] - if frag_key in self.reassemblers: - del self.reassemblers[frag_key] + # Check if any OTHER addresses still have the same identity + # If so, keep the peer interface alive - only detach when ALL addresses are gone + other_addresses_with_identity = [] + if identity_hash: + for other_addr, other_identity in self.address_to_identity.items(): + if other_addr != address: + other_hash = self._compute_identity_hash(other_identity) + if other_hash == identity_hash: + other_addresses_with_identity.append(other_addr) + + if other_addresses_with_identity: + # Other addresses still connected with same identity - keep interface AND fragmenter alive + RNS.log(f"{self} keeping peer interface for {identity_hash[:8]} alive, other addresses still connected: {other_addresses_with_identity}", RNS.LOG_DEBUG) + # Update identity_to_address to point to one of the remaining addresses + self.identity_to_address[identity_hash] = other_addresses_with_identity[0] + # Cancel any pending detach for this identity + if identity_hash in self._pending_detach: + del self._pending_detach[identity_hash] + RNS.log(f"{self} cancelled pending detach for {identity_hash[:8]}", RNS.LOG_DEBUG) + # NOTE: Do NOT clean up fragmenter - it's keyed by identity, not address + # Other addresses are still using it + else: + # No other addresses with this identity YET - schedule detach with grace period + # This allows new connections with the same identity to establish before detaching + if peer_if and identity_hash: + self._pending_detach[identity_hash] = time.time() + RNS.log(f"{self} scheduled detach for {identity_hash[:8]} in {self._pending_detach_grace_period}s", RNS.LOG_DEBUG) + elif not peer_if: + RNS.log(f"{self} no interface found for disconnected {address} (may have been cleaned already)", RNS.LOG_DEBUG) + # NOTE: Fragmenter cleanup happens in _process_pending_detaches after grace period + # This gives new connections time to establish before removing the fragmenter # Clean up pending MTU (from MTU/identity race condition) if address in self.pending_mtu: del self.pending_mtu[address] - def _cleanup_stale_interface(self, identity_hash: str, old_address: str): + def _cleanup_stale_address(self, identity_hash: str, old_address: str): """ - Clean up stale interface after MAC rotation. + Clean up stale address mappings after MAC rotation. Called when we detect the same identity at a new MAC address but the - old connection is no longer alive. This allows reconnection to the - peer at their new MAC address. + old connection is no longer alive. This cleans up old address mappings + but KEEPS the interface alive for reuse with the new address. Args: identity_hash: 16-character hex hash of the peer's identity old_address: The old MAC address that is no longer valid """ - # Get peer identity for fragmenter cleanup - peer_identity = self.address_to_identity.get(old_address) + # NOTE: Do NOT detach the interface here! The interface should be REUSED + # for the new address. _spawn_peer_interface() handles reuse automatically. + # We only clean up stale address-specific mappings. - # Detach and remove old interface - if identity_hash in self.spawned_interfaces: - old_interface = self.spawned_interfaces.pop(identity_hash) - old_interface.detach() - RNS.log(f"{self} detached stale interface for {identity_hash[:8]}", RNS.LOG_DEBUG) - - # Clean up address mappings (both directions) - if identity_hash in self.identity_to_address: - del self.identity_to_address[identity_hash] + # Clean up old address mappings (but keep identity_to_address - it will be updated) if old_address in self.address_to_identity: del self.address_to_identity[old_address] if old_address in self.address_to_interface: del self.address_to_interface[old_address] - # Clean up fragmenter/reassembler for old address - if peer_identity: - frag_key = self._get_fragmenter_key(peer_identity, old_address) - with self.frag_lock: - if frag_key in self.fragmenters: - del self.fragmenters[frag_key] - if frag_key in self.reassemblers: - del self.reassemblers[frag_key] - # Clean up pending MTU for old address if old_address in self.pending_mtu: del self.pending_mtu[old_address] - RNS.log(f"{self} cleaned up stale state for {old_address}", RNS.LOG_DEBUG) + # NOTE: Do NOT clean up fragmenters here - they are keyed by identity, not address + # The fragmenter will continue to work with the new address + + RNS.log(f"{self} cleaned up stale address mappings for {old_address} (interface preserved for reuse)", RNS.LOG_DEBUG) def _address_changed_callback(self, old_address: str, new_address: str, identity_hash: str): """ @@ -1499,10 +1586,11 @@ class BLEInterface(Interface): RNS.LOG_DEBUG) continue else: - # Old connection dead - clean up and allow new connection - RNS.log(f"{self} [v2.2] MAC rotation: {identity_hash[:8]} moved from {existing_address[-8:]} to {address[-8:]}, cleaning up stale interface", + # Old connection dead - clean up stale address mappings and allow new connection + # NOTE: Interface is preserved for reuse, not detached + RNS.log(f"{self} [v2.2] MAC rotation: {identity_hash[:8]} moved from {existing_address[-8:]} to {address[-8:]}, cleaning up stale address", RNS.LOG_INFO) - self._cleanup_stale_interface(identity_hash, existing_address) + self._cleanup_stale_address(identity_hash, existing_address) # Bypass MAC sorting - we must reconnect after MAC rotation # regardless of which device has the higher MAC address score = self._score_peer(peer) @@ -1699,29 +1787,40 @@ class BLEInterface(Interface): # Compute lookup key using identity hash identity_hash = self._compute_identity_hash(peer_identity) - # Check if interface already exists (MAC rotation causes same identity at different addresses) - if identity_hash in self.spawned_interfaces: - existing_if = self.spawned_interfaces[identity_hash] - # Update address_to_interface for the new address (critical for cleanup) - self.address_to_interface[address] = existing_if - RNS.log(f"{self} interface already exists for {name} ({identity_hash[:8]}), reusing (added address mapping for {address})", RNS.LOG_DEBUG) - return existing_if + # Use lock to prevent race condition where two threads create interfaces + # for the same identity simultaneously (e.g., central and peripheral callbacks) + with self.peer_lock: + # Check if interface already exists (MAC rotation causes same identity at different addresses) + if identity_hash in self.spawned_interfaces: + existing_if = self.spawned_interfaces[identity_hash] + # Update all address mappings for the new address (critical for cleanup and routing) + self.address_to_interface[address] = existing_if + self.address_to_identity[address] = peer_identity + self.identity_to_address[identity_hash] = address + # Cancel any pending detach for this identity - new connection arrived + if identity_hash in self._pending_detach: + del self._pending_detach[identity_hash] + RNS.log(f"{self} cancelled pending detach for {identity_hash[:8]} (new connection at {address})", RNS.LOG_DEBUG) + # Mark interface as online in case it was marked offline during pending detach + existing_if.online = True + RNS.log(f"{self} interface already exists for {name} ({identity_hash[:8]}), reusing (added address mapping for {address})", RNS.LOG_DEBUG) + return existing_if - # Create new peer interface - peer_if = BLEPeerInterface(self, address, name, peer_identity) - peer_if.OUT = self.OUT - peer_if.IN = self.IN - peer_if.parent_interface = self - peer_if.bitrate = self.bitrate - peer_if.HW_MTU = self.HW_MTU - peer_if.online = True + # Create new peer interface + peer_if = BLEPeerInterface(self, address, name, peer_identity) + peer_if.OUT = self.OUT + peer_if.IN = self.IN + peer_if.parent_interface = self + peer_if.bitrate = self.bitrate + peer_if.HW_MTU = self.HW_MTU + peer_if.online = True - # Register with transport - RNS.Transport.interfaces.append(peer_if) + # Register with transport + RNS.Transport.interfaces.append(peer_if) - # Store in tracking dicts (dual-indexed for reliable cleanup) - self.spawned_interfaces[identity_hash] = peer_if - self.address_to_interface[address] = peer_if + # Store in tracking dicts (dual-indexed for reliable cleanup) + self.spawned_interfaces[identity_hash] = peer_if + self.address_to_interface[address] = peer_if RNS.log(f"{self} created peer interface for {name} ({identity_hash[:8]}), type={connection_type}", RNS.LOG_INFO) @@ -2012,39 +2111,43 @@ class BLEInterface(Interface): peer_identity = peer_if.peer_identity identity_hash = self._compute_identity_hash(peer_identity) - # Detach interface if found - if peer_if: - peer_if.detach() - RNS.log(f"{self} detached interface for {address}", RNS.LOG_DEBUG) - - # Clean up spawned_interfaces dict - if identity_hash and identity_hash in self.spawned_interfaces: - del self.spawned_interfaces[identity_hash] - else: - RNS.log(f"{self} no interface found for disconnected central {address} (may have been cleaned already)", RNS.LOG_DEBUG) - - # Always clean up address_to_interface mapping + # Clean up address-specific mappings first (before checking for other addresses) if address in self.address_to_interface: del self.address_to_interface[address] - # Clean up identity mappings if address in self.address_to_identity: del self.address_to_identity[address] RNS.log(f"{self} cleaned up address_to_identity for {address}", RNS.LOG_DEBUG) - if identity_hash and identity_hash in self.identity_to_address: - del self.identity_to_address[identity_hash] - RNS.log(f"{self} cleaned up identity_to_address for {identity_hash}", RNS.LOG_DEBUG) - # Clean up fragmenter/reassembler - if peer_identity: - frag_key = self._get_fragmenter_key(peer_identity, address) - with self.frag_lock: - if frag_key in self.reassemblers: - del self.reassemblers[frag_key] - RNS.log(f"{self} cleaned up reassembler for {address}", RNS.LOG_DEBUG) - if frag_key in self.fragmenters: - del self.fragmenters[frag_key] - RNS.log(f"{self} cleaned up fragmenter for {address}", RNS.LOG_DEBUG) + # Check if any OTHER addresses still have the same identity + # If so, keep the peer interface alive - only detach when ALL addresses are gone + other_addresses_with_identity = [] + if identity_hash: + for other_addr, other_identity in self.address_to_identity.items(): + if other_addr != address: + other_hash = self._compute_identity_hash(other_identity) + if other_hash == identity_hash: + other_addresses_with_identity.append(other_addr) + + if other_addresses_with_identity: + # Other addresses still connected with same identity - keep interface AND fragmenter alive + RNS.log(f"{self} keeping peer interface for {identity_hash[:8]} alive (central disconnect), other addresses: {other_addresses_with_identity}", RNS.LOG_DEBUG) + # Update identity_to_address to point to one of the remaining addresses + self.identity_to_address[identity_hash] = other_addresses_with_identity[0] + # Cancel any pending detach for this identity + if identity_hash in self._pending_detach: + del self._pending_detach[identity_hash] + RNS.log(f"{self} cancelled pending detach for {identity_hash[:8]}", RNS.LOG_DEBUG) + # NOTE: Do NOT clean up fragmenter - it's keyed by identity, not address + # Other addresses are still using it + else: + # No other addresses with this identity YET - schedule detach with grace period + if peer_if and identity_hash: + self._pending_detach[identity_hash] = time.time() + RNS.log(f"{self} scheduled detach for {identity_hash[:8]} in {self._pending_detach_grace_period}s (central)", RNS.LOG_DEBUG) + elif not peer_if: + RNS.log(f"{self} no interface found for disconnected central {address} (may have been cleaned already)", RNS.LOG_DEBUG) + # NOTE: Fragmenter cleanup happens in _process_pending_detaches after grace period def process_incoming(self, data): """ From 5947544cd7a431417ee20c9fde3544488ad8fbbe Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Thu, 1 Jan 2026 14:03:59 -0500 Subject: [PATCH 4/6] test: fix test suite for BLE interface changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix mock_ble_driver.py import path (src/RNS/Interfaces -> src/ble_reticulum) - Add address_to_interface, _pending_detach, _pending_detach_grace_period to test fixture - Update test_identity_cache to expect grace period behavior (deferred cleanup) - Update test_v2_2_mac_sorting to use renamed _cleanup_stale_address function 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- tests/mock_ble_driver.py | 2 +- tests/test_identity_cache.py | 15 +++++++++++---- tests/test_v2_2_mac_sorting.py | 8 ++++---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/mock_ble_driver.py b/tests/mock_ble_driver.py index 23d2ec7..994f967 100644 --- a/tests/mock_ble_driver.py +++ b/tests/mock_ble_driver.py @@ -41,7 +41,7 @@ if src_path not in sys.path: # Import directly using importlib to bypass RNS namespace conflicts # This avoids issues when a real RNS package is installed globally import importlib.util -bluetooth_driver_path = os.path.join(src_path, 'RNS', 'Interfaces', 'bluetooth_driver.py') +bluetooth_driver_path = os.path.join(src_path, 'ble_reticulum', 'bluetooth_driver.py') spec = importlib.util.spec_from_file_location("bluetooth_driver", bluetooth_driver_path) bluetooth_driver = importlib.util.module_from_spec(spec) spec.loader.exec_module(bluetooth_driver) diff --git a/tests/test_identity_cache.py b/tests/test_identity_cache.py index f723bdc..d52968d 100644 --- a/tests/test_identity_cache.py +++ b/tests/test_identity_cache.py @@ -85,8 +85,11 @@ def ble_interface(mock_rns, mock_driver): interface.spawned_interfaces = {} interface.address_to_identity = {} interface.identity_to_address = {} + interface.address_to_interface = {} # address -> BLEPeerInterface interface._identity_cache = {} interface._identity_cache_ttl = 60 + interface._pending_detach = {} # identity_hash -> timestamp + interface._pending_detach_grace_period = 2.0 # seconds # Fragmentation interface.fragmenters = {} @@ -143,12 +146,16 @@ class TestIdentityCacheOnDisconnect: assert cached_identity == identity assert time.time() - cached_time < 2 # Cached recently - # Assert: Active mappings should be cleaned up + # Assert: Address-specific mappings should be cleaned up immediately assert mac not in ble_interface.address_to_identity - assert identity_hash not in ble_interface.identity_to_address - # Assert: Peer interface was detached - mock_peer_if.detach.assert_called_once() + # Assert: identity_to_address and interface are NOT cleaned up immediately + # (grace period allows reconnection with same identity at new address) + assert identity_hash in ble_interface.identity_to_address + + # Assert: Detach is scheduled, not immediate + assert identity_hash in ble_interface._pending_detach + mock_peer_if.detach.assert_not_called() def test_disconnect_unknown_address_no_crash(self, ble_interface, mock_rns): """ diff --git a/tests/test_v2_2_mac_sorting.py b/tests/test_v2_2_mac_sorting.py index 81eee45..ecaf97d 100644 --- a/tests/test_v2_2_mac_sorting.py +++ b/tests/test_v2_2_mac_sorting.py @@ -360,7 +360,7 @@ class TestMACRotationBypassesSorting: list regardless of MAC sorting. Previously, the code fell through to the MAC sorting check which could skip the peer if local MAC > peer MAC. - Fix: After _cleanup_stale_interface(), immediately add peer and continue, + Fix: After _cleanup_stale_address(), immediately add peer and continue, bypassing the MAC sorting check. """ @@ -419,7 +419,7 @@ class TestMACRotationBypassesSorting: "MAC rotation should bypass MAC sorting and add peer" def test_mac_rotation_cleanup_is_called(self): - """Test that _cleanup_stale_interface is called during MAC rotation.""" + """Test that _cleanup_stale_address is called during MAC rotation.""" driver = MockBLEDriver(local_address="FF:FF:FF:FF:FF:FF") owner = MockOwner() @@ -430,13 +430,13 @@ class TestMACRotationBypassesSorting: # Track cleanup calls cleanup_calls = [] - original_cleanup = interface._cleanup_stale_interface + original_cleanup = interface._cleanup_stale_address def tracked_cleanup(identity_hash, old_address): cleanup_calls.append((identity_hash, old_address)) return original_cleanup(identity_hash, old_address) - interface._cleanup_stale_interface = tracked_cleanup + interface._cleanup_stale_address = tracked_cleanup # Set up MAC rotation scenario old_address = "AA:AA:AA:AA:AA:AA" From 921dce2dbaf2c5f98444f41b8639b797230f95c7 Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Thu, 1 Jan 2026 19:32:17 -0500 Subject: [PATCH 5/6] test: add comprehensive tests for interface cleanup methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test coverage for new BLEInterface cleanup functionality: - _cleanup_pending_identity_connections: timeout handling for non-Reticulum devices - _process_pending_detaches: delayed interface detachment with grace period - _validate_spawned_interfaces: orphaned interface cleanup - Multi-address disconnect handling (keeping interface alive for MAC rotation) - Thread-safe _spawn_peer_interface with locking - Central disconnect callback with address fallback These tests cover the 195 lines of new code in BLEInterface.py to improve patch coverage for PR #35. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- tests/test_interface_cleanup.py | 701 ++++++++++++++++++++++++++++++++ 1 file changed, 701 insertions(+) create mode 100644 tests/test_interface_cleanup.py diff --git a/tests/test_interface_cleanup.py b/tests/test_interface_cleanup.py new file mode 100644 index 0000000..dfb3a3b --- /dev/null +++ b/tests/test_interface_cleanup.py @@ -0,0 +1,701 @@ +""" +Tests for BLEInterface cleanup methods. + +These tests cover the new cleanup functionality added to handle: +1. Pending identity connection timeouts (non-Reticulum devices) +2. Delayed interface detachment with grace period (MAC rotation) +3. Orphaned interface validation (race condition protection) +4. Multi-address identity handling (same identity at multiple addresses) + +These tests exercise the REAL BLEInterface code with mocked external dependencies. +""" + +import pytest +import time +import threading +from unittest.mock import Mock, MagicMock, patch, PropertyMock +import sys +import os + +# Ensure src is in path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../src')) + + +@pytest.fixture +def mock_rns(): + """Mock RNS module for testing.""" + with patch.dict('sys.modules', {'RNS': MagicMock()}): + import sys + rns = sys.modules['RNS'] + rns.LOG_CRITICAL = 0 + rns.LOG_ERROR = 1 + rns.LOG_WARNING = 2 + rns.LOG_NOTICE = 3 + rns.LOG_INFO = 4 + rns.LOG_VERBOSE = 5 + rns.LOG_DEBUG = 6 + rns.LOG_EXTREME = 7 + rns.log = Mock() + rns.prettyhexrep = lambda x: x.hex() if isinstance(x, bytes) else str(x) + rns.Transport = Mock() + rns.Transport.interfaces = [] + yield rns + + +@pytest.fixture +def mock_driver(): + """Create a mock BLE driver.""" + driver = Mock() + driver.on_device_connected = None + driver.on_device_disconnected = None + driver.on_data_received = None + driver.on_identity_received = None + driver.on_error = None + driver.on_duplicate_identity_detected = None + driver.on_address_changed = None + driver.request_identity_resync = Mock(return_value=False) + driver.get_cached_identity = Mock(return_value=None) + driver.disconnect = Mock() + driver.connected_peers = [] + return driver + + +@pytest.fixture +def ble_interface(mock_rns, mock_driver): + """ + Create a BLEInterface instance with mocked dependencies. + + This uses the REAL BLEInterface class but mocks external dependencies + to allow testing the cleanup logic. + """ + try: + from ble_reticulum.BLEInterface import BLEInterface + + # Create interface without calling __init__ + interface = object.__new__(BLEInterface) + + # Initialize all required attributes manually + interface.name = "TestBLE" + interface.owner = Mock() + interface.online = True + interface.driver = mock_driver + + # Identity mappings + interface.peers = {} + interface.spawned_interfaces = {} + interface.address_to_identity = {} + interface.identity_to_address = {} + interface.address_to_interface = {} + + # Identity cache + interface._identity_cache = {} + interface._identity_cache_ttl = 60 + + # Pending identity connections + interface._pending_identity_connections = {} + interface._pending_identity_timeout = 30 + + # Pending detachments + interface._pending_detach = {} + interface._pending_detach_grace_period = 2.0 + + # Fragmentation + interface.fragmenters = {} + interface.reassemblers = {} + interface.pending_mtu = {} + + # Locks + interface.peer_lock = threading.RLock() + interface.frag_lock = threading.RLock() + + # Other attributes + interface.HW_MTU = 500 + interface.MIN_MTU = 20 + interface.bitrate = 700000 + interface.rxb = 0 + interface.txb = 0 + interface.OUT = True + interface.IN = True + + # Add required methods + interface._compute_identity_hash = lambda x: x[:8].hex() + interface._get_fragmenter_key = lambda ident, addr: f"{ident[:8].hex()}" + + return interface + + except ImportError as e: + pytest.skip(f"Cannot import BLEInterface (RNS not installed): {e}") + + +class TestCleanupPendingIdentityConnections: + """Test _cleanup_pending_identity_connections() method.""" + + def test_no_pending_connections(self, ble_interface, mock_rns): + """Should handle empty pending connections gracefully.""" + assert len(ble_interface._pending_identity_connections) == 0 + ble_interface._cleanup_pending_identity_connections() + # Should not crash, no disconnects called + ble_interface.driver.disconnect.assert_not_called() + + def test_connection_not_timed_out(self, ble_interface, mock_rns): + """Connections within timeout should not be disconnected.""" + mac = "AA:BB:CC:DD:EE:FF" + # Connection started 5 seconds ago (within 30s timeout) + ble_interface._pending_identity_connections[mac] = time.time() - 5 + + ble_interface._cleanup_pending_identity_connections() + + # Should not disconnect + ble_interface.driver.disconnect.assert_not_called() + # Should still be tracked + assert mac in ble_interface._pending_identity_connections + + def test_connection_timed_out(self, ble_interface, mock_rns): + """Connections past timeout should be disconnected.""" + mac = "AA:BB:CC:DD:EE:FF" + # Connection started 35 seconds ago (past 30s timeout) + ble_interface._pending_identity_connections[mac] = time.time() - 35 + + ble_interface._cleanup_pending_identity_connections() + + # Should disconnect + ble_interface.driver.disconnect.assert_called_once_with(mac) + # Should be removed from tracking + assert mac not in ble_interface._pending_identity_connections + + def test_multiple_connections_mixed_states(self, ble_interface, mock_rns): + """Should handle mix of timed-out and valid connections.""" + mac_expired1 = "AA:BB:CC:DD:EE:01" + mac_valid = "AA:BB:CC:DD:EE:02" + mac_expired2 = "AA:BB:CC:DD:EE:03" + + ble_interface._pending_identity_connections[mac_expired1] = time.time() - 40 + ble_interface._pending_identity_connections[mac_valid] = time.time() - 5 + ble_interface._pending_identity_connections[mac_expired2] = time.time() - 35 + + ble_interface._cleanup_pending_identity_connections() + + # Should disconnect both expired connections + assert ble_interface.driver.disconnect.call_count == 2 + # Valid connection should remain + assert mac_valid in ble_interface._pending_identity_connections + assert mac_expired1 not in ble_interface._pending_identity_connections + assert mac_expired2 not in ble_interface._pending_identity_connections + + def test_disconnect_error_handled(self, ble_interface, mock_rns): + """Errors during disconnect should be caught and logged.""" + mac = "AA:BB:CC:DD:EE:FF" + ble_interface._pending_identity_connections[mac] = time.time() - 35 + ble_interface.driver.disconnect.side_effect = Exception("BLE disconnect failed") + + # Should not raise + ble_interface._cleanup_pending_identity_connections() + + # Connection should still be removed from tracking + assert mac not in ble_interface._pending_identity_connections + + +class TestProcessPendingDetaches: + """Test _process_pending_detaches() method.""" + + def test_no_pending_detaches(self, ble_interface, mock_rns): + """Should handle empty pending detaches gracefully.""" + assert len(ble_interface._pending_detach) == 0 + ble_interface._process_pending_detaches() + # Should not crash + + def test_detach_within_grace_period(self, ble_interface, mock_rns): + """Detaches within grace period should not execute.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + + # Scheduled 0.5 seconds ago (within 2s grace period) + ble_interface._pending_detach[identity_hash] = time.time() - 0.5 + + # Create mock interface + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + + ble_interface._process_pending_detaches() + + # Should not detach yet + mock_peer_if.detach.assert_not_called() + assert identity_hash in ble_interface._pending_detach + assert identity_hash in ble_interface.spawned_interfaces + + def test_detach_after_grace_period_no_reconnect(self, ble_interface, mock_rns): + """Detaches past grace period should execute if no reconnection.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + + # Scheduled 3 seconds ago (past 2s grace period) + ble_interface._pending_detach[identity_hash] = time.time() - 3 + ble_interface.identity_to_address[identity_hash] = "AA:BB:CC:DD:EE:FF" + + # Create mock interface + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + + ble_interface._process_pending_detaches() + + # Should detach + mock_peer_if.detach.assert_called_once() + assert identity_hash not in ble_interface._pending_detach + assert identity_hash not in ble_interface.spawned_interfaces + assert identity_hash not in ble_interface.identity_to_address + + def test_detach_cancelled_if_reconnected(self, ble_interface, mock_rns): + """Detach should be cancelled if address reconnected during grace period.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + mac = "AA:BB:CC:DD:EE:FF" + + # Scheduled 3 seconds ago (past 2s grace period) + ble_interface._pending_detach[identity_hash] = time.time() - 3 + + # But address reconnected (identity mapping exists) + ble_interface.address_to_identity[mac] = identity + + # Create mock interface + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + + ble_interface._process_pending_detaches() + + # Should NOT detach (reconnected during grace period) + mock_peer_if.detach.assert_not_called() + assert identity_hash not in ble_interface._pending_detach # Pending cleared + assert identity_hash in ble_interface.spawned_interfaces # Interface kept + + def test_detach_cleans_up_fragmenter(self, ble_interface, mock_rns): + """Detach should clean up fragmenter and reassembler.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + frag_key = identity_hash + + # Scheduled 3 seconds ago (past 2s grace period) + ble_interface._pending_detach[identity_hash] = time.time() - 3 + ble_interface.identity_to_address[identity_hash] = "AA:BB:CC:DD:EE:FF" + + # Create mock interface + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + + # Create mock fragmenter/reassembler + ble_interface.fragmenters[frag_key] = Mock() + ble_interface.reassemblers[frag_key] = Mock() + + ble_interface._process_pending_detaches() + + # Fragmenter/reassembler should be cleaned up + assert frag_key not in ble_interface.fragmenters + assert frag_key not in ble_interface.reassemblers + + def test_detach_interface_already_gone(self, ble_interface, mock_rns): + """Should handle case where interface was already removed.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + + # Scheduled 3 seconds ago (past 2s grace period) + ble_interface._pending_detach[identity_hash] = time.time() - 3 + + # No interface exists (already cleaned up elsewhere) + assert identity_hash not in ble_interface.spawned_interfaces + + # Should not crash + ble_interface._process_pending_detaches() + + # Pending entry should be cleared + assert identity_hash not in ble_interface._pending_detach + + +class TestValidateSpawnedInterfaces: + """Test _validate_spawned_interfaces() method.""" + + def test_no_orphaned_interfaces(self, ble_interface, mock_rns): + """Should handle case with no orphaned interfaces.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + mac = "AA:BB:CC:DD:EE:FF" + + # Interface with connected address + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + ble_interface.address_to_interface[mac] = mock_peer_if + ble_interface.address_to_identity[mac] = identity + ble_interface.identity_to_address[identity_hash] = mac + + # Driver shows address connected + ble_interface.driver.connected_peers = [mac] + + ble_interface._validate_spawned_interfaces() + + # Nothing should be detached + mock_peer_if.detach.assert_not_called() + assert identity_hash in ble_interface.spawned_interfaces + assert mac in ble_interface.address_to_interface + + def test_orphaned_address_mapping_cleaned(self, ble_interface, mock_rns): + """Should clean up address mappings for disconnected addresses.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + mac_disconnected = "AA:BB:CC:DD:EE:01" + mac_connected = "AA:BB:CC:DD:EE:02" + + # Interface with two address mappings + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + ble_interface.address_to_interface[mac_disconnected] = mock_peer_if + ble_interface.address_to_interface[mac_connected] = mock_peer_if + ble_interface.address_to_identity[mac_disconnected] = identity + ble_interface.address_to_identity[mac_connected] = identity + ble_interface.identity_to_address[identity_hash] = mac_disconnected + + # Only one address still connected + ble_interface.driver.connected_peers = [mac_connected] + + ble_interface._validate_spawned_interfaces() + + # Disconnected address should be cleaned up + assert mac_disconnected not in ble_interface.address_to_interface + assert mac_disconnected not in ble_interface.address_to_identity + + # Connected address should remain + assert mac_connected in ble_interface.address_to_interface + + # Interface should NOT be detached (other address still connected) + mock_peer_if.detach.assert_not_called() + assert identity_hash in ble_interface.spawned_interfaces + + # identity_to_address should point to connected address + assert ble_interface.identity_to_address[identity_hash] == mac_connected + + def test_orphaned_interface_detached(self, ble_interface, mock_rns): + """Should detach interface when all addresses disconnected.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + mac = "AA:BB:CC:DD:EE:FF" + frag_key = identity_hash + + # Interface with no connected addresses + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + ble_interface.address_to_interface[mac] = mock_peer_if + ble_interface.address_to_identity[mac] = identity + ble_interface.identity_to_address[identity_hash] = mac + + # Create fragmenter/reassembler + ble_interface.fragmenters[frag_key] = Mock() + ble_interface.reassemblers[frag_key] = Mock() + + # No addresses connected + ble_interface.driver.connected_peers = [] + + ble_interface._validate_spawned_interfaces() + + # Interface should be detached + mock_peer_if.detach.assert_called_once() + assert identity_hash not in ble_interface.spawned_interfaces + assert identity_hash not in ble_interface.identity_to_address + assert mac not in ble_interface.address_to_interface + assert mac not in ble_interface.address_to_identity + + # Fragmenter/reassembler should be cleaned up + assert frag_key not in ble_interface.fragmenters + assert frag_key not in ble_interface.reassemblers + + def test_validation_handles_exception(self, ble_interface, mock_rns): + """Should catch and log exceptions during validation.""" + # Make connected_peers property raise an exception + type(ble_interface.driver).connected_peers = PropertyMock( + side_effect=Exception("BLE driver error") + ) + + # Should not raise + ble_interface._validate_spawned_interfaces() + + +class TestMultiAddressDisconnect: + """Test disconnect callback with multiple addresses per identity.""" + + def test_disconnect_preserves_interface_with_other_addresses(self, ble_interface, mock_rns): + """When one address disconnects, interface should stay if others remain.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + mac1 = "AA:BB:CC:DD:EE:01" + mac2 = "AA:BB:CC:DD:EE:02" + + # Setup: Two addresses mapped to same identity/interface + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + mock_peer_if.detach = Mock() + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + ble_interface.address_to_interface[mac1] = mock_peer_if + ble_interface.address_to_interface[mac2] = mock_peer_if + ble_interface.address_to_identity[mac1] = identity + ble_interface.address_to_identity[mac2] = identity + ble_interface.identity_to_address[identity_hash] = mac1 + + # Disconnect mac1 + ble_interface._device_disconnected_callback(mac1) + + # Interface should NOT be detached (mac2 still connected) + mock_peer_if.detach.assert_not_called() + assert identity_hash in ble_interface.spawned_interfaces + + # mac1 mappings should be cleaned + assert mac1 not in ble_interface.address_to_interface + assert mac1 not in ble_interface.address_to_identity + + # mac2 should remain + assert mac2 in ble_interface.address_to_interface + assert mac2 in ble_interface.address_to_identity + + # identity_to_address should point to mac2 now + assert ble_interface.identity_to_address[identity_hash] == mac2 + + # No pending detach (other address still connected) + assert identity_hash not in ble_interface._pending_detach + + def test_disconnect_schedules_detach_when_last_address(self, ble_interface, mock_rns): + """When last address disconnects, detach should be scheduled.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + mac = "AA:BB:CC:DD:EE:FF" + + # Setup: Single address mapped to identity + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + mock_peer_if.detach = Mock() + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + ble_interface.address_to_interface[mac] = mock_peer_if + ble_interface.address_to_identity[mac] = identity + ble_interface.identity_to_address[identity_hash] = mac + + # Disconnect + ble_interface._device_disconnected_callback(mac) + + # Interface should NOT be immediately detached + mock_peer_if.detach.assert_not_called() + + # But detach should be scheduled + assert identity_hash in ble_interface._pending_detach + + # Address mappings should be cleaned immediately + assert mac not in ble_interface.address_to_interface + assert mac not in ble_interface.address_to_identity + + +class TestCentralDisconnected: + """Test handle_central_disconnected with multi-address handling.""" + + def test_central_disconnect_preserves_interface_with_other_addresses(self, ble_interface, mock_rns): + """When central disconnects, interface should stay if other addresses exist.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + mac_central = "AA:BB:CC:DD:EE:01" + mac_other = "AA:BB:CC:DD:EE:02" + + # Setup: Two addresses mapped to same identity + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + mock_peer_if.detach = Mock() + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + ble_interface.address_to_interface[mac_central] = mock_peer_if + ble_interface.address_to_interface[mac_other] = mock_peer_if + ble_interface.address_to_identity[mac_central] = identity + ble_interface.address_to_identity[mac_other] = identity + ble_interface.identity_to_address[identity_hash] = mac_central + + # Central disconnects + ble_interface.handle_central_disconnected(mac_central) + + # Interface should NOT be detached + mock_peer_if.detach.assert_not_called() + assert identity_hash in ble_interface.spawned_interfaces + + # identity_to_address should point to remaining address + assert ble_interface.identity_to_address[identity_hash] == mac_other + + def test_central_disconnect_uses_address_fallback(self, ble_interface, mock_rns): + """When no identity mapping, should use address_to_interface fallback.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + mac = "AA:BB:CC:DD:EE:FF" + + # Setup: Only address_to_interface exists (no identity mapping) + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + mock_peer_if.detach = Mock() + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + ble_interface.address_to_interface[mac] = mock_peer_if + ble_interface.identity_to_address[identity_hash] = mac + # Note: address_to_identity is NOT set - testing fallback + + # Central disconnects + ble_interface.handle_central_disconnected(mac) + + # Should still schedule detach via fallback + assert identity_hash in ble_interface._pending_detach + + +class TestSpawnPeerInterfaceThreadSafety: + """Test thread-safety of _spawn_peer_interface.""" + + def test_spawn_interface_reuses_existing(self, ble_interface, mock_rns): + """Should reuse existing interface for same identity at new address.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + mac_old = "AA:BB:CC:DD:EE:01" + mac_new = "AA:BB:CC:DD:EE:02" + + # Create existing interface + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + mock_peer_if.online = True + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + ble_interface.address_to_interface[mac_old] = mock_peer_if + + # Spawn interface for new address with same identity + result = ble_interface._spawn_peer_interface( + mac_new, "RNS-peer", identity, client=None, mtu=185 + ) + + # Should return existing interface + assert result == mock_peer_if + + # New address should be mapped + assert ble_interface.address_to_interface[mac_new] == mock_peer_if + assert ble_interface.address_to_identity[mac_new] == identity + assert ble_interface.identity_to_address[identity_hash] == mac_new + + def test_spawn_interface_cancels_pending_detach(self, ble_interface, mock_rns): + """Spawning interface should cancel any pending detach.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + mac = "AA:BB:CC:DD:EE:FF" + + # Create existing interface with pending detach + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + mock_peer_if.online = False + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + ble_interface._pending_detach[identity_hash] = time.time() - 1 + + # Spawn interface (reuse) + result = ble_interface._spawn_peer_interface( + mac, "RNS-peer", identity, client=None, mtu=185 + ) + + # Pending detach should be cancelled + assert identity_hash not in ble_interface._pending_detach + + # Interface should be marked online + assert result.online is True + + +class TestDeviceConnectedCallback: + """Test _device_connected_callback with pending identity handling.""" + + def test_connected_cancels_pending_detach(self, ble_interface, mock_rns): + """Connection with identity should cancel pending detach.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + mac = "AA:BB:CC:DD:EE:FF" + + # Setup: pending detach exists + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + ble_interface._pending_detach[identity_hash] = time.time() - 1 + + # Mock driver role + ble_interface.driver.get_peer_role = Mock(return_value="central") + ble_interface._check_duplicate_identity = Mock(return_value=False) + ble_interface._record_connection_success = Mock() + ble_interface._spawn_peer_interface = Mock(return_value=mock_peer_if) + ble_interface._ensure_fragmenter = Mock() + + # Device connects + ble_interface._device_connected_callback(mac, identity) + + # Pending detach should be cancelled + assert identity_hash not in ble_interface._pending_detach + + def test_connected_removes_from_pending_identity(self, ble_interface, mock_rns): + """Connection with identity should remove from pending identity tracking.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + mac = "AA:BB:CC:DD:EE:FF" + + # Setup: pending identity exists + ble_interface._pending_identity_connections[mac] = time.time() - 5 + + # Mock driver role + ble_interface.driver.get_peer_role = Mock(return_value="central") + ble_interface._check_duplicate_identity = Mock(return_value=False) + ble_interface._record_connection_success = Mock() + ble_interface._spawn_peer_interface = Mock(return_value=Mock()) + ble_interface._ensure_fragmenter = Mock() + + # Device connects + ble_interface._device_connected_callback(mac, identity) + + # Should be removed from pending tracking + assert mac not in ble_interface._pending_identity_connections + + def test_peripheral_connection_tracked_for_timeout(self, ble_interface, mock_rns): + """Peripheral connection without identity should be tracked for timeout.""" + mac = "AA:BB:CC:DD:EE:FF" + + # Mock driver role (peripheral = waiting for identity handshake) + ble_interface.driver.get_peer_role = Mock(return_value="peripheral") + + # Device connects as peripheral (no identity yet) + ble_interface._device_connected_callback(mac, None) + + # Should be tracked for identity timeout + assert mac in ble_interface._pending_identity_connections + + +class TestCleanupStaleAddress: + """Test _cleanup_stale_address (renamed from _cleanup_stale_interface).""" + + def test_cleanup_preserves_interface(self, ble_interface, mock_rns): + """Cleanup should preserve interface for reuse.""" + identity = bytes.fromhex("456c6978823c85e228a545259c241e0e") + identity_hash = identity[:8].hex() + old_mac = "AA:BB:CC:DD:EE:01" + + # Setup: interface and mappings + mock_peer_if = Mock() + mock_peer_if.peer_identity = identity + mock_peer_if.detach = Mock() + ble_interface.spawned_interfaces[identity_hash] = mock_peer_if + ble_interface.address_to_interface[old_mac] = mock_peer_if + ble_interface.address_to_identity[old_mac] = identity + ble_interface.identity_to_address[identity_hash] = old_mac + ble_interface.pending_mtu[old_mac] = 185 + + # Cleanup old address + ble_interface._cleanup_stale_address(identity_hash, old_mac) + + # Interface should NOT be detached (preserved for reuse) + mock_peer_if.detach.assert_not_called() + assert identity_hash in ble_interface.spawned_interfaces + + # Old address mappings should be cleaned + assert old_mac not in ble_interface.address_to_interface + assert old_mac not in ble_interface.address_to_identity + assert old_mac not in ble_interface.pending_mtu + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 2276f493d02333f67c40ec09847c2014bb4a4dd7 Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Thu, 1 Jan 2026 19:34:49 -0500 Subject: [PATCH 6/6] fix(ble): properly remove cancelled pending detaches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When _process_pending_detaches() finds that an address has reconnected during the grace period, the pending detach entry was not being removed from _pending_detach dict. This caused the entry to be re-evaluated on every cleanup cycle. Now properly deletes the entry when cancelling the detach. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/ble_reticulum/BLEInterface.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ble_reticulum/BLEInterface.py b/src/ble_reticulum/BLEInterface.py index d52f055..b576ccc 100644 --- a/src/ble_reticulum/BLEInterface.py +++ b/src/ble_reticulum/BLEInterface.py @@ -785,6 +785,7 @@ class BLEInterface(Interface): if has_connected_address: # New connection arrived during grace period - cancel detach + del self._pending_detach[identity_hash] RNS.log(f"{self} cancelled detach for {identity_hash[:8]} - address reconnected during grace period", RNS.LOG_DEBUG) else: # No connections - safe to detach