diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 19df484c4e1..a90f367fc38 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -1,114 +1,51 @@ """The bluetooth integration.""" from __future__ import annotations -import asyncio from asyncio import Future from collections.abc import Callable -from dataclasses import dataclass -from datetime import datetime, timedelta -from enum import Enum -import logging -import time -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING import async_timeout -from bleak import BleakError -from dbus_next import InvalidMessageError from homeassistant import config_entries -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import ( - CALLBACK_TYPE, - Event, - HomeAssistant, - callback as hass_callback, -) -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.core import HomeAssistant, callback as hass_callback from homeassistant.helpers import discovery_flow -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.loader import async_get_bluetooth -from homeassistant.util.package import is_docker_env -from . import models -from .const import CONF_ADAPTER, DEFAULT_ADAPTERS, DOMAIN -from .match import ( - ADDRESS, - BluetoothCallbackMatcher, - IntegrationMatcher, - ble_device_matches, +from .const import CONF_ADAPTER, DOMAIN, SOURCE_LOCAL +from .manager import BluetoothManager +from .match import BluetoothCallbackMatcher, IntegrationMatcher +from .models import ( + BluetoothCallback, + BluetoothChange, + BluetoothScanningMode, + BluetoothServiceInfo, + BluetoothServiceInfoBleak, + HaBleakScannerWrapper, + ProcessAdvertisementCallback, ) -from .models import HaBleakScanner, HaBleakScannerWrapper -from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher from .util import async_get_bluetooth_adapters if TYPE_CHECKING: from bleak.backends.device import BLEDevice - from bleak.backends.scanner import AdvertisementData from homeassistant.helpers.typing import ConfigType -_LOGGER = logging.getLogger(__name__) - - -UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 -START_TIMEOUT = 9 - -SOURCE_LOCAL: Final = "local" - -SCANNER_WATCHDOG_TIMEOUT: Final = 60 * 5 -SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=SCANNER_WATCHDOG_TIMEOUT) -MONOTONIC_TIME = time.monotonic - - -@dataclass -class BluetoothServiceInfoBleak(BluetoothServiceInfo): - """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 - ) -> BluetoothServiceInfoBleak: - """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, - ) - - -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") -BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None] -ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool] +__all__ = [ + "async_ble_device_from_address", + "async_discovered_service_info", + "async_get_scanner", + "async_process_advertisements", + "async_rediscover_address", + "async_register_callback", + "async_track_unavailable", + "BluetoothServiceInfo", + "BluetoothServiceInfoBleak", + "BluetoothScanningMode", + "BluetoothCallback", + "SOURCE_LOCAL", +] @hass_callback @@ -287,329 +224,3 @@ async def async_unload_entry( manager.async_start_reload() await manager.async_stop() return True - - -class BluetoothManager: - """Manage Bluetooth.""" - - def __init__( - self, - hass: HomeAssistant, - integration_matcher: IntegrationMatcher, - ) -> None: - """Init bluetooth discovery.""" - self.hass = hass - self._integration_matcher = integration_matcher - self.scanner: HaBleakScanner | None = None - self.start_stop_lock = asyncio.Lock() - self._cancel_device_detected: CALLBACK_TYPE | None = None - self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None - self._cancel_stop: CALLBACK_TYPE | None = None - self._cancel_watchdog: CALLBACK_TYPE | None = None - self._unavailable_callbacks: dict[str, list[Callable[[str], None]]] = {} - self._callbacks: list[ - tuple[BluetoothCallback, BluetoothCallbackMatcher | None] - ] = [] - self._last_detection = 0.0 - self._reloading = False - self._adapter: str | None = None - self._scanning_mode = BluetoothScanningMode.ACTIVE - - @hass_callback - def async_setup(self) -> None: - """Set up the bluetooth manager.""" - models.HA_BLEAK_SCANNER = self.scanner = HaBleakScanner() - - @hass_callback - def async_get_scanner(self) -> HaBleakScannerWrapper: - """Get the scanner.""" - return HaBleakScannerWrapper() - - @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: - """Set up BT Discovery.""" - assert self.scanner is not None - self._adapter = adapter - self._scanning_mode = scanning_mode - 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) - try: - self.scanner.async_setup(**scanner_kwargs) - except (FileNotFoundError, BleakError) as ex: - raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex - install_multiple_bleak_catcher() - # 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, {} - ) - try: - async with async_timeout.timeout(START_TIMEOUT): - await self.scanner.start() # type: ignore[no-untyped-call] - except InvalidMessageError as ex: - self._async_cancel_scanner_callback() - _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._async_cancel_scanner_callback() - _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._async_cancel_scanner_callback() - _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 - except asyncio.TimeoutError as ex: - self._async_cancel_scanner_callback() - raise ConfigEntryNotReady( - f"Timed out starting Bluetooth after {START_TIMEOUT} seconds" - ) from ex - except BleakError as ex: - self._async_cancel_scanner_callback() - _LOGGER.debug("BleakError while starting bluetooth: %s", ex, exc_info=True) - raise ConfigEntryNotReady(f"Failed to start Bluetooth: {ex}") from ex - self.async_setup_unavailable_tracking() - self._async_setup_scanner_watchdog() - self._cancel_stop = self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, self._async_hass_stopping - ) - - @hass_callback - def _async_setup_scanner_watchdog(self) -> None: - """If Dbus gets restarted or updated, we need to restart the scanner.""" - self._last_detection = MONOTONIC_TIME() - self._cancel_watchdog = async_track_time_interval( - self.hass, self._async_scanner_watchdog, SCANNER_WATCHDOG_INTERVAL - ) - - async def _async_scanner_watchdog(self, now: datetime) -> None: - """Check if the scanner is running.""" - time_since_last_detection = MONOTONIC_TIME() - self._last_detection - if time_since_last_detection < SCANNER_WATCHDOG_TIMEOUT: - return - _LOGGER.info( - "Bluetooth scanner has gone quiet for %s, restarting", - SCANNER_WATCHDOG_INTERVAL, - ) - async with self.start_stop_lock: - self.async_start_reload() - await self.async_stop() - await self.async_start(self._scanning_mode, self._adapter) - - @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.""" - scanner = self.scanner - assert scanner is not None - 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), - ) - - @hass_callback - def _device_detected( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Handle a detected device.""" - self._last_detection = MONOTONIC_TIME() - matched_domains = self._integration_matcher.match_domains( - device, advertisement_data - ) - _LOGGER.debug( - "Device detected: %s with advertisement_data: %s matched domains: %s", - device.address, - advertisement_data, - matched_domains, - ) - - if not matched_domains and not self._callbacks: - return - - service_info: BluetoothServiceInfoBleak | None = None - for callback, matcher in self._callbacks: - if matcher is None or ble_device_matches( - matcher, device, advertisement_data - ): - if service_info is None: - service_info = BluetoothServiceInfoBleak.from_advertisement( - device, advertisement_data, SOURCE_LOCAL - ) - 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: - service_info = BluetoothServiceInfoBleak.from_advertisement( - device, advertisement_data, SOURCE_LOCAL - ) - for domain in matched_domains: - discovery_flow.async_create_flow( - self.hass, - domain, - {"source": config_entries.SOURCE_BLUETOOTH}, - service_info, - ) - - @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 - - @hass_callback - def async_register_callback( - self, - callback: BluetoothCallback, - matcher: BluetoothCallbackMatcher | None = None, - ) -> Callable[[], None]: - """Register a callback.""" - callback_entry = (callback, matcher) - self._callbacks.append(callback_entry) - - @hass_callback - def _async_remove_callback() -> None: - self._callbacks.remove(callback_entry) - - # 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)) - and self.scanner - and (device_adv_data := self.scanner.history.get(address)) - ): - try: - callback( - BluetoothServiceInfoBleak.from_advertisement( - *device_adv_data, SOURCE_LOCAL - ), - BluetoothChange.ADVERTISEMENT, - ) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in bluetooth callback") - - return _async_remove_callback - - @hass_callback - def async_ble_device_from_address(self, address: str) -> BLEDevice | None: - """Return the BLEDevice if present.""" - if self.scanner and (ble_adv := self.scanner.history.get(address)): - return ble_adv[0] - return None - - @hass_callback - def async_address_present(self, address: str) -> bool: - """Return if the address is present.""" - return bool(self.scanner and address in self.scanner.history) - - @hass_callback - def async_discovered_service_info(self) -> list[BluetoothServiceInfoBleak]: - """Return if the address is present.""" - assert self.scanner is not None - return [ - BluetoothServiceInfoBleak.from_advertisement(*device_adv, SOURCE_LOCAL) - for device_adv in self.scanner.history.values() - ] - - async def _async_hass_stopping(self, event: Event) -> None: - """Stop the Bluetooth integration at shutdown.""" - self._cancel_stop = None - await self.async_stop() - - @hass_callback - def _async_cancel_scanner_callback(self) -> None: - """Cancel the scanner callback.""" - if self._cancel_device_detected: - self._cancel_device_detected() - self._cancel_device_detected = None - - async def async_stop(self) -> None: - """Stop bluetooth discovery.""" - _LOGGER.debug("Stopping bluetooth discovery") - if self._cancel_watchdog: - self._cancel_watchdog() - self._cancel_watchdog = None - self._async_cancel_scanner_callback() - if self._cancel_unavailable_tracking: - self._cancel_unavailable_tracking() - self._cancel_unavailable_tracking = None - if self._cancel_stop: - self._cancel_stop() - self._cancel_stop = None - if self.scanner: - try: - await self.scanner.stop() # type: ignore[no-untyped-call] - 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) - uninstall_multiple_bleak_catcher() - - @hass_callback - def async_rediscover_address(self, address: str) -> None: - """Trigger discovery of devices which have already been seen.""" - self._integration_matcher.async_clear_address(address) diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index f3f00f581ee..fac191202b0 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -1,4 +1,8 @@ """Constants for the Bluetooth integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Final DOMAIN = "bluetooth" DEFAULT_NAME = "Bluetooth" @@ -9,3 +13,11 @@ MACOS_DEFAULT_BLUETOOTH_ADAPTER = "CoreBluetooth" UNIX_DEFAULT_BLUETOOTH_ADAPTER = "hci0" DEFAULT_ADAPTERS = {MACOS_DEFAULT_BLUETOOTH_ADAPTER, UNIX_DEFAULT_BLUETOOTH_ADAPTER} + +SOURCE_LOCAL: Final = "local" + + +UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 +START_TIMEOUT = 12 +SCANNER_WATCHDOG_TIMEOUT: Final = 60 * 5 +SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=SCANNER_WATCHDOG_TIMEOUT) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py new file mode 100644 index 00000000000..e4f75350575 --- /dev/null +++ b/homeassistant/components/bluetooth/manager.py @@ -0,0 +1,393 @@ +"""The bluetooth integration.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from datetime import datetime, timedelta +import logging +import time +from typing import TYPE_CHECKING + +import async_timeout +from bleak import BleakError +from dbus_next import InvalidMessageError + +from homeassistant import config_entries +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HomeAssistant, + callback as hass_callback, +) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import discovery_flow +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.package import is_docker_env + +from . import models +from .const import ( + DEFAULT_ADAPTERS, + SCANNER_WATCHDOG_INTERVAL, + SCANNER_WATCHDOG_TIMEOUT, + SOURCE_LOCAL, + START_TIMEOUT, + UNAVAILABLE_TRACK_SECONDS, +) +from .match import ( + ADDRESS, + BluetoothCallbackMatcher, + IntegrationMatcher, + ble_device_matches, +) +from .models import ( + BluetoothCallback, + BluetoothChange, + BluetoothScanningMode, + BluetoothServiceInfoBleak, + HaBleakScanner, + HaBleakScannerWrapper, +) +from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher + +if TYPE_CHECKING: + from bleak.backends.device import BLEDevice + from bleak.backends.scanner import AdvertisementData + + +_LOGGER = logging.getLogger(__name__) + + +MONOTONIC_TIME = time.monotonic + + +SCANNING_MODE_TO_BLEAK = { + BluetoothScanningMode.ACTIVE: "active", + BluetoothScanningMode.PASSIVE: "passive", +} + + +class BluetoothManager: + """Manage Bluetooth.""" + + def __init__( + self, + hass: HomeAssistant, + integration_matcher: IntegrationMatcher, + ) -> None: + """Init bluetooth discovery.""" + self.hass = hass + self._integration_matcher = integration_matcher + self.scanner: HaBleakScanner | None = None + self.start_stop_lock = asyncio.Lock() + self._cancel_device_detected: CALLBACK_TYPE | None = None + self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None + self._cancel_stop: CALLBACK_TYPE | None = None + self._cancel_watchdog: CALLBACK_TYPE | None = None + self._unavailable_callbacks: dict[str, list[Callable[[str], None]]] = {} + self._callbacks: list[ + tuple[BluetoothCallback, BluetoothCallbackMatcher | None] + ] = [] + self._last_detection = 0.0 + self._reloading = False + self._adapter: str | None = None + self._scanning_mode = BluetoothScanningMode.ACTIVE + + @hass_callback + def async_setup(self) -> None: + """Set up the bluetooth manager.""" + models.HA_BLEAK_SCANNER = self.scanner = HaBleakScanner() + + @hass_callback + def async_get_scanner(self) -> HaBleakScannerWrapper: + """Get the scanner.""" + return HaBleakScannerWrapper() + + @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: + """Set up BT Discovery.""" + assert self.scanner is not None + self._adapter = adapter + self._scanning_mode = scanning_mode + 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) + try: + self.scanner.async_setup(**scanner_kwargs) + except (FileNotFoundError, BleakError) as ex: + raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex + install_multiple_bleak_catcher() + # 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, {} + ) + try: + async with async_timeout.timeout(START_TIMEOUT): + await self.scanner.start() # type: ignore[no-untyped-call] + except InvalidMessageError as ex: + self._async_cancel_scanner_callback() + _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._async_cancel_scanner_callback() + _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._async_cancel_scanner_callback() + _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 + except asyncio.TimeoutError as ex: + self._async_cancel_scanner_callback() + raise ConfigEntryNotReady( + f"Timed out starting Bluetooth after {START_TIMEOUT} seconds" + ) from ex + except BleakError as ex: + self._async_cancel_scanner_callback() + _LOGGER.debug("BleakError while starting bluetooth: %s", ex, exc_info=True) + raise ConfigEntryNotReady(f"Failed to start Bluetooth: {ex}") from ex + self.async_setup_unavailable_tracking() + self._async_setup_scanner_watchdog() + self._cancel_stop = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self._async_hass_stopping + ) + + @hass_callback + def _async_setup_scanner_watchdog(self) -> None: + """If Dbus gets restarted or updated, we need to restart the scanner.""" + self._last_detection = MONOTONIC_TIME() + self._cancel_watchdog = async_track_time_interval( + self.hass, self._async_scanner_watchdog, SCANNER_WATCHDOG_INTERVAL + ) + + async def _async_scanner_watchdog(self, now: datetime) -> None: + """Check if the scanner is running.""" + time_since_last_detection = MONOTONIC_TIME() - self._last_detection + if time_since_last_detection < SCANNER_WATCHDOG_TIMEOUT: + return + _LOGGER.info( + "Bluetooth scanner has gone quiet for %s, restarting", + SCANNER_WATCHDOG_INTERVAL, + ) + async with self.start_stop_lock: + self.async_start_reload() + await self.async_stop() + await self.async_start(self._scanning_mode, self._adapter) + + @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.""" + scanner = self.scanner + assert scanner is not None + 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), + ) + + @hass_callback + def _device_detected( + self, device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Handle a detected device.""" + self._last_detection = MONOTONIC_TIME() + matched_domains = self._integration_matcher.match_domains( + device, advertisement_data + ) + _LOGGER.debug( + "Device detected: %s with advertisement_data: %s matched domains: %s", + device.address, + advertisement_data, + matched_domains, + ) + + if not matched_domains and not self._callbacks: + return + + service_info: BluetoothServiceInfoBleak | None = None + for callback, matcher in self._callbacks: + if matcher is None or ble_device_matches( + matcher, device, advertisement_data + ): + if service_info is None: + service_info = BluetoothServiceInfoBleak.from_advertisement( + device, advertisement_data, SOURCE_LOCAL + ) + 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: + service_info = BluetoothServiceInfoBleak.from_advertisement( + device, advertisement_data, SOURCE_LOCAL + ) + for domain in matched_domains: + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_BLUETOOTH}, + service_info, + ) + + @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 + + @hass_callback + def async_register_callback( + self, + callback: BluetoothCallback, + matcher: BluetoothCallbackMatcher | None = None, + ) -> Callable[[], None]: + """Register a callback.""" + callback_entry = (callback, matcher) + self._callbacks.append(callback_entry) + + @hass_callback + def _async_remove_callback() -> None: + self._callbacks.remove(callback_entry) + + # 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)) + and self.scanner + and (device_adv_data := self.scanner.history.get(address)) + ): + try: + callback( + BluetoothServiceInfoBleak.from_advertisement( + *device_adv_data, SOURCE_LOCAL + ), + BluetoothChange.ADVERTISEMENT, + ) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error in bluetooth callback") + + return _async_remove_callback + + @hass_callback + def async_ble_device_from_address(self, address: str) -> BLEDevice | None: + """Return the BLEDevice if present.""" + if self.scanner and (ble_adv := self.scanner.history.get(address)): + return ble_adv[0] + return None + + @hass_callback + def async_address_present(self, address: str) -> bool: + """Return if the address is present.""" + return bool(self.scanner and address in self.scanner.history) + + @hass_callback + def async_discovered_service_info(self) -> list[BluetoothServiceInfoBleak]: + """Return if the address is present.""" + assert self.scanner is not None + return [ + BluetoothServiceInfoBleak.from_advertisement(*device_adv, SOURCE_LOCAL) + for device_adv in self.scanner.history.values() + ] + + async def _async_hass_stopping(self, event: Event) -> None: + """Stop the Bluetooth integration at shutdown.""" + self._cancel_stop = None + await self.async_stop() + + @hass_callback + def _async_cancel_scanner_callback(self) -> None: + """Cancel the scanner callback.""" + if self._cancel_device_detected: + self._cancel_device_detected() + self._cancel_device_detected = None + + async def async_stop(self) -> None: + """Stop bluetooth discovery.""" + _LOGGER.debug("Stopping bluetooth discovery") + if self._cancel_watchdog: + self._cancel_watchdog() + self._cancel_watchdog = None + self._async_cancel_scanner_callback() + if self._cancel_unavailable_tracking: + self._cancel_unavailable_tracking() + self._cancel_unavailable_tracking = None + if self._cancel_stop: + self._cancel_stop() + self._cancel_stop = None + if self.scanner: + try: + await self.scanner.stop() # type: ignore[no-untyped-call] + 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) + uninstall_multiple_bleak_catcher() + + @hass_callback + def async_rediscover_address(self, address: str) -> None: + """Trigger discovery of devices which have already been seen.""" + self._integration_matcher.async_clear_address(address) diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 51704a2f530..d5cb1429a2a 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -2,7 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import contextlib +from dataclasses import dataclass +from enum import Enum import logging from typing import TYPE_CHECKING, Any, Final @@ -14,6 +17,7 @@ from bleak.backends.scanner import ( ) from homeassistant.core import CALLBACK_TYPE, callback as hass_callback +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo if TYPE_CHECKING: from bleak.backends.device import BLEDevice @@ -26,6 +30,49 @@ FILTER_UUIDS: Final = "UUIDs" HA_BLEAK_SCANNER: HaBleakScanner | None = None +@dataclass +class BluetoothServiceInfoBleak(BluetoothServiceInfo): + """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 + ) -> BluetoothServiceInfoBleak: + """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, + ) + + +class BluetoothScanningMode(Enum): + """The mode of scanning for bluetooth devices.""" + + PASSIVE = "passive" + ACTIVE = "active" + + +BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT") +BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None] +ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool] + + def _dispatch_callback( callback: AdvertisementDataCallback, filters: dict[str, set[str]], diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 796b3ffb469..2387d35fc23 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -10,20 +10,21 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( - SCANNER_WATCHDOG_INTERVAL, - SCANNER_WATCHDOG_TIMEOUT, - SOURCE_LOCAL, - UNAVAILABLE_TRACK_SECONDS, BluetoothChange, BluetoothScanningMode, BluetoothServiceInfo, async_process_advertisements, async_rediscover_address, async_track_unavailable, + manager, models, ) from homeassistant.components.bluetooth.const import ( CONF_ADAPTER, + SCANNER_WATCHDOG_INTERVAL, + SCANNER_WATCHDOG_TIMEOUT, + SOURCE_LOCAL, + UNAVAILABLE_TRACK_SECONDS, UNIX_DEFAULT_BLUETOOTH_ADAPTER, ) from homeassistant.config_entries import ConfigEntryState @@ -62,7 +63,7 @@ async def test_setup_and_stop_no_bluetooth(hass, caplog): {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} ] with patch( - "homeassistant.components.bluetooth.HaBleakScanner.async_setup", + "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup", side_effect=BleakError, ) as mock_ha_bleak_scanner, patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -83,8 +84,10 @@ async def test_setup_and_stop_no_bluetooth(hass, caplog): async def test_setup_and_stop_broken_bluetooth(hass, caplog): """Test we fail gracefully when bluetooth/dbus is broken.""" mock_bt = [] - with patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch( - "homeassistant.components.bluetooth.HaBleakScanner.start", + with patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup" + ), patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -109,10 +112,10 @@ async def test_setup_and_stop_broken_bluetooth_hanging(hass, caplog): async def _mock_hang(): await asyncio.sleep(1) - with patch.object(bluetooth, "START_TIMEOUT", 0), patch( - "homeassistant.components.bluetooth.HaBleakScanner.async_setup" + with patch.object(manager, "START_TIMEOUT", 0), patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup" ), patch( - "homeassistant.components.bluetooth.HaBleakScanner.start", + "homeassistant.components.bluetooth.models.HaBleakScanner.start", side_effect=_mock_hang, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -132,8 +135,10 @@ async def test_setup_and_stop_broken_bluetooth_hanging(hass, caplog): async def test_setup_and_retry_adapter_not_yet_available(hass, caplog): """Test we retry if the adapter is not yet available.""" mock_bt = [] - with patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch( - "homeassistant.components.bluetooth.HaBleakScanner.start", + with patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup" + ), patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -152,14 +157,14 @@ async def test_setup_and_retry_adapter_not_yet_available(hass, caplog): assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.HaBleakScanner.start", + "homeassistant.components.bluetooth.models.HaBleakScanner.start", ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.HaBleakScanner.stop", + "homeassistant.components.bluetooth.models.HaBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -168,8 +173,10 @@ async def test_setup_and_retry_adapter_not_yet_available(hass, caplog): async def test_no_race_during_manual_reload_in_retry_state(hass, caplog): """Test we can successfully reload when the entry is in a retry state.""" mock_bt = [] - with patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch( - "homeassistant.components.bluetooth.HaBleakScanner.start", + with patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup" + ), patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -188,7 +195,7 @@ async def test_no_race_during_manual_reload_in_retry_state(hass, caplog): assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.HaBleakScanner.start", + "homeassistant.components.bluetooth.models.HaBleakScanner.start", ): await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() @@ -196,7 +203,7 @@ async def test_no_race_during_manual_reload_in_retry_state(hass, caplog): assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.HaBleakScanner.stop", + "homeassistant.components.bluetooth.models.HaBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -206,7 +213,7 @@ async def test_calling_async_discovered_devices_no_bluetooth(hass, caplog): """Test we fail gracefully when asking for discovered devices and there is no blueooth.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.HaBleakScanner.async_setup", + "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup", side_effect=FileNotFoundError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -1514,7 +1521,8 @@ async def test_config_entry_can_be_reloaded_when_stop_raises( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.HaBleakScanner.stop", side_effect=BleakError + "homeassistant.components.bluetooth.models.HaBleakScanner.stop", + side_effect=BleakError, ): await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() @@ -1533,11 +1541,11 @@ async def test_changing_the_adapter_at_runtime(hass): entry.add_to_hass(hass) with patch( - "homeassistant.components.bluetooth.HaBleakScanner.async_setup" + "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup" ) as mock_setup, patch( - "homeassistant.components.bluetooth.HaBleakScanner.start" + "homeassistant.components.bluetooth.models.HaBleakScanner.start" ), patch( - "homeassistant.components.bluetooth.HaBleakScanner.stop" + "homeassistant.components.bluetooth.models.HaBleakScanner.stop" ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -1558,9 +1566,11 @@ async def test_dbus_socket_missing_in_container(hass, caplog): """Test we handle dbus being missing in the container.""" with patch( - "homeassistant.components.bluetooth.is_docker_env", return_value=True - ), patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch( - "homeassistant.components.bluetooth.HaBleakScanner.start", + "homeassistant.components.bluetooth.manager.is_docker_env", return_value=True + ), patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup" + ), patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.start", side_effect=FileNotFoundError, ): assert await async_setup_component( @@ -1580,9 +1590,11 @@ async def test_dbus_socket_missing(hass, caplog): """Test we handle dbus being missing.""" with patch( - "homeassistant.components.bluetooth.is_docker_env", return_value=False - ), patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch( - "homeassistant.components.bluetooth.HaBleakScanner.start", + "homeassistant.components.bluetooth.manager.is_docker_env", return_value=False + ), patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup" + ), patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.start", side_effect=FileNotFoundError, ): assert await async_setup_component( @@ -1602,9 +1614,11 @@ async def test_dbus_broken_pipe_in_container(hass, caplog): """Test we handle dbus broken pipe in the container.""" with patch( - "homeassistant.components.bluetooth.is_docker_env", return_value=True - ), patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch( - "homeassistant.components.bluetooth.HaBleakScanner.start", + "homeassistant.components.bluetooth.manager.is_docker_env", return_value=True + ), patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup" + ), patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.start", side_effect=BrokenPipeError, ): assert await async_setup_component( @@ -1625,9 +1639,11 @@ async def test_dbus_broken_pipe(hass, caplog): """Test we handle dbus broken pipe.""" with patch( - "homeassistant.components.bluetooth.is_docker_env", return_value=False - ), patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch( - "homeassistant.components.bluetooth.HaBleakScanner.start", + "homeassistant.components.bluetooth.manager.is_docker_env", return_value=False + ), patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup" + ), patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.start", side_effect=BrokenPipeError, ): assert await async_setup_component( @@ -1647,8 +1663,10 @@ async def test_dbus_broken_pipe(hass, caplog): async def test_invalid_dbus_message(hass, caplog): """Test we handle invalid dbus message.""" - with patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch( - "homeassistant.components.bluetooth.HaBleakScanner.start", + with patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup" + ), patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.start", side_effect=InvalidMessageError, ): assert await async_setup_component( @@ -1678,7 +1696,7 @@ async def test_recovery_from_dbus_restart( # Ensure we don't restart the scanner if we don't need to with patch( - "homeassistant.components.bluetooth.MONOTONIC_TIME", + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", return_value=start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -1688,7 +1706,7 @@ async def test_recovery_from_dbus_restart( # Fire a callback to reset the timer with patch( - "homeassistant.components.bluetooth.MONOTONIC_TIME", + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", return_value=start_time_monotonic, ): scanner._callback( @@ -1698,7 +1716,7 @@ async def test_recovery_from_dbus_restart( # Ensure we don't restart the scanner if we don't need to with patch( - "homeassistant.components.bluetooth.MONOTONIC_TIME", + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", return_value=start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -1708,7 +1726,7 @@ async def test_recovery_from_dbus_restart( # We hit the timer, so we restart the scanner with patch( - "homeassistant.components.bluetooth.MONOTONIC_TIME", + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) diff --git a/tests/components/bluetooth/test_passive_update_coordinator.py b/tests/components/bluetooth/test_passive_update_coordinator.py index 31530cd6995..12531c52e40 100644 --- a/tests/components/bluetooth/test_passive_update_coordinator.py +++ b/tests/components/bluetooth/test_passive_update_coordinator.py @@ -8,10 +8,10 @@ from unittest.mock import MagicMock, patch from homeassistant.components.bluetooth import ( DOMAIN, - UNAVAILABLE_TRACK_SECONDS, BluetoothChange, BluetoothScanningMode, ) +from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothCoordinatorEntity, PassiveBluetoothDataUpdateCoordinator, diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 5653b938ada..6b21d1aa32c 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -14,10 +14,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.components.bluetooth import ( DOMAIN, - UNAVAILABLE_TRACK_SECONDS, BluetoothChange, BluetoothScanningMode, ) +from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, diff --git a/tests/conftest.py b/tests/conftest.py index 50c24df8d44..3b43fcd14ba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -904,8 +904,8 @@ def mock_bleak_scanner_start(): scanner = bleak.BleakScanner bluetooth_models.HA_BLEAK_SCANNER = None - with patch("homeassistant.components.bluetooth.HaBleakScanner.stop"), patch( - "homeassistant.components.bluetooth.HaBleakScanner.start", + with patch("homeassistant.components.bluetooth.models.HaBleakScanner.stop"), patch( + "homeassistant.components.bluetooth.models.HaBleakScanner.start", ) as mock_bleak_scanner_start: yield mock_bleak_scanner_start