Restore passive bluetooth entity data at startup (#97462)

This commit is contained in:
J. Nick Koston 2023-08-06 20:25:18 -10:00 committed by GitHub
parent 1adfa6bbeb
commit 56257b7a38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 560 additions and 17 deletions

View File

@ -38,7 +38,7 @@ from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.issue_registry import async_delete_issue
from homeassistant.loader import async_get_bluetooth from homeassistant.loader import async_get_bluetooth
from . import models from . import models, passive_update_processor
from .api import ( from .api import (
_get_manager, _get_manager,
async_address_present, async_address_present,
@ -125,6 +125,7 @@ async def _async_get_adapter_from_address(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the bluetooth integration.""" """Set up the bluetooth integration."""
await passive_update_processor.async_setup(hass)
integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass)) integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass))
integration_matcher.async_setup() integration_matcher.async_setup()
bluetooth_adapters = get_adapters() bluetooth_adapters = get_adapters()

View File

@ -2,12 +2,31 @@
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
from datetime import timedelta
from functools import cache
import logging import logging
from typing import TYPE_CHECKING, Any, Generic, TypeVar from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast
from homeassistant.const import ATTR_IDENTIFIERS, ATTR_NAME from homeassistant import config_entries
from homeassistant.core import HomeAssistant, callback from homeassistant.const import (
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription ATTR_IDENTIFIERS,
ATTR_NAME,
CONF_ENTITY_CATEGORY,
EVENT_HOMEASSISTANT_STOP,
EntityCategory,
)
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers.entity import (
DeviceInfo,
Entity,
EntityDescription,
)
from homeassistant.helpers.entity_platform import (
async_get_current_platform,
)
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.storage import Store
from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN from .const import DOMAIN
from .update_coordinator import BasePassiveBluetoothCoordinator from .update_coordinator import BasePassiveBluetoothCoordinator
@ -23,6 +42,12 @@ if TYPE_CHECKING:
BluetoothServiceInfoBleak, BluetoothServiceInfoBleak,
) )
STORAGE_KEY = "bluetooth.passive_update_processor"
STORAGE_VERSION = 1
STORAGE_SAVE_INTERVAL = timedelta(minutes=15)
PASSIVE_UPDATE_PROCESSOR = "passive_update_processor"
_T = TypeVar("_T")
@dataclasses.dataclass(slots=True, frozen=True) @dataclasses.dataclass(slots=True, frozen=True)
class PassiveBluetoothEntityKey: class PassiveBluetoothEntityKey:
@ -36,8 +61,67 @@ class PassiveBluetoothEntityKey:
key: str key: str
device_id: str | None device_id: str | None
def to_string(self) -> str:
"""Convert the key to a string which can be used as JSON key."""
return f"{self.key}___{self.device_id or ''}"
_T = TypeVar("_T") @classmethod
def from_string(cls, key: str) -> PassiveBluetoothEntityKey:
"""Convert a string (from JSON) to a key."""
key, device_id = key.split("___")
return cls(key, device_id or None)
@dataclasses.dataclass(slots=True, frozen=False)
class PassiveBluetoothProcessorData:
"""Data for the passive bluetooth processor."""
coordinators: set[PassiveBluetoothProcessorCoordinator]
all_restore_data: dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]]
class RestoredPassiveBluetoothDataUpdate(TypedDict):
"""Restored PassiveBluetoothDataUpdate."""
devices: dict[str, DeviceInfo]
entity_descriptions: dict[str, dict[str, Any]]
entity_names: dict[str, str | None]
entity_data: dict[str, Any]
# Fields do not change so we can cache the result
# of calling fields() on the dataclass
cached_fields = cache(dataclasses.fields)
def deserialize_entity_description(
descriptions_class: type[EntityDescription], data: dict[str, Any]
) -> EntityDescription:
"""Deserialize an entity description."""
result: dict[str, Any] = {}
for field in cached_fields(descriptions_class): # type: ignore[arg-type]
field_name = field.name
# It would be nice if field.type returned the actual
# type instead of a str so we could avoid writing this
# out, but it doesn't. If we end up using this in more
# places we can add a `as_dict` and a `from_dict`
# method to these classes
if field_name == CONF_ENTITY_CATEGORY:
value = try_parse_enum(EntityCategory, data.get(field_name))
else:
value = data.get(field_name)
result[field_name] = value
return descriptions_class(**result)
def serialize_entity_description(description: EntityDescription) -> dict[str, Any]:
"""Serialize an entity description."""
as_dict = dataclasses.asdict(description)
return {
field.name: as_dict[field.name]
for field in cached_fields(type(description)) # type: ignore[arg-type]
if field.default != as_dict.get(field.name)
}
@dataclasses.dataclass(slots=True, frozen=True) @dataclasses.dataclass(slots=True, frozen=True)
@ -62,6 +146,114 @@ class PassiveBluetoothDataUpdate(Generic[_T]):
self.entity_data.update(new_data.entity_data) self.entity_data.update(new_data.entity_data)
self.entity_names.update(new_data.entity_names) self.entity_names.update(new_data.entity_names)
def async_get_restore_data(self) -> RestoredPassiveBluetoothDataUpdate:
"""Serialize restore data to storage."""
return {
"devices": {
key or "": device_info for key, device_info in self.devices.items()
},
"entity_descriptions": {
key.to_string(): serialize_entity_description(description)
for key, description in self.entity_descriptions.items()
},
"entity_names": {
key.to_string(): name for key, name in self.entity_names.items()
},
"entity_data": {
key.to_string(): data for key, data in self.entity_data.items()
},
}
@callback
def async_set_restore_data(
self,
restore_data: RestoredPassiveBluetoothDataUpdate,
entity_description_class: type[EntityDescription],
) -> None:
"""Set the restored data from storage."""
self.devices.update(
{
key or None: device_info
for key, device_info in restore_data["devices"].items()
}
)
self.entity_descriptions.update(
{
PassiveBluetoothEntityKey.from_string(
key
): deserialize_entity_description(entity_description_class, description)
for key, description in restore_data["entity_descriptions"].items()
if description
}
)
self.entity_names.update(
{
PassiveBluetoothEntityKey.from_string(key): name
for key, name in restore_data["entity_names"].items()
}
)
self.entity_data.update(
{
PassiveBluetoothEntityKey.from_string(key): cast(_T, data)
for key, data in restore_data["entity_data"].items()
}
)
def async_register_coordinator_for_restore(
hass: HomeAssistant, coordinator: PassiveBluetoothProcessorCoordinator
) -> CALLBACK_TYPE:
"""Register a coordinator to have its processors data restored."""
data: PassiveBluetoothProcessorData = hass.data[PASSIVE_UPDATE_PROCESSOR]
coordinators = data.coordinators
coordinators.add(coordinator)
if restore_key := coordinator.restore_key:
coordinator.restore_data = data.all_restore_data.setdefault(restore_key, {})
@callback
def _unregister_coordinator_for_restore() -> None:
"""Unregister a coordinator."""
coordinators.remove(coordinator)
return _unregister_coordinator_for_restore
async def async_setup(hass: HomeAssistant) -> None:
"""Set up the passive update processor coordinators."""
storage: Store[dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]]] = Store(
hass, STORAGE_VERSION, STORAGE_KEY
)
coordinators: set[PassiveBluetoothProcessorCoordinator] = set()
all_restore_data: dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]] = (
await storage.async_load() or {}
)
hass.data[PASSIVE_UPDATE_PROCESSOR] = PassiveBluetoothProcessorData(
coordinators, all_restore_data
)
async def _async_save_processor_data(_: Any) -> None:
"""Save the processor data."""
await storage.async_save(
{
coordinator.restore_key: coordinator.async_get_restore_data()
for coordinator in coordinators
if coordinator.restore_key
}
)
cancel_interval = async_track_time_interval(
hass, _async_save_processor_data, STORAGE_SAVE_INTERVAL
)
async def _async_save_processor_data_at_stop(_event: Event) -> None:
"""Save the processor data at shutdown."""
cancel_interval()
await _async_save_processor_data(None)
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, _async_save_processor_data_at_stop
)
class PassiveBluetoothProcessorCoordinator( class PassiveBluetoothProcessorCoordinator(
Generic[_T], BasePassiveBluetoothCoordinator Generic[_T], BasePassiveBluetoothCoordinator
@ -90,23 +282,49 @@ class PassiveBluetoothProcessorCoordinator(
self._processors: list[PassiveBluetoothDataProcessor] = [] self._processors: list[PassiveBluetoothDataProcessor] = []
self._update_method = update_method self._update_method = update_method
self.last_update_success = True self.last_update_success = True
self.restore_data: dict[str, RestoredPassiveBluetoothDataUpdate] = {}
self.restore_key = None
if config_entry := config_entries.current_entry.get():
self.restore_key = config_entry.entry_id
self._on_stop.append(async_register_coordinator_for_restore(self.hass, self))
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if the device is available.""" """Return if the device is available."""
return super().available and self.last_update_success return super().available and self.last_update_success
@callback
def async_get_restore_data(
self,
) -> dict[str, RestoredPassiveBluetoothDataUpdate]:
"""Generate the restore data."""
return {
processor.restore_key: processor.data.async_get_restore_data()
for processor in self._processors
if processor.restore_key
}
@callback @callback
def async_register_processor( def async_register_processor(
self, self,
processor: PassiveBluetoothDataProcessor, processor: PassiveBluetoothDataProcessor,
entity_description_class: type[EntityDescription] | None = None,
) -> Callable[[], None]: ) -> Callable[[], None]:
"""Register a processor that subscribes to updates.""" """Register a processor that subscribes to updates."""
processor.async_register_coordinator(self)
# entity_description_class will become mandatory
# in the future, but is optional for now to allow
# for a transition period.
processor.async_register_coordinator(self, entity_description_class)
@callback @callback
def remove_processor() -> None: def remove_processor() -> None:
"""Remove a processor.""" """Remove a processor."""
# Save the data before removing the processor
# so if they reload its still there
if restore_key := processor.restore_key:
self.restore_data[restore_key] = processor.data.async_get_restore_data()
self._processors.remove(processor) self._processors.remove(processor)
self._processors.append(processor) self._processors.append(processor)
@ -182,12 +400,18 @@ class PassiveBluetoothDataProcessor(Generic[_T]):
entity_data: dict[PassiveBluetoothEntityKey, _T] entity_data: dict[PassiveBluetoothEntityKey, _T]
entity_descriptions: dict[PassiveBluetoothEntityKey, EntityDescription] entity_descriptions: dict[PassiveBluetoothEntityKey, EntityDescription]
devices: dict[str | None, DeviceInfo] devices: dict[str | None, DeviceInfo]
restore_key: str | None
def __init__( def __init__(
self, self,
update_method: Callable[[_T], PassiveBluetoothDataUpdate[_T]], update_method: Callable[[_T], PassiveBluetoothDataUpdate[_T]],
restore_key: str | None = None,
) -> None: ) -> None:
"""Initialize the coordinator.""" """Initialize the coordinator."""
try:
self.restore_key = restore_key or async_get_current_platform().domain
except RuntimeError:
self.restore_key = None
self._listeners: list[ self._listeners: list[
Callable[[PassiveBluetoothDataUpdate[_T] | None], None] Callable[[PassiveBluetoothDataUpdate[_T] | None], None]
] = [] ] = []
@ -202,15 +426,29 @@ class PassiveBluetoothDataProcessor(Generic[_T]):
def async_register_coordinator( def async_register_coordinator(
self, self,
coordinator: PassiveBluetoothProcessorCoordinator, coordinator: PassiveBluetoothProcessorCoordinator,
entity_description_class: type[EntityDescription] | None,
) -> None: ) -> None:
"""Register a coordinator.""" """Register a coordinator."""
self.coordinator = coordinator self.coordinator = coordinator
self.data = PassiveBluetoothDataUpdate() self.data = PassiveBluetoothDataUpdate()
data = self.data data = self.data
# These attributes to access the data in
# self.data are for backwards compatibility.
self.entity_names = data.entity_names self.entity_names = data.entity_names
self.entity_data = data.entity_data self.entity_data = data.entity_data
self.entity_descriptions = data.entity_descriptions self.entity_descriptions = data.entity_descriptions
self.devices = data.devices self.devices = data.devices
if (
entity_description_class
and (restore_key := self.restore_key)
and (restore_data := coordinator.restore_data)
and (restored_processor_data := restore_data.get(restore_key))
):
data.async_set_restore_data(
restored_processor_data,
entity_description_class,
)
self.async_update_listeners(data)
@property @property
def available(self) -> bool: def available(self) -> bool:

View File

@ -1,15 +1,18 @@
"""Tests for the Bluetooth integration.""" """Tests for the Bluetooth integration."""
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
import time import time
from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from home_assistant_bluetooth import BluetoothServiceInfo from home_assistant_bluetooth import BluetoothServiceInfo
import pytest import pytest
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntityDescription, BinarySensorEntityDescription,
) )
@ -22,13 +25,19 @@ from homeassistant.components.bluetooth import (
) )
from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS
from homeassistant.components.bluetooth.passive_update_processor import ( from homeassistant.components.bluetooth.passive_update_processor import (
STORAGE_KEY,
PassiveBluetoothDataProcessor, PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate, PassiveBluetoothDataUpdate,
PassiveBluetoothEntityKey, PassiveBluetoothEntityKey,
PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorCoordinator,
PassiveBluetoothProcessorEntity, PassiveBluetoothProcessorEntity,
) )
from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntityDescription,
)
from homeassistant.config_entries import current_entry
from homeassistant.const import UnitOfTemperature from homeassistant.const import UnitOfTemperature
from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.core import CoreState, HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
@ -41,7 +50,12 @@ from . import (
patch_all_discovered_devices, patch_all_discovered_devices,
) )
from tests.common import MockEntityPlatform, async_fire_time_changed from tests.common import (
MockConfigEntry,
MockEntityPlatform,
async_fire_time_changed,
async_test_home_assistant,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -1092,6 +1106,18 @@ BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
) )
DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
devices={
None: DeviceInfo(
name="Test Device", model="Test Model", manufacturer="Test Manufacturer"
),
},
entity_data={},
entity_names={},
entity_descriptions={},
)
async def test_integration_multiple_entity_platforms( async def test_integration_multiple_entity_platforms(
hass: HomeAssistant, hass: HomeAssistant,
mock_bleak_scanner_start: MagicMock, mock_bleak_scanner_start: MagicMock,
@ -1118,21 +1144,21 @@ async def test_integration_multiple_entity_platforms(
binary_sensor_processor = PassiveBluetoothDataProcessor( binary_sensor_processor = PassiveBluetoothDataProcessor(
lambda service_info: BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE lambda service_info: BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE
) )
sesnor_processor = PassiveBluetoothDataProcessor( sensor_processor = PassiveBluetoothDataProcessor(
lambda service_info: SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE lambda service_info: SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE
) )
coordinator.async_register_processor(binary_sensor_processor) coordinator.async_register_processor(binary_sensor_processor)
coordinator.async_register_processor(sesnor_processor) coordinator.async_register_processor(sensor_processor)
cancel_coordinator = coordinator.async_start() cancel_coordinator = coordinator.async_start()
binary_sensor_processor.async_add_listener(MagicMock()) binary_sensor_processor.async_add_listener(MagicMock())
sesnor_processor.async_add_listener(MagicMock()) sensor_processor.async_add_listener(MagicMock())
mock_add_sensor_entities = MagicMock() mock_add_sensor_entities = MagicMock()
mock_add_binary_sensor_entities = MagicMock() mock_add_binary_sensor_entities = MagicMock()
sesnor_processor.async_add_entities_listener( sensor_processor.async_add_entities_listener(
PassiveBluetoothProcessorEntity, PassiveBluetoothProcessorEntity,
mock_add_sensor_entities, mock_add_sensor_entities,
) )
@ -1146,14 +1172,14 @@ async def test_integration_multiple_entity_platforms(
assert len(mock_add_binary_sensor_entities.mock_calls) == 1 assert len(mock_add_binary_sensor_entities.mock_calls) == 1
assert len(mock_add_sensor_entities.mock_calls) == 1 assert len(mock_add_sensor_entities.mock_calls) == 1
binary_sesnor_entities = [ binary_sensor_entities = [
*mock_add_binary_sensor_entities.mock_calls[0][1][0], *mock_add_binary_sensor_entities.mock_calls[0][1][0],
] ]
sesnor_entities = [ sensor_entities = [
*mock_add_sensor_entities.mock_calls[0][1][0], *mock_add_sensor_entities.mock_calls[0][1][0],
] ]
sensor_entity_one: PassiveBluetoothProcessorEntity = sesnor_entities[0] sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0]
sensor_entity_one.hass = hass sensor_entity_one.hass = hass
assert sensor_entity_one.available is True assert sensor_entity_one.available is True
assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure"
@ -1167,7 +1193,7 @@ async def test_integration_multiple_entity_platforms(
key="pressure", device_id=None key="pressure", device_id=None
) )
binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sesnor_entities[ binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[
0 0
] ]
binary_sensor_entity_one.hass = hass binary_sensor_entity_one.hass = hass
@ -1242,3 +1268,281 @@ async def test_exception_from_coordinator_update_method(
assert processor.available is True assert processor.available is True
unregister_processor() unregister_processor()
cancel_coordinator() cancel_coordinator()
async def test_integration_multiple_entity_platforms_with_reload_and_restart(
hass: HomeAssistant,
mock_bleak_scanner_start: MagicMock,
mock_bluetooth_adapters: None,
hass_storage: dict[str, Any],
) -> None:
"""Test integration of PassiveBluetoothProcessorCoordinator with multiple platforms with reload."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
entry = MockConfigEntry(domain=DOMAIN, data={})
@callback
def _mock_update_method(
service_info: BluetoothServiceInfo,
) -> dict[str, str]:
return {"test": "data"}
current_entry.set(entry)
coordinator = PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
"aa:bb:cc:dd:ee:ff",
BluetoothScanningMode.ACTIVE,
_mock_update_method,
)
assert coordinator.available is False # no data yet
binary_sensor_processor = PassiveBluetoothDataProcessor(
lambda service_info: BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE,
BINARY_SENSOR_DOMAIN,
)
sensor_processor = PassiveBluetoothDataProcessor(
lambda service_info: SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE, SENSOR_DOMAIN
)
unregister_binary_sensor_processor = coordinator.async_register_processor(
binary_sensor_processor, BinarySensorEntityDescription
)
unregister_sensor_processor = coordinator.async_register_processor(
sensor_processor, SensorEntityDescription
)
cancel_coordinator = coordinator.async_start()
binary_sensor_processor.async_add_listener(MagicMock())
sensor_processor.async_add_listener(MagicMock())
mock_add_sensor_entities = MagicMock()
mock_add_binary_sensor_entities = MagicMock()
sensor_processor.async_add_entities_listener(
PassiveBluetoothProcessorEntity,
mock_add_sensor_entities,
)
binary_sensor_processor.async_add_entities_listener(
PassiveBluetoothProcessorEntity,
mock_add_binary_sensor_entities,
)
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO)
# First call with just the remote sensor entities results in them being added
assert len(mock_add_binary_sensor_entities.mock_calls) == 1
assert len(mock_add_sensor_entities.mock_calls) == 1
binary_sensor_entities = [
*mock_add_binary_sensor_entities.mock_calls[0][1][0],
]
sensor_entities = [
*mock_add_sensor_entities.mock_calls[0][1][0],
]
sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0]
sensor_entity_one.hass = hass
assert sensor_entity_one.available is True
assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure"
assert sensor_entity_one.device_info == {
"identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")},
"manufacturer": "Test Manufacturer",
"model": "Test Model",
"name": "Test Device",
}
assert sensor_entity_one.entity_key == PassiveBluetoothEntityKey(
key="pressure", device_id=None
)
binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[
0
]
binary_sensor_entity_one.hass = hass
assert binary_sensor_entity_one.available is True
assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion"
assert binary_sensor_entity_one.device_info == {
"identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")},
"manufacturer": "Test Manufacturer",
"model": "Test Model",
"name": "Test Device",
}
assert binary_sensor_entity_one.entity_key == PassiveBluetoothEntityKey(
key="motion", device_id=None
)
cancel_coordinator()
unregister_binary_sensor_processor()
unregister_sensor_processor()
mock_add_sensor_entities = MagicMock()
mock_add_binary_sensor_entities = MagicMock()
current_entry.set(entry)
coordinator = PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
"aa:bb:cc:dd:ee:ff",
BluetoothScanningMode.ACTIVE,
_mock_update_method,
)
binary_sensor_processor = PassiveBluetoothDataProcessor(
lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE,
BINARY_SENSOR_DOMAIN,
)
sensor_processor = PassiveBluetoothDataProcessor(
lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE,
SENSOR_DOMAIN,
)
sensor_processor.async_add_entities_listener(
PassiveBluetoothProcessorEntity,
mock_add_sensor_entities,
)
binary_sensor_processor.async_add_entities_listener(
PassiveBluetoothProcessorEntity,
mock_add_binary_sensor_entities,
)
unregister_binary_sensor_processor = coordinator.async_register_processor(
binary_sensor_processor, BinarySensorEntityDescription
)
unregister_sensor_processor = coordinator.async_register_processor(
sensor_processor, SensorEntityDescription
)
cancel_coordinator = coordinator.async_start()
assert len(mock_add_binary_sensor_entities.mock_calls) == 1
assert len(mock_add_sensor_entities.mock_calls) == 1
binary_sensor_entities = [
*mock_add_binary_sensor_entities.mock_calls[0][1][0],
]
sensor_entities = [
*mock_add_sensor_entities.mock_calls[0][1][0],
]
sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0]
sensor_entity_one.hass = hass
assert sensor_entity_one.available is True
assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure"
assert sensor_entity_one.device_info == {
"identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")},
"manufacturer": "Test Manufacturer",
"model": "Test Model",
"name": "Test Device",
}
assert sensor_entity_one.entity_key == PassiveBluetoothEntityKey(
key="pressure", device_id=None
)
binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[
0
]
binary_sensor_entity_one.hass = hass
assert binary_sensor_entity_one.available is True
assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion"
assert binary_sensor_entity_one.device_info == {
"identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")},
"manufacturer": "Test Manufacturer",
"model": "Test Model",
"name": "Test Device",
}
assert binary_sensor_entity_one.entity_key == PassiveBluetoothEntityKey(
key="motion", device_id=None
)
await hass.async_stop()
await hass.async_block_till_done()
assert SENSOR_DOMAIN in hass_storage[STORAGE_KEY]["data"][entry.entry_id]
assert BINARY_SENSOR_DOMAIN in hass_storage[STORAGE_KEY]["data"][entry.entry_id]
# We don't normally cancel or unregister these at stop,
# but since we are mocking a restart we need to cleanup
cancel_coordinator()
unregister_binary_sensor_processor()
unregister_sensor_processor()
hass = await async_test_home_assistant(asyncio.get_running_loop())
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
current_entry.set(entry)
coordinator = PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
"aa:bb:cc:dd:ee:ff",
BluetoothScanningMode.ACTIVE,
_mock_update_method,
)
assert coordinator.available is False # no data yet
mock_add_sensor_entities = MagicMock()
mock_add_binary_sensor_entities = MagicMock()
binary_sensor_processor = PassiveBluetoothDataProcessor(
lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE,
BINARY_SENSOR_DOMAIN,
)
sensor_processor = PassiveBluetoothDataProcessor(
lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE,
SENSOR_DOMAIN,
)
sensor_processor.async_add_entities_listener(
PassiveBluetoothProcessorEntity,
mock_add_sensor_entities,
)
binary_sensor_processor.async_add_entities_listener(
PassiveBluetoothProcessorEntity,
mock_add_binary_sensor_entities,
)
unregister_binary_sensor_processor = coordinator.async_register_processor(
binary_sensor_processor, BinarySensorEntityDescription
)
unregister_sensor_processor = coordinator.async_register_processor(
sensor_processor, SensorEntityDescription
)
cancel_coordinator = coordinator.async_start()
assert len(mock_add_binary_sensor_entities.mock_calls) == 1
assert len(mock_add_sensor_entities.mock_calls) == 1
binary_sensor_entities = [
*mock_add_binary_sensor_entities.mock_calls[0][1][0],
]
sensor_entities = [
*mock_add_sensor_entities.mock_calls[0][1][0],
]
sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0]
sensor_entity_one.hass = hass
assert sensor_entity_one.available is False # service data not injected
assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure"
assert sensor_entity_one.device_info == {
"identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")},
"manufacturer": "Test Manufacturer",
"model": "Test Model",
"name": "Test Device",
}
assert sensor_entity_one.entity_key == PassiveBluetoothEntityKey(
key="pressure", device_id=None
)
binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[
0
]
binary_sensor_entity_one.hass = hass
assert binary_sensor_entity_one.available is False # service data not injected
assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion"
assert binary_sensor_entity_one.device_info == {
"identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")},
"manufacturer": "Test Manufacturer",
"model": "Test Model",
"name": "Test Device",
}
assert binary_sensor_entity_one.entity_key == PassiveBluetoothEntityKey(
key="motion", device_id=None
)
cancel_coordinator()
unregister_binary_sensor_processor()
unregister_sensor_processor()
await hass.async_stop()