idasit/bleak/backends/scanner.py

336 lines
9.7 KiB
Python
Raw Normal View History

2024-12-14 14:55:37 +01:00
import abc
import asyncio
import inspect
import os
import platform
from typing import (
Any,
Callable,
Coroutine,
Dict,
Hashable,
List,
NamedTuple,
Optional,
Set,
Tuple,
Type,
)
from ..exc import BleakError
from .device import BLEDevice
# prevent tasks from being garbage collected
_background_tasks: Set[asyncio.Task] = set()
class AdvertisementData(NamedTuple):
"""
Wrapper around the advertisement data that each platform returns upon discovery
"""
local_name: Optional[str]
"""
The local name of the device or ``None`` if not included in advertising data.
"""
manufacturer_data: Dict[int, bytes]
"""
Dictionary of manufacturer data in bytes from the received advertisement data or empty dict if not present.
The keys are Bluetooth SIG assigned Company Identifiers and the values are bytes.
https://www.bluetooth.com/specifications/assigned-numbers/company-identifiers/
"""
service_data: Dict[str, bytes]
"""
Dictionary of service data from the received advertisement data or empty dict if not present.
"""
service_uuids: List[str]
"""
List of service UUIDs from the received advertisement data or empty list if not present.
"""
tx_power: Optional[int]
"""
TX Power Level of the remote device from the received advertising data or ``None`` if not present.
.. versionadded:: 0.17
"""
rssi: int
"""
The Radio Receive Signal Strength (RSSI) in dBm.
.. versionadded:: 0.19
"""
platform_data: Tuple
"""
Tuple of platform specific data.
This is not a stable API. The actual values may change between releases.
"""
def __repr__(self) -> str:
kwargs = []
if self.local_name:
kwargs.append(f"local_name={repr(self.local_name)}")
if self.manufacturer_data:
kwargs.append(f"manufacturer_data={repr(self.manufacturer_data)}")
if self.service_data:
kwargs.append(f"service_data={repr(self.service_data)}")
if self.service_uuids:
kwargs.append(f"service_uuids={repr(self.service_uuids)}")
if self.tx_power is not None:
kwargs.append(f"tx_power={repr(self.tx_power)}")
kwargs.append(f"rssi={repr(self.rssi)}")
return f"AdvertisementData({', '.join(kwargs)})"
AdvertisementDataCallback = Callable[
[BLEDevice, AdvertisementData],
Optional[Coroutine[Any, Any, None]],
]
"""
Type alias for callback called when advertisement data is received.
"""
AdvertisementDataFilter = Callable[
[BLEDevice, AdvertisementData],
bool,
]
"""
Type alias for an advertisement data filter function.
Implementations should return ``True`` for matches, otherwise ``False``.
"""
class BaseBleakScanner(abc.ABC):
"""
Interface for Bleak Bluetooth LE Scanners
Args:
detection_callback:
Optional function that will be called each time a device is
discovered or advertising data has changed.
service_uuids:
Optional list of service UUIDs to filter on. Only advertisements
containing this advertising data will be received.
"""
seen_devices: Dict[str, Tuple[BLEDevice, AdvertisementData]]
"""
Map of device identifier to BLEDevice and most recent advertisement data.
This map must be cleared when scanning starts.
"""
def __init__(
self,
detection_callback: Optional[AdvertisementDataCallback],
service_uuids: Optional[List[str]],
):
super(BaseBleakScanner, self).__init__()
self._ad_callbacks: Dict[
Hashable, Callable[[BLEDevice, AdvertisementData], None]
] = {}
"""
List of callbacks to call when an advertisement is received.
"""
if detection_callback is not None:
self.register_detection_callback(detection_callback)
self._service_uuids: Optional[List[str]] = (
[u.lower() for u in service_uuids] if service_uuids is not None else None
)
self.seen_devices = {}
def register_detection_callback(
self, callback: Optional[AdvertisementDataCallback]
) -> Callable[[], None]:
"""
Register a callback that is called when an advertisement event from the
OS is received.
The ``callback`` is a function or coroutine that takes two arguments: :class:`BLEDevice`
and :class:`AdvertisementData`.
Args:
callback: A function, coroutine or ``None``.
Returns:
A method that can be called to unregister the callback.
"""
error_text = "callback must be callable with 2 parameters"
if not callable(callback):
raise TypeError(error_text)
handler_signature = inspect.signature(callback)
if len(handler_signature.parameters) != 2:
raise TypeError(error_text)
if inspect.iscoroutinefunction(callback):
def detection_callback(s: BLEDevice, d: AdvertisementData) -> None:
task = asyncio.create_task(callback(s, d))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
else:
detection_callback = callback
token = object()
self._ad_callbacks[token] = detection_callback
def remove() -> None:
self._ad_callbacks.pop(token, None)
return remove
def is_allowed_uuid(self, service_uuids: Optional[List[str]]) -> bool:
"""
Check if the advertisement data contains any of the service UUIDs
matching the filter. If no filter is set, this will always return
``True``.
Args:
service_uuids: The service UUIDs from the advertisement data.
Returns:
``True`` if the advertisement data should be allowed or ``False``
if the advertisement data should be filtered out.
"""
# Backends will make best effort to filter out advertisements that
# don't match the service UUIDs, but if other apps are scanning at the
# same time or something like that, we may still receive advertisements
# that don't match. So we need to do more filtering here to get the
# expected behavior.
if not self._service_uuids:
# if there is no filter, everything is allowed
return True
if not service_uuids:
# if there is a filter the advertisement data doesn't contain any
# service UUIDs, filter it out
return False
for uuid in service_uuids:
if uuid in self._service_uuids:
# match was found, keep this advertisement
return True
# there were no matching service uuids, filter this one out
return False
def call_detection_callbacks(
self, device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""
Calls all registered detection callbacks.
Backend implementations should call this method when an advertisement
event is received from the OS.
"""
for callback in self._ad_callbacks.values():
callback(device, advertisement_data)
def create_or_update_device(
self, address: str, name: str, details: Any, adv: AdvertisementData
) -> BLEDevice:
"""
Creates or updates a device in :attr:`seen_devices`.
Args:
address: The Bluetooth address of the device (UUID on macOS).
name: The OS display name for the device.
details: The platform-specific handle for the device.
adv: The most recent advertisement data received.
Returns:
The updated device.
"""
# for backwards compatibility, see https://github.com/hbldh/bleak/issues/1025
metadata = dict(
uuids=adv.service_uuids,
manufacturer_data=adv.manufacturer_data,
)
try:
device, _ = self.seen_devices[address]
device.name = name
device._rssi = adv.rssi
device._metadata = metadata
except KeyError:
device = BLEDevice(
address,
name,
details,
adv.rssi,
**metadata,
)
self.seen_devices[address] = (device, adv)
return device
@abc.abstractmethod
async def start(self) -> None:
"""Start scanning for devices"""
raise NotImplementedError()
@abc.abstractmethod
async def stop(self) -> None:
"""Stop scanning for devices"""
raise NotImplementedError()
@abc.abstractmethod
def set_scanning_filter(self, **kwargs) -> None:
"""Set scanning filter for the BleakScanner.
Args:
**kwargs: The filter details. This will differ a lot between backend implementations.
"""
raise NotImplementedError()
def get_platform_scanner_backend_type() -> Type[BaseBleakScanner]:
"""
Gets the platform-specific :class:`BaseBleakScanner` type.
"""
if os.environ.get("P4A_BOOTSTRAP") is not None:
from bleak.backends.p4android.scanner import BleakScannerP4Android
return BleakScannerP4Android
if platform.system() == "Linux":
from bleak.backends.bluezdbus.scanner import BleakScannerBlueZDBus
return BleakScannerBlueZDBus
if platform.system() == "Darwin":
from bleak.backends.corebluetooth.scanner import BleakScannerCoreBluetooth
return BleakScannerCoreBluetooth
if platform.system() == "Windows":
from bleak.backends.winrt.scanner import BleakScannerWinRT
return BleakScannerWinRT
raise BleakError(f"Unsupported platform: {platform.system()}")