Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Restore remote discovered devices between remote scanner restarts #83699

Merged
merged 42 commits into from
Dec 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
bc7fa2a
Restore discovered devices between remote scanner restarts
bdraco Dec 10, 2022
cb754c5
Restore discovered devices between remote scanner restarts
bdraco Dec 10, 2022
dd347a9
make it serializable
bdraco Dec 10, 2022
8674b17
restore
bdraco Dec 10, 2022
9ecb975
wip
bdraco Dec 10, 2022
4d8fcc4
fix
bdraco Dec 10, 2022
1269ad0
fix
bdraco Dec 10, 2022
78bec89
fix
bdraco Dec 10, 2022
fec2c84
cleanup
bdraco Dec 10, 2022
6fcaf26
fixes
bdraco Dec 10, 2022
19d0fc6
fix
bdraco Dec 10, 2022
a6e5e8b
restore int
bdraco Dec 10, 2022
0691e80
typing
bdraco Dec 10, 2022
a3213a0
typing
bdraco Dec 10, 2022
557baff
typing
bdraco Dec 10, 2022
689ae2b
typing
bdraco Dec 10, 2022
ca21293
typing
bdraco Dec 10, 2022
2c8d7c6
typing
bdraco Dec 10, 2022
0de55ae
Update homeassistant/components/bluetooth/base_scanner.py
bdraco Dec 10, 2022
85bc7b8
offload
bdraco Dec 10, 2022
cb42b1b
Merge remote-tracking branch 'origin/esphome_restore_addresses' into …
bdraco Dec 10, 2022
4a2cd07
offload more
bdraco Dec 10, 2022
060781f
bump home-assistant-bluetooth==1.9.0
bdraco Dec 10, 2022
7a12387
cleanups
bdraco Dec 10, 2022
f63633b
bump again
bdraco Dec 10, 2022
77ee89c
Merge branch 'dev' into esphome_restore_addresses
bdraco Dec 10, 2022
a79ffc3
fix mocking
bdraco Dec 10, 2022
56e1bd9
cover
bdraco Dec 10, 2022
6bfe9b2
reduce delay
bdraco Dec 10, 2022
3f1b196
preen
bdraco Dec 10, 2022
b04aeff
restore
bdraco Dec 11, 2022
fbc6aed
tweak
bdraco Dec 11, 2022
52b0349
fixtures
bdraco Dec 11, 2022
4166942
Merge branch 'dev' into esphome_restore_addresses
bdraco Dec 11, 2022
3cbbcd9
empty
bdraco Dec 11, 2022
56e1cef
empty
bdraco Dec 11, 2022
fe21e75
Merge branch 'dev' into esphome_restore_addresses
bdraco Dec 11, 2022
58e0ae4
Merge branch 'dev' into esphome_restore_addresses
bdraco Dec 11, 2022
bcc5d43
fix missing mocking
bdraco Dec 11, 2022
87d0469
Merge branch 'dev' into esphome_restore_addresses
bdraco Dec 11, 2022
a325be7
mock
bdraco Dec 11, 2022
7c74e1f
Merge remote-tracking branch 'origin/esphome_restore_addresses' into …
bdraco Dec 11, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions homeassistant/components/bluetooth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,14 @@
)
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
Expand Down Expand Up @@ -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
Expand Down
69 changes: 59 additions & 10 deletions homeassistant/components/bluetooth/base_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,21 @@
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,
Expand All @@ -30,12 +38,22 @@
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
Expand Down Expand Up @@ -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__(
Expand All @@ -109,22 +126,54 @@ def __init__(
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
)

@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."""
Expand Down Expand Up @@ -222,7 +271,7 @@ def _async_on_advertisement(
source=self.source,
device=device,
advertisement=advertisement_data,
connectable=self._connectable,
connectable=self.connectable,
time=now,
)
)
Expand Down
12 changes: 6 additions & 6 deletions homeassistant/components/bluetooth/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
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

Expand Down Expand Up @@ -102,6 +103,7 @@ def __init__(
hass: HomeAssistant,
integration_matcher: IntegrationMatcher,
bluetooth_adapters: BluetoothAdapters,
storage: BluetoothStorage,
) -> None:
"""Init bluetooth manager."""
self.hass = hass
Expand All @@ -128,6 +130,7 @@ def __init__(
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:
Expand Down Expand Up @@ -196,12 +199,9 @@ async def async_setup(self) -> None:
"""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
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/bluetooth/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/bluetooth/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ def __init__(
"""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()
Expand Down
59 changes: 59 additions & 0 deletions homeassistant/components/bluetooth/storage.py
Original file line number Diff line number Diff line change
@@ -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)
74 changes: 53 additions & 21 deletions homeassistant/components/bluetooth/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,64 @@
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:
Expand Down
Loading