mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
Restore passive bluetooth entity data at startup (#97462)
This commit is contained in:
parent
1adfa6bbeb
commit
56257b7a38
@ -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()
|
||||||
|
@ -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:
|
||||||
|
@ -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()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user