#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ AndroidBLEDriver - Platform-specific BLE driver for Android Implements the BLEDriverInterface from ble-reticulum for Android devices. Bridges Python to KotlinBLEBridge via Chaquopy for native Android BLE access. This enables Reticulum BLE networking on Android by providing the same driver interface as the Linux implementation, but using Android's native BLE stack. Author: Columba Project License: MIT """ import RNS import sys import os import time import threading from typing import List, Optional, Callable, Union # Hierarchical log tag for consistent filtering (matches Kotlin BLE components) LOG_TAG = "Columba:BLE:Py:Driver" def ensure_bytes(data: Union[bytes, 'jarray']) -> bytes: """ Convert Chaquopy jarray to Python bytes if needed. When Kotlin passes ByteArray to Python via Chaquopy, it arrives as a jarray('B') (Java array), not Python bytes. Java arrays don't have Python methods like .hex(), .decode(), etc. This function ensures we always have a Python bytes object. Usage: data = ensure_bytes(data) # Safe to call .hex(), .decode(), etc. Args: data: Either Python bytes or Chaquopy jarray Returns: Python bytes object """ if isinstance(data, bytes): return data # jarray is iterable and bytes() constructor accepts iterables return bytes(data) # Add parent interfaces directory to path for imports # When deployed, bluetooth_driver.py is in the parent interfaces/ directory _interfaces_dir = os.path.dirname(os.path.dirname(__file__)) if _interfaces_dir not in sys.path: sys.path.insert(0, _interfaces_dir) # Import driver interface from bluetooth_driver (deployed in interfaces/) from bluetooth_driver import BLEDriverInterface, BLEDevice, DriverState class AndroidBLEDriver(BLEDriverInterface): """ Android BLE driver implementing BLEDriverInterface from ble-reticulum. This driver bridges Python to the KotlinBLEBridge via Chaquopy, providing native Android BLE access while implementing the standard driver interface. The KotlinBLEBridge handles: - BLE scanning and advertising - GATT client/server operations - Dual-mode operation (central + peripheral) - Protocol v2.2 identity tracking - MTU negotiation - Connection management and error recovery Note: Fragmentation/reassembly handled by BLEInterface (parent class) This driver simply translates between the BLEDriverInterface and the KotlinBLEBridge, handling Chaquopy interop and callback routing. """ def __init__(self, **kwargs): """ Initialize the Android BLE driver. Args: **kwargs: Configuration parameters (accepted for compatibility with BLEInterface but not used - Android driver gets config via Kotlin) """ super().__init__() self._state = DriverState.IDLE self.kotlin_bridge = None self._transport_identity = None self._service_uuid = None self._rx_char_uuid = None self._tx_char_uuid = None self._identity_char_uuid = None # Track connected peers self._connected_peers = [] self._peer_roles = {} # address -> "central" or "peripheral" self._peer_mtus = {} # address -> mtu (int) # Identity tracking for duplicate detection (MAC rotation handling) self._address_to_identity = {} # address -> identity_hash (32-char hex) self._identity_to_address = {} # identity_hash -> address # Thread safety for identity handling (prevents race conditions) self._identity_lock = threading.Lock() self._pending_identities = {} # address -> identity bytes (cached before connection) # Track last receive address for RSSI queries (signal_quality.py) self._last_receive_address = None # Configuration (from kwargs or defaults) self._service_discovery_delay = kwargs.get('service_discovery_delay', 0.5) self._power_mode = "balanced" RNS.log(f"{LOG_TAG}: Initialized", RNS.LOG_DEBUG) # --- Lifecycle & Configuration --- def start(self, service_uuid: str, rx_char_uuid: str, tx_char_uuid: str, identity_char_uuid: str): """Initialize the driver and Android BLE stack.""" try: if self._state != DriverState.IDLE: RNS.log(f"{LOG_TAG}: Cannot start - current state: {self._state}", RNS.LOG_WARNING) return RNS.log(f"{LOG_TAG}: Starting...", RNS.LOG_INFO) # Store UUIDs self._service_uuid = service_uuid self._rx_char_uuid = rx_char_uuid self._tx_char_uuid = tx_char_uuid self._identity_char_uuid = identity_char_uuid # Clear pending identities from previous session with self._identity_lock: self._pending_identities.clear() # Get Kotlin bridge if self.kotlin_bridge is None: self.kotlin_bridge = self._get_kotlin_bridge() if self.kotlin_bridge is None: raise Exception("Failed to get KotlinBLEBridge") # Setup callbacks self._setup_kotlin_callbacks() # Initialize Kotlin BLE stack self.kotlin_bridge.startAsync( service_uuid, rx_char_uuid, tx_char_uuid, identity_char_uuid ) # Set identity if we have one if self._transport_identity: self.kotlin_bridge.setIdentity(self._transport_identity) self._state = DriverState.IDLE RNS.log(f"{LOG_TAG}: Started successfully", RNS.LOG_INFO) except Exception as e: RNS.log(f"{LOG_TAG}: Failed to start: {e}", RNS.LOG_ERROR) if self.on_error: self.on_error("critical", f"Failed to start driver: {e}", e) raise def stop(self): """Stop all BLE activity and release resources.""" try: if self._state == DriverState.IDLE: return RNS.log(f"{LOG_TAG}: Stopping...", RNS.LOG_INFO) if self.kotlin_bridge: self.kotlin_bridge.stopAsync() self._connected_peers.clear() self._peer_roles.clear() self._state = DriverState.IDLE RNS.log(f"{LOG_TAG}: Stopped", RNS.LOG_INFO) except Exception as e: RNS.log(f"{LOG_TAG}: Error stopping: {e}", RNS.LOG_ERROR) if self.on_error: self.on_error("error", f"Error stopping driver: {e}", e) def set_identity(self, identity_bytes: bytes): """Set the local transport identity (16 bytes).""" if len(identity_bytes) != 16: raise ValueError("Identity must be 16 bytes") self._transport_identity = identity_bytes if self.kotlin_bridge: self.kotlin_bridge.setIdentity(identity_bytes) hex_identity = identity_bytes.hex() RNS.log(f"{LOG_TAG}: Identity set: {hex_identity}", RNS.LOG_DEBUG) # --- State & Properties --- @property def state(self) -> DriverState: """Return current driver state.""" return self._state @property def connected_peers(self) -> List[str]: """Return list of connected peer addresses.""" return self._connected_peers.copy() # --- Core Actions --- def start_scanning(self): """Start BLE scanning for nearby Reticulum nodes.""" try: if not self.kotlin_bridge: raise Exception("Driver not started") self.kotlin_bridge.startScanningAsync() self._state = DriverState.SCANNING RNS.log(f"{LOG_TAG}: Scanning started", RNS.LOG_DEBUG) except Exception as e: RNS.log(f"{LOG_TAG}: Failed to start scanning: {e}", RNS.LOG_ERROR) if self.on_error: self.on_error("error", f"Failed to start scanning: {e}", e) def stop_scanning(self): """Stop BLE scanning.""" try: if self.kotlin_bridge: self.kotlin_bridge.stopScanningAsync() if self._state == DriverState.SCANNING: self._state = DriverState.IDLE RNS.log(f"{LOG_TAG}: Scanning stopped", RNS.LOG_DEBUG) except Exception as e: RNS.log(f"{LOG_TAG}: Error stopping scan: {e}", RNS.LOG_ERROR) def start_advertising(self, device_name: str, identity: bytes): """Start BLE advertising.""" try: if not self.kotlin_bridge: raise Exception("Driver not started") self.kotlin_bridge.startAdvertisingAsync(device_name) self._state = DriverState.ADVERTISING RNS.log(f"{LOG_TAG}: Advertising started as '{device_name}'", RNS.LOG_DEBUG) except Exception as e: RNS.log(f"{LOG_TAG}: Failed to start advertising: {e}", RNS.LOG_ERROR) if self.on_error: self.on_error("error", f"Failed to start advertising: {e}", e) def stop_advertising(self): """Stop BLE advertising.""" try: if self.kotlin_bridge: self.kotlin_bridge.stopAdvertisingAsync() if self._state == DriverState.ADVERTISING: self._state = DriverState.IDLE RNS.log(f"{LOG_TAG}: Advertising stopped", RNS.LOG_DEBUG) except Exception as e: RNS.log(f"{LOG_TAG}: Error stopping advertising: {e}", RNS.LOG_ERROR) def should_connect(self, peer_address: str) -> bool: """Check if we should initiate connection based on MAC address sorting. This implements the MAC sorting algorithm from the BLE protocol: - Lower MAC address initiates connection (acts as central) - Higher MAC address waits (acts as peripheral only) This prevents dual connections by ensuring both devices independently agree on connection direction. NOTE: This method is exposed for BLEInterface to call BEFORE connect(). The MAC sorting decision has been moved to Python layer per RFC architecture. Args: peer_address: BLE MAC address of peer to potentially connect to Returns: True if we should connect (our MAC < peer MAC), False otherwise """ try: if not self.kotlin_bridge: RNS.log(f"{LOG_TAG}: Cannot check shouldConnect - no bridge", RNS.LOG_WARNING) return False result = self.kotlin_bridge.shouldConnect(peer_address) RNS.log(f"{LOG_TAG}: shouldConnect({peer_address}) = {result}", RNS.LOG_DEBUG) return bool(result) except Exception as e: RNS.log(f"{LOG_TAG}: Error in shouldConnect: {e}", RNS.LOG_ERROR) return False def connect(self, address: str): """Connect to a peer device (central role). NOTE: MAC sorting is no longer enforced by Kotlin. BLEInterface should call should_connect() first if it wants to follow the MAC sorting protocol. This method now connects unconditionally when called. """ try: if not self.kotlin_bridge: raise Exception("Driver not started") self.kotlin_bridge.connectAsync(address) except Exception as e: RNS.log(f"{LOG_TAG}: Failed to connect to {address}: {e}", RNS.LOG_ERROR) if self.on_error: self.on_error("error", f"Failed to connect to {address}: {e}", e) def disconnect(self, address: str): """Disconnect from a peer device (both central and peripheral connections).""" try: if self.kotlin_bridge: # Kotlin bridge launches coroutines internally self.kotlin_bridge.disconnectAsync(address) RNS.log(f"{LOG_TAG}: Disconnecting from {address}", RNS.LOG_DEBUG) except Exception as e: RNS.log(f"{LOG_TAG}: Error disconnecting from {address}: {e}", RNS.LOG_ERROR) def disconnect_central(self, address: str): """Disconnect our central connection TO a peer device. Used by BLEInterface for deduplication when we want to keep the peripheral connection (them connected to us) but close our central connection (us connected to them). """ try: if self.kotlin_bridge: self.kotlin_bridge.disconnectCentralAsync(address) RNS.log(f"{LOG_TAG}: Disconnecting central connection to {address}", RNS.LOG_DEBUG) else: RNS.log(f"{LOG_TAG}: Cannot disconnect central - no bridge", RNS.LOG_WARNING) except Exception as e: RNS.log(f"{LOG_TAG}: Error disconnecting central from {address}: {e}", RNS.LOG_ERROR) def disconnect_peripheral(self, address: str): """Disconnect a peripheral connection FROM a peer device. Used by BLEInterface for deduplication when we want to keep our central connection (us connected to them) but close the peripheral connection (them connected to us). """ try: if self.kotlin_bridge: self.kotlin_bridge.disconnectPeripheralAsync(address) RNS.log(f"{LOG_TAG}: Disconnecting peripheral connection from {address}", RNS.LOG_DEBUG) else: RNS.log(f"{LOG_TAG}: Cannot disconnect peripheral - no bridge", RNS.LOG_WARNING) except Exception as e: RNS.log(f"{LOG_TAG}: Error disconnecting peripheral from {address}: {e}", RNS.LOG_ERROR) def send(self, address: str, data: bytes): """Send data to a connected peer (data already fragmented by BLEInterface).""" try: if not self.kotlin_bridge: raise Exception("Driver not started") self.kotlin_bridge.sendAsync(address, data) RNS.log(f"{LOG_TAG}: Sent {len(data)} bytes to {address}", RNS.LOG_EXTREME) except Exception as e: RNS.log(f"{LOG_TAG}: Failed to send to {address}: {e}", RNS.LOG_ERROR) if self.on_error: self.on_error("error", f"Failed to send to {address}: {e}", e) # --- GATT Characteristic Operations --- def read_characteristic(self, address: str, char_uuid: str) -> bytes: """Read a GATT characteristic (not directly supported - identity via callback).""" # This method is here for interface compatibility. In this driver, # characteristic reads are handled by the Kotlin bridge, and the results # (like the peer identity) are passed up via callbacks. RNS.log(f"{LOG_TAG}: read_characteristic is not implemented for direct use. Use callbacks instead.", RNS.LOG_WARNING) return b"" def write_characteristic(self, address: str, char_uuid: str, data: bytes): """Write a GATT characteristic (use send() instead).""" # This method is here for interface compatibility. In this driver, # all data transmission is handled by the send() method, which abstracts # away the specific characteristic writes. RNS.log(f"{LOG_TAG}: write_characteristic is not implemented for direct use. Use send() instead.", RNS.LOG_WARNING) def start_notify(self, address: str, char_uuid: str, callback: Callable[[bytes], None]): """Subscribe to notifications (handled automatically by Kotlin bridge).""" # Note: Notifications are automatically handled by KotlinBLEBridge # Data comes through on_data_received callback RNS.log(f"{LOG_TAG}: start_notify not needed (automatic in bridge)", RNS.LOG_DEBUG) # --- Configuration & Queries --- def get_local_address(self) -> str: """Get the local Bluetooth adapter address.""" # WARNING: Android privacy features prevent apps from accessing the real # Bluetooth MAC address. Returning a placeholder. This could have # unforeseen consequences in the ble-reticulum logic, which might rely # on a real address for tie-breaking or other functions. return "00:00:00:00:00:00" # Placeholder def get_peer_role(self, address: str) -> Optional[str]: """Get the connection role for a peer ('central' or 'peripheral').""" return self._peer_roles.get(address) def get_peer_rssi(self, address: str) -> Optional[int]: """Get the last known RSSI for a peer. Queries the Kotlin bridge for the peer's signal strength. Args: address: BLE MAC address of the peer Returns: RSSI in dBm, or None if peer not found or RSSI unknown """ try: if not self.kotlin_bridge: return None rssi = self.kotlin_bridge.getPeerRssi(address) # Kotlin returns null as Python None via Chaquopy return rssi except Exception as e: RNS.log(f"{LOG_TAG}: Error getting RSSI for {address}: {e}", RNS.LOG_DEBUG) return None def get_last_receive_rssi(self) -> Optional[int]: """Get the RSSI of the last peer that sent us data. This is used by signal_quality.py to extract signal metrics at message delivery time. Returns: RSSI in dBm, or None if no recent data or RSSI unknown """ if self._last_receive_address: return self.get_peer_rssi(self._last_receive_address) return None def set_service_discovery_delay(self, seconds: float): """Set delay between connection and service discovery (not needed on Android).""" self._service_discovery_delay = seconds RNS.log(f"{LOG_TAG}: Service discovery delay set to {seconds}s (ignored on Android)", RNS.LOG_DEBUG) def set_power_mode(self, mode: str): """Set power mode for scanning ('aggressive', 'balanced', 'saver').""" if mode not in ["aggressive", "balanced", "saver"]: raise ValueError(f"Invalid power mode: {mode}") self._power_mode = mode RNS.log(f"{LOG_TAG}: Power mode set to {mode}", RNS.LOG_DEBUG) # Note: Could propagate to KotlinBLEBridge if needed def get_peer_mtu(self, address: str) -> Optional[int]: """Get the negotiated MTU for a peer.""" return self._peer_mtus.get(address) def ensure_advertising(self) -> bool: """Ensure advertising is active, restarting if silently stopped. Android may silently stop BLE advertising when: - App goes to background - Screen turns off - Device enters Doze mode This method checks if advertising is active and restarts it if needed. Returns: True if advertising was already active, False if restart was triggered """ try: if self.kotlin_bridge is None: RNS.log(f"{LOG_TAG}: Cannot ensure advertising - no bridge", RNS.LOG_WARNING) return False result = self.kotlin_bridge.ensureAdvertising() if not result: RNS.log(f"{LOG_TAG}: Advertising was stopped, restarting...", RNS.LOG_INFO) return bool(result) except Exception as e: RNS.log(f"{LOG_TAG}: Error ensuring advertising: {e}", RNS.LOG_ERROR) return False def request_identity_resync(self, address: str) -> bool: """Request identity resync from Kotlin for a peer. Called by BLEInterface when it receives data from a peer but has no identity mapping. This can happen if Python's disconnect callback fired but Kotlin maintained the GATT connection. Args: address: BLE MAC address of the peer Returns: True if identity was found and callback will be fired, False otherwise """ try: if self.kotlin_bridge is None: RNS.log(f"{LOG_TAG}: Cannot resync identity - no bridge", RNS.LOG_WARNING) return False result = self.kotlin_bridge.requestIdentityResync(address) RNS.log(f"{LOG_TAG}: Identity resync for {address}: {'found' if result else 'not found'}", RNS.LOG_DEBUG) return bool(result) except Exception as e: RNS.log(f"{LOG_TAG}: Error requesting identity resync for {address}: {e}", RNS.LOG_ERROR) return False # --- Internal Methods --- def _get_kotlin_bridge(self): """Get KotlinBLEBridge instance from global wrapper.""" try: # Import the global wrapper instance import reticulum_wrapper wrapper = reticulum_wrapper._global_wrapper_instance if wrapper is None: RNS.log(f"{LOG_TAG}: No global wrapper instance found", RNS.LOG_ERROR) return None if wrapper.kotlin_ble_bridge is None: RNS.log(f"{LOG_TAG}: No BLE bridge set in wrapper. Call set_ble_bridge() from Kotlin first.", RNS.LOG_ERROR) return None RNS.log(f"{LOG_TAG}: Kotlin bridge acquired from wrapper", RNS.LOG_DEBUG) return wrapper.kotlin_ble_bridge except Exception as e: RNS.log(f"{LOG_TAG}: Failed to get Kotlin bridge: {e}", RNS.LOG_ERROR) import traceback RNS.log(traceback.format_exc(), RNS.LOG_ERROR) return None def _setup_kotlin_callbacks(self): """Setup callbacks from Kotlin to Python.""" if not self.kotlin_bridge: return self.kotlin_bridge.setOnDeviceDiscovered(lambda address, name, rssi, service_uuids: self._handle_device_discovered(address, name, rssi, service_uuids)) # Accept 4 params: address, mtu, role, identity_hash (identity_hash may be None for Protocol v1 or peripheral) self.kotlin_bridge.setOnConnected(lambda address, mtu, role, identity_hash=None: self._handle_connected(address, mtu, role, identity_hash)) self.kotlin_bridge.setOnDisconnected(lambda address: self._handle_disconnected(address)) self.kotlin_bridge.setOnDataReceived(lambda address, data: self._handle_data_received(address, data)) self.kotlin_bridge.setOnIdentityReceived(lambda address, identity_hash: self._handle_identity_received(address, identity_hash)) self.kotlin_bridge.setOnMtuNegotiated(lambda address, mtu: self._handle_mtu_negotiated(address, mtu)) self.kotlin_bridge.setOnAddressChanged(lambda old_addr, new_addr, identity_hash: self._handle_address_changed(old_addr, new_addr, identity_hash)) # Wire up duplicate identity detection callback # Python's _check_duplicate_identity is set by BLEInterface and returns True if duplicate self.kotlin_bridge.setOnDuplicateIdentityDetected(lambda address, identity_bytes: self._handle_duplicate_identity_detected(address, identity_bytes)) RNS.log(f"{LOG_TAG}: Kotlin callbacks configured", RNS.LOG_DEBUG) def _handle_device_discovered(self, address: str, name: Optional[str], rssi: int, service_uuids: Optional[List[str]]): """Handle device discovered event from Kotlin.""" try: # service_uuids arrives as proper Python list (converted from Kotlin Array) device = BLEDevice( address=address, name=name or "Unknown", rssi=rssi, service_uuids=service_uuids or [] ) if self.on_device_discovered: self.on_device_discovered(device) except Exception as e: RNS.log(f"{LOG_TAG}: Error handling device discovered: {e}", RNS.LOG_ERROR) def _handle_connected(self, address: str, mtu: int, role: str = "central", identity_hash: Optional[str] = None): """Handle peer connected event from Kotlin. Args: address: Peer MAC address mtu: Negotiated MTU role: Connection role ("central" or "peripheral") identity_hash: 32-char hex identity string (Protocol v2.2), or None for Protocol v1/peripheral """ try: # DEBUG: Log callback entry for tracking callback chain RNS.log(f"{LOG_TAG}: [CALLBACK] _handle_connected ENTRY: address={address}, mtu={mtu}, role={role}, identity_hash={identity_hash[:16] if identity_hash else 'None'}...", RNS.LOG_DEBUG) if address not in self._connected_peers: self._connected_peers.append(address) # Set role from parameter (passed from Kotlin) self._peer_roles[address] = role RNS.log(f"{LOG_TAG}: Connected to {address} (MTU={mtu}, role={role}, identity={'present' if identity_hash else 'pending'})", RNS.LOG_INFO) # Use identity from callback (passed directly to avoid race condition with onIdentityReceived) # Fall back to _pending_identities for backwards compatibility identity = None if identity_hash: try: identity = bytes.fromhex(identity_hash) RNS.log(f"{LOG_TAG}: Using identity from onConnected callback: {identity_hash[:16]}...", RNS.LOG_DEBUG) except ValueError as e: RNS.log(f"{LOG_TAG}: Invalid identity_hash format: {e}", RNS.LOG_WARNING) if identity is None: # Fall back to pending identities (from earlier onIdentityReceived if it arrived first) # Use lock to prevent race condition with _handle_identity_received with self._identity_lock: identity = self._pending_identities.pop(address, None) if identity: RNS.log(f"{LOG_TAG}: Using identity from pending cache", RNS.LOG_DEBUG) # Track identity-to-address mapping for debugging/logging purposes only # Deduplication is handled by BLEInterface, not the driver # In dual-mode BLE, the same identity may legitimately have two addresses # (one for central connection, one for peripheral connection) if identity: identity_hex = identity.hex() with self._identity_lock: existing_address = self._identity_to_address.get(identity_hex) if existing_address and existing_address != address: # Same identity at different address - log but don't disconnect # This is normal in dual-mode BLE or during MAC rotation RNS.log( f"{LOG_TAG}: Identity {identity_hex[:16]}... also at {address} (was at {existing_address})", RNS.LOG_DEBUG ) # Update mappings - we track the most recent address per identity self._identity_to_address[identity_hex] = address self._address_to_identity[address] = identity_hex # Notify BLEInterface of connection (regardless of role) # Dual-connection prevention is handled by KotlinBLEBridge (lines 954-959) # If this callback fires, there's only ONE connection type (central XOR peripheral) if self.on_device_connected: # Call on_device_connected with identity if available (Protocol v2.2) # or None if no identity (Protocol v1 device, or peripheral pending handshake) self.on_device_connected(address, identity) if identity: RNS.log(f"{LOG_TAG}: Notified {role} connection with identity for {address}", RNS.LOG_DEBUG) else: RNS.log(f"{LOG_TAG}: Notified {role} connection without identity for {address} (will receive via handshake)", RNS.LOG_DEBUG) # Report MTU negotiation if self.on_mtu_negotiated: self.on_mtu_negotiated(address, mtu) # DEBUG: Log callback completion RNS.log(f"{LOG_TAG}: [CALLBACK] _handle_connected EXIT: address={address}, called on_device_connected={self.on_device_connected is not None}, called on_mtu_negotiated={self.on_mtu_negotiated is not None}", RNS.LOG_DEBUG) except Exception as e: RNS.log(f"{LOG_TAG}: Error handling connected: {e}", RNS.LOG_ERROR) def _handle_disconnected(self, address: str): """Handle peer disconnected event from Kotlin.""" try: if address in self._connected_peers: self._connected_peers.remove(address) if address in self._peer_roles: del self._peer_roles[address] if address in self._peer_mtus: del self._peer_mtus[address] # Clean up identity mappings for this address with self._identity_lock: identity_hex = self._address_to_identity.pop(address, None) if identity_hex: # Only remove identity->address mapping if it still points to this address # (might have been updated to a new address during MAC rotation) if self._identity_to_address.get(identity_hex) == address: del self._identity_to_address[identity_hex] RNS.log(f"{LOG_TAG}: Cleared identity mapping for {address} ({identity_hex[:16]}...)", RNS.LOG_DEBUG) RNS.log(f"{LOG_TAG}: Disconnected from {address}", RNS.LOG_INFO) if self.on_device_disconnected: self.on_device_disconnected(address) except Exception as e: RNS.log(f"{LOG_TAG}: Error handling disconnected: {e}", RNS.LOG_ERROR) def _handle_address_changed(self, old_address: str, new_address: str, identity_hash: str): """Handle address change event from Kotlin during dual connection deduplication. When Kotlin deduplicates a dual connection (same identity connected as both central and peripheral), it closes one direction and notifies Python via this callback so Python can update its address mappings. Args: old_address: The address that was closed/removed new_address: The address that remains active identity_hash: The 32-char hex identity hash for this peer """ try: RNS.log( f"{LOG_TAG}: Address changed for {identity_hash[:8]}: {old_address} -> {new_address}", RNS.LOG_INFO ) if self.on_address_changed: self.on_address_changed(old_address, new_address, identity_hash) except Exception as e: RNS.log(f"{LOG_TAG}: Error handling address changed: {e}", RNS.LOG_ERROR) def _handle_data_received(self, address: str, data: bytes): """Handle data received event from Kotlin (already defragmented). Note: Due to Android MAC randomization, data may arrive from an address that has a pending identity but never received onConnected. This happens when the same peer identity is seen from different MACs. In this case, we finalize the connection using the pending identity before passing data. """ try: data = ensure_bytes(data) RNS.log(f"{LOG_TAG}: Received {len(data)} bytes from {address}", RNS.LOG_EXTREME) # Track last receive address for RSSI queries self._last_receive_address = address # Check if this address has a pending identity but never got onConnected # This handles the case where Android MAC randomization causes identity # to arrive from one MAC but onConnected for a different MAC with same identity if address not in self._connected_peers: with self._identity_lock: pending_identity = self._pending_identities.get(address) if pending_identity: # Finalize connection with pending identity RNS.log(f"{LOG_TAG}: Finalizing connection for {address} from pending identity (data arrived first)", RNS.LOG_DEBUG) self._connected_peers.append(address) self._peer_roles[address] = "peripheral" # Data arriving = peripheral role # Remove from pending before callback to prevent double-use del self._pending_identities[address] # Call on_device_connected to create identity mappings if self.on_device_connected: self.on_device_connected(address, pending_identity) # IMPORTANT: Also call on_mtu_negotiated to create reassembler # Without this, BLEInterface has identity but no reassembler mtu = self._peer_mtus.get(address, 23) # Default to BLE 4.0 minimum if self.on_mtu_negotiated: self.on_mtu_negotiated(address, mtu) if self.on_data_received: self.on_data_received(address, data) except Exception as e: RNS.log(f"{LOG_TAG}: Error handling data received: {e}", RNS.LOG_ERROR) def _handle_identity_received(self, address: str, identity_hash: str): """Handle identity received event from Kotlin (Protocol v2.2). This is called when the central's identity is received via the GATT identity characteristic (for peripheral mode). For central mode, identity is passed directly in the onConnected callback, so this should not be called. IMPORTANT: For peripheral connections, this callback may arrive AFTER onConnected has already fired. In that case, we must notify BLEInterface of the identity so it can spawn the peer interface. Thread safety: Uses _identity_lock to prevent race conditions with _handle_connected when identity and connection callbacks interleave. """ try: # DEBUG: Log callback entry RNS.log(f"{LOG_TAG}: [CALLBACK] _handle_identity_received ENTRY: address={address}, identity={identity_hash[:16]}..., already_connected={address in self._connected_peers}", RNS.LOG_DEBUG) RNS.log(f"{LOG_TAG}: Identity received from {address}: {identity_hash[:16]}...", RNS.LOG_INFO) # Convert hex string to bytes (16 bytes = 32 hex chars) identity_bytes = bytes.fromhex(identity_hash) # Use lock to prevent race condition with _handle_connected with self._identity_lock: # Track identity-to-address mapping for debugging/logging purposes only # Deduplication is handled by BLEInterface, not the driver # In dual-mode BLE, the same identity may legitimately have two addresses # (one for central connection, one for peripheral connection) existing_address = self._identity_to_address.get(identity_hash) if existing_address and existing_address != address: # Same identity at different address - log but don't disconnect # This is normal in dual-mode BLE or during MAC rotation RNS.log( f"{LOG_TAG}: Identity {identity_hash[:16]}... also at {address} (was at {existing_address})", RNS.LOG_DEBUG ) # Update identity mappings self._identity_to_address[identity_hash] = address self._address_to_identity[address] = identity_hash # Check if peer is already connected (common case for peripheral mode) # where onConnected fires before identity is received if address in self._connected_peers: # Peer is already connected - notify BLEInterface immediately # This allows BLEInterface to spawn the peer interface with identity RNS.log(f"{LOG_TAG}: Late identity for connected peer {address}, notifying BLEInterface", RNS.LOG_DEBUG) if self.on_device_connected: self.on_device_connected(address, identity_bytes) RNS.log(f"{LOG_TAG}: [CALLBACK] _handle_identity_received: Called on_device_connected for late identity at {address}", RNS.LOG_DEBUG) else: # Peer not yet connected - cache identity for when onConnected fires # This handles the race where identity arrives before connection self._pending_identities[address] = identity_bytes RNS.log(f"{LOG_TAG}: Cached identity for {address}, waiting for connection complete", RNS.LOG_DEBUG) RNS.log(f"{LOG_TAG}: [CALLBACK] _handle_identity_received: Cached identity (peer not connected yet) for {address}", RNS.LOG_DEBUG) except Exception as e: RNS.log(f"{LOG_TAG}: Error handling identity received: {e}", RNS.LOG_ERROR) def _handle_mtu_negotiated(self, address: str, mtu: int): """Handle MTU negotiation completion from Kotlin.""" try: # DEBUG: Log callback entry RNS.log(f"{LOG_TAG}: [CALLBACK] _handle_mtu_negotiated ENTRY: address={address}, mtu={mtu}", RNS.LOG_DEBUG) RNS.log(f"{LOG_TAG}: MTU negotiated with {address}: {mtu}", RNS.LOG_INFO) # Store MTU for this peer self._peer_mtus[address] = mtu if self.on_mtu_negotiated: self.on_mtu_negotiated(address, mtu) RNS.log(f"{LOG_TAG}: [CALLBACK] _handle_mtu_negotiated: Called on_mtu_negotiated for {address}", RNS.LOG_DEBUG) except Exception as e: RNS.log(f"{LOG_TAG}: Error handling MTU negotiated: {e}", RNS.LOG_ERROR) def _handle_duplicate_identity_detected(self, address: str, identity_bytes: bytes) -> bool: """Handle duplicate identity detection request from Kotlin. Called by Kotlin's handleIdentityReceived before accepting a connection. This forwards to Python's _check_duplicate_identity (set by BLEInterface) which checks if this identity is already connected at a different address. Args: address: MAC address of the incoming connection identity_bytes: 16-byte identity of the peer Returns: True if duplicate (connection should be rejected), False otherwise """ try: RNS.log(f"{LOG_TAG}: [CALLBACK] _handle_duplicate_identity_detected: address={address}", RNS.LOG_DEBUG) identity_bytes = ensure_bytes(identity_bytes) # Forward to the callback set by BLEInterface if hasattr(self, 'on_duplicate_identity_detected') and self.on_duplicate_identity_detected: is_duplicate = self.on_duplicate_identity_detected(address, identity_bytes) if is_duplicate: RNS.log( f"{LOG_TAG}: Duplicate identity rejected for {address} (MAC rotation)", RNS.LOG_WARNING ) return bool(is_duplicate) # No callback set - allow connection return False except Exception as e: RNS.log(f"{LOG_TAG}: Error in duplicate identity check: {e}", RNS.LOG_ERROR) # On error, allow connection (fail open) return False