Compare commits

...

45 Commits

Author SHA1 Message Date
23fb2eec62 Freeze requirements 2026-02-11 22:18:43 +00:00
78bd027cbf fix: Reconnect MQTT client automatically on MqttError
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-02-04 16:43:09 +00:00
26a75c1f12 Ignore aider 2026-02-04 16:41:17 +00:00
b5ee0b5df2 fix: Correct D-Bus signal subscription for properties changed
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-31 00:08:24 +00:00
b0b5905169 fix: Replace add_signal_receiver with bus.add_match
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-31 00:07:31 +00:00
a01a655d6c feat: Subscribe to PropertiesChanged for dynamic device naming
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-31 00:06:52 +00:00
58397e6b4c feat: Cache device names and listen for property updates
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-31 00:05:56 +00:00
61eb680695 feat: Log new devices with '(new)' suffix
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 23:58:59 +00:00
6ec536a964 style: Remove leading blank lines in scan.py 2025-12-30 23:58:57 +00:00
3b1e8bdaf3 feat: Add scan.py for continuous Bluetooth device scanning
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 23:56:29 +00:00
7163ff2d6f feat: Add scan script 2025-12-30 23:56:28 +00:00
ad31ed0f2e fix: Adjust volume via MediaTransport1 interface
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 23:26:36 +00:00
8bc8cf4089 feat: Add 'up X' and 'down X' commands to adjust media player volume
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 23:24:09 +00:00
240fe2f39d feat: Add MQTT commands for music playback control
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 23:20:51 +00:00
ac4145ac05 feat: Add MQTT command "kick" to disconnect connected device
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 23:16:45 +00:00
be180b073a fix: Reset Bluetooth pairing timer on new pair request
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 23:11:47 +00:00
cc63c6807d fix: Remove setting of non-writable adapter Class property
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 23:00:17 +00:00
4bd98c17cf feat: Set Bluetooth adapter class to display speaker icon
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 22:59:32 +00:00
706a9b4abd refactor: Remove automatic device unpairing monitor 2025-12-30 22:57:28 +00:00
d9f5c7382b fix: Refactor pairing agent to only trust devices
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 22:38:43 +00:00
aed90db5e8 refactor: Replace pre-connect disconnect with a sleep 2025-12-30 22:38:40 +00:00
99dc67e807 feat: Configure Bluetooth agent for DisplayYesNo and optimize connection 2025-12-30 22:25:12 +00:00
2a02dc0f5a fix: Suppress HFP profile rejection DBusError traceback
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 22:15:48 +00:00
6c6c70c254 chore: Configure Bluetooth agent capability 2025-12-30 22:15:45 +00:00
8e624950aa fix: Correct D-Bus signal handling and agent capability
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 22:12:17 +00:00
19deff17a7 feat: Automatically remove devices unpaired by remote host
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 22:10:30 +00:00
9c9adea41c chore: Configure agent capability to NoInputNoOutput 2025-12-30 22:10:29 +00:00
d0bd68bc6b Fix: Make AuthorizeService async to handle D-Bus rejections; correct CAPABILITY
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 22:00:46 +00:00
f8c09124a4 feat: Set agent capability to NoInputNoOutput and define service UUIDs 2025-12-30 22:00:43 +00:00
2e68948f09 fix: Attempt disconnect before connecting Bluetooth devices
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 21:44:40 +00:00
c3c2fc794c feat: Reject HFP connections to disable phone calls
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 21:42:02 +00:00
4b03d92674 feat: Allow setting Bluetooth adapter alias to "Home Audio"
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 21:40:34 +00:00
1c219aa564 fix: Update agent capability to DisplayYesNo
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 21:35:51 +00:00
d67ad2ef2a fix: Store D-Bus agent instance globally to prevent GC
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 19:59:28 +00:00
19cf54b2c2 fix: Adjust agent path and add agent initialization log
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 19:57:27 +00:00
4fdc6042dc fix: Make D-Bus agent methods async to resolve pairing issue
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 19:55:10 +00:00
1d7a084a3a fix: Correct D-Bus ObjectManager path for BlueZ
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 19:50:29 +00:00
ef658a76a1 fix: Connect to D-Bus system bus explicitly
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 19:48:52 +00:00
18997d5295 fix: Refactor D-Bus connection to use a single persistent instance
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 19:47:41 +00:00
a2702734ff fix: Use D-Bus signature strings for Agent method annotations
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 19:44:59 +00:00
89e6ee718f fix: Call dbus-next @method decorator as function
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 19:44:05 +00:00
865f010f75 fix: Update @method decorators and type hints for dbus-next compatibility
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 19:43:11 +00:00
de774ccf56 fix: Remove incorrect dbus_service import and decorator
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 19:42:12 +00:00
fb08301687 feat: Implement Bluetooth speaker mode with auto-pairing
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-30 19:40:12 +00:00
56134c2d70 fix: Narrow MQTT subscription topic 2025-12-30 19:40:09 +00:00
4 changed files with 488 additions and 14 deletions

