Rework bluetooth to support scans from multiple sources (#76900)

This commit is contained in:
J. Nick Koston 2022-08-17 10:51:56 -10:00 committed by GitHub
parent ff7ef7e526
commit 3bcc274dfa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 805 additions and 685 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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
)

View File

@ -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)

View File

@ -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
)

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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

View File

@ -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")