columba/python/ble_modules/BLEGATTServer.py
2025-11-30 19:59:14 -05:00

684 lines
26 KiB
Python

"""
BLE GATT Server for Reticulum
This module implements BLE peripheral mode (GATT server) using the bluezero library
to enable devices to advertise themselves and accept connections from BLE centrals.
Implementation details:
- Uses bluezero (direct BlueZ D-Bus API)
- Linux-only (requires BlueZ 5.x)
- Thread-based architecture (bluezero publish() blocks)
- Supports multiple concurrent central connections
- MTU negotiation via BlueZ callback options
Author: Reticulum BLE Interface Contributors
License: MIT
"""
from __future__ import annotations
import asyncio
import time
import threading
import queue
from typing import Any, Dict, Optional, Callable
import logging
try:
from bluezero import peripheral, adapter
BLUEZERO_AVAILABLE = True
except ImportError:
BLUEZERO_AVAILABLE = False
# Import BLE agent for automatic pairing
try:
from BLEAgent import register_agent, unregister_agent
HAS_BLE_AGENT = True
except ImportError:
try:
from RNS.Interfaces.BLEAgent import register_agent, unregister_agent
HAS_BLE_AGENT = True
except ImportError:
HAS_BLE_AGENT = False
class BLEGATTServer:
"""
BLE GATT Server for Reticulum (Peripheral Mode)
Handles:
- Advertising Reticulum service
- Accepting connections from BLE centrals
- Receiving data via RX characteristic (centrals write to us)
- Sending data via TX characteristic (we notify centrals)
- Managing multiple concurrent central connections
This enables a device to be discovered by other BLE devices acting as centrals.
"""
# Service UUID for Reticulum BLE
SERVICE_UUID = "37145b00-442d-4a94-917f-8f42c5da28e3"
# RX Characteristic: Centrals write to this (we receive)
RX_CHAR_UUID = "37145b00-442d-4a94-917f-8f42c5da28e5"
# TX Characteristic: We notify on this (centrals receive)
TX_CHAR_UUID = "37145b00-442d-4a94-917f-8f42c5da28e4"
# Identity Characteristic: Centrals read this to get stable node identity (Protocol v2)
IDENTITY_CHAR_UUID = "37145b00-442d-4a94-917f-8f42c5da28e6"
def __init__(self, interface, device_name: str = "Reticulum-Node", agent_capability: str = "NoInputNoOutput"):
"""
Initialize BLE GATT Server
Args:
interface: Parent BLEInterface instance
device_name: BLE device name for advertising
agent_capability: Pairing agent capability ("NoInputNoOutput" for Just Works pairing, or "DisplayOnly")
Default "NoInputNoOutput" avoids MITM protection requirements
"""
if not BLUEZERO_AVAILABLE:
raise ImportError("BLE GATT Server requires 'bluezero' library. Install with: pip install bluezero>=0.9.1 dbus-python>=1.2.18")
self.interface = interface
self.device_name = device_name
self.agent_capability = agent_capability
self.running = False
# bluezero objects (created in thread)
self.peripheral_obj = None
self.tx_characteristic = None
self.rx_characteristic = None
# Identity (Protocol v2)
self.identity_hash = None # 16-byte Transport identity hash
# BLE agent for automatic pairing
self.ble_agent = None
# Threading
self.server_thread = None
self.stop_event = threading.Event()
self.started_event = threading.Event()
self.notification_queue = queue.Queue()
# Track connected centrals
# Key: central address (if available), Value: connection info
self.connected_centrals: Dict[str, dict] = {}
self._centrals_lock = threading.RLock() # Reentrant lock to allow nested acquisitions in callback chains
# Callbacks for data handling
self.on_data_received: Optional[Callable] = None # Called when data written to RX
self.on_central_connected: Optional[Callable] = None
self.on_central_disconnected: Optional[Callable] = None
# Logging
self.log_prefix = f"BLEGATTServer[{device_name}]"
self._log(f"Initialized bluezero GATT server (agent capability: {agent_capability})", level="DEBUG")
def _log(self, message: str, level: str = "INFO"):
"""Log message with appropriate level"""
# Use RNS.log for consistent logging with interface
try:
import RNS
# Map string level to RNS log levels
level_map = {
"DEBUG": RNS.LOG_DEBUG,
"INFO": RNS.LOG_INFO,
"WARNING": RNS.LOG_WARNING,
"ERROR": RNS.LOG_ERROR,
}
rns_level = level_map.get(level.upper(), RNS.LOG_INFO)
RNS.log(f"{self.log_prefix} {message}", rns_level)
except:
# Fallback to standard logging if RNS not available
log_func = getattr(logging, level.lower(), logging.info)
log_func(f"{self.log_prefix} {message}")
# ========== bluezero Callbacks (run in server thread) ==========
def _handle_write_rx(self, value, options):
"""
Handle write request from central (bluezero callback)
Called when a central writes data to RX characteristic.
This runs in the bluezero thread.
Args:
value: The data written by the central (list of ints)
options: D-Bus options dict (may contain 'device' address)
Returns:
value: Echo back the value (required by bluezero)
"""
# Convert to bytes - ensure we always have bytes type
if isinstance(value, list):
data = bytes(value)
elif isinstance(value, bytes):
data = value
else:
# Handle other types (bytearray, etc.)
data = bytes(value)
# Extract central address and MTU from options (if available)
central_address = options.get("device", "unknown")
if central_address and central_address != "unknown":
central_address = central_address.split("/")[-1].replace("_", ":")
# Extract negotiated MTU from options (BlueZ provides this in GATT server callbacks)
mtu = options.get("mtu", None)
self._log(f">>> WRITE REQUEST from {central_address}: {len(data)} bytes (type: {type(data).__name__}, MTU: {mtu})", level="INFO")
# Track this central if not already tracked, and update MTU if provided
with self._centrals_lock:
already_connected = central_address in self.connected_centrals
self._log(f"Central membership check: {central_address} already_connected={already_connected}, dict_size={len(self.connected_centrals)}", level="DEBUG")
if not already_connected:
self._log(f"New central detected, calling _handle_central_connected({central_address}, mtu={mtu})", level="DEBUG")
self._handle_central_connected(central_address, mtu)
elif mtu is not None:
# Update MTU for existing central (may be negotiated after first connection)
old_mtu = self.connected_centrals[central_address].get("mtu", "unknown")
if old_mtu != mtu:
self.connected_centrals[central_address]["mtu"] = mtu
self._log(f"Updated MTU for {central_address}: {old_mtu} -> {mtu}", level="DEBUG")
# Pass data to callback for processing
if self.on_data_received:
try:
# Verify data is bytes before callback
if not isinstance(data, bytes):
self._log(f"WARNING: Converting {type(data).__name__} to bytes before callback", level="WARNING")
data = bytes(data)
# Call the callback (synchronous call - runs in bluezero thread)
self.on_data_received(data, central_address)
except Exception as e:
self._log(f"ERROR in data received callback: {type(e).__name__}: {e}", level="ERROR")
import traceback
self._log(f"Traceback: {traceback.format_exc()}", level="ERROR")
else:
self._log(f"on_data_received callback is NONE! Data LOST: {len(data)} bytes from {central_address}", level="ERROR")
return value # bluezero expects us to return the value
def _handle_read_identity(self, options):
"""
Handle read request for Identity characteristic (bluezero callback)
Called when a central reads the Identity characteristic.
Returns the 16-byte Transport identity hash.
Args:
options: D-Bus options dict (may contain 'device' address)
Returns:
list of ints: The 16-byte identity hash as a list of integers
"""
# Extract central address from options
central_address = options.get("device", "unknown")
if central_address and central_address != "unknown":
central_address = central_address.split("/")[-1].replace("_", ":")
if self.identity_hash is None:
self._log(f">>> READ REQUEST for Identity from {central_address}: Identity not available yet", level="WARNING")
return [] # Return empty if not available
# Convert bytes to list of ints for bluezero
identity_list = list(self.identity_hash)
self._log(f">>> READ REQUEST for Identity from {central_address}: Serving {len(identity_list)} bytes", level="INFO")
return identity_list
def _handle_central_connected(self, central_address: str, mtu: Optional[int] = None):
"""
Handle new central connection
Args:
central_address: Address of connected central
mtu: Negotiated MTU size (if available from BlueZ callback)
"""
# DIAGNOSTIC: Method entry
self._log(f"_handle_central_connected ENTRY: address={central_address}, mtu={mtu}, dict_size={len(self.connected_centrals)}", level="DEBUG")
if central_address in self.connected_centrals:
self._log(f"_handle_central_connected: {central_address} ALREADY in connected_centrals (duplicate call), skipping", level="WARNING")
return
# Default MTU: 185 bytes is common for BLE 4.2
# Will be updated if BlueZ provides actual negotiated MTU
effective_mtu = mtu if mtu is not None else 185
self.connected_centrals[central_address] = {
"address": central_address,
"connected_at": time.time(),
"bytes_received": 0,
"bytes_sent": 0,
"mtu": effective_mtu,
}
self._log(f"Central connected: {central_address} (MTU: {effective_mtu})", level="INFO")
if self.on_central_connected:
try:
self._log(f"Invoking on_central_connected({central_address})...", level="DEBUG")
self.on_central_connected(central_address)
self._log(f"on_central_connected callback completed successfully for {central_address}", level="DEBUG")
except Exception as e:
self._log(f"Error in central connected callback: {e}", level="ERROR")
def _handle_central_disconnected(self, central_address: str):
"""
Handle central disconnection
Args:
central_address: Address of disconnected central
"""
if central_address not in self.connected_centrals:
return
info = self.connected_centrals[central_address]
self._log(
f"Central disconnected: {central_address} "
f"(RX: {info['bytes_received']}, TX: {info['bytes_sent']})",
level="INFO"
)
del self.connected_centrals[central_address]
if self.on_central_disconnected:
try:
self.on_central_disconnected(central_address)
except Exception as e:
self._log(f"Error in central disconnected callback: {e}", level="ERROR")
# ========== Server Thread ==========
def _run_server_thread(self):
"""
Run bluezero GATT server in separate thread
This thread blocks in peripheral.publish() until stopped.
"""
try:
self._log("Server thread starting...", level="DEBUG")
# Register BLE agent for automatic pairing (if available)
# MUST be done before creating peripheral to handle initial pairing
if HAS_BLE_AGENT:
try:
self.ble_agent = register_agent(self.agent_capability)
self._log(f"✓ BLE agent registered with capability: {self.agent_capability}", level="INFO")
except Exception as e:
self._log(f"Failed to register BLE agent: {e}. Pairing may fail.", level="WARNING")
self.ble_agent = None
else:
self._log("BLEAgent module not available. Pairing will require manual interaction.", level="WARNING")
# Suppress bluezero INFO logging to prevent TUI interference
# bluezero logs things like "Notifying already, nothing to do" which
# pollute stdout/stderr and break Nomadnet TUI display
import logging
logging.getLogger('bluezero').setLevel(logging.WARNING)
logging.getLogger('bluezero.GATT').setLevel(logging.WARNING)
logging.getLogger('bluezero.localGATT').setLevel(logging.WARNING)
logging.getLogger('bluezero.adapter').setLevel(logging.WARNING)
logging.getLogger('bluezero.peripheral').setLevel(logging.WARNING)
# Get Bluetooth adapter
adapters = adapter.list_adapters()
if not adapters:
self._log("No Bluetooth adapters found!", level="ERROR")
self.started_event.set() # Signal failure
return
local_adapter = adapter.Adapter(adapters[0])
adapter_address = local_adapter.address
self._log(f"Using adapter: {adapter_address}", level="DEBUG")
# Create peripheral
self.peripheral_obj = peripheral.Peripheral(
adapter_address,
local_name=self.device_name
)
# Add Reticulum service
self.peripheral_obj.add_service(
srv_id=1,
uuid=self.SERVICE_UUID,
primary=True
)
self._log(f"Added service: {self.SERVICE_UUID}", level="DEBUG")
# Add RX characteristic (write from central)
self.peripheral_obj.add_characteristic(
srv_id=1,
chr_id=1,
uuid=self.RX_CHAR_UUID,
value=[],
notifying=False,
flags=['write', 'write-without-response'],
write_callback=self._handle_write_rx
)
self._log(f"Added RX characteristic: {self.RX_CHAR_UUID} (WRITE)", level="DEBUG")
# Add TX characteristic (notify to central)
self.peripheral_obj.add_characteristic(
srv_id=1,
chr_id=2,
uuid=self.TX_CHAR_UUID,
value=[],
notifying=True,
flags=['read', 'notify']
)
self._log(f"Added TX characteristic: {self.TX_CHAR_UUID} (READ, NOTIFY)", level="DEBUG")
# Add Identity characteristic (read to get stable node identity - Protocol v2)
identity_value = list(self.identity_hash) if self.identity_hash else []
self.peripheral_obj.add_characteristic(
srv_id=1,
chr_id=3,
uuid=self.IDENTITY_CHAR_UUID,
value=identity_value,
notifying=False,
flags=['read'],
read_callback=self._handle_read_identity
)
if identity_value:
self._log(f"Added Identity characteristic: {self.IDENTITY_CHAR_UUID} (READ) with {len(identity_value)} bytes - Protocol v2", level="DEBUG")
else:
self._log(f"Added Identity characteristic: {self.IDENTITY_CHAR_UUID} (READ) with EMPTY value - will be updated when identity loads", level="WARNING")
# Find and save TX characteristic for later notification sends
# Characteristics are stored in order added: chr_id=1 (RX) is index 0, chr_id=2 (TX) is index 1
if len(self.peripheral_obj.characteristics) >= 2:
self.tx_characteristic = self.peripheral_obj.characteristics[1] # chr_id=2 (TX)
self._log(f"Saved TX characteristic reference (chr_id=2)", level="DEBUG")
else:
self._log(f"ERROR: TX characteristic not found! Only {len(self.peripheral_obj.characteristics)} characteristics", level="ERROR")
self.started_event.set()
return
self._log("✓ GATT server configured successfully", level="INFO")
# Signal that server is ready
self.running = True
self.started_event.set()
# Start publishing (this blocks until stopped)
self._log("Publishing (blocking call)...", level="DEBUG")
self.peripheral_obj.publish()
except Exception as e:
self._log(f"Server thread error: {type(e).__name__}: {e}", level="ERROR")
import traceback
traceback.print_exc()
self.started_event.set() # Signal failure
finally:
# Unregister agent
if self.ble_agent and HAS_BLE_AGENT:
try:
unregister_agent(self.ble_agent)
self._log("BLE agent unregistered", level="DEBUG")
except Exception as e:
self._log(f"Error unregistering agent: {e}", level="DEBUG")
self.ble_agent = None
self.running = False
self._log("Server thread exiting", level="DEBUG")
# ========== Public API (async, compatible with BLEGATTServer) ==========
async def start(self):
"""
Start the GATT server and begin advertising
This creates the BLE service and characteristics, then starts advertising
so that BLE centrals can discover and connect to this device.
"""
if self.running:
self._log("Server already running", level="WARNING")
return
try:
self._log("Starting GATT server...")
# Reset events
self.stop_event.clear()
self.started_event.clear()
# Start server thread
self.server_thread = threading.Thread(
target=self._run_server_thread,
daemon=True,
name="bluezero-gatt-server"
)
self.server_thread.start()
# Wait for server to start (with timeout)
started = self.started_event.wait(timeout=10.0)
if not started or not self.running:
self._log("GATT server failed to start within timeout", level="ERROR")
raise TimeoutError("GATT server startup timeout")
self._log("✓ GATT server started and advertising", level="INFO")
self._log(f"Device name: {self.device_name}", level="INFO")
self._log(f"Service UUID: {self.SERVICE_UUID}", level="DEBUG")
except Exception as e:
error_type = type(e).__name__
self._log(f"Failed to start GATT server: {error_type}: {e}", level="ERROR")
self.running = False
raise
def set_transport_identity(self, identity_hash: bytes):
"""
Set the Transport identity hash for BLE Protocol v2.
This should be called after RNS.Transport is initialized and before
starting the GATT server (or early during startup).
Args:
identity_hash: 16-byte Reticulum Transport identity hash
"""
if not isinstance(identity_hash, bytes):
raise TypeError(f"identity_hash must be bytes, got {type(identity_hash)}")
if len(identity_hash) != 16:
raise ValueError(f"identity_hash must be 16 bytes, got {len(identity_hash)}")
self.identity_hash = identity_hash
self._log(f"Transport identity set: {identity_hash.hex()}", level="INFO")
async def stop(self):
"""
Stop the GATT server and advertising
Disconnects all centrals and stops advertising.
"""
if not self.running:
return
try:
self._log("Stopping GATT server...")
# Signal server thread to stop
self.stop_event.set()
self.running = False
# Wait for thread to exit (with timeout)
if self.server_thread and self.server_thread.is_alive():
self.server_thread.join(timeout=5.0)
if self.server_thread.is_alive():
self._log("Server thread did not exit cleanly", level="WARNING")
# Clean up connected centrals
num_centrals = len(self.connected_centrals)
if num_centrals > 0:
self._log(f"Disconnecting {num_centrals} connected central(s)", level="DEBUG")
with self._centrals_lock:
self.connected_centrals.clear()
self._log("✓ GATT server stopped", level="INFO")
except Exception as e:
error_type = type(e).__name__
self._log(f"Error stopping GATT server: {error_type}: {e}", level="ERROR")
# Ensure cleanup even on error
self.running = False
with self._centrals_lock:
self.connected_centrals.clear()
async def send_notification(self, data: bytes, central_address: Optional[str] = None):
"""
Send notification to connected central(s)
Sends data to a specific central or broadcasts to all connected centrals.
Uses BLE notification mechanism on TX characteristic.
Args:
data: Data to send (BLE fragment)
central_address: Specific central to send to, or None for broadcast
Returns:
bool: True if sent successfully, False otherwise
"""
if not self.running or not self.tx_characteristic:
self._log("Cannot send notification: server not running", level="WARNING")
return False
if not data:
self._log("Cannot send notification: empty data", level="WARNING")
return False
# Check if target central is connected
if central_address:
with self._centrals_lock:
if central_address not in self.connected_centrals:
self._log(f"Cannot send notification: central {central_address} not connected", level="WARNING")
return False
try:
# Convert bytes to list of ints (bluezero format)
if isinstance(data, bytes):
value = list(data)
else:
value = data
# Update TX characteristic value
# bluezero automatically sends notification to subscribed centrals
self.tx_characteristic.set_value(value)
# Update statistics
with self._centrals_lock:
if central_address and central_address in self.connected_centrals:
self.connected_centrals[central_address]["bytes_sent"] += len(data)
else:
# Broadcast: update all centrals
for addr in self.connected_centrals:
self.connected_centrals[addr]["bytes_sent"] += len(data)
self._log(
f"Sent notification: {len(data)} bytes to "
f"{central_address if central_address else 'all centrals'}",
level="DEBUG"
)
return True
except Exception as e:
error_type = type(e).__name__
self._log(f"Error sending notification: {error_type}: {e}", level="ERROR")
return False
# ========== Connection Management ==========
def is_connected(self, central_address: str) -> bool:
"""
Check if a central is currently connected
Args:
central_address: Address to check
Returns:
bool: True if connected, False otherwise
"""
with self._centrals_lock:
return central_address in self.connected_centrals
def get_connected_centrals(self) -> list:
"""
Get list of currently connected central addresses
Returns:
list: List of central addresses
"""
with self._centrals_lock:
return list(self.connected_centrals.keys())
def get_connection_info(self, central_address: str) -> Optional[dict]:
"""
Get connection information for a specific central
Args:
central_address: Address of central
Returns:
dict: Connection info or None if not connected
"""
with self._centrals_lock:
return self.connected_centrals.get(central_address)
def get_central_mtu(self, central_address: str) -> int:
"""
Get negotiated MTU for a connected central
Args:
central_address: Address of central
Returns:
int: Negotiated MTU size, or 185 (default) if not connected or unknown
"""
with self._centrals_lock:
if central_address in self.connected_centrals:
return self.connected_centrals[central_address].get("mtu", 185)
return 185 # Default fallback
def get_statistics(self) -> dict:
"""
Get server statistics
Returns:
dict: Statistics including connected centrals, bytes transferred, etc.
"""
with self._centrals_lock:
total_rx = sum(info["bytes_received"] for info in self.connected_centrals.values())
total_tx = sum(info["bytes_sent"] for info in self.connected_centrals.values())
return {
"running": self.running,
"device_name": self.device_name,
"connected_centrals": len(self.connected_centrals),
"total_bytes_received": total_rx,
"total_bytes_sent": total_tx,
"centrals": list(self.connected_centrals.values()),
}
def __str__(self) -> str:
"""String representation"""
status = "running" if self.running else "stopped"
centrals = len(self.connected_centrals)
return f"BLEGATTServer({self.device_name}, {status}, {centrals} centrals)"
def __repr__(self) -> str:
"""Detailed representation"""
return (
f"BLEGATTServer(device_name='{self.device_name}', "
f"running={self.running}, "
f"connected_centrals={len(self.connected_centrals)})"
)