Files
Bjorn/web_utils/bluetooth_utils.py
infinition aac77a3e76 Add Loki and Sentinel utility classes for web API endpoints
- Implemented LokiUtils class with GET and POST endpoints for managing scripts, jobs, and payloads.
- Added SentinelUtils class with GET and POST endpoints for managing events, rules, devices, and notifications.
- Both classes include error handling and JSON response formatting.
2026-03-14 22:33:10 +01:00

368 lines
16 KiB
Python

# web_utils/bluetooth_utils.py
"""
Bluetooth device management utilities.
Handles Bluetooth scanning, pairing, connection, and device management.
"""
from __future__ import annotations
import json
import subprocess
import time
import os
import threading
import dbus
import dbus.mainloop.glib
import dbus.exceptions
from typing import Any, Dict, Optional
import logging
from logger import Logger
logger = Logger(name="bluetooth_utils.py", level=logging.DEBUG)
# Constants
BT_SCAN_DURATION_S = 3
BT_PAIR_TIMEOUT_S = 60
BT_CONNECT_SETTLE_S = 2
BT_CONFIG_PATH = "/home/bjorn/.settings_bjorn/bt.json"
BT_DISCOVERABLE_TIMEOUT = 180
class BluetoothUtils:
"""Utilities for Bluetooth device management."""
def __init__(self, shared_data):
self.logger = logger
self.shared_data = shared_data
self.bluetooth_initialized = False
self.bus = None
self.manager_interface = None
self.adapter_path = None
self.adapter = None
self.adapter_props = None
self.adapter_methods = None
self._config_lock = threading.Lock()
def _ensure_bluetooth_service(self):
"""Check if bluetooth service is running, if not start and enable it."""
try:
res = subprocess.run(
["systemctl", "is-active", "bluetooth"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
if "active" not in res.stdout:
self.logger.info("Bluetooth service not active. Starting and enabling it...")
start_res = subprocess.run(
["sudo", "systemctl", "start", "bluetooth"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
if start_res.returncode != 0:
self.logger.error(f"Failed to start bluetooth service: {start_res.stderr}")
raise Exception("Failed to start bluetooth service.")
enable_res = subprocess.run(
["sudo", "systemctl", "enable", "bluetooth"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
if enable_res.returncode != 0:
self.logger.error(f"Failed to enable bluetooth service: {enable_res.stderr}")
else:
self.logger.info("Bluetooth service enabled successfully.")
else:
self.logger.info("Bluetooth service is already active.")
except Exception as e:
self.logger.error(f"Error ensuring bluetooth service: {e}")
raise
def _init_bluetooth(self):
"""Initialize Bluetooth DBus connection."""
if self.bluetooth_initialized:
return
try:
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
self._ensure_bluetooth_service()
self.bus = dbus.SystemBus()
manager = self.bus.get_object("org.bluez", "/")
self.manager_interface = dbus.Interface(manager, "org.freedesktop.DBus.ObjectManager")
objects = self.manager_interface.GetManagedObjects()
self.adapter_path = None
for path, ifaces in objects.items():
if "org.bluez.Adapter1" in ifaces:
self.adapter_path = path
break
if not self.adapter_path:
self.logger.error("No Bluetooth adapter found.")
raise Exception("No Bluetooth adapter found.")
self.adapter = self.bus.get_object("org.bluez", self.adapter_path)
self.adapter_props = dbus.Interface(self.adapter, "org.freedesktop.DBus.Properties")
self.adapter_methods = dbus.Interface(self.adapter, "org.bluez.Adapter1")
self.bluetooth_initialized = True
except Exception as e:
self.logger.error(f"Failed to initialize Bluetooth: {e}")
raise
def _get_device_object(self, address):
"""Get DBus device object by MAC address."""
self._init_bluetooth()
objects = self.manager_interface.GetManagedObjects()
for path, ifaces in objects.items():
if "org.bluez.Device1" in ifaces:
dev = ifaces["org.bluez.Device1"]
if dev.get("Address") == address:
return self.bus.get_object("org.bluez", path)
return None
def _save_bt_config(self, address):
"""Atomically save Bluetooth device MAC to config file (thread-safe)."""
with self._config_lock:
current_mac = None
if os.path.exists(BT_CONFIG_PATH):
try:
with open(BT_CONFIG_PATH, "r") as f:
data = json.load(f)
current_mac = data.get("device_mac")
except (json.JSONDecodeError, IOError) as e:
self.logger.warning(f"Could not read bt.json: {e}")
if current_mac != address:
self.logger.info(f"Updating bt.json with new MAC: {address}")
os.makedirs(os.path.dirname(BT_CONFIG_PATH), exist_ok=True)
tmp_path = BT_CONFIG_PATH + ".tmp"
with open(tmp_path, "w") as f:
json.dump({"device_mac": address}, f)
os.replace(tmp_path, BT_CONFIG_PATH)
self.logger.info("Updated bt.json with new device MAC.")
def _restart_auto_bt_connect(self):
"""Restart auto_bt_connect service (non-fatal if service doesn't exist)."""
result = subprocess.run(
["sudo", "systemctl", "restart", "auto_bt_connect"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
if result.returncode != 0:
self.logger.warning(f"auto_bt_connect service restart failed (may not exist): {result.stderr.strip()}")
else:
self.logger.info("auto_bt_connect service restarted successfully")
def scan_bluetooth(self, handler):
"""Scan for Bluetooth devices."""
try:
self._init_bluetooth()
self.adapter_props.Set("org.bluez.Adapter1", "Powered", dbus.Boolean(True))
self.adapter_props.Set("org.bluez.Adapter1", "Discoverable", dbus.Boolean(True))
self.adapter_props.Set("org.bluez.Adapter1", "DiscoverableTimeout", dbus.UInt32(BT_DISCOVERABLE_TIMEOUT))
self.adapter_methods.StartDiscovery()
time.sleep(BT_SCAN_DURATION_S)
objects = self.manager_interface.GetManagedObjects()
devices = []
for path, ifaces in objects.items():
if "org.bluez.Device1" in ifaces:
dev = ifaces["org.bluez.Device1"]
devices.append({
"name": str(dev.get("Name", "Unknown")),
"address": str(dev.get("Address", "")),
"paired": bool(dev.get("Paired", False)),
"trusted": bool(dev.get("Trusted", False)),
"connected": bool(dev.get("Connected", False))
})
try:
self.adapter_methods.StopDiscovery()
except dbus.exceptions.DBusException:
pass # Discovery may have already stopped
response = {"devices": devices}
handler.send_response(200)
handler.send_header("Content-Type", "application/json")
handler.end_headers()
handler.wfile.write(json.dumps(response).encode('utf-8'))
except dbus.exceptions.DBusException as e:
self.logger.error(f"DBus error scanning Bluetooth: {e}")
handler.send_response(500)
handler.send_header("Content-Type", "application/json")
handler.end_headers()
handler.wfile.write(json.dumps({"status": "error", "message": f"Bluetooth DBus error: {e}"}).encode('utf-8'))
except Exception as e:
self.logger.error(f"Error scanning Bluetooth: {e}")
handler.send_response(500)
handler.send_header("Content-Type", "application/json")
handler.end_headers()
handler.wfile.write(json.dumps({"status": "error", "message": str(e)}).encode('utf-8'))
def pair_bluetooth(self, address, pin=None):
"""Pair with a Bluetooth device."""
bt_process = None
try:
device = self._get_device_object(address)
if device is None:
self.logger.error(f"Device {address} not found")
return {"status": "error", "message": f"Device {address} not found"}
device_props = dbus.Interface(device, "org.freedesktop.DBus.Properties")
bt_process = subprocess.Popen(
['bluetoothctl'],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True
)
try:
self.logger.info(f"Attempting to pair with {address}")
bt_process.stdin.write(f"pair {address}\n")
bt_process.stdin.flush()
start_time = time.time()
while (time.time() - start_time) < BT_PAIR_TIMEOUT_S:
line = bt_process.stdout.readline()
if not line:
continue
self.logger.info(f"Bluetoothctl output: {line.strip()}")
if "Confirm passkey" in line or "Request confirmation" in line:
self.logger.info("Sending confirmation...")
bt_process.stdin.write("yes\n")
bt_process.stdin.flush()
try:
paired = device_props.Get("org.bluez.Device1", "Paired")
if paired:
self.logger.info("Device successfully paired!")
device_props.Set("org.bluez.Device1", "Trusted", dbus.Boolean(True))
time.sleep(BT_CONNECT_SETTLE_S)
self._save_bt_config(address)
self._restart_auto_bt_connect()
return {"status": "success", "message": "Device successfully paired and trusted"}
except dbus.exceptions.DBusException as e:
self.logger.debug(f"Pairing check DBus error (may be transient): {e}")
if "Failed" in line or "Error" in line:
self.logger.error(f"Bluetoothctl error: {line}")
return {"status": "error", "message": f"Pairing failed: {line.strip()}"}
return {"status": "error", "message": "Pairing timed out - please try again"}
except Exception as e:
self.logger.error(f"Error during pairing process: {str(e)}")
return {"status": "error", "message": f"Error during pairing: {str(e)}"}
except Exception as e:
self.logger.error(f"Error initiating pairing: {str(e)}")
return {"status": "error", "message": f"Error initiating pairing: {str(e)}"}
finally:
if bt_process is not None:
try:
bt_process.stdin.write("quit\n")
bt_process.stdin.flush()
bt_process.wait(timeout=3)
except Exception:
bt_process.kill()
def forget_bluetooth(self, address):
"""Remove/forget a Bluetooth device."""
try:
device = self._get_device_object(address)
if device is None:
return {"status": "error", "message": f"Device {address} not found"}
device_methods = dbus.Interface(device, "org.bluez.Device1")
adapter_methods = dbus.Interface(self.adapter, "org.bluez.Adapter1")
try:
device_methods.Disconnect()
except dbus.exceptions.DBusException as e:
self.logger.debug(f"Disconnect before forget (non-fatal): {e}")
adapter_methods.RemoveDevice(device)
self.logger.info(f"Successfully removed device {address}")
return {"status": "success", "message": "Device forgotten successfully"}
except dbus.exceptions.DBusException as e:
self.logger.error(f"DBus error forgetting device: {e}")
return {"status": "error", "message": f"Failed to forget device: {str(e)}"}
except Exception as e:
self.logger.error(f"Error in forget_bluetooth: {str(e)}")
return {"status": "error", "message": f"Error forgetting device: {str(e)}"}
def trust_bluetooth(self, address):
"""Trust a Bluetooth device."""
device = self._get_device_object(address)
if device is None:
return {"status": "error", "message": f"Device {address} not found"}
device_props = dbus.Interface(device, "org.freedesktop.DBus.Properties")
try:
device_props.Set("org.bluez.Device1", "Trusted", dbus.Boolean(True))
return {"status": "success", "message": f"Trusted {address}"}
except dbus.exceptions.DBusException as e:
return {"status": "error", "message": f"Failed to trust {address}: {e}"}
def connect_bluetooth(self, address):
"""Connect to a Bluetooth device and set up networking."""
device = self._get_device_object(address)
if device is None:
return {"status": "error", "message": f"Device {address} not found"}
device_methods = dbus.Interface(device, "org.bluez.Device1")
try:
device_methods.Connect()
self.logger.info(f"Device {address} connected. Setting up PAN and obtaining IP...")
bt_net_process = subprocess.Popen(
["sudo", "bt-network", "-c", address, "nap"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
time.sleep(BT_CONNECT_SETTLE_S)
if bt_net_process.poll() is not None:
if bt_net_process.returncode != 0:
stderr_output = bt_net_process.stderr.read() if bt_net_process.stderr else ""
self.logger.error(f"Failed to run bt-network: {stderr_output}")
return {"status": "error", "message": f"Connected to {address}, but failed to set up bt-network: {stderr_output}"}
else:
self.logger.warning("bt-network ended. PAN might not remain established.")
else:
self.logger.info("bt-network process started successfully and is running in background.")
dhclient_res = subprocess.run(
["sudo", "dhclient", "-4", "bnep0"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
if dhclient_res.returncode != 0:
self.logger.error(f"Failed to run dhclient: {dhclient_res.stderr}")
return {"status": "error", "message": f"Connected to {address}, bt-network ok, but dhclient failed: {dhclient_res.stderr}"}
self.logger.info("Successfully obtained IP via dhclient on bnep0.")
self._save_bt_config(address)
return {"status": "success", "message": f"Connected to {address} and network interface set up."}
except dbus.exceptions.DBusException as e:
return {"status": "error", "message": f"Failed to connect to {address}: {e}"}
def disconnect_bluetooth(self, address):
"""Disconnect from a Bluetooth device."""
device = self._get_device_object(address)
if device is None:
return {"status": "error", "message": f"Device {address} not found"}
device_methods = dbus.Interface(device, "org.bluez.Device1")
try:
device_methods.Disconnect()
return {"status": "success", "message": f"Disconnected from {address}"}
except dbus.exceptions.DBusException as e:
return {"status": "error", "message": f"Failed to disconnect from {address}: {e}"}