diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index a0eb263757a..99bb02054e7 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -21,7 +21,12 @@ from bluetooth_adapters import ( adapter_unique_name, get_adapters, ) -from habluetooth import HaBluetoothConnector +from habluetooth import ( + BluetoothScanningMode, + HaBluetoothConnector, + HaScanner, + ScannerStartError, +) from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from homeassistant.components import usb @@ -76,10 +81,9 @@ from .const import ( LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, SOURCE_LOCAL, ) -from .manager import BluetoothManager +from .manager import MONOTONIC_TIME, BluetoothManager from .match import BluetoothCallbackMatcher, IntegrationMatcher -from .models import BluetoothCallback, BluetoothChange, BluetoothScanningMode -from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError +from .models import BluetoothCallback, BluetoothChange from .storage import BluetoothStorage if TYPE_CHECKING: @@ -281,7 +285,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE new_info_callback = async_get_advertisement_callback(hass) manager: BluetoothManager = hass.data[DATA_MANAGER] - scanner = HaScanner(hass, mode, adapter, address, new_info_callback) + scanner = HaScanner(mode, adapter, address, new_info_callback) try: scanner.async_setup() except RuntimeError as err: diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 9d24428e3d2..897402d4049 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -9,6 +9,7 @@ from asyncio import Future from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, cast +from habluetooth import BluetoothScanningMode from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback @@ -17,12 +18,7 @@ from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import DATA_MANAGER from .manager import BluetoothManager from .match import BluetoothCallbackMatcher -from .models import ( - BluetoothCallback, - BluetoothChange, - BluetoothScanningMode, - ProcessAdvertisementCallback, -) +from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback from .wrappers import HaBleakScannerWrapper if TYPE_CHECKING: diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 48ba021cd6c..a35c5be6daf 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -17,13 +17,6 @@ MANAGER: BluetoothManager | None = None MONOTONIC_TIME: Final = monotonic_time_coarse -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] diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 7dd39c14039..8da0d2c462b 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -7,6 +7,8 @@ from functools import cache import logging from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast +from habluetooth import BluetoothScanningMode + from homeassistant import config_entries from homeassistant.const import ( ATTR_CONNECTIONS, @@ -33,11 +35,7 @@ if TYPE_CHECKING: from homeassistant.helpers.entity_platform import AddEntitiesCallback - from .models import ( - BluetoothChange, - BluetoothScanningMode, - BluetoothServiceInfoBleak, - ) + from .models import BluetoothChange, BluetoothServiceInfoBleak STORAGE_KEY = "bluetooth.passive_update_processor" STORAGE_VERSION = 1 diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py deleted file mode 100644 index 95733039df4..00000000000 --- a/homeassistant/components/bluetooth/scanner.py +++ /dev/null @@ -1,390 +0,0 @@ -"""The bluetooth integration.""" -from __future__ import annotations - -import asyncio -from collections.abc import Callable -import logging -import platform -from typing import Any - -import bleak -from bleak import BleakError -from bleak.assigned_numbers import AdvertisementDataType -from bleak.backends.bluezdbus.advertisement_monitor import OrPattern -from bleak.backends.bluezdbus.scanner import BlueZScannerArgs -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback -from bleak_retry_connector import restore_discoveries -from bluetooth_adapters import DEFAULT_ADDRESS -from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME -from dbus_fast import InvalidMessageError - -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.package import is_docker_env - -from .base_scanner import BaseHaScanner -from .const import ( - SCANNER_WATCHDOG_INTERVAL, - SCANNER_WATCHDOG_TIMEOUT, - SOURCE_LOCAL, - START_TIMEOUT, -) -from .models import BluetoothScanningMode, BluetoothServiceInfoBleak -from .util import async_reset_adapter - -OriginalBleakScanner = bleak.BleakScanner - -# or_patterns is a workaround for the fact that passive scanning -# needs at least one matcher to be set. The below matcher -# will match all devices. -PASSIVE_SCANNER_ARGS = BlueZScannerArgs( - or_patterns=[ - OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"), - OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"), - ] -) -_LOGGER = logging.getLogger(__name__) - - -# If the adapter is in a stuck state the following errors are raised: -NEED_RESET_ERRORS = [ - "org.bluez.Error.Failed", - "org.bluez.Error.InProgress", - "org.bluez.Error.NotReady", - "not found", -] - -# When the adapter is still initializing, the scanner will raise an exception -# with org.freedesktop.DBus.Error.UnknownObject -WAIT_FOR_ADAPTER_TO_INIT_ERRORS = ["org.freedesktop.DBus.Error.UnknownObject"] -ADAPTER_INIT_TIME = 1.5 - -START_ATTEMPTS = 3 - -SCANNING_MODE_TO_BLEAK = { - BluetoothScanningMode.ACTIVE: "active", - BluetoothScanningMode.PASSIVE: "passive", -} - -# The minimum number of seconds to know -# the adapter has not had advertisements -# and we already tried to restart the scanner -# without success when the first time the watch -# dog hit the failure path. -SCANNER_WATCHDOG_MULTIPLE = ( - SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds() -) - - -class ScannerStartError(HomeAssistantError): - """Error to indicate that the scanner failed to start.""" - - -def create_bleak_scanner( - detection_callback: AdvertisementDataCallback, - scanning_mode: BluetoothScanningMode, - adapter: str | None, -) -> bleak.BleakScanner: - """Create a Bleak scanner.""" - scanner_kwargs: dict[str, Any] = { - "detection_callback": detection_callback, - "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode], - } - system = platform.system() - if system == "Linux": - # Only Linux supports multiple adapters - if adapter: - scanner_kwargs["adapter"] = adapter - if scanning_mode == BluetoothScanningMode.PASSIVE: - scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS - elif system == "Darwin": - # We want mac address on macOS - scanner_kwargs["cb"] = {"use_bdaddr": True} - _LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs) - - try: - return OriginalBleakScanner(**scanner_kwargs) - except (FileNotFoundError, BleakError) as ex: - raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex - - -class HaScanner(BaseHaScanner): - """Operate and automatically recover 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. - """ - - scanner: bleak.BleakScanner - - def __init__( - self, - hass: HomeAssistant, - mode: BluetoothScanningMode, - adapter: str, - address: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], - ) -> None: - """Init bluetooth discovery.""" - self.mac_address = address - source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL - super().__init__(source, adapter) - self.connectable = True - self.mode = mode - self._start_stop_lock = asyncio.Lock() - self._new_info_callback = new_info_callback - self.scanning = False - self.hass = hass - self._last_detection = 0.0 - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - return self.scanner.discovered_devices - - @property - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and advertisement data.""" - return self.scanner.discovered_devices_and_advertisement_data - - @hass_callback - def async_setup(self) -> CALLBACK_TYPE: - """Set up the scanner.""" - super().async_setup() - self.scanner = create_bleak_scanner( - self._async_detection_callback, self.mode, self.adapter - ) - return self._unsetup - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - base_diag = await super().async_diagnostics() - return base_diag | { - "adapter": self.adapter, - } - - @hass_callback - def _async_detection_callback( - self, - 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. - """ - callback_time = MONOTONIC_TIME() - if ( - advertisement_data.local_name - or advertisement_data.manufacturer_data - or advertisement_data.service_data - or advertisement_data.service_uuids - ): - # Don't count empty advertisements - # as the adapter is in a failure - # state if all the data is empty. - self._last_detection = callback_time - self._new_info_callback( - BluetoothServiceInfoBleak( - name=advertisement_data.local_name or device.name or device.address, - address=device.address, - rssi=advertisement_data.rssi, - manufacturer_data=advertisement_data.manufacturer_data, - service_data=advertisement_data.service_data, - service_uuids=advertisement_data.service_uuids, - source=self.source, - device=device, - advertisement=advertisement_data, - connectable=True, - time=callback_time, - ) - ) - - async def async_start(self) -> None: - """Start bluetooth scanner.""" - async with self._start_stop_lock: - await self._async_start() - - async def _async_start(self) -> None: - """Start bluetooth scanner under the lock.""" - for attempt in range(START_ATTEMPTS): - _LOGGER.debug( - "%s: Starting bluetooth discovery attempt: (%s/%s)", - self.name, - attempt + 1, - START_ATTEMPTS, - ) - try: - async with asyncio.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 ScannerStartError( - 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 ScannerStartError( - f"{self.name}: DBus connection broken: {ex}; try restarting " - "`bluetooth`, `dbus`, and finally the docker container" - ) from ex - raise ScannerStartError( - 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 ScannerStartError( - f"{self.name}: DBus service not found; docker config may " - "be missing `-v /run/dbus:/run/dbus:ro`: {ex}" - ) from ex - raise ScannerStartError( - f"{self.name}: DBus service not found; make sure the DBus socket " - f"is available to Home Assistant: {ex}" - ) from ex - except asyncio.TimeoutError as ex: - if attempt == 0: - await self._async_reset_adapter() - continue - raise ScannerStartError( - f"{self.name}: Timed out starting Bluetooth after" - f" {START_TIMEOUT} seconds" - ) from ex - except BleakError as ex: - error_str = str(ex) - if attempt == 0: - if any( - needs_reset_error in error_str - for needs_reset_error in NEED_RESET_ERRORS - ): - await self._async_reset_adapter() - continue - if attempt != START_ATTEMPTS - 1: - # If we are not out of retry attempts, and the - # adapter is still initializing, wait a bit and try again. - if any( - wait_error in error_str - for wait_error in WAIT_FOR_ADAPTER_TO_INIT_ERRORS - ): - _LOGGER.debug( - "%s: Waiting for adapter to initialize; attempt (%s/%s)", - self.name, - attempt + 1, - START_ATTEMPTS, - ) - await asyncio.sleep(ADAPTER_INIT_TIME) - continue - - _LOGGER.debug( - "%s: BleakError while starting bluetooth; attempt: (%s/%s): %s", - self.name, - attempt + 1, - START_ATTEMPTS, - ex, - exc_info=True, - ) - raise ScannerStartError( - f"{self.name}: Failed to start Bluetooth: {ex}" - ) from ex - - # Everything is fine, break out of the loop - break - - self.scanning = True - self._async_setup_scanner_watchdog() - await restore_discoveries(self.scanner, self.adapter) - - @hass_callback - def _async_scanner_watchdog(self) -> None: - """Check if the scanner is running.""" - if not self._async_watchdog_triggered(): - return - if self._start_stop_lock.locked(): - _LOGGER.debug( - "%s: Scanner is already restarting, deferring restart", - self.name, - ) - return - _LOGGER.info( - "%s: Bluetooth scanner has gone quiet for %ss, restarting", - self.name, - SCANNER_WATCHDOG_TIMEOUT, - ) - # Immediately mark the scanner as not scanning - # since the restart task will have to wait for the lock - self.scanning = False - self.hass.async_create_task(self._async_restart_scanner()) - - async def _async_restart_scanner(self) -> None: - """Restart the scanner.""" - async with self._start_stop_lock: - time_since_last_detection = MONOTONIC_TIME() - self._last_detection - # Stop the scanner but not the watchdog - # since we want to try again later if it's still quiet - await self._async_stop_scanner() - # If there have not been any valid advertisements, - # or the watchdog has hit the failure path multiple times, - # do the reset. - if ( - self._start_time == self._last_detection - or time_since_last_detection > SCANNER_WATCHDOG_MULTIPLE - ): - await self._async_reset_adapter() - try: - await self._async_start() - except ScannerStartError as ex: - _LOGGER.exception( - "%s: Failed to restart Bluetooth scanner: %s", - self.name, - ex, - ) - - async def _async_reset_adapter(self) -> None: - """Reset the adapter.""" - # There is currently nothing the user can do to fix this - # so we log at debug level. If we later come up with a repair - # strategy, we will change this to raise a repair issue as well. - _LOGGER.debug("%s: adapter stopped responding; executing reset", self.name) - result = await async_reset_adapter(self.adapter, self.mac_address) - _LOGGER.debug("%s: adapter reset result: %s", self.name, result) - - async def async_stop(self) -> None: - """Stop bluetooth scanner.""" - async with self._start_stop_lock: - self._async_stop_scanner_watchdog() - await self._async_stop_scanner() - - async def _async_stop_scanner(self) -> None: - """Stop bluetooth discovery under the lock.""" - self.scanning = False - _LOGGER.debug("%s: Stopping bluetooth discovery", self.name) - 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("%s: Error stopping scanner: %s", self.name, ex) diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 295e84d4481..2d495a0659c 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -4,6 +4,8 @@ from __future__ import annotations from abc import ABC, abstractmethod import logging +from habluetooth import BluetoothScanningMode + from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from .api import ( @@ -13,7 +15,7 @@ from .api import ( async_track_unavailable, ) from .match import BluetoothCallbackMatcher -from .models import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak +from .models import BluetoothChange, BluetoothServiceInfoBleak class BasePassiveBluetoothCoordinator(ABC): diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index f276b6b51e5..d531e46f911 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -2,7 +2,6 @@ from __future__ import annotations from bluetooth_adapters import BluetoothAdapters -from bluetooth_auto_recovery import recover_adapter from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import callback @@ -69,11 +68,3 @@ def async_load_history_from_system( connectable_loaded_history[address] = service_info return all_loaded_history, connectable_loaded_history - - -async def async_reset_adapter(adapter: str | None, mac_address: str) -> bool | None: - """Reset the adapter.""" - if adapter and adapter.startswith("hci"): - adapter_id = int(adapter[3:]) - return await recover_adapter(adapter_id, mac_address) - return False diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 5f166a3fca2..4ec6c4e5388 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -50,7 +50,7 @@ def macos_adapter(): "homeassistant.components.bluetooth.platform.system", return_value="Darwin", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Darwin", ), patch( "bluetooth_adapters.systems.platform.system", @@ -76,7 +76,7 @@ def no_adapter_fixture(): "homeassistant.components.bluetooth.platform.system", return_value="Linux", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch( "bluetooth_adapters.systems.platform.system", @@ -97,7 +97,7 @@ def one_adapter_fixture(): "homeassistant.components.bluetooth.platform.system", return_value="Linux", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch( "bluetooth_adapters.systems.platform.system", @@ -128,7 +128,7 @@ def two_adapters_fixture(): with patch( "homeassistant.components.bluetooth.platform.system", return_value="Linux" ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" @@ -168,7 +168,7 @@ def one_adapter_old_bluez(): with patch( "homeassistant.components.bluetooth.platform.system", return_value="Linux" ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 8625283266e..a69c26a16ea 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -3,6 +3,7 @@ from unittest.mock import ANY, MagicMock, patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import HaScanner from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -25,6 +26,21 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +class FakeHaScanner(HaScanner): + """Fake HaScanner.""" + + @property + def discovered_devices_and_advertisement_data(self): + """Return the discovered devices and advertisement data.""" + return { + "44:44:33:11:23:45": ( + generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), + generate_advertisement_data(local_name="x"), + ) + } + + +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -38,15 +54,8 @@ async def test_diagnostics( # because we cannot import the scanner class directly without it throwing an # error if the test is not running on linux since we won't have the correct # deps installed when testing on MacOS. + with patch( - "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", - { - "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), - generate_advertisement_data(local_name="x"), - ) - }, - ), patch( "homeassistant.components.bluetooth.diagnostics.platform.system", return_value="Linux", ), patch( @@ -88,25 +97,25 @@ async def test_diagnostics( "adapters": { "hci0": { "address": "00:00:00:00:00:01", + "connection_slots": 1, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": False, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": ANY, "vendor_id": "cc01", - "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", + "connection_slots": 2, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": True, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": True, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": ANY, "vendor_id": "cc01", - "connection_slots": 2, }, }, "dbus": { @@ -126,63 +135,42 @@ async def test_diagnostics( } }, "manager": { - "slot_manager": { - "adapter_slots": {"hci0": 5, "hci1": 2}, - "allocations_by_adapter": {"hci0": [], "hci1": []}, - "manager": False, - }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", + "connection_slots": 1, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": False, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": "homeassistant", "vendor_id": "cc01", - "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", + "connection_slots": 2, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": True, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": True, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": "homeassistant", "vendor_id": "cc01", - "connection_slots": 2, }, }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {}, "timings": {}, }, - "connectable_history": [], "all_history": [], + "connectable_history": [], "scanners": [ { "adapter": "hci0", - "discovered_devices_and_advertisement_data": [ - { - "address": "44:44:33:11:23:45", - "advertisement_data": [ - "x", - {}, - {}, - [], - -127, - -127, - [[]], - ], - "details": None, - "name": "x", - "rssi": -127, - } - ], + "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (00:00:00:00:00:01)", @@ -216,7 +204,7 @@ async def test_diagnostics( "scanning": True, "source": "00:00:00:00:00:01", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, { "adapter": "hci1", @@ -243,13 +231,19 @@ async def test_diagnostics( "scanning": True, "source": "00:00:00:00:00:02", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, ], + "slot_manager": { + "adapter_slots": {"hci0": 5, "hci1": 2}, + "allocations_by_adapter": {"hci0": [], "hci1": []}, + "manager": False, + }, }, } +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics_macos( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -269,14 +263,6 @@ async def test_diagnostics_macos( ) with patch( - "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", - { - "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), - switchbot_adv, - ) - }, - ), patch( "homeassistant.components.bluetooth.diagnostics.platform.system", return_value="Darwin", ), patch( @@ -297,70 +283,36 @@ async def test_diagnostics_macos( inject_advertisement(hass, switchbot_device, switchbot_adv) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) - assert diag == { "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", - "passive_scan": False, - "sw_version": ANY, "manufacturer": "Apple", + "passive_scan": False, "product": "Unknown MacOS Model", "product_id": "Unknown", + "sw_version": ANY, "vendor_id": "Unknown", } }, "manager": { - "slot_manager": { - "adapter_slots": {"Core Bluetooth": 5}, - "allocations_by_adapter": {"Core Bluetooth": []}, - "manager": False, - }, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", - "passive_scan": False, - "sw_version": ANY, "manufacturer": "Apple", + "passive_scan": False, "product": "Unknown MacOS Model", "product_id": "Unknown", + "sw_version": ANY, "vendor_id": "Unknown", } }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {"44:44:33:11:23:45": "local"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, - "connectable_history": [ - { - "address": "44:44:33:11:23:45", - "advertisement": [ - "wohand", - {"1": {"__type": "", "repr": "b'\\x01'"}}, - {}, - [], - -127, - -127, - [[]], - ], - "device": { - "__type": "", - "repr": "BLEDevice(44:44:33:11:23:45, wohand)", - }, - "connectable": True, - "manufacturer_data": { - "1": {"__type": "", "repr": "b'\\x01'"} - }, - "name": "wohand", - "rssi": -127, - "service_data": {}, - "service_uuids": [], - "source": "local", - "time": ANY, - } - ], "all_history": [ { "address": "44:44:33:11:23:45", @@ -373,11 +325,39 @@ async def test_diagnostics_macos( -127, [[]], ], + "connectable": True, "device": { "__type": "", "repr": "BLEDevice(44:44:33:11:23:45, wohand)", }, + "manufacturer_data": { + "1": {"__type": "", "repr": "b'\\x01'"} + }, + "name": "wohand", + "rssi": -127, + "service_data": {}, + "service_uuids": [], + "source": "local", + "time": ANY, + } + ], + "connectable_history": [ + { + "address": "44:44:33:11:23:45", + "advertisement": [ + "wohand", + {"1": {"__type": "", "repr": "b'\\x01'"}}, + {}, + [], + -127, + -127, + [[]], + ], "connectable": True, + "device": { + "__type": "", + "repr": "BLEDevice(44:44:33:11:23:45, wohand)", + }, "manufacturer_data": { "1": {"__type": "", "repr": "b'\\x01'"} }, @@ -396,13 +376,8 @@ async def test_diagnostics_macos( { "address": "44:44:33:11:23:45", "advertisement_data": [ - "wohand", - { - "1": { - "__type": "", - "repr": "b'\\x01'", - } - }, + "x", + {}, {}, [], -127, @@ -420,13 +395,19 @@ async def test_diagnostics_macos( "scanning": True, "source": "Core Bluetooth", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", } ], + "slot_manager": { + "adapter_slots": {"Core Bluetooth": 5}, + "allocations_by_adapter": {"Core Bluetooth": []}, + "manager": False, + }, }, } +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics_remote_adapter( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -497,17 +478,12 @@ async def test_diagnostics_remote_adapter( "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", - "sw_version": "homeassistant", + "sw_version": ANY, "vendor_id": "cc01", } }, "dbus": {}, "manager": { - "slot_manager": { - "adapter_slots": {"hci0": 5}, - "allocations_by_adapter": {"hci0": []}, - "manager": False, - }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -521,8 +497,8 @@ async def test_diagnostics_remote_adapter( } }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {"44:44:33:11:23:45": "esp32"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, @@ -596,19 +572,34 @@ async def test_diagnostics_remote_adapter( }, { "adapter": "hci0", - "discovered_devices_and_advertisement_data": [], + "discovered_devices_and_advertisement_data": [ + { + "address": "44:44:33:11:23:45", + "advertisement_data": [ + "x", + {}, + {}, + [], + -127, + -127, + [[]], + ], + "details": None, + "name": "x", + "rssi": -127, + } + ], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (00:00:00:00:00:01)", "scanning": True, "source": "00:00:00:00:00:01", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, { "connectable": False, "discovered_device_timestamps": {"44:44:33:11:23:45": ANY}, - "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, "discovered_devices_and_advertisement_data": [ { "address": "44:44:33:11:23:45", @@ -639,11 +630,17 @@ async def test_diagnostics_remote_adapter( "name": "esp32", "scanning": True, "source": "esp32", - "storage": None, - "type": "FakeScanner", "start_time": ANY, + "storage": None, + "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, + "type": "FakeScanner", }, ], + "slot_manager": { + "adapter_slots": {"hci0": 5}, + "allocations_by_adapter": {"hci0": []}, + "manager": False, + }, }, } diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index b24bb97e1e3..63ff735ca43 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import ANY, MagicMock, Mock, patch from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import scanner import pytest from homeassistant.components import bluetooth @@ -17,7 +18,6 @@ from homeassistant.components.bluetooth import ( async_process_advertisements, async_rediscover_address, async_track_unavailable, - scanner, ) from homeassistant.components.bluetooth.const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, @@ -107,7 +107,7 @@ async def test_setup_and_stop_passive( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockPassiveBleakScanner, ): assert await async_setup_component( @@ -158,7 +158,7 @@ async def test_setup_and_stop_old_bluez( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): assert await async_setup_component( @@ -185,7 +185,7 @@ async def test_setup_and_stop_no_bluetooth( {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} ] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", side_effect=BleakError, ) as mock_ha_bleak_scanner, patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -206,7 +206,7 @@ async def test_setup_and_stop_broken_bluetooth( """Test we fail gracefully when bluetooth/dbus is broken.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -231,7 +231,7 @@ async def test_setup_and_stop_broken_bluetooth_hanging( await asyncio.sleep(1) with patch.object(scanner, "START_TIMEOUT", 0), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=_mock_hang, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -251,7 +251,7 @@ async def test_setup_and_retry_adapter_not_yet_available( """Test we retry if the adapter is not yet available.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -267,14 +267,14 @@ async def test_setup_and_retry_adapter_not_yet_available( assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.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.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -286,7 +286,7 @@ async def test_no_race_during_manual_reload_in_retry_state( """Test we can successfully reload when the entry is in a retry state.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -302,7 +302,7 @@ async def test_no_race_during_manual_reload_in_retry_state( assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ): await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() @@ -310,7 +310,7 @@ async def test_no_race_during_manual_reload_in_retry_state( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -322,7 +322,7 @@ async def test_calling_async_discovered_devices_no_bluetooth( """Test we fail gracefully when asking for discovered devices and there is no blueooth.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", side_effect=FileNotFoundError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index b660be74aa9..c33bfd6db84 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -14,7 +14,6 @@ from homeassistant.components.bluetooth.const import ( SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, ) -from homeassistant.components.bluetooth.scanner import NEED_RESET_ERRORS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -30,6 +29,14 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed +# If the adapter is in a stuck state the following errors are raised: +NEED_RESET_ERRORS = [ + "org.bluez.Error.Failed", + "org.bluez.Error.InProgress", + "org.bluez.Error.NotReady", + "not found", +] + async def test_config_entry_can_be_reloaded_when_stop_raises( hass: HomeAssistant, @@ -42,7 +49,7 @@ async def test_config_entry_can_be_reloaded_when_stop_raises( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", side_effect=BleakError, ): await hass.config_entries.async_reload(entry.entry_id) @@ -57,10 +64,8 @@ async def test_dbus_socket_missing_in_container( ) -> None: """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", + with patch("habluetooth.scanner.is_docker_env", return_value=True), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ): await async_setup_with_one_adapter(hass) @@ -79,10 +84,8 @@ async def test_dbus_socket_missing( ) -> None: """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", + with patch("habluetooth.scanner.is_docker_env", return_value=False), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ): await async_setup_with_one_adapter(hass) @@ -101,10 +104,8 @@ async def test_dbus_broken_pipe_in_container( ) -> None: """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", + with patch("habluetooth.scanner.is_docker_env", return_value=True), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ): await async_setup_with_one_adapter(hass) @@ -124,10 +125,8 @@ async def test_dbus_broken_pipe( ) -> None: """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", + with patch("habluetooth.scanner.is_docker_env", return_value=False), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ): await async_setup_with_one_adapter(hass) @@ -148,7 +147,7 @@ async def test_invalid_dbus_message( """Test we handle invalid dbus message.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=InvalidMessageError, ): await async_setup_with_one_adapter(hass) @@ -168,10 +167,10 @@ async def test_adapter_needs_reset_at_start( """Test we cycle the adapter when it needs a restart.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=[BleakError(error), None], ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: await async_setup_with_one_adapter(hass) @@ -216,7 +215,7 @@ async def test_recovery_from_dbus_restart( return mock_discovered with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): await async_setup_with_one_adapter(hass) @@ -306,7 +305,7 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ): await async_setup_with_one_adapter(hass) @@ -343,7 +342,7 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -395,7 +394,7 @@ async def test_adapter_scanner_fails_to_start_first_time( "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ): await async_setup_with_one_adapter(hass) @@ -432,7 +431,7 @@ async def test_adapter_scanner_fails_to_start_first_time( + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -448,7 +447,7 @@ async def test_adapter_scanner_fails_to_start_first_time( + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -503,16 +502,16 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + "habluetooth.scanner.ADAPTER_INIT_TIME", 0, ), patch( "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: await async_setup_with_one_adapter(hass) @@ -554,17 +553,15 @@ async def test_restart_takes_longer_than_watchdog_time( start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + "habluetooth.scanner.ADAPTER_INIT_TIME", 0, ), patch( "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, - ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True - ): + ), patch("habluetooth.util.recover_adapter", return_value=True): await async_setup_with_one_adapter(hass) assert called_start == 1 @@ -617,7 +614,7 @@ async def test_setup_and_stop_macos( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): assert await async_setup_component( diff --git a/tests/conftest.py b/tests/conftest.py index fcd8e8b73a9..4d0e2565164 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1574,14 +1574,14 @@ def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: # Late imports to avoid loading bleak unless we need it # pylint: disable-next=import-outside-toplevel - from homeassistant.components.bluetooth import scanner as bluetooth_scanner + from habluetooth import scanner as bluetooth_scanner # 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. bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() # type: ignore[assignment] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ) as mock_bleak_scanner_start: yield mock_bleak_scanner_start