Restore history from bluetooth stack at startup (#78612)

pull/76878/head^2
J. Nick Koston 2022-09-17 16:58:19 -05:00 committed by GitHub
parent 13d3f4c3b2
commit 18eef5da1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 151 additions and 55 deletions

View File

@ -228,7 +228,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass))
integration_matcher.async_setup()
manager = BluetoothManager(hass, integration_matcher)
manager.async_setup()
await manager.async_setup()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop)
hass.data[DATA_MANAGER] = models.MANAGER = manager
adapters = await manager.async_get_bluetooth_adapters()

View File

@ -45,7 +45,7 @@ from .models import (
BluetoothServiceInfoBleak,
)
from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher
from .util import async_get_bluetooth_adapters
from .util import async_get_bluetooth_adapters, async_load_history_from_system
if TYPE_CHECKING:
from bleak.backends.device import BLEDevice
@ -213,10 +213,15 @@ class BluetoothManager:
self._adapters = await async_get_bluetooth_adapters()
return self._find_adapter_by_address(address)
@hass_callback
def async_setup(self) -> None:
async def async_setup(self) -> None:
"""Set up the bluetooth manager."""
install_multiple_bleak_catcher()
history = await async_load_history_from_system()
# Everything is connectable so it fall into both
# buckets since the host system can only provide
# connectable devices
self._history = history.copy()
self._connectable_history = history.copy()
self.async_setup_unavailable_tracking()
@hass_callback

View File

@ -7,7 +7,7 @@
"requirements": [
"bleak==0.17.0",
"bleak-retry-connector==1.17.1",
"bluetooth-adapters==0.4.1",
"bluetooth-adapters==0.5.1",
"bluetooth-auto-recovery==0.3.3",
"dbus-fast==1.4.0"
],

View File

@ -19,13 +19,7 @@ from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from dbus_fast import InvalidMessageError
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
CALLBACK_TYPE,
Event,
HomeAssistant,
callback as hass_callback,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.package import is_docker_env
@ -133,7 +127,6 @@ class HaScanner(BaseHaScanner):
self.scanner = scanner
self.adapter = adapter
self._start_stop_lock = asyncio.Lock()
self._cancel_stop: CALLBACK_TYPE | None = None
self._cancel_watchdog: CALLBACK_TYPE | None = None
self._last_detection = 0.0
self._start_time = 0.0
@ -318,9 +311,6 @@ class HaScanner(BaseHaScanner):
break
self._async_setup_scanner_watchdog()
self._cancel_stop = self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, self._async_hass_stopping
)
@hass_callback
def _async_setup_scanner_watchdog(self) -> None:
@ -368,11 +358,6 @@ class HaScanner(BaseHaScanner):
exc_info=True,
)
async def _async_hass_stopping(self, event: Event) -> None:
"""Stop the Bluetooth integration at shutdown."""
self._cancel_stop = None
await self.async_stop()
async def _async_reset_adapter(self) -> None:
"""Reset the adapter."""
# There is currently nothing the user can do to fix this
@ -396,9 +381,6 @@ class HaScanner(BaseHaScanner):
async def _async_stop_scanner(self) -> None:
"""Stop bluetooth discovery under the lock."""
if self._cancel_stop:
self._cancel_stop()
self._cancel_stop = None
_LOGGER.debug("%s: Stopping bluetooth discovery", self.name)
try:
await self.scanner.stop() # type: ignore[no-untyped-call]

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import platform
import time
from bluetooth_auto_recovery import recover_adapter
@ -15,6 +16,38 @@ from .const import (
WINDOWS_DEFAULT_BLUETOOTH_ADAPTER,
AdapterDetails,
)
from .models import BluetoothServiceInfoBleak
async def async_load_history_from_system() -> dict[str, BluetoothServiceInfoBleak]:
"""Load the device and advertisement_data history if available on the current system."""
if platform.system() != "Linux":
return {}
from bluetooth_adapters import ( # pylint: disable=import-outside-toplevel
BlueZDBusObjects,
)
bluez_dbus = BlueZDBusObjects()
await bluez_dbus.load()
now = time.monotonic()
return {
address: BluetoothServiceInfoBleak(
name=history.advertisement_data.local_name
or history.device.name
or history.device.address,
address=history.device.address,
rssi=history.device.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 bluez_dbus.history.items()
}
async def async_get_bluetooth_adapters() -> dict[str, AdapterDetails]:

View File

@ -12,7 +12,7 @@ awesomeversion==22.9.0
bcrypt==3.1.7
bleak-retry-connector==1.17.1
bleak==0.17.0
bluetooth-adapters==0.4.1
bluetooth-adapters==0.5.1
bluetooth-auto-recovery==0.3.3
certifi>=2021.5.30
ciso8601==2.2.0

View File

@ -430,7 +430,7 @@ bluemaestro-ble==0.2.0
# bluepy==1.3.0
# homeassistant.components.bluetooth
bluetooth-adapters==0.4.1
bluetooth-adapters==0.5.1
# homeassistant.components.bluetooth
bluetooth-auto-recovery==0.3.3

View File

@ -341,7 +341,7 @@ blinkpy==0.19.2
bluemaestro-ble==0.2.0
# homeassistant.components.bluetooth
bluetooth-adapters==0.4.1
bluetooth-adapters==0.5.1
# homeassistant.components.bluetooth
bluetooth-auto-recovery==0.3.3

View File

@ -1,10 +1,20 @@
"""Tests for the bluetooth component."""
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture(name="bluez_dbus_mock")
def bluez_dbus_mock():
"""Fixture that mocks out the bluez dbus calls."""
# Must patch directly since this is loaded on demand only
with patch(
"bluetooth_adapters.BlueZDBusObjects", return_value=MagicMock(load=AsyncMock())
):
yield
@pytest.fixture(name="macos_adapter")
def macos_adapter():
"""Fixture that mocks the macos adapter."""
@ -25,7 +35,7 @@ def windows_adapter():
@pytest.fixture(name="one_adapter")
def one_adapter_fixture():
def one_adapter_fixture(bluez_dbus_mock):
"""Fixture that mocks one adapter on Linux."""
with patch(
"homeassistant.components.bluetooth.platform.system", return_value="Linux"
@ -54,7 +64,7 @@ def one_adapter_fixture():
@pytest.fixture(name="two_adapters")
def two_adapters_fixture():
def two_adapters_fixture(bluez_dbus_mock):
"""Fixture that mocks two adapters on Linux."""
with patch(
"homeassistant.components.bluetooth.platform.system", return_value="Linux"

View File

@ -47,7 +47,9 @@ GENERIC_BLUETOOTH_SERVICE_INFO_2 = BluetoothServiceInfo(
)
async def test_basic_usage(hass: HomeAssistant, mock_bleak_scanner_start):
async def test_basic_usage(
hass: HomeAssistant, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test basic usage of the ActiveBluetoothProcessorCoordinator."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@ -92,7 +94,9 @@ async def test_basic_usage(hass: HomeAssistant, mock_bleak_scanner_start):
cancel()
async def test_poll_can_be_skipped(hass: HomeAssistant, mock_bleak_scanner_start):
async def test_poll_can_be_skipped(
hass: HomeAssistant, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test need_poll callback works and can skip a poll if its not needed."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@ -151,7 +155,7 @@ async def test_poll_can_be_skipped(hass: HomeAssistant, mock_bleak_scanner_start
async def test_bleak_error_and_recover(
hass: HomeAssistant, mock_bleak_scanner_start, caplog
hass: HomeAssistant, mock_bleak_scanner_start, mock_bluetooth_adapters, caplog
):
"""Test bleak error handling and recovery."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@ -212,7 +216,9 @@ async def test_bleak_error_and_recover(
cancel()
async def test_poll_failure_and_recover(hass: HomeAssistant, mock_bleak_scanner_start):
async def test_poll_failure_and_recover(
hass: HomeAssistant, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test error handling and recovery."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@ -267,7 +273,9 @@ async def test_poll_failure_and_recover(hass: HomeAssistant, mock_bleak_scanner_
cancel()
async def test_second_poll_needed(hass: HomeAssistant, mock_bleak_scanner_start):
async def test_second_poll_needed(
hass: HomeAssistant, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""If a poll is queued, by the time it starts it may no longer be needed."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@ -314,7 +322,9 @@ async def test_second_poll_needed(hass: HomeAssistant, mock_bleak_scanner_start)
cancel()
async def test_rate_limit(hass: HomeAssistant, mock_bleak_scanner_start):
async def test_rate_limit(
hass: HomeAssistant, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test error handling and recovery."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})

View File

@ -18,7 +18,11 @@ from tests.common import MockConfigEntry
async def test_options_flow_disabled_not_setup(
hass, hass_ws_client, mock_bleak_scanner_start, macos_adapter
hass,
hass_ws_client,
mock_bleak_scanner_start,
mock_bluetooth_adapters,
macos_adapter,
):
"""Test options are disabled if the integration has not been setup."""
await async_setup_component(hass, "config", {})
@ -38,6 +42,7 @@ async def test_options_flow_disabled_not_setup(
)
response = await ws_client.receive_json()
assert response["result"][0]["supports_options"] is False
await hass.config_entries.async_unload(entry.entry_id)
async def test_async_step_user_macos(hass, macos_adapter):
@ -262,7 +267,9 @@ async def test_async_step_integration_discovery_already_exists(hass):
assert result["reason"] == "already_configured"
async def test_options_flow_linux(hass, mock_bleak_scanner_start, one_adapter):
async def test_options_flow_linux(
hass, mock_bleak_scanner_start, mock_bluetooth_adapters, one_adapter
):
"""Test options on Linux."""
entry = MockConfigEntry(
domain=DOMAIN,
@ -308,10 +315,15 @@ async def test_options_flow_linux(hass, mock_bleak_scanner_start, one_adapter):
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_PASSIVE] is False
await hass.config_entries.async_unload(entry.entry_id)
async def test_options_flow_disabled_macos(
hass, hass_ws_client, mock_bleak_scanner_start, macos_adapter
hass,
hass_ws_client,
mock_bleak_scanner_start,
mock_bluetooth_adapters,
macos_adapter,
):
"""Test options are disabled on MacOS."""
await async_setup_component(hass, "config", {})
@ -334,10 +346,11 @@ async def test_options_flow_disabled_macos(
)
response = await ws_client.receive_json()
assert response["result"][0]["supports_options"] is False
await hass.config_entries.async_unload(entry.entry_id)
async def test_options_flow_enabled_linux(
hass, hass_ws_client, mock_bleak_scanner_start, one_adapter
hass, hass_ws_client, mock_bleak_scanner_start, mock_bluetooth_adapters, one_adapter
):
"""Test options are enabled on Linux."""
await async_setup_component(hass, "config", {})
@ -363,3 +376,4 @@ async def test_options_flow_enabled_linux(
)
response = await ws_client.receive_json()
assert response["result"][0]["supports_options"] is True
await hass.config_entries.async_unload(entry.entry_id)

View File

@ -2446,7 +2446,7 @@ async def test_auto_detect_bluetooth_adapters_linux_multiple(hass, two_adapters)
assert len(hass.config_entries.flow.async_progress(bluetooth.DOMAIN)) == 2
async def test_auto_detect_bluetooth_adapters_linux_none_found(hass):
async def test_auto_detect_bluetooth_adapters_linux_none_found(hass, bluez_dbus_mock):
"""Test we auto detect bluetooth adapters on linux with no adapters found."""
with patch(
"bluetooth_adapters.get_bluetooth_adapter_details", return_value={}

View File

@ -1,10 +1,13 @@
"""Tests for the Bluetooth integration manager."""
from unittest.mock import AsyncMock, MagicMock, patch
from bleak.backends.scanner import AdvertisementData, BLEDevice
from bluetooth_adapters import AdvertisementHistory
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.manager import STALE_ADVERTISEMENT_SECONDS
from homeassistant.setup import async_setup_component
from . import (
inject_advertisement_with_source,
@ -176,3 +179,24 @@ async def test_switching_adapters_based_on_stale(hass, enable_bluetooth):
bluetooth.async_ble_device_from_address(hass, address)
is switchbot_device_poor_signal_hci1
)
async def test_restore_history_from_dbus(hass, one_adapter):
"""Test we can restore history from dbus."""
address = "AA:BB:CC:CC:CC:FF"
ble_device = BLEDevice(address, "name")
history = {
address: AdvertisementHistory(
ble_device, AdvertisementData(local_name="name"), "hci0"
)
}
with patch(
"bluetooth_adapters.BlueZDBusObjects",
return_value=MagicMock(load=AsyncMock(), 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 ble_device

View File

@ -59,7 +59,7 @@ class MyCoordinator(PassiveBluetoothDataUpdateCoordinator):
super()._async_handle_bluetooth_event(service_info, change)
async def test_basic_usage(hass, mock_bleak_scanner_start):
async def test_basic_usage(hass, mock_bleak_scanner_start, mock_bluetooth_adapters):
"""Test basic usage of the PassiveBluetoothDataUpdateCoordinator."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
coordinator = MyCoordinator(
@ -88,7 +88,7 @@ async def test_basic_usage(hass, mock_bleak_scanner_start):
async def test_context_compatiblity_with_data_update_coordinator(
hass, mock_bleak_scanner_start
hass, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test contexts can be passed for compatibility with DataUpdateCoordinator."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@ -124,7 +124,7 @@ async def test_context_compatiblity_with_data_update_coordinator(
async def test_unavailable_callbacks_mark_the_coordinator_unavailable(
hass, mock_bleak_scanner_start
hass, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test that the coordinator goes unavailable when the bluetooth stack no longer sees the device."""
with patch(
@ -165,7 +165,9 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable(
assert coordinator.available is False
async def test_passive_bluetooth_coordinator_entity(hass, mock_bleak_scanner_start):
async def test_passive_bluetooth_coordinator_entity(
hass, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test integration of PassiveBluetoothDataUpdateCoordinator with PassiveBluetoothCoordinatorEntity."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
coordinator = MyCoordinator(

View File

@ -98,7 +98,7 @@ GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
)
async def test_basic_usage(hass, mock_bleak_scanner_start):
async def test_basic_usage(hass, mock_bleak_scanner_start, mock_bluetooth_adapters):
"""Test basic usage of the PassiveBluetoothProcessorCoordinator."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@ -196,7 +196,9 @@ async def test_basic_usage(hass, mock_bleak_scanner_start):
cancel_coordinator()
async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
async def test_unavailable_after_no_data(
hass, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test that the coordinator is unavailable after no data for a while."""
with patch(
"bleak.BleakScanner.discovered_devices", # Must patch before we setup
@ -290,7 +292,9 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
cancel_coordinator()
async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start):
async def test_no_updates_once_stopping(
hass, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test updates are ignored once hass is stopping."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@ -343,7 +347,9 @@ async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start):
cancel_coordinator()
async def test_exception_from_update_method(hass, caplog, mock_bleak_scanner_start):
async def test_exception_from_update_method(
hass, caplog, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test we handle exceptions from the update method."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@ -406,7 +412,9 @@ async def test_exception_from_update_method(hass, caplog, mock_bleak_scanner_sta
cancel_coordinator()
async def test_bad_data_from_update_method(hass, mock_bleak_scanner_start):
async def test_bad_data_from_update_method(
hass, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test we handle bad data from the update method."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@ -758,7 +766,9 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
)
async def test_integration_with_entity(hass, mock_bleak_scanner_start):
async def test_integration_with_entity(
hass, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test integration of PassiveBluetoothProcessorCoordinator with PassiveBluetoothCoordinatorEntity."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@ -888,7 +898,9 @@ NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
)
async def test_integration_with_entity_without_a_device(hass, mock_bleak_scanner_start):
async def test_integration_with_entity_without_a_device(
hass, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test integration with PassiveBluetoothCoordinatorEntity with no device."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@ -950,7 +962,7 @@ async def test_integration_with_entity_without_a_device(hass, mock_bleak_scanner
async def test_passive_bluetooth_entity_with_entity_platform(
hass, mock_bleak_scanner_start
hass, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test with a mock entity platform."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@ -1048,7 +1060,9 @@ BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
)
async def test_integration_multiple_entity_platforms(hass, mock_bleak_scanner_start):
async def test_integration_multiple_entity_platforms(
hass, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test integration of PassiveBluetoothProcessorCoordinator with multiple platforms."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@ -1138,7 +1152,7 @@ async def test_integration_multiple_entity_platforms(hass, mock_bleak_scanner_st
async def test_exception_from_coordinator_update_method(
hass, caplog, mock_bleak_scanner_start
hass, caplog, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Test we handle exceptions from the update method."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})

View File

@ -991,6 +991,8 @@ def mock_bluetooth_adapters():
"""Fixture to mock bluetooth adapters."""
with patch(
"homeassistant.components.bluetooth.util.platform.system", return_value="Linux"
), patch(
"bluetooth_adapters.BlueZDBusObjects", return_value=MagicMock(load=AsyncMock())
), patch(
"bluetooth_adapters.get_bluetooth_adapter_details",
return_value={