From 6da25c733e014624564140a761af5201e3bf0adc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Jul 2022 15:54:37 -0500 Subject: [PATCH] Add coordinator and entity for passive bluetooth devices (#75468) Co-authored-by: Martin Hjelmare --- .../bluetooth/passive_update_coordinator.py | 341 +++++++ .../test_passive_update_coordinator.py | 868 ++++++++++++++++++ 2 files changed, 1209 insertions(+) create mode 100644 homeassistant/components/bluetooth/passive_update_coordinator.py create mode 100644 tests/components/bluetooth/test_passive_update_coordinator.py diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py new file mode 100644 index 00000000000..df4233452df --- /dev/null +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -0,0 +1,341 @@ +"""The Bluetooth integration.""" +from __future__ import annotations + +from collections.abc import Callable +import dataclasses +from datetime import datetime +import logging +import time +from typing import Any, Generic, TypeVar + +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.event import async_call_later + +from . import ( + BluetoothCallbackMatcher, + BluetoothChange, + async_address_present, + async_register_callback, +) +from .const import DOMAIN + +UNAVAILABLE_SECONDS = 60 * 5 +NEVER_TIME = -UNAVAILABLE_SECONDS + + +@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: dict[ + PassiveBluetoothEntityKey, EntityDescription + ] = dataclasses.field(default_factory=dict) + entity_data: dict[PassiveBluetoothEntityKey, _T] = dataclasses.field( + default_factory=dict + ) + + +_PassiveBluetoothDataUpdateCoordinatorT = TypeVar( + "_PassiveBluetoothDataUpdateCoordinatorT", + bound="PassiveBluetoothDataUpdateCoordinator[Any]", +) + + +class PassiveBluetoothDataUpdateCoordinator(Generic[_T]): + """Passive bluetooth data update coordinator for bluetooth advertisements. + + The coordinator is responsible for keeping track of the bluetooth data, + updating subscribers, and device availability. + + The update_method must return a PassiveBluetoothDataUpdate object. Callers + are responsible for formatting the data returned from their parser into + the appropriate format. + + The coordinator will call the update_method every time the bluetooth device + receives a new advertisement with the following signature: + + update_method(service_info: BluetoothServiceInfo) -> PassiveBluetoothDataUpdate + + As the size of each advertisement is limited, the update_method should + return a PassiveBluetoothDataUpdate object that contains only data that + should be updated. The coordinator will then dispatch subscribers based + on the data in the PassiveBluetoothDataUpdate object. The accumulated data + is available in the devices, entity_data, and entity_descriptions attributes. + """ + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + address: str, + update_method: Callable[[BluetoothServiceInfo], PassiveBluetoothDataUpdate[_T]], + ) -> None: + """Initialize the coordinator.""" + self.hass = hass + self.logger = logger + self.name: str | None = None + self.address = address + self._listeners: list[ + Callable[[PassiveBluetoothDataUpdate[_T] | None], None] + ] = [] + self._entity_key_listeners: dict[ + PassiveBluetoothEntityKey, + list[Callable[[PassiveBluetoothDataUpdate[_T] | None], None]], + ] = {} + self.update_method = update_method + + self.entity_data: dict[PassiveBluetoothEntityKey, _T] = {} + self.entity_descriptions: dict[ + PassiveBluetoothEntityKey, EntityDescription + ] = {} + self.devices: dict[str | None, DeviceInfo] = {} + + self.last_update_success = True + self._last_callback_time: float = NEVER_TIME + self._cancel_track_available: CALLBACK_TYPE | None = None + self._present = False + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self._present and self.last_update_success + + @callback + def _async_cancel_available_tracker(self) -> None: + """Reset the available tracker.""" + if self._cancel_track_available: + self._cancel_track_available() + self._cancel_track_available = None + + @callback + def _async_schedule_available_tracker(self, time_remaining: float) -> None: + """Schedule the available tracker.""" + self._cancel_track_available = async_call_later( + self.hass, time_remaining, self._async_check_device_present + ) + + @callback + def _async_check_device_present(self, _: datetime) -> None: + """Check if the device is present.""" + time_passed_since_seen = time.monotonic() - self._last_callback_time + self._async_cancel_available_tracker() + if ( + not self._present + or time_passed_since_seen < UNAVAILABLE_SECONDS + or async_address_present(self.hass, self.address) + ): + self._async_schedule_available_tracker( + UNAVAILABLE_SECONDS - time_passed_since_seen + ) + return + self._present = False + self.async_update_listeners(None) + + @callback + def async_setup(self) -> CALLBACK_TYPE: + """Start the callback.""" + return async_register_callback( + self.hass, + self._async_handle_bluetooth_event, + BluetoothCallbackMatcher(address=self.address), + ) + + @callback + def async_add_entities_listener( + self, + entity_class: type[PassiveBluetoothCoordinatorEntity], + 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[PassiveBluetoothCoordinatorEntity] = [] + 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.""" + self.name = service_info.name + self._last_callback_time = time.monotonic() + self._present = True + if not self._cancel_track_available: + self._async_schedule_available_tracker(UNAVAILABLE_SECONDS) + if self.hass.is_stopping: + return + + try: + new_data = self.update_method(service_info) + except Exception as err: # pylint: disable=broad-except + self.last_update_success = False + self.logger.exception( + "Unexpected error updating %s data: %s", self.name, err + ) + return + + if not isinstance(new_data, PassiveBluetoothDataUpdate): + self.last_update_success = False # type: ignore[unreachable] + self.logger.error( + "The update_method for %s returned %s instead of a PassiveBluetoothDataUpdate", + self.name, + new_data, + ) + return + + if not self.last_update_success: + self.last_update_success = True + self.logger.info("Processing %s data recovered", self.name) + + self.devices.update(new_data.devices) + self.entity_descriptions.update(new_data.entity_descriptions) + self.entity_data.update(new_data.entity_data) + self.async_update_listeners(new_data) + + +class PassiveBluetoothCoordinatorEntity( + Entity, Generic[_PassiveBluetoothDataUpdateCoordinatorT] +): + """A class for entities using PassiveBluetoothDataUpdateCoordinator.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + coordinator: _PassiveBluetoothDataUpdateCoordinatorT, + entity_key: PassiveBluetoothEntityKey, + description: EntityDescription, + context: Any = None, + ) -> None: + """Create the entity with a PassiveBluetoothDataUpdateCoordinator.""" + self.entity_description = description + self.entity_key = entity_key + self.coordinator = coordinator + self.coordinator_context = context + address = coordinator.address + device_id = entity_key.device_id + devices = coordinator.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.coordinator.name + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.available + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_entity_key_listener( + self._handle_coordinator_update, self.entity_key + ) + ) + + @callback + def _handle_coordinator_update( + self, new_data: PassiveBluetoothDataUpdate | None + ) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() diff --git a/tests/components/bluetooth/test_passive_update_coordinator.py b/tests/components/bluetooth/test_passive_update_coordinator.py new file mode 100644 index 00000000000..ca4ef6e909c --- /dev/null +++ b/tests/components/bluetooth/test_passive_update_coordinator.py @@ -0,0 +1,868 @@ +"""Tests for the Bluetooth integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +import time +from unittest.mock import MagicMock, patch + +from home_assistant_bluetooth import BluetoothServiceInfo + +from homeassistant.components.bluetooth import BluetoothChange +from homeassistant.components.bluetooth.passive_update_coordinator import ( + UNAVAILABLE_SECONDS, + PassiveBluetoothCoordinatorEntity, + PassiveBluetoothDataUpdate, + PassiveBluetoothDataUpdateCoordinator, + PassiveBluetoothEntityKey, +) +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.util import dt as dt_util + +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_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_basic_usage(hass): + """Test basic usage of the PassiveBluetoothDataUpdateCoordinator.""" + + @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", _async_generate_mock_data + ) + 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 + + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + _async_register_callback, + ): + cancel_coordinator = coordinator.async_setup() + + 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 = coordinator.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 = coordinator.async_add_listener( + _all_listener, + ) + + cancel_async_add_entities_listener = coordinator.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 + + cancel_coordinator() + + +async def test_unavailable_after_no_data(hass): + """Test that the coordinator is unavailable after no data for a while.""" + + @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", _async_generate_mock_data + ) + 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 + + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + _async_register_callback, + ): + cancel_coordinator = coordinator.async_setup() + + mock_entity = MagicMock() + mock_add_entities = MagicMock() + coordinator.async_add_entities_listener( + mock_entity, + mock_add_entities, + ) + + assert coordinator.available is False + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + assert len(mock_add_entities.mock_calls) == 1 + assert coordinator.available is True + + monotonic_now = time.monotonic() + now = dt_util.utcnow() + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator.time.monotonic", + return_value=monotonic_now + UNAVAILABLE_SECONDS, + ): + async_fire_time_changed(hass, now + timedelta(seconds=UNAVAILABLE_SECONDS)) + await hass.async_block_till_done() + assert coordinator.available is False + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + assert coordinator.available is True + + # Now simulate the device is still present even though we got + # no data for a while + + monotonic_now = time.monotonic() + now = dt_util.utcnow() + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator.async_address_present", + return_value=True, + ), patch( + "homeassistant.components.bluetooth.passive_update_coordinator.time.monotonic", + return_value=monotonic_now + UNAVAILABLE_SECONDS, + ): + async_fire_time_changed(hass, now + timedelta(seconds=UNAVAILABLE_SECONDS)) + await hass.async_block_till_done() + + assert coordinator.available is True + + # And finally that it can go unavailable again when its gone + monotonic_now = time.monotonic() + now = dt_util.utcnow() + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator.time.monotonic", + return_value=monotonic_now + UNAVAILABLE_SECONDS, + ): + async_fire_time_changed(hass, now + timedelta(seconds=UNAVAILABLE_SECONDS)) + await hass.async_block_till_done() + assert coordinator.available is False + + cancel_coordinator() + + +async def test_no_updates_once_stopping(hass): + """Test updates are ignored once hass is stopping.""" + + @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", _async_generate_mock_data + ) + 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 + + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + _async_register_callback, + ): + cancel_coordinator = coordinator.async_setup() + + all_events = [] + + def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock an all listener.""" + all_events.append(data) + + coordinator.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 + + cancel_coordinator() + + +async def test_exception_from_update_method(hass, caplog): + """Test we handle exceptions from the update method.""" + 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", _async_generate_mock_data + ) + 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 + + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + _async_register_callback, + ): + cancel_coordinator = coordinator.async_setup() + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + assert coordinator.available is True + + # We should go unavailable once we get an exception + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + assert "Test exception" in caplog.text + assert coordinator.available is False + + # We should go available again once we get data again + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + assert coordinator.available is True + + cancel_coordinator() + + +async def test_bad_data_from_update_method(hass, caplog): + """Test we handle bad data from the update method.""" + 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", _async_generate_mock_data + ) + 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 + + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + _async_register_callback, + ): + cancel_coordinator = coordinator.async_setup() + + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + assert coordinator.available is True + + # We should go unavailable once we get bad data + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + assert "update_method" in caplog.text + assert "bad_data" in caplog.text + assert coordinator.available is False + + # We should go available again once we get good data again + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + assert coordinator.available is True + + cancel_coordinator() + + +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, + name="Temperature", + 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, + name="Humidity", + 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, + name="Battery", + 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, + name="Signal Strength", + unit_of_measurement=None, + last_reset=None, + native_unit_of_measurement="dBm", + state_class=None, + ), + }, + 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, + name="Temperature", + 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, + name="Humidity", + 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, + name="Battery", + 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, + name="Signal Strength", + 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, + name="Temperature", + 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, + name="Humidity", + 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, + name="Battery", + 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, + name="Signal Strength", + unit_of_measurement=None, + last_reset=None, + native_unit_of_measurement="dBm", + state_class=None, + ), + }, + 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): + """Test integration of PassiveBluetoothDataUpdateCoordinator with PassiveBluetoothCoordinatorEntity.""" + + 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", _async_generate_mock_data + ) + 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 + + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + _async_register_callback, + ): + coordinator.async_setup() + + mock_add_entities = MagicMock() + + coordinator.async_add_entities_listener( + PassiveBluetoothCoordinatorEntity, + 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: PassiveBluetoothCoordinatorEntity = 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): + """Test integration with PassiveBluetoothCoordinatorEntity with no device.""" + + @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", _async_generate_mock_data + ) + 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 + + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + _async_register_callback, + ): + coordinator.async_setup() + + mock_add_entities = MagicMock() + + coordinator.async_add_entities_listener( + PassiveBluetoothCoordinatorEntity, + 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: PassiveBluetoothCoordinatorEntity = 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): + """Test with a mock entity platform.""" + 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", _async_generate_mock_data + ) + 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 + + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", + _async_register_callback, + ): + coordinator.async_setup() + + coordinator.async_add_entities_listener( + PassiveBluetoothCoordinatorEntity, + 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.temperature") is not None + assert hass.states.get("test_domain.pressure") is not None