2022-11-20 14:44:28 +00:00
|
|
|
"""Base classes for HA Bluetooth scanners for bluetooth."""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
2022-12-08 16:50:36 +00:00
|
|
|
from abc import ABC, abstractmethod
|
2022-11-22 02:23:07 +00:00
|
|
|
from collections.abc import Callable, Generator
|
|
|
|
from contextlib import contextmanager
|
2023-01-09 00:06:32 +00:00
|
|
|
from dataclasses import dataclass
|
2022-11-20 14:44:28 +00:00
|
|
|
import datetime
|
|
|
|
from datetime import timedelta
|
2022-12-13 21:57:40 +00:00
|
|
|
import logging
|
2022-11-20 14:44:28 +00:00
|
|
|
from typing import Any, Final
|
|
|
|
|
|
|
|
from bleak.backends.device import BLEDevice
|
|
|
|
from bleak.backends.scanner import AdvertisementData
|
|
|
|
from bleak_retry_connector import NO_RSSI_VALUE
|
2022-12-11 19:02:55 +00:00
|
|
|
from bluetooth_adapters import DiscoveredDeviceAdvertisementData, adapter_human_name
|
2022-11-20 14:44:28 +00:00
|
|
|
from home_assistant_bluetooth import BluetoothServiceInfoBleak
|
|
|
|
|
2022-12-11 19:02:55 +00:00
|
|
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
|
|
|
from homeassistant.core import (
|
|
|
|
CALLBACK_TYPE,
|
|
|
|
Event,
|
|
|
|
HomeAssistant,
|
|
|
|
callback as hass_callback,
|
|
|
|
)
|
2022-11-20 14:44:28 +00:00
|
|
|
from homeassistant.helpers.event import async_track_time_interval
|
2022-12-11 19:02:55 +00:00
|
|
|
import homeassistant.util.dt as dt_util
|
2022-11-20 14:44:28 +00:00
|
|
|
from homeassistant.util.dt import monotonic_time_coarse
|
|
|
|
|
2022-12-11 19:02:55 +00:00
|
|
|
from . import models
|
2022-11-20 14:44:28 +00:00
|
|
|
from .const import (
|
|
|
|
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
|
2022-12-13 21:57:40 +00:00
|
|
|
SCANNER_WATCHDOG_INTERVAL,
|
|
|
|
SCANNER_WATCHDOG_TIMEOUT,
|
2022-11-20 14:44:28 +00:00
|
|
|
)
|
|
|
|
from .models import HaBluetoothConnector
|
|
|
|
|
|
|
|
MONOTONIC_TIME: Final = monotonic_time_coarse
|
2022-12-13 21:57:40 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
2022-11-20 14:44:28 +00:00
|
|
|
|
|
|
|
|
2023-04-14 18:22:39 +00:00
|
|
|
@dataclass(slots=True)
|
2023-01-09 00:06:32 +00:00
|
|
|
class BluetoothScannerDevice:
|
|
|
|
"""Data for a bluetooth device from a given scanner."""
|
|
|
|
|
|
|
|
scanner: BaseHaScanner
|
|
|
|
ble_device: BLEDevice
|
|
|
|
advertisement: AdvertisementData
|
|
|
|
|
|
|
|
|
2022-12-08 16:50:36 +00:00
|
|
|
class BaseHaScanner(ABC):
|
2022-11-20 14:44:28 +00:00
|
|
|
"""Base class for Ha Scanners."""
|
|
|
|
|
2022-12-11 19:02:55 +00:00
|
|
|
__slots__ = (
|
|
|
|
"hass",
|
2022-12-23 18:58:33 +00:00
|
|
|
"adapter",
|
2022-12-11 19:02:55 +00:00
|
|
|
"connectable",
|
|
|
|
"source",
|
|
|
|
"connector",
|
|
|
|
"_connecting",
|
|
|
|
"name",
|
|
|
|
"scanning",
|
2022-12-12 07:33:30 +00:00
|
|
|
"_last_detection",
|
2022-12-13 21:57:40 +00:00
|
|
|
"_start_time",
|
|
|
|
"_cancel_watchdog",
|
2022-12-11 19:02:55 +00:00
|
|
|
)
|
2022-11-22 02:23:07 +00:00
|
|
|
|
2022-12-12 07:33:30 +00:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
hass: HomeAssistant,
|
|
|
|
source: str,
|
|
|
|
adapter: str,
|
|
|
|
connector: HaBluetoothConnector | None = None,
|
|
|
|
) -> None:
|
2022-11-20 14:44:28 +00:00
|
|
|
"""Initialize the scanner."""
|
|
|
|
self.hass = hass
|
2022-12-11 19:02:55 +00:00
|
|
|
self.connectable = False
|
2022-11-20 14:44:28 +00:00
|
|
|
self.source = source
|
2022-12-12 07:33:30 +00:00
|
|
|
self.connector = connector
|
2022-11-22 02:23:07 +00:00
|
|
|
self._connecting = 0
|
2022-12-23 18:58:33 +00:00
|
|
|
self.adapter = adapter
|
2022-11-22 02:23:07 +00:00
|
|
|
self.name = adapter_human_name(adapter, source) if adapter != source else source
|
|
|
|
self.scanning = True
|
2022-12-12 07:33:30 +00:00
|
|
|
self._last_detection = 0.0
|
2022-12-13 21:57:40 +00:00
|
|
|
self._start_time = 0.0
|
|
|
|
self._cancel_watchdog: CALLBACK_TYPE | None = None
|
|
|
|
|
|
|
|
@hass_callback
|
|
|
|
def _async_stop_scanner_watchdog(self) -> None:
|
|
|
|
"""Stop the scanner watchdog."""
|
|
|
|
if self._cancel_watchdog:
|
|
|
|
self._cancel_watchdog()
|
|
|
|
self._cancel_watchdog = None
|
|
|
|
|
|
|
|
@hass_callback
|
|
|
|
def _async_setup_scanner_watchdog(self) -> None:
|
|
|
|
"""If something has restarted or updated, we need to restart the scanner."""
|
|
|
|
self._start_time = self._last_detection = MONOTONIC_TIME()
|
|
|
|
if not self._cancel_watchdog:
|
|
|
|
self._cancel_watchdog = async_track_time_interval(
|
2023-03-25 14:11:14 +00:00
|
|
|
self.hass,
|
|
|
|
self._async_scanner_watchdog,
|
|
|
|
SCANNER_WATCHDOG_INTERVAL,
|
2023-04-05 14:58:02 +00:00
|
|
|
name=f"{self.name} Bluetooth scanner watchdog",
|
2022-12-13 21:57:40 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
@hass_callback
|
|
|
|
def _async_watchdog_triggered(self) -> bool:
|
|
|
|
"""Check if the watchdog has been triggered."""
|
|
|
|
time_since_last_detection = MONOTONIC_TIME() - self._last_detection
|
|
|
|
_LOGGER.debug(
|
|
|
|
"%s: Scanner watchdog time_since_last_detection: %s",
|
|
|
|
self.name,
|
|
|
|
time_since_last_detection,
|
|
|
|
)
|
|
|
|
return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT
|
|
|
|
|
2022-12-28 02:00:24 +00:00
|
|
|
@hass_callback
|
|
|
|
def _async_scanner_watchdog(self, now: datetime.datetime) -> None:
|
2022-12-13 21:57:40 +00:00
|
|
|
"""Check if the scanner is running.
|
|
|
|
|
2023-01-08 21:20:02 +00:00
|
|
|
Override this method if you need to do something else when the watchdog
|
|
|
|
is triggered.
|
2022-12-13 21:57:40 +00:00
|
|
|
"""
|
|
|
|
if self._async_watchdog_triggered():
|
|
|
|
_LOGGER.info(
|
2022-12-22 09:12:50 +00:00
|
|
|
(
|
|
|
|
"%s: Bluetooth scanner has gone quiet for %ss, check logs on the"
|
|
|
|
" scanner device for more information"
|
|
|
|
),
|
2022-12-13 21:57:40 +00:00
|
|
|
self.name,
|
|
|
|
SCANNER_WATCHDOG_TIMEOUT,
|
|
|
|
)
|
2022-11-22 02:23:07 +00:00
|
|
|
|
|
|
|
@contextmanager
|
|
|
|
def connecting(self) -> Generator[None, None, None]:
|
|
|
|
"""Context manager to track connecting state."""
|
|
|
|
self._connecting += 1
|
|
|
|
self.scanning = not self._connecting
|
|
|
|
try:
|
|
|
|
yield
|
|
|
|
finally:
|
|
|
|
self._connecting -= 1
|
|
|
|
self.scanning = not self._connecting
|
2022-11-20 14:44:28 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
@abstractmethod
|
|
|
|
def discovered_devices(self) -> list[BLEDevice]:
|
|
|
|
"""Return a list of discovered devices."""
|
|
|
|
|
|
|
|
@property
|
|
|
|
@abstractmethod
|
|
|
|
def discovered_devices_and_advertisement_data(
|
|
|
|
self,
|
|
|
|
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
|
|
|
|
"""Return a list of discovered devices and their advertisement data."""
|
|
|
|
|
|
|
|
async def async_diagnostics(self) -> dict[str, Any]:
|
|
|
|
"""Return diagnostic information about the scanner."""
|
2023-01-08 21:20:02 +00:00
|
|
|
device_adv_datas = self.discovered_devices_and_advertisement_data.values()
|
2022-11-20 14:44:28 +00:00
|
|
|
return {
|
2022-12-12 07:33:30 +00:00
|
|
|
"name": self.name,
|
2022-12-13 21:57:40 +00:00
|
|
|
"start_time": self._start_time,
|
2022-12-12 07:33:30 +00:00
|
|
|
"source": self.source,
|
|
|
|
"scanning": self.scanning,
|
2022-11-20 14:44:28 +00:00
|
|
|
"type": self.__class__.__name__,
|
2022-12-12 07:33:30 +00:00
|
|
|
"last_detection": self._last_detection,
|
|
|
|
"monotonic_time": MONOTONIC_TIME(),
|
2022-11-27 19:59:37 +00:00
|
|
|
"discovered_devices_and_advertisement_data": [
|
2022-11-20 14:44:28 +00:00
|
|
|
{
|
2023-03-20 11:06:15 +00:00
|
|
|
"name": device.name,
|
|
|
|
"address": device.address,
|
|
|
|
"rssi": advertisement_data.rssi,
|
|
|
|
"advertisement_data": advertisement_data,
|
|
|
|
"details": device.details,
|
2022-11-20 14:44:28 +00:00
|
|
|
}
|
2023-03-20 11:06:15 +00:00
|
|
|
for device, advertisement_data in device_adv_datas
|
2022-11-20 14:44:28 +00:00
|
|
|
],
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class BaseHaRemoteScanner(BaseHaScanner):
|
|
|
|
"""Base class for a Home Assistant remote BLE scanner."""
|
|
|
|
|
2022-11-22 02:23:07 +00:00
|
|
|
__slots__ = (
|
|
|
|
"_new_info_callback",
|
|
|
|
"_discovered_device_advertisement_datas",
|
|
|
|
"_discovered_device_timestamps",
|
|
|
|
"_details",
|
|
|
|
"_expire_seconds",
|
2022-12-11 19:02:55 +00:00
|
|
|
"_storage",
|
2022-11-22 02:23:07 +00:00
|
|
|
)
|
|
|
|
|
2022-11-20 14:44:28 +00:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
hass: HomeAssistant,
|
|
|
|
scanner_id: str,
|
2022-11-22 02:23:07 +00:00
|
|
|
name: str,
|
2022-11-20 14:44:28 +00:00
|
|
|
new_info_callback: Callable[[BluetoothServiceInfoBleak], None],
|
2022-12-30 16:49:37 +00:00
|
|
|
connector: HaBluetoothConnector | None,
|
2022-11-20 14:44:28 +00:00
|
|
|
connectable: bool,
|
|
|
|
) -> None:
|
|
|
|
"""Initialize the scanner."""
|
2022-12-12 07:33:30 +00:00
|
|
|
super().__init__(hass, scanner_id, name, connector)
|
2022-11-20 14:44:28 +00:00
|
|
|
self._new_info_callback = new_info_callback
|
|
|
|
self._discovered_device_advertisement_datas: dict[
|
|
|
|
str, tuple[BLEDevice, AdvertisementData]
|
|
|
|
] = {}
|
|
|
|
self._discovered_device_timestamps: dict[str, float] = {}
|
2022-12-11 19:02:55 +00:00
|
|
|
self.connectable = connectable
|
2022-11-20 14:44:28 +00:00
|
|
|
self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id}
|
2023-01-11 22:11:25 +00:00
|
|
|
# Scanners only care about connectable devices. The manager
|
|
|
|
# will handle taking care of availability for non-connectable devices
|
|
|
|
self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
|
2022-12-11 19:02:55 +00:00
|
|
|
assert models.MANAGER is not None
|
|
|
|
self._storage = models.MANAGER.storage
|
2022-11-20 14:44:28 +00:00
|
|
|
|
|
|
|
@hass_callback
|
|
|
|
def async_setup(self) -> CALLBACK_TYPE:
|
|
|
|
"""Set up the scanner."""
|
2022-12-11 19:02:55 +00:00
|
|
|
if history := self._storage.async_get_advertisement_history(self.source):
|
|
|
|
self._discovered_device_advertisement_datas = (
|
|
|
|
history.discovered_device_advertisement_datas
|
|
|
|
)
|
|
|
|
self._discovered_device_timestamps = history.discovered_device_timestamps
|
|
|
|
# Expire anything that is too old
|
|
|
|
self._async_expire_devices(dt_util.utcnow())
|
|
|
|
|
|
|
|
cancel_track = async_track_time_interval(
|
2023-03-25 14:11:14 +00:00
|
|
|
self.hass,
|
|
|
|
self._async_expire_devices,
|
|
|
|
timedelta(seconds=30),
|
2023-04-05 14:58:02 +00:00
|
|
|
name=f"{self.name} Bluetooth scanner device expire",
|
2022-11-20 14:44:28 +00:00
|
|
|
)
|
2022-12-11 19:02:55 +00:00
|
|
|
cancel_stop = self.hass.bus.async_listen(
|
2023-03-08 14:27:34 +00:00
|
|
|
EVENT_HOMEASSISTANT_STOP, self._async_save_history
|
2022-12-11 19:02:55 +00:00
|
|
|
)
|
2022-12-13 21:57:40 +00:00
|
|
|
self._async_setup_scanner_watchdog()
|
2022-12-11 19:02:55 +00:00
|
|
|
|
|
|
|
@hass_callback
|
|
|
|
def _cancel() -> None:
|
2023-03-08 14:27:34 +00:00
|
|
|
self._async_save_history()
|
2022-12-13 21:57:40 +00:00
|
|
|
self._async_stop_scanner_watchdog()
|
2022-12-11 19:02:55 +00:00
|
|
|
cancel_track()
|
|
|
|
cancel_stop()
|
|
|
|
|
|
|
|
return _cancel
|
|
|
|
|
2023-03-08 14:27:34 +00:00
|
|
|
@hass_callback
|
|
|
|
def _async_save_history(self, event: Event | None = None) -> None:
|
2022-12-11 19:02:55 +00:00
|
|
|
"""Save the history."""
|
|
|
|
self._storage.async_set_advertisement_history(
|
|
|
|
self.source,
|
|
|
|
DiscoveredDeviceAdvertisementData(
|
|
|
|
self.connectable,
|
|
|
|
self._expire_seconds,
|
|
|
|
self._discovered_device_advertisement_datas,
|
|
|
|
self._discovered_device_timestamps,
|
|
|
|
),
|
|
|
|
)
|
2022-11-20 14:44:28 +00:00
|
|
|
|
2023-03-08 14:27:34 +00:00
|
|
|
@hass_callback
|
2022-11-20 14:44:28 +00:00
|
|
|
def _async_expire_devices(self, _datetime: datetime.datetime) -> None:
|
|
|
|
"""Expire old devices."""
|
|
|
|
now = MONOTONIC_TIME()
|
|
|
|
expired = [
|
|
|
|
address
|
|
|
|
for address, timestamp in self._discovered_device_timestamps.items()
|
|
|
|
if now - timestamp > self._expire_seconds
|
|
|
|
]
|
|
|
|
for address in expired:
|
|
|
|
del self._discovered_device_advertisement_datas[address]
|
|
|
|
del self._discovered_device_timestamps[address]
|
|
|
|
|
|
|
|
@property
|
|
|
|
def discovered_devices(self) -> list[BLEDevice]:
|
|
|
|
"""Return a list of discovered devices."""
|
2023-01-08 21:20:02 +00:00
|
|
|
device_adv_datas = self._discovered_device_advertisement_datas.values()
|
2022-11-20 14:44:28 +00:00
|
|
|
return [
|
|
|
|
device_advertisement_data[0]
|
2023-01-08 21:20:02 +00:00
|
|
|
for device_advertisement_data in device_adv_datas
|
2022-11-20 14:44:28 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
@property
|
|
|
|
def discovered_devices_and_advertisement_data(
|
|
|
|
self,
|
|
|
|
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
|
|
|
|
"""Return a list of discovered devices and advertisement data."""
|
|
|
|
return self._discovered_device_advertisement_datas
|
|
|
|
|
|
|
|
@hass_callback
|
|
|
|
def _async_on_advertisement(
|
|
|
|
self,
|
|
|
|
address: str,
|
|
|
|
rssi: int,
|
|
|
|
local_name: str | None,
|
|
|
|
service_uuids: list[str],
|
|
|
|
service_data: dict[str, bytes],
|
|
|
|
manufacturer_data: dict[int, bytes],
|
|
|
|
tx_power: int | None,
|
2022-11-29 04:54:53 +00:00
|
|
|
details: dict[Any, Any],
|
2022-11-20 14:44:28 +00:00
|
|
|
) -> None:
|
|
|
|
"""Call the registered callback."""
|
|
|
|
now = MONOTONIC_TIME()
|
2022-12-12 07:33:30 +00:00
|
|
|
self._last_detection = now
|
2022-11-20 14:44:28 +00:00
|
|
|
if prev_discovery := self._discovered_device_advertisement_datas.get(address):
|
|
|
|
# Merge the new data with the old data
|
|
|
|
# to function the same as BlueZ which
|
|
|
|
# merges the dicts on PropertiesChanged
|
|
|
|
prev_device = prev_discovery[0]
|
|
|
|
prev_advertisement = prev_discovery[1]
|
2023-04-26 09:16:34 +00:00
|
|
|
prev_service_uuids = prev_advertisement.service_uuids
|
|
|
|
prev_service_data = prev_advertisement.service_data
|
|
|
|
prev_manufacturer_data = prev_advertisement.manufacturer_data
|
|
|
|
prev_name = prev_device.name
|
|
|
|
|
|
|
|
if local_name and prev_name and len(prev_name) > len(local_name):
|
|
|
|
local_name = prev_name
|
|
|
|
|
|
|
|
if service_uuids and service_uuids != prev_service_uuids:
|
|
|
|
service_uuids = list(set(service_uuids + prev_service_uuids))
|
2023-01-06 01:22:14 +00:00
|
|
|
elif not service_uuids:
|
2023-04-26 09:16:34 +00:00
|
|
|
service_uuids = prev_service_uuids
|
|
|
|
|
|
|
|
if service_data and service_data != prev_service_data:
|
|
|
|
service_data = prev_service_data | service_data
|
2023-01-06 01:22:14 +00:00
|
|
|
elif not service_data:
|
2023-04-26 09:16:34 +00:00
|
|
|
service_data = prev_service_data
|
2022-11-20 14:44:28 +00:00
|
|
|
|
2023-04-26 09:16:34 +00:00
|
|
|
if manufacturer_data and manufacturer_data != prev_manufacturer_data:
|
|
|
|
manufacturer_data = prev_manufacturer_data | manufacturer_data
|
|
|
|
elif not manufacturer_data:
|
|
|
|
manufacturer_data = prev_manufacturer_data
|
2023-04-05 10:19:37 +00:00
|
|
|
#
|
|
|
|
# Bleak updates the BLEDevice via create_or_update_device.
|
|
|
|
# We need to do the same to ensure integrations that already
|
|
|
|
# have the BLEDevice object get the updated details when they
|
|
|
|
# change.
|
|
|
|
#
|
|
|
|
# https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203
|
|
|
|
#
|
|
|
|
device = prev_device
|
|
|
|
device.name = local_name
|
|
|
|
device.details = self._details | details
|
|
|
|
# pylint: disable-next=protected-access
|
|
|
|
device._rssi = rssi # deprecated, will be removed in newer bleak
|
|
|
|
else:
|
|
|
|
device = BLEDevice(
|
|
|
|
address=address,
|
|
|
|
name=local_name,
|
|
|
|
details=self._details | details,
|
|
|
|
rssi=rssi, # deprecated, will be removed in newer bleak
|
|
|
|
)
|
2023-04-26 09:16:34 +00:00
|
|
|
|
|
|
|
advertisement_data = AdvertisementData(
|
|
|
|
local_name=None if local_name == "" else local_name,
|
|
|
|
manufacturer_data=manufacturer_data,
|
|
|
|
service_data=service_data,
|
|
|
|
service_uuids=service_uuids,
|
|
|
|
tx_power=NO_RSSI_VALUE if tx_power is None else tx_power,
|
|
|
|
rssi=rssi,
|
|
|
|
platform_data=(),
|
|
|
|
)
|
2022-11-20 14:44:28 +00:00
|
|
|
self._discovered_device_advertisement_datas[address] = (
|
|
|
|
device,
|
|
|
|
advertisement_data,
|
|
|
|
)
|
|
|
|
self._discovered_device_timestamps[address] = now
|
|
|
|
self._new_info_callback(
|
|
|
|
BluetoothServiceInfoBleak(
|
2023-04-26 09:16:34 +00:00
|
|
|
name=local_name or address,
|
|
|
|
address=address,
|
2022-11-20 14:44:28 +00:00
|
|
|
rssi=rssi,
|
2023-04-26 09:16:34 +00:00
|
|
|
manufacturer_data=manufacturer_data,
|
|
|
|
service_data=service_data,
|
|
|
|
service_uuids=service_uuids,
|
2022-11-20 14:44:28 +00:00
|
|
|
source=self.source,
|
|
|
|
device=device,
|
|
|
|
advertisement=advertisement_data,
|
2022-12-11 19:02:55 +00:00
|
|
|
connectable=self.connectable,
|
2022-11-20 14:44:28 +00:00
|
|
|
time=now,
|
|
|
|
)
|
|
|
|
)
|
2022-12-09 15:55:10 +00:00
|
|
|
|
|
|
|
async def async_diagnostics(self) -> dict[str, Any]:
|
|
|
|
"""Return diagnostic information about the scanner."""
|
2022-12-12 07:33:30 +00:00
|
|
|
now = MONOTONIC_TIME()
|
2022-12-09 15:55:10 +00:00
|
|
|
return await super().async_diagnostics() | {
|
2022-12-12 07:33:30 +00:00
|
|
|
"storage": self._storage.async_get_advertisement_history_as_dict(
|
|
|
|
self.source
|
|
|
|
),
|
|
|
|
"connectable": self.connectable,
|
|
|
|
"discovered_device_timestamps": self._discovered_device_timestamps,
|
|
|
|
"time_since_last_device_detection": {
|
|
|
|
address: now - timestamp
|
|
|
|
for address, timestamp in self._discovered_device_timestamps.items()
|
|
|
|
},
|
2022-12-09 15:55:10 +00:00
|
|
|
}
|