1
.gitignore vendored
View File

@@ -150,3 +150,4 @@ out.*
*.csv
*.txt
*.json
.aider*

333
main.py
View File

@@ -10,15 +10,293 @@ import json
import asyncio
from aiomqtt import Client
from aiomqtt.exceptions import MqttError
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
pairing_task = None
# --- Bluetooth constants and agent ---
BLUEZ_SERVICE = 'org.bluez'
ADAPTER_IFACE = 'org.bluez.Adapter1'
DEVICE_IFACE = 'org.bluez.Device1'
MEDIA_PLAYER_IFACE = 'org.bluez.MediaPlayer1'
MEDIA_TRANSPORT_IFACE = 'org.bluez.MediaTransport1'
AGENT_IFACE = 'org.bluez.Agent1'
AGENT_MANAGER_IFACE = 'org.bluez.AgentManager1'
AGENT_PATH = '/io/bluetooth_speaker/agent'
CAPABILITY = 'DisplayYesNo'
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_device(device))
@method()
async def RequestAuthorization(self, device: 'o'):
logging.info(f"RequestAuthorization for {device}")
# Automatically authorize and trust
asyncio.create_task(trust_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_device(device_path):
logging.info(f'Trusting device {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}')
except Exception as e:
logging.error(f'Failed to trust device {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}")
# --- 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(1)
await asyncio.sleep(3600)
async def enable_pairing():
"""Enable pairing for 120 seconds. This task can be cancelled and restarted."""
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)
logging.info('Pairing timeout reached. Making adapter non-discoverable.')
await adapter_props.call_set(ADAPTER_IFACE, 'Discoverable', Variant('b', False))
except asyncio.CancelledError:
logging.info('Pairing timer cancelled, likely by a new pair request.')
raise
except Exception as e:
logging.error(f"Failed to manage pairing state: {e}")
async def disconnect_connected_device():
logging.info("Attempting to disconnect any connected device.")
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 DEVICE_IFACE in ifaces:
device_props = ifaces[DEVICE_IFACE]
if device_props.get('Connected', Variant('b', False)).value:
logging.info(f"Found connected device: {path}. Disconnecting...")
try:
device_introspection = await bus.introspect(BLUEZ_SERVICE, path)
device_obj = bus.get_proxy_object(BLUEZ_SERVICE, path, device_introspection)
device_iface = device_obj.get_interface(DEVICE_IFACE)
await device_iface.call_disconnect()
logging.info(f"Successfully disconnected {path}")
return # Assume only one device is connected
except Exception as e:
logging.error(f"Failed to disconnect {path}: {e}")
logging.info("No connected device found to disconnect.")
async def send_media_command(command):
"""Finds a media player and sends a command to it."""
logging.info(f"Attempting to send media command: {command}")
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 MEDIA_PLAYER_IFACE in ifaces:
logging.info(f"Found media player: {path}. Sending command '{command}'...")
try:
player_introspection = await bus.introspect(BLUEZ_SERVICE, path)
player_obj = bus.get_proxy_object(BLUEZ_SERVICE, path, player_introspection)
player_iface = player_obj.get_interface(MEDIA_PLAYER_IFACE)
method_name = f'call_{command}'
dbus_method = getattr(player_iface, method_name)
await dbus_method()
logging.info(f"Successfully sent command '{command}' to {path}")
return # Assume only one media player is active
except Exception as e:
logging.error(f"Failed to send media command to {path}: {e}")
logging.warning("No active media player found to send command to.")
async def adjust_volume(direction, amount):
"""Adjusts the volume of the media transport."""
logging.info(f"Attempting to adjust volume {direction} by {amount}")
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 MEDIA_TRANSPORT_IFACE in ifaces:
logging.info(f"Found media transport: {path}. Adjusting volume...")
try:
transport_introspection = await bus.introspect(BLUEZ_SERVICE, path)
transport_obj = bus.get_proxy_object(BLUEZ_SERVICE, path, transport_introspection)
transport_props = transport_obj.get_interface('org.freedesktop.DBus.Properties')
current_volume_variant = await transport_props.call_get(MEDIA_TRANSPORT_IFACE, 'Volume')
current_volume = current_volume_variant.value
logging.info(f"Current volume is {current_volume}")
if direction == 'up':
new_volume = current_volume + amount
else: # direction == 'down'
new_volume = current_volume - amount
# The Volume on MediaTransport1 is a uint16, but AVRCP uses 0-127.
# We'll clamp to this range and hope BlueZ handles scaling.
new_volume = max(0, min(127, new_volume))
logging.info(f"Setting new volume to {new_volume}")
await transport_props.call_set(MEDIA_TRANSPORT_IFACE, 'Volume', Variant('q', new_volume))
logging.info(f"Successfully adjusted volume on {path}")
return
except Exception as e:
logging.error(f"Failed to adjust volume on {path}: {e}")
logging.warning("No active media transport found to adjust volume.")
async def process_bluetooth_command(topic, text):
global pairing_task
logging.info('Bluetooth command: %s', text)
pass
if text == "pair":
if pairing_task and not pairing_task.done():
logging.info('A pairing process is already active. Cancelling it to restart the timer.')
pairing_task.cancel()
pairing_task = asyncio.create_task(enable_pairing())
elif text == "kick":
await disconnect_connected_device()
elif text in ["play", "pause", "next", "prev"]:
command = "previous" if text == "prev" else text
await send_media_command(command)
elif text.startswith("up ") or text.startswith("down "):
parts = text.split()
if len(parts) == 2 and parts[1].isdigit():
direction = parts[0]
amount = int(parts[1])
await adjust_volume(direction, amount)
else:
logging.warning(f"Invalid volume command format: {text}")
async def process_mqtt(message):
text = message.payload.decode()
@@ -34,21 +312,48 @@ async def process_mqtt(message):
async def fetch_mqtt():
await asyncio.sleep(3)
async with Client(
hostname='10.55.0.106',
port=1883,
) as client:
await client.subscribe('#')
async for message in client.messages:
loop = asyncio.get_event_loop()
loop.create_task(process_mqtt(message))
while True:
try:
async with Client(
hostname='10.55.0.106',
port=1883,
) as client:
logging.info("MQTT client connected")
await client.subscribe('iot/12ser/#')
async for message in client.messages:
loop = asyncio.get_event_loop()
loop.create_task(process_mqtt(message))
except MqttError as e:
logging.warning(f"MQTT connection error: {e}. Reconnecting in 5 seconds...")
await asyncio.sleep(5)
if __name__ == '__main__':
def suppress_hfp_rejection_error(loop, context):
exception = context.get('exception')
if isinstance(exception, DBusError) and 'HFP profile not supported' in str(exception):
# This is the expected error from AuthorizeService, so we can suppress the traceback.
logging.info('Suppressed expected DBusError for HFP rejection exception.')
return
# For all other exceptions, fall back to the default handler.
loop.default_exception_handler(context)
async def main():
loop = asyncio.get_running_loop()
loop.set_exception_handler(suppress_hfp_rejection_error)
global bus
bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
logging.info('')
logging.info('==========================')
logging.info('Booting up...')
loop = asyncio.get_event_loop()
a = loop.create_task(manage_bluetooth())
loop.run_until_complete(fetch_mqtt())
manage_task = asyncio.create_task(manage_bluetooth())
mqtt_task = asyncio.create_task(fetch_mqtt())
await asyncio.gather(manage_task, mqtt_task)
if __name__ == '__main__':
asyncio.run(main())

31
requirements.txt Normal file
View File

@@ -0,0 +1,31 @@
aiomqtt==2.4.0
certifi==2022.9.24
chardet==5.1.0
charset-normalizer==3.0.1
dbus-next==0.2.3
dbus-python==1.3.2
distlib==0.3.6
distro==1.8.0
distro-info==1.5
filelock==3.9.0
gyp==0.1
httplib2==0.20.4
idna==3.3
paho-mqtt==2.1.0
platformdirs==2.6.0
pycurl==7.45.2
pydbus==0.6.0
PyGObject==3.42.2
pyparsing==3.0.9
PySimpleSOAP==1.16.2
python-apt==2.6.0
python-debian==0.1.49
python-debianbts==4.0.1
reportbug==12.0.0
requests==2.28.1
six==1.16.0
supervisor==4.2.5
ufw==0.36.2
unattended-upgrades==0.1
urllib3==1.26.12
virtualenv==20.17.1+ds

137
scan.py Normal file
View File

@@ -0,0 +1,137 @@
import os, sys
import logging
DEBUG = os.environ.get('DEBUG')
logging.basicConfig(stream=sys.stdout,
format='[%(asctime)s] %(levelname)s - %(message)s',
level=logging.DEBUG if DEBUG else logging.INFO)
import asyncio
from dbus_next.aio import MessageBus
from dbus_next.constants import BusType, MessageType
from dbus_next import Message
BLUEZ_SERVICE = 'org.bluez'
ADAPTER_IFACE = 'org.bluez.Adapter1'
DEVICE_IFACE = 'org.bluez.Device1'
bus = None
seen_devices = set()
name_cache = {}
async def get_adapter(bus):
"""Gets the first Bluetooth adapter found."""
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
def on_interfaces_added(path, interfaces):
"""Callback for when a new D-Bus interface is added."""
if DEVICE_IFACE in interfaces:
device_properties = interfaces[DEVICE_IFACE]
address_variant = device_properties.get('Address')
if not address_variant:
return
addr_str = address_variant.value
alias_variant = device_properties.get('Alias')
alias = alias_variant.value if alias_variant else name_cache.get(addr_str)
if alias:
# Update cache if we found a new alias
if alias_variant and alias_variant.value:
name_cache[addr_str] = alias_variant.value
if addr_str not in seen_devices:
seen_devices.add(addr_str)
logging.info(f"Found: {alias} ({addr_str}) (new)")
else:
# Log repeat discoveries only if they have a name
logging.info(f"Found: {alias} ({addr_str})")
def properties_changed_handler(message: Message):
"""Sync handler to dispatch async task for property changes."""
if message.message_type == MessageType.SIGNAL and \
message.member == 'PropertiesChanged' and \
message.interface == 'org.freedesktop.DBus.Properties':
# Further filtering is done in the async handler
asyncio.create_task(on_properties_changed(message))
async def on_properties_changed(message: Message):
"""Callback for when a device's properties change (e.g., Alias appears)."""
if not message.body or message.body[0] != DEVICE_IFACE:
return
changed_properties = message.body[1]
alias_variant = changed_properties.get('Alias')
if not alias_variant or not alias_variant.value:
return
alias = alias_variant.value
device_path = message.path
try:
introspection = await bus.introspect(BLUEZ_SERVICE, device_path)
device_obj = bus.get_proxy_object(BLUEZ_SERVICE, device_path, introspection)
device_props_iface = device_obj.get_interface('org.freedesktop.DBus.Properties')
address_variant = await device_props_iface.call_get(DEVICE_IFACE, 'Address')
addr_str = address_variant.value
# Update cache
name_cache[addr_str] = alias
if addr_str not in seen_devices:
seen_devices.add(addr_str)
logging.info(f"Found: {alias} ({addr_str}) (new)")
except Exception as e:
logging.debug(f"Could not process property change for {device_path}: {e}")
async def main():
"""Main function to run the scanner."""
global bus
bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
adapter = await get_adapter(bus)
if not adapter:
logging.error("Bluetooth adapter not found.")
return
adapter_iface = adapter.get_interface(ADAPTER_IFACE)
# Subscribe to InterfacesAdded signal to discover new devices
introspection = await bus.introspect(BLUEZ_SERVICE, '/')
obj_manager = bus.get_proxy_object(BLUEZ_SERVICE, '/', introspection)
obj_manager_iface = obj_manager.get_interface('org.freedesktop.DBus.ObjectManager')
obj_manager_iface.on_interfaces_added(on_interfaces_added)
# Subscribe to PropertiesChanged signal to catch late-arriving device names
rule = "type='signal',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path_namespace='/org/bluez'"
introspection = await bus.introspect('org.freedesktop.DBus', '/org/freedesktop/DBus')
proxy_obj = bus.get_proxy_object('org.freedesktop.DBus', '/org/freedesktop/DBus', introspection)
dbus_interface = proxy_obj.get_interface('org.freedesktop.DBus')
await dbus_interface.call_add_match(rule)
bus.add_message_handler(properties_changed_handler)
logging.info("Starting Bluetooth scan... Press Ctrl+C to stop.")
try:
await adapter_iface.call_start_discovery()
# Keep the script running to listen for signals
while True:
await asyncio.sleep(3600)
except Exception as e:
logging.error(f"An error occurred during scanning: {e}")
finally:
logging.info("Stopping Bluetooth scan.")
await adapter_iface.call_stop_discovery()
if __name__ == '__main__':
try:
asyncio.run(main())
except KeyboardInterrupt:
logging.info("Scan stopped by user.")