Reorganize bluetooth integration to prepare for remote and multi-adapter support (#76883)
This commit is contained in:
parent
8c62713af3
commit
6f3cdb6db1
8 changed files with 542 additions and 461 deletions
|
@ -1,114 +1,51 @@
|
|||
"""The bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from asyncio import Future
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import async_timeout
|
||||
from bleak import BleakError
|
||||
from dbus_next import InvalidMessageError
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
HomeAssistant,
|
||||
callback as hass_callback,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.core import HomeAssistant, callback as hass_callback
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
|
||||
from homeassistant.loader import async_get_bluetooth
|
||||
from homeassistant.util.package import is_docker_env
|
||||
|
||||
from . import models
|
||||
from .const import CONF_ADAPTER, DEFAULT_ADAPTERS, DOMAIN
|
||||
from .match import (
|
||||
ADDRESS,
|
||||
BluetoothCallbackMatcher,
|
||||
IntegrationMatcher,
|
||||
ble_device_matches,
|
||||
from .const import CONF_ADAPTER, DOMAIN, SOURCE_LOCAL
|
||||
from .manager import BluetoothManager
|
||||
from .match import BluetoothCallbackMatcher, IntegrationMatcher
|
||||
from .models import (
|
||||
BluetoothCallback,
|
||||
BluetoothChange,
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfo,
|
||||
BluetoothServiceInfoBleak,
|
||||
HaBleakScannerWrapper,
|
||||
ProcessAdvertisementCallback,
|
||||
)
|
||||
from .models import HaBleakScanner, HaBleakScannerWrapper
|
||||
from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher
|
||||
from .util import async_get_bluetooth_adapters
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5
|
||||
START_TIMEOUT = 9
|
||||
|
||||
SOURCE_LOCAL: Final = "local"
|
||||
|
||||
SCANNER_WATCHDOG_TIMEOUT: Final = 60 * 5
|
||||
SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=SCANNER_WATCHDOG_TIMEOUT)
|
||||
MONOTONIC_TIME = time.monotonic
|
||||
|
||||
|
||||
@dataclass
|
||||
class BluetoothServiceInfoBleak(BluetoothServiceInfo):
|
||||
"""BluetoothServiceInfo with bleak data.
|
||||
|
||||
Integrations may need BLEDevice and AdvertisementData
|
||||
to connect to the device without having bleak trigger
|
||||
another scan to translate the address to the system's
|
||||
internal details.
|
||||
"""
|
||||
|
||||
device: BLEDevice
|
||||
advertisement: AdvertisementData
|
||||
|
||||
@classmethod
|
||||
def from_advertisement(
|
||||
cls, device: BLEDevice, advertisement_data: AdvertisementData, source: str
|
||||
) -> BluetoothServiceInfoBleak:
|
||||
"""Create a BluetoothServiceInfoBleak from an advertisement."""
|
||||
return cls(
|
||||
name=advertisement_data.local_name or device.name or device.address,
|
||||
address=device.address,
|
||||
rssi=device.rssi,
|
||||
manufacturer_data=advertisement_data.manufacturer_data,
|
||||
service_data=advertisement_data.service_data,
|
||||
service_uuids=advertisement_data.service_uuids,
|
||||
source=source,
|
||||
device=device,
|
||||
advertisement=advertisement_data,
|
||||
)
|
||||
|
||||
|
||||
class BluetoothScanningMode(Enum):
|
||||
"""The mode of scanning for bluetooth devices."""
|
||||
|
||||
PASSIVE = "passive"
|
||||
ACTIVE = "active"
|
||||
|
||||
|
||||
SCANNING_MODE_TO_BLEAK = {
|
||||
BluetoothScanningMode.ACTIVE: "active",
|
||||
BluetoothScanningMode.PASSIVE: "passive",
|
||||
}
|
||||
|
||||
|
||||
BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT")
|
||||
BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None]
|
||||
ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool]
|
||||
__all__ = [
|
||||
"async_ble_device_from_address",
|
||||
"async_discovered_service_info",
|
||||
"async_get_scanner",
|
||||
"async_process_advertisements",
|
||||
"async_rediscover_address",
|
||||
"async_register_callback",
|
||||
"async_track_unavailable",
|
||||
"BluetoothServiceInfo",
|
||||
"BluetoothServiceInfoBleak",
|
||||
"BluetoothScanningMode",
|
||||
"BluetoothCallback",
|
||||
"SOURCE_LOCAL",
|
||||
]
|
||||
|
||||
|
||||
@hass_callback
|
||||
|
@ -287,329 +224,3 @@ async def async_unload_entry(
|
|||
manager.async_start_reload()
|
||||
await manager.async_stop()
|
||||
return True
|
||||
|
||||
|
||||
class BluetoothManager:
|
||||
"""Manage Bluetooth."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
integration_matcher: IntegrationMatcher,
|
||||
) -> None:
|
||||
"""Init bluetooth discovery."""
|
||||
self.hass = hass
|
||||
self._integration_matcher = integration_matcher
|
||||
self.scanner: HaBleakScanner | None = None
|
||||
self.start_stop_lock = asyncio.Lock()
|
||||
self._cancel_device_detected: CALLBACK_TYPE | None = None
|
||||
self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None
|
||||
self._cancel_stop: CALLBACK_TYPE | None = None
|
||||
self._cancel_watchdog: CALLBACK_TYPE | None = None
|
||||
self._unavailable_callbacks: dict[str, list[Callable[[str], None]]] = {}
|
||||
self._callbacks: list[
|
||||
tuple[BluetoothCallback, BluetoothCallbackMatcher | None]
|
||||
] = []
|
||||
self._last_detection = 0.0
|
||||
self._reloading = False
|
||||
self._adapter: str | None = None
|
||||
self._scanning_mode = BluetoothScanningMode.ACTIVE
|
||||
|
||||
@hass_callback
|
||||
def async_setup(self) -> None:
|
||||
"""Set up the bluetooth manager."""
|
||||
models.HA_BLEAK_SCANNER = self.scanner = HaBleakScanner()
|
||||
|
||||
@hass_callback
|
||||
def async_get_scanner(self) -> HaBleakScannerWrapper:
|
||||
"""Get the scanner."""
|
||||
return HaBleakScannerWrapper()
|
||||
|
||||
@hass_callback
|
||||
def async_start_reload(self) -> None:
|
||||
"""Start reloading."""
|
||||
self._reloading = True
|
||||
|
||||
async def async_start(
|
||||
self, scanning_mode: BluetoothScanningMode, adapter: str | None
|
||||
) -> None:
|
||||
"""Set up BT Discovery."""
|
||||
assert self.scanner is not None
|
||||
self._adapter = adapter
|
||||
self._scanning_mode = scanning_mode
|
||||
if self._reloading:
|
||||
# On reload, we need to reset the scanner instance
|
||||
# since the devices in its history may not be reachable
|
||||
# anymore.
|
||||
self.scanner.async_reset()
|
||||
self._integration_matcher.async_clear_history()
|
||||
self._reloading = False
|
||||
scanner_kwargs = {"scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode]}
|
||||
if adapter and adapter not in DEFAULT_ADAPTERS:
|
||||
scanner_kwargs["adapter"] = adapter
|
||||
_LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs)
|
||||
try:
|
||||
self.scanner.async_setup(**scanner_kwargs)
|
||||
except (FileNotFoundError, BleakError) as ex:
|
||||
raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex
|
||||
install_multiple_bleak_catcher()
|
||||
# We have to start it right away as some integrations might
|
||||
# need it straight away.
|
||||
_LOGGER.debug("Starting bluetooth scanner")
|
||||
self.scanner.register_detection_callback(self.scanner.async_callback_dispatcher)
|
||||
self._cancel_device_detected = self.scanner.async_register_callback(
|
||||
self._device_detected, {}
|
||||
)
|
||||
try:
|
||||
async with async_timeout.timeout(START_TIMEOUT):
|
||||
await self.scanner.start() # type: ignore[no-untyped-call]
|
||||
except InvalidMessageError as ex:
|
||||
self._async_cancel_scanner_callback()
|
||||
_LOGGER.debug("Invalid DBus message received: %s", ex, exc_info=True)
|
||||
raise ConfigEntryNotReady(
|
||||
f"Invalid DBus message received: {ex}; try restarting `dbus`"
|
||||
) from ex
|
||||
except BrokenPipeError as ex:
|
||||
self._async_cancel_scanner_callback()
|
||||
_LOGGER.debug("DBus connection broken: %s", ex, exc_info=True)
|
||||
if is_docker_env():
|
||||
raise ConfigEntryNotReady(
|
||||
f"DBus connection broken: {ex}; try restarting `bluetooth`, `dbus`, and finally the docker container"
|
||||
) from ex
|
||||
raise ConfigEntryNotReady(
|
||||
f"DBus connection broken: {ex}; try restarting `bluetooth` and `dbus`"
|
||||
) from ex
|
||||
except FileNotFoundError as ex:
|
||||
self._async_cancel_scanner_callback()
|
||||
_LOGGER.debug(
|
||||
"FileNotFoundError while starting bluetooth: %s", ex, exc_info=True
|
||||
)
|
||||
if is_docker_env():
|
||||
raise ConfigEntryNotReady(
|
||||
f"DBus service not found; docker config may be missing `-v /run/dbus:/run/dbus:ro`: {ex}"
|
||||
) from ex
|
||||
raise ConfigEntryNotReady(
|
||||
f"DBus service not found; make sure the DBus socket is available to Home Assistant: {ex}"
|
||||
) from ex
|
||||
except asyncio.TimeoutError as ex:
|
||||
self._async_cancel_scanner_callback()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Timed out starting Bluetooth after {START_TIMEOUT} seconds"
|
||||
) from ex
|
||||
except BleakError as ex:
|
||||
self._async_cancel_scanner_callback()
|
||||
_LOGGER.debug("BleakError while starting bluetooth: %s", ex, exc_info=True)
|
||||
raise ConfigEntryNotReady(f"Failed to start Bluetooth: {ex}") from ex
|
||||
self.async_setup_unavailable_tracking()
|
||||
self._async_setup_scanner_watchdog()
|
||||
self._cancel_stop = self.hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, self._async_hass_stopping
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
def _async_setup_scanner_watchdog(self) -> None:
|
||||
"""If Dbus gets restarted or updated, we need to restart the scanner."""
|
||||
self._last_detection = MONOTONIC_TIME()
|
||||
self._cancel_watchdog = async_track_time_interval(
|
||||
self.hass, self._async_scanner_watchdog, SCANNER_WATCHDOG_INTERVAL
|
||||
)
|
||||
|
||||
async def _async_scanner_watchdog(self, now: datetime) -> None:
|
||||
"""Check if the scanner is running."""
|
||||
time_since_last_detection = MONOTONIC_TIME() - self._last_detection
|
||||
if time_since_last_detection < SCANNER_WATCHDOG_TIMEOUT:
|
||||
return
|
||||
_LOGGER.info(
|
||||
"Bluetooth scanner has gone quiet for %s, restarting",
|
||||
SCANNER_WATCHDOG_INTERVAL,
|
||||
)
|
||||
async with self.start_stop_lock:
|
||||
self.async_start_reload()
|
||||
await self.async_stop()
|
||||
await self.async_start(self._scanning_mode, self._adapter)
|
||||
|
||||
@hass_callback
|
||||
def async_setup_unavailable_tracking(self) -> None:
|
||||
"""Set up the unavailable tracking."""
|
||||
|
||||
@hass_callback
|
||||
def _async_check_unavailable(now: datetime) -> None:
|
||||
"""Watch for unavailable devices."""
|
||||
scanner = self.scanner
|
||||
assert scanner is not None
|
||||
history = set(scanner.history)
|
||||
active = {device.address for device in scanner.discovered_devices}
|
||||
disappeared = history.difference(active)
|
||||
for address in disappeared:
|
||||
del scanner.history[address]
|
||||
if not (callbacks := self._unavailable_callbacks.get(address)):
|
||||
continue
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(address)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Error in unavailable callback")
|
||||
|
||||
self._cancel_unavailable_tracking = async_track_time_interval(
|
||||
self.hass,
|
||||
_async_check_unavailable,
|
||||
timedelta(seconds=UNAVAILABLE_TRACK_SECONDS),
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
def _device_detected(
|
||||
self, device: BLEDevice, advertisement_data: AdvertisementData
|
||||
) -> None:
|
||||
"""Handle a detected device."""
|
||||
self._last_detection = MONOTONIC_TIME()
|
||||
matched_domains = self._integration_matcher.match_domains(
|
||||
device, advertisement_data
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Device detected: %s with advertisement_data: %s matched domains: %s",
|
||||
device.address,
|
||||
advertisement_data,
|
||||
matched_domains,
|
||||
)
|
||||
|
||||
if not matched_domains and not self._callbacks:
|
||||
return
|
||||
|
||||
service_info: BluetoothServiceInfoBleak | None = None
|
||||
for callback, matcher in self._callbacks:
|
||||
if matcher is None or ble_device_matches(
|
||||
matcher, device, advertisement_data
|
||||
):
|
||||
if service_info is None:
|
||||
service_info = BluetoothServiceInfoBleak.from_advertisement(
|
||||
device, advertisement_data, SOURCE_LOCAL
|
||||
)
|
||||
try:
|
||||
callback(service_info, BluetoothChange.ADVERTISEMENT)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Error in bluetooth callback")
|
||||
|
||||
if not matched_domains:
|
||||
return
|
||||
if service_info is None:
|
||||
service_info = BluetoothServiceInfoBleak.from_advertisement(
|
||||
device, advertisement_data, SOURCE_LOCAL
|
||||
)
|
||||
for domain in matched_domains:
|
||||
discovery_flow.async_create_flow(
|
||||
self.hass,
|
||||
domain,
|
||||
{"source": config_entries.SOURCE_BLUETOOTH},
|
||||
service_info,
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
def async_track_unavailable(
|
||||
self, callback: Callable[[str], None], address: str
|
||||
) -> Callable[[], None]:
|
||||
"""Register a callback."""
|
||||
self._unavailable_callbacks.setdefault(address, []).append(callback)
|
||||
|
||||
@hass_callback
|
||||
def _async_remove_callback() -> None:
|
||||
self._unavailable_callbacks[address].remove(callback)
|
||||
if not self._unavailable_callbacks[address]:
|
||||
del self._unavailable_callbacks[address]
|
||||
|
||||
return _async_remove_callback
|
||||
|
||||
@hass_callback
|
||||
def async_register_callback(
|
||||
self,
|
||||
callback: BluetoothCallback,
|
||||
matcher: BluetoothCallbackMatcher | None = None,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a callback."""
|
||||
callback_entry = (callback, matcher)
|
||||
self._callbacks.append(callback_entry)
|
||||
|
||||
@hass_callback
|
||||
def _async_remove_callback() -> None:
|
||||
self._callbacks.remove(callback_entry)
|
||||
|
||||
# If we have history for the subscriber, we can trigger the callback
|
||||
# immediately with the last packet so the subscriber can see the
|
||||
# device.
|
||||
if (
|
||||
matcher
|
||||
and (address := matcher.get(ADDRESS))
|
||||
and self.scanner
|
||||
and (device_adv_data := self.scanner.history.get(address))
|
||||
):
|
||||
try:
|
||||
callback(
|
||||
BluetoothServiceInfoBleak.from_advertisement(
|
||||
*device_adv_data, SOURCE_LOCAL
|
||||
),
|
||||
BluetoothChange.ADVERTISEMENT,
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Error in bluetooth callback")
|
||||
|
||||
return _async_remove_callback
|
||||
|
||||
@hass_callback
|
||||
def async_ble_device_from_address(self, address: str) -> BLEDevice | None:
|
||||
"""Return the BLEDevice if present."""
|
||||
if self.scanner and (ble_adv := self.scanner.history.get(address)):
|
||||
return ble_adv[0]
|
||||
return None
|
||||
|
||||
@hass_callback
|
||||
def async_address_present(self, address: str) -> bool:
|
||||
"""Return if the address is present."""
|
||||
return bool(self.scanner and address in self.scanner.history)
|
||||
|
||||
@hass_callback
|
||||
def async_discovered_service_info(self) -> list[BluetoothServiceInfoBleak]:
|
||||
"""Return if the address is present."""
|
||||
assert self.scanner is not None
|
||||
return [
|
||||
BluetoothServiceInfoBleak.from_advertisement(*device_adv, SOURCE_LOCAL)
|
||||
for device_adv in self.scanner.history.values()
|
||||
]
|
||||
|
||||
async def _async_hass_stopping(self, event: Event) -> None:
|
||||
"""Stop the Bluetooth integration at shutdown."""
|
||||
self._cancel_stop = None
|
||||
await self.async_stop()
|
||||
|
||||
@hass_callback
|
||||
def _async_cancel_scanner_callback(self) -> None:
|
||||
"""Cancel the scanner callback."""
|
||||
if self._cancel_device_detected:
|
||||
self._cancel_device_detected()
|
||||
self._cancel_device_detected = None
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
"""Stop bluetooth discovery."""
|
||||
_LOGGER.debug("Stopping bluetooth discovery")
|
||||
if self._cancel_watchdog:
|
||||
self._cancel_watchdog()
|
||||
self._cancel_watchdog = None
|
||||
self._async_cancel_scanner_callback()
|
||||
if self._cancel_unavailable_tracking:
|
||||
self._cancel_unavailable_tracking()
|
||||
self._cancel_unavailable_tracking = None
|
||||
if self._cancel_stop:
|
||||
self._cancel_stop()
|
||||
self._cancel_stop = None
|
||||
if self.scanner:
|
||||
try:
|
||||
await self.scanner.stop() # type: ignore[no-untyped-call]
|
||||
except BleakError as ex:
|
||||
# This is not fatal, and they may want to reload
|
||||
# the config entry to restart the scanner if they
|
||||
# change the bluetooth dongle.
|
||||
_LOGGER.error("Error stopping scanner: %s", ex)
|
||||
uninstall_multiple_bleak_catcher()
|
||||
|
||||
@hass_callback
|
||||
def async_rediscover_address(self, address: str) -> None:
|
||||
"""Trigger discovery of devices which have already been seen."""
|
||||
self._integration_matcher.async_clear_address(address)
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
"""Constants for the Bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
DOMAIN = "bluetooth"
|
||||
DEFAULT_NAME = "Bluetooth"
|
||||
|
@ -9,3 +13,11 @@ MACOS_DEFAULT_BLUETOOTH_ADAPTER = "CoreBluetooth"
|
|||
UNIX_DEFAULT_BLUETOOTH_ADAPTER = "hci0"
|
||||
|
||||
DEFAULT_ADAPTERS = {MACOS_DEFAULT_BLUETOOTH_ADAPTER, UNIX_DEFAULT_BLUETOOTH_ADAPTER}
|
||||
|
||||
SOURCE_LOCAL: Final = "local"
|
||||
|
||||
|
||||
UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5
|
||||
START_TIMEOUT = 12
|
||||
SCANNER_WATCHDOG_TIMEOUT: Final = 60 * 5
|
||||
SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=SCANNER_WATCHDOG_TIMEOUT)
|
||||
|
|
393
homeassistant/components/bluetooth/manager.py
Normal file
393
homeassistant/components/bluetooth/manager.py
Normal file
|
@ -0,0 +1,393 @@
|
|||
"""The bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import async_timeout
|
||||
from bleak import BleakError
|
||||
from dbus_next import InvalidMessageError
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
HomeAssistant,
|
||||
callback as hass_callback,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.util.package import is_docker_env
|
||||
|
||||
from . import models
|
||||
from .const import (
|
||||
DEFAULT_ADAPTERS,
|
||||
SCANNER_WATCHDOG_INTERVAL,
|
||||
SCANNER_WATCHDOG_TIMEOUT,
|
||||
SOURCE_LOCAL,
|
||||
START_TIMEOUT,
|
||||
UNAVAILABLE_TRACK_SECONDS,
|
||||
)
|
||||
from .match import (
|
||||
ADDRESS,
|
||||
BluetoothCallbackMatcher,
|
||||
IntegrationMatcher,
|
||||
ble_device_matches,
|
||||
)
|
||||
from .models import (
|
||||
BluetoothCallback,
|
||||
BluetoothChange,
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfoBleak,
|
||||
HaBleakScanner,
|
||||
HaBleakScannerWrapper,
|
||||
)
|
||||
from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MONOTONIC_TIME = time.monotonic
|
||||
|
||||
|
||||
SCANNING_MODE_TO_BLEAK = {
|
||||
BluetoothScanningMode.ACTIVE: "active",
|
||||
BluetoothScanningMode.PASSIVE: "passive",
|
||||
}
|
||||
|
||||
|
||||
class BluetoothManager:
|
||||
"""Manage Bluetooth."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
integration_matcher: IntegrationMatcher,
|
||||
) -> None:
|
||||
"""Init bluetooth discovery."""
|
||||
self.hass = hass
|
||||
self._integration_matcher = integration_matcher
|
||||
self.scanner: HaBleakScanner | None = None
|
||||
self.start_stop_lock = asyncio.Lock()
|
||||
self._cancel_device_detected: CALLBACK_TYPE | None = None
|
||||
self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None
|
||||
self._cancel_stop: CALLBACK_TYPE | None = None
|
||||
self._cancel_watchdog: CALLBACK_TYPE | None = None
|
||||
self._unavailable_callbacks: dict[str, list[Callable[[str], None]]] = {}
|
||||
self._callbacks: list[
|
||||
tuple[BluetoothCallback, BluetoothCallbackMatcher | None]
|
||||
] = []
|
||||
self._last_detection = 0.0
|
||||
self._reloading = False
|
||||
self._adapter: str | None = None
|
||||
self._scanning_mode = BluetoothScanningMode.ACTIVE
|
||||
|
||||
@hass_callback
|
||||
def async_setup(self) -> None:
|
||||
"""Set up the bluetooth manager."""
|
||||
models.HA_BLEAK_SCANNER = self.scanner = HaBleakScanner()
|
||||
|
||||
@hass_callback
|
||||
def async_get_scanner(self) -> HaBleakScannerWrapper:
|
||||
"""Get the scanner."""
|
||||
return HaBleakScannerWrapper()
|
||||
|
||||
@hass_callback
|
||||
def async_start_reload(self) -> None:
|
||||
"""Start reloading."""
|
||||
self._reloading = True
|
||||
|
||||
async def async_start(
|
||||
self, scanning_mode: BluetoothScanningMode, adapter: str | None
|
||||
) -> None:
|
||||
"""Set up BT Discovery."""
|
||||
assert self.scanner is not None
|
||||
self._adapter = adapter
|
||||
self._scanning_mode = scanning_mode
|
||||
if self._reloading:
|
||||
# On reload, we need to reset the scanner instance
|
||||
# since the devices in its history may not be reachable
|
||||
# anymore.
|
||||
self.scanner.async_reset()
|
||||
self._integration_matcher.async_clear_history()
|
||||
self._reloading = False
|
||||
scanner_kwargs = {"scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode]}
|
||||
if adapter and adapter not in DEFAULT_ADAPTERS:
|
||||
scanner_kwargs["adapter"] = adapter
|
||||
_LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs)
|
||||
try:
|
||||
self.scanner.async_setup(**scanner_kwargs)
|
||||
except (FileNotFoundError, BleakError) as ex:
|
||||
raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex
|
||||
install_multiple_bleak_catcher()
|
||||
# We have to start it right away as some integrations might
|
||||
# need it straight away.
|
||||
_LOGGER.debug("Starting bluetooth scanner")
|
||||
self.scanner.register_detection_callback(self.scanner.async_callback_dispatcher)
|
||||
self._cancel_device_detected = self.scanner.async_register_callback(
|
||||
self._device_detected, {}
|
||||
)
|
||||
try:
|
||||
async with async_timeout.timeout(START_TIMEOUT):
|
||||
await self.scanner.start() # type: ignore[no-untyped-call]
|
||||
except InvalidMessageError as ex:
|
||||
self._async_cancel_scanner_callback()
|
||||
_LOGGER.debug("Invalid DBus message received: %s", ex, exc_info=True)
|
||||
raise ConfigEntryNotReady(
|
||||
f"Invalid DBus message received: {ex}; try restarting `dbus`"
|
||||
) from ex
|
||||
except BrokenPipeError as ex:
|
||||
self._async_cancel_scanner_callback()
|
||||
_LOGGER.debug("DBus connection broken: %s", ex, exc_info=True)
|
||||
if is_docker_env():
|
||||
raise ConfigEntryNotReady(
|
||||
f"DBus connection broken: {ex}; try restarting `bluetooth`, `dbus`, and finally the docker container"
|
||||
) from ex
|
||||
raise ConfigEntryNotReady(
|
||||
f"DBus connection broken: {ex}; try restarting `bluetooth` and `dbus`"
|
||||
) from ex
|
||||
except FileNotFoundError as ex:
|
||||
self._async_cancel_scanner_callback()
|
||||
_LOGGER.debug(
|
||||
"FileNotFoundError while starting bluetooth: %s", ex, exc_info=True
|
||||
)
|
||||
if is_docker_env():
|
||||
raise ConfigEntryNotReady(
|
||||
f"DBus service not found; docker config may be missing `-v /run/dbus:/run/dbus:ro`: {ex}"
|
||||
) from ex
|
||||
raise ConfigEntryNotReady(
|
||||
f"DBus service not found; make sure the DBus socket is available to Home Assistant: {ex}"
|
||||
) from ex
|
||||
except asyncio.TimeoutError as ex:
|
||||
self._async_cancel_scanner_callback()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Timed out starting Bluetooth after {START_TIMEOUT} seconds"
|
||||
) from ex
|
||||
except BleakError as ex:
|
||||
self._async_cancel_scanner_callback()
|
||||
_LOGGER.debug("BleakError while starting bluetooth: %s", ex, exc_info=True)
|
||||
raise ConfigEntryNotReady(f"Failed to start Bluetooth: {ex}") from ex
|
||||
self.async_setup_unavailable_tracking()
|
||||
self._async_setup_scanner_watchdog()
|
||||
self._cancel_stop = self.hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, self._async_hass_stopping
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
def _async_setup_scanner_watchdog(self) -> None:
|
||||
"""If Dbus gets restarted or updated, we need to restart the scanner."""
|
||||
self._last_detection = MONOTONIC_TIME()
|
||||
self._cancel_watchdog = async_track_time_interval(
|
||||
self.hass, self._async_scanner_watchdog, SCANNER_WATCHDOG_INTERVAL
|
||||
)
|
||||
|
||||
async def _async_scanner_watchdog(self, now: datetime) -> None:
|
||||
"""Check if the scanner is running."""
|
||||
time_since_last_detection = MONOTONIC_TIME() - self._last_detection
|
||||
if time_since_last_detection < SCANNER_WATCHDOG_TIMEOUT:
|
||||
return
|
||||
_LOGGER.info(
|
||||
"Bluetooth scanner has gone quiet for %s, restarting",
|
||||
SCANNER_WATCHDOG_INTERVAL,
|
||||
)
|
||||
async with self.start_stop_lock:
|
||||
self.async_start_reload()
|
||||
await self.async_stop()
|
||||
await self.async_start(self._scanning_mode, self._adapter)
|
||||
|
||||
@hass_callback
|
||||
def async_setup_unavailable_tracking(self) -> None:
|
||||
"""Set up the unavailable tracking."""
|
||||
|
||||
@hass_callback
|
||||
def _async_check_unavailable(now: datetime) -> None:
|
||||
"""Watch for unavailable devices."""
|
||||
scanner = self.scanner
|
||||
assert scanner is not None
|
||||
history = set(scanner.history)
|
||||
active = {device.address for device in scanner.discovered_devices}
|
||||
disappeared = history.difference(active)
|
||||
for address in disappeared:
|
||||
del scanner.history[address]
|
||||
if not (callbacks := self._unavailable_callbacks.get(address)):
|
||||
continue
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(address)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Error in unavailable callback")
|
||||
|
||||
self._cancel_unavailable_tracking = async_track_time_interval(
|
||||
self.hass,
|
||||
_async_check_unavailable,
|
||||
timedelta(seconds=UNAVAILABLE_TRACK_SECONDS),
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
def _device_detected(
|
||||
self, device: BLEDevice, advertisement_data: AdvertisementData
|
||||
) -> None:
|
||||
"""Handle a detected device."""
|
||||
self._last_detection = MONOTONIC_TIME()
|
||||
matched_domains = self._integration_matcher.match_domains(
|
||||
device, advertisement_data
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Device detected: %s with advertisement_data: %s matched domains: %s",
|
||||
device.address,
|
||||
advertisement_data,
|
||||
matched_domains,
|
||||
)
|
||||
|
||||
if not matched_domains and not self._callbacks:
|
||||
return
|
||||
|
||||
service_info: BluetoothServiceInfoBleak | None = None
|
||||
for callback, matcher in self._callbacks:
|
||||
if matcher is None or ble_device_matches(
|
||||
matcher, device, advertisement_data
|
||||
):
|
||||
if service_info is None:
|
||||
service_info = BluetoothServiceInfoBleak.from_advertisement(
|
||||
device, advertisement_data, SOURCE_LOCAL
|
||||
)
|
||||
try:
|
||||
callback(service_info, BluetoothChange.ADVERTISEMENT)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Error in bluetooth callback")
|
||||
|
||||
if not matched_domains:
|
||||
return
|
||||
if service_info is None:
|
||||
service_info = BluetoothServiceInfoBleak.from_advertisement(
|
||||
device, advertisement_data, SOURCE_LOCAL
|
||||
)
|
||||
for domain in matched_domains:
|
||||
discovery_flow.async_create_flow(
|
||||
self.hass,
|
||||
domain,
|
||||
{"source": config_entries.SOURCE_BLUETOOTH},
|
||||
service_info,
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
def async_track_unavailable(
|
||||
self, callback: Callable[[str], None], address: str
|
||||
) -> Callable[[], None]:
|
||||
"""Register a callback."""
|
||||
self._unavailable_callbacks.setdefault(address, []).append(callback)
|
||||
|
||||
@hass_callback
|
||||
def _async_remove_callback() -> None:
|
||||
self._unavailable_callbacks[address].remove(callback)
|
||||
if not self._unavailable_callbacks[address]:
|
||||
del self._unavailable_callbacks[address]
|
||||
|
||||
return _async_remove_callback
|
||||
|
||||
@hass_callback
|
||||
def async_register_callback(
|
||||
self,
|
||||
callback: BluetoothCallback,
|
||||
matcher: BluetoothCallbackMatcher | None = None,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a callback."""
|
||||
callback_entry = (callback, matcher)
|
||||
self._callbacks.append(callback_entry)
|
||||
|
||||
@hass_callback
|
||||
def _async_remove_callback() -> None:
|
||||
self._callbacks.remove(callback_entry)
|
||||
|
||||
# If we have history for the subscriber, we can trigger the callback
|
||||
# immediately with the last packet so the subscriber can see the
|
||||
# device.
|
||||
if (
|
||||
matcher
|
||||
and (address := matcher.get(ADDRESS))
|
||||
and self.scanner
|
||||
and (device_adv_data := self.scanner.history.get(address))
|
||||
):
|
||||
try:
|
||||
callback(
|
||||
BluetoothServiceInfoBleak.from_advertisement(
|
||||
*device_adv_data, SOURCE_LOCAL
|
||||
),
|
||||
BluetoothChange.ADVERTISEMENT,
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Error in bluetooth callback")
|
||||
|
||||
return _async_remove_callback
|
||||
|
||||
@hass_callback
|
||||
def async_ble_device_from_address(self, address: str) -> BLEDevice | None:
|
||||
"""Return the BLEDevice if present."""
|
||||
if self.scanner and (ble_adv := self.scanner.history.get(address)):
|
||||
return ble_adv[0]
|
||||
return None
|
||||
|
||||
@hass_callback
|
||||
def async_address_present(self, address: str) -> bool:
|
||||
"""Return if the address is present."""
|
||||
return bool(self.scanner and address in self.scanner.history)
|
||||
|
||||
@hass_callback
|
||||
def async_discovered_service_info(self) -> list[BluetoothServiceInfoBleak]:
|
||||
"""Return if the address is present."""
|
||||
assert self.scanner is not None
|
||||
return [
|
||||
BluetoothServiceInfoBleak.from_advertisement(*device_adv, SOURCE_LOCAL)
|
||||
for device_adv in self.scanner.history.values()
|
||||
]
|
||||
|
||||
async def _async_hass_stopping(self, event: Event) -> None:
|
||||
"""Stop the Bluetooth integration at shutdown."""
|
||||
self._cancel_stop = None
|
||||
await self.async_stop()
|
||||
|
||||
@hass_callback
|
||||
def _async_cancel_scanner_callback(self) -> None:
|
||||
"""Cancel the scanner callback."""
|
||||
if self._cancel_device_detected:
|
||||
self._cancel_device_detected()
|
||||
self._cancel_device_detected = None
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
"""Stop bluetooth discovery."""
|
||||
_LOGGER.debug("Stopping bluetooth discovery")
|
||||
if self._cancel_watchdog:
|
||||
self._cancel_watchdog()
|
||||
self._cancel_watchdog = None
|
||||
self._async_cancel_scanner_callback()
|
||||
if self._cancel_unavailable_tracking:
|
||||
self._cancel_unavailable_tracking()
|
||||
self._cancel_unavailable_tracking = None
|
||||
if self._cancel_stop:
|
||||
self._cancel_stop()
|
||||
self._cancel_stop = None
|
||||
if self.scanner:
|
||||
try:
|
||||
await self.scanner.stop() # type: ignore[no-untyped-call]
|
||||
except BleakError as ex:
|
||||
# This is not fatal, and they may want to reload
|
||||
# the config entry to restart the scanner if they
|
||||
# change the bluetooth dongle.
|
||||
_LOGGER.error("Error stopping scanner: %s", ex)
|
||||
uninstall_multiple_bleak_catcher()
|
||||
|
||||
@hass_callback
|
||||
def async_rediscover_address(self, address: str) -> None:
|
||||
"""Trigger discovery of devices which have already been seen."""
|
||||
self._integration_matcher.async_clear_address(address)
|
|
@ -2,7 +2,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
|
@ -14,6 +17,7 @@ from bleak.backends.scanner import (
|
|||
)
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, callback as hass_callback
|
||||
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bleak.backends.device import BLEDevice
|
||||
|
@ -26,6 +30,49 @@ FILTER_UUIDS: Final = "UUIDs"
|
|||
HA_BLEAK_SCANNER: HaBleakScanner | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BluetoothServiceInfoBleak(BluetoothServiceInfo):
|
||||
"""BluetoothServiceInfo with bleak data.
|
||||
|
||||
Integrations may need BLEDevice and AdvertisementData
|
||||
to connect to the device without having bleak trigger
|
||||
another scan to translate the address to the system's
|
||||
internal details.
|
||||
"""
|
||||
|
||||
device: BLEDevice
|
||||
advertisement: AdvertisementData
|
||||
|
||||
@classmethod
|
||||
def from_advertisement(
|
||||
cls, device: BLEDevice, advertisement_data: AdvertisementData, source: str
|
||||
) -> BluetoothServiceInfoBleak:
|
||||
"""Create a BluetoothServiceInfoBleak from an advertisement."""
|
||||
return cls(
|
||||
name=advertisement_data.local_name or device.name or device.address,
|
||||
address=device.address,
|
||||
rssi=device.rssi,
|
||||
manufacturer_data=advertisement_data.manufacturer_data,
|
||||
service_data=advertisement_data.service_data,
|
||||
service_uuids=advertisement_data.service_uuids,
|
||||
source=source,
|
||||
device=device,
|
||||
advertisement=advertisement_data,
|
||||
)
|
||||
|
||||
|
||||
class BluetoothScanningMode(Enum):
|
||||
"""The mode of scanning for bluetooth devices."""
|
||||
|
||||
PASSIVE = "passive"
|
||||
ACTIVE = "active"
|
||||
|
||||
|
||||
BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT")
|
||||
BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None]
|
||||
ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool]
|
||||
|
||||
|
||||
def _dispatch_callback(
|
||||
callback: AdvertisementDataCallback,
|
||||
filters: dict[str, set[str]],
|
||||
|
|
|
@ -10,20 +10,21 @@ import pytest
|
|||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import (
|
||||
SCANNER_WATCHDOG_INTERVAL,
|
||||
SCANNER_WATCHDOG_TIMEOUT,
|
||||
SOURCE_LOCAL,
|
||||
UNAVAILABLE_TRACK_SECONDS,
|
||||
BluetoothChange,
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfo,
|
||||
async_process_advertisements,
|
||||
async_rediscover_address,
|
||||
async_track_unavailable,
|
||||
manager,
|
||||
models,
|
||||
)
|
||||
from homeassistant.components.bluetooth.const import (
|
||||
CONF_ADAPTER,
|
||||
SCANNER_WATCHDOG_INTERVAL,
|
||||
SCANNER_WATCHDOG_TIMEOUT,
|
||||
SOURCE_LOCAL,
|
||||
UNAVAILABLE_TRACK_SECONDS,
|
||||
UNIX_DEFAULT_BLUETOOTH_ADAPTER,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
|
@ -62,7 +63,7 @@ async def test_setup_and_stop_no_bluetooth(hass, caplog):
|
|||
{"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"}
|
||||
]
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.async_setup",
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.async_setup",
|
||||
side_effect=BleakError,
|
||||
) as mock_ha_bleak_scanner, patch(
|
||||
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
|
||||
|
@ -83,8 +84,10 @@ async def test_setup_and_stop_no_bluetooth(hass, caplog):
|
|||
async def test_setup_and_stop_broken_bluetooth(hass, caplog):
|
||||
"""Test we fail gracefully when bluetooth/dbus is broken."""
|
||||
mock_bt = []
|
||||
with patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.start",
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.async_setup"
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.start",
|
||||
side_effect=BleakError,
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
|
||||
|
@ -109,10 +112,10 @@ async def test_setup_and_stop_broken_bluetooth_hanging(hass, caplog):
|
|||
async def _mock_hang():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
with patch.object(bluetooth, "START_TIMEOUT", 0), patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.async_setup"
|
||||
with patch.object(manager, "START_TIMEOUT", 0), patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.async_setup"
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.start",
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.start",
|
||||
side_effect=_mock_hang,
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
|
||||
|
@ -132,8 +135,10 @@ async def test_setup_and_stop_broken_bluetooth_hanging(hass, caplog):
|
|||
async def test_setup_and_retry_adapter_not_yet_available(hass, caplog):
|
||||
"""Test we retry if the adapter is not yet available."""
|
||||
mock_bt = []
|
||||
with patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.start",
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.async_setup"
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.start",
|
||||
side_effect=BleakError,
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
|
||||
|
@ -152,14 +157,14 @@ async def test_setup_and_retry_adapter_not_yet_available(hass, caplog):
|
|||
assert entry.state == ConfigEntryState.SETUP_RETRY
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.start",
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.start",
|
||||
):
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10))
|
||||
await hass.async_block_till_done()
|
||||
assert entry.state == ConfigEntryState.LOADED
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.stop",
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.stop",
|
||||
):
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
||||
await hass.async_block_till_done()
|
||||
|
@ -168,8 +173,10 @@ async def test_setup_and_retry_adapter_not_yet_available(hass, caplog):
|
|||
async def test_no_race_during_manual_reload_in_retry_state(hass, caplog):
|
||||
"""Test we can successfully reload when the entry is in a retry state."""
|
||||
mock_bt = []
|
||||
with patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.start",
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.async_setup"
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.start",
|
||||
side_effect=BleakError,
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
|
||||
|
@ -188,7 +195,7 @@ async def test_no_race_during_manual_reload_in_retry_state(hass, caplog):
|
|||
assert entry.state == ConfigEntryState.SETUP_RETRY
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.start",
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.start",
|
||||
):
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
@ -196,7 +203,7 @@ async def test_no_race_during_manual_reload_in_retry_state(hass, caplog):
|
|||
assert entry.state == ConfigEntryState.LOADED
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.stop",
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.stop",
|
||||
):
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
||||
await hass.async_block_till_done()
|
||||
|
@ -206,7 +213,7 @@ async def test_calling_async_discovered_devices_no_bluetooth(hass, caplog):
|
|||
"""Test we fail gracefully when asking for discovered devices and there is no blueooth."""
|
||||
mock_bt = []
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.async_setup",
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.async_setup",
|
||||
side_effect=FileNotFoundError,
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
|
||||
|
@ -1514,7 +1521,8 @@ async def test_config_entry_can_be_reloaded_when_stop_raises(
|
|||
assert entry.state == ConfigEntryState.LOADED
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.stop", side_effect=BleakError
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.stop",
|
||||
side_effect=BleakError,
|
||||
):
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
@ -1533,11 +1541,11 @@ async def test_changing_the_adapter_at_runtime(hass):
|
|||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.async_setup"
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.async_setup"
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.start"
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.start"
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.stop"
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.stop"
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
@ -1558,9 +1566,11 @@ async def test_dbus_socket_missing_in_container(hass, caplog):
|
|||
"""Test we handle dbus being missing in the container."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.is_docker_env", return_value=True
|
||||
), patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.start",
|
||||
"homeassistant.components.bluetooth.manager.is_docker_env", return_value=True
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.async_setup"
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.start",
|
||||
side_effect=FileNotFoundError,
|
||||
):
|
||||
assert await async_setup_component(
|
||||
|
@ -1580,9 +1590,11 @@ async def test_dbus_socket_missing(hass, caplog):
|
|||
"""Test we handle dbus being missing."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.is_docker_env", return_value=False
|
||||
), patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.start",
|
||||
"homeassistant.components.bluetooth.manager.is_docker_env", return_value=False
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.async_setup"
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.start",
|
||||
side_effect=FileNotFoundError,
|
||||
):
|
||||
assert await async_setup_component(
|
||||
|
@ -1602,9 +1614,11 @@ async def test_dbus_broken_pipe_in_container(hass, caplog):
|
|||
"""Test we handle dbus broken pipe in the container."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.is_docker_env", return_value=True
|
||||
), patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.start",
|
||||
"homeassistant.components.bluetooth.manager.is_docker_env", return_value=True
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.async_setup"
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.start",
|
||||
side_effect=BrokenPipeError,
|
||||
):
|
||||
assert await async_setup_component(
|
||||
|
@ -1625,9 +1639,11 @@ async def test_dbus_broken_pipe(hass, caplog):
|
|||
"""Test we handle dbus broken pipe."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.is_docker_env", return_value=False
|
||||
), patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.start",
|
||||
"homeassistant.components.bluetooth.manager.is_docker_env", return_value=False
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.async_setup"
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.start",
|
||||
side_effect=BrokenPipeError,
|
||||
):
|
||||
assert await async_setup_component(
|
||||
|
@ -1647,8 +1663,10 @@ async def test_dbus_broken_pipe(hass, caplog):
|
|||
async def test_invalid_dbus_message(hass, caplog):
|
||||
"""Test we handle invalid dbus message."""
|
||||
|
||||
with patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.start",
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.async_setup"
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.start",
|
||||
side_effect=InvalidMessageError,
|
||||
):
|
||||
assert await async_setup_component(
|
||||
|
@ -1678,7 +1696,7 @@ async def test_recovery_from_dbus_restart(
|
|||
|
||||
# Ensure we don't restart the scanner if we don't need to
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.MONOTONIC_TIME",
|
||||
"homeassistant.components.bluetooth.manager.MONOTONIC_TIME",
|
||||
return_value=start_time_monotonic + 10,
|
||||
):
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL)
|
||||
|
@ -1688,7 +1706,7 @@ async def test_recovery_from_dbus_restart(
|
|||
|
||||
# Fire a callback to reset the timer
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.MONOTONIC_TIME",
|
||||
"homeassistant.components.bluetooth.manager.MONOTONIC_TIME",
|
||||
return_value=start_time_monotonic,
|
||||
):
|
||||
scanner._callback(
|
||||
|
@ -1698,7 +1716,7 @@ async def test_recovery_from_dbus_restart(
|
|||
|
||||
# Ensure we don't restart the scanner if we don't need to
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.MONOTONIC_TIME",
|
||||
"homeassistant.components.bluetooth.manager.MONOTONIC_TIME",
|
||||
return_value=start_time_monotonic + 20,
|
||||
):
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL)
|
||||
|
@ -1708,7 +1726,7 @@ async def test_recovery_from_dbus_restart(
|
|||
|
||||
# We hit the timer, so we restart the scanner
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.MONOTONIC_TIME",
|
||||
"homeassistant.components.bluetooth.manager.MONOTONIC_TIME",
|
||||
return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT,
|
||||
):
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL)
|
||||
|
|
|
@ -8,10 +8,10 @@ from unittest.mock import MagicMock, patch
|
|||
|
||||
from homeassistant.components.bluetooth import (
|
||||
DOMAIN,
|
||||
UNAVAILABLE_TRACK_SECONDS,
|
||||
BluetoothChange,
|
||||
BluetoothScanningMode,
|
||||
)
|
||||
from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS
|
||||
from homeassistant.components.bluetooth.passive_update_coordinator import (
|
||||
PassiveBluetoothCoordinatorEntity,
|
||||
PassiveBluetoothDataUpdateCoordinator,
|
||||
|
|
|
@ -14,10 +14,10 @@ from homeassistant.components.binary_sensor import (
|
|||
)
|
||||
from homeassistant.components.bluetooth import (
|
||||
DOMAIN,
|
||||
UNAVAILABLE_TRACK_SECONDS,
|
||||
BluetoothChange,
|
||||
BluetoothScanningMode,
|
||||
)
|
||||
from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothDataProcessor,
|
||||
PassiveBluetoothDataUpdate,
|
||||
|
|
|
@ -904,8 +904,8 @@ def mock_bleak_scanner_start():
|
|||
scanner = bleak.BleakScanner
|
||||
bluetooth_models.HA_BLEAK_SCANNER = None
|
||||
|
||||
with patch("homeassistant.components.bluetooth.HaBleakScanner.stop"), patch(
|
||||
"homeassistant.components.bluetooth.HaBleakScanner.start",
|
||||
with patch("homeassistant.components.bluetooth.models.HaBleakScanner.stop"), patch(
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.start",
|
||||
) as mock_bleak_scanner_start:
|
||||
yield mock_bleak_scanner_start
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue