Move local bluetooth scanner code into habluetooth library (#104970)

This commit is contained in:
J. Nick Koston 2023-12-05 07:19:02 -10:00 committed by GitHub
parent 428c184c75
commit b6245c834d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 180 additions and 592 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": "<class 'bytes'>", "repr": "b'\\x01'"}},
{},
[],
-127,
-127,
[[]],
],
"device": {
"__type": "<class 'bleak.backends.device.BLEDevice'>",
"repr": "BLEDevice(44:44:33:11:23:45, wohand)",
},
"connectable": True,
"manufacturer_data": {
"1": {"__type": "<class 'bytes'>", "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": "<class 'bleak.backends.device.BLEDevice'>",
"repr": "BLEDevice(44:44:33:11:23:45, wohand)",
},
"manufacturer_data": {
"1": {"__type": "<class 'bytes'>", "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": "<class 'bytes'>", "repr": "b'\\x01'"}},
{},
[],
-127,
-127,
[[]],
],
"connectable": True,
"device": {
"__type": "<class 'bleak.backends.device.BLEDevice'>",
"repr": "BLEDevice(44:44:33:11:23:45, wohand)",
},
"manufacturer_data": {
"1": {"__type": "<class 'bytes'>", "repr": "b'\\x01'"}
},
@ -396,13 +376,8 @@ async def test_diagnostics_macos(
{
"address": "44:44:33:11:23:45",
"advertisement_data": [
"wohand",
{
"1": {
"__type": "<class 'bytes'>",
"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,
},
},
}

View file

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

View file

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

View file

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