mirror of
https://github.com/home-assistant/core.git
synced 2025-07-18 02:37:08 +00:00
Add coordinator and entity for passive bluetooth devices (#75468)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
01c105b89c
commit
6da25c733e
341
homeassistant/components/bluetooth/passive_update_coordinator.py
Normal file
341
homeassistant/components/bluetooth/passive_update_coordinator.py
Normal 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()
|
868
tests/components/bluetooth/test_passive_update_coordinator.py
Normal file
868
tests/components/bluetooth/test_passive_update_coordinator.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user