mirror of
https://github.com/home-assistant/core.git
synced 2025-04-25 17:57:55 +00:00
Rework bluetooth to support scans from multiple sources (#76900)
This commit is contained in:
parent
ff7ef7e526
commit
3bcc274dfa
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
241
homeassistant/components/bluetooth/scanner.py
Normal file
241
homeassistant/components/bluetooth/scanner.py
Normal 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)
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
264
tests/components/bluetooth/test_scanner.py
Normal file
264
tests/components/bluetooth/test_scanner.py
Normal 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
|
@ -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")
|
||||
|
Loading…
x
Reference in New Issue
Block a user