269 lines
10 KiB
Python
269 lines
10 KiB
Python
import os, sys
|
|
import logging
|
|
DEBUG = os.environ.get('DEBUG')
|
|
logging.basicConfig(stream=sys.stdout,
|
|
format='[%(asctime)s] %(levelname)s %(module)s/%(funcName)s - %(message)s',
|
|
level=logging.DEBUG if DEBUG else logging.INFO)
|
|
|
|
import time
|
|
import json
|
|
|
|
import asyncio
|
|
from aiomqtt import Client
|
|
from dbus_next.aio import MessageBus
|
|
from dbus_next.service import ServiceInterface, method
|
|
from dbus_next.constants import BusType
|
|
from dbus_next.errors import DBusError
|
|
from dbus_next import Variant, Message, MessageType
|
|
|
|
|
|
bus = None
|
|
agent_instance = None
|
|
|
|
# --- Bluetooth constants and agent ---
|
|
BLUEZ_SERVICE = 'org.bluez'
|
|
ADAPTER_IFACE = 'org.bluez.Adapter1'
|
|
DEVICE_IFACE = 'org.bluez.Device1'
|
|
AGENT_IFACE = 'org.bluez.Agent1'
|
|
AGENT_MANAGER_IFACE = 'org.bluez.AgentManager1'
|
|
AGENT_PATH = '/io/bluetooth_speaker/agent'
|
|
CAPABILITY = 'NoInputNoOutput'
|
|
CALLS_SERVICE_UUID = '0000111e-0000-1000-8000-00805f9b34fb'
|
|
AUDIO_SERVICE_UUID = '0000110d-0000-1000-8000-00805f9b34fb'
|
|
|
|
class Agent(ServiceInterface):
|
|
def __init__(self, interface_name):
|
|
super().__init__(interface_name)
|
|
logging.info('Agent instance created')
|
|
|
|
@method()
|
|
def Release(self):
|
|
logging.info('Agent Released')
|
|
|
|
@method()
|
|
def RequestPinCode(self, device: 'o') -> 's':
|
|
logging.info(f"RequestPinCode for {device}, returning static PIN")
|
|
return "0000"
|
|
|
|
@method()
|
|
def RequestPasskey(self, device: 'o') -> 'u':
|
|
logging.info(f"RequestPasskey for {device}")
|
|
return 0
|
|
|
|
@method()
|
|
def DisplayPasskey(self, device: 'o', passkey: 'u', entered: 'q'):
|
|
logging.info(f"DisplayPasskey for {device}: {passkey}")
|
|
|
|
@method()
|
|
def DisplayPinCode(self, device: 'o', pincode: 's'):
|
|
logging.info(f"DisplayPinCode for {device}: {pincode}")
|
|
|
|
@method()
|
|
async def RequestConfirmation(self, device: 'o', passkey: 'u'):
|
|
logging.info(f"RequestConfirmation for {device} with passkey {passkey}")
|
|
# Automatically confirm and trust
|
|
asyncio.create_task(trust_and_connect_device(device))
|
|
|
|
@method()
|
|
async def RequestAuthorization(self, device: 'o'):
|
|
logging.info(f"RequestAuthorization for {device}")
|
|
# Automatically authorize and trust
|
|
asyncio.create_task(trust_and_connect_device(device))
|
|
|
|
@method()
|
|
async def AuthorizeService(self, device: 'o', uuid: 's'):
|
|
logging.info(f"AuthorizeService request for device {device} with UUID {uuid}")
|
|
if uuid.lower() == CALLS_SERVICE_UUID:
|
|
logging.warning("Rejecting Hands-Free Profile (HFP) connection.")
|
|
raise DBusError('org.bluez.Error.Rejected', 'HFP profile not supported')
|
|
logging.info(f"Authorizing service UUID {uuid}")
|
|
|
|
@method()
|
|
def Cancel(self):
|
|
logging.info('Pairing Cancelled')
|
|
|
|
async def trust_and_connect_device(device_path):
|
|
logging.info(f'Trusting and connecting to {device_path}')
|
|
try:
|
|
introspection = await bus.introspect(BLUEZ_SERVICE, device_path)
|
|
device_obj = bus.get_proxy_object(BLUEZ_SERVICE, device_path, introspection)
|
|
|
|
device_props = device_obj.get_interface('org.freedesktop.DBus.Properties')
|
|
await device_props.call_set(DEVICE_IFACE, 'Trusted', Variant('b', True))
|
|
logging.info(f'Trusted device {device_path}')
|
|
|
|
device_iface = device_obj.get_interface(DEVICE_IFACE)
|
|
|
|
try:
|
|
logging.info(f"Attempting to disconnect {device_path} before connecting.")
|
|
await device_iface.call_disconnect()
|
|
await asyncio.sleep(1) # Give it a moment to settle
|
|
except DBusError as e:
|
|
# This is expected if the device is not already connected.
|
|
logging.info(f"Disconnect failed (this is likely okay): {e}")
|
|
|
|
await device_iface.call_connect()
|
|
logging.info(f'Connected to device {device_path}')
|
|
except Exception as e:
|
|
logging.error(f'Failed to trust/connect to {device_path}: {e}')
|
|
|
|
async def get_adapter():
|
|
introspection = await bus.introspect(BLUEZ_SERVICE, '/')
|
|
manager_obj = bus.get_proxy_object(BLUEZ_SERVICE, '/', introspection)
|
|
manager_iface = manager_obj.get_interface('org.freedesktop.DBus.ObjectManager')
|
|
managed_objects = await manager_iface.call_get_managed_objects()
|
|
|
|
for path, ifaces in managed_objects.items():
|
|
if ADAPTER_IFACE in ifaces:
|
|
adapter_introspection = await bus.introspect(BLUEZ_SERVICE, path)
|
|
return bus.get_proxy_object(BLUEZ_SERVICE, path, adapter_introspection)
|
|
return None
|
|
|
|
async def register_agent():
|
|
global agent_instance
|
|
agent_instance = Agent(AGENT_IFACE)
|
|
bus.export(AGENT_PATH, agent_instance)
|
|
|
|
introspection = await bus.introspect(BLUEZ_SERVICE, '/org/bluez')
|
|
manager_obj = bus.get_proxy_object(BLUEZ_SERVICE, '/org/bluez', introspection)
|
|
agent_manager = manager_obj.get_interface(AGENT_MANAGER_IFACE)
|
|
|
|
try:
|
|
await agent_manager.call_register_agent(AGENT_PATH, CAPABILITY)
|
|
logging.info(f"Agent registered at {AGENT_PATH} with capability {CAPABILITY}")
|
|
await agent_manager.call_request_default_agent(AGENT_PATH)
|
|
logging.info("Agent set as default")
|
|
except Exception as e:
|
|
logging.error(f'Failed to register agent: {e}')
|
|
logging.info('Trying to unregister and register again')
|
|
try:
|
|
await agent_manager.call_unregister_agent(AGENT_PATH)
|
|
await agent_manager.call_register_agent(AGENT_PATH, CAPABILITY)
|
|
await agent_manager.call_request_default_agent(AGENT_PATH)
|
|
logging.info("Agent registered after unregistering")
|
|
except Exception as e2:
|
|
logging.error(f'Failed to register agent again: {e2}')
|
|
|
|
async def set_adapter_alias(alias):
|
|
logging.info(f"Setting Bluetooth adapter alias to '{alias}'")
|
|
adapter_obj = await get_adapter()
|
|
if not adapter_obj:
|
|
logging.error('Bluetooth adapter not found, cannot set alias.')
|
|
return
|
|
|
|
adapter_props = adapter_obj.get_interface('org.freedesktop.DBus.Properties')
|
|
try:
|
|
await adapter_props.call_set(ADAPTER_IFACE, 'Alias', Variant('s', alias))
|
|
logging.info(f"Successfully set adapter alias to '{alias}'")
|
|
except Exception as e:
|
|
logging.error(f"Failed to set adapter alias: {e}")
|
|
|
|
async def monitor_unpairing():
|
|
logging.info("Starting to monitor for device unpairing events.")
|
|
adapter_obj = await get_adapter()
|
|
if not adapter_obj:
|
|
logging.error("Bluetooth adapter not found, cannot monitor unpairing.")
|
|
return
|
|
|
|
adapter_iface = adapter_obj.get_interface(ADAPTER_IFACE)
|
|
|
|
def _properties_changed_handler(msg):
|
|
if msg.message_type != MessageType.SIGNAL or msg.member != 'PropertiesChanged' or msg.interface != 'org.freedesktop.DBus.Properties':
|
|
return
|
|
|
|
interface_name, changed_properties, invalidated_properties = msg.body
|
|
|
|
if interface_name == DEVICE_IFACE and 'Paired' in changed_properties and not changed_properties['Paired'].value:
|
|
device_path = msg.path
|
|
logging.info(f"Device {device_path} was unpaired by the remote host. Removing it.")
|
|
asyncio.create_task(adapter_iface.call_remove_device(device_path))
|
|
|
|
bus.add_message_handler(_properties_changed_handler)
|
|
|
|
await bus.call(
|
|
Message(
|
|
destination='org.freedesktop.DBus',
|
|
path='/org/freedesktop/DBus',
|
|
interface='org.freedesktop.DBus',
|
|
member='AddMatch',
|
|
signature='s',
|
|
body=[f"type='signal',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path_namespace='/org/bluez'"]))
|
|
|
|
# Keep the task alive to listen for signals
|
|
while True:
|
|
await asyncio.sleep(3600)
|
|
|
|
# --- End Bluetooth ---
|
|
|
|
async def manage_bluetooth():
|
|
await register_agent()
|
|
await set_adapter_alias("Home Audio")
|
|
# The agent will handle things, this task can just sleep
|
|
while True:
|
|
await asyncio.sleep(3600)
|
|
|
|
|
|
async def process_bluetooth_command(topic, text):
|
|
logging.info('Bluetooth command: %s', text)
|
|
if text == "pair":
|
|
logging.info('Starting pairing process by making adapter discoverable')
|
|
adapter_obj = await get_adapter()
|
|
if not adapter_obj:
|
|
logging.error('Bluetooth adapter not found')
|
|
return
|
|
|
|
adapter_props = adapter_obj.get_interface('org.freedesktop.DBus.Properties')
|
|
|
|
try:
|
|
await adapter_props.call_set(ADAPTER_IFACE, 'Discoverable', Variant('b', True))
|
|
await adapter_props.call_set(ADAPTER_IFACE, 'Pairable', Variant('b', True))
|
|
logging.info('Adapter is discoverable and pairable for 120 seconds')
|
|
|
|
await asyncio.sleep(120)
|
|
|
|
await adapter_props.call_set(ADAPTER_IFACE, 'Discoverable', Variant('b', False))
|
|
logging.info('Adapter is no longer discoverable')
|
|
except Exception as e:
|
|
logging.error(f"Failed to set adapter properties: {e}")
|
|
|
|
async def process_mqtt(message):
|
|
text = message.payload.decode()
|
|
topic = message.topic.value
|
|
logging.debug('MQTT topic: %s, message: %s', topic, text)
|
|
|
|
if topic.startswith('iot/12ser/bluetooth'):
|
|
await process_bluetooth_command(topic, text)
|
|
else:
|
|
logging.debug('Invalid topic, returning')
|
|
return
|
|
|
|
async def fetch_mqtt():
|
|
await asyncio.sleep(3)
|
|
|
|
async with Client(
|
|
hostname='10.55.0.106',
|
|
port=1883,
|
|
) as client:
|
|
await client.subscribe('iot/12ser/#')
|
|
async for message in client.messages:
|
|
loop = asyncio.get_event_loop()
|
|
loop.create_task(process_mqtt(message))
|
|
|
|
|
|
async def main():
|
|
global bus
|
|
bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
|
|
|
|
logging.info('')
|
|
logging.info('==========================')
|
|
logging.info('Booting up...')
|
|
|
|
manage_task = asyncio.create_task(manage_bluetooth())
|
|
mqtt_task = asyncio.create_task(fetch_mqtt())
|
|
unpair_task = asyncio.create_task(monitor_unpairing())
|
|
await asyncio.gather(manage_task, mqtt_task, unpair_task)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
asyncio.run(main())
|