2022-07-08 23:55:31 +00:00
|
|
|
"""Models for bluetooth."""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
import contextlib
|
|
|
|
import logging
|
2022-07-30 00:53:33 +00:00
|
|
|
from typing import Any, Final
|
2022-07-08 23:55:31 +00:00
|
|
|
|
|
|
|
from bleak import BleakScanner
|
|
|
|
from bleak.backends.device import BLEDevice
|
2022-07-11 15:14:00 +00:00
|
|
|
from bleak.backends.scanner import (
|
|
|
|
AdvertisementData,
|
|
|
|
AdvertisementDataCallback,
|
|
|
|
BaseBleakScanner,
|
|
|
|
)
|
2022-07-08 23:55:31 +00:00
|
|
|
|
|
|
|
from homeassistant.core import CALLBACK_TYPE, callback as hass_callback
|
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
FILTER_UUIDS: Final = "UUIDs"
|
|
|
|
|
|
|
|
HA_BLEAK_SCANNER: HaBleakScanner | None = None
|
|
|
|
|
|
|
|
|
|
|
|
def _dispatch_callback(
|
|
|
|
callback: AdvertisementDataCallback,
|
|
|
|
filters: dict[str, set[str]],
|
|
|
|
device: BLEDevice,
|
|
|
|
advertisement_data: AdvertisementData,
|
|
|
|
) -> None:
|
|
|
|
"""Dispatch the callback."""
|
|
|
|
if not callback:
|
|
|
|
# Callback destroyed right before being called, ignore
|
2022-07-30 00:53:33 +00:00
|
|
|
return # type: ignore[unreachable]
|
2022-07-08 23:55:31 +00:00
|
|
|
|
|
|
|
if (uuids := filters.get(FILTER_UUIDS)) and not uuids.intersection(
|
|
|
|
advertisement_data.service_uuids
|
|
|
|
):
|
|
|
|
return
|
|
|
|
|
|
|
|
try:
|
|
|
|
callback(device, advertisement_data)
|
|
|
|
except Exception: # pylint: disable=broad-except
|
|
|
|
_LOGGER.exception("Error in callback: %s", callback)
|
|
|
|
|
|
|
|
|
2022-07-30 00:53:33 +00:00
|
|
|
class HaBleakScanner(BleakScanner):
|
2022-07-08 23:55:31 +00:00
|
|
|
"""BleakScanner that cannot be stopped."""
|
|
|
|
|
2022-07-22 18:19:53 +00:00
|
|
|
def __init__( # pylint: disable=super-init-not-called
|
|
|
|
self, *args: Any, **kwargs: Any
|
|
|
|
) -> None:
|
2022-07-08 23:55:31 +00:00
|
|
|
"""Initialize the BleakScanner."""
|
|
|
|
self._callbacks: list[
|
|
|
|
tuple[AdvertisementDataCallback, dict[str, set[str]]]
|
|
|
|
] = []
|
2022-07-22 00:16:45 +00:00
|
|
|
self.history: dict[str, tuple[BLEDevice, AdvertisementData]] = {}
|
2022-07-22 18:19:53 +00:00
|
|
|
# Init called later in async_setup if we are enabling the scanner
|
|
|
|
# since init has side effects that can throw exceptions
|
|
|
|
self._setup = False
|
|
|
|
|
|
|
|
@hass_callback
|
|
|
|
def async_setup(self, *args: Any, **kwargs: Any) -> None:
|
|
|
|
"""Deferred setup of the BleakScanner since __init__ has side effects."""
|
|
|
|
if not self._setup:
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self._setup = True
|
2022-07-08 23:55:31 +00:00
|
|
|
|
2022-07-25 14:52:35 +00:00
|
|
|
@hass_callback
|
|
|
|
def async_reset(self) -> None:
|
|
|
|
"""Reset the scanner so it can be setup again."""
|
|
|
|
self.history = {}
|
|
|
|
self._setup = False
|
|
|
|
|
2022-07-08 23:55:31 +00:00
|
|
|
@hass_callback
|
|
|
|
def async_register_callback(
|
|
|
|
self, callback: AdvertisementDataCallback, filters: dict[str, set[str]]
|
|
|
|
) -> CALLBACK_TYPE:
|
|
|
|
"""Register a callback."""
|
|
|
|
callback_entry = (callback, filters)
|
|
|
|
self._callbacks.append(callback_entry)
|
|
|
|
|
|
|
|
@hass_callback
|
|
|
|
def _remove_callback() -> None:
|
|
|
|
self._callbacks.remove(callback_entry)
|
|
|
|
|
|
|
|
# Replay the history since otherwise we miss devices
|
|
|
|
# that were already discovered before the callback was registered
|
|
|
|
# or we are in passive mode
|
2022-07-11 15:14:00 +00:00
|
|
|
for device, advertisement_data in self.history.values():
|
2022-07-08 23:55:31 +00:00
|
|
|
_dispatch_callback(callback, filters, device, advertisement_data)
|
|
|
|
|
|
|
|
return _remove_callback
|
|
|
|
|
|
|
|
def async_callback_dispatcher(
|
|
|
|
self, device: BLEDevice, advertisement_data: AdvertisementData
|
|
|
|
) -> None:
|
|
|
|
"""Dispatch the callback.
|
|
|
|
|
|
|
|
Here we get the actual callback from bleak and dispatch
|
|
|
|
it to all the wrapped HaBleakScannerWrapper classes
|
|
|
|
"""
|
2022-07-22 00:16:45 +00:00
|
|
|
self.history[device.address] = (device, advertisement_data)
|
2022-07-08 23:55:31 +00:00
|
|
|
for callback_filters in self._callbacks:
|
|
|
|
_dispatch_callback(*callback_filters, device, advertisement_data)
|
|
|
|
|
|
|
|
|
2022-07-30 00:53:33 +00:00
|
|
|
class HaBleakScannerWrapper(BaseBleakScanner):
|
2022-07-08 23:55:31 +00:00
|
|
|
"""A wrapper that uses the single instance."""
|
|
|
|
|
2022-07-30 00:53:33 +00:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
*args: Any,
|
|
|
|
detection_callback: AdvertisementDataCallback | None = None,
|
|
|
|
service_uuids: list[str] | None = None,
|
|
|
|
**kwargs: Any,
|
|
|
|
) -> None:
|
2022-07-08 23:55:31 +00:00
|
|
|
"""Initialize the BleakScanner."""
|
|
|
|
self._detection_cancel: CALLBACK_TYPE | None = None
|
|
|
|
self._mapped_filters: dict[str, set[str]] = {}
|
2022-07-11 15:14:00 +00:00
|
|
|
self._adv_data_callback: AdvertisementDataCallback | None = None
|
2022-07-30 00:53:33 +00:00
|
|
|
remapped_kwargs = {
|
|
|
|
"detection_callback": detection_callback,
|
|
|
|
"service_uuids": service_uuids or [],
|
|
|
|
**kwargs,
|
|
|
|
}
|
|
|
|
self._map_filters(*args, **remapped_kwargs)
|
|
|
|
super().__init__(
|
|
|
|
detection_callback=detection_callback, service_uuids=service_uuids or []
|
|
|
|
)
|
2022-07-08 23:55:31 +00:00
|
|
|
|
|
|
|
async def stop(self, *args: Any, **kwargs: Any) -> None:
|
|
|
|
"""Stop scanning for devices."""
|
|
|
|
|
|
|
|
async def start(self, *args: Any, **kwargs: Any) -> None:
|
|
|
|
"""Start scanning for devices."""
|
2022-07-11 15:14:00 +00:00
|
|
|
|
|
|
|
def _map_filters(self, *args: Any, **kwargs: Any) -> bool:
|
|
|
|
"""Map the filters."""
|
|
|
|
mapped_filters = {}
|
|
|
|
if filters := kwargs.get("filters"):
|
2022-07-17 21:13:12 +00:00
|
|
|
if filter_uuids := filters.get(FILTER_UUIDS):
|
|
|
|
mapped_filters[FILTER_UUIDS] = set(filter_uuids)
|
|
|
|
else:
|
2022-07-11 15:14:00 +00:00
|
|
|
_LOGGER.warning("Only %s filters are supported", FILTER_UUIDS)
|
|
|
|
if service_uuids := kwargs.get("service_uuids"):
|
|
|
|
mapped_filters[FILTER_UUIDS] = set(service_uuids)
|
|
|
|
if mapped_filters == self._mapped_filters:
|
|
|
|
return False
|
|
|
|
self._mapped_filters = mapped_filters
|
|
|
|
return True
|
|
|
|
|
|
|
|
def set_scanning_filter(self, *args: Any, **kwargs: Any) -> None:
|
|
|
|
"""Set the filters to use."""
|
|
|
|
if self._map_filters(*args, **kwargs):
|
|
|
|
self._setup_detection_callback()
|
2022-07-08 23:55:31 +00:00
|
|
|
|
|
|
|
def _cancel_callback(self) -> None:
|
|
|
|
"""Cancel callback."""
|
|
|
|
if self._detection_cancel:
|
|
|
|
self._detection_cancel()
|
|
|
|
self._detection_cancel = None
|
|
|
|
|
|
|
|
@property
|
|
|
|
def discovered_devices(self) -> list[BLEDevice]:
|
|
|
|
"""Return a list of discovered devices."""
|
|
|
|
assert HA_BLEAK_SCANNER is not None
|
2022-07-30 00:53:33 +00:00
|
|
|
return HA_BLEAK_SCANNER.discovered_devices
|
2022-07-08 23:55:31 +00:00
|
|
|
|
2022-07-30 00:53:33 +00:00
|
|
|
def register_detection_callback(
|
|
|
|
self, callback: AdvertisementDataCallback | None
|
|
|
|
) -> None:
|
2022-07-08 23:55:31 +00:00
|
|
|
"""Register a callback that is called when a device is discovered or has a property changed.
|
|
|
|
|
|
|
|
This method takes the callback and registers it with the long running
|
|
|
|
scanner.
|
|
|
|
"""
|
2022-07-11 15:14:00 +00:00
|
|
|
self._adv_data_callback = callback
|
|
|
|
self._setup_detection_callback()
|
|
|
|
|
|
|
|
def _setup_detection_callback(self) -> None:
|
|
|
|
"""Set up the detection callback."""
|
|
|
|
if self._adv_data_callback is None:
|
|
|
|
return
|
2022-07-08 23:55:31 +00:00
|
|
|
self._cancel_callback()
|
2022-07-11 15:14:00 +00:00
|
|
|
super().register_detection_callback(self._adv_data_callback)
|
2022-07-08 23:55:31 +00:00
|
|
|
assert HA_BLEAK_SCANNER is not None
|
2022-07-30 00:53:33 +00:00
|
|
|
assert self._callback is not None
|
2022-07-08 23:55:31 +00:00
|
|
|
self._detection_cancel = HA_BLEAK_SCANNER.async_register_callback(
|
|
|
|
self._callback, self._mapped_filters
|
|
|
|
)
|
|
|
|
|
|
|
|
def __del__(self) -> None:
|
|
|
|
"""Delete the BleakScanner."""
|
|
|
|
if self._detection_cancel:
|
|
|
|
# Nothing to do if event loop is already closed
|
|
|
|
with contextlib.suppress(RuntimeError):
|
|
|
|
asyncio.get_running_loop().call_soon_threadsafe(self._detection_cancel)
|