From da131beced42203630778aa1460c1767200f00ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Jul 2022 19:33:47 -0500 Subject: [PATCH] Split bluetooth coordinator into two classes (#75675) --- .../bluetooth/passive_update_coordinator.py | 397 +------ .../bluetooth/passive_update_processor.py | 346 ++++++ .../bluetooth/update_coordinator.py | 84 ++ homeassistant/components/inkbird/__init__.py | 6 +- homeassistant/components/inkbird/sensor.py | 6 +- .../components/sensorpush/__init__.py | 6 +- homeassistant/components/sensorpush/sensor.py | 6 +- .../components/xiaomi_ble/__init__.py | 6 +- homeassistant/components/xiaomi_ble/sensor.py | 6 +- .../test_passive_update_coordinator.py | 1033 ++-------------- .../test_passive_update_processor.py | 1058 +++++++++++++++++ tests/components/inkbird/test_sensor.py | 2 +- tests/components/sensorpush/test_sensor.py | 2 +- tests/components/xiaomi_ble/test_sensor.py | 4 +- 14 files changed, 1667 insertions(+), 1295 deletions(-) create mode 100644 homeassistant/components/bluetooth/passive_update_processor.py create mode 100644 homeassistant/components/bluetooth/update_coordinator.py create mode 100644 tests/components/bluetooth/test_passive_update_processor.py diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index 507350f2162..4a22f03449e 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -1,65 +1,23 @@ -"""The Bluetooth integration.""" +"""Passive update coordinator for the Bluetooth integration.""" from __future__ import annotations -from collections.abc import Callable, Mapping -import dataclasses +from collections.abc import Callable, Generator import logging -import time -from typing import Any, Generic, TypeVar +from typing import Any -from home_assistant_bluetooth import BluetoothServiceInfo - -from homeassistant.const import ATTR_IDENTIFIERS, ATTR_NAME from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ( - BluetoothCallbackMatcher, - BluetoothChange, - async_register_callback, - async_track_unavailable, -) -from .const import DOMAIN +from . import BluetoothChange +from .update_coordinator import BasePassiveBluetoothCoordinator -@dataclasses.dataclass(frozen=True) -class PassiveBluetoothEntityKey: - """Key for a passive bluetooth entity. +class PassiveBluetoothDataUpdateCoordinator(BasePassiveBluetoothCoordinator): + """Class to manage passive bluetooth advertisements. - Example: - key: temperature - device_id: outdoor_sensor_1 - """ - - key: str - device_id: str | None - - -_T = TypeVar("_T") - - -@dataclasses.dataclass(frozen=True) -class PassiveBluetoothDataUpdate(Generic[_T]): - """Generic bluetooth data.""" - - devices: dict[str | None, DeviceInfo] = dataclasses.field(default_factory=dict) - entity_descriptions: Mapping[ - PassiveBluetoothEntityKey, EntityDescription - ] = dataclasses.field(default_factory=dict) - entity_names: Mapping[PassiveBluetoothEntityKey, str | None] = dataclasses.field( - default_factory=dict - ) - entity_data: Mapping[PassiveBluetoothEntityKey, _T] = dataclasses.field( - default_factory=dict - ) - - -class PassiveBluetoothDataUpdateCoordinator: - """Passive bluetooth data update coordinator for bluetooth advertisements. - - The coordinator is responsible for dispatching the bluetooth data, - to each processor, and tracking devices. + This coordinator is responsible for dispatching the bluetooth data + and tracking devices. """ def __init__( @@ -68,78 +26,52 @@ class PassiveBluetoothDataUpdateCoordinator: logger: logging.Logger, address: str, ) -> None: - """Initialize the coordinator.""" - self.hass = hass - self.logger = logger - self.name: str | None = None - self.address = address - self._processors: list[PassiveBluetoothDataProcessor] = [] - self._cancel_track_unavailable: CALLBACK_TYPE | None = None - self._cancel_bluetooth_advertisements: CALLBACK_TYPE | None = None - self._present = False - self.last_seen = 0.0 - - @property - def available(self) -> bool: - """Return if the device is available.""" - return self._present + """Initialize PassiveBluetoothDataUpdateCoordinator.""" + super().__init__(hass, logger, address) + self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} @callback - def _async_start(self) -> None: - """Start the callbacks.""" - self._cancel_bluetooth_advertisements = async_register_callback( - self.hass, - self._async_handle_bluetooth_event, - BluetoothCallbackMatcher(address=self.address), - ) - self._cancel_track_unavailable = async_track_unavailable( - self.hass, - self._async_handle_unavailable, - self.address, - ) + def async_update_listeners(self) -> None: + """Update all registered listeners.""" + for update_callback, _ in list(self._listeners.values()): + update_callback() @callback - def _async_stop(self) -> None: - """Stop the callbacks.""" - if self._cancel_bluetooth_advertisements is not None: - self._cancel_bluetooth_advertisements() - self._cancel_bluetooth_advertisements = None - if self._cancel_track_unavailable is not None: - self._cancel_track_unavailable() - self._cancel_track_unavailable = None + def _async_handle_unavailable(self, address: str) -> None: + """Handle the device going unavailable.""" + super()._async_handle_unavailable(address) + self.async_update_listeners() @callback - def async_register_processor( - self, processor: PassiveBluetoothDataProcessor - ) -> Callable[[], None]: - """Register a processor that subscribes to updates.""" - processor.coordinator = self + def async_start(self) -> CALLBACK_TYPE: + """Start the data updater.""" + self._async_start() @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: + def _async_cancel() -> None: self._async_stop() - elif not running and self._processors: - self._async_start() + + return _async_cancel @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() + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: + """Listen for data updates.""" + + @callback + def remove_listener() -> None: + """Remove update listener.""" + self._listeners.pop(remove_listener) + + self._listeners[remove_listener] = (update_callback, context) + return remove_listener + + def async_contexts(self) -> Generator[Any, None, None]: + """Return all registered contexts.""" + yield from ( + context for _, context in self._listeners.values() if context is not None + ) @callback def _async_handle_bluetooth_event( @@ -148,242 +80,19 @@ class PassiveBluetoothDataUpdateCoordinator: 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) + super()._async_handle_bluetooth_event(service_info, change) + self.async_update_listeners() -_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. - """ +class PassiveBluetoothCoordinatorEntity(CoordinatorEntity): + """A class for entities using DataUpdateCoordinator.""" 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[PassiveBluetoothProcessorEntity], - async_add_entites: AddEntitiesCallback, - ) -> Callable[[], None]: - """Add a listener for new entities.""" - created: set[PassiveBluetoothEntityKey] = set() - - @callback - def _async_add_or_update_entities( - data: PassiveBluetoothDataUpdate[_T] | None, - ) -> None: - """Listen for new entities.""" - if data is None: - return - 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)) - created.add(entity_key) - if entities: - async_add_entites(entities) - - return self.async_add_listener(_async_add_or_update_entities) - - @callback - def async_add_listener( - self, - update_callback: Callable[[PassiveBluetoothDataUpdate[_T] | None], None], - ) -> Callable[[], None]: - """Listen for all updates.""" - - @callback - def remove_listener() -> None: - """Remove update listener.""" - self._listeners.remove(update_callback) - - self._listeners.append(update_callback) - return remove_listener - - @callback - def async_add_entity_key_listener( - self, - update_callback: Callable[[PassiveBluetoothDataUpdate[_T] | None], None], - entity_key: PassiveBluetoothEntityKey, - ) -> Callable[[], None]: - """Listen for updates by device key.""" - - @callback - def remove_listener() -> None: - """Remove update listener.""" - 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._entity_key_listeners.setdefault(entity_key, []).append(update_callback) - return remove_listener - - @callback - def async_update_listeners( - self, data: PassiveBluetoothDataUpdate[_T] | None - ) -> None: - """Update all registered listeners.""" - # Dispatch to listeners without a filter key - for update_callback in self._listeners: - update_callback(data) - - # Dispatch to listeners with a filter key - for listeners in self._entity_key_listeners.values(): - for update_callback in listeners: - update_callback(data) - - @callback - def async_handle_bluetooth_event( - self, - service_info: BluetoothServiceInfo, - change: BluetoothChange, - ) -> None: - """Handle a Bluetooth event.""" - try: - new_data = self.update_method(service_info) - except Exception as err: # pylint: disable=broad-except - self.last_update_success = False - 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.coordinator.name} returned {new_data} instead of a PassiveBluetoothDataUpdate" - ) - - if not self.last_update_success: - self.last_update_success = True - 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) - self.entity_data.update(new_data.entity_data) - self.entity_names.update(new_data.entity_names) - self.async_update_listeners(new_data) - - -class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]): - """A class for entities using PassiveBluetoothDataProcessor.""" - - _attr_has_entity_name = True - _attr_should_poll = False - - def __init__( - self, - processor: _PassiveBluetoothDataProcessorT, - entity_key: PassiveBluetoothEntityKey, - description: EntityDescription, - context: Any = None, - ) -> None: - """Create the entity with a PassiveBluetoothDataProcessor.""" - self.entity_description = description - self.entity_key = entity_key - self.processor = processor - self.processor_context = context - address = processor.coordinator.address - device_id = entity_key.device_id - devices = processor.devices - key = entity_key.key - if device_id in devices: - base_device_info = devices[device_id] - else: - base_device_info = DeviceInfo({}) - if device_id: - self._attr_device_info = base_device_info | DeviceInfo( - {ATTR_IDENTIFIERS: {(DOMAIN, f"{address}-{device_id}")}} - ) - self._attr_unique_id = f"{address}-{key}-{device_id}" - else: - self._attr_device_info = base_device_info | DeviceInfo( - {ATTR_IDENTIFIERS: {(DOMAIN, address)}} - ) - self._attr_unique_id = f"{address}-{key}" - if ATTR_NAME not in self._attr_device_info: - self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name - self._attr_name = processor.entity_names.get(entity_key) + async def async_update(self) -> None: + """All updates are passive.""" @property def available(self) -> bool: """Return if entity is 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.processor.async_add_entity_key_listener( - self._handle_processor_update, self.entity_key - ) - ) - - @callback - def _handle_processor_update( - self, new_data: PassiveBluetoothDataUpdate | None - ) -> None: - """Handle updated data from the processor.""" - self.async_write_ha_state() + return self.coordinator.available diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py new file mode 100644 index 00000000000..2e4118000bd --- /dev/null +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -0,0 +1,346 @@ +"""Passive update processors for the Bluetooth integration.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +import dataclasses +import logging +from typing import Any, Generic, TypeVar + +from home_assistant_bluetooth import BluetoothServiceInfo + +from homeassistant.const import ATTR_IDENTIFIERS, ATTR_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BluetoothChange +from .const import DOMAIN +from .update_coordinator import BasePassiveBluetoothCoordinator + + +@dataclasses.dataclass(frozen=True) +class PassiveBluetoothEntityKey: + """Key for a passive bluetooth entity. + + Example: + key: temperature + device_id: outdoor_sensor_1 + """ + + key: str + device_id: str | None + + +_T = TypeVar("_T") + + +@dataclasses.dataclass(frozen=True) +class PassiveBluetoothDataUpdate(Generic[_T]): + """Generic bluetooth data.""" + + devices: dict[str | None, DeviceInfo] = dataclasses.field(default_factory=dict) + entity_descriptions: Mapping[ + PassiveBluetoothEntityKey, EntityDescription + ] = dataclasses.field(default_factory=dict) + entity_names: Mapping[PassiveBluetoothEntityKey, str | None] = dataclasses.field( + default_factory=dict + ) + entity_data: Mapping[PassiveBluetoothEntityKey, _T] = dataclasses.field( + default_factory=dict + ) + + +class PassiveBluetoothProcessorCoordinator(BasePassiveBluetoothCoordinator): + """Passive bluetooth processor coordinator for bluetooth advertisements. + + The coordinator is responsible for dispatching the bluetooth data, + to each processor, and tracking devices. + """ + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + address: str, + ) -> None: + """Initialize the coordinator.""" + super().__init__(hass, logger, address) + self._processors: list[PassiveBluetoothDataProcessor] = [] + + @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.""" + super()._async_handle_unavailable(address) + 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.""" + super()._async_handle_bluetooth_event(service_info, change) + 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: PassiveBluetoothProcessorCoordinator + + def __init__( + self, + update_method: Callable[[BluetoothServiceInfo], PassiveBluetoothDataUpdate[_T]], + ) -> None: + """Initialize the coordinator.""" + self.coordinator: PassiveBluetoothProcessorCoordinator + 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[PassiveBluetoothProcessorEntity], + async_add_entites: AddEntitiesCallback, + ) -> Callable[[], None]: + """Add a listener for new entities.""" + created: set[PassiveBluetoothEntityKey] = set() + + @callback + def _async_add_or_update_entities( + data: PassiveBluetoothDataUpdate[_T] | None, + ) -> None: + """Listen for new entities.""" + if data is None: + return + 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)) + created.add(entity_key) + if entities: + async_add_entites(entities) + + return self.async_add_listener(_async_add_or_update_entities) + + @callback + def async_add_listener( + self, + update_callback: Callable[[PassiveBluetoothDataUpdate[_T] | None], None], + ) -> Callable[[], None]: + """Listen for all updates.""" + + @callback + def remove_listener() -> None: + """Remove update listener.""" + self._listeners.remove(update_callback) + + self._listeners.append(update_callback) + return remove_listener + + @callback + def async_add_entity_key_listener( + self, + update_callback: Callable[[PassiveBluetoothDataUpdate[_T] | None], None], + entity_key: PassiveBluetoothEntityKey, + ) -> Callable[[], None]: + """Listen for updates by device key.""" + + @callback + def remove_listener() -> None: + """Remove update listener.""" + 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._entity_key_listeners.setdefault(entity_key, []).append(update_callback) + return remove_listener + + @callback + def async_update_listeners( + self, data: PassiveBluetoothDataUpdate[_T] | None + ) -> None: + """Update all registered listeners.""" + # Dispatch to listeners without a filter key + for update_callback in self._listeners: + update_callback(data) + + # Dispatch to listeners with a filter key + for listeners in self._entity_key_listeners.values(): + for update_callback in listeners: + update_callback(data) + + @callback + def async_handle_bluetooth_event( + self, + service_info: BluetoothServiceInfo, + change: BluetoothChange, + ) -> None: + """Handle a Bluetooth event.""" + try: + new_data = self.update_method(service_info) + except Exception as err: # pylint: disable=broad-except + self.last_update_success = False + 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.coordinator.name} returned {new_data} instead of a PassiveBluetoothDataUpdate" + ) + + if not self.last_update_success: + self.last_update_success = True + 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) + self.entity_data.update(new_data.entity_data) + self.entity_names.update(new_data.entity_names) + self.async_update_listeners(new_data) + + +class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]): + """A class for entities using PassiveBluetoothDataProcessor.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + processor: _PassiveBluetoothDataProcessorT, + entity_key: PassiveBluetoothEntityKey, + description: EntityDescription, + context: Any = None, + ) -> None: + """Create the entity with a PassiveBluetoothDataProcessor.""" + self.entity_description = description + self.entity_key = entity_key + self.processor = processor + self.processor_context = context + address = processor.coordinator.address + device_id = entity_key.device_id + devices = processor.devices + key = entity_key.key + if device_id in devices: + base_device_info = devices[device_id] + else: + base_device_info = DeviceInfo({}) + if device_id: + self._attr_device_info = base_device_info | DeviceInfo( + {ATTR_IDENTIFIERS: {(DOMAIN, f"{address}-{device_id}")}} + ) + self._attr_unique_id = f"{address}-{key}-{device_id}" + else: + self._attr_device_info = base_device_info | DeviceInfo( + {ATTR_IDENTIFIERS: {(DOMAIN, address)}} + ) + self._attr_unique_id = f"{address}-{key}" + if ATTR_NAME not in self._attr_device_info: + 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.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.processor.async_add_entity_key_listener( + self._handle_processor_update, self.entity_key + ) + ) + + @callback + def _handle_processor_update( + self, new_data: PassiveBluetoothDataUpdate | None + ) -> None: + """Handle updated data from the processor.""" + self.async_write_ha_state() diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py new file mode 100644 index 00000000000..d45514ab9ab --- /dev/null +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -0,0 +1,84 @@ +"""Update coordinator for the Bluetooth integration.""" +from __future__ import annotations + +import logging +import time + +from home_assistant_bluetooth import BluetoothServiceInfo + +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback + +from . import ( + BluetoothCallbackMatcher, + BluetoothChange, + async_register_callback, + async_track_unavailable, +) + + +class BasePassiveBluetoothCoordinator: + """Base class for passive bluetooth coordinator for bluetooth advertisements. + + The coordinator is responsible for tracking devices. + """ + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + address: str, + ) -> None: + """Initialize the coordinator.""" + self.hass = hass + self.logger = logger + self.name: str | None = None + self.address = address + self._cancel_track_unavailable: CALLBACK_TYPE | None = None + self._cancel_bluetooth_advertisements: CALLBACK_TYPE | None = None + self._present = False + self.last_seen = 0.0 + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self._present + + @callback + def _async_start(self) -> None: + """Start the callbacks.""" + self._cancel_bluetooth_advertisements = async_register_callback( + self.hass, + self._async_handle_bluetooth_event, + BluetoothCallbackMatcher(address=self.address), + ) + self._cancel_track_unavailable = async_track_unavailable( + self.hass, + self._async_handle_unavailable, + self.address, + ) + + @callback + def _async_stop(self) -> None: + """Stop the callbacks.""" + if self._cancel_bluetooth_advertisements is not None: + self._cancel_bluetooth_advertisements() + self._cancel_bluetooth_advertisements = None + if self._cancel_track_unavailable is not None: + self._cancel_track_unavailable() + self._cancel_track_unavailable = None + + @callback + def _async_handle_unavailable(self, address: str) -> None: + """Handle the device going unavailable.""" + self._present = False + + @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 diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index 330d85b665c..574f2a23355 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -3,8 +3,8 @@ from __future__ import annotations import logging -from homeassistant.components.bluetooth.passive_update_coordinator import ( - PassiveBluetoothDataUpdateCoordinator, +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothProcessorCoordinator, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: assert address is not None hass.data.setdefault(DOMAIN, {})[ entry.entry_id - ] = PassiveBluetoothDataUpdateCoordinator( + ] = PassiveBluetoothProcessorCoordinator( hass, _LOGGER, address=address, diff --git a/homeassistant/components/inkbird/sensor.py b/homeassistant/components/inkbird/sensor.py index 600f28b6880..6b472edefe9 100644 --- a/homeassistant/components/inkbird/sensor.py +++ b/homeassistant/components/inkbird/sensor.py @@ -13,11 +13,11 @@ from inkbird_ble import ( ) from homeassistant import config_entries -from homeassistant.components.bluetooth.passive_update_coordinator import ( +from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothEntityKey, + PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -126,7 +126,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the INKBIRD BLE sensors.""" - coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][ + coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] data = INKBIRDBluetoothDeviceData() diff --git a/homeassistant/components/sensorpush/__init__.py b/homeassistant/components/sensorpush/__init__.py index 94e98e30f82..0f0d4d4fe8f 100644 --- a/homeassistant/components/sensorpush/__init__.py +++ b/homeassistant/components/sensorpush/__init__.py @@ -3,8 +3,8 @@ from __future__ import annotations import logging -from homeassistant.components.bluetooth.passive_update_coordinator import ( - PassiveBluetoothDataUpdateCoordinator, +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothProcessorCoordinator, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: assert address is not None hass.data.setdefault(DOMAIN, {})[ entry.entry_id - ] = PassiveBluetoothDataUpdateCoordinator( + ] = PassiveBluetoothProcessorCoordinator( hass, _LOGGER, address=address, diff --git a/homeassistant/components/sensorpush/sensor.py b/homeassistant/components/sensorpush/sensor.py index f592b594289..3921cbe43dd 100644 --- a/homeassistant/components/sensorpush/sensor.py +++ b/homeassistant/components/sensorpush/sensor.py @@ -13,11 +13,11 @@ from sensorpush_ble import ( ) from homeassistant import config_entries -from homeassistant.components.bluetooth.passive_update_coordinator import ( +from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothEntityKey, + PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -127,7 +127,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SensorPush BLE sensors.""" - coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][ + coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] data = SensorPushBluetoothDeviceData() diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index d7f3e4071aa..a40dc8995d1 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -3,8 +3,8 @@ from __future__ import annotations import logging -from homeassistant.components.bluetooth.passive_update_coordinator import ( - PassiveBluetoothDataUpdateCoordinator, +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothProcessorCoordinator, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: assert address is not None hass.data.setdefault(DOMAIN, {})[ entry.entry_id - ] = PassiveBluetoothDataUpdateCoordinator( + ] = PassiveBluetoothProcessorCoordinator( hass, _LOGGER, address=address, diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index 1452b5e9053..50cbb0f66cf 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -13,11 +13,11 @@ from xiaomi_ble import ( ) from homeassistant import config_entries -from homeassistant.components.bluetooth.passive_update_coordinator import ( +from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothEntityKey, + PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -155,7 +155,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Xiaomi BLE sensors.""" - coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][ + coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] data = XiaomiBluetoothDeviceData() diff --git a/tests/components/bluetooth/test_passive_update_coordinator.py b/tests/components/bluetooth/test_passive_update_coordinator.py index 2b8c876460f..4da14fb13d3 100644 --- a/tests/components/bluetooth/test_passive_update_coordinator.py +++ b/tests/components/bluetooth/test_passive_update_coordinator.py @@ -1,39 +1,27 @@ -"""Tests for the Bluetooth integration.""" +"""Tests for the Bluetooth integration PassiveBluetoothDataUpdateCoordinator.""" from __future__ import annotations from datetime import timedelta import logging +from typing import Any 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 ( - PassiveBluetoothDataProcessor, - PassiveBluetoothDataUpdate, + PassiveBluetoothCoordinatorEntity, PassiveBluetoothDataUpdateCoordinator, - PassiveBluetoothEntityKey, - PassiveBluetoothProcessorEntity, ) -from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription -from homeassistant.const import TEMP_CELSIUS -from homeassistant.core import CoreState, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import _get_underlying_scanner -from tests.common import MockEntityPlatform, async_fire_time_changed +from tests.common import async_fire_time_changed _LOGGER = logging.getLogger(__name__) @@ -49,49 +37,30 @@ GENERIC_BLUETOOTH_SERVICE_INFO = BluetoothServiceInfo( service_uuids=[], source="local", ) -GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( - devices={ - None: DeviceInfo( - name="Test Device", model="Test Model", manufacturer="Test Manufacturer" - ), - }, - entity_data={ - PassiveBluetoothEntityKey("temperature", None): 14.5, - PassiveBluetoothEntityKey("pressure", None): 1234, - }, - entity_names={ - PassiveBluetoothEntityKey("temperature", None): "Temperature", - PassiveBluetoothEntityKey("pressure", None): "Pressure", - }, - entity_descriptions={ - PassiveBluetoothEntityKey("temperature", None): SensorEntityDescription( - key="temperature", - native_unit_of_measurement=TEMP_CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - ), - PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription( - key="pressure", - native_unit_of_measurement="hPa", - device_class=SensorDeviceClass.PRESSURE, - ), - }, -) + + +class MyCoordinator(PassiveBluetoothDataUpdateCoordinator): + """An example coordinator that subclasses PassiveBluetoothDataUpdateCoordinator.""" + + def __init__(self, hass, logger, device_id) -> None: + """Initialize the coordinator.""" + super().__init__(hass, logger, device_id) + self.data: dict[str, Any] = {} + + def _async_handle_bluetooth_event( + self, + service_info: BluetoothServiceInfo, + change: BluetoothChange, + ) -> None: + """Handle a Bluetooth event.""" + self.data = {"rssi": service_info.rssi} + super()._async_handle_bluetooth_event(service_info, change) async def test_basic_usage(hass, mock_bleak_scanner_start): """Test basic usage of the PassiveBluetoothDataUpdateCoordinator.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - - @callback - def _async_generate_mock_data( - service_info: BluetoothServiceInfo, - ) -> PassiveBluetoothDataUpdate: - """Generate mock data.""" - return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE - - coordinator = PassiveBluetoothDataUpdateCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff" - ) + coordinator = MyCoordinator(hass, _LOGGER, "aa:bb:cc:dd:ee:ff") assert coordinator.available is False # no data yet saved_callback = None @@ -100,98 +69,87 @@ async def test_basic_usage(hass, mock_bleak_scanner_start): saved_callback = _callback return lambda: None - processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + mock_listener = MagicMock() + unregister_listener = coordinator.async_add_listener(mock_listener) with patch( - "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", _async_register_callback, ): - unregister_processor = coordinator.async_register_processor(processor) + cancel = coordinator.async_start() - 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) - - 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) - - cancel_listener = processor.async_add_listener( - _all_listener, - ) - - cancel_async_add_entities_listener = processor.async_add_entities_listener( - mock_entity, - mock_add_entities, - ) + assert saved_callback is not None saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - # Each listener should receive the same data - # since both match - assert len(entity_key_events) == 1 - assert len(all_events) == 1 - - # There should be 4 calls to create entities - assert len(mock_entity.mock_calls) == 2 - - saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - - # Each listener should receive the same data - # since both match - assert len(entity_key_events) == 2 - assert len(all_events) == 2 - - # On the second, the entities should already be created - # so the mock should not be called again - assert len(mock_entity.mock_calls) == 2 - - cancel_async_add_entity_key_listener() - cancel_listener() - cancel_async_add_entities_listener() - - saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - - # Each listener should not trigger any more now - # that they were cancelled - assert len(entity_key_events) == 2 - assert len(all_events) == 2 - assert len(mock_entity.mock_calls) == 2 + assert len(mock_listener.mock_calls) == 1 + assert coordinator.data == {"rssi": GENERIC_BLUETOOTH_SERVICE_INFO.rssi} assert coordinator.available is True - unregister_processor() + unregister_listener() + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + + assert len(mock_listener.mock_calls) == 1 + assert coordinator.data == {"rssi": GENERIC_BLUETOOTH_SERVICE_INFO.rssi} + assert coordinator.available is True + cancel() -async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start): - """Test that the coordinator is unavailable after no data for a while.""" +async def test_context_compatiblity_with_data_update_coordinator( + hass, mock_bleak_scanner_start +): + """Test contexts can be passed for compatibility with DataUpdateCoordinator.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + coordinator = MyCoordinator(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 + + mock_listener = MagicMock() + coordinator.async_add_listener(mock_listener) + + with patch( + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", + _async_register_callback, + ): + coordinator.async_start() + + assert not set(coordinator.async_contexts()) + + def update_callback1(): + pass + + def update_callback2(): + pass + + unsub1 = coordinator.async_add_listener(update_callback1, 1) + assert set(coordinator.async_contexts()) == {1} + + unsub2 = coordinator.async_add_listener(update_callback2, 2) + assert set(coordinator.async_contexts()) == {1, 2} + + unsub1() + assert set(coordinator.async_contexts()) == {2} + + unsub2() + assert not set(coordinator.async_contexts()) + + +async def test_unavailable_callbacks_mark_the_coordinator_unavailable( + hass, mock_bleak_scanner_start +): + """Test that the coordinator goes unavailable when the bluetooth stack no longer sees the device.""" with patch( "bleak.BleakScanner.discovered_devices", # Must patch before we setup [MagicMock(address="44:44:33:11:23:45")], ): await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - - @callback - def _async_generate_mock_data( - service_info: BluetoothServiceInfo, - ) -> PassiveBluetoothDataUpdate: - """Generate mock data.""" - return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE - - coordinator = PassiveBluetoothDataUpdateCoordinator( - hass, _LOGGER, "aa:bb:cc:dd:ee:ff" - ) + coordinator = MyCoordinator(hass, _LOGGER, "aa:bb:cc:dd:ee:ff") assert coordinator.available is False # no data yet saved_callback = None @@ -200,27 +158,19 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start): saved_callback = _callback return lambda: None - processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + mock_listener = MagicMock() + coordinator.async_add_listener(mock_listener) + with patch( - "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", _async_register_callback, ): - unregister_processor = coordinator.async_register_processor(processor) - - mock_entity = MagicMock() - mock_add_entities = MagicMock() - processor.async_add_entities_listener( - mock_entity, - mock_add_entities, - ) + coordinator.async_start() 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( @@ -236,12 +186,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 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", @@ -256,474 +203,15 @@ 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): - """Test updates are ignored once hass is stopping.""" - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - - @callback - def _async_generate_mock_data( - service_info: BluetoothServiceInfo, - ) -> PassiveBluetoothDataUpdate: - """Generate mock data.""" - return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE - - 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 - - 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 = [] - - def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: - """Mock an all listener.""" - all_events.append(data) - - processor.async_add_listener( - _all_listener, - ) - - saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - assert len(all_events) == 1 - - hass.state = CoreState.stopping - - # 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): - """Test we handle exceptions from the update method.""" - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - - run_count = 0 - - @callback - def _async_generate_mock_data( - service_info: BluetoothServiceInfo, - ) -> PassiveBluetoothDataUpdate: - """Generate mock data.""" - nonlocal run_count - run_count += 1 - if run_count == 2: - raise Exception("Test exception") - return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE - - 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 - - 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) - - processor.async_add_listener(MagicMock()) - - saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - 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 processor.available is False - - # We should go available again once we get data again - saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - assert processor.available is True - unregister_processor() - - -async def test_bad_data_from_update_method(hass, mock_bleak_scanner_start): - """Test we handle bad data from the update method.""" - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - - run_count = 0 - - @callback - def _async_generate_mock_data( - service_info: BluetoothServiceInfo, - ) -> PassiveBluetoothDataUpdate: - """Generate mock data.""" - nonlocal run_count - run_count += 1 - if run_count == 2: - return "bad_data" - return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE - - 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 - - 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) - - processor.async_add_listener(MagicMock()) - - saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - 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 processor.available is False - - # We should go available again once we get good data again - saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - assert processor.available is True - unregister_processor() - - -GOVEE_B5178_REMOTE_SERVICE_INFO = BluetoothServiceInfo( - name="B5178D6FB", - address="749A17CB-F7A9-D466-C29F-AABE601938A0", - rssi=-95, - manufacturer_data={ - 1: b"\x01\x01\x01\x04\xb5\xa2d\x00\x06L\x00\x02\x15INTELLI_ROCKS_HWPu\xf2\xff\xc2" - }, - service_data={}, - service_uuids=["0000ec88-0000-1000-8000-00805f9b34fb"], - source="local", -) -GOVEE_B5178_PRIMARY_SERVICE_INFO = BluetoothServiceInfo( - name="B5178D6FB", - address="749A17CB-F7A9-D466-C29F-AABE601938A0", - rssi=-92, - manufacturer_data={ - 1: b"\x01\x01\x00\x03\x07Xd\x00\x00L\x00\x02\x15INTELLI_ROCKS_HWPu\xf2\xff\xc2" - }, - service_data={}, - service_uuids=["0000ec88-0000-1000-8000-00805f9b34fb"], - source="local", -) - -GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( - devices={ - "remote": { - "name": "B5178D6FB Remote", - "manufacturer": "Govee", - "model": "H5178-REMOTE", - }, - }, - entity_descriptions={ - PassiveBluetoothEntityKey( - key="temperature", device_id="remote" - ): SensorEntityDescription( - key="temperature_remote", - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=None, - entity_registry_enabled_default=True, - entity_registry_visible_default=True, - force_update=False, - icon=None, - has_entity_name=False, - unit_of_measurement=None, - last_reset=None, - native_unit_of_measurement="°C", - state_class=None, - ), - PassiveBluetoothEntityKey( - key="humidity", device_id="remote" - ): SensorEntityDescription( - key="humidity_remote", - device_class=SensorDeviceClass.HUMIDITY, - entity_category=None, - entity_registry_enabled_default=True, - entity_registry_visible_default=True, - force_update=False, - icon=None, - has_entity_name=False, - unit_of_measurement=None, - last_reset=None, - native_unit_of_measurement="%", - state_class=None, - ), - PassiveBluetoothEntityKey( - key="battery", device_id="remote" - ): SensorEntityDescription( - key="battery_remote", - device_class=SensorDeviceClass.BATTERY, - entity_category=None, - entity_registry_enabled_default=True, - entity_registry_visible_default=True, - force_update=False, - icon=None, - has_entity_name=False, - unit_of_measurement=None, - last_reset=None, - native_unit_of_measurement="%", - state_class=None, - ), - PassiveBluetoothEntityKey( - key="signal_strength", device_id="remote" - ): SensorEntityDescription( - key="signal_strength_remote", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - entity_category=None, - entity_registry_enabled_default=False, - entity_registry_visible_default=True, - force_update=False, - icon=None, - has_entity_name=False, - unit_of_measurement=None, - last_reset=None, - native_unit_of_measurement="dBm", - state_class=None, - ), - }, - entity_names={ - PassiveBluetoothEntityKey(key="temperature", device_id="remote"): "Temperature", - PassiveBluetoothEntityKey(key="humidity", device_id="remote"): "Humidity", - PassiveBluetoothEntityKey(key="battery", device_id="remote"): "Battery", - PassiveBluetoothEntityKey( - key="signal_strength", device_id="remote" - ): "Signal Strength", - }, - entity_data={ - PassiveBluetoothEntityKey(key="temperature", device_id="remote"): 30.8642, - PassiveBluetoothEntityKey(key="humidity", device_id="remote"): 64.2, - PassiveBluetoothEntityKey(key="battery", device_id="remote"): 100, - PassiveBluetoothEntityKey(key="signal_strength", device_id="remote"): -95, - }, -) -GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = ( - PassiveBluetoothDataUpdate( - devices={ - "remote": { - "name": "B5178D6FB Remote", - "manufacturer": "Govee", - "model": "H5178-REMOTE", - }, - "primary": { - "name": "B5178D6FB Primary", - "manufacturer": "Govee", - "model": "H5178", - }, - }, - entity_descriptions={ - PassiveBluetoothEntityKey( - key="temperature", device_id="remote" - ): SensorEntityDescription( - key="temperature_remote", - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=None, - entity_registry_enabled_default=True, - entity_registry_visible_default=True, - force_update=False, - icon=None, - has_entity_name=False, - unit_of_measurement=None, - last_reset=None, - native_unit_of_measurement="°C", - state_class=None, - ), - PassiveBluetoothEntityKey( - key="humidity", device_id="remote" - ): SensorEntityDescription( - key="humidity_remote", - device_class=SensorDeviceClass.HUMIDITY, - entity_category=None, - entity_registry_enabled_default=True, - entity_registry_visible_default=True, - force_update=False, - icon=None, - has_entity_name=False, - unit_of_measurement=None, - last_reset=None, - native_unit_of_measurement="%", - state_class=None, - ), - PassiveBluetoothEntityKey( - key="battery", device_id="remote" - ): SensorEntityDescription( - key="battery_remote", - device_class=SensorDeviceClass.BATTERY, - entity_category=None, - entity_registry_enabled_default=True, - entity_registry_visible_default=True, - force_update=False, - icon=None, - has_entity_name=False, - unit_of_measurement=None, - last_reset=None, - native_unit_of_measurement="%", - state_class=None, - ), - PassiveBluetoothEntityKey( - key="signal_strength", device_id="remote" - ): SensorEntityDescription( - key="signal_strength_remote", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - entity_category=None, - entity_registry_enabled_default=False, - entity_registry_visible_default=True, - force_update=False, - icon=None, - has_entity_name=False, - unit_of_measurement=None, - last_reset=None, - native_unit_of_measurement="dBm", - state_class=None, - ), - PassiveBluetoothEntityKey( - key="temperature", device_id="primary" - ): SensorEntityDescription( - key="temperature_primary", - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=None, - entity_registry_enabled_default=True, - entity_registry_visible_default=True, - force_update=False, - icon=None, - has_entity_name=False, - unit_of_measurement=None, - last_reset=None, - native_unit_of_measurement="°C", - state_class=None, - ), - PassiveBluetoothEntityKey( - key="humidity", device_id="primary" - ): SensorEntityDescription( - key="humidity_primary", - device_class=SensorDeviceClass.HUMIDITY, - entity_category=None, - entity_registry_enabled_default=True, - entity_registry_visible_default=True, - force_update=False, - icon=None, - has_entity_name=False, - unit_of_measurement=None, - last_reset=None, - native_unit_of_measurement="%", - state_class=None, - ), - PassiveBluetoothEntityKey( - key="battery", device_id="primary" - ): SensorEntityDescription( - key="battery_primary", - device_class=SensorDeviceClass.BATTERY, - entity_category=None, - entity_registry_enabled_default=True, - entity_registry_visible_default=True, - force_update=False, - icon=None, - has_entity_name=False, - unit_of_measurement=None, - last_reset=None, - native_unit_of_measurement="%", - state_class=None, - ), - PassiveBluetoothEntityKey( - key="signal_strength", device_id="primary" - ): SensorEntityDescription( - key="signal_strength_primary", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - entity_category=None, - entity_registry_enabled_default=False, - entity_registry_visible_default=True, - force_update=False, - icon=None, - has_entity_name=False, - unit_of_measurement=None, - last_reset=None, - native_unit_of_measurement="dBm", - state_class=None, - ), - }, - entity_names={ - PassiveBluetoothEntityKey( - key="temperature", device_id="remote" - ): "Temperature", - PassiveBluetoothEntityKey(key="humidity", device_id="remote"): "Humidity", - PassiveBluetoothEntityKey(key="battery", device_id="remote"): "Battery", - PassiveBluetoothEntityKey( - key="signal_strength", device_id="remote" - ): "Signal Strength", - PassiveBluetoothEntityKey( - key="temperature", device_id="primary" - ): "Temperature", - PassiveBluetoothEntityKey(key="humidity", device_id="primary"): "Humidity", - PassiveBluetoothEntityKey(key="battery", device_id="primary"): "Battery", - PassiveBluetoothEntityKey( - key="signal_strength", device_id="primary" - ): "Signal Strength", - }, - entity_data={ - PassiveBluetoothEntityKey(key="temperature", device_id="remote"): 30.8642, - PassiveBluetoothEntityKey(key="humidity", device_id="remote"): 64.2, - PassiveBluetoothEntityKey(key="battery", device_id="remote"): 100, - PassiveBluetoothEntityKey(key="signal_strength", device_id="remote"): -92, - PassiveBluetoothEntityKey(key="temperature", device_id="primary"): 19.8488, - PassiveBluetoothEntityKey(key="humidity", device_id="primary"): 48.8, - PassiveBluetoothEntityKey(key="battery", device_id="primary"): 100, - PassiveBluetoothEntityKey(key="signal_strength", device_id="primary"): -92, - }, - ) -) - - -async def test_integration_with_entity(hass, mock_bleak_scanner_start): +async def test_passive_bluetooth_coordinator_entity(hass, mock_bleak_scanner_start): """Test integration of PassiveBluetoothDataUpdateCoordinator with PassiveBluetoothCoordinatorEntity.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + coordinator = MyCoordinator(hass, _LOGGER, "aa:bb:cc:dd:ee:ff") + entity = PassiveBluetoothCoordinatorEntity(coordinator) + assert entity.available is False - update_count = 0 - - @callback - def _async_generate_mock_data( - service_info: BluetoothServiceInfo, - ) -> PassiveBluetoothDataUpdate: - """Generate mock data.""" - nonlocal update_count - update_count += 1 - if update_count > 2: - return GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE - return GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE - - 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): @@ -731,328 +219,15 @@ 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", + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", _async_register_callback, ): - coordinator.async_register_processor(processor) - - processor.async_add_listener(MagicMock()) - - mock_add_entities = MagicMock() - - processor.async_add_entities_listener( - PassiveBluetoothProcessorEntity, - mock_add_entities, - ) + coordinator.async_start() + assert coordinator.available is False 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_entities.mock_calls) == 1 - - saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - # Second call with just the remote sensor entities does not add them again - assert len(mock_add_entities.mock_calls) == 1 - - saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - # Third call with primary and remote sensor entities adds the primary sensor entities - assert len(mock_add_entities.mock_calls) == 2 - - saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - # Forth call with both primary and remote sensor entities does not add them again - assert len(mock_add_entities.mock_calls) == 2 - - entities = [ - *mock_add_entities.mock_calls[0][1][0], - *mock_add_entities.mock_calls[1][1][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" - assert entity_one.device_info == { - "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff-remote")}, - "manufacturer": "Govee", - "model": "H5178-REMOTE", - "name": "B5178D6FB Remote", - } - assert entity_one.entity_key == PassiveBluetoothEntityKey( - key="temperature", device_id="remote" - ) - - -NO_DEVICES_BLUETOOTH_SERVICE_INFO = BluetoothServiceInfo( - name="Generic", - address="aa:bb:cc:dd:ee:ff", - rssi=-95, - manufacturer_data={ - 1: b"\x01\x01\x01\x01\x01\x01\x01\x01", - }, - service_data={}, - service_uuids=[], - source="local", -) -NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( - devices={}, - entity_data={ - PassiveBluetoothEntityKey("temperature", None): 14.5, - PassiveBluetoothEntityKey("pressure", None): 1234, - }, - entity_descriptions={ - PassiveBluetoothEntityKey("temperature", None): SensorEntityDescription( - key="temperature", - name="Temperature", - native_unit_of_measurement=TEMP_CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - ), - PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription( - key="pressure", - name="Pressure", - native_unit_of_measurement="hPa", - device_class=SensorDeviceClass.PRESSURE, - ), - }, -) - - -async def test_integration_with_entity_without_a_device(hass, mock_bleak_scanner_start): - """Test integration with PassiveBluetoothCoordinatorEntity with no device.""" - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - - @callback - def _async_generate_mock_data( - service_info: BluetoothServiceInfo, - ) -> PassiveBluetoothDataUpdate: - """Generate mock data.""" - return NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE - - 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 - - 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() - - 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 - assert len(mock_add_entities.mock_calls) == 1 - - saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - # Second call with just the remote sensor entities does not add them again - assert len(mock_add_entities.mock_calls) == 1 - - entities = mock_add_entities.mock_calls[0][1][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" - assert entity_one.device_info == { - "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, - "name": "Generic", - } - assert entity_one.entity_key == PassiveBluetoothEntityKey( - key="temperature", device_id=None - ) - - -async def test_passive_bluetooth_entity_with_entity_platform( - hass, mock_bleak_scanner_start -): - """Test with a mock entity platform.""" - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - - entity_platform = MockEntityPlatform(hass) - - @callback - def _async_generate_mock_data( - service_info: BluetoothServiceInfo, - ) -> PassiveBluetoothDataUpdate: - """Generate mock data.""" - return NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE - - 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 - - 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) - - 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) - await hass.async_block_till_done() - assert ( - hass.states.get("test_domain.test_platform_aa_bb_cc_dd_ee_ff_temperature") - is not None - ) - assert ( - 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 - ) + assert coordinator.available is True + entity.hass = hass + await entity.async_update() + assert entity.available is True diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py new file mode 100644 index 00000000000..e5f992eebb2 --- /dev/null +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -0,0 +1,1058 @@ +"""Tests for the Bluetooth integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +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_processor import ( + PassiveBluetoothDataProcessor, + PassiveBluetoothDataUpdate, + PassiveBluetoothEntityKey, + PassiveBluetoothProcessorCoordinator, + PassiveBluetoothProcessorEntity, +) +from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription +from homeassistant.const import TEMP_CELSIUS +from homeassistant.core import CoreState, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from . import _get_underlying_scanner + +from tests.common import MockEntityPlatform, async_fire_time_changed + +_LOGGER = logging.getLogger(__name__) + + +GENERIC_BLUETOOTH_SERVICE_INFO = BluetoothServiceInfo( + name="Generic", + address="aa:bb:cc:dd:ee:ff", + rssi=-95, + manufacturer_data={ + 1: b"\x01\x01\x01\x01\x01\x01\x01\x01", + }, + service_data={}, + service_uuids=[], + source="local", +) +GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( + devices={ + None: DeviceInfo( + name="Test Device", model="Test Model", manufacturer="Test Manufacturer" + ), + }, + entity_data={ + PassiveBluetoothEntityKey("temperature", None): 14.5, + PassiveBluetoothEntityKey("pressure", None): 1234, + }, + entity_names={ + PassiveBluetoothEntityKey("temperature", None): "Temperature", + PassiveBluetoothEntityKey("pressure", None): "Pressure", + }, + entity_descriptions={ + PassiveBluetoothEntityKey("temperature", None): SensorEntityDescription( + key="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription( + key="pressure", + native_unit_of_measurement="hPa", + device_class=SensorDeviceClass.PRESSURE, + ), + }, +) + + +async def test_basic_usage(hass, mock_bleak_scanner_start): + """Test basic usage of the PassiveBluetoothProcessorCoordinator.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + @callback + def _async_generate_mock_data( + service_info: BluetoothServiceInfo, + ) -> PassiveBluetoothDataUpdate: + """Generate mock data.""" + return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE + + coordinator = PassiveBluetoothProcessorCoordinator( + 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 + + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + + with patch( + "homeassistant.components.bluetooth.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() + + def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock entity key listener.""" + entity_key_events.append(data) + + 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) + + cancel_listener = processor.async_add_listener( + _all_listener, + ) + + cancel_async_add_entities_listener = processor.async_add_entities_listener( + mock_entity, + mock_add_entities, + ) + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + + # Each listener should receive the same data + # since both match + assert len(entity_key_events) == 1 + assert len(all_events) == 1 + + # There should be 4 calls to create entities + assert len(mock_entity.mock_calls) == 2 + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + + # Each listener should receive the same data + # since both match + assert len(entity_key_events) == 2 + assert len(all_events) == 2 + + # On the second, the entities should already be created + # so the mock should not be called again + assert len(mock_entity.mock_calls) == 2 + + cancel_async_add_entity_key_listener() + cancel_listener() + cancel_async_add_entities_listener() + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + + # Each listener should not trigger any more now + # that they were cancelled + assert len(entity_key_events) == 2 + assert len(all_events) == 2 + 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.""" + with patch( + "bleak.BleakScanner.discovered_devices", # Must patch before we setup + [MagicMock(address="44:44:33:11:23:45")], + ): + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + @callback + def _async_generate_mock_data( + service_info: BluetoothServiceInfo, + ) -> PassiveBluetoothDataUpdate: + """Generate mock data.""" + return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE + + coordinator = PassiveBluetoothProcessorCoordinator( + 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 + + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + with patch( + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", + _async_register_callback, + ): + unregister_processor = coordinator.async_register_processor(processor) + + 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( + "homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices", + [MagicMock(address="44:44:33:11:23:45")], + ), patch.object( + scanner, + "history", + {"aa:bb:cc:dd:ee:ff": MagicMock()}, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + 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", + [MagicMock(address="44:44:33:11:23:45")], + ), patch.object( + scanner, + "history", + {"aa:bb:cc:dd:ee:ff": MagicMock()}, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + 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): + """Test updates are ignored once hass is stopping.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + @callback + def _async_generate_mock_data( + service_info: BluetoothServiceInfo, + ) -> PassiveBluetoothDataUpdate: + """Generate mock data.""" + return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE + + coordinator = PassiveBluetoothProcessorCoordinator( + 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 + + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + + with patch( + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", + _async_register_callback, + ): + unregister_processor = coordinator.async_register_processor(processor) + + all_events = [] + + def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock an all listener.""" + all_events.append(data) + + processor.async_add_listener( + _all_listener, + ) + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + assert len(all_events) == 1 + + hass.state = CoreState.stopping + + # 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): + """Test we handle exceptions from the update method.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + run_count = 0 + + @callback + def _async_generate_mock_data( + service_info: BluetoothServiceInfo, + ) -> PassiveBluetoothDataUpdate: + """Generate mock data.""" + nonlocal run_count + run_count += 1 + if run_count == 2: + raise Exception("Test exception") + return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE + + coordinator = PassiveBluetoothProcessorCoordinator( + 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 + + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + with patch( + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", + _async_register_callback, + ): + unregister_processor = coordinator.async_register_processor(processor) + + processor.async_add_listener(MagicMock()) + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + 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 processor.available is False + + # We should go available again once we get data again + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + assert processor.available is True + unregister_processor() + + +async def test_bad_data_from_update_method(hass, mock_bleak_scanner_start): + """Test we handle bad data from the update method.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + run_count = 0 + + @callback + def _async_generate_mock_data( + service_info: BluetoothServiceInfo, + ) -> PassiveBluetoothDataUpdate: + """Generate mock data.""" + nonlocal run_count + run_count += 1 + if run_count == 2: + return "bad_data" + return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE + + coordinator = PassiveBluetoothProcessorCoordinator( + 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 + + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + with patch( + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", + _async_register_callback, + ): + unregister_processor = coordinator.async_register_processor(processor) + + processor.async_add_listener(MagicMock()) + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + 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 processor.available is False + + # We should go available again once we get good data again + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + assert processor.available is True + unregister_processor() + + +GOVEE_B5178_REMOTE_SERVICE_INFO = BluetoothServiceInfo( + name="B5178D6FB", + address="749A17CB-F7A9-D466-C29F-AABE601938A0", + rssi=-95, + manufacturer_data={ + 1: b"\x01\x01\x01\x04\xb5\xa2d\x00\x06L\x00\x02\x15INTELLI_ROCKS_HWPu\xf2\xff\xc2" + }, + service_data={}, + service_uuids=["0000ec88-0000-1000-8000-00805f9b34fb"], + source="local", +) +GOVEE_B5178_PRIMARY_SERVICE_INFO = BluetoothServiceInfo( + name="B5178D6FB", + address="749A17CB-F7A9-D466-C29F-AABE601938A0", + rssi=-92, + manufacturer_data={ + 1: b"\x01\x01\x00\x03\x07Xd\x00\x00L\x00\x02\x15INTELLI_ROCKS_HWPu\xf2\xff\xc2" + }, + service_data={}, + service_uuids=["0000ec88-0000-1000-8000-00805f9b34fb"], + source="local", +) + +GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( + devices={ + "remote": { + "name": "B5178D6FB Remote", + "manufacturer": "Govee", + "model": "H5178-REMOTE", + }, + }, + entity_descriptions={ + PassiveBluetoothEntityKey( + key="temperature", device_id="remote" + ): SensorEntityDescription( + key="temperature_remote", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=None, + entity_registry_enabled_default=True, + entity_registry_visible_default=True, + force_update=False, + icon=None, + has_entity_name=False, + unit_of_measurement=None, + last_reset=None, + native_unit_of_measurement="°C", + state_class=None, + ), + PassiveBluetoothEntityKey( + key="humidity", device_id="remote" + ): SensorEntityDescription( + key="humidity_remote", + device_class=SensorDeviceClass.HUMIDITY, + entity_category=None, + entity_registry_enabled_default=True, + entity_registry_visible_default=True, + force_update=False, + icon=None, + has_entity_name=False, + unit_of_measurement=None, + last_reset=None, + native_unit_of_measurement="%", + state_class=None, + ), + PassiveBluetoothEntityKey( + key="battery", device_id="remote" + ): SensorEntityDescription( + key="battery_remote", + device_class=SensorDeviceClass.BATTERY, + entity_category=None, + entity_registry_enabled_default=True, + entity_registry_visible_default=True, + force_update=False, + icon=None, + has_entity_name=False, + unit_of_measurement=None, + last_reset=None, + native_unit_of_measurement="%", + state_class=None, + ), + PassiveBluetoothEntityKey( + key="signal_strength", device_id="remote" + ): SensorEntityDescription( + key="signal_strength_remote", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=None, + entity_registry_enabled_default=False, + entity_registry_visible_default=True, + force_update=False, + icon=None, + has_entity_name=False, + unit_of_measurement=None, + last_reset=None, + native_unit_of_measurement="dBm", + state_class=None, + ), + }, + entity_names={ + PassiveBluetoothEntityKey(key="temperature", device_id="remote"): "Temperature", + PassiveBluetoothEntityKey(key="humidity", device_id="remote"): "Humidity", + PassiveBluetoothEntityKey(key="battery", device_id="remote"): "Battery", + PassiveBluetoothEntityKey( + key="signal_strength", device_id="remote" + ): "Signal Strength", + }, + entity_data={ + PassiveBluetoothEntityKey(key="temperature", device_id="remote"): 30.8642, + PassiveBluetoothEntityKey(key="humidity", device_id="remote"): 64.2, + PassiveBluetoothEntityKey(key="battery", device_id="remote"): 100, + PassiveBluetoothEntityKey(key="signal_strength", device_id="remote"): -95, + }, +) +GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = ( + PassiveBluetoothDataUpdate( + devices={ + "remote": { + "name": "B5178D6FB Remote", + "manufacturer": "Govee", + "model": "H5178-REMOTE", + }, + "primary": { + "name": "B5178D6FB Primary", + "manufacturer": "Govee", + "model": "H5178", + }, + }, + entity_descriptions={ + PassiveBluetoothEntityKey( + key="temperature", device_id="remote" + ): SensorEntityDescription( + key="temperature_remote", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=None, + entity_registry_enabled_default=True, + entity_registry_visible_default=True, + force_update=False, + icon=None, + has_entity_name=False, + unit_of_measurement=None, + last_reset=None, + native_unit_of_measurement="°C", + state_class=None, + ), + PassiveBluetoothEntityKey( + key="humidity", device_id="remote" + ): SensorEntityDescription( + key="humidity_remote", + device_class=SensorDeviceClass.HUMIDITY, + entity_category=None, + entity_registry_enabled_default=True, + entity_registry_visible_default=True, + force_update=False, + icon=None, + has_entity_name=False, + unit_of_measurement=None, + last_reset=None, + native_unit_of_measurement="%", + state_class=None, + ), + PassiveBluetoothEntityKey( + key="battery", device_id="remote" + ): SensorEntityDescription( + key="battery_remote", + device_class=SensorDeviceClass.BATTERY, + entity_category=None, + entity_registry_enabled_default=True, + entity_registry_visible_default=True, + force_update=False, + icon=None, + has_entity_name=False, + unit_of_measurement=None, + last_reset=None, + native_unit_of_measurement="%", + state_class=None, + ), + PassiveBluetoothEntityKey( + key="signal_strength", device_id="remote" + ): SensorEntityDescription( + key="signal_strength_remote", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=None, + entity_registry_enabled_default=False, + entity_registry_visible_default=True, + force_update=False, + icon=None, + has_entity_name=False, + unit_of_measurement=None, + last_reset=None, + native_unit_of_measurement="dBm", + state_class=None, + ), + PassiveBluetoothEntityKey( + key="temperature", device_id="primary" + ): SensorEntityDescription( + key="temperature_primary", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=None, + entity_registry_enabled_default=True, + entity_registry_visible_default=True, + force_update=False, + icon=None, + has_entity_name=False, + unit_of_measurement=None, + last_reset=None, + native_unit_of_measurement="°C", + state_class=None, + ), + PassiveBluetoothEntityKey( + key="humidity", device_id="primary" + ): SensorEntityDescription( + key="humidity_primary", + device_class=SensorDeviceClass.HUMIDITY, + entity_category=None, + entity_registry_enabled_default=True, + entity_registry_visible_default=True, + force_update=False, + icon=None, + has_entity_name=False, + unit_of_measurement=None, + last_reset=None, + native_unit_of_measurement="%", + state_class=None, + ), + PassiveBluetoothEntityKey( + key="battery", device_id="primary" + ): SensorEntityDescription( + key="battery_primary", + device_class=SensorDeviceClass.BATTERY, + entity_category=None, + entity_registry_enabled_default=True, + entity_registry_visible_default=True, + force_update=False, + icon=None, + has_entity_name=False, + unit_of_measurement=None, + last_reset=None, + native_unit_of_measurement="%", + state_class=None, + ), + PassiveBluetoothEntityKey( + key="signal_strength", device_id="primary" + ): SensorEntityDescription( + key="signal_strength_primary", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=None, + entity_registry_enabled_default=False, + entity_registry_visible_default=True, + force_update=False, + icon=None, + has_entity_name=False, + unit_of_measurement=None, + last_reset=None, + native_unit_of_measurement="dBm", + state_class=None, + ), + }, + entity_names={ + PassiveBluetoothEntityKey( + key="temperature", device_id="remote" + ): "Temperature", + PassiveBluetoothEntityKey(key="humidity", device_id="remote"): "Humidity", + PassiveBluetoothEntityKey(key="battery", device_id="remote"): "Battery", + PassiveBluetoothEntityKey( + key="signal_strength", device_id="remote" + ): "Signal Strength", + PassiveBluetoothEntityKey( + key="temperature", device_id="primary" + ): "Temperature", + PassiveBluetoothEntityKey(key="humidity", device_id="primary"): "Humidity", + PassiveBluetoothEntityKey(key="battery", device_id="primary"): "Battery", + PassiveBluetoothEntityKey( + key="signal_strength", device_id="primary" + ): "Signal Strength", + }, + entity_data={ + PassiveBluetoothEntityKey(key="temperature", device_id="remote"): 30.8642, + PassiveBluetoothEntityKey(key="humidity", device_id="remote"): 64.2, + PassiveBluetoothEntityKey(key="battery", device_id="remote"): 100, + PassiveBluetoothEntityKey(key="signal_strength", device_id="remote"): -92, + PassiveBluetoothEntityKey(key="temperature", device_id="primary"): 19.8488, + PassiveBluetoothEntityKey(key="humidity", device_id="primary"): 48.8, + PassiveBluetoothEntityKey(key="battery", device_id="primary"): 100, + PassiveBluetoothEntityKey(key="signal_strength", device_id="primary"): -92, + }, + ) +) + + +async def test_integration_with_entity(hass, mock_bleak_scanner_start): + """Test integration of PassiveBluetoothProcessorCoordinator with PassiveBluetoothCoordinatorEntity.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + update_count = 0 + + @callback + def _async_generate_mock_data( + service_info: BluetoothServiceInfo, + ) -> PassiveBluetoothDataUpdate: + """Generate mock data.""" + nonlocal update_count + update_count += 1 + if update_count > 2: + return GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE + return GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE + + coordinator = PassiveBluetoothProcessorCoordinator( + 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 + + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + with patch( + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", + _async_register_callback, + ): + coordinator.async_register_processor(processor) + + processor.async_add_listener(MagicMock()) + + mock_add_entities = MagicMock() + + processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_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_entities.mock_calls) == 1 + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + # Second call with just the remote sensor entities does not add them again + assert len(mock_add_entities.mock_calls) == 1 + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + # Third call with primary and remote sensor entities adds the primary sensor entities + assert len(mock_add_entities.mock_calls) == 2 + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + # Forth call with both primary and remote sensor entities does not add them again + assert len(mock_add_entities.mock_calls) == 2 + + entities = [ + *mock_add_entities.mock_calls[0][1][0], + *mock_add_entities.mock_calls[1][1][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" + assert entity_one.device_info == { + "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff-remote")}, + "manufacturer": "Govee", + "model": "H5178-REMOTE", + "name": "B5178D6FB Remote", + } + assert entity_one.entity_key == PassiveBluetoothEntityKey( + key="temperature", device_id="remote" + ) + + +NO_DEVICES_BLUETOOTH_SERVICE_INFO = BluetoothServiceInfo( + name="Generic", + address="aa:bb:cc:dd:ee:ff", + rssi=-95, + manufacturer_data={ + 1: b"\x01\x01\x01\x01\x01\x01\x01\x01", + }, + service_data={}, + service_uuids=[], + source="local", +) +NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( + devices={}, + entity_data={ + PassiveBluetoothEntityKey("temperature", None): 14.5, + PassiveBluetoothEntityKey("pressure", None): 1234, + }, + entity_descriptions={ + PassiveBluetoothEntityKey("temperature", None): SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription( + key="pressure", + name="Pressure", + native_unit_of_measurement="hPa", + device_class=SensorDeviceClass.PRESSURE, + ), + }, +) + + +async def test_integration_with_entity_without_a_device(hass, mock_bleak_scanner_start): + """Test integration with PassiveBluetoothCoordinatorEntity with no device.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + @callback + def _async_generate_mock_data( + service_info: BluetoothServiceInfo, + ) -> PassiveBluetoothDataUpdate: + """Generate mock data.""" + return NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE + + coordinator = PassiveBluetoothProcessorCoordinator( + 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 + + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + with patch( + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", + _async_register_callback, + ): + coordinator.async_register_processor(processor) + + mock_add_entities = MagicMock() + + 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 + assert len(mock_add_entities.mock_calls) == 1 + + saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + # Second call with just the remote sensor entities does not add them again + assert len(mock_add_entities.mock_calls) == 1 + + entities = mock_add_entities.mock_calls[0][1][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" + assert entity_one.device_info == { + "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "name": "Generic", + } + assert entity_one.entity_key == PassiveBluetoothEntityKey( + key="temperature", device_id=None + ) + + +async def test_passive_bluetooth_entity_with_entity_platform( + hass, mock_bleak_scanner_start +): + """Test with a mock entity platform.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + entity_platform = MockEntityPlatform(hass) + + @callback + def _async_generate_mock_data( + service_info: BluetoothServiceInfo, + ) -> PassiveBluetoothDataUpdate: + """Generate mock data.""" + return NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE + + coordinator = PassiveBluetoothProcessorCoordinator( + 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 + + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + with patch( + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", + _async_register_callback, + ): + coordinator.async_register_processor(processor) + + 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) + await hass.async_block_till_done() + assert ( + hass.states.get("test_domain.test_platform_aa_bb_cc_dd_ee_ff_temperature") + is not None + ) + assert ( + 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 PassiveBluetoothProcessorCoordinator with multiple platforms.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + coordinator = PassiveBluetoothProcessorCoordinator( + 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.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 + ) diff --git a/tests/components/inkbird/test_sensor.py b/tests/components/inkbird/test_sensor.py index 80a2179666f..a851cb92ec3 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -28,7 +28,7 @@ async def test_sensors(hass): return lambda: None with patch( - "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", _async_register_callback, ): assert await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/sensorpush/test_sensor.py b/tests/components/sensorpush/test_sensor.py index c48b8bc3407..31fbdd8d712 100644 --- a/tests/components/sensorpush/test_sensor.py +++ b/tests/components/sensorpush/test_sensor.py @@ -28,7 +28,7 @@ async def test_sensors(hass): return lambda: None with patch( - "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", _async_register_callback, ): assert await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index 44b29ff1051..74a4fe65131 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -28,7 +28,7 @@ async def test_sensors(hass): return lambda: None with patch( - "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", _async_register_callback, ): assert await hass.config_entries.async_setup(entry.entry_id) @@ -66,7 +66,7 @@ async def test_xiaomi_HHCCJCY01(hass): return lambda: None with patch( - "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", _async_register_callback, ): assert await hass.config_entries.async_setup(entry.entry_id)