Restore remote discovered devices between remote scanner restarts (#83699)

This commit is contained in:
J. Nick Koston 2022-12-11 09:02:55 -10:00 committed by GitHub
parent fbab7413a5
commit 9008006ac8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 3323 additions and 110 deletions

View file

@ -71,9 +71,14 @@ from .const import (
)
from .manager import BluetoothManager
from .match import BluetoothCallbackMatcher, IntegrationMatcher
from .models import BluetoothCallback, BluetoothChange, BluetoothScanningMode
from .models import (
BluetoothCallback,
BluetoothChange,
BluetoothScanningMode,
HaBluetoothConnector,
)
from .scanner import HaScanner, ScannerStartError
from .wrappers import HaBluetoothConnector
from .storage import BluetoothStorage
if TYPE_CHECKING:
from homeassistant.helpers.typing import ConfigType
@ -158,7 +163,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass))
integration_matcher.async_setup()
bluetooth_adapters = get_adapters()
manager = BluetoothManager(hass, integration_matcher, bluetooth_adapters)
bluetooth_storage = BluetoothStorage(hass)
await bluetooth_storage.async_setup()
manager = BluetoothManager(
hass, integration_matcher, bluetooth_adapters, bluetooth_storage
)
await manager.async_setup()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop)
hass.data[DATA_MANAGER] = models.MANAGER = manager

View file

@ -11,13 +11,21 @@ from typing import Any, Final
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak_retry_connector import NO_RSSI_VALUE
from bluetooth_adapters import adapter_human_name
from bluetooth_adapters import DiscoveredDeviceAdvertisementData, adapter_human_name
from home_assistant_bluetooth import BluetoothServiceInfoBleak
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
CALLBACK_TYPE,
Event,
HomeAssistant,
callback as hass_callback,
)
from homeassistant.helpers.event import async_track_time_interval
import homeassistant.util.dt as dt_util
from homeassistant.util.dt import monotonic_time_coarse
from . import models
from .const import (
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
@ -30,12 +38,22 @@ MONOTONIC_TIME: Final = monotonic_time_coarse
class BaseHaScanner(ABC):
"""Base class for Ha Scanners."""
__slots__ = ("hass", "source", "_connecting", "name", "scanning")
__slots__ = (
"hass",
"connectable",
"source",
"connector",
"_connecting",
"name",
"scanning",
)
def __init__(self, hass: HomeAssistant, source: str, adapter: str) -> None:
"""Initialize the scanner."""
self.hass = hass
self.connectable = False
self.source = source
self.connector: HaBluetoothConnector | None = None
self._connecting = 0
self.name = adapter_human_name(adapter, source) if adapter != source else source
self.scanning = True
@ -87,10 +105,9 @@ class BaseHaRemoteScanner(BaseHaScanner):
"_new_info_callback",
"_discovered_device_advertisement_datas",
"_discovered_device_timestamps",
"_connector",
"_connectable",
"_details",
"_expire_seconds",
"_storage",
)
def __init__(
@ -109,12 +126,13 @@ class BaseHaRemoteScanner(BaseHaScanner):
str, tuple[BLEDevice, AdvertisementData]
] = {}
self._discovered_device_timestamps: dict[str, float] = {}
self._connector = connector
self._connectable = connectable
self.connectable = connectable
self.connector = connector
self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id}
self._expire_seconds = FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
assert models.MANAGER is not None
self._storage = models.MANAGER.storage
if connectable:
self._details["connector"] = connector
self._expire_seconds = (
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
)
@ -122,9 +140,40 @@ class BaseHaRemoteScanner(BaseHaScanner):
@hass_callback
def async_setup(self) -> CALLBACK_TYPE:
"""Set up the scanner."""
return async_track_time_interval(
if history := self._storage.async_get_advertisement_history(self.source):
self._discovered_device_advertisement_datas = (
history.discovered_device_advertisement_datas
)
self._discovered_device_timestamps = history.discovered_device_timestamps
# Expire anything that is too old
self._async_expire_devices(dt_util.utcnow())
cancel_track = async_track_time_interval(
self.hass, self._async_expire_devices, timedelta(seconds=30)
)
cancel_stop = self.hass.bus.async_listen(
EVENT_HOMEASSISTANT_STOP, self._save_history
)
@hass_callback
def _cancel() -> None:
self._save_history()
cancel_track()
cancel_stop()
return _cancel
def _save_history(self, event: Event | None = None) -> None:
"""Save the history."""
self._storage.async_set_advertisement_history(
self.source,
DiscoveredDeviceAdvertisementData(
self.connectable,
self._expire_seconds,
self._discovered_device_advertisement_datas,
self._discovered_device_timestamps,
),
)
def _async_expire_devices(self, _datetime: datetime.datetime) -> None:
"""Expire old devices."""
@ -222,7 +271,7 @@ class BaseHaRemoteScanner(BaseHaScanner):
source=self.source,
device=device,
advertisement=advertisement_data,
connectable=self._connectable,
connectable=self.connectable,
time=now,
)
)

View file

@ -45,6 +45,7 @@ from .match import (
ble_device_matches,
)
from .models import BluetoothCallback, BluetoothChange, BluetoothServiceInfoBleak
from .storage import BluetoothStorage
from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher
from .util import async_load_history_from_system
@ -102,6 +103,7 @@ class BluetoothManager:
hass: HomeAssistant,
integration_matcher: IntegrationMatcher,
bluetooth_adapters: BluetoothAdapters,
storage: BluetoothStorage,
) -> None:
"""Init bluetooth manager."""
self.hass = hass
@ -128,6 +130,7 @@ class BluetoothManager:
self._adapters: dict[str, AdapterDetails] = {}
self._sources: dict[str, BaseHaScanner] = {}
self._bluetooth_adapters = bluetooth_adapters
self.storage = storage
@property
def supports_passive_scan(self) -> bool:
@ -196,12 +199,9 @@ class BluetoothManager:
"""Set up the bluetooth manager."""
await self._bluetooth_adapters.refresh()
install_multiple_bleak_catcher()
history = async_load_history_from_system(self._bluetooth_adapters)
# Everything is connectable so it fall into both
# buckets since the host system can only provide
# connectable devices
self._all_history = history.copy()
self._connectable_history = history.copy()
self._all_history, self._connectable_history = async_load_history_from_system(
self._bluetooth_adapters, self.storage
)
self.async_setup_unavailable_tracking()
@hass_callback

View file

@ -8,7 +8,7 @@
"requirements": [
"bleak==0.19.2",
"bleak-retry-connector==2.10.1",
"bluetooth-adapters==0.12.0",
"bluetooth-adapters==0.14.1",
"bluetooth-auto-recovery==0.5.5",
"bluetooth-data-tools==0.3.0",
"dbus-fast==1.82.0"

View file

@ -132,6 +132,7 @@ class HaScanner(BaseHaScanner):
"""Init bluetooth discovery."""
source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL
super().__init__(hass, source, adapter)
self.connectable = True
self.mode = mode
self.adapter = adapter
self._start_stop_lock = asyncio.Lock()

View file

@ -0,0 +1,59 @@
"""Storage for remote scanners."""
from __future__ import annotations
from bluetooth_adapters import (
DiscoveredDeviceAdvertisementData,
DiscoveryStorageType,
discovered_device_advertisement_data_from_dict,
discovered_device_advertisement_data_to_dict,
expire_stale_scanner_discovered_device_advertisement_data,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
REMOTE_SCANNER_STORAGE_VERSION = 1
REMOTE_SCANNER_STORAGE_KEY = "bluetooth.remote_scanners"
SCANNER_SAVE_DELAY = 5
class BluetoothStorage:
"""Storage for remote scanners."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the storage."""
self._store: Store[DiscoveryStorageType] = Store(
hass, REMOTE_SCANNER_STORAGE_VERSION, REMOTE_SCANNER_STORAGE_KEY
)
self._data: DiscoveryStorageType = {}
async def async_setup(self) -> None:
"""Set up the storage."""
self._data = await self._store.async_load() or {}
expire_stale_scanner_discovered_device_advertisement_data(self._data)
def scanners(self) -> list[str]:
"""Get all scanners."""
return list(self._data.keys())
@callback
def async_get_advertisement_history(
self, scanner: str
) -> DiscoveredDeviceAdvertisementData | None:
"""Get discovered devices by scanner."""
if not (scanner_data := self._data.get(scanner)):
return None
return discovered_device_advertisement_data_from_dict(scanner_data)
@callback
def _async_get_data(self) -> DiscoveryStorageType:
"""Get data to save to disk."""
return self._data
@callback
def async_set_advertisement_history(
self, scanner: str, data: DiscoveredDeviceAdvertisementData
) -> None:
"""Set discovered devices by scanner."""
self._data[scanner] = discovered_device_advertisement_data_to_dict(data)
self._store.async_delay_save(self._async_get_data, SCANNER_SAVE_DELAY)

View file

@ -8,32 +8,64 @@ from homeassistant.core import callback
from homeassistant.util.dt import monotonic_time_coarse
from .models import BluetoothServiceInfoBleak
from .storage import BluetoothStorage
@callback
def async_load_history_from_system(
adapters: BluetoothAdapters,
) -> dict[str, BluetoothServiceInfoBleak]:
adapters: BluetoothAdapters, storage: BluetoothStorage
) -> tuple[dict[str, BluetoothServiceInfoBleak], dict[str, BluetoothServiceInfoBleak]]:
"""Load the device and advertisement_data history if available on the current system."""
now = monotonic_time_coarse()
return {
address: BluetoothServiceInfoBleak(
name=history.advertisement_data.local_name
or history.device.name
or history.device.address,
address=history.device.address,
rssi=history.advertisement_data.rssi,
manufacturer_data=history.advertisement_data.manufacturer_data,
service_data=history.advertisement_data.service_data,
service_uuids=history.advertisement_data.service_uuids,
source=history.source,
device=history.device,
advertisement=history.advertisement_data,
connectable=False,
time=now,
)
for address, history in adapters.history.items()
}
now_monotonic = monotonic_time_coarse()
connectable_loaded_history: dict[str, BluetoothServiceInfoBleak] = {}
all_loaded_history: dict[str, BluetoothServiceInfoBleak] = {}
# Restore local adapters
for address, history in adapters.history.items():
if (
not (existing_all := connectable_loaded_history.get(address))
or history.advertisement_data.rssi > existing_all.rssi
):
connectable_loaded_history[address] = all_loaded_history[
address
] = BluetoothServiceInfoBleak.from_device_and_advertisement_data(
history.device,
history.advertisement_data,
history.source,
now_monotonic,
True,
)
# Restore remote adapters
for scanner in storage.scanners():
if not (adv_history := storage.async_get_advertisement_history(scanner)):
continue
connectable = adv_history.connectable
discovered_device_timestamps = adv_history.discovered_device_timestamps
for (
address,
(device, advertisement_data),
) in adv_history.discovered_device_advertisement_datas.items():
service_info = BluetoothServiceInfoBleak.from_device_and_advertisement_data(
device,
advertisement_data,
scanner,
discovered_device_timestamps[address],
connectable,
)
if (
not (existing_all := all_loaded_history.get(address))
or service_info.rssi > existing_all.rssi
):
all_loaded_history[address] = service_info
if connectable and (
not (existing_connectable := connectable_loaded_history.get(address))
or service_info.rssi > existing_connectable.rssi
):
connectable_loaded_history[address] = service_info
return all_loaded_history, connectable_loaded_history
async def async_reset_adapter(adapter: str | None) -> bool | None:

View file

@ -6,7 +6,7 @@ from collections.abc import Callable
import contextlib
from dataclasses import dataclass
import logging
from typing import Any, Final
from typing import TYPE_CHECKING, Any, Final
from bleak import BleakClient, BleakError
from bleak.backends.client import BaseBleakClient, get_platform_client_backend_type
@ -18,12 +18,15 @@ from homeassistant.core import CALLBACK_TYPE, callback as hass_callback
from homeassistant.helpers.frame import report
from . import models
from .models import HaBluetoothConnector
FILTER_UUIDS: Final = "UUIDs"
_LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
from .manager import BluetoothManager
@dataclass
class _HaWrappedBleakBackend:
"""Wrap bleak backend to make it usable by Home Assistant."""
@ -207,23 +210,27 @@ class HaBleakClientWrapper(BleakClient):
@hass_callback
def _async_get_backend_for_ble_device(
self, ble_device: BLEDevice
self, manager: BluetoothManager, ble_device: BLEDevice
) -> _HaWrappedBleakBackend | None:
"""Get the backend for a BLEDevice."""
details = ble_device.details
if not isinstance(details, dict) or "connector" not in details:
if not isinstance(details, dict) or "source" not in details:
# If client is not defined in details
# its the client for this platform
cls = get_platform_client_backend_type()
return _HaWrappedBleakBackend(ble_device, cls)
connector: HaBluetoothConnector = details["connector"]
source: str = details["source"]
# Make sure the backend can connect to the device
# as some backends have connection limits
if not connector.can_connect():
if (
not (scanner := manager.async_scanner_by_source(source))
or not scanner.connector
or not scanner.connector.can_connect()
):
return None
return _HaWrappedBleakBackend(ble_device, connector.client)
return _HaWrappedBleakBackend(ble_device, scanner.connector.client)
@hass_callback
def _async_get_best_available_backend_and_device(
@ -246,7 +253,7 @@ class HaBleakClientWrapper(BleakClient):
reverse=True,
):
if backend := self._async_get_backend_for_ble_device(
device_advertisement_data[0]
models.MANAGER, device_advertisement_data[0]
):
return backend

View file

@ -12,7 +12,7 @@ awesomeversion==22.9.0
bcrypt==3.1.7
bleak-retry-connector==2.10.1
bleak==0.19.2
bluetooth-adapters==0.12.0
bluetooth-adapters==0.14.1
bluetooth-auto-recovery==0.5.5
bluetooth-data-tools==0.3.0
certifi>=2021.5.30
@ -21,7 +21,7 @@ cryptography==38.0.3
dbus-fast==1.82.0
fnvhash==0.1.0
hass-nabucasa==0.61.0
home-assistant-bluetooth==1.8.1
home-assistant-bluetooth==1.9.0
home-assistant-frontend==20221208.0
httpx==0.23.1
ifaddr==0.1.7

View file

@ -36,7 +36,7 @@ dependencies = [
# When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.23.1",
"home-assistant-bluetooth==1.8.1",
"home-assistant-bluetooth==1.9.0",
"ifaddr==0.1.7",
"jinja2==3.1.2",
"lru-dict==1.1.8",

View file

@ -11,7 +11,7 @@ bcrypt==3.1.7
certifi>=2021.5.30
ciso8601==2.2.0
httpx==0.23.1
home-assistant-bluetooth==1.8.1
home-assistant-bluetooth==1.9.0
ifaddr==0.1.7
jinja2==3.1.2
lru-dict==1.1.8

View file

@ -450,7 +450,7 @@ bluemaestro-ble==0.2.0
# bluepy==1.3.0
# homeassistant.components.bluetooth
bluetooth-adapters==0.12.0
bluetooth-adapters==0.14.1
# homeassistant.components.bluetooth
bluetooth-auto-recovery==0.5.5

View file

@ -364,7 +364,7 @@ blinkpy==0.19.2
bluemaestro-ble==0.2.0
# homeassistant.components.bluetooth
bluetooth-adapters==0.12.0
bluetooth-adapters==0.14.1
# homeassistant.components.bluetooth
bluetooth-auto-recovery==0.5.5

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -8,16 +8,23 @@ from unittest.mock import patch
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from homeassistant.components.bluetooth import BaseHaRemoteScanner, HaBluetoothConnector
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BaseHaRemoteScanner,
HaBluetoothConnector,
storage,
)
from homeassistant.components.bluetooth.const import (
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
)
from homeassistant.helpers.json import json_loads
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from . import MockBleakClient, _get_manager, generate_advertisement_data
from tests.common import async_fire_time_changed
from tests.common import async_fire_time_changed, load_fixture
async def test_remote_scanner(hass, enable_bluetooth):
@ -72,7 +79,7 @@ async def test_remote_scanner(hass, enable_bluetooth):
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
)
scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, True)
scanner.async_setup()
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner, True)
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
@ -103,6 +110,7 @@ async def test_remote_scanner(hass, enable_bluetooth):
}
cancel()
unsetup()
async def test_remote_scanner_expires_connectable(hass, enable_bluetooth):
@ -143,7 +151,7 @@ async def test_remote_scanner_expires_connectable(hass, enable_bluetooth):
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
)
scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, True)
scanner.async_setup()
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner, True)
start_time_monotonic = time.monotonic()
@ -174,6 +182,7 @@ async def test_remote_scanner_expires_connectable(hass, enable_bluetooth):
assert len(scanner.discovered_devices_and_advertisement_data) == 0
cancel()
unsetup()
async def test_remote_scanner_expires_non_connectable(hass, enable_bluetooth):
@ -214,7 +223,7 @@ async def test_remote_scanner_expires_non_connectable(hass, enable_bluetooth):
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
)
scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False)
scanner.async_setup()
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner, True)
start_time_monotonic = time.monotonic()
@ -268,6 +277,7 @@ async def test_remote_scanner_expires_non_connectable(hass, enable_bluetooth):
assert len(scanner.discovered_devices_and_advertisement_data) == 0
cancel()
unsetup()
async def test_base_scanner_connecting_behavior(hass, enable_bluetooth):
@ -308,7 +318,7 @@ async def test_base_scanner_connecting_behavior(hass, enable_bluetooth):
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
)
scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False)
scanner.async_setup()
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner, True)
with scanner.connecting():
@ -327,3 +337,60 @@ async def test_base_scanner_connecting_behavior(hass, enable_bluetooth):
assert devices[0].name == "wohand"
cancel()
unsetup()
async def test_restore_history_remote_adapter(hass, hass_storage):
"""Test we can restore history for a remote adapter."""
data = hass_storage[storage.REMOTE_SCANNER_STORAGE_KEY] = json_loads(
load_fixture("bluetooth.remote_scanners", bluetooth.DOMAIN)
)
now = time.time()
timestamps = data["data"]["atom-bluetooth-proxy-ceaac4"][
"discovered_device_timestamps"
]
for address in timestamps:
if address != "E3:A5:63:3E:5E:23":
timestamps[address] = now
with patch("bluetooth_adapters.systems.linux.LinuxAdapters.history", {},), patch(
"bluetooth_adapters.systems.linux.LinuxAdapters.refresh",
):
assert await async_setup_component(hass, bluetooth.DOMAIN, {})
await hass.async_block_till_done()
connector = (
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
)
scanner = BaseHaRemoteScanner(
hass,
"atom-bluetooth-proxy-ceaac4",
"atom-bluetooth-proxy-ceaac4",
lambda adv: None,
connector,
True,
)
unsetup = scanner.async_setup()
cancel = _get_manager().async_register_scanner(scanner, True)
assert "EB:0B:36:35:6F:A4" in scanner.discovered_devices_and_advertisement_data
assert "E3:A5:63:3E:5E:23" not in scanner.discovered_devices_and_advertisement_data
cancel()
unsetup()
scanner = BaseHaRemoteScanner(
hass,
"atom-bluetooth-proxy-ceaac4",
"atom-bluetooth-proxy-ceaac4",
lambda adv: None,
connector,
True,
)
unsetup = scanner.async_setup()
cancel = _get_manager().async_register_scanner(scanner, True)
assert "EB:0B:36:35:6F:A4" in scanner.discovered_devices_and_advertisement_data
assert "E3:A5:63:3E:5E:23" not in scanner.discovered_devices_and_advertisement_data
cancel()
unsetup()

View file

@ -8,10 +8,12 @@ from bluetooth_adapters import AdvertisementHistory
import pytest
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import storage
from homeassistant.components.bluetooth.manager import (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import json_loads
from homeassistant.setup import async_setup_component
from . import (
@ -22,6 +24,8 @@ from . import (
inject_advertisement_with_time_and_source_connectable,
)
from tests.common import load_fixture
@pytest.fixture
def register_hci0_scanner(hass: HomeAssistant) -> None:
@ -282,6 +286,76 @@ async def test_restore_history_from_dbus(hass, one_adapter):
assert bluetooth.async_ble_device_from_address(hass, address) is ble_device
async def test_restore_history_from_dbus_and_remote_adapters(
hass, one_adapter, hass_storage
):
"""Test we can restore history from dbus along with remote adapters."""
address = "AA:BB:CC:CC:CC:FF"
data = hass_storage[storage.REMOTE_SCANNER_STORAGE_KEY] = json_loads(
load_fixture("bluetooth.remote_scanners", bluetooth.DOMAIN)
)
now = time.time()
timestamps = data["data"]["atom-bluetooth-proxy-ceaac4"][
"discovered_device_timestamps"
]
for address in timestamps:
timestamps[address] = now
ble_device = BLEDevice(address, "name")
history = {
address: AdvertisementHistory(
ble_device, generate_advertisement_data(local_name="name"), "hci0"
)
}
with patch(
"bluetooth_adapters.systems.linux.LinuxAdapters.history",
history,
):
assert await async_setup_component(hass, bluetooth.DOMAIN, {})
await hass.async_block_till_done()
assert bluetooth.async_ble_device_from_address(hass, address) is not None
assert (
bluetooth.async_ble_device_from_address(hass, "EB:0B:36:35:6F:A4") is not None
)
async def test_restore_history_from_dbus_and_corrupted_remote_adapters(
hass, one_adapter, hass_storage
):
"""Test we can restore history from dbus when the remote adapters data is corrupted."""
address = "AA:BB:CC:CC:CC:FF"
data = hass_storage[storage.REMOTE_SCANNER_STORAGE_KEY] = json_loads(
load_fixture("bluetooth.remote_scanners.corrupt", bluetooth.DOMAIN)
)
now = time.time()
timestamps = data["data"]["atom-bluetooth-proxy-ceaac4"][
"discovered_device_timestamps"
]
for address in timestamps:
timestamps[address] = now
ble_device = BLEDevice(address, "name")
history = {
address: AdvertisementHistory(
ble_device, generate_advertisement_data(local_name="name"), "hci0"
)
}
with patch(
"bluetooth_adapters.systems.linux.LinuxAdapters.history",
history,
):
assert await async_setup_component(hass, bluetooth.DOMAIN, {})
await hass.async_block_till_done()
assert bluetooth.async_ble_device_from_address(hass, address) is not None
assert bluetooth.async_ble_device_from_address(hass, "EB:0B:36:35:6F:A4") is None
async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable(
hass, enable_bluetooth, register_hci0_scanner, register_hci1_scanner
):

View file

@ -9,7 +9,11 @@ from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
import pytest
from homeassistant.components.bluetooth import BaseHaScanner, HaBluetoothConnector
from homeassistant.components.bluetooth import (
BaseHaRemoteScanner,
BaseHaScanner,
HaBluetoothConnector,
)
from homeassistant.components.bluetooth.wrappers import (
HaBleakClientWrapper,
HaBleakScannerWrapper,
@ -57,6 +61,67 @@ async def test_wrapped_bleak_client_set_disconnected_callback_before_connected(
client.set_disconnected_callback(lambda client: None)
async def test_wrapped_bleak_client_local_adapter_only(
hass, enable_bluetooth, one_adapter
):
"""Test wrapped bleak client with only a local adapter."""
manager = _get_manager()
switchbot_device = BLEDevice(
"44:44:33:11:23:45",
"wohand",
{"path": "/org/bluez/hci0/dev_44_44_33_11_23_45"},
)
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100
)
class FakeScanner(BaseHaScanner):
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
return []
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices."""
return {
switchbot_device.address: (
switchbot_device,
switchbot_adv,
)
}
async def async_get_device_by_address(self, address: str) -> BLEDevice | None:
"""Return a list of discovered devices."""
if address == switchbot_device.address:
return switchbot_adv
return None
scanner = FakeScanner(
hass,
"00:00:00:00:00:01",
"hci0",
)
cancel = manager.async_register_scanner(scanner, True)
inject_advertisement_with_source(
hass, switchbot_device, switchbot_adv, "00:00:00:00:00:01"
)
client = HaBleakClientWrapper(switchbot_device)
with patch(
"bleak.backends.bluezdbus.client.BleakClientBlueZDBus.connect",
return_value=True,
), patch("bleak.backends.bluezdbus.client.BleakClientBlueZDBus.is_connected", True):
assert await client.connect() is True
assert client.is_connected is True
client.set_disconnected_callback(lambda client: None)
await client.disconnect()
cancel()
async def test_wrapped_bleak_client_set_disconnected_callback_after_connected(
hass, enable_bluetooth, one_adapter
):
@ -67,9 +132,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected(
"44:44:33:11:23:45",
"wohand",
{
"connector": HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: True
),
"source": "esp32_has_connection_slot",
"path": "/org/bluez/hci0/dev_44_44_33_11_23_45",
},
rssi=-40,
@ -89,17 +152,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected(
local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100
)
inject_advertisement_with_source(
hass, switchbot_device, switchbot_adv, "00:00:00:00:00:01"
)
inject_advertisement_with_source(
hass,
switchbot_proxy_device_has_connection_slot,
switchbot_proxy_device_adv_has_connection_slot,
"esp32_has_connection_slot",
)
class FakeScanner(BaseHaScanner):
class FakeScanner(BaseHaRemoteScanner):
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
@ -123,31 +176,50 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected(
return switchbot_proxy_device_has_connection_slot
return None
scanner = FakeScanner(hass, "esp32", "esp32")
connector = HaBluetoothConnector(
MockBleakClient, "esp32_has_connection_slot", lambda: True
)
scanner = FakeScanner(
hass,
"esp32_has_connection_slot",
"esp32_has_connection_slot",
lambda info: None,
connector,
True,
)
cancel = manager.async_register_scanner(scanner, True)
inject_advertisement_with_source(
hass, switchbot_device, switchbot_adv, "00:00:00:00:00:01"
)
inject_advertisement_with_source(
hass,
switchbot_proxy_device_has_connection_slot,
switchbot_proxy_device_adv_has_connection_slot,
"esp32_has_connection_slot",
)
client = HaBleakClientWrapper(switchbot_proxy_device_has_connection_slot)
with patch("bleak.backends.bluezdbus.client.BleakClientBlueZDBus.connect"):
await client.connect()
assert client.is_connected is True
with patch(
"bleak.backends.bluezdbus.client.BleakClientBlueZDBus.connect",
return_value=True,
), patch("bleak.backends.bluezdbus.client.BleakClientBlueZDBus.is_connected", True):
assert await client.connect() is True
assert client.is_connected is True
client.set_disconnected_callback(lambda client: None)
await client.disconnect()
cancel()
async def test_ble_device_with_proxy_client_out_of_connections(
async def test_ble_device_with_proxy_client_out_of_connections_no_scanners(
hass, enable_bluetooth, one_adapter
):
"""Test we switch to the next available proxy when one runs out of connections."""
"""Test we switch to the next available proxy when one runs out of connections with no scanners."""
manager = _get_manager()
switchbot_proxy_device_no_connection_slot = BLEDevice(
"44:44:33:11:23:45",
"wohand",
{
"connector": HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
),
"source": "esp32",
"path": "/org/bluez/hci0/dev_44_44_33_11_23_45",
},
rssi=-30,
@ -174,17 +246,17 @@ async def test_ble_device_with_proxy_client_out_of_connections(
await client.disconnect()
async def test_ble_device_with_proxy_clear_cache(hass, enable_bluetooth, one_adapter):
"""Test we can clear cache on the proxy."""
async def test_ble_device_with_proxy_client_out_of_connections(
hass, enable_bluetooth, one_adapter
):
"""Test handling all scanners are out of connection slots."""
manager = _get_manager()
switchbot_proxy_device_with_connection_slot = BLEDevice(
switchbot_proxy_device_no_connection_slot = BLEDevice(
"44:44:33:11:23:45",
"wohand",
{
"connector": HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: True
),
"source": "esp32",
"path": "/org/bluez/hci0/dev_44_44_33_11_23_45",
},
rssi=-30,
@ -193,7 +265,70 @@ async def test_ble_device_with_proxy_clear_cache(hass, enable_bluetooth, one_ada
local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}
)
class FakeScanner(BaseHaScanner):
class FakeScanner(BaseHaRemoteScanner):
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
return []
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices."""
return {
switchbot_proxy_device_no_connection_slot.address: (
switchbot_proxy_device_no_connection_slot,
switchbot_adv,
)
}
async def async_get_device_by_address(self, address: str) -> BLEDevice | None:
"""Return a list of discovered devices."""
if address == switchbot_proxy_device_no_connection_slot.address:
return switchbot_adv
return None
connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: False)
scanner = FakeScanner(hass, "esp32", "esp32", lambda info: None, connector, True)
cancel = manager.async_register_scanner(scanner, True)
inject_advertisement_with_source(
hass, switchbot_proxy_device_no_connection_slot, switchbot_adv, "esp32"
)
assert manager.async_discovered_devices(True) == [
switchbot_proxy_device_no_connection_slot
]
client = HaBleakClientWrapper(switchbot_proxy_device_no_connection_slot)
with patch(
"bleak.backends.bluezdbus.client.BleakClientBlueZDBus.connect"
), pytest.raises(BleakError):
await client.connect()
assert client.is_connected is False
client.set_disconnected_callback(lambda client: None)
await client.disconnect()
cancel()
async def test_ble_device_with_proxy_clear_cache(hass, enable_bluetooth, one_adapter):
"""Test we can clear cache on the proxy."""
manager = _get_manager()
switchbot_proxy_device_with_connection_slot = BLEDevice(
"44:44:33:11:23:45",
"wohand",
{
"source": "esp32",
"path": "/org/bluez/hci0/dev_44_44_33_11_23_45",
},
rssi=-30,
)
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}
)
class FakeScanner(BaseHaRemoteScanner):
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
@ -217,7 +352,8 @@ async def test_ble_device_with_proxy_clear_cache(hass, enable_bluetooth, one_ada
return switchbot_adv
return None
scanner = FakeScanner(hass, "esp32", "esp32")
connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: True)
scanner = FakeScanner(hass, "esp32", "esp32", lambda info: None, connector, True)
cancel = manager.async_register_scanner(scanner, True)
inject_advertisement_with_source(
hass, switchbot_proxy_device_with_connection_slot, switchbot_adv, "esp32"
@ -245,9 +381,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab
"44:44:33:11:23:45",
"wohand",
{
"connector": HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
),
"source": "esp32_no_connection_slot",
"path": "/org/bluez/hci0/dev_44_44_33_11_23_45",
},
)
@ -261,9 +395,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab
"44:44:33:11:23:45",
"wohand",
{
"connector": HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: True
),
"source": "esp32_has_connection_slot",
"path": "/org/bluez/hci0/dev_44_44_33_11_23_45",
},
rssi=-40,
@ -299,7 +431,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab
"esp32_no_connection_slot",
)
class FakeScanner(BaseHaScanner):
class FakeScanner(BaseHaRemoteScanner):
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
@ -323,7 +455,17 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab
return switchbot_proxy_device_has_connection_slot
return None
scanner = FakeScanner(hass, "esp32", "esp32")
connector = HaBluetoothConnector(
MockBleakClient, "esp32_has_connection_slot", lambda: True
)
scanner = FakeScanner(
hass,
"esp32_has_connection_slot",
"esp32_has_connection_slot",
lambda info: None,
connector,
True,
)
cancel = manager.async_register_scanner(scanner, True)
assert manager.async_discovered_devices(True) == [
switchbot_proxy_device_no_connection_slot
@ -348,9 +490,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab
"44:44:33:11:23:45",
"wohand_no_connection_slot",
{
"connector": HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
),
"source": "esp32_no_connection_slot",
"path": "/org/bluez/hci0/dev_44_44_33_11_23_45",
},
rssi=-30,
@ -366,9 +506,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab
"44:44:33:11:23:45",
"wohand_has_connection_slot",
{
"connector": HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: True
),
"source": "esp32_has_connection_slot",
"path": "/org/bluez/hci0/dev_44_44_33_11_23_45",
},
)
@ -410,7 +548,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab
"esp32_no_connection_slot",
)
class FakeScanner(BaseHaScanner):
class FakeScanner(BaseHaRemoteScanner):
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
@ -434,7 +572,17 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab
return switchbot_proxy_device_has_connection_slot
return None
scanner = FakeScanner(hass, "esp32", "esp32")
connector = HaBluetoothConnector(
MockBleakClient, "esp32_has_connection_slot", lambda: True
)
scanner = FakeScanner(
hass,
"esp32_has_connection_slot",
"esp32_has_connection_slot",
lambda info: None,
connector,
True,
)
cancel = manager.async_register_scanner(scanner, True)
assert manager.async_discovered_devices(True) == [
switchbot_proxy_device_no_connection_slot

View file

@ -1092,6 +1092,9 @@ async def mock_enable_bluetooth(
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
yield
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
@pytest.fixture(name="mock_bluetooth_adapters")