mirror of
https://github.com/torlando-tech/columba
synced 2026-08-12 18:07:15 -04:00
Extends signal metrics support to BLE interfaces by implementing RSSI tracking for BLE peer connections. Uses existing KotlinBLEBridge to query the last RSSI value from connected peers via the Android GATT connection. Changes: - Add getPeerRssi() method to KotlinBLEBridge for querying BLE RSSI - Add get_peer_rssi() to AndroidBLEDriver with last-receive tracking - Add get_rssi() to AndroidBLEInterface wrapper classes - Handle BLEPeerInterface in signal_quality.py for proper RSSI extraction - Add 16 comprehensive unit tests for new functionality Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
887 lines
39 KiB
Python
887 lines
39 KiB
Python
#!/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
|