Skip to content

Commit

Permalink
Restore remote discovered devices between remote scanner restarts (#8…
Browse files Browse the repository at this point in the history
  • Loading branch information
bdraco authored Dec 11, 2022
1 parent fbab741 commit 9008006
Show file tree
Hide file tree
Showing 19 changed files with 3,319 additions and 106 deletions.
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

0 comments on commit 9008006

Please sign in to comment.