1061 lines
38 KiB
Python
1061 lines
38 KiB
Python
|
"""
|
||
|
BlueZ D-Bus manager module
|
||
|
--------------------------
|
||
|
|
||
|
This module contains code for the global BlueZ D-Bus object manager that is
|
||
|
used internally by Bleak.
|
||
|
"""
|
||
|
|
||
|
import asyncio
|
||
|
import contextlib
|
||
|
import logging
|
||
|
import os
|
||
|
from collections import defaultdict
|
||
|
from typing import (
|
||
|
Any,
|
||
|
Callable,
|
||
|
Coroutine,
|
||
|
Dict,
|
||
|
List,
|
||
|
MutableMapping,
|
||
|
NamedTuple,
|
||
|
Optional,
|
||
|
Set,
|
||
|
cast,
|
||
|
)
|
||
|
from weakref import WeakKeyDictionary
|
||
|
|
||
|
from dbus_fast import BusType, Message, MessageType, Variant, unpack_variants
|
||
|
from dbus_fast.aio.message_bus import MessageBus
|
||
|
|
||
|
from ...exc import BleakDBusError, BleakError
|
||
|
from ..service import BleakGATTServiceCollection
|
||
|
from . import defs
|
||
|
from .advertisement_monitor import AdvertisementMonitor, OrPatternLike
|
||
|
from .characteristic import BleakGATTCharacteristicBlueZDBus
|
||
|
from .defs import Device1, GattCharacteristic1, GattDescriptor1, GattService1
|
||
|
from .descriptor import BleakGATTDescriptorBlueZDBus
|
||
|
from .service import BleakGATTServiceBlueZDBus
|
||
|
from .signals import MatchRules, add_match
|
||
|
from .utils import (
|
||
|
assert_reply,
|
||
|
device_path_from_characteristic_path,
|
||
|
get_dbus_authenticator,
|
||
|
)
|
||
|
|
||
|
logger = logging.getLogger(__name__)
|
||
|
|
||
|
AdvertisementCallback = Callable[[str, Device1], None]
|
||
|
"""
|
||
|
A callback that is called when advertisement data is received.
|
||
|
|
||
|
Args:
|
||
|
arg0: The D-Bus object path of the device.
|
||
|
arg1: The D-Bus properties of the device object.
|
||
|
"""
|
||
|
|
||
|
|
||
|
DevicePropertiesChangedCallback = Callable[[Optional[Any]], None]
|
||
|
"""
|
||
|
A callback that is called when the properties of a device change in BlueZ.
|
||
|
|
||
|
Args:
|
||
|
arg0: The new property value.
|
||
|
"""
|
||
|
|
||
|
|
||
|
class DeviceConditionCallback(NamedTuple):
|
||
|
"""
|
||
|
Encapsulates a :data:`DevicePropertiesChangedCallback` and the property name being watched.
|
||
|
"""
|
||
|
|
||
|
callback: DevicePropertiesChangedCallback
|
||
|
"""
|
||
|
The callback.
|
||
|
"""
|
||
|
|
||
|
property_name: str
|
||
|
"""
|
||
|
The name of the property to watch.
|
||
|
"""
|
||
|
|
||
|
|
||
|
DeviceRemovedCallback = Callable[[str], None]
|
||
|
"""
|
||
|
A callback that is called when a device is removed from BlueZ.
|
||
|
|
||
|
Args:
|
||
|
arg0: The D-Bus object path of the device.
|
||
|
"""
|
||
|
|
||
|
|
||
|
class DeviceRemovedCallbackAndState(NamedTuple):
|
||
|
"""
|
||
|
Encapsulates an :data:`DeviceRemovedCallback` and some state.
|
||
|
"""
|
||
|
|
||
|
callback: DeviceRemovedCallback
|
||
|
"""
|
||
|
The callback.
|
||
|
"""
|
||
|
|
||
|
adapter_path: str
|
||
|
"""
|
||
|
The D-Bus object path of the adapter associated with the callback.
|
||
|
"""
|
||
|
|
||
|
|
||
|
DeviceConnectedChangedCallback = Callable[[bool], None]
|
||
|
"""
|
||
|
A callback that is called when a device's "Connected" property changes.
|
||
|
|
||
|
Args:
|
||
|
arg0: The current value of the "Connected" property.
|
||
|
"""
|
||
|
|
||
|
CharacteristicValueChangedCallback = Callable[[str, bytes], None]
|
||
|
"""
|
||
|
A callback that is called when a characteristics's "Value" property changes.
|
||
|
|
||
|
Args:
|
||
|
arg0: The D-Bus object path of the characteristic.
|
||
|
arg1: The current value of the "Value" property.
|
||
|
"""
|
||
|
|
||
|
|
||
|
class DeviceWatcher(NamedTuple):
|
||
|
device_path: str
|
||
|
"""
|
||
|
The D-Bus object path of the device.
|
||
|
"""
|
||
|
|
||
|
on_connected_changed: DeviceConnectedChangedCallback
|
||
|
"""
|
||
|
A callback that is called when a device's "Connected" property changes.
|
||
|
"""
|
||
|
|
||
|
on_characteristic_value_changed: CharacteristicValueChangedCallback
|
||
|
"""
|
||
|
A callback that is called when a characteristics's "Value" property changes.
|
||
|
"""
|
||
|
|
||
|
|
||
|
# set of org.bluez.Device1 property names that come from advertising data
|
||
|
_ADVERTISING_DATA_PROPERTIES = {
|
||
|
"AdvertisingData",
|
||
|
"AdvertisingFlags",
|
||
|
"ManufacturerData",
|
||
|
"Name",
|
||
|
"ServiceData",
|
||
|
"UUIDs",
|
||
|
}
|
||
|
|
||
|
|
||
|
class BlueZManager:
|
||
|
"""
|
||
|
BlueZ D-Bus object manager.
|
||
|
|
||
|
Use :func:`bleak.backends.bluezdbus.get_global_bluez_manager` to get the global instance.
|
||
|
"""
|
||
|
|
||
|
def __init__(self):
|
||
|
self._bus: Optional[MessageBus] = None
|
||
|
self._bus_lock = asyncio.Lock()
|
||
|
|
||
|
# dict of object path: dict of interface name: dict of property name: property value
|
||
|
self._properties: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
||
|
|
||
|
# set of available adapters for quick lookup
|
||
|
self._adapters: Set[str] = set()
|
||
|
|
||
|
# The BlueZ APIs only maps children to parents, so we need to keep maps
|
||
|
# to quickly find the children of a parent D-Bus object.
|
||
|
|
||
|
# map of device d-bus object paths to set of service d-bus object paths
|
||
|
self._service_map: Dict[str, Set[str]] = {}
|
||
|
# map of service d-bus object paths to set of characteristic d-bus object paths
|
||
|
self._characteristic_map: Dict[str, Set[str]] = {}
|
||
|
# map of characteristic d-bus object paths to set of descriptor d-bus object paths
|
||
|
self._descriptor_map: Dict[str, Set[str]] = {}
|
||
|
|
||
|
self._advertisement_callbacks: defaultdict[str, List[AdvertisementCallback]] = (
|
||
|
defaultdict(list)
|
||
|
)
|
||
|
self._device_removed_callbacks: List[DeviceRemovedCallbackAndState] = []
|
||
|
self._device_watchers: Dict[str, Set[DeviceWatcher]] = {}
|
||
|
self._condition_callbacks: Dict[str, Set[DeviceConditionCallback]] = {}
|
||
|
self._services_cache: Dict[str, BleakGATTServiceCollection] = {}
|
||
|
|
||
|
def _check_adapter(self, adapter_path: str) -> None:
|
||
|
"""
|
||
|
Raises:
|
||
|
BleakError: if adapter is not present in BlueZ
|
||
|
"""
|
||
|
if adapter_path not in self._properties:
|
||
|
raise BleakError(f"adapter '{adapter_path.split('/')[-1]}' not found")
|
||
|
|
||
|
def _check_device(self, device_path: str) -> None:
|
||
|
"""
|
||
|
Raises:
|
||
|
BleakError: if device is not present in BlueZ
|
||
|
"""
|
||
|
if device_path not in self._properties:
|
||
|
raise BleakError(f"device '{device_path.split('/')[-1]}' not found")
|
||
|
|
||
|
def _get_device_property(
|
||
|
self, device_path: str, interface: str, property_name: str
|
||
|
) -> Any:
|
||
|
self._check_device(device_path)
|
||
|
device_properties = self._properties[device_path]
|
||
|
|
||
|
try:
|
||
|
interface_properties = device_properties[interface]
|
||
|
except KeyError:
|
||
|
raise BleakError(
|
||
|
f"Interface {interface} not found for device '{device_path}'"
|
||
|
)
|
||
|
|
||
|
try:
|
||
|
value = interface_properties[property_name]
|
||
|
except KeyError:
|
||
|
raise BleakError(
|
||
|
f"Property '{property_name}' not found for '{interface}' in '{device_path}'"
|
||
|
)
|
||
|
|
||
|
return value
|
||
|
|
||
|
async def async_init(self) -> None:
|
||
|
"""
|
||
|
Connects to the D-Bus message bus and begins monitoring signals.
|
||
|
|
||
|
It is safe to call this method multiple times. If the bus is already
|
||
|
connected, no action is performed.
|
||
|
"""
|
||
|
async with self._bus_lock:
|
||
|
if self._bus and self._bus.connected:
|
||
|
return
|
||
|
|
||
|
self._services_cache = {}
|
||
|
|
||
|
# We need to create a new MessageBus each time as
|
||
|
# dbus-next will destroy the underlying file descriptors
|
||
|
# when the previous one is closed in its finalizer.
|
||
|
bus = MessageBus(bus_type=BusType.SYSTEM, auth=get_dbus_authenticator())
|
||
|
await bus.connect()
|
||
|
|
||
|
try:
|
||
|
# Add signal listeners
|
||
|
|
||
|
bus.add_message_handler(self._parse_msg)
|
||
|
|
||
|
rules = MatchRules(
|
||
|
interface=defs.OBJECT_MANAGER_INTERFACE,
|
||
|
member="InterfacesAdded",
|
||
|
arg0path="/org/bluez/",
|
||
|
)
|
||
|
reply = await add_match(bus, rules)
|
||
|
assert_reply(reply)
|
||
|
|
||
|
rules = MatchRules(
|
||
|
interface=defs.OBJECT_MANAGER_INTERFACE,
|
||
|
member="InterfacesRemoved",
|
||
|
arg0path="/org/bluez/",
|
||
|
)
|
||
|
reply = await add_match(bus, rules)
|
||
|
assert_reply(reply)
|
||
|
|
||
|
rules = MatchRules(
|
||
|
interface=defs.PROPERTIES_INTERFACE,
|
||
|
member="PropertiesChanged",
|
||
|
path_namespace="/org/bluez",
|
||
|
)
|
||
|
reply = await add_match(bus, rules)
|
||
|
assert_reply(reply)
|
||
|
|
||
|
# get existing objects after adding signal handlers to avoid
|
||
|
# race condition
|
||
|
|
||
|
reply = await bus.call(
|
||
|
Message(
|
||
|
destination=defs.BLUEZ_SERVICE,
|
||
|
path="/",
|
||
|
member="GetManagedObjects",
|
||
|
interface=defs.OBJECT_MANAGER_INTERFACE,
|
||
|
)
|
||
|
)
|
||
|
assert_reply(reply)
|
||
|
|
||
|
# dictionaries are cleared in case AddInterfaces was received first
|
||
|
# or there was a bus reset and we are reconnecting
|
||
|
self._properties.clear()
|
||
|
self._service_map.clear()
|
||
|
self._characteristic_map.clear()
|
||
|
self._descriptor_map.clear()
|
||
|
|
||
|
for path, interfaces in reply.body[0].items():
|
||
|
props = unpack_variants(interfaces)
|
||
|
self._properties[path] = props
|
||
|
|
||
|
if defs.ADAPTER_INTERFACE in props:
|
||
|
self._adapters.add(path)
|
||
|
|
||
|
service_props = cast(
|
||
|
GattService1, props.get(defs.GATT_SERVICE_INTERFACE)
|
||
|
)
|
||
|
|
||
|
if service_props:
|
||
|
self._service_map.setdefault(
|
||
|
service_props["Device"], set()
|
||
|
).add(path)
|
||
|
|
||
|
char_props = cast(
|
||
|
GattCharacteristic1,
|
||
|
props.get(defs.GATT_CHARACTERISTIC_INTERFACE),
|
||
|
)
|
||
|
|
||
|
if char_props:
|
||
|
self._characteristic_map.setdefault(
|
||
|
char_props["Service"], set()
|
||
|
).add(path)
|
||
|
|
||
|
desc_props = cast(
|
||
|
GattDescriptor1, props.get(defs.GATT_DESCRIPTOR_INTERFACE)
|
||
|
)
|
||
|
|
||
|
if desc_props:
|
||
|
self._descriptor_map.setdefault(
|
||
|
desc_props["Characteristic"], set()
|
||
|
).add(path)
|
||
|
|
||
|
if logger.isEnabledFor(logging.DEBUG):
|
||
|
logger.debug("initial properties: %s", self._properties)
|
||
|
|
||
|
except BaseException:
|
||
|
# if setup failed, disconnect
|
||
|
bus.disconnect()
|
||
|
raise
|
||
|
|
||
|
# Everything is setup, so save the bus
|
||
|
self._bus = bus
|
||
|
|
||
|
def get_default_adapter(self) -> str:
|
||
|
"""
|
||
|
Gets the D-Bus object path of of the first powered Bluetooth adapter.
|
||
|
|
||
|
Returns:
|
||
|
Name of the first found powered adapter on the system, i.e. "/org/bluez/hciX".
|
||
|
|
||
|
Raises:
|
||
|
BleakError:
|
||
|
if there are no Bluetooth adapters or if none of the adapters are powered
|
||
|
"""
|
||
|
if not any(self._adapters):
|
||
|
raise BleakError("No Bluetooth adapters found.")
|
||
|
|
||
|
for adapter_path in self._adapters:
|
||
|
if cast(
|
||
|
defs.Adapter1, self._properties[adapter_path][defs.ADAPTER_INTERFACE]
|
||
|
)["Powered"]:
|
||
|
return adapter_path
|
||
|
|
||
|
raise BleakError("No powered Bluetooth adapters found.")
|
||
|
|
||
|
async def active_scan(
|
||
|
self,
|
||
|
adapter_path: str,
|
||
|
filters: Dict[str, Variant],
|
||
|
advertisement_callback: AdvertisementCallback,
|
||
|
device_removed_callback: DeviceRemovedCallback,
|
||
|
) -> Callable[[], Coroutine]:
|
||
|
"""
|
||
|
Configures the advertisement data filters and starts scanning.
|
||
|
|
||
|
Args:
|
||
|
adapter_path: The D-Bus object path of the adapter to use for scanning.
|
||
|
filters: A dictionary of filters to pass to ``SetDiscoveryFilter``.
|
||
|
advertisement_callback:
|
||
|
A callable that will be called when new advertisement data is received.
|
||
|
device_removed_callback:
|
||
|
A callable that will be called when a device is removed from BlueZ.
|
||
|
|
||
|
Returns:
|
||
|
An async function that is used to stop scanning and remove the filters.
|
||
|
|
||
|
Raises:
|
||
|
BleakError: if the adapter is not present in BlueZ
|
||
|
"""
|
||
|
async with self._bus_lock:
|
||
|
# If the adapter doesn't exist, then the message calls below would
|
||
|
# fail with "method not found". This provides a more informative
|
||
|
# error message.
|
||
|
self._check_adapter(adapter_path)
|
||
|
|
||
|
self._advertisement_callbacks[adapter_path].append(advertisement_callback)
|
||
|
|
||
|
device_removed_callback_and_state = DeviceRemovedCallbackAndState(
|
||
|
device_removed_callback, adapter_path
|
||
|
)
|
||
|
self._device_removed_callbacks.append(device_removed_callback_and_state)
|
||
|
|
||
|
try:
|
||
|
# Apply the filters
|
||
|
reply = await self._bus.call(
|
||
|
Message(
|
||
|
destination=defs.BLUEZ_SERVICE,
|
||
|
path=adapter_path,
|
||
|
interface=defs.ADAPTER_INTERFACE,
|
||
|
member="SetDiscoveryFilter",
|
||
|
signature="a{sv}",
|
||
|
body=[filters],
|
||
|
)
|
||
|
)
|
||
|
assert_reply(reply)
|
||
|
|
||
|
# Start scanning
|
||
|
reply = await self._bus.call(
|
||
|
Message(
|
||
|
destination=defs.BLUEZ_SERVICE,
|
||
|
path=adapter_path,
|
||
|
interface=defs.ADAPTER_INTERFACE,
|
||
|
member="StartDiscovery",
|
||
|
)
|
||
|
)
|
||
|
assert_reply(reply)
|
||
|
|
||
|
async def stop() -> None:
|
||
|
# need to remove callbacks first, otherwise we get TxPower
|
||
|
# and RSSI properties removed during stop which causes
|
||
|
# incorrect advertisement data callbacks
|
||
|
self._advertisement_callbacks[adapter_path].remove(
|
||
|
advertisement_callback
|
||
|
)
|
||
|
self._device_removed_callbacks.remove(
|
||
|
device_removed_callback_and_state
|
||
|
)
|
||
|
|
||
|
async with self._bus_lock:
|
||
|
reply = await self._bus.call(
|
||
|
Message(
|
||
|
destination=defs.BLUEZ_SERVICE,
|
||
|
path=adapter_path,
|
||
|
interface=defs.ADAPTER_INTERFACE,
|
||
|
member="StopDiscovery",
|
||
|
)
|
||
|
)
|
||
|
|
||
|
try:
|
||
|
assert_reply(reply)
|
||
|
except BleakDBusError as ex:
|
||
|
if ex.dbus_error != "org.bluez.Error.NotReady":
|
||
|
raise
|
||
|
else:
|
||
|
# remove the filters
|
||
|
reply = await self._bus.call(
|
||
|
Message(
|
||
|
destination=defs.BLUEZ_SERVICE,
|
||
|
path=adapter_path,
|
||
|
interface=defs.ADAPTER_INTERFACE,
|
||
|
member="SetDiscoveryFilter",
|
||
|
signature="a{sv}",
|
||
|
body=[{}],
|
||
|
)
|
||
|
)
|
||
|
assert_reply(reply)
|
||
|
|
||
|
return stop
|
||
|
except BaseException:
|
||
|
# if starting scanning failed, don't leak the callbacks
|
||
|
self._advertisement_callbacks[adapter_path].remove(
|
||
|
advertisement_callback
|
||
|
)
|
||
|
self._device_removed_callbacks.remove(device_removed_callback_and_state)
|
||
|
raise
|
||
|
|
||
|
async def passive_scan(
|
||
|
self,
|
||
|
adapter_path: str,
|
||
|
filters: List[OrPatternLike],
|
||
|
advertisement_callback: AdvertisementCallback,
|
||
|
device_removed_callback: DeviceRemovedCallback,
|
||
|
) -> Callable[[], Coroutine]:
|
||
|
"""
|
||
|
Configures the advertisement data filters and starts scanning.
|
||
|
|
||
|
Args:
|
||
|
adapter_path: The D-Bus object path of the adapter to use for scanning.
|
||
|
filters: A list of "or patterns" to pass to ``org.bluez.AdvertisementMonitor1``.
|
||
|
advertisement_callback:
|
||
|
A callable that will be called when new advertisement data is received.
|
||
|
device_removed_callback:
|
||
|
A callable that will be called when a device is removed from BlueZ.
|
||
|
|
||
|
Returns:
|
||
|
An async function that is used to stop scanning and remove the filters.
|
||
|
|
||
|
Raises:
|
||
|
BleakError: if the adapter is not present in BlueZ
|
||
|
"""
|
||
|
async with self._bus_lock:
|
||
|
# If the adapter doesn't exist, then the message calls below would
|
||
|
# fail with "method not found". This provides a more informative
|
||
|
# error message.
|
||
|
self._check_adapter(adapter_path)
|
||
|
|
||
|
self._advertisement_callbacks[adapter_path].append(advertisement_callback)
|
||
|
|
||
|
device_removed_callback_and_state = DeviceRemovedCallbackAndState(
|
||
|
device_removed_callback, adapter_path
|
||
|
)
|
||
|
self._device_removed_callbacks.append(device_removed_callback_and_state)
|
||
|
|
||
|
try:
|
||
|
monitor = AdvertisementMonitor(filters)
|
||
|
|
||
|
# this should be a unique path to allow multiple python interpreters
|
||
|
# running bleak and multiple scanners within a single interpreter
|
||
|
monitor_path = f"/org/bleak/{os.getpid()}/{id(monitor)}"
|
||
|
|
||
|
reply = await self._bus.call(
|
||
|
Message(
|
||
|
destination=defs.BLUEZ_SERVICE,
|
||
|
path=adapter_path,
|
||
|
interface=defs.ADVERTISEMENT_MONITOR_MANAGER_INTERFACE,
|
||
|
member="RegisterMonitor",
|
||
|
signature="o",
|
||
|
body=[monitor_path],
|
||
|
)
|
||
|
)
|
||
|
|
||
|
if (
|
||
|
reply.message_type == MessageType.ERROR
|
||
|
and reply.error_name == "org.freedesktop.DBus.Error.UnknownMethod"
|
||
|
):
|
||
|
raise BleakError(
|
||
|
"passive scanning on Linux requires BlueZ >= 5.56 with --experimental enabled and Linux kernel >= 5.10"
|
||
|
)
|
||
|
|
||
|
assert_reply(reply)
|
||
|
|
||
|
# It is important to export after registering, otherwise BlueZ
|
||
|
# won't use the monitor
|
||
|
self._bus.export(monitor_path, monitor)
|
||
|
|
||
|
async def stop() -> None:
|
||
|
# need to remove callbacks first, otherwise we get TxPower
|
||
|
# and RSSI properties removed during stop which causes
|
||
|
# incorrect advertisement data callbacks
|
||
|
self._advertisement_callbacks[adapter_path].remove(
|
||
|
advertisement_callback
|
||
|
)
|
||
|
self._device_removed_callbacks.remove(
|
||
|
device_removed_callback_and_state
|
||
|
)
|
||
|
|
||
|
async with self._bus_lock:
|
||
|
self._bus.unexport(monitor_path, monitor)
|
||
|
|
||
|
reply = await self._bus.call(
|
||
|
Message(
|
||
|
destination=defs.BLUEZ_SERVICE,
|
||
|
path=adapter_path,
|
||
|
interface=defs.ADVERTISEMENT_MONITOR_MANAGER_INTERFACE,
|
||
|
member="UnregisterMonitor",
|
||
|
signature="o",
|
||
|
body=[monitor_path],
|
||
|
)
|
||
|
)
|
||
|
assert_reply(reply)
|
||
|
|
||
|
return stop
|
||
|
|
||
|
except BaseException:
|
||
|
# if starting scanning failed, don't leak the callbacks
|
||
|
self._advertisement_callbacks[adapter_path].remove(
|
||
|
advertisement_callback
|
||
|
)
|
||
|
self._device_removed_callbacks.remove(device_removed_callback_and_state)
|
||
|
raise
|
||
|
|
||
|
def add_device_watcher(
|
||
|
self,
|
||
|
device_path: str,
|
||
|
on_connected_changed: DeviceConnectedChangedCallback,
|
||
|
on_characteristic_value_changed: CharacteristicValueChangedCallback,
|
||
|
) -> DeviceWatcher:
|
||
|
"""
|
||
|
Registers a device watcher to receive callbacks when device state
|
||
|
changes or events are received.
|
||
|
|
||
|
Args:
|
||
|
device_path:
|
||
|
The D-Bus object path of the device.
|
||
|
on_connected_changed:
|
||
|
A callback that is called when the device's "Connected"
|
||
|
state changes.
|
||
|
on_characteristic_value_changed:
|
||
|
A callback that is called whenever a characteristic receives
|
||
|
a notification/indication.
|
||
|
|
||
|
Returns:
|
||
|
A device watcher object that acts a token to unregister the watcher.
|
||
|
|
||
|
Raises:
|
||
|
BleakError: if the device is not present in BlueZ
|
||
|
"""
|
||
|
self._check_device(device_path)
|
||
|
|
||
|
watcher = DeviceWatcher(
|
||
|
device_path, on_connected_changed, on_characteristic_value_changed
|
||
|
)
|
||
|
|
||
|
self._device_watchers.setdefault(device_path, set()).add(watcher)
|
||
|
return watcher
|
||
|
|
||
|
def remove_device_watcher(self, watcher: DeviceWatcher) -> None:
|
||
|
"""
|
||
|
Unregisters a device watcher.
|
||
|
|
||
|
Args:
|
||
|
The device watcher token that was returned by
|
||
|
:meth:`add_device_watcher`.
|
||
|
"""
|
||
|
device_path = watcher.device_path
|
||
|
self._device_watchers[device_path].remove(watcher)
|
||
|
if not self._device_watchers[device_path]:
|
||
|
del self._device_watchers[device_path]
|
||
|
|
||
|
async def get_services(
|
||
|
self, device_path: str, use_cached: bool, requested_services: Optional[Set[str]]
|
||
|
) -> BleakGATTServiceCollection:
|
||
|
"""
|
||
|
Builds a new :class:`BleakGATTServiceCollection` from the current state.
|
||
|
|
||
|
Args:
|
||
|
device_path:
|
||
|
The D-Bus object path of the Bluetooth device.
|
||
|
use_cached:
|
||
|
When ``True`` if there is a cached :class:`BleakGATTServiceCollection`,
|
||
|
the method will not wait for ``"ServicesResolved"`` to become true
|
||
|
and instead return the cached service collection immediately.
|
||
|
requested_services:
|
||
|
When given, only return services with UUID that is in the list
|
||
|
of requested services.
|
||
|
|
||
|
Returns:
|
||
|
A new :class:`BleakGATTServiceCollection`.
|
||
|
|
||
|
Raises:
|
||
|
BleakError: if the device is not present in BlueZ
|
||
|
"""
|
||
|
self._check_device(device_path)
|
||
|
|
||
|
if use_cached:
|
||
|
services = self._services_cache.get(device_path)
|
||
|
if services is not None:
|
||
|
logger.debug("Using cached services for %s", device_path)
|
||
|
return services
|
||
|
|
||
|
await self._wait_for_services_discovery(device_path)
|
||
|
|
||
|
services = BleakGATTServiceCollection()
|
||
|
|
||
|
for service_path in self._service_map.get(device_path, set()):
|
||
|
service_props = cast(
|
||
|
GattService1,
|
||
|
self._properties[service_path][defs.GATT_SERVICE_INTERFACE],
|
||
|
)
|
||
|
|
||
|
service = BleakGATTServiceBlueZDBus(service_props, service_path)
|
||
|
|
||
|
if (
|
||
|
requested_services is not None
|
||
|
and service.uuid not in requested_services
|
||
|
):
|
||
|
continue
|
||
|
|
||
|
services.add_service(service)
|
||
|
|
||
|
for char_path in self._characteristic_map.get(service_path, set()):
|
||
|
char_props = cast(
|
||
|
GattCharacteristic1,
|
||
|
self._properties[char_path][defs.GATT_CHARACTERISTIC_INTERFACE],
|
||
|
)
|
||
|
|
||
|
char = BleakGATTCharacteristicBlueZDBus(
|
||
|
char_props,
|
||
|
char_path,
|
||
|
service.uuid,
|
||
|
service.handle,
|
||
|
# "MTU" property was added in BlueZ 5.62, otherwise fall
|
||
|
# back to minimum MTU according to Bluetooth spec.
|
||
|
lambda: char_props.get("MTU", 23) - 3,
|
||
|
)
|
||
|
|
||
|
services.add_characteristic(char)
|
||
|
|
||
|
for desc_path in self._descriptor_map.get(char_path, set()):
|
||
|
desc_props = cast(
|
||
|
GattDescriptor1,
|
||
|
self._properties[desc_path][defs.GATT_DESCRIPTOR_INTERFACE],
|
||
|
)
|
||
|
|
||
|
desc = BleakGATTDescriptorBlueZDBus(
|
||
|
desc_props,
|
||
|
desc_path,
|
||
|
char.uuid,
|
||
|
char.handle,
|
||
|
)
|
||
|
|
||
|
services.add_descriptor(desc)
|
||
|
|
||
|
self._services_cache[device_path] = services
|
||
|
|
||
|
return services
|
||
|
|
||
|
def get_device_name(self, device_path: str) -> str:
|
||
|
"""
|
||
|
Gets the value of the "Name" property for a device.
|
||
|
|
||
|
Args:
|
||
|
device_path: The D-Bus object path of the device.
|
||
|
|
||
|
Returns:
|
||
|
The current property value.
|
||
|
|
||
|
Raises:
|
||
|
BleakError: if the device is not present in BlueZ
|
||
|
"""
|
||
|
return self._get_device_property(device_path, defs.DEVICE_INTERFACE, "Name")
|
||
|
|
||
|
def is_connected(self, device_path: str) -> bool:
|
||
|
"""
|
||
|
Gets the value of the "Connected" property for a device.
|
||
|
|
||
|
Args:
|
||
|
device_path: The D-Bus object path of the device.
|
||
|
|
||
|
Returns:
|
||
|
The current property value or ``False`` if the device does not exist in BlueZ.
|
||
|
"""
|
||
|
try:
|
||
|
return self._properties[device_path][defs.DEVICE_INTERFACE]["Connected"]
|
||
|
except KeyError:
|
||
|
return False
|
||
|
|
||
|
async def _wait_for_services_discovery(self, device_path: str) -> None:
|
||
|
"""
|
||
|
Waits for the device services to be discovered.
|
||
|
|
||
|
If a disconnect happens before the completion a BleakError exception is raised.
|
||
|
|
||
|
Raises:
|
||
|
BleakError: if the device is not present in BlueZ
|
||
|
"""
|
||
|
self._check_device(device_path)
|
||
|
|
||
|
with contextlib.ExitStack() as stack:
|
||
|
services_discovered_wait_task = asyncio.create_task(
|
||
|
self._wait_condition(device_path, "ServicesResolved", True)
|
||
|
)
|
||
|
stack.callback(services_discovered_wait_task.cancel)
|
||
|
|
||
|
device_disconnected_wait_task = asyncio.create_task(
|
||
|
self._wait_condition(device_path, "Connected", False)
|
||
|
)
|
||
|
stack.callback(device_disconnected_wait_task.cancel)
|
||
|
|
||
|
# in some cases, we can get "InterfaceRemoved" without the
|
||
|
# "Connected" property changing, so we need to race against both
|
||
|
# conditions
|
||
|
device_removed_wait_task = asyncio.create_task(
|
||
|
self._wait_removed(device_path)
|
||
|
)
|
||
|
stack.callback(device_removed_wait_task.cancel)
|
||
|
|
||
|
done, _ = await asyncio.wait(
|
||
|
{
|
||
|
services_discovered_wait_task,
|
||
|
device_disconnected_wait_task,
|
||
|
device_removed_wait_task,
|
||
|
},
|
||
|
return_when=asyncio.FIRST_COMPLETED,
|
||
|
)
|
||
|
|
||
|
# check for exceptions
|
||
|
for task in done:
|
||
|
task.result()
|
||
|
|
||
|
if not done.isdisjoint(
|
||
|
{device_disconnected_wait_task, device_removed_wait_task}
|
||
|
):
|
||
|
raise BleakError("failed to discover services, device disconnected")
|
||
|
|
||
|
async def _wait_removed(self, device_path: str) -> None:
|
||
|
"""
|
||
|
Waits for the device interface to be removed.
|
||
|
|
||
|
If the device is not present in BlueZ, this returns immediately.
|
||
|
|
||
|
Args:
|
||
|
device_path: The D-Bus object path of a Bluetooth device.
|
||
|
"""
|
||
|
if device_path not in self._properties:
|
||
|
return
|
||
|
|
||
|
event = asyncio.Event()
|
||
|
|
||
|
def callback(o: str) -> None:
|
||
|
if o == device_path:
|
||
|
event.set()
|
||
|
|
||
|
device_removed_callback_and_state = DeviceRemovedCallbackAndState(
|
||
|
callback, self._properties[device_path][defs.DEVICE_INTERFACE]["Adapter"]
|
||
|
)
|
||
|
|
||
|
with contextlib.ExitStack() as stack:
|
||
|
self._device_removed_callbacks.append(device_removed_callback_and_state)
|
||
|
stack.callback(
|
||
|
self._device_removed_callbacks.remove, device_removed_callback_and_state
|
||
|
)
|
||
|
await event.wait()
|
||
|
|
||
|
async def _wait_condition(
|
||
|
self, device_path: str, property_name: str, property_value: Any
|
||
|
) -> None:
|
||
|
"""
|
||
|
Waits for a condition to become true.
|
||
|
|
||
|
Args:
|
||
|
device_path: The D-Bus object path of a Bluetooth device.
|
||
|
property_name: The name of the property to test.
|
||
|
property_value: A value to compare the current property value to.
|
||
|
|
||
|
Raises:
|
||
|
BleakError: if the device is not present in BlueZ
|
||
|
"""
|
||
|
value = self._get_device_property(
|
||
|
device_path, defs.DEVICE_INTERFACE, property_name
|
||
|
)
|
||
|
|
||
|
if value == property_value:
|
||
|
return
|
||
|
|
||
|
event = asyncio.Event()
|
||
|
|
||
|
def _wait_condition_callback(new_value: Optional[Any]) -> None:
|
||
|
"""Callback for when a property changes."""
|
||
|
if new_value == property_value:
|
||
|
event.set()
|
||
|
|
||
|
condition_callbacks = self._condition_callbacks
|
||
|
device_callbacks = condition_callbacks.setdefault(device_path, set())
|
||
|
callback = DeviceConditionCallback(_wait_condition_callback, property_name)
|
||
|
device_callbacks.add(callback)
|
||
|
|
||
|
try:
|
||
|
# can be canceled
|
||
|
await event.wait()
|
||
|
finally:
|
||
|
device_callbacks.remove(callback)
|
||
|
if not device_callbacks:
|
||
|
del condition_callbacks[device_path]
|
||
|
|
||
|
def _parse_msg(self, message: Message) -> None:
|
||
|
"""
|
||
|
Handles callbacks from dbus_fast.
|
||
|
"""
|
||
|
|
||
|
if message.message_type != MessageType.SIGNAL:
|
||
|
return
|
||
|
|
||
|
if logger.isEnabledFor(logging.DEBUG):
|
||
|
logger.debug(
|
||
|
"received D-Bus signal: %s.%s (%s): %s",
|
||
|
message.interface,
|
||
|
message.member,
|
||
|
message.path,
|
||
|
message.body,
|
||
|
)
|
||
|
|
||
|
# type hints
|
||
|
obj_path: str
|
||
|
interfaces_and_props: Dict[str, Dict[str, Variant]]
|
||
|
interfaces: List[str]
|
||
|
interface: str
|
||
|
changed: Dict[str, Variant]
|
||
|
invalidated: List[str]
|
||
|
|
||
|
if message.member == "InterfacesAdded":
|
||
|
obj_path, interfaces_and_props = message.body
|
||
|
|
||
|
for interface, props in interfaces_and_props.items():
|
||
|
unpacked_props = unpack_variants(props)
|
||
|
self._properties.setdefault(obj_path, {})[interface] = unpacked_props
|
||
|
|
||
|
if interface == defs.GATT_SERVICE_INTERFACE:
|
||
|
service_props = cast(GattService1, unpacked_props)
|
||
|
self._service_map.setdefault(service_props["Device"], set()).add(
|
||
|
obj_path
|
||
|
)
|
||
|
elif interface == defs.GATT_CHARACTERISTIC_INTERFACE:
|
||
|
char_props = cast(GattCharacteristic1, unpacked_props)
|
||
|
self._characteristic_map.setdefault(
|
||
|
char_props["Service"], set()
|
||
|
).add(obj_path)
|
||
|
elif interface == defs.GATT_DESCRIPTOR_INTERFACE:
|
||
|
desc_props = cast(GattDescriptor1, unpacked_props)
|
||
|
self._descriptor_map.setdefault(
|
||
|
desc_props["Characteristic"], set()
|
||
|
).add(obj_path)
|
||
|
|
||
|
elif interface == defs.ADAPTER_INTERFACE:
|
||
|
self._adapters.add(obj_path)
|
||
|
|
||
|
# If this is a device and it has advertising data properties,
|
||
|
# then it should mean that this device just started advertising.
|
||
|
# Previously, we just relied on RSSI updates to determine if
|
||
|
# a device was actually advertising, but we were missing "slow"
|
||
|
# devices that only advertise once and then go to sleep for a while.
|
||
|
elif interface == defs.DEVICE_INTERFACE:
|
||
|
self._run_advertisement_callbacks(
|
||
|
obj_path, cast(Device1, unpacked_props)
|
||
|
)
|
||
|
elif message.member == "InterfacesRemoved":
|
||
|
obj_path, interfaces = message.body
|
||
|
|
||
|
for interface in interfaces:
|
||
|
try:
|
||
|
del self._properties[obj_path][interface]
|
||
|
except KeyError:
|
||
|
pass
|
||
|
|
||
|
if interface == defs.ADAPTER_INTERFACE:
|
||
|
try:
|
||
|
self._adapters.remove(obj_path)
|
||
|
except KeyError:
|
||
|
pass
|
||
|
elif interface == defs.DEVICE_INTERFACE:
|
||
|
self._services_cache.pop(obj_path, None)
|
||
|
try:
|
||
|
del self._service_map[obj_path]
|
||
|
except KeyError:
|
||
|
pass
|
||
|
|
||
|
for callback, adapter_path in self._device_removed_callbacks:
|
||
|
if obj_path.startswith(adapter_path):
|
||
|
callback(obj_path)
|
||
|
elif interface == defs.GATT_SERVICE_INTERFACE:
|
||
|
try:
|
||
|
del self._characteristic_map[obj_path]
|
||
|
except KeyError:
|
||
|
pass
|
||
|
elif interface == defs.GATT_CHARACTERISTIC_INTERFACE:
|
||
|
try:
|
||
|
del self._descriptor_map[obj_path]
|
||
|
except KeyError:
|
||
|
pass
|
||
|
|
||
|
# Remove empty properties when all interfaces have been removed.
|
||
|
# This avoids wasting memory for people who have noisy devices
|
||
|
# with private addresses that change frequently.
|
||
|
if obj_path in self._properties and not self._properties[obj_path]:
|
||
|
del self._properties[obj_path]
|
||
|
elif message.member == "PropertiesChanged":
|
||
|
interface, changed, invalidated = message.body
|
||
|
message_path = message.path
|
||
|
assert message_path is not None
|
||
|
|
||
|
try:
|
||
|
self_interface = self._properties[message.path][interface]
|
||
|
except KeyError:
|
||
|
# This can happen during initialization. The "PropertiesChanged"
|
||
|
# handler is attached before "GetManagedObjects" is called
|
||
|
# and so self._properties may not yet be populated.
|
||
|
# This is not a problem. We just discard the property value
|
||
|
# since "GetManagedObjects" will return a newer value.
|
||
|
pass
|
||
|
else:
|
||
|
# update self._properties first
|
||
|
|
||
|
self_interface.update(unpack_variants(changed))
|
||
|
|
||
|
for name in invalidated:
|
||
|
try:
|
||
|
del self_interface[name]
|
||
|
except KeyError:
|
||
|
# sometimes there BlueZ tries to remove properties
|
||
|
# that were never added
|
||
|
pass
|
||
|
|
||
|
# then call any callbacks so they will be called with the
|
||
|
# updated state
|
||
|
|
||
|
if interface == defs.DEVICE_INTERFACE:
|
||
|
# handle advertisement watchers
|
||
|
device_path = message_path
|
||
|
|
||
|
self._run_advertisement_callbacks(
|
||
|
device_path, cast(Device1, self_interface)
|
||
|
)
|
||
|
|
||
|
# handle device condition watchers
|
||
|
callbacks = self._condition_callbacks.get(device_path)
|
||
|
if callbacks:
|
||
|
for callback in callbacks:
|
||
|
name = callback.property_name
|
||
|
if name in changed:
|
||
|
callback.callback(self_interface.get(name))
|
||
|
|
||
|
# handle device connection change watchers
|
||
|
if "Connected" in changed:
|
||
|
new_connected = self_interface["Connected"]
|
||
|
watchers = self._device_watchers.get(device_path)
|
||
|
if watchers:
|
||
|
# callbacks may remove the watcher, hence the copy
|
||
|
for watcher in watchers.copy():
|
||
|
watcher.on_connected_changed(new_connected)
|
||
|
|
||
|
elif interface == defs.GATT_CHARACTERISTIC_INTERFACE:
|
||
|
# handle characteristic value change watchers
|
||
|
if "Value" in changed:
|
||
|
new_value = self_interface["Value"]
|
||
|
device_path = device_path_from_characteristic_path(message_path)
|
||
|
watchers = self._device_watchers.get(device_path)
|
||
|
if watchers:
|
||
|
for watcher in watchers:
|
||
|
watcher.on_characteristic_value_changed(
|
||
|
message_path, new_value
|
||
|
)
|
||
|
|
||
|
def _run_advertisement_callbacks(self, device_path: str, device: Device1) -> None:
|
||
|
"""
|
||
|
Runs any registered advertisement callbacks.
|
||
|
|
||
|
Args:
|
||
|
device_path: The D-Bus object path of the remote device.
|
||
|
device: The current D-Bus properties of the device.
|
||
|
"""
|
||
|
adapter_path = device["Adapter"]
|
||
|
for callback in self._advertisement_callbacks[adapter_path]:
|
||
|
callback(device_path, device.copy())
|
||
|
|
||
|
|
||
|
_global_instances: MutableMapping[Any, BlueZManager] = WeakKeyDictionary()
|
||
|
|
||
|
|
||
|
async def get_global_bluez_manager() -> BlueZManager:
|
||
|
"""
|
||
|
Gets an existing initialized global BlueZ manager instance associated with the current event loop,
|
||
|
or initializes a new instance.
|
||
|
"""
|
||
|
|
||
|
loop = asyncio.get_running_loop()
|
||
|
try:
|
||
|
instance = _global_instances[loop]
|
||
|
except KeyError:
|
||
|
instance = _global_instances[loop] = BlueZManager()
|
||
|
|
||
|
await instance.async_init()
|
||
|
|
||
|
return instance
|