Files
pi-pm3/app/backend/ble/bluez_adapter.py
michael 2ec89041ef Phase 4 enhancements: WebSocket auth, HTTPS UI, plugin hooks, build fixes
Security:
- Add token-based WebSocket authentication (closes critical security gap)
  - In-memory token store with 24h TTL (token_store.py)
  - POST /api/auth/token exchanges Basic Auth for WS token
  - GET /api/auth/status public endpoint for auth check
  - WebSocket validates token query param, rejects with close code 4401
  - Frontend LoginPrompt modal for credential entry
  - WebSocket manager handles full auth flow with auth_required state
  - No-op when AUTH_ENABLED=false (preserves existing behavior)

HTTPS:
- Wire HTTPS toggle in Settings UI (POST /api/system/ssl/toggle)
- Add certificate regeneration button
- Display SSL info (expiration, SANs, SHA256 fingerprint)

Plugins:
- Wire trigger_hook("pm3_command") in PM3 service
- Wire trigger_hook("update_check") in update manager

Build/Infrastructure:
- Enable NetworkManager in pi-gen AP setup stage
- Add HF booster board detection patch for Proxmark3
- Update LED PWM control patch
- Fix BLE adapter, UPS drivers, WiFi manager improvements
- Update HTTPS support stage script

Documentation:
- Update PROJECT_STATUS.md and IMPLEMENTATION_PRIORITIES.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:45:11 -08:00

480 lines
17 KiB
Python

"""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, retries: int = 3, retry_delay: float = 2.0) -> bool:
"""Start the BLE GATT server.
Args:
retries: Number of startup attempts (for boot timing issues)
retry_delay: Delay between retries in seconds
Returns:
True if started successfully, False otherwise
"""
if self._is_running:
logger.warning("BLE server already running")
return True
last_error = None
for attempt in range(retries):
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:
last_error = e
if attempt < retries - 1:
logger.warning(
"BLE server start attempt %d/%d failed: %s, retrying in %.1fs...",
attempt + 1, retries, e, retry_delay
)
# Clean up failed server before retry
if self.server:
try:
await self.server.stop()
except Exception:
pass
self.server = None
await asyncio.sleep(retry_delay)
else:
logger.error("Failed to start BLE server after %d attempts: %s", retries, 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()