Add coordinator and entity for passive bluetooth devices (#75468)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Nick Koston 2022-07-20 15:54:37 -05:00 committed by GitHub
parent 01c105b89c
commit 6da25c733e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 1209 additions and 0 deletions

View File

@ -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()

View File

@ -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