Initial commit - Phase 3/4
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
457
app/backend/ble/bluez_adapter.py
Normal file
457
app/backend/ble/bluez_adapter.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""BlueZ GATT adapter using the bless library.
|
||||
|
||||
This module bridges our GATT server handlers to BlueZ via bless,
|
||||
enabling BLE peripheral functionality on Linux.
|
||||
|
||||
Architecture:
|
||||
bless BLEServer (handles BlueZ D-Bus)
|
||||
|
|
||||
BlueZGATTAdapter (this file - bridges handlers)
|
||||
|
|
||||
DangerousPiGATTServer (handlers call service layer)
|
||||
|
|
||||
Service Layer (business logic)
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from typing import Dict, Optional, Any, Union
|
||||
|
||||
from bless import (
|
||||
BlessServer,
|
||||
BlessGATTCharacteristic,
|
||||
GATTCharacteristicProperties,
|
||||
GATTAttributePermissions,
|
||||
)
|
||||
|
||||
from .gatt_server import DangerousPiGATTServer
|
||||
from .characteristics import (
|
||||
PM3CharacteristicUUIDs,
|
||||
WiFiCharacteristicUUIDs,
|
||||
SystemCharacteristicUUIDs,
|
||||
UpdateCharacteristicUUIDs,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Device name for BLE advertising
|
||||
DEFAULT_DEVICE_NAME = "Dangerous-Pi"
|
||||
|
||||
|
||||
class BlueZGATTAdapter:
|
||||
"""Adapter connecting bless BLE server to our GATT handlers.
|
||||
|
||||
This class:
|
||||
- Initializes the bless BLE server
|
||||
- Registers all services and characteristics via GATT dictionary
|
||||
- Routes read/write requests to DangerousPiGATTServer handlers
|
||||
- Sends notifications via bless
|
||||
"""
|
||||
|
||||
def __init__(self, device_name: str = DEFAULT_DEVICE_NAME):
|
||||
"""Initialize the BlueZ GATT adapter.
|
||||
|
||||
Args:
|
||||
device_name: BLE device name for advertising
|
||||
"""
|
||||
self.device_name = device_name
|
||||
self.server: Optional[BlessServer] = None
|
||||
self.gatt_server = DangerousPiGATTServer()
|
||||
self._is_running = False
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
# Platform-specific trigger for async coordination
|
||||
self._trigger: Union[asyncio.Event, threading.Event]
|
||||
if sys.platform in ["darwin", "win32"]:
|
||||
self._trigger = threading.Event()
|
||||
else:
|
||||
self._trigger = asyncio.Event()
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if BLE server is running."""
|
||||
return self._is_running
|
||||
|
||||
def _build_gatt_dict(self) -> Dict:
|
||||
"""Build the GATT dictionary for bless.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping service UUIDs to characteristic definitions
|
||||
"""
|
||||
return {
|
||||
# PM3 Service
|
||||
PM3CharacteristicUUIDs.SERVICE: {
|
||||
PM3CharacteristicUUIDs.COMMAND_WRITE: {
|
||||
"Properties": GATTCharacteristicProperties.write,
|
||||
"Permissions": GATTAttributePermissions.writeable,
|
||||
"Value": None,
|
||||
},
|
||||
PM3CharacteristicUUIDs.COMMAND_RESULT: {
|
||||
"Properties": (
|
||||
GATTCharacteristicProperties.read |
|
||||
GATTCharacteristicProperties.notify
|
||||
),
|
||||
"Permissions": GATTAttributePermissions.readable,
|
||||
"Value": bytearray(b'{}'),
|
||||
},
|
||||
PM3CharacteristicUUIDs.STATUS: {
|
||||
"Properties": GATTCharacteristicProperties.read,
|
||||
"Permissions": GATTAttributePermissions.readable,
|
||||
"Value": bytearray(b'{"connected": false}'),
|
||||
},
|
||||
PM3CharacteristicUUIDs.SESSION_CREATE: {
|
||||
"Properties": GATTCharacteristicProperties.write,
|
||||
"Permissions": GATTAttributePermissions.writeable,
|
||||
"Value": None,
|
||||
},
|
||||
PM3CharacteristicUUIDs.SESSION_RELEASE: {
|
||||
"Properties": GATTCharacteristicProperties.write,
|
||||
"Permissions": GATTAttributePermissions.writeable,
|
||||
"Value": None,
|
||||
},
|
||||
},
|
||||
# WiFi Service
|
||||
WiFiCharacteristicUUIDs.SERVICE: {
|
||||
WiFiCharacteristicUUIDs.STATUS: {
|
||||
"Properties": GATTCharacteristicProperties.read,
|
||||
"Permissions": GATTAttributePermissions.readable,
|
||||
"Value": bytearray(b'{"mode": "unknown"}'),
|
||||
},
|
||||
WiFiCharacteristicUUIDs.SCAN: {
|
||||
"Properties": (
|
||||
GATTCharacteristicProperties.write |
|
||||
GATTCharacteristicProperties.notify
|
||||
),
|
||||
"Permissions": (
|
||||
GATTAttributePermissions.readable |
|
||||
GATTAttributePermissions.writeable
|
||||
),
|
||||
"Value": None,
|
||||
},
|
||||
WiFiCharacteristicUUIDs.CONNECT: {
|
||||
"Properties": GATTCharacteristicProperties.write,
|
||||
"Permissions": GATTAttributePermissions.writeable,
|
||||
"Value": None,
|
||||
},
|
||||
WiFiCharacteristicUUIDs.MODE: {
|
||||
"Properties": (
|
||||
GATTCharacteristicProperties.read |
|
||||
GATTCharacteristicProperties.write
|
||||
),
|
||||
"Permissions": (
|
||||
GATTAttributePermissions.readable |
|
||||
GATTAttributePermissions.writeable
|
||||
),
|
||||
"Value": bytearray(b'client'),
|
||||
},
|
||||
},
|
||||
# System Service
|
||||
SystemCharacteristicUUIDs.SERVICE: {
|
||||
SystemCharacteristicUUIDs.INFO: {
|
||||
"Properties": (
|
||||
GATTCharacteristicProperties.read |
|
||||
GATTCharacteristicProperties.notify
|
||||
),
|
||||
"Permissions": GATTAttributePermissions.readable,
|
||||
"Value": bytearray(b'{}'),
|
||||
},
|
||||
SystemCharacteristicUUIDs.SHUTDOWN: {
|
||||
"Properties": GATTCharacteristicProperties.write,
|
||||
"Permissions": GATTAttributePermissions.writeable,
|
||||
"Value": None,
|
||||
},
|
||||
SystemCharacteristicUUIDs.RESTART: {
|
||||
"Properties": GATTCharacteristicProperties.write,
|
||||
"Permissions": GATTAttributePermissions.writeable,
|
||||
"Value": None,
|
||||
},
|
||||
},
|
||||
# Update Service
|
||||
UpdateCharacteristicUUIDs.SERVICE: {
|
||||
UpdateCharacteristicUUIDs.CHECK: {
|
||||
"Properties": (
|
||||
GATTCharacteristicProperties.write |
|
||||
GATTCharacteristicProperties.notify
|
||||
),
|
||||
"Permissions": (
|
||||
GATTAttributePermissions.readable |
|
||||
GATTAttributePermissions.writeable
|
||||
),
|
||||
"Value": None,
|
||||
},
|
||||
UpdateCharacteristicUUIDs.PROGRESS: {
|
||||
"Properties": (
|
||||
GATTCharacteristicProperties.read |
|
||||
GATTCharacteristicProperties.notify
|
||||
),
|
||||
"Permissions": GATTAttributePermissions.readable,
|
||||
"Value": bytearray(b'{"progress": 0}'),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _on_read(self, characteristic: BlessGATTCharacteristic) -> bytearray:
|
||||
"""Handle BLE read request.
|
||||
|
||||
Note: This callback is called synchronously from the D-Bus event handler.
|
||||
We cannot block on async operations here as it would deadlock the event loop.
|
||||
Instead, we return cached values and schedule async updates in the background.
|
||||
|
||||
Args:
|
||||
characteristic: The characteristic being read
|
||||
|
||||
Returns:
|
||||
Characteristic value as bytearray
|
||||
"""
|
||||
uuid = str(characteristic.uuid).lower()
|
||||
logger.info("BLE Read request for characteristic %s", uuid)
|
||||
|
||||
# Return the current cached value (synchronously)
|
||||
# Async handlers update these values in the background
|
||||
if characteristic.value:
|
||||
logger.info("Read returning cached: %s", characteristic.value[:50] if len(characteristic.value) > 50 else characteristic.value)
|
||||
return characteristic.value
|
||||
|
||||
# Return default JSON if no cached value
|
||||
default_value = bytearray(b'{"status": "initializing"}')
|
||||
logger.info("Read returning default: %s", default_value)
|
||||
return default_value
|
||||
|
||||
def _on_write(self, characteristic: BlessGATTCharacteristic, value: Any):
|
||||
"""Handle BLE write request.
|
||||
|
||||
Args:
|
||||
characteristic: The characteristic being written
|
||||
value: Value being written
|
||||
"""
|
||||
uuid = str(characteristic.uuid).lower() # Use lowercase to match our UUID format
|
||||
logger.info("BLE Write request for characteristic %s: %s", uuid, value)
|
||||
|
||||
# Update the characteristic value
|
||||
characteristic.value = value
|
||||
|
||||
handler = self.gatt_server.get_characteristic_handler(uuid)
|
||||
if handler and handler.write_handler:
|
||||
try:
|
||||
# Convert value to bytes if needed
|
||||
if isinstance(value, bytearray):
|
||||
data = bytes(value)
|
||||
elif isinstance(value, bytes):
|
||||
data = value
|
||||
else:
|
||||
data = bytes(value)
|
||||
|
||||
# Run async handler in event loop
|
||||
if self._loop and self._loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
handler.write_handler(data),
|
||||
self._loop
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error handling write for %s: %s", uuid, e)
|
||||
|
||||
def _on_subscribe(self, characteristic: BlessGATTCharacteristic, **kwargs):
|
||||
"""Handle subscription to characteristic notifications."""
|
||||
logger.info("Client subscribed to %s", characteristic.uuid)
|
||||
|
||||
def _on_unsubscribe(self, characteristic: BlessGATTCharacteristic, **kwargs):
|
||||
"""Handle unsubscription from characteristic notifications."""
|
||||
logger.info("Client unsubscribed from %s", characteristic.uuid)
|
||||
|
||||
async def start(self) -> bool:
|
||||
"""Start the BLE GATT server.
|
||||
|
||||
Returns:
|
||||
True if started successfully, False otherwise
|
||||
"""
|
||||
if self._is_running:
|
||||
logger.warning("BLE server already running")
|
||||
return True
|
||||
|
||||
try:
|
||||
self._loop = asyncio.get_running_loop()
|
||||
|
||||
# Create bless server
|
||||
self.server = BlessServer(name=self.device_name, loop=self._loop)
|
||||
|
||||
# Set up callbacks (bless 0.3.0+ API)
|
||||
self.server.read_request_func = self._on_read
|
||||
self.server.write_request_func = self._on_write
|
||||
|
||||
# Build and add GATT structure
|
||||
gatt = self._build_gatt_dict()
|
||||
await self.server.add_gatt(gatt)
|
||||
|
||||
# Start the server (begins advertising)
|
||||
await self.server.start()
|
||||
|
||||
# Start GATT server handlers
|
||||
await self.gatt_server.start()
|
||||
|
||||
# Register notification callbacks
|
||||
self._setup_notification_callbacks()
|
||||
|
||||
self._is_running = True
|
||||
logger.info("BLE GATT server started, advertising as '%s'", self.device_name)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to start BLE server: %s", e)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self._is_running = False
|
||||
return False
|
||||
|
||||
def _setup_notification_callbacks(self):
|
||||
"""Set up notification callbacks for characteristics that support notify."""
|
||||
notify_chars = [
|
||||
PM3CharacteristicUUIDs.COMMAND_RESULT,
|
||||
WiFiCharacteristicUUIDs.SCAN,
|
||||
SystemCharacteristicUUIDs.INFO,
|
||||
UpdateCharacteristicUUIDs.CHECK,
|
||||
UpdateCharacteristicUUIDs.PROGRESS,
|
||||
]
|
||||
|
||||
for uuid in notify_chars:
|
||||
self._register_notification_callback(uuid)
|
||||
|
||||
def _register_notification_callback(self, uuid: str):
|
||||
"""Register notification callback for a characteristic.
|
||||
|
||||
Args:
|
||||
uuid: Characteristic UUID
|
||||
"""
|
||||
async def send_notification(value: bytes):
|
||||
if self.server and self._is_running:
|
||||
try:
|
||||
char = self.server.get_characteristic(uuid)
|
||||
if char:
|
||||
char.value = bytearray(value)
|
||||
service_uuid = self._get_service_uuid_for_characteristic(uuid)
|
||||
# update_value is not async in bless 0.3+
|
||||
self.server.update_value(service_uuid, uuid)
|
||||
logger.debug("Notification sent for %s", uuid)
|
||||
except Exception as e:
|
||||
logger.error("Failed to send notification for %s: %s", uuid, e)
|
||||
|
||||
self.gatt_server.register_notification_callback(uuid, send_notification)
|
||||
|
||||
def _get_service_uuid_for_characteristic(self, char_uuid: str) -> str:
|
||||
"""Get the service UUID that contains a characteristic.
|
||||
|
||||
Args:
|
||||
char_uuid: Characteristic UUID
|
||||
|
||||
Returns:
|
||||
Service UUID
|
||||
"""
|
||||
# Check each service's characteristics
|
||||
if char_uuid in [
|
||||
PM3CharacteristicUUIDs.COMMAND_WRITE,
|
||||
PM3CharacteristicUUIDs.COMMAND_RESULT,
|
||||
PM3CharacteristicUUIDs.STATUS,
|
||||
PM3CharacteristicUUIDs.SESSION_CREATE,
|
||||
PM3CharacteristicUUIDs.SESSION_RELEASE,
|
||||
]:
|
||||
return PM3CharacteristicUUIDs.SERVICE
|
||||
elif char_uuid in [
|
||||
WiFiCharacteristicUUIDs.STATUS,
|
||||
WiFiCharacteristicUUIDs.SCAN,
|
||||
WiFiCharacteristicUUIDs.CONNECT,
|
||||
WiFiCharacteristicUUIDs.MODE,
|
||||
]:
|
||||
return WiFiCharacteristicUUIDs.SERVICE
|
||||
elif char_uuid in [
|
||||
SystemCharacteristicUUIDs.INFO,
|
||||
SystemCharacteristicUUIDs.SHUTDOWN,
|
||||
SystemCharacteristicUUIDs.RESTART,
|
||||
]:
|
||||
return SystemCharacteristicUUIDs.SERVICE
|
||||
elif char_uuid in [
|
||||
UpdateCharacteristicUUIDs.CHECK,
|
||||
UpdateCharacteristicUUIDs.PROGRESS,
|
||||
]:
|
||||
return UpdateCharacteristicUUIDs.SERVICE
|
||||
else:
|
||||
return PM3CharacteristicUUIDs.SERVICE # Default
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the BLE GATT server."""
|
||||
if not self._is_running:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.gatt_server.stop()
|
||||
|
||||
if self.server:
|
||||
await self.server.stop()
|
||||
self.server = None
|
||||
|
||||
self._is_running = False
|
||||
logger.info("BLE server stopped")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error stopping BLE server: %s", e)
|
||||
|
||||
async def send_notification(self, uuid: str, value: bytes) -> bool:
|
||||
"""Send a notification for a characteristic.
|
||||
|
||||
Args:
|
||||
uuid: Characteristic UUID
|
||||
value: Notification value
|
||||
|
||||
Returns:
|
||||
True if sent successfully
|
||||
"""
|
||||
if not self.server or not self._is_running:
|
||||
return False
|
||||
|
||||
try:
|
||||
char = self.server.get_characteristic(uuid)
|
||||
if char:
|
||||
char.value = bytearray(value)
|
||||
service_uuid = self._get_service_uuid_for_characteristic(uuid)
|
||||
# update_value is not async in bless 0.3+
|
||||
self.server.update_value(service_uuid, uuid)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Error sending notification for %s: %s", uuid, e)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance for application use
|
||||
_adapter_instance: Optional[BlueZGATTAdapter] = None
|
||||
|
||||
|
||||
def get_ble_adapter() -> BlueZGATTAdapter:
|
||||
"""Get or create the singleton BLE adapter instance.
|
||||
|
||||
Returns:
|
||||
BlueZGATTAdapter instance
|
||||
"""
|
||||
global _adapter_instance
|
||||
if _adapter_instance is None:
|
||||
_adapter_instance = BlueZGATTAdapter()
|
||||
return _adapter_instance
|
||||
|
||||
|
||||
async def start_ble_server(device_name: str = DEFAULT_DEVICE_NAME) -> bool:
|
||||
"""Start the BLE GATT server.
|
||||
|
||||
Args:
|
||||
device_name: BLE device name for advertising
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
adapter = get_ble_adapter()
|
||||
adapter.device_name = device_name
|
||||
return await adapter.start()
|
||||
|
||||
|
||||
async def stop_ble_server():
|
||||
"""Stop the BLE GATT server."""
|
||||
adapter = get_ble_adapter()
|
||||
await adapter.stop()
|
||||
Reference in New Issue
Block a user