From c5afaa2e6a1af77a69a0151dc1b8d7f5e3dcf2da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Jul 2022 13:03:01 -0500 Subject: [PATCH] Refactor PassiveBluetoothDataUpdateCoordinator to support multiple platforms (#75642) --- .../bluetooth/passive_update_coordinator.py | 235 ++++++++------ homeassistant/components/inkbird/__init__.py | 18 +- homeassistant/components/inkbird/sensor.py | 27 +- .../components/sensorpush/__init__.py | 18 +- homeassistant/components/sensorpush/sensor.py | 27 +- .../components/xiaomi_ble/__init__.py | 18 +- homeassistant/components/xiaomi_ble/sensor.py | 27 +- .../test_passive_update_coordinator.py | 307 ++++++++++++++---- 8 files changed, 443 insertions(+), 234 deletions(-) diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index b2fdd5d7d58..507350f2162 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -55,32 +55,11 @@ class PassiveBluetoothDataUpdate(Generic[_T]): ) -_PassiveBluetoothDataUpdateCoordinatorT = TypeVar( - "_PassiveBluetoothDataUpdateCoordinatorT", - bound="PassiveBluetoothDataUpdateCoordinator[Any]", -) - - -class PassiveBluetoothDataUpdateCoordinator(Generic[_T]): +class PassiveBluetoothDataUpdateCoordinator: """Passive bluetooth data update coordinator for bluetooth advertisements. - The coordinator is responsible for keeping track of the bluetooth data, - updating subscribers, and device availability. - - The update_method must return a PassiveBluetoothDataUpdate object. Callers - are responsible for formatting the data returned from their parser into - the appropriate format. - - The coordinator will call the update_method every time the bluetooth device - receives a new advertisement with the following signature: - - update_method(service_info: BluetoothServiceInfo) -> PassiveBluetoothDataUpdate - - As the size of each advertisement is limited, the update_method should - return a PassiveBluetoothDataUpdate object that contains only data that - should be updated. The coordinator will then dispatch subscribers based - on the data in the PassiveBluetoothDataUpdate object. The accumulated data - is available in the devices, entity_data, and entity_descriptions attributes. + The coordinator is responsible for dispatching the bluetooth data, + to each processor, and tracking devices. """ def __init__( @@ -88,45 +67,22 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]): hass: HomeAssistant, logger: logging.Logger, address: str, - update_method: Callable[[BluetoothServiceInfo], PassiveBluetoothDataUpdate[_T]], ) -> None: """Initialize the coordinator.""" self.hass = hass self.logger = logger self.name: str | None = None self.address = address - self._listeners: list[ - Callable[[PassiveBluetoothDataUpdate[_T] | None], None] - ] = [] - self._entity_key_listeners: dict[ - PassiveBluetoothEntityKey, - list[Callable[[PassiveBluetoothDataUpdate[_T] | None], None]], - ] = {} - self.update_method = update_method - - self.entity_names: dict[PassiveBluetoothEntityKey, str | None] = {} - self.entity_data: dict[PassiveBluetoothEntityKey, _T] = {} - self.entity_descriptions: dict[ - PassiveBluetoothEntityKey, EntityDescription - ] = {} - self.devices: dict[str | None, DeviceInfo] = {} - - self.last_update_success = True + self._processors: list[PassiveBluetoothDataProcessor] = [] self._cancel_track_unavailable: CALLBACK_TYPE | None = None self._cancel_bluetooth_advertisements: CALLBACK_TYPE | None = None - self.present = False + self._present = False self.last_seen = 0.0 @property def available(self) -> bool: """Return if the device is available.""" - return self.present and self.last_update_success - - @callback - def _async_handle_unavailable(self, _address: str) -> None: - """Handle the device going unavailable.""" - self.present = False - self.async_update_listeners(None) + return self._present @callback def _async_start(self) -> None: @@ -152,10 +108,121 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]): self._cancel_track_unavailable() self._cancel_track_unavailable = None + @callback + def async_register_processor( + self, processor: PassiveBluetoothDataProcessor + ) -> Callable[[], None]: + """Register a processor that subscribes to updates.""" + processor.coordinator = self + + @callback + def remove_processor() -> None: + """Remove a processor.""" + self._processors.remove(processor) + self._async_handle_processors_changed() + + self._processors.append(processor) + self._async_handle_processors_changed() + return remove_processor + + @callback + def _async_handle_processors_changed(self) -> None: + """Handle processors changed.""" + running = bool(self._cancel_bluetooth_advertisements) + if running and not self._processors: + self._async_stop() + elif not running and self._processors: + self._async_start() + + @callback + def _async_handle_unavailable(self, _address: str) -> None: + """Handle the device going unavailable.""" + self._present = False + for processor in self._processors: + processor.async_handle_unavailable() + + @callback + def _async_handle_bluetooth_event( + self, + service_info: BluetoothServiceInfo, + change: BluetoothChange, + ) -> None: + """Handle a Bluetooth event.""" + self.last_seen = time.monotonic() + self.name = service_info.name + self._present = True + if self.hass.is_stopping: + return + for processor in self._processors: + processor.async_handle_bluetooth_event(service_info, change) + + +_PassiveBluetoothDataProcessorT = TypeVar( + "_PassiveBluetoothDataProcessorT", + bound="PassiveBluetoothDataProcessor[Any]", +) + + +class PassiveBluetoothDataProcessor(Generic[_T]): + """Passive bluetooth data processor for bluetooth advertisements. + + The processor is responsible for keeping track of the bluetooth data + and updating subscribers. + + The update_method must return a PassiveBluetoothDataUpdate object. Callers + are responsible for formatting the data returned from their parser into + the appropriate format. + + The processor will call the update_method every time the bluetooth device + receives a new advertisement data from the coordinator with the following signature: + + update_method(service_info: BluetoothServiceInfo) -> PassiveBluetoothDataUpdate + + As the size of each advertisement is limited, the update_method should + return a PassiveBluetoothDataUpdate object that contains only data that + should be updated. The coordinator will then dispatch subscribers based + on the data in the PassiveBluetoothDataUpdate object. The accumulated data + is available in the devices, entity_data, and entity_descriptions attributes. + """ + + coordinator: PassiveBluetoothDataUpdateCoordinator + + def __init__( + self, + update_method: Callable[[BluetoothServiceInfo], PassiveBluetoothDataUpdate[_T]], + ) -> None: + """Initialize the coordinator.""" + self.coordinator: PassiveBluetoothDataUpdateCoordinator + self._listeners: list[ + Callable[[PassiveBluetoothDataUpdate[_T] | None], None] + ] = [] + self._entity_key_listeners: dict[ + PassiveBluetoothEntityKey, + list[Callable[[PassiveBluetoothDataUpdate[_T] | None], None]], + ] = {} + self.update_method = update_method + self.entity_names: dict[PassiveBluetoothEntityKey, str | None] = {} + self.entity_data: dict[PassiveBluetoothEntityKey, _T] = {} + self.entity_descriptions: dict[ + PassiveBluetoothEntityKey, EntityDescription + ] = {} + self.devices: dict[str | None, DeviceInfo] = {} + self.last_update_success = True + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self.coordinator.available and self.last_update_success + + @callback + def async_handle_unavailable(self) -> None: + """Handle the device going unavailable.""" + self.async_update_listeners(None) + @callback def async_add_entities_listener( self, - entity_class: type[PassiveBluetoothCoordinatorEntity], + entity_class: type[PassiveBluetoothProcessorEntity], async_add_entites: AddEntitiesCallback, ) -> Callable[[], None]: """Add a listener for new entities.""" @@ -168,7 +235,7 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]): """Listen for new entities.""" if data is None: return - entities: list[PassiveBluetoothCoordinatorEntity] = [] + entities: list[PassiveBluetoothProcessorEntity] = [] for entity_key, description in data.entity_descriptions.items(): if entity_key not in created: entities.append(entity_class(self, entity_key, description)) @@ -189,22 +256,10 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]): def remove_listener() -> None: """Remove update listener.""" self._listeners.remove(update_callback) - self._async_handle_listeners_changed() self._listeners.append(update_callback) - self._async_handle_listeners_changed() return remove_listener - @callback - def _async_handle_listeners_changed(self) -> None: - """Handle listeners changed.""" - has_listeners = self._listeners or self._entity_key_listeners - running = bool(self._cancel_bluetooth_advertisements) - if running and not has_listeners: - self._async_stop() - elif not running and has_listeners: - self._async_start() - @callback def async_add_entity_key_listener( self, @@ -219,10 +274,8 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]): self._entity_key_listeners[entity_key].remove(update_callback) if not self._entity_key_listeners[entity_key]: del self._entity_key_listeners[entity_key] - self._async_handle_listeners_changed() self._entity_key_listeners.setdefault(entity_key, []).append(update_callback) - self._async_handle_listeners_changed() return remove_listener @callback @@ -240,36 +293,32 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]): update_callback(data) @callback - def _async_handle_bluetooth_event( + def async_handle_bluetooth_event( self, service_info: BluetoothServiceInfo, change: BluetoothChange, ) -> None: """Handle a Bluetooth event.""" - self.last_seen = time.monotonic() - self.name = service_info.name - self.present = True - if self.hass.is_stopping: - return - try: new_data = self.update_method(service_info) except Exception as err: # pylint: disable=broad-except self.last_update_success = False - self.logger.exception( - "Unexpected error updating %s data: %s", self.name, err + self.coordinator.logger.exception( + "Unexpected error updating %s data: %s", self.coordinator.name, err ) return if not isinstance(new_data, PassiveBluetoothDataUpdate): self.last_update_success = False # type: ignore[unreachable] raise ValueError( - f"The update_method for {self.name} returned {new_data} instead of a PassiveBluetoothDataUpdate" + f"The update_method for {self.coordinator.name} returned {new_data} instead of a PassiveBluetoothDataUpdate" ) if not self.last_update_success: self.last_update_success = True - self.logger.info("Processing %s data recovered", self.name) + self.coordinator.logger.info( + "Processing %s data recovered", self.coordinator.name + ) self.devices.update(new_data.devices) self.entity_descriptions.update(new_data.entity_descriptions) @@ -278,29 +327,27 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]): self.async_update_listeners(new_data) -class PassiveBluetoothCoordinatorEntity( - Entity, Generic[_PassiveBluetoothDataUpdateCoordinatorT] -): - """A class for entities using PassiveBluetoothDataUpdateCoordinator.""" +class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]): + """A class for entities using PassiveBluetoothDataProcessor.""" _attr_has_entity_name = True _attr_should_poll = False def __init__( self, - coordinator: _PassiveBluetoothDataUpdateCoordinatorT, + processor: _PassiveBluetoothDataProcessorT, entity_key: PassiveBluetoothEntityKey, description: EntityDescription, context: Any = None, ) -> None: - """Create the entity with a PassiveBluetoothDataUpdateCoordinator.""" + """Create the entity with a PassiveBluetoothDataProcessor.""" self.entity_description = description self.entity_key = entity_key - self.coordinator = coordinator - self.coordinator_context = context - address = coordinator.address + self.processor = processor + self.processor_context = context + address = processor.coordinator.address device_id = entity_key.device_id - devices = coordinator.devices + devices = processor.devices key = entity_key.key if device_id in devices: base_device_info = devices[device_id] @@ -317,26 +364,26 @@ class PassiveBluetoothCoordinatorEntity( ) self._attr_unique_id = f"{address}-{key}" if ATTR_NAME not in self._attr_device_info: - self._attr_device_info[ATTR_NAME] = self.coordinator.name - self._attr_name = coordinator.entity_names.get(entity_key) + self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name + self._attr_name = processor.entity_names.get(entity_key) @property def available(self) -> bool: """Return if entity is available.""" - return self.coordinator.available + return self.processor.available async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() self.async_on_remove( - self.coordinator.async_add_entity_key_listener( - self._handle_coordinator_update, self.entity_key + self.processor.async_add_entity_key_listener( + self._handle_processor_update, self.entity_key ) ) @callback - def _handle_coordinator_update( + def _handle_processor_update( self, new_data: PassiveBluetoothDataUpdate | None ) -> None: - """Handle updated data from the coordinator.""" + """Handle updated data from the processor.""" self.async_write_ha_state() diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index 29b63e34263..330d85b665c 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -3,19 +3,14 @@ from __future__ import annotations import logging -from inkbird_ble import INKBIRDBluetoothDeviceData - from homeassistant.components.bluetooth.passive_update_coordinator import ( - PassiveBluetoothDataUpdate, PassiveBluetoothDataUpdateCoordinator, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from homeassistant.core import HomeAssistant from .const import DOMAIN -from .sensor import sensor_update_to_bluetooth_data_update PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -26,22 +21,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up INKBIRD BLE device from a config entry.""" address = entry.unique_id assert address is not None - - data = INKBIRDBluetoothDeviceData() - - @callback - def _async_update_data( - service_info: BluetoothServiceInfo, - ) -> PassiveBluetoothDataUpdate: - """Update data from INKBIRD Bluetooth.""" - return sensor_update_to_bluetooth_data_update(data.update(service_info)) - hass.data.setdefault(DOMAIN, {})[ entry.entry_id ] = PassiveBluetoothDataUpdateCoordinator( hass, _LOGGER, - update_method=_async_update_data, address=address, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/inkbird/sensor.py b/homeassistant/components/inkbird/sensor.py index 26311131181..600f28b6880 100644 --- a/homeassistant/components/inkbird/sensor.py +++ b/homeassistant/components/inkbird/sensor.py @@ -3,14 +3,22 @@ from __future__ import annotations from typing import Optional, Union -from inkbird_ble import DeviceClass, DeviceKey, SensorDeviceInfo, SensorUpdate, Units +from inkbird_ble import ( + DeviceClass, + DeviceKey, + INKBIRDBluetoothDeviceData, + SensorDeviceInfo, + SensorUpdate, + Units, +) from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_coordinator import ( - PassiveBluetoothCoordinatorEntity, + PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothEntityKey, + PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -121,16 +129,23 @@ async def async_setup_entry( coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][ entry.entry_id ] + data = INKBIRDBluetoothDeviceData() + processor = PassiveBluetoothDataProcessor( + lambda service_info: sensor_update_to_bluetooth_data_update( + data.update(service_info) + ) + ) + entry.async_on_unload(coordinator.async_register_processor(processor)) entry.async_on_unload( - coordinator.async_add_entities_listener( + processor.async_add_entities_listener( INKBIRDBluetoothSensorEntity, async_add_entities ) ) class INKBIRDBluetoothSensorEntity( - PassiveBluetoothCoordinatorEntity[ - PassiveBluetoothDataUpdateCoordinator[Optional[Union[float, int]]] + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[Optional[Union[float, int]]] ], SensorEntity, ): @@ -139,4 +154,4 @@ class INKBIRDBluetoothSensorEntity( @property def native_value(self) -> int | float | None: """Return the native value.""" - return self.coordinator.entity_data.get(self.entity_key) + return self.processor.entity_data.get(self.entity_key) diff --git a/homeassistant/components/sensorpush/__init__.py b/homeassistant/components/sensorpush/__init__.py index 89e2287be70..94e98e30f82 100644 --- a/homeassistant/components/sensorpush/__init__.py +++ b/homeassistant/components/sensorpush/__init__.py @@ -3,19 +3,14 @@ from __future__ import annotations import logging -from sensorpush_ble import SensorPushBluetoothDeviceData - from homeassistant.components.bluetooth.passive_update_coordinator import ( - PassiveBluetoothDataUpdate, PassiveBluetoothDataUpdateCoordinator, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from homeassistant.core import HomeAssistant from .const import DOMAIN -from .sensor import sensor_update_to_bluetooth_data_update PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -26,22 +21,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SensorPush BLE device from a config entry.""" address = entry.unique_id assert address is not None - - data = SensorPushBluetoothDeviceData() - - @callback - def _async_update_data( - service_info: BluetoothServiceInfo, - ) -> PassiveBluetoothDataUpdate: - """Update data from SensorPush Bluetooth.""" - return sensor_update_to_bluetooth_data_update(data.update(service_info)) - hass.data.setdefault(DOMAIN, {})[ entry.entry_id ] = PassiveBluetoothDataUpdateCoordinator( hass, _LOGGER, - update_method=_async_update_data, address=address, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/sensorpush/sensor.py b/homeassistant/components/sensorpush/sensor.py index 147555990e4..f592b594289 100644 --- a/homeassistant/components/sensorpush/sensor.py +++ b/homeassistant/components/sensorpush/sensor.py @@ -3,14 +3,22 @@ from __future__ import annotations from typing import Optional, Union -from sensorpush_ble import DeviceClass, DeviceKey, SensorDeviceInfo, SensorUpdate, Units +from sensorpush_ble import ( + DeviceClass, + DeviceKey, + SensorDeviceInfo, + SensorPushBluetoothDeviceData, + SensorUpdate, + Units, +) from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_coordinator import ( - PassiveBluetoothCoordinatorEntity, + PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothEntityKey, + PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -122,16 +130,23 @@ async def async_setup_entry( coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][ entry.entry_id ] + data = SensorPushBluetoothDeviceData() + processor = PassiveBluetoothDataProcessor( + lambda service_info: sensor_update_to_bluetooth_data_update( + data.update(service_info) + ) + ) + entry.async_on_unload(coordinator.async_register_processor(processor)) entry.async_on_unload( - coordinator.async_add_entities_listener( + processor.async_add_entities_listener( SensorPushBluetoothSensorEntity, async_add_entities ) ) class SensorPushBluetoothSensorEntity( - PassiveBluetoothCoordinatorEntity[ - PassiveBluetoothDataUpdateCoordinator[Optional[Union[float, int]]] + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[Optional[Union[float, int]]] ], SensorEntity, ): @@ -140,4 +155,4 @@ class SensorPushBluetoothSensorEntity( @property def native_value(self) -> int | float | None: """Return the native value.""" - return self.coordinator.entity_data.get(self.entity_key) + return self.processor.entity_data.get(self.entity_key) diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index 036ff37a306..d7f3e4071aa 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -3,19 +3,14 @@ from __future__ import annotations import logging -from xiaomi_ble import XiaomiBluetoothDeviceData - from homeassistant.components.bluetooth.passive_update_coordinator import ( - PassiveBluetoothDataUpdate, PassiveBluetoothDataUpdateCoordinator, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from homeassistant.core import HomeAssistant from .const import DOMAIN -from .sensor import sensor_update_to_bluetooth_data_update PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -26,22 +21,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Xiaomi BLE device from a config entry.""" address = entry.unique_id assert address is not None - - data = XiaomiBluetoothDeviceData() - - @callback - def _async_update_data( - service_info: BluetoothServiceInfo, - ) -> PassiveBluetoothDataUpdate: - """Update data from Xiaomi Bluetooth.""" - return sensor_update_to_bluetooth_data_update(data.update(service_info)) - hass.data.setdefault(DOMAIN, {})[ entry.entry_id ] = PassiveBluetoothDataUpdateCoordinator( hass, _LOGGER, - update_method=_async_update_data, address=address, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index f485ccb8eb6..1452b5e9053 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -3,14 +3,22 @@ from __future__ import annotations from typing import Optional, Union -from xiaomi_ble import DeviceClass, DeviceKey, SensorDeviceInfo, SensorUpdate, Units +from xiaomi_ble import ( + DeviceClass, + DeviceKey, + SensorDeviceInfo, + SensorUpdate, + Units, + XiaomiBluetoothDeviceData, +) from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_coordinator import ( - PassiveBluetoothCoordinatorEntity, + PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothEntityKey, + PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -150,16 +158,23 @@ async def async_setup_entry( coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][ entry.entry_id ] + data = XiaomiBluetoothDeviceData() + processor = PassiveBluetoothDataProcessor( + lambda service_info: sensor_update_to_bluetooth_data_update( + data.update(service_info) + ) + ) + entry.async_on_unload(coordinator.async_register_processor(processor)) entry.async_on_unload( - coordinator.async_add_entities_listener( + processor.async_add_entities_listener( XiaomiBluetoothSensorEntity, async_add_entities ) ) class XiaomiBluetoothSensorEntity( - PassiveBluetoothCoordinatorEntity[ - PassiveBluetoothDataUpdateCoordinator[Optional[Union[float, int]]] + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[Optional[Union[float, int]]] ], SensorEntity, ): @@ -168,4 +183,4 @@ class XiaomiBluetoothSensorEntity( @property def native_value(self) -> int | float | None: """Return the native value.""" - return self.coordinator.entity_data.get(self.entity_key) + return self.processor.entity_data.get(self.entity_key) diff --git a/tests/components/bluetooth/test_passive_update_coordinator.py b/tests/components/bluetooth/test_passive_update_coordinator.py index 48f2e8edf06..2b8c876460f 100644 --- a/tests/components/bluetooth/test_passive_update_coordinator.py +++ b/tests/components/bluetooth/test_passive_update_coordinator.py @@ -8,16 +8,21 @@ from unittest.mock import MagicMock, patch from home_assistant_bluetooth import BluetoothServiceInfo import pytest +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntityDescription, +) from homeassistant.components.bluetooth import ( DOMAIN, UNAVAILABLE_TRACK_SECONDS, BluetoothChange, ) from homeassistant.components.bluetooth.passive_update_coordinator import ( - PassiveBluetoothCoordinatorEntity, + PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothEntityKey, + PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription from homeassistant.const import TEMP_CELSIUS @@ -85,7 +90,7 @@ async def test_basic_usage(hass, mock_bleak_scanner_start): return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothDataUpdateCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data + hass, _LOGGER, "aa:bb:cc:dd:ee:ff" ) assert coordinator.available is False # no data yet saved_callback = None @@ -95,40 +100,41 @@ async def test_basic_usage(hass, mock_bleak_scanner_start): saved_callback = _callback return lambda: None + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + with patch( "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", _async_register_callback, ): + unregister_processor = coordinator.async_register_processor(processor) - entity_key = PassiveBluetoothEntityKey("temperature", None) - entity_key_events = [] - all_events = [] - mock_entity = MagicMock() - mock_add_entities = MagicMock() + entity_key = PassiveBluetoothEntityKey("temperature", None) + entity_key_events = [] + all_events = [] + mock_entity = MagicMock() + mock_add_entities = MagicMock() - def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None: - """Mock entity key listener.""" - entity_key_events.append(data) + def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock entity key listener.""" + entity_key_events.append(data) - cancel_async_add_entity_key_listener = ( - coordinator.async_add_entity_key_listener( - _async_entity_key_listener, - entity_key, - ) - ) + cancel_async_add_entity_key_listener = processor.async_add_entity_key_listener( + _async_entity_key_listener, + entity_key, + ) - def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: - """Mock an all listener.""" - all_events.append(data) + def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock an all listener.""" + all_events.append(data) - cancel_listener = coordinator.async_add_listener( - _all_listener, - ) + cancel_listener = processor.async_add_listener( + _all_listener, + ) - cancel_async_add_entities_listener = coordinator.async_add_entities_listener( - mock_entity, - mock_add_entities, - ) + cancel_async_add_entities_listener = processor.async_add_entities_listener( + mock_entity, + mock_add_entities, + ) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) @@ -164,6 +170,8 @@ async def test_basic_usage(hass, mock_bleak_scanner_start): assert len(mock_entity.mock_calls) == 2 assert coordinator.available is True + unregister_processor() + async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start): """Test that the coordinator is unavailable after no data for a while.""" @@ -182,7 +190,7 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start): return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothDataUpdateCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data + hass, _LOGGER, "aa:bb:cc:dd:ee:ff" ) assert coordinator.available is False # no data yet saved_callback = None @@ -192,23 +200,27 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start): saved_callback = _callback return lambda: None + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) with patch( "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", _async_register_callback, ): + unregister_processor = coordinator.async_register_processor(processor) - mock_entity = MagicMock() - mock_add_entities = MagicMock() - coordinator.async_add_entities_listener( - mock_entity, - mock_add_entities, - ) + mock_entity = MagicMock() + mock_add_entities = MagicMock() + processor.async_add_entities_listener( + mock_entity, + mock_add_entities, + ) assert coordinator.available is False + assert processor.available is False saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) assert len(mock_add_entities.mock_calls) == 1 assert coordinator.available is True + assert processor.available is True scanner = _get_underlying_scanner() with patch( @@ -224,10 +236,12 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start): ) await hass.async_block_till_done() assert coordinator.available is False + assert processor.available is False saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) assert len(mock_add_entities.mock_calls) == 1 assert coordinator.available is True + assert processor.available is True with patch( "homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices", @@ -242,6 +256,9 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start): ) await hass.async_block_till_done() assert coordinator.available is False + assert processor.available is False + + unregister_processor() async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start): @@ -256,7 +273,7 @@ async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start): return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothDataUpdateCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data + hass, _LOGGER, "aa:bb:cc:dd:ee:ff" ) assert coordinator.available is False # no data yet saved_callback = None @@ -266,20 +283,23 @@ async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start): saved_callback = _callback return lambda: None + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + with patch( "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", _async_register_callback, ): + unregister_processor = coordinator.async_register_processor(processor) - all_events = [] + all_events = [] - def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: - """Mock an all listener.""" - all_events.append(data) + def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock an all listener.""" + all_events.append(data) - coordinator.async_add_listener( - _all_listener, - ) + processor.async_add_listener( + _all_listener, + ) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) assert len(all_events) == 1 @@ -289,6 +309,7 @@ async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start): # We should stop processing events once hass is stopping saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) assert len(all_events) == 1 + unregister_processor() async def test_exception_from_update_method(hass, caplog, mock_bleak_scanner_start): @@ -309,7 +330,7 @@ async def test_exception_from_update_method(hass, caplog, mock_bleak_scanner_sta return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothDataUpdateCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data + hass, _LOGGER, "aa:bb:cc:dd:ee:ff" ) assert coordinator.available is False # no data yet saved_callback = None @@ -319,23 +340,27 @@ async def test_exception_from_update_method(hass, caplog, mock_bleak_scanner_sta saved_callback = _callback return lambda: None + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) with patch( "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", _async_register_callback, ): - coordinator.async_add_listener(MagicMock()) + unregister_processor = coordinator.async_register_processor(processor) + + processor.async_add_listener(MagicMock()) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - assert coordinator.available is True + assert processor.available is True # We should go unavailable once we get an exception saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) assert "Test exception" in caplog.text - assert coordinator.available is False + assert processor.available is False # We should go available again once we get data again saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - assert coordinator.available is True + assert processor.available is True + unregister_processor() async def test_bad_data_from_update_method(hass, mock_bleak_scanner_start): @@ -356,7 +381,7 @@ async def test_bad_data_from_update_method(hass, mock_bleak_scanner_start): return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothDataUpdateCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data + hass, _LOGGER, "aa:bb:cc:dd:ee:ff" ) assert coordinator.available is False # no data yet saved_callback = None @@ -366,24 +391,28 @@ async def test_bad_data_from_update_method(hass, mock_bleak_scanner_start): saved_callback = _callback return lambda: None + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) with patch( "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", _async_register_callback, ): - coordinator.async_add_listener(MagicMock()) + unregister_processor = coordinator.async_register_processor(processor) + + processor.async_add_listener(MagicMock()) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - assert coordinator.available is True + assert processor.available is True # We should go unavailable once we get bad data with pytest.raises(ValueError): saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - assert coordinator.available is False + assert processor.available is False # We should go available again once we get good data again saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - assert coordinator.available is True + assert processor.available is True + unregister_processor() GOVEE_B5178_REMOTE_SERVICE_INFO = BluetoothServiceInfo( @@ -692,7 +721,7 @@ async def test_integration_with_entity(hass, mock_bleak_scanner_start): return GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothDataUpdateCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data + hass, _LOGGER, "aa:bb:cc:dd:ee:ff" ) assert coordinator.available is False # no data yet saved_callback = None @@ -702,16 +731,19 @@ async def test_integration_with_entity(hass, mock_bleak_scanner_start): saved_callback = _callback return lambda: None + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) with patch( "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", _async_register_callback, ): - coordinator.async_add_listener(MagicMock()) + coordinator.async_register_processor(processor) + + processor.async_add_listener(MagicMock()) mock_add_entities = MagicMock() - coordinator.async_add_entities_listener( - PassiveBluetoothCoordinatorEntity, + processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, mock_add_entities, ) @@ -736,7 +768,7 @@ async def test_integration_with_entity(hass, mock_bleak_scanner_start): *mock_add_entities.mock_calls[1][1][0], ] - entity_one: PassiveBluetoothCoordinatorEntity = entities[0] + entity_one: PassiveBluetoothProcessorEntity = entities[0] entity_one.hass = hass assert entity_one.available is True assert entity_one.unique_id == "aa:bb:cc:dd:ee:ff-temperature-remote" @@ -797,7 +829,7 @@ async def test_integration_with_entity_without_a_device(hass, mock_bleak_scanner return NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothDataUpdateCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data + hass, _LOGGER, "aa:bb:cc:dd:ee:ff" ) assert coordinator.available is False # no data yet saved_callback = None @@ -807,17 +839,19 @@ async def test_integration_with_entity_without_a_device(hass, mock_bleak_scanner saved_callback = _callback return lambda: None + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) with patch( "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", _async_register_callback, ): + coordinator.async_register_processor(processor) - mock_add_entities = MagicMock() + mock_add_entities = MagicMock() - coordinator.async_add_entities_listener( - PassiveBluetoothCoordinatorEntity, - mock_add_entities, - ) + processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_entities, + ) saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) # First call with just the remote sensor entities results in them being added @@ -828,7 +862,7 @@ async def test_integration_with_entity_without_a_device(hass, mock_bleak_scanner assert len(mock_add_entities.mock_calls) == 1 entities = mock_add_entities.mock_calls[0][1][0] - entity_one: PassiveBluetoothCoordinatorEntity = entities[0] + entity_one: PassiveBluetoothProcessorEntity = entities[0] entity_one.hass = hass assert entity_one.available is True assert entity_one.unique_id == "aa:bb:cc:dd:ee:ff-temperature" @@ -857,7 +891,7 @@ async def test_passive_bluetooth_entity_with_entity_platform( return NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE coordinator = PassiveBluetoothDataUpdateCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data + hass, _LOGGER, "aa:bb:cc:dd:ee:ff" ) assert coordinator.available is False # no data yet saved_callback = None @@ -867,18 +901,19 @@ async def test_passive_bluetooth_entity_with_entity_platform( saved_callback = _callback return lambda: None + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) with patch( "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", _async_register_callback, ): + coordinator.async_register_processor(processor) - coordinator.async_add_entities_listener( - PassiveBluetoothCoordinatorEntity, - lambda entities: hass.async_create_task( - entity_platform.async_add_entities(entities) - ), - ) - + processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + lambda entities: hass.async_create_task( + entity_platform.async_add_entities(entities) + ), + ) saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) await hass.async_block_till_done() saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) @@ -891,3 +926,133 @@ async def test_passive_bluetooth_entity_with_entity_platform( hass.states.get("test_domain.test_platform_aa_bb_cc_dd_ee_ff_pressure") is not None ) + + +SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( + devices={ + None: DeviceInfo( + name="Test Device", model="Test Model", manufacturer="Test Manufacturer" + ), + }, + entity_data={ + PassiveBluetoothEntityKey("pressure", None): 1234, + }, + entity_names={ + PassiveBluetoothEntityKey("pressure", None): "Pressure", + }, + entity_descriptions={ + PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription( + key="pressure", + native_unit_of_measurement="hPa", + device_class=SensorDeviceClass.PRESSURE, + ), + }, +) + + +BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( + devices={ + None: DeviceInfo( + name="Test Device", model="Test Model", manufacturer="Test Manufacturer" + ), + }, + entity_data={ + PassiveBluetoothEntityKey("motion", None): True, + }, + entity_names={ + PassiveBluetoothEntityKey("motion", None): "Motion", + }, + entity_descriptions={ + PassiveBluetoothEntityKey("motion", None): BinarySensorEntityDescription( + key="motion", + device_class=BinarySensorDeviceClass.MOTION, + ), + }, +) + + +async def test_integration_multiple_entity_platforms(hass, mock_bleak_scanner_start): + """Test integration of PassiveBluetoothDataUpdateCoordinator with multiple platforms.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + coordinator = PassiveBluetoothDataUpdateCoordinator( + hass, _LOGGER, "aa:bb:cc:dd:ee:ff" + ) + assert coordinator.available is False # no data yet + saved_callback = None + + def _async_register_callback(_hass, _callback, _matcher): + nonlocal saved_callback + saved_callback = _callback + return lambda: None + + binary_sensor_processor = PassiveBluetoothDataProcessor( + lambda service_info: BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE + ) + sesnor_processor = PassiveBluetoothDataProcessor( + lambda service_info: SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE + ) + + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + _async_register_callback, + ): + coordinator.async_register_processor(binary_sensor_processor) + coordinator.async_register_processor(sesnor_processor) + + binary_sensor_processor.async_add_listener(MagicMock()) + sesnor_processor.async_add_listener(MagicMock()) + + mock_add_sensor_entities = MagicMock() + mock_add_binary_sensor_entities = MagicMock() + + sesnor_processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_sensor_entities, + ) + binary_sensor_processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_binary_sensor_entities, + ) + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + # 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_sesnor_entities = [ + *mock_add_binary_sensor_entities.mock_calls[0][1][0], + ] + sesnor_entities = [ + *mock_add_sensor_entities.mock_calls[0][1][0], + ] + + sensor_entity_one: PassiveBluetoothProcessorEntity = sesnor_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_sesnor_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 + )