diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index bf4dbf81f01..2e0e62440ab 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -38,7 +38,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.loader import async_get_bluetooth -from . import models +from . import models, passive_update_processor from .api import ( _get_manager, async_address_present, @@ -125,6 +125,7 @@ async def _async_get_adapter_from_address( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the bluetooth integration.""" + await passive_update_processor.async_setup(hass) integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass)) integration_matcher.async_setup() bluetooth_adapters = get_adapters() diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 29634c9a18c..78965ae5cde 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -2,12 +2,31 @@ from __future__ import annotations import dataclasses +from datetime import timedelta +from functools import cache 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.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant import config_entries +from homeassistant.const import ( + 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 .update_coordinator import BasePassiveBluetoothCoordinator @@ -23,6 +42,12 @@ if TYPE_CHECKING: 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) class PassiveBluetoothEntityKey: @@ -36,8 +61,67 @@ class PassiveBluetoothEntityKey: key: str 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) @@ -62,6 +146,114 @@ class PassiveBluetoothDataUpdate(Generic[_T]): self.entity_data.update(new_data.entity_data) 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( Generic[_T], BasePassiveBluetoothCoordinator @@ -90,23 +282,49 @@ class PassiveBluetoothProcessorCoordinator( self._processors: list[PassiveBluetoothDataProcessor] = [] self._update_method = update_method 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 def available(self) -> bool: """Return if the device is available.""" 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 def async_register_processor( self, processor: PassiveBluetoothDataProcessor, + entity_description_class: type[EntityDescription] | None = None, ) -> Callable[[], None]: """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 def remove_processor() -> None: """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.append(processor) @@ -182,12 +400,18 @@ class PassiveBluetoothDataProcessor(Generic[_T]): entity_data: dict[PassiveBluetoothEntityKey, _T] entity_descriptions: dict[PassiveBluetoothEntityKey, EntityDescription] devices: dict[str | None, DeviceInfo] + restore_key: str | None def __init__( self, update_method: Callable[[_T], PassiveBluetoothDataUpdate[_T]], + restore_key: str | None = None, ) -> None: """Initialize the coordinator.""" + try: + self.restore_key = restore_key or async_get_current_platform().domain + except RuntimeError: + self.restore_key = None self._listeners: list[ Callable[[PassiveBluetoothDataUpdate[_T] | None], None] ] = [] @@ -202,15 +426,29 @@ class PassiveBluetoothDataProcessor(Generic[_T]): def async_register_coordinator( self, coordinator: PassiveBluetoothProcessorCoordinator, + entity_description_class: type[EntityDescription] | None, ) -> None: """Register a coordinator.""" self.coordinator = coordinator self.data = PassiveBluetoothDataUpdate() data = self.data + # These attributes to access the data in + # self.data are for backwards compatibility. self.entity_names = data.entity_names self.entity_data = data.entity_data self.entity_descriptions = data.entity_descriptions 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 def available(self) -> bool: diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index a270b20855a..5906ab0bf25 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -1,15 +1,18 @@ """Tests for the Bluetooth integration.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging import time +from typing import Any from unittest.mock import MagicMock, patch from home_assistant_bluetooth import BluetoothServiceInfo import pytest from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntityDescription, ) @@ -22,13 +25,19 @@ from homeassistant.components.bluetooth import ( ) from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS from homeassistant.components.bluetooth.passive_update_processor import ( + STORAGE_KEY, PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothEntityKey, PassiveBluetoothProcessorCoordinator, 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.core import CoreState, HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo @@ -41,7 +50,12 @@ from . import ( 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__) @@ -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( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, @@ -1118,21 +1144,21 @@ async def test_integration_multiple_entity_platforms( binary_sensor_processor = PassiveBluetoothDataProcessor( lambda service_info: BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE ) - sesnor_processor = PassiveBluetoothDataProcessor( + sensor_processor = PassiveBluetoothDataProcessor( lambda service_info: SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE ) coordinator.async_register_processor(binary_sensor_processor) - coordinator.async_register_processor(sesnor_processor) + coordinator.async_register_processor(sensor_processor) cancel_coordinator = coordinator.async_start() 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_binary_sensor_entities = MagicMock() - sesnor_processor.async_add_entities_listener( + sensor_processor.async_add_entities_listener( PassiveBluetoothProcessorEntity, 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_sensor_entities.mock_calls) == 1 - binary_sesnor_entities = [ + binary_sensor_entities = [ *mock_add_binary_sensor_entities.mock_calls[0][1][0], ] - sesnor_entities = [ + sensor_entities = [ *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 assert sensor_entity_one.available is True 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 ) - binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sesnor_entities[ + binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[ 0 ] binary_sensor_entity_one.hass = hass @@ -1242,3 +1268,281 @@ async def test_exception_from_coordinator_update_method( assert processor.available is True unregister_processor() 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()