diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index a90f367fc38..8bb275c94fd 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -8,11 +8,14 @@ from typing import TYPE_CHECKING import async_timeout from homeassistant import config_entries +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback as hass_callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import discovery_flow from homeassistant.loader import async_get_bluetooth -from .const import CONF_ADAPTER, DOMAIN, SOURCE_LOCAL +from . import models +from .const import CONF_ADAPTER, DATA_MANAGER, DOMAIN, SOURCE_LOCAL from .manager import BluetoothManager from .match import BluetoothCallbackMatcher, IntegrationMatcher from .models import ( @@ -24,6 +27,7 @@ from .models import ( HaBleakScannerWrapper, ProcessAdvertisementCallback, ) +from .scanner import HaScanner, create_bleak_scanner from .util import async_get_bluetooth_adapters if TYPE_CHECKING: @@ -55,10 +59,7 @@ def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper: This is a wrapper around our BleakScanner singleton that allows multiple integrations to share the same BleakScanner. """ - if DOMAIN not in hass.data: - raise RuntimeError("Bluetooth integration not loaded") - manager: BluetoothManager = hass.data[DOMAIN] - return manager.async_get_scanner() + return HaBleakScannerWrapper() @hass_callback @@ -66,9 +67,9 @@ def async_discovered_service_info( hass: HomeAssistant, ) -> list[BluetoothServiceInfoBleak]: """Return the discovered devices list.""" - if DOMAIN not in hass.data: + if DATA_MANAGER not in hass.data: return [] - manager: BluetoothManager = hass.data[DOMAIN] + manager: BluetoothManager = hass.data[DATA_MANAGER] return manager.async_discovered_service_info() @@ -78,9 +79,9 @@ def async_ble_device_from_address( address: str, ) -> BLEDevice | None: """Return BLEDevice for an address if its present.""" - if DOMAIN not in hass.data: + if DATA_MANAGER not in hass.data: return None - manager: BluetoothManager = hass.data[DOMAIN] + manager: BluetoothManager = hass.data[DATA_MANAGER] return manager.async_ble_device_from_address(address) @@ -90,9 +91,9 @@ def async_address_present( address: str, ) -> bool: """Check if an address is present in the bluetooth device list.""" - if DOMAIN not in hass.data: + if DATA_MANAGER not in hass.data: return False - manager: BluetoothManager = hass.data[DOMAIN] + manager: BluetoothManager = hass.data[DATA_MANAGER] return manager.async_address_present(address) @@ -112,7 +113,7 @@ def async_register_callback( Returns a callback that can be used to cancel the registration. """ - manager: BluetoothManager = hass.data[DOMAIN] + manager: BluetoothManager = hass.data[DATA_MANAGER] return manager.async_register_callback(callback, match_dict) @@ -152,14 +153,14 @@ def async_track_unavailable( Returns a callback that can be used to cancel the registration. """ - manager: BluetoothManager = hass.data[DOMAIN] + manager: BluetoothManager = hass.data[DATA_MANAGER] return manager.async_track_unavailable(callback, address) @hass_callback def async_rediscover_address(hass: HomeAssistant, address: str) -> None: """Trigger discovery of devices which have already been seen.""" - manager: BluetoothManager = hass.data[DOMAIN] + manager: BluetoothManager = hass.data[DATA_MANAGER] manager.async_rediscover_address(address) @@ -173,7 +174,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass)) manager = BluetoothManager(hass, integration_matcher) manager.async_setup() - hass.data[DOMAIN] = manager + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop) + hass.data[DATA_MANAGER] = models.MANAGER = manager # The config entry is responsible for starting the manager # if its enabled @@ -198,13 +200,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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] - async with manager.start_stop_lock: - await manager.async_start( - BluetoothScanningMode.ACTIVE, entry.options.get(CONF_ADAPTER) - ) + """Set up a config entry for a bluetooth scanner.""" + manager: BluetoothManager = hass.data[DATA_MANAGER] + adapter: str | None = entry.options.get(CONF_ADAPTER) + try: + bleak_scanner = create_bleak_scanner(BluetoothScanningMode.ACTIVE, adapter) + except RuntimeError as err: + raise ConfigEntryNotReady from err + scanner = HaScanner(hass, bleak_scanner, adapter) + entry.async_on_unload(scanner.async_register_callback(manager.scanner_adv_received)) + await scanner.async_start() + entry.async_on_unload(manager.async_register_scanner(scanner)) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = scanner return True @@ -219,8 +227,6 @@ async def async_unload_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry ) -> bool: """Unload a config entry.""" - manager: BluetoothManager = hass.data[DOMAIN] - async with manager.start_stop_lock: - manager.async_start_reload() - await manager.async_stop() + scanner: HaScanner = hass.data[DOMAIN].pop(entry.entry_id) + await scanner.async_stop() return True diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index fac191202b0..04581b841b9 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -16,6 +16,7 @@ DEFAULT_ADAPTERS = {MACOS_DEFAULT_BLUETOOTH_ADAPTER, UNIX_DEFAULT_BLUETOOTH_ADAP SOURCE_LOCAL: Final = "local" +DATA_MANAGER: Final = "bluetooth_manager" UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 START_TIMEOUT = 12 diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index e4f75350575..15b05271bd4 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -1,70 +1,66 @@ """The bluetooth integration.""" from __future__ import annotations -import asyncio -from collections.abc import Callable +from collections.abc import Callable, Iterable from datetime import datetime, timedelta +import itertools import logging -import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final -import async_timeout -from bleak import BleakError -from dbus_next import InvalidMessageError +from bleak.backends.scanner import AdvertisementDataCallback 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 .const import SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS from .match import ( ADDRESS, BluetoothCallbackMatcher, IntegrationMatcher, ble_device_matches, ) -from .models import ( - BluetoothCallback, - BluetoothChange, - BluetoothScanningMode, - BluetoothServiceInfoBleak, - HaBleakScanner, - HaBleakScannerWrapper, -) +from .models import BluetoothCallback, BluetoothChange, BluetoothServiceInfoBleak 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 + from .scanner import HaScanner + +FILTER_UUIDS: Final = "UUIDs" + _LOGGER = logging.getLogger(__name__) -MONOTONIC_TIME = time.monotonic +def _dispatch_bleak_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 + return # type: ignore[unreachable] # pragma: no cover + if (uuids := filters.get(FILTER_UUIDS)) and not uuids.intersection( + advertisement_data.service_uuids + ): + return -SCANNING_MODE_TO_BLEAK = { - BluetoothScanningMode.ACTIVE: "active", - BluetoothScanningMode.PASSIVE: "passive", -} + try: + callback(device, advertisement_data) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error in callback: %s", callback) class BluetoothManager: @@ -75,136 +71,46 @@ class BluetoothManager: hass: HomeAssistant, integration_matcher: IntegrationMatcher, ) -> None: - """Init bluetooth discovery.""" + """Init bluetooth manager.""" 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 + self._bleak_callbacks: list[ + tuple[AdvertisementDataCallback, dict[str, set[str]]] + ] = [] + self.history: dict[str, tuple[BLEDevice, AdvertisementData, float, str]] = {} + self._scanners: list[HaScanner] = [] @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_stop(self, event: Event) -> None: + """Stop the Bluetooth integration at shutdown.""" + _LOGGER.debug("Stopping bluetooth manager") + if self._cancel_unavailable_tracking: + self._cancel_unavailable_tracking() + self._cancel_unavailable_tracking = None + uninstall_multiple_bleak_catcher() + + @hass_callback + def async_all_discovered_devices(self) -> Iterable[BLEDevice]: + """Return all of discovered devices from all the scanners including duplicates.""" + return itertools.chain.from_iterable( + scanner.discovered_devices for scanner in self._scanners ) @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) + def async_discovered_devices(self) -> list[BLEDevice]: + """Return all of combined best path to discovered from all the scanners.""" + return [history[0] for history in self.history.values()] @hass_callback def async_setup_unavailable_tracking(self) -> None: @@ -213,13 +119,13 @@ class BluetoothManager: @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) + history_set = set(self.history) + active_addresses = { + device.address for device in self.async_all_discovered_devices() + } + disappeared = history_set.difference(active_addresses) for address in disappeared: - del scanner.history[address] + del self.history[address] if not (callbacks := self._unavailable_callbacks.get(address)): continue for callback in callbacks: @@ -235,16 +141,40 @@ class BluetoothManager: ) @hass_callback - def _device_detected( - self, device: BLEDevice, advertisement_data: AdvertisementData + def scanner_adv_received( + self, + device: BLEDevice, + advertisement_data: AdvertisementData, + monotonic_time: float, + source: str, ) -> None: - """Handle a detected device.""" - self._last_detection = MONOTONIC_TIME() + """Handle a new advertisement from any scanner. + + Callbacks from all the scanners arrive here. + + In the future we will only process callbacks if + + - The device is not in the history + - The RSSI is above a certain threshold better than + than the source from the history or the timestamp + in the history is older than 180s + """ + self.history[device.address] = ( + device, + advertisement_data, + monotonic_time, + source, + ) + + for callback_filters in self._bleak_callbacks: + _dispatch_bleak_callback(*callback_filters, device, advertisement_data) + matched_domains = self._integration_matcher.match_domains( device, advertisement_data ) _LOGGER.debug( - "Device detected: %s with advertisement_data: %s matched domains: %s", + "%s: %s %s match: %s", + source, device.address, advertisement_data, matched_domains, @@ -260,7 +190,7 @@ class BluetoothManager: ): if service_info is None: service_info = BluetoothServiceInfoBleak.from_advertisement( - device, advertisement_data, SOURCE_LOCAL + device, advertisement_data, source ) try: callback(service_info, BluetoothChange.ADVERTISEMENT) @@ -271,7 +201,7 @@ class BluetoothManager: return if service_info is None: service_info = BluetoothServiceInfoBleak.from_advertisement( - device, advertisement_data, SOURCE_LOCAL + device, advertisement_data, source ) for domain in matched_domains: discovery_flow.async_create_flow( @@ -316,13 +246,13 @@ class BluetoothManager: if ( matcher and (address := matcher.get(ADDRESS)) - and self.scanner - and (device_adv_data := self.scanner.history.get(address)) + and (device_adv_data := self.history.get(address)) ): + ble_device, adv_data, _, _ = device_adv_data try: callback( BluetoothServiceInfoBleak.from_advertisement( - *device_adv_data, SOURCE_LOCAL + ble_device, adv_data, SOURCE_LOCAL ), BluetoothChange.ADVERTISEMENT, ) @@ -334,60 +264,55 @@ class BluetoothManager: @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)): + if ble_adv := self.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) + return address in self.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() + BluetoothServiceInfoBleak.from_advertisement( + device_adv[0], device_adv[1], SOURCE_LOCAL + ) + for device_adv in self.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) + + def async_register_scanner(self, scanner: HaScanner) -> CALLBACK_TYPE: + """Register a new scanner.""" + + def _unregister_scanner() -> None: + self._scanners.remove(scanner) + + self._scanners.append(scanner) + return _unregister_scanner + + @hass_callback + def async_register_bleak_callback( + self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] + ) -> CALLBACK_TYPE: + """Register a callback.""" + callback_entry = (callback, filters) + self._bleak_callbacks.append(callback_entry) + + @hass_callback + def _remove_callback() -> None: + self._bleak_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 + for device, advertisement_data, _, _ in self.history.values(): + _dispatch_bleak_callback(callback, filters, device, advertisement_data) + + return _remove_callback diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index 9c942d9f411..49f9e49db54 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -74,10 +74,6 @@ class IntegrationMatcher: MAX_REMEMBER_ADDRESSES ) - def async_clear_history(self) -> None: - """Clear the history.""" - self._matched = {} - def async_clear_address(self, address: str) -> None: """Clear the history matches for a set of domains.""" self._matched.pop(address, None) diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index d5cb1429a2a..1006ed912dd 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -9,25 +9,26 @@ from enum import Enum import logging from typing import TYPE_CHECKING, Any, Final -from bleak import BleakScanner from bleak.backends.scanner import ( AdvertisementData, AdvertisementDataCallback, BaseBleakScanner, ) -from homeassistant.core import CALLBACK_TYPE, callback as hass_callback +from homeassistant.core import CALLBACK_TYPE from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo if TYPE_CHECKING: from bleak.backends.device import BLEDevice + from .manager import BluetoothManager + _LOGGER = logging.getLogger(__name__) FILTER_UUIDS: Final = "UUIDs" -HA_BLEAK_SCANNER: HaBleakScanner | None = None +MANAGER: BluetoothManager | None = None @dataclass @@ -73,89 +74,6 @@ BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None] ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool] -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 - return # type: ignore[unreachable] - - 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) - - -class HaBleakScanner(BleakScanner): - """BleakScanner that cannot be stopped.""" - - def __init__( # pylint: disable=super-init-not-called - self, *args: Any, **kwargs: Any - ) -> None: - """Initialize the BleakScanner.""" - self._callbacks: list[ - tuple[AdvertisementDataCallback, dict[str, set[str]]] - ] = [] - self.history: dict[str, tuple[BLEDevice, AdvertisementData]] = {} - # 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 - - @hass_callback - def async_reset(self) -> None: - """Reset the scanner so it can be setup again.""" - self.history = {} - self._setup = False - - @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 - for device, advertisement_data in self.history.values(): - _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 - """ - self.history[device.address] = (device, advertisement_data) - for callback_filters in self._callbacks: - _dispatch_callback(*callback_filters, device, advertisement_data) - - class HaBleakScannerWrapper(BaseBleakScanner): """A wrapper that uses the single instance.""" @@ -215,8 +133,8 @@ class HaBleakScannerWrapper(BaseBleakScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" - assert HA_BLEAK_SCANNER is not None - return HA_BLEAK_SCANNER.discovered_devices + assert MANAGER is not None + return list(MANAGER.async_discovered_devices()) def register_detection_callback( self, callback: AdvertisementDataCallback | None @@ -235,9 +153,9 @@ class HaBleakScannerWrapper(BaseBleakScanner): return self._cancel_callback() super().register_detection_callback(self._adv_data_callback) - assert HA_BLEAK_SCANNER is not None + assert MANAGER is not None assert self._callback is not None - self._detection_cancel = HA_BLEAK_SCANNER.async_register_callback( + self._detection_cancel = MANAGER.async_register_bleak_callback( self._callback, self._mapped_filters ) diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py new file mode 100644 index 00000000000..c3d45dbde95 --- /dev/null +++ b/homeassistant/components/bluetooth/scanner.py @@ -0,0 +1,241 @@ +"""The bluetooth integration.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from datetime import datetime +import logging +import time + +import async_timeout +import bleak +from bleak import BleakError +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData +from dbus_next import InvalidMessageError + +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.event import async_track_time_interval +from homeassistant.util.package import is_docker_env + +from .const import ( + DEFAULT_ADAPTERS, + SCANNER_WATCHDOG_INTERVAL, + SCANNER_WATCHDOG_TIMEOUT, + SOURCE_LOCAL, + START_TIMEOUT, +) +from .models import BluetoothScanningMode + +OriginalBleakScanner = bleak.BleakScanner +MONOTONIC_TIME = time.monotonic + + +_LOGGER = logging.getLogger(__name__) + + +MONOTONIC_TIME = time.monotonic + + +SCANNING_MODE_TO_BLEAK = { + BluetoothScanningMode.ACTIVE: "active", + BluetoothScanningMode.PASSIVE: "passive", +} + + +def create_bleak_scanner( + scanning_mode: BluetoothScanningMode, adapter: str | None +) -> bleak.BleakScanner: + """Create a Bleak scanner.""" + 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: + return OriginalBleakScanner(**scanner_kwargs) # type: ignore[arg-type] + except (FileNotFoundError, BleakError) as ex: + raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex + + +class HaScanner: + """Operate a BleakScanner. + + Multiple BleakScanner can be used at the same time + if there are multiple adapters. This is only useful + if the adapters are not located physically next to each other. + + Example use cases are usbip, a long extension cable, usb to bluetooth + over ethernet, usb over ethernet, etc. + """ + + def __init__( + self, hass: HomeAssistant, scanner: bleak.BleakScanner, adapter: str | None + ) -> None: + """Init bluetooth discovery.""" + self.hass = hass + self.scanner = scanner + self.adapter = adapter + self._start_stop_lock = asyncio.Lock() + self._cancel_stop: CALLBACK_TYPE | None = None + self._cancel_watchdog: CALLBACK_TYPE | None = None + self._last_detection = 0.0 + self._callbacks: list[ + Callable[[BLEDevice, AdvertisementData, float, str], None] + ] = [] + self.name = self.adapter or "default" + self.source = self.adapter or SOURCE_LOCAL + + @property + def discovered_devices(self) -> list[BLEDevice]: + """Return a list of discovered devices.""" + return self.scanner.discovered_devices + + @hass_callback + def async_register_callback( + self, callback: Callable[[BLEDevice, AdvertisementData, float, str], None] + ) -> CALLBACK_TYPE: + """Register a callback. + + Currently this is used to feed the callbacks into the + central manager. + """ + + def _remove() -> None: + self._callbacks.remove(callback) + + self._callbacks.append(callback) + return _remove + + @hass_callback + def _async_detection_callback( + self, + ble_device: BLEDevice, + advertisement_data: AdvertisementData, + ) -> None: + """Call the callback when an advertisement is received. + + Currently this is used to feed the callbacks into the + central manager. + """ + self._last_detection = MONOTONIC_TIME() + for callback in self._callbacks: + callback(ble_device, advertisement_data, self._last_detection, self.source) + + async def async_start(self) -> None: + """Start bluetooth scanner.""" + self.scanner.register_detection_callback(self._async_detection_callback) + + async with self._start_stop_lock: + await self._async_start() + + async def _async_start(self) -> None: + """Start bluetooth scanner under the lock.""" + try: + async with async_timeout.timeout(START_TIMEOUT): + await self.scanner.start() # type: ignore[no-untyped-call] + except InvalidMessageError as ex: + _LOGGER.debug( + "%s: Invalid DBus message received: %s", self.name, ex, exc_info=True + ) + raise ConfigEntryNotReady( + f"{self.name}: Invalid DBus message received: {ex}; try restarting `dbus`" + ) from ex + except BrokenPipeError as ex: + _LOGGER.debug( + "%s: DBus connection broken: %s", self.name, ex, exc_info=True + ) + if is_docker_env(): + raise ConfigEntryNotReady( + f"{self.name}: DBus connection broken: {ex}; try restarting `bluetooth`, `dbus`, and finally the docker container" + ) from ex + raise ConfigEntryNotReady( + f"{self.name}: DBus connection broken: {ex}; try restarting `bluetooth` and `dbus`" + ) from ex + except FileNotFoundError as ex: + _LOGGER.debug( + "%s: FileNotFoundError while starting bluetooth: %s", + self.name, + ex, + exc_info=True, + ) + if is_docker_env(): + raise ConfigEntryNotReady( + f"{self.name}: DBus service not found; docker config may be missing `-v /run/dbus:/run/dbus:ro`: {ex}" + ) from ex + raise ConfigEntryNotReady( + f"{self.name}: DBus service not found; make sure the DBus socket is available to Home Assistant: {ex}" + ) from ex + except asyncio.TimeoutError as ex: + raise ConfigEntryNotReady( + f"{self.name}: Timed out starting Bluetooth after {START_TIMEOUT} seconds" + ) from ex + except BleakError as ex: + _LOGGER.debug( + "%s: BleakError while starting bluetooth: %s", + self.name, + ex, + exc_info=True, + ) + raise ConfigEntryNotReady( + f"{self.name}: Failed to start Bluetooth: {ex}" + ) from ex + 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( + "%s: Bluetooth scanner has gone quiet for %s, restarting", + self.name, + SCANNER_WATCHDOG_INTERVAL, + ) + async with self._start_stop_lock: + await self._async_stop() + await self._async_start() + + async def _async_hass_stopping(self, event: Event) -> None: + """Stop the Bluetooth integration at shutdown.""" + self._cancel_stop = None + await self.async_stop() + + async def async_stop(self) -> None: + """Stop bluetooth scanner.""" + async with self._start_stop_lock: + await self._async_stop() + + async def _async_stop(self) -> None: + """Stop bluetooth discovery under the lock.""" + _LOGGER.debug("Stopping bluetooth discovery") + if self._cancel_watchdog: + self._cancel_watchdog() + self._cancel_watchdog = None + if self._cancel_stop: + self._cancel_stop() + self._cancel_stop = None + 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) diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 3dc80d55590..2b6ad75d2b9 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -1,8 +1,38 @@ """Tests for the Bluetooth integration.""" -from homeassistant.components.bluetooth import models + +import time +from unittest.mock import patch + +from bleak.backends.scanner import AdvertisementData, BLEDevice + +from homeassistant.components.bluetooth import SOURCE_LOCAL, models +from homeassistant.components.bluetooth.manager import BluetoothManager -def _get_underlying_scanner(): +def _get_manager() -> BluetoothManager: + """Return the bluetooth manager.""" + return models.MANAGER + + +def inject_advertisement(device: BLEDevice, adv: AdvertisementData) -> None: """Return the underlying scanner that has been wrapped.""" - return models.HA_BLEAK_SCANNER + return _get_manager().scanner_adv_received( + device, adv, time.monotonic(), SOURCE_LOCAL + ) + + +def patch_all_discovered_devices(mock_discovered: list[BLEDevice]) -> None: + """Mock all the discovered devices from all the scanners.""" + manager = _get_manager() + return patch.object( + manager, "async_all_discovered_devices", return_value=mock_discovered + ) + + +def patch_discovered_devices(mock_discovered: list[BLEDevice]) -> None: + """Mock the combined best path to discovered devices from all the scanners.""" + manager = _get_manager() + return patch.object( + manager, "async_discovered_devices", return_value=mock_discovered + ) diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 2387d35fc23..84c37300dc4 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -5,7 +5,6 @@ from unittest.mock import MagicMock, patch from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice -from dbus_next import InvalidMessageError import pytest from homeassistant.components import bluetooth @@ -16,16 +15,12 @@ from homeassistant.components.bluetooth import ( async_process_advertisements, async_rediscover_address, async_track_unavailable, - manager, models, + scanner, ) 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 from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP @@ -33,7 +28,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import _get_underlying_scanner +from . import _get_manager, inject_advertisement, patch_discovered_devices from tests.common import MockConfigEntry, async_fire_time_changed @@ -63,7 +58,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.models.HaBleakScanner.async_setup", + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", side_effect=BleakError, ) as mock_ha_bleak_scanner, patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -85,9 +80,7 @@ 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.models.HaBleakScanner.async_setup" - ), patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.start", + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -112,10 +105,8 @@ async def test_setup_and_stop_broken_bluetooth_hanging(hass, caplog): async def _mock_hang(): await asyncio.sleep(1) - with patch.object(manager, "START_TIMEOUT", 0), patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup" - ), patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.start", + with patch.object(scanner, "START_TIMEOUT", 0), patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", side_effect=_mock_hang, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -136,9 +127,7 @@ 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.models.HaBleakScanner.async_setup" - ), patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.start", + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -157,14 +146,14 @@ async def test_setup_and_retry_adapter_not_yet_available(hass, caplog): assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.start", + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.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.models.HaBleakScanner.stop", + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -174,9 +163,7 @@ 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.models.HaBleakScanner.async_setup" - ), patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.start", + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -195,7 +182,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.models.HaBleakScanner.start", + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", ): await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() @@ -203,7 +190,7 @@ async def test_no_race_during_manual_reload_in_retry_state(hass, caplog): assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.stop", + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -213,7 +200,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.models.HaBleakScanner.async_setup", + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", side_effect=FileNotFoundError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -252,7 +239,7 @@ async def test_discovery_match_by_service_uuid( wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) - _get_underlying_scanner()._callback(wrong_device, wrong_adv) + inject_advertisement(wrong_device, wrong_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 @@ -262,7 +249,7 @@ async def test_discovery_match_by_service_uuid( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 @@ -289,7 +276,7 @@ async def test_discovery_match_by_local_name(hass, mock_bleak_scanner_start): wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) - _get_underlying_scanner()._callback(wrong_device, wrong_adv) + inject_advertisement(wrong_device, wrong_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 @@ -297,7 +284,7 @@ async def test_discovery_match_by_local_name(hass, mock_bleak_scanner_start): switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 @@ -343,21 +330,21 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( # 1st discovery with no manufacturer data # should not trigger config flow - _get_underlying_scanner()._callback(hkc_device, hkc_adv_no_mfr_data) + inject_advertisement(hkc_device, hkc_adv_no_mfr_data) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 mock_config_flow.reset_mock() # 2nd discovery with manufacturer data # should trigger a config flow - _get_underlying_scanner()._callback(hkc_device, hkc_adv) + inject_advertisement(hkc_device, hkc_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "homekit_controller" mock_config_flow.reset_mock() # 3rd discovery should not generate another flow - _get_underlying_scanner()._callback(hkc_device, hkc_adv) + inject_advertisement(hkc_device, hkc_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 @@ -368,7 +355,7 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( local_name="lock", service_uuids=[], manufacturer_data={76: b"\x02"} ) - _get_underlying_scanner()._callback(not_hkc_device, not_hkc_adv) + inject_advertisement(not_hkc_device, not_hkc_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 @@ -377,7 +364,7 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( local_name="lock", service_uuids=[], manufacturer_data={21: b"\x02"} ) - _get_underlying_scanner()._callback(not_apple_device, not_apple_adv) + inject_advertisement(not_apple_device, not_apple_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 @@ -453,21 +440,21 @@ async def test_discovery_match_by_service_data_uuid_then_others( ) # 1st discovery should not generate a flow because the # service_data_uuid is not in the advertisement - _get_underlying_scanner()._callback(device, adv_without_service_data_uuid) + inject_advertisement(device, adv_without_service_data_uuid) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 mock_config_flow.reset_mock() # 2nd discovery should not generate a flow because the # service_data_uuid is not in the advertisement - _get_underlying_scanner()._callback(device, adv_without_service_data_uuid) + inject_advertisement(device, adv_without_service_data_uuid) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 mock_config_flow.reset_mock() # 3rd discovery should generate a flow because the # manufacturer_data is in the advertisement - _get_underlying_scanner()._callback(device, adv_with_mfr_data) + inject_advertisement(device, adv_with_mfr_data) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "other_domain" @@ -476,7 +463,7 @@ async def test_discovery_match_by_service_data_uuid_then_others( # 4th discovery should generate a flow because the # service_data_uuid is in the advertisement and # we never saw a service_data_uuid before - _get_underlying_scanner()._callback(device, adv_with_service_data_uuid) + inject_advertisement(device, adv_with_service_data_uuid) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "my_domain" @@ -484,16 +471,14 @@ async def test_discovery_match_by_service_data_uuid_then_others( # 5th discovery should not generate a flow because the # we already saw an advertisement with the service_data_uuid - _get_underlying_scanner()._callback(device, adv_with_service_data_uuid) + inject_advertisement(device, adv_with_service_data_uuid) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 # 6th discovery should not generate a flow because the # manufacturer_data is in the advertisement # and we saw manufacturer_data before - _get_underlying_scanner()._callback( - device, adv_with_service_data_uuid_and_mfr_data - ) + inject_advertisement(device, adv_with_service_data_uuid_and_mfr_data) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 mock_config_flow.reset_mock() @@ -501,7 +486,7 @@ async def test_discovery_match_by_service_data_uuid_then_others( # 7th discovery should generate a flow because the # service_uuids is in the advertisement # and we never saw service_uuids before - _get_underlying_scanner()._callback( + inject_advertisement( device, adv_with_service_data_uuid_and_mfr_data_and_service_uuid ) await hass.async_block_till_done() @@ -514,7 +499,7 @@ async def test_discovery_match_by_service_data_uuid_then_others( # 8th discovery should not generate a flow # since all fields have been seen at this point - _get_underlying_scanner()._callback( + inject_advertisement( device, adv_with_service_data_uuid_and_mfr_data_and_service_uuid ) await hass.async_block_till_done() @@ -523,19 +508,19 @@ async def test_discovery_match_by_service_data_uuid_then_others( # 9th discovery should not generate a flow # since all fields have been seen at this point - _get_underlying_scanner()._callback(device, adv_with_service_uuid) + inject_advertisement(device, adv_with_service_uuid) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 # 10th discovery should not generate a flow # since all fields have been seen at this point - _get_underlying_scanner()._callback(device, adv_with_service_data_uuid) + inject_advertisement(device, adv_with_service_data_uuid) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 # 11th discovery should not generate a flow # since all fields have been seen at this point - _get_underlying_scanner()._callback(device, adv_without_service_data_uuid) + inject_advertisement(device, adv_without_service_data_uuid) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 @@ -582,7 +567,7 @@ async def test_discovery_match_first_by_service_uuid_and_then_manufacturer_id( # 1st discovery with matches service_uuid # should trigger config flow - _get_underlying_scanner()._callback(device, adv_service_uuids) + inject_advertisement(device, adv_service_uuids) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "my_domain" @@ -590,19 +575,19 @@ async def test_discovery_match_first_by_service_uuid_and_then_manufacturer_id( # 2nd discovery with manufacturer data # should trigger a config flow - _get_underlying_scanner()._callback(device, adv_manufacturer_data) + inject_advertisement(device, adv_manufacturer_data) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "my_domain" mock_config_flow.reset_mock() # 3rd discovery should not generate another flow - _get_underlying_scanner()._callback(device, adv_service_uuids) + inject_advertisement(device, adv_service_uuids) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 # 4th discovery should not generate another flow - _get_underlying_scanner()._callback(device, adv_manufacturer_data) + inject_advertisement(device, adv_manufacturer_data) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 @@ -628,10 +613,10 @@ async def test_rediscovery(hass, mock_bleak_scanner_start, enable_bluetooth): local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) await hass.async_block_till_done() - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 @@ -639,7 +624,7 @@ async def test_rediscovery(hass, mock_bleak_scanner_start, enable_bluetooth): async_rediscover_address(hass, "44:44:33:11:23:45") - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 2 @@ -672,10 +657,10 @@ async def test_async_discovered_device_api(hass, mock_bleak_scanner_start): wrong_device = BLEDevice("44:44:33:11:23:42", "wrong_name") wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) - _get_underlying_scanner()._callback(wrong_device, wrong_adv) + inject_advertisement(wrong_device, wrong_adv) switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) wrong_device_went_unavailable = False switchbot_device_went_unavailable = False @@ -709,8 +694,8 @@ async def test_async_discovered_device_api(hass, mock_bleak_scanner_start): assert wrong_device_went_unavailable is True # See the devices again - _get_underlying_scanner()._callback(wrong_device, wrong_adv) - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(wrong_device, wrong_adv) + inject_advertisement(switchbot_device, switchbot_adv) # Cancel the callbacks wrong_device_unavailable_cancel() switchbot_device_unavailable_cancel() @@ -776,25 +761,25 @@ async def test_register_callbacks(hass, mock_bleak_scanner_start, enable_bluetoo service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_adv = AdvertisementData(local_name="empty") - _get_underlying_scanner()._callback(empty_device, empty_adv) + inject_advertisement(empty_device, empty_adv) await hass.async_block_till_done() empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_adv = AdvertisementData(local_name="empty") # 3rd callback raises ValueError but is still tracked - _get_underlying_scanner()._callback(empty_device, empty_adv) + inject_advertisement(empty_device, empty_adv) await hass.async_block_till_done() cancel() # 4th callback should not be tracked since we canceled - _get_underlying_scanner()._callback(empty_device, empty_adv) + inject_advertisement(empty_device, empty_adv) await hass.async_block_till_done() assert len(callbacks) == 3 @@ -862,25 +847,25 @@ async def test_register_callback_by_address( service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_adv = AdvertisementData(local_name="empty") - _get_underlying_scanner()._callback(empty_device, empty_adv) + inject_advertisement(empty_device, empty_adv) await hass.async_block_till_done() empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_adv = AdvertisementData(local_name="empty") # 3rd callback raises ValueError but is still tracked - _get_underlying_scanner()._callback(empty_device, empty_adv) + inject_advertisement(empty_device, empty_adv) await hass.async_block_till_done() cancel() # 4th callback should not be tracked since we canceled - _get_underlying_scanner()._callback(empty_device, empty_adv) + inject_advertisement(empty_device, empty_adv) await hass.async_block_till_done() # Now register again with a callback that fails to @@ -953,7 +938,7 @@ async def test_register_callback_survives_reload( service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) assert len(callbacks) == 1 service_info: BluetoothServiceInfo = callbacks[0][0] assert service_info.name == "wohand" @@ -964,7 +949,7 @@ async def test_register_callback_survives_reload( await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) assert len(callbacks) == 2 service_info: BluetoothServiceInfo = callbacks[1][0] assert service_info.name == "wohand" @@ -1001,9 +986,9 @@ async def test_process_advertisements_bail_on_good_advertisement( service_data={"00000d00-0000-1000-8000-00805f9b34fa": b"H\x10c"}, ) - _get_underlying_scanner()._callback(device, adv) - _get_underlying_scanner()._callback(device, adv) - _get_underlying_scanner()._callback(device, adv) + inject_advertisement(device, adv) + inject_advertisement(device, adv) + inject_advertisement(device, adv) await asyncio.sleep(0) @@ -1043,14 +1028,14 @@ async def test_process_advertisements_ignore_bad_advertisement( # The goal of this loop is to make sure that async_process_advertisements sees at least one # callback that returns False while not done.is_set(): - _get_underlying_scanner()._callback(device, adv) + inject_advertisement(device, adv) await asyncio.sleep(0) # Set the return value and mutate the advertisement # Check that scan ends and correct advertisement data is returned return_value.set() adv.service_data["00000d00-0000-1000-8000-00805f9b34fa"] = b"H\x10c" - _get_underlying_scanner()._callback(device, adv) + inject_advertisement(device, adv) await asyncio.sleep(0) result = await handle @@ -1105,20 +1090,18 @@ async def test_wrapped_instance_with_filter( empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_adv = AdvertisementData(local_name="empty") - assert _get_underlying_scanner() is not None + assert _get_manager() is not None scanner = models.HaBleakScannerWrapper( filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]} ) scanner.register_detection_callback(_device_detected) - mock_discovered = [MagicMock()] - type(_get_underlying_scanner()).discovered_devices = mock_discovered - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) await hass.async_block_till_done() discovered = await scanner.discover(timeout=0) assert len(discovered) == 1 - assert discovered == mock_discovered + assert discovered == [switchbot_device] assert len(detected) == 1 scanner.register_detection_callback(_device_detected) @@ -1128,17 +1111,17 @@ async def test_wrapped_instance_with_filter( # We should get a reply from the history when we register again assert len(detected) == 3 - type(_get_underlying_scanner()).discovered_devices = [] - discovered = await scanner.discover(timeout=0) - assert len(discovered) == 0 - assert discovered == [] + with patch_discovered_devices([]): + discovered = await scanner.discover(timeout=0) + assert len(discovered) == 0 + assert discovered == [] - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) assert len(detected) == 4 # The filter we created in the wrapped scanner with should be respected # and we should not get another callback - _get_underlying_scanner()._callback(empty_device, empty_adv) + inject_advertisement(empty_device, empty_adv) assert len(detected) == 4 @@ -1176,22 +1159,21 @@ async def test_wrapped_instance_with_service_uuids( empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_adv = AdvertisementData(local_name="empty") - assert _get_underlying_scanner() is not None + assert _get_manager() is not None scanner = models.HaBleakScannerWrapper( service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) scanner.register_detection_callback(_device_detected) - type(_get_underlying_scanner()).discovered_devices = [MagicMock()] for _ in range(2): - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) await hass.async_block_till_done() assert len(detected) == 2 # The UUIDs list we created in the wrapped scanner with should be respected # and we should not get another callback - _get_underlying_scanner()._callback(empty_device, empty_adv) + inject_advertisement(empty_device, empty_adv) assert len(detected) == 2 @@ -1229,15 +1211,15 @@ async def test_wrapped_instance_with_broken_callbacks( service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - assert _get_underlying_scanner() is not None + assert _get_manager() is not None scanner = models.HaBleakScannerWrapper( service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) scanner.register_detection_callback(_device_detected) - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) await hass.async_block_till_done() - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) await hass.async_block_till_done() assert len(detected) == 1 @@ -1275,23 +1257,22 @@ async def test_wrapped_instance_changes_uuids( empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_adv = AdvertisementData(local_name="empty") - assert _get_underlying_scanner() is not None + assert _get_manager() is not None scanner = models.HaBleakScannerWrapper() scanner.set_scanning_filter( service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) scanner.register_detection_callback(_device_detected) - type(_get_underlying_scanner()).discovered_devices = [MagicMock()] for _ in range(2): - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) await hass.async_block_till_done() assert len(detected) == 2 # The UUIDs list we created in the wrapped scanner with should be respected # and we should not get another callback - _get_underlying_scanner()._callback(empty_device, empty_adv) + inject_advertisement(empty_device, empty_adv) assert len(detected) == 2 @@ -1328,23 +1309,22 @@ async def test_wrapped_instance_changes_filters( empty_device = BLEDevice("11:22:33:44:55:62", "empty") empty_adv = AdvertisementData(local_name="empty") - assert _get_underlying_scanner() is not None + assert _get_manager() is not None scanner = models.HaBleakScannerWrapper() scanner.set_scanning_filter( filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]} ) scanner.register_detection_callback(_device_detected) - type(_get_underlying_scanner()).discovered_devices = [MagicMock()] for _ in range(2): - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) await hass.async_block_till_done() assert len(detected) == 2 # The UUIDs list we created in the wrapped scanner with should be respected # and we should not get another callback - _get_underlying_scanner()._callback(empty_device, empty_adv) + inject_advertisement(empty_device, empty_adv) assert len(detected) == 2 @@ -1363,7 +1343,7 @@ async def test_wrapped_instance_unsupported_filter( with patch.object(hass.config_entries.flow, "async_init"): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert _get_underlying_scanner() is not None + assert _get_manager() is not None scanner = models.HaBleakScannerWrapper() scanner.set_scanning_filter( filters={ @@ -1401,7 +1381,7 @@ async def test_async_ble_device_from_address(hass, mock_bleak_scanner_start): switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) - _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + inject_advertisement(switchbot_device, switchbot_adv) await hass.async_block_till_done() assert ( @@ -1501,235 +1481,7 @@ async def test_no_auto_detect_bluetooth_adapters_windows(hass): assert len(hass.config_entries.flow.async_progress(bluetooth.DOMAIN)) == 0 -async def test_raising_runtime_error_when_no_bluetooth(hass): - """Test we raise an exception if we try to get the scanner when its not there.""" - with pytest.raises(RuntimeError): - bluetooth.async_get_scanner(hass) - - async def test_getting_the_scanner_returns_the_wrapped_instance(hass, enable_bluetooth): """Test getting the scanner returns the wrapped instance.""" scanner = bluetooth.async_get_scanner(hass) assert isinstance(scanner, models.HaBleakScannerWrapper) - - -async def test_config_entry_can_be_reloaded_when_stop_raises( - hass, caplog, enable_bluetooth -): - """Test we can reload if stopping the scanner raises.""" - entry = hass.config_entries.async_entries(bluetooth.DOMAIN)[0] - assert entry.state == ConfigEntryState.LOADED - - with patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.stop", - side_effect=BleakError, - ): - await hass.config_entries.async_reload(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state == ConfigEntryState.LOADED - assert "Error stopping scanner" in caplog.text - - -async def test_changing_the_adapter_at_runtime(hass): - """Test we can change the adapter at runtime.""" - entry = MockConfigEntry( - domain=bluetooth.DOMAIN, - data={}, - options={CONF_ADAPTER: UNIX_DEFAULT_BLUETOOTH_ADAPTER}, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup" - ) as mock_setup, patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.start" - ), patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.stop" - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert "adapter" not in mock_setup.mock_calls[0][2] - - entry.options = {CONF_ADAPTER: "hci1"} - - await hass.config_entries.async_reload(entry.entry_id) - await hass.async_block_till_done() - assert mock_setup.mock_calls[1][2]["adapter"] == "hci1" - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - - -async def test_dbus_socket_missing_in_container(hass, caplog): - """Test we handle dbus being missing in the container.""" - - with patch( - "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( - hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} - ) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - assert "/run/dbus" in caplog.text - assert "docker" in caplog.text - - -async def test_dbus_socket_missing(hass, caplog): - """Test we handle dbus being missing.""" - - with patch( - "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( - hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} - ) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - assert "DBus" in caplog.text - assert "docker" not in caplog.text - - -async def test_dbus_broken_pipe_in_container(hass, caplog): - """Test we handle dbus broken pipe in the container.""" - - with patch( - "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( - hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} - ) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - assert "dbus" in caplog.text - assert "restarting" in caplog.text - assert "container" in caplog.text - - -async def test_dbus_broken_pipe(hass, caplog): - """Test we handle dbus broken pipe.""" - - with patch( - "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( - hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} - ) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - assert "DBus" in caplog.text - assert "restarting" in caplog.text - assert "container" not in caplog.text - - -async def test_invalid_dbus_message(hass, caplog): - """Test we handle invalid dbus message.""" - - with patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.async_setup" - ), patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.start", - side_effect=InvalidMessageError, - ): - assert await async_setup_component( - hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} - ) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - assert "dbus" in caplog.text - - -async def test_recovery_from_dbus_restart( - hass, mock_bleak_scanner_start, enable_bluetooth -): - """Test we can recover when DBus gets restarted out from under us.""" - assert await async_setup_component(hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}) - await hass.async_block_till_done() - assert len(mock_bleak_scanner_start.mock_calls) == 1 - - start_time_monotonic = 1000 - scanner = _get_underlying_scanner() - mock_discovered = [MagicMock()] - type(scanner).discovered_devices = mock_discovered - - # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() - - assert len(mock_bleak_scanner_start.mock_calls) == 1 - - # Fire a callback to reset the timer - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=start_time_monotonic, - ): - scanner._callback( - BLEDevice("44:44:33:11:23:42", "any_name"), - AdvertisementData(local_name="any_name"), - ) - - # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() - - assert len(mock_bleak_scanner_start.mock_calls) == 1 - - # We hit the timer, so we restart the scanner - with patch( - "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) - await hass.async_block_till_done() - - assert len(mock_bleak_scanner_start.mock_calls) == 2 diff --git a/tests/components/bluetooth/test_passive_update_coordinator.py b/tests/components/bluetooth/test_passive_update_coordinator.py index 12531c52e40..9a90f99d11b 100644 --- a/tests/components/bluetooth/test_passive_update_coordinator.py +++ b/tests/components/bluetooth/test_passive_update_coordinator.py @@ -20,7 +20,7 @@ from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import _get_underlying_scanner +from . import _get_manager, patch_all_discovered_devices from tests.common import async_fire_time_changed @@ -178,11 +178,10 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable( saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) assert coordinator.available is True - scanner = _get_underlying_scanner() + scanner = _get_manager() - with patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices", - [MagicMock(address="44:44:33:11:23:45")], + with patch_all_discovered_devices( + [MagicMock(address="44:44:33:11:23:45")] ), patch.object( scanner, "history", @@ -197,9 +196,8 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable( saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) assert coordinator.available is True - with patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices", - [MagicMock(address="44:44:33:11:23:45")], + with patch_all_discovered_devices( + [MagicMock(address="44:44:33:11:23:45")] ), patch.object( scanner, "history", diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 6b21d1aa32c..ac35e9f2bee 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -32,7 +32,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import _get_underlying_scanner +from . import _get_manager, patch_all_discovered_devices from tests.common import MockEntityPlatform, async_fire_time_changed @@ -246,11 +246,9 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start): assert len(mock_add_entities.mock_calls) == 1 assert coordinator.available is True assert processor.available is True - scanner = _get_underlying_scanner() - - with patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices", - [MagicMock(address="44:44:33:11:23:45")], + scanner = _get_manager() + with patch_all_discovered_devices( + [MagicMock(address="44:44:33:11:23:45")] ), patch.object( scanner, "history", @@ -268,9 +266,8 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start): assert coordinator.available is True assert processor.available is True - with patch( - "homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices", - [MagicMock(address="44:44:33:11:23:45")], + with patch_all_discovered_devices( + [MagicMock(address="44:44:33:11:23:45")] ), patch.object( scanner, "history", diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py new file mode 100644 index 00000000000..032b67662df --- /dev/null +++ b/tests/components/bluetooth/test_scanner.py @@ -0,0 +1,264 @@ +"""Tests for the Bluetooth integration scanners.""" +from unittest.mock import MagicMock, patch + +from bleak import BleakError +from bleak.backends.scanner import ( + AdvertisementData, + AdvertisementDataCallback, + BLEDevice, +) +from dbus_next import InvalidMessageError + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth.const import ( + CONF_ADAPTER, + SCANNER_WATCHDOG_INTERVAL, + SCANNER_WATCHDOG_TIMEOUT, + UNIX_DEFAULT_BLUETOOTH_ADAPTER, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from . import _get_manager + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_config_entry_can_be_reloaded_when_stop_raises( + hass, caplog, enable_bluetooth +): + """Test we can reload if stopping the scanner raises.""" + entry = hass.config_entries.async_entries(bluetooth.DOMAIN)[0] + assert entry.state == ConfigEntryState.LOADED + + with patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + side_effect=BleakError, + ): + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + assert "Error stopping scanner" in caplog.text + + +async def test_changing_the_adapter_at_runtime(hass): + """Test we can change the adapter at runtime.""" + entry = MockConfigEntry( + domain=bluetooth.DOMAIN, + data={}, + options={CONF_ADAPTER: UNIX_DEFAULT_BLUETOOTH_ADAPTER}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start" + ), patch("homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop"): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entry.options = {CONF_ADAPTER: "hci1"} + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + +async def test_dbus_socket_missing_in_container(hass, caplog): + """Test we handle dbus being missing in the container.""" + + with patch( + "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=True + ), patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + side_effect=FileNotFoundError, + ): + assert await async_setup_component( + hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} + ) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert "/run/dbus" in caplog.text + assert "docker" in caplog.text + + +async def test_dbus_socket_missing(hass, caplog): + """Test we handle dbus being missing.""" + + with patch( + "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=False + ), patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + side_effect=FileNotFoundError, + ): + assert await async_setup_component( + hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} + ) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert "DBus" in caplog.text + assert "docker" not in caplog.text + + +async def test_dbus_broken_pipe_in_container(hass, caplog): + """Test we handle dbus broken pipe in the container.""" + + with patch( + "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=True + ), patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + side_effect=BrokenPipeError, + ): + assert await async_setup_component( + hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} + ) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert "dbus" in caplog.text + assert "restarting" in caplog.text + assert "container" in caplog.text + + +async def test_dbus_broken_pipe(hass, caplog): + """Test we handle dbus broken pipe.""" + + with patch( + "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=False + ), patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + side_effect=BrokenPipeError, + ): + assert await async_setup_component( + hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} + ) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert "DBus" in caplog.text + assert "restarting" in caplog.text + assert "container" not in caplog.text + + +async def test_invalid_dbus_message(hass, caplog): + """Test we handle invalid dbus message.""" + + with patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + side_effect=InvalidMessageError, + ): + assert await async_setup_component( + hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} + ) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert "dbus" in caplog.text + + +async def test_recovery_from_dbus_restart(hass): + """Test we can recover when DBus gets restarted out from under us.""" + + called_start = 0 + called_stop = 0 + _callback = None + mock_discovered = [] + + class MockBleakScanner: + async def start(self, *args, **kwargs): + """Mock Start.""" + nonlocal called_start + called_start += 1 + + async def stop(self, *args, **kwargs): + """Mock Start.""" + nonlocal called_stop + called_stop += 1 + + @property + def discovered_devices(self): + """Mock discovered_devices.""" + nonlocal mock_discovered + return mock_discovered + + def register_detection_callback(self, callback: AdvertisementDataCallback): + """Mock Register Detection Callback.""" + nonlocal _callback + _callback = callback + + scanner = MockBleakScanner() + + with patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + return_value=scanner, + ): + assert await async_setup_component( + hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} + ) + await hass.async_block_till_done() + assert called_start == 1 + + start_time_monotonic = 1000 + scanner = _get_manager() + mock_discovered = [MagicMock()] + + # Ensure we don't restart the scanner if we don't need to + with patch( + "homeassistant.components.bluetooth.scanner.MONOTONIC_TIME", + return_value=start_time_monotonic + 10, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() + + assert called_start == 1 + + # Fire a callback to reset the timer + with patch( + "homeassistant.components.bluetooth.scanner.MONOTONIC_TIME", + return_value=start_time_monotonic, + ): + _callback( + BLEDevice("44:44:33:11:23:42", "any_name"), + AdvertisementData(local_name="any_name"), + ) + + # Ensure we don't restart the scanner if we don't need to + with patch( + "homeassistant.components.bluetooth.scanner.MONOTONIC_TIME", + return_value=start_time_monotonic + 20, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() + + assert called_start == 1 + + # We hit the timer, so we restart the scanner + with patch( + "homeassistant.components.bluetooth.scanner.MONOTONIC_TIME", + return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() + + assert called_start == 2 diff --git a/tests/conftest.py b/tests/conftest.py index 3b43fcd14ba..4c268206805 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -895,26 +895,18 @@ def mock_bleak_scanner_start(): # Late imports to avoid loading bleak unless we need it - import bleak # pylint: disable=import-outside-toplevel - from homeassistant.components.bluetooth import ( # pylint: disable=import-outside-toplevel - models as bluetooth_models, + scanner as bluetooth_scanner, ) - scanner = bleak.BleakScanner - bluetooth_models.HA_BLEAK_SCANNER = None - - 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 - # We need to drop the stop method from the object since we patched # out start and this fixture will expire before the stop method is called # when EVENT_HOMEASSISTANT_STOP is fired. - if bluetooth_models.HA_BLEAK_SCANNER: - bluetooth_models.HA_BLEAK_SCANNER.stop = AsyncMock() - bleak.BleakScanner = scanner + bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() + with patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + ) as mock_bleak_scanner_start: + yield mock_bleak_scanner_start @pytest.fixture(name="mock_bluetooth")