2022-07-08 23:55:31 +00:00
|
|
|
"""The bluetooth integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
2022-07-28 21:14:13 +00:00
|
|
|
import asyncio
|
2022-07-28 18:10:37 +00:00
|
|
|
from asyncio import Future
|
2022-07-08 23:55:31 +00:00
|
|
|
from collections.abc import Callable
|
2022-07-18 22:58:08 +00:00
|
|
|
from dataclasses import dataclass
|
2022-07-22 00:16:45 +00:00
|
|
|
from datetime import datetime, timedelta
|
2022-07-08 23:55:31 +00:00
|
|
|
from enum import Enum
|
|
|
|
import logging
|
2022-08-01 15:54:06 +00:00
|
|
|
from typing import TYPE_CHECKING, Final
|
2022-07-08 23:55:31 +00:00
|
|
|
|
2022-07-28 18:10:37 +00:00
|
|
|
import async_timeout
|
2022-07-08 23:55:31 +00:00
|
|
|
from bleak import BleakError
|
2022-08-02 23:46:43 +00:00
|
|
|
from dbus_next import InvalidMessageError
|
2022-07-08 23:55:31 +00:00
|
|
|
|
|
|
|
from homeassistant import config_entries
|
|
|
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
|
|
|
from homeassistant.core import (
|
|
|
|
CALLBACK_TYPE,
|
|
|
|
Event,
|
|
|
|
HomeAssistant,
|
|
|
|
callback as hass_callback,
|
|
|
|
)
|
2022-07-23 17:00:34 +00:00
|
|
|
from homeassistant.exceptions import ConfigEntryNotReady
|
2022-07-08 23:55:31 +00:00
|
|
|
from homeassistant.helpers import discovery_flow
|
2022-07-22 00:16:45 +00:00
|
|
|
from homeassistant.helpers.event import async_track_time_interval
|
2022-07-14 18:36:54 +00:00
|
|
|
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
|
2022-07-24 21:39:53 +00:00
|
|
|
from homeassistant.loader import async_get_bluetooth
|
2022-08-02 23:46:43 +00:00
|
|
|
from homeassistant.util.package import is_docker_env
|
2022-07-08 23:55:31 +00:00
|
|
|
|
|
|
|
from . import models
|
2022-07-25 14:52:35 +00:00
|
|
|
from .const import CONF_ADAPTER, DEFAULT_ADAPTERS, DOMAIN
|
2022-07-24 21:39:53 +00:00
|
|
|
from .match import (
|
|
|
|
ADDRESS,
|
|
|
|
BluetoothCallbackMatcher,
|
|
|
|
IntegrationMatcher,
|
|
|
|
ble_device_matches,
|
|
|
|
)
|
2022-07-22 23:12:08 +00:00
|
|
|
from .models import HaBleakScanner, HaBleakScannerWrapper
|
2022-07-22 18:19:53 +00:00
|
|
|
from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher
|
2022-07-25 14:52:35 +00:00
|
|
|
from .util import async_get_bluetooth_adapters
|
2022-07-08 23:55:31 +00:00
|
|
|
|
2022-08-01 15:54:06 +00:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
from bleak.backends.device import BLEDevice
|
|
|
|
from bleak.backends.scanner import AdvertisementData
|
|
|
|
|
|
|
|
from homeassistant.helpers.typing import ConfigType
|
|
|
|
|
|
|
|
|
2022-07-08 23:55:31 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
2022-07-22 00:16:45 +00:00
|
|
|
UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5
|
2022-08-02 06:46:22 +00:00
|
|
|
START_TIMEOUT = 9
|
2022-07-22 00:16:45 +00:00
|
|
|
|
2022-07-16 16:02:08 +00:00
|
|
|
SOURCE_LOCAL: Final = "local"
|
|
|
|
|
2022-07-08 23:55:31 +00:00
|
|
|
|
2022-07-18 22:58:08 +00:00
|
|
|
@dataclass
|
2022-07-20 03:46:18 +00:00
|
|
|
class BluetoothServiceInfoBleak(BluetoothServiceInfo):
|
2022-07-18 22:58:08 +00:00
|
|
|
"""BluetoothServiceInfo with bleak data.
|
|
|
|
|
|
|
|
Integrations may need BLEDevice and AdvertisementData
|
|
|
|
to connect to the device without having bleak trigger
|
|
|
|
another scan to translate the address to the system's
|
|
|
|
internal details.
|
|
|
|
"""
|
|
|
|
|
|
|
|
device: BLEDevice
|
|
|
|
advertisement: AdvertisementData
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def from_advertisement(
|
|
|
|
cls, device: BLEDevice, advertisement_data: AdvertisementData, source: str
|
2022-07-20 03:46:18 +00:00
|
|
|
) -> BluetoothServiceInfoBleak:
|
2022-07-18 22:58:08 +00:00
|
|
|
"""Create a BluetoothServiceInfoBleak from an advertisement."""
|
|
|
|
return cls(
|
|
|
|
name=advertisement_data.local_name or device.name or device.address,
|
|
|
|
address=device.address,
|
|
|
|
rssi=device.rssi,
|
|
|
|
manufacturer_data=advertisement_data.manufacturer_data,
|
|
|
|
service_data=advertisement_data.service_data,
|
|
|
|
service_uuids=advertisement_data.service_uuids,
|
|
|
|
source=source,
|
|
|
|
device=device,
|
|
|
|
advertisement=advertisement_data,
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2022-07-08 23:55:31 +00:00
|
|
|
class BluetoothScanningMode(Enum):
|
|
|
|
"""The mode of scanning for bluetooth devices."""
|
|
|
|
|
|
|
|
PASSIVE = "passive"
|
|
|
|
ACTIVE = "active"
|
|
|
|
|
|
|
|
|
|
|
|
SCANNING_MODE_TO_BLEAK = {
|
|
|
|
BluetoothScanningMode.ACTIVE: "active",
|
|
|
|
BluetoothScanningMode.PASSIVE: "passive",
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT")
|
2022-07-30 00:53:33 +00:00
|
|
|
BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None]
|
|
|
|
ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool]
|
2022-07-08 23:55:31 +00:00
|
|
|
|
|
|
|
|
2022-07-22 18:19:53 +00:00
|
|
|
@hass_callback
|
2022-07-22 23:12:08 +00:00
|
|
|
def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper:
|
|
|
|
"""Return a HaBleakScannerWrapper.
|
|
|
|
|
|
|
|
This is a wrapper around our BleakScanner singleton that allows
|
|
|
|
multiple integrations to share the same BleakScanner.
|
|
|
|
"""
|
2022-07-22 18:19:53 +00:00
|
|
|
if DOMAIN not in hass.data:
|
|
|
|
raise RuntimeError("Bluetooth integration not loaded")
|
|
|
|
manager: BluetoothManager = hass.data[DOMAIN]
|
|
|
|
return manager.async_get_scanner()
|
|
|
|
|
|
|
|
|
2022-07-11 15:14:00 +00:00
|
|
|
@hass_callback
|
|
|
|
def async_discovered_service_info(
|
|
|
|
hass: HomeAssistant,
|
2022-07-18 22:58:08 +00:00
|
|
|
) -> list[BluetoothServiceInfoBleak]:
|
2022-07-11 15:14:00 +00:00
|
|
|
"""Return the discovered devices list."""
|
|
|
|
if DOMAIN not in hass.data:
|
|
|
|
return []
|
|
|
|
manager: BluetoothManager = hass.data[DOMAIN]
|
|
|
|
return manager.async_discovered_service_info()
|
|
|
|
|
|
|
|
|
2022-07-18 22:58:08 +00:00
|
|
|
@hass_callback
|
|
|
|
def async_ble_device_from_address(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
address: str,
|
|
|
|
) -> BLEDevice | None:
|
|
|
|
"""Return BLEDevice for an address if its present."""
|
|
|
|
if DOMAIN not in hass.data:
|
|
|
|
return None
|
|
|
|
manager: BluetoothManager = hass.data[DOMAIN]
|
|
|
|
return manager.async_ble_device_from_address(address)
|
|
|
|
|
|
|
|
|
2022-07-11 15:14:00 +00:00
|
|
|
@hass_callback
|
|
|
|
def async_address_present(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
address: str,
|
|
|
|
) -> bool:
|
|
|
|
"""Check if an address is present in the bluetooth device list."""
|
|
|
|
if DOMAIN not in hass.data:
|
|
|
|
return False
|
|
|
|
manager: BluetoothManager = hass.data[DOMAIN]
|
|
|
|
return manager.async_address_present(address)
|
|
|
|
|
|
|
|
|
2022-07-08 23:55:31 +00:00
|
|
|
@hass_callback
|
|
|
|
def async_register_callback(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
callback: BluetoothCallback,
|
2022-07-11 15:14:00 +00:00
|
|
|
match_dict: BluetoothCallbackMatcher | None,
|
2022-07-30 00:53:33 +00:00
|
|
|
mode: BluetoothScanningMode,
|
2022-07-08 23:55:31 +00:00
|
|
|
) -> Callable[[], None]:
|
|
|
|
"""Register to receive a callback on bluetooth change.
|
|
|
|
|
2022-07-30 00:53:33 +00:00
|
|
|
mode is currently not used as we only support active scanning.
|
|
|
|
Passive scanning will be available in the future. The flag
|
|
|
|
is required to be present to avoid a future breaking change
|
|
|
|
when we support passive scanning.
|
|
|
|
|
2022-07-08 23:55:31 +00:00
|
|
|
Returns a callback that can be used to cancel the registration.
|
|
|
|
"""
|
|
|
|
manager: BluetoothManager = hass.data[DOMAIN]
|
|
|
|
return manager.async_register_callback(callback, match_dict)
|
|
|
|
|
|
|
|
|
2022-07-28 18:10:37 +00:00
|
|
|
async def async_process_advertisements(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
callback: ProcessAdvertisementCallback,
|
|
|
|
match_dict: BluetoothCallbackMatcher,
|
2022-07-30 00:53:33 +00:00
|
|
|
mode: BluetoothScanningMode,
|
2022-07-28 18:10:37 +00:00
|
|
|
timeout: int,
|
2022-07-30 00:53:33 +00:00
|
|
|
) -> BluetoothServiceInfoBleak:
|
2022-07-28 18:10:37 +00:00
|
|
|
"""Process advertisements until callback returns true or timeout expires."""
|
2022-07-30 00:53:33 +00:00
|
|
|
done: Future[BluetoothServiceInfoBleak] = Future()
|
2022-07-28 18:10:37 +00:00
|
|
|
|
|
|
|
@hass_callback
|
|
|
|
def _async_discovered_device(
|
2022-07-30 00:53:33 +00:00
|
|
|
service_info: BluetoothServiceInfoBleak, change: BluetoothChange
|
2022-07-28 18:10:37 +00:00
|
|
|
) -> None:
|
2022-08-04 15:58:15 +00:00
|
|
|
if not done.done() and callback(service_info):
|
2022-07-28 18:10:37 +00:00
|
|
|
done.set_result(service_info)
|
|
|
|
|
2022-07-30 00:53:33 +00:00
|
|
|
unload = async_register_callback(hass, _async_discovered_device, match_dict, mode)
|
2022-07-28 18:10:37 +00:00
|
|
|
|
|
|
|
try:
|
|
|
|
async with async_timeout.timeout(timeout):
|
|
|
|
return await done
|
|
|
|
finally:
|
|
|
|
unload()
|
|
|
|
|
|
|
|
|
2022-07-22 00:16:45 +00:00
|
|
|
@hass_callback
|
|
|
|
def async_track_unavailable(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
callback: Callable[[str], None],
|
|
|
|
address: str,
|
|
|
|
) -> Callable[[], None]:
|
|
|
|
"""Register to receive a callback when an address is unavailable.
|
|
|
|
|
|
|
|
Returns a callback that can be used to cancel the registration.
|
|
|
|
"""
|
|
|
|
manager: BluetoothManager = hass.data[DOMAIN]
|
|
|
|
return manager.async_track_unavailable(callback, address)
|
|
|
|
|
|
|
|
|
2022-07-22 18:19:53 +00:00
|
|
|
async def _async_has_bluetooth_adapter() -> bool:
|
|
|
|
"""Return if the device has a bluetooth adapter."""
|
2022-07-25 14:52:35 +00:00
|
|
|
return bool(await async_get_bluetooth_adapters())
|
2022-07-22 18:19:53 +00:00
|
|
|
|
|
|
|
|
2022-07-08 23:55:31 +00:00
|
|
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|
|
|
"""Set up the bluetooth integration."""
|
2022-07-24 21:39:53 +00:00
|
|
|
integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass))
|
|
|
|
manager = BluetoothManager(hass, integration_matcher)
|
2022-07-22 18:19:53 +00:00
|
|
|
manager.async_setup()
|
|
|
|
hass.data[DOMAIN] = manager
|
|
|
|
# The config entry is responsible for starting the manager
|
|
|
|
# if its enabled
|
|
|
|
|
|
|
|
if hass.config_entries.async_entries(DOMAIN):
|
|
|
|
return True
|
|
|
|
if DOMAIN in config:
|
|
|
|
hass.async_create_task(
|
|
|
|
hass.config_entries.flow.async_init(
|
|
|
|
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={}
|
|
|
|
)
|
|
|
|
)
|
|
|
|
elif await _async_has_bluetooth_adapter():
|
|
|
|
hass.async_create_task(
|
|
|
|
hass.config_entries.flow.async_init(
|
|
|
|
DOMAIN,
|
|
|
|
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
|
|
|
|
data={},
|
|
|
|
)
|
|
|
|
)
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
async def async_setup_entry(
|
|
|
|
hass: HomeAssistant, entry: config_entries.ConfigEntry
|
|
|
|
) -> bool:
|
|
|
|
"""Set up the bluetooth integration from a config entry."""
|
|
|
|
manager: BluetoothManager = hass.data[DOMAIN]
|
2022-07-25 14:52:35 +00:00
|
|
|
await manager.async_start(
|
|
|
|
BluetoothScanningMode.ACTIVE, entry.options.get(CONF_ADAPTER)
|
|
|
|
)
|
|
|
|
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
2022-07-22 18:19:53 +00:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
2022-07-25 14:52:35 +00:00
|
|
|
async def _async_update_listener(
|
|
|
|
hass: HomeAssistant, entry: config_entries.ConfigEntry
|
|
|
|
) -> None:
|
|
|
|
"""Handle options update."""
|
|
|
|
manager: BluetoothManager = hass.data[DOMAIN]
|
|
|
|
manager.async_start_reload()
|
|
|
|
await hass.config_entries.async_reload(entry.entry_id)
|
|
|
|
|
|
|
|
|
2022-07-22 18:19:53 +00:00
|
|
|
async def async_unload_entry(
|
|
|
|
hass: HomeAssistant, entry: config_entries.ConfigEntry
|
|
|
|
) -> bool:
|
|
|
|
"""Unload a config entry."""
|
|
|
|
manager: BluetoothManager = hass.data[DOMAIN]
|
|
|
|
await manager.async_stop()
|
2022-07-08 23:55:31 +00:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
class BluetoothManager:
|
|
|
|
"""Manage Bluetooth."""
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
hass: HomeAssistant,
|
2022-07-24 21:39:53 +00:00
|
|
|
integration_matcher: IntegrationMatcher,
|
2022-07-08 23:55:31 +00:00
|
|
|
) -> None:
|
|
|
|
"""Init bluetooth discovery."""
|
|
|
|
self.hass = hass
|
2022-07-24 21:39:53 +00:00
|
|
|
self._integration_matcher = integration_matcher
|
2022-07-08 23:55:31 +00:00
|
|
|
self.scanner: HaBleakScanner | None = None
|
|
|
|
self._cancel_device_detected: CALLBACK_TYPE | None = None
|
2022-07-22 00:16:45 +00:00
|
|
|
self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None
|
|
|
|
self._unavailable_callbacks: dict[str, list[Callable[[str], None]]] = {}
|
2022-07-11 15:14:00 +00:00
|
|
|
self._callbacks: list[
|
|
|
|
tuple[BluetoothCallback, BluetoothCallbackMatcher | None]
|
|
|
|
] = []
|
2022-07-25 14:52:35 +00:00
|
|
|
self._reloading = False
|
2022-07-08 23:55:31 +00:00
|
|
|
|
2022-07-22 18:19:53 +00:00
|
|
|
@hass_callback
|
|
|
|
def async_setup(self) -> None:
|
|
|
|
"""Set up the bluetooth manager."""
|
|
|
|
models.HA_BLEAK_SCANNER = self.scanner = HaBleakScanner()
|
|
|
|
|
|
|
|
@hass_callback
|
2022-07-22 23:12:08 +00:00
|
|
|
def async_get_scanner(self) -> HaBleakScannerWrapper:
|
2022-07-22 18:19:53 +00:00
|
|
|
"""Get the scanner."""
|
2022-07-22 23:12:08 +00:00
|
|
|
return HaBleakScannerWrapper()
|
2022-07-22 18:19:53 +00:00
|
|
|
|
2022-07-25 14:52:35 +00:00
|
|
|
@hass_callback
|
|
|
|
def async_start_reload(self) -> None:
|
|
|
|
"""Start reloading."""
|
|
|
|
self._reloading = True
|
|
|
|
|
|
|
|
async def async_start(
|
|
|
|
self, scanning_mode: BluetoothScanningMode, adapter: str | None
|
|
|
|
) -> None:
|
2022-07-08 23:55:31 +00:00
|
|
|
"""Set up BT Discovery."""
|
2022-07-22 18:19:53 +00:00
|
|
|
assert self.scanner is not None
|
2022-07-25 14:52:35 +00:00
|
|
|
if self._reloading:
|
|
|
|
# On reload, we need to reset the scanner instance
|
|
|
|
# since the devices in its history may not be reachable
|
|
|
|
# anymore.
|
|
|
|
self.scanner.async_reset()
|
|
|
|
self._integration_matcher.async_clear_history()
|
|
|
|
self._reloading = False
|
|
|
|
scanner_kwargs = {"scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode]}
|
|
|
|
if adapter and adapter not in DEFAULT_ADAPTERS:
|
|
|
|
scanner_kwargs["adapter"] = adapter
|
|
|
|
_LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs)
|
2022-07-08 23:55:31 +00:00
|
|
|
try:
|
2022-07-25 14:52:35 +00:00
|
|
|
self.scanner.async_setup(**scanner_kwargs)
|
2022-07-08 23:55:31 +00:00
|
|
|
except (FileNotFoundError, BleakError) as ex:
|
2022-07-22 18:19:53 +00:00
|
|
|
raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex
|
|
|
|
install_multiple_bleak_catcher()
|
2022-07-08 23:55:31 +00:00
|
|
|
# We have to start it right away as some integrations might
|
|
|
|
# need it straight away.
|
|
|
|
_LOGGER.debug("Starting bluetooth scanner")
|
|
|
|
self.scanner.register_detection_callback(self.scanner.async_callback_dispatcher)
|
|
|
|
self._cancel_device_detected = self.scanner.async_register_callback(
|
|
|
|
self._device_detected, {}
|
|
|
|
)
|
2022-07-22 18:19:53 +00:00
|
|
|
try:
|
2022-07-28 21:14:13 +00:00
|
|
|
async with async_timeout.timeout(START_TIMEOUT):
|
2022-07-30 00:53:33 +00:00
|
|
|
await self.scanner.start() # type: ignore[no-untyped-call]
|
2022-08-02 23:46:43 +00:00
|
|
|
except InvalidMessageError as ex:
|
|
|
|
self._cancel_device_detected()
|
|
|
|
_LOGGER.debug("Invalid DBus message received: %s", ex, exc_info=True)
|
|
|
|
raise ConfigEntryNotReady(
|
|
|
|
f"Invalid DBus message received: {ex}; try restarting `dbus`"
|
|
|
|
) from ex
|
|
|
|
except BrokenPipeError as ex:
|
|
|
|
self._cancel_device_detected()
|
|
|
|
_LOGGER.debug("DBus connection broken: %s", ex, exc_info=True)
|
|
|
|
if is_docker_env():
|
|
|
|
raise ConfigEntryNotReady(
|
|
|
|
f"DBus connection broken: {ex}; try restarting `bluetooth`, `dbus`, and finally the docker container"
|
|
|
|
) from ex
|
|
|
|
raise ConfigEntryNotReady(
|
|
|
|
f"DBus connection broken: {ex}; try restarting `bluetooth` and `dbus`"
|
|
|
|
) from ex
|
|
|
|
except FileNotFoundError as ex:
|
|
|
|
self._cancel_device_detected()
|
|
|
|
_LOGGER.debug(
|
|
|
|
"FileNotFoundError while starting bluetooth: %s", ex, exc_info=True
|
|
|
|
)
|
|
|
|
if is_docker_env():
|
|
|
|
raise ConfigEntryNotReady(
|
|
|
|
f"DBus service not found; docker config may be missing `-v /run/dbus:/run/dbus:ro`: {ex}"
|
|
|
|
) from ex
|
|
|
|
raise ConfigEntryNotReady(
|
|
|
|
f"DBus service not found; make sure the DBus socket is available to Home Assistant: {ex}"
|
|
|
|
) from ex
|
2022-07-28 21:14:13 +00:00
|
|
|
except asyncio.TimeoutError as ex:
|
|
|
|
self._cancel_device_detected()
|
|
|
|
raise ConfigEntryNotReady(
|
|
|
|
f"Timed out starting Bluetooth after {START_TIMEOUT} seconds"
|
|
|
|
) from ex
|
2022-08-02 23:46:43 +00:00
|
|
|
except BleakError as ex:
|
2022-07-23 17:00:34 +00:00
|
|
|
self._cancel_device_detected()
|
2022-08-02 23:46:43 +00:00
|
|
|
_LOGGER.debug("BleakError while starting bluetooth: %s", ex, exc_info=True)
|
2022-07-23 17:00:34 +00:00
|
|
|
raise ConfigEntryNotReady(f"Failed to start Bluetooth: {ex}") from ex
|
|
|
|
self.async_setup_unavailable_tracking()
|
2022-07-08 23:55:31 +00:00
|
|
|
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop)
|
|
|
|
|
2022-07-22 00:16:45 +00:00
|
|
|
@hass_callback
|
|
|
|
def async_setup_unavailable_tracking(self) -> None:
|
|
|
|
"""Set up the unavailable tracking."""
|
|
|
|
|
|
|
|
@hass_callback
|
|
|
|
def _async_check_unavailable(now: datetime) -> None:
|
|
|
|
"""Watch for unavailable devices."""
|
2022-07-22 18:19:53 +00:00
|
|
|
scanner = self.scanner
|
|
|
|
assert scanner is not None
|
2022-07-22 00:16:45 +00:00
|
|
|
history = set(scanner.history)
|
|
|
|
active = {device.address for device in scanner.discovered_devices}
|
|
|
|
disappeared = history.difference(active)
|
|
|
|
for address in disappeared:
|
|
|
|
del scanner.history[address]
|
|
|
|
if not (callbacks := self._unavailable_callbacks.get(address)):
|
|
|
|
continue
|
|
|
|
for callback in callbacks:
|
|
|
|
try:
|
|
|
|
callback(address)
|
|
|
|
except Exception: # pylint: disable=broad-except
|
|
|
|
_LOGGER.exception("Error in unavailable callback")
|
|
|
|
|
|
|
|
self._cancel_unavailable_tracking = async_track_time_interval(
|
|
|
|
self.hass,
|
|
|
|
_async_check_unavailable,
|
|
|
|
timedelta(seconds=UNAVAILABLE_TRACK_SECONDS),
|
|
|
|
)
|
|
|
|
|
2022-07-08 23:55:31 +00:00
|
|
|
@hass_callback
|
|
|
|
def _device_detected(
|
|
|
|
self, device: BLEDevice, advertisement_data: AdvertisementData
|
|
|
|
) -> None:
|
|
|
|
"""Handle a detected device."""
|
2022-07-24 21:39:53 +00:00
|
|
|
matched_domains = self._integration_matcher.match_domains(
|
|
|
|
device, advertisement_data
|
|
|
|
)
|
2022-07-22 00:16:45 +00:00
|
|
|
_LOGGER.debug(
|
|
|
|
"Device detected: %s with advertisement_data: %s matched domains: %s",
|
2022-07-24 21:39:53 +00:00
|
|
|
device.address,
|
2022-07-22 00:16:45 +00:00
|
|
|
advertisement_data,
|
|
|
|
matched_domains,
|
|
|
|
)
|
2022-07-08 23:55:31 +00:00
|
|
|
|
|
|
|
if not matched_domains and not self._callbacks:
|
|
|
|
return
|
|
|
|
|
2022-07-18 22:58:08 +00:00
|
|
|
service_info: BluetoothServiceInfoBleak | None = None
|
2022-07-08 23:55:31 +00:00
|
|
|
for callback, matcher in self._callbacks:
|
2022-07-24 21:39:53 +00:00
|
|
|
if matcher is None or ble_device_matches(
|
2022-07-08 23:55:31 +00:00
|
|
|
matcher, device, advertisement_data
|
|
|
|
):
|
|
|
|
if service_info is None:
|
2022-07-18 22:58:08 +00:00
|
|
|
service_info = BluetoothServiceInfoBleak.from_advertisement(
|
2022-07-16 16:02:08 +00:00
|
|
|
device, advertisement_data, SOURCE_LOCAL
|
2022-07-08 23:55:31 +00:00
|
|
|
)
|
|
|
|
try:
|
|
|
|
callback(service_info, BluetoothChange.ADVERTISEMENT)
|
|
|
|
except Exception: # pylint: disable=broad-except
|
|
|
|
_LOGGER.exception("Error in bluetooth callback")
|
|
|
|
|
|
|
|
if not matched_domains:
|
|
|
|
return
|
|
|
|
if service_info is None:
|
2022-07-18 22:58:08 +00:00
|
|
|
service_info = BluetoothServiceInfoBleak.from_advertisement(
|
2022-07-16 16:02:08 +00:00
|
|
|
device, advertisement_data, SOURCE_LOCAL
|
2022-07-08 23:55:31 +00:00
|
|
|
)
|
|
|
|
for domain in matched_domains:
|
|
|
|
discovery_flow.async_create_flow(
|
|
|
|
self.hass,
|
|
|
|
domain,
|
|
|
|
{"source": config_entries.SOURCE_BLUETOOTH},
|
|
|
|
service_info,
|
|
|
|
)
|
|
|
|
|
2022-07-22 00:16:45 +00:00
|
|
|
@hass_callback
|
|
|
|
def async_track_unavailable(
|
|
|
|
self, callback: Callable[[str], None], address: str
|
|
|
|
) -> Callable[[], None]:
|
|
|
|
"""Register a callback."""
|
|
|
|
self._unavailable_callbacks.setdefault(address, []).append(callback)
|
|
|
|
|
|
|
|
@hass_callback
|
|
|
|
def _async_remove_callback() -> None:
|
|
|
|
self._unavailable_callbacks[address].remove(callback)
|
|
|
|
if not self._unavailable_callbacks[address]:
|
|
|
|
del self._unavailable_callbacks[address]
|
|
|
|
|
|
|
|
return _async_remove_callback
|
|
|
|
|
2022-07-08 23:55:31 +00:00
|
|
|
@hass_callback
|
|
|
|
def async_register_callback(
|
2022-07-11 15:14:00 +00:00
|
|
|
self,
|
|
|
|
callback: BluetoothCallback,
|
|
|
|
matcher: BluetoothCallbackMatcher | None = None,
|
2022-07-08 23:55:31 +00:00
|
|
|
) -> Callable[[], None]:
|
|
|
|
"""Register a callback."""
|
2022-07-11 15:14:00 +00:00
|
|
|
callback_entry = (callback, matcher)
|
2022-07-08 23:55:31 +00:00
|
|
|
self._callbacks.append(callback_entry)
|
|
|
|
|
|
|
|
@hass_callback
|
|
|
|
def _async_remove_callback() -> None:
|
|
|
|
self._callbacks.remove(callback_entry)
|
|
|
|
|
2022-07-11 15:14:00 +00:00
|
|
|
# If we have history for the subscriber, we can trigger the callback
|
|
|
|
# immediately with the last packet so the subscriber can see the
|
|
|
|
# device.
|
|
|
|
if (
|
|
|
|
matcher
|
|
|
|
and (address := matcher.get(ADDRESS))
|
2022-07-22 18:19:53 +00:00
|
|
|
and self.scanner
|
|
|
|
and (device_adv_data := self.scanner.history.get(address))
|
2022-07-11 15:14:00 +00:00
|
|
|
):
|
|
|
|
try:
|
|
|
|
callback(
|
2022-07-18 22:58:08 +00:00
|
|
|
BluetoothServiceInfoBleak.from_advertisement(
|
2022-07-16 16:02:08 +00:00
|
|
|
*device_adv_data, SOURCE_LOCAL
|
|
|
|
),
|
2022-07-11 15:14:00 +00:00
|
|
|
BluetoothChange.ADVERTISEMENT,
|
|
|
|
)
|
|
|
|
except Exception: # pylint: disable=broad-except
|
|
|
|
_LOGGER.exception("Error in bluetooth callback")
|
|
|
|
|
2022-07-08 23:55:31 +00:00
|
|
|
return _async_remove_callback
|
|
|
|
|
2022-07-18 22:58:08 +00:00
|
|
|
@hass_callback
|
|
|
|
def async_ble_device_from_address(self, address: str) -> BLEDevice | None:
|
|
|
|
"""Return the BLEDevice if present."""
|
2022-07-22 18:19:53 +00:00
|
|
|
if self.scanner and (ble_adv := self.scanner.history.get(address)):
|
2022-07-18 22:58:08 +00:00
|
|
|
return ble_adv[0]
|
|
|
|
return None
|
|
|
|
|
2022-07-11 15:14:00 +00:00
|
|
|
@hass_callback
|
|
|
|
def async_address_present(self, address: str) -> bool:
|
|
|
|
"""Return if the address is present."""
|
2022-07-22 18:19:53 +00:00
|
|
|
return bool(self.scanner and address in self.scanner.history)
|
2022-07-11 15:14:00 +00:00
|
|
|
|
|
|
|
@hass_callback
|
2022-07-20 03:46:18 +00:00
|
|
|
def async_discovered_service_info(self) -> list[BluetoothServiceInfoBleak]:
|
2022-07-11 15:14:00 +00:00
|
|
|
"""Return if the address is present."""
|
2022-07-22 18:19:53 +00:00
|
|
|
assert self.scanner is not None
|
|
|
|
return [
|
|
|
|
BluetoothServiceInfoBleak.from_advertisement(*device_adv, SOURCE_LOCAL)
|
|
|
|
for device_adv in self.scanner.history.values()
|
|
|
|
]
|
2022-07-11 15:14:00 +00:00
|
|
|
|
2022-07-22 18:19:53 +00:00
|
|
|
async def async_stop(self, event: Event | None = None) -> None:
|
2022-07-08 23:55:31 +00:00
|
|
|
"""Stop bluetooth discovery."""
|
|
|
|
if self._cancel_device_detected:
|
|
|
|
self._cancel_device_detected()
|
|
|
|
self._cancel_device_detected = None
|
2022-07-22 00:16:45 +00:00
|
|
|
if self._cancel_unavailable_tracking:
|
|
|
|
self._cancel_unavailable_tracking()
|
|
|
|
self._cancel_unavailable_tracking = None
|
2022-07-08 23:55:31 +00:00
|
|
|
if self.scanner:
|
2022-07-25 00:23:23 +00:00
|
|
|
try:
|
2022-07-30 00:53:33 +00:00
|
|
|
await self.scanner.stop() # type: ignore[no-untyped-call]
|
2022-07-25 00:23:23 +00:00
|
|
|
except BleakError as ex:
|
|
|
|
# This is not fatal, and they may want to reload
|
|
|
|
# the config entry to restart the scanner if they
|
|
|
|
# change the bluetooth dongle.
|
|
|
|
_LOGGER.error("Error stopping scanner: %s", ex)
|
2022-07-22 18:19:53 +00:00
|
|
|
uninstall_multiple_bleak_catcher()
|