Refactor PassiveBluetoothDataUpdateCoordinator to support multiple platforms (#75642)

pull/75675/head
J. Nick Koston 2022-07-23 13:03:01 -05:00 committed by GitHub
parent 8300d5b89e
commit c5afaa2e6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 443 additions and 234 deletions

View File

@ -55,32 +55,11 @@ class PassiveBluetoothDataUpdate(Generic[_T]):
)
_PassiveBluetoothDataUpdateCoordinatorT = TypeVar(
"_PassiveBluetoothDataUpdateCoordinatorT",
bound="PassiveBluetoothDataUpdateCoordinator[Any]",
)
class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
class PassiveBluetoothDataUpdateCoordinator:
"""Passive bluetooth data update coordinator for bluetooth advertisements.
The coordinator is responsible for keeping track of the bluetooth data,
updating subscribers, and device availability.
The update_method must return a PassiveBluetoothDataUpdate object. Callers
are responsible for formatting the data returned from their parser into
the appropriate format.
The coordinator will call the update_method every time the bluetooth device
receives a new advertisement with the following signature:
update_method(service_info: BluetoothServiceInfo) -> PassiveBluetoothDataUpdate
As the size of each advertisement is limited, the update_method should
return a PassiveBluetoothDataUpdate object that contains only data that
should be updated. The coordinator will then dispatch subscribers based
on the data in the PassiveBluetoothDataUpdate object. The accumulated data
is available in the devices, entity_data, and entity_descriptions attributes.
The coordinator is responsible for dispatching the bluetooth data,
to each processor, and tracking devices.
"""
def __init__(
@ -88,45 +67,22 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
hass: HomeAssistant,
logger: logging.Logger,
address: str,
update_method: Callable[[BluetoothServiceInfo], PassiveBluetoothDataUpdate[_T]],
) -> None:
"""Initialize the coordinator."""
self.hass = hass
self.logger = logger
self.name: str | None = None
self.address = address
self._listeners: list[
Callable[[PassiveBluetoothDataUpdate[_T] | None], None]
] = []
self._entity_key_listeners: dict[
PassiveBluetoothEntityKey,
list[Callable[[PassiveBluetoothDataUpdate[_T] | None], None]],
] = {}
self.update_method = update_method
self.entity_names: dict[PassiveBluetoothEntityKey, str | None] = {}
self.entity_data: dict[PassiveBluetoothEntityKey, _T] = {}
self.entity_descriptions: dict[
PassiveBluetoothEntityKey, EntityDescription
] = {}
self.devices: dict[str | None, DeviceInfo] = {}
self.last_update_success = True
self._processors: list[PassiveBluetoothDataProcessor] = []
self._cancel_track_unavailable: CALLBACK_TYPE | None = None
self._cancel_bluetooth_advertisements: CALLBACK_TYPE | None = None
self.present = False
self._present = False
self.last_seen = 0.0
@property
def available(self) -> bool:
"""Return if the device is available."""
return self.present and self.last_update_success
@callback
def _async_handle_unavailable(self, _address: str) -> None:
"""Handle the device going unavailable."""
self.present = False
self.async_update_listeners(None)
return self._present
@callback
def _async_start(self) -> None:
@ -152,10 +108,121 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
self._cancel_track_unavailable()
self._cancel_track_unavailable = None
@callback
def async_register_processor(
self, processor: PassiveBluetoothDataProcessor
) -> Callable[[], None]:
"""Register a processor that subscribes to updates."""
processor.coordinator = self
@callback
def remove_processor() -> None:
"""Remove a processor."""
self._processors.remove(processor)
self._async_handle_processors_changed()
self._processors.append(processor)
self._async_handle_processors_changed()
return remove_processor
@callback
def _async_handle_processors_changed(self) -> None:
"""Handle processors changed."""
running = bool(self._cancel_bluetooth_advertisements)
if running and not self._processors:
self._async_stop()
elif not running and self._processors:
self._async_start()
@callback
def _async_handle_unavailable(self, _address: str) -> None:
"""Handle the device going unavailable."""
self._present = False
for processor in self._processors:
processor.async_handle_unavailable()
@callback
def _async_handle_bluetooth_event(
self,
service_info: BluetoothServiceInfo,
change: BluetoothChange,
) -> None:
"""Handle a Bluetooth event."""
self.last_seen = time.monotonic()
self.name = service_info.name
self._present = True
if self.hass.is_stopping:
return
for processor in self._processors:
processor.async_handle_bluetooth_event(service_info, change)
_PassiveBluetoothDataProcessorT = TypeVar(
"_PassiveBluetoothDataProcessorT",
bound="PassiveBluetoothDataProcessor[Any]",
)
class PassiveBluetoothDataProcessor(Generic[_T]):
"""Passive bluetooth data processor for bluetooth advertisements.
The processor is responsible for keeping track of the bluetooth data
and updating subscribers.
The update_method must return a PassiveBluetoothDataUpdate object. Callers
are responsible for formatting the data returned from their parser into
the appropriate format.
The processor will call the update_method every time the bluetooth device
receives a new advertisement data from the coordinator with the following signature:
update_method(service_info: BluetoothServiceInfo) -> PassiveBluetoothDataUpdate
As the size of each advertisement is limited, the update_method should
return a PassiveBluetoothDataUpdate object that contains only data that
should be updated. The coordinator will then dispatch subscribers based
on the data in the PassiveBluetoothDataUpdate object. The accumulated data
is available in the devices, entity_data, and entity_descriptions attributes.
"""
coordinator: PassiveBluetoothDataUpdateCoordinator
def __init__(
self,
update_method: Callable[[BluetoothServiceInfo], PassiveBluetoothDataUpdate[_T]],
) -> None:
"""Initialize the coordinator."""
self.coordinator: PassiveBluetoothDataUpdateCoordinator
self._listeners: list[
Callable[[PassiveBluetoothDataUpdate[_T] | None], None]
] = []
self._entity_key_listeners: dict[
PassiveBluetoothEntityKey,
list[Callable[[PassiveBluetoothDataUpdate[_T] | None], None]],
] = {}
self.update_method = update_method
self.entity_names: dict[PassiveBluetoothEntityKey, str | None] = {}
self.entity_data: dict[PassiveBluetoothEntityKey, _T] = {}
self.entity_descriptions: dict[
PassiveBluetoothEntityKey, EntityDescription
] = {}
self.devices: dict[str | None, DeviceInfo] = {}
self.last_update_success = True
@property
def available(self) -> bool:
"""Return if the device is available."""
return self.coordinator.available and self.last_update_success
@callback
def async_handle_unavailable(self) -> None:
"""Handle the device going unavailable."""
self.async_update_listeners(None)
@callback
def async_add_entities_listener(
self,
entity_class: type[PassiveBluetoothCoordinatorEntity],
entity_class: type[PassiveBluetoothProcessorEntity],
async_add_entites: AddEntitiesCallback,
) -> Callable[[], None]:
"""Add a listener for new entities."""
@ -168,7 +235,7 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
"""Listen for new entities."""
if data is None:
return
entities: list[PassiveBluetoothCoordinatorEntity] = []
entities: list[PassiveBluetoothProcessorEntity] = []
for entity_key, description in data.entity_descriptions.items():
if entity_key not in created:
entities.append(entity_class(self, entity_key, description))
@ -189,22 +256,10 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
def remove_listener() -> None:
"""Remove update listener."""
self._listeners.remove(update_callback)
self._async_handle_listeners_changed()
self._listeners.append(update_callback)
self._async_handle_listeners_changed()
return remove_listener
@callback
def _async_handle_listeners_changed(self) -> None:
"""Handle listeners changed."""
has_listeners = self._listeners or self._entity_key_listeners
running = bool(self._cancel_bluetooth_advertisements)
if running and not has_listeners:
self._async_stop()
elif not running and has_listeners:
self._async_start()
@callback
def async_add_entity_key_listener(
self,
@ -219,10 +274,8 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
self._entity_key_listeners[entity_key].remove(update_callback)
if not self._entity_key_listeners[entity_key]:
del self._entity_key_listeners[entity_key]
self._async_handle_listeners_changed()
self._entity_key_listeners.setdefault(entity_key, []).append(update_callback)
self._async_handle_listeners_changed()
return remove_listener
@callback
@ -240,36 +293,32 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
update_callback(data)
@callback
def _async_handle_bluetooth_event(
def async_handle_bluetooth_event(
self,
service_info: BluetoothServiceInfo,
change: BluetoothChange,
) -> None:
"""Handle a Bluetooth event."""
self.last_seen = time.monotonic()
self.name = service_info.name
self.present = True
if self.hass.is_stopping:
return
try:
new_data = self.update_method(service_info)
except Exception as err: # pylint: disable=broad-except
self.last_update_success = False
self.logger.exception(
"Unexpected error updating %s data: %s", self.name, err
self.coordinator.logger.exception(
"Unexpected error updating %s data: %s", self.coordinator.name, err
)
return
if not isinstance(new_data, PassiveBluetoothDataUpdate):
self.last_update_success = False # type: ignore[unreachable]
raise ValueError(
f"The update_method for {self.name} returned {new_data} instead of a PassiveBluetoothDataUpdate"
f"The update_method for {self.coordinator.name} returned {new_data} instead of a PassiveBluetoothDataUpdate"
)
if not self.last_update_success:
self.last_update_success = True
self.logger.info("Processing %s data recovered", self.name)
self.coordinator.logger.info(
"Processing %s data recovered", self.coordinator.name
)
self.devices.update(new_data.devices)
self.entity_descriptions.update(new_data.entity_descriptions)
@ -278,29 +327,27 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
self.async_update_listeners(new_data)
class PassiveBluetoothCoordinatorEntity(
Entity, Generic[_PassiveBluetoothDataUpdateCoordinatorT]
):
"""A class for entities using PassiveBluetoothDataUpdateCoordinator."""
class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]):
"""A class for entities using PassiveBluetoothDataProcessor."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
coordinator: _PassiveBluetoothDataUpdateCoordinatorT,
processor: _PassiveBluetoothDataProcessorT,
entity_key: PassiveBluetoothEntityKey,
description: EntityDescription,
context: Any = None,
) -> None:
"""Create the entity with a PassiveBluetoothDataUpdateCoordinator."""
"""Create the entity with a PassiveBluetoothDataProcessor."""
self.entity_description = description
self.entity_key = entity_key
self.coordinator = coordinator
self.coordinator_context = context
address = coordinator.address
self.processor = processor
self.processor_context = context
address = processor.coordinator.address
device_id = entity_key.device_id
devices = coordinator.devices
devices = processor.devices
key = entity_key.key
if device_id in devices:
base_device_info = devices[device_id]
@ -317,26 +364,26 @@ class PassiveBluetoothCoordinatorEntity(
)
self._attr_unique_id = f"{address}-{key}"
if ATTR_NAME not in self._attr_device_info:
self._attr_device_info[ATTR_NAME] = self.coordinator.name
self._attr_name = coordinator.entity_names.get(entity_key)
self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name
self._attr_name = processor.entity_names.get(entity_key)
@property
def available(self) -> bool:
"""Return if entity is available."""
return self.coordinator.available
return self.processor.available
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.async_add_entity_key_listener(
self._handle_coordinator_update, self.entity_key
self.processor.async_add_entity_key_listener(
self._handle_processor_update, self.entity_key
)
)
@callback
def _handle_coordinator_update(
def _handle_processor_update(
self, new_data: PassiveBluetoothDataUpdate | None
) -> None:
"""Handle updated data from the coordinator."""
"""Handle updated data from the processor."""
self.async_write_ha_state()

View File

@ -3,19 +3,14 @@ from __future__ import annotations
import logging
from inkbird_ble import INKBIRDBluetoothDeviceData
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .sensor import sensor_update_to_bluetooth_data_update
PLATFORMS: list[Platform] = [Platform.SENSOR]
@ -26,22 +21,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up INKBIRD BLE device from a config entry."""
address = entry.unique_id
assert address is not None
data = INKBIRDBluetoothDeviceData()
@callback
def _async_update_data(
service_info: BluetoothServiceInfo,
) -> PassiveBluetoothDataUpdate:
"""Update data from INKBIRD Bluetooth."""
return sensor_update_to_bluetooth_data_update(data.update(service_info))
hass.data.setdefault(DOMAIN, {})[
entry.entry_id
] = PassiveBluetoothDataUpdateCoordinator(
hass,
_LOGGER,
update_method=_async_update_data,
address=address,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@ -3,14 +3,22 @@ from __future__ import annotations
from typing import Optional, Union
from inkbird_ble import DeviceClass, DeviceKey, SensorDeviceInfo, SensorUpdate, Units
from inkbird_ble import (
DeviceClass,
DeviceKey,
INKBIRDBluetoothDeviceData,
SensorDeviceInfo,
SensorUpdate,
Units,
)
from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity,
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator,
PassiveBluetoothEntityKey,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -121,16 +129,23 @@ async def async_setup_entry(
coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
]
data = INKBIRDBluetoothDeviceData()
processor = PassiveBluetoothDataProcessor(
lambda service_info: sensor_update_to_bluetooth_data_update(
data.update(service_info)
)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
entry.async_on_unload(
coordinator.async_add_entities_listener(
processor.async_add_entities_listener(
INKBIRDBluetoothSensorEntity, async_add_entities
)
)
class INKBIRDBluetoothSensorEntity(
PassiveBluetoothCoordinatorEntity[
PassiveBluetoothDataUpdateCoordinator[Optional[Union[float, int]]]
PassiveBluetoothProcessorEntity[
PassiveBluetoothDataProcessor[Optional[Union[float, int]]]
],
SensorEntity,
):
@ -139,4 +154,4 @@ class INKBIRDBluetoothSensorEntity(
@property
def native_value(self) -> int | float | None:
"""Return the native value."""
return self.coordinator.entity_data.get(self.entity_key)
return self.processor.entity_data.get(self.entity_key)

View File

@ -3,19 +3,14 @@ from __future__ import annotations
import logging
from sensorpush_ble import SensorPushBluetoothDeviceData
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .sensor import sensor_update_to_bluetooth_data_update
PLATFORMS: list[Platform] = [Platform.SENSOR]
@ -26,22 +21,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SensorPush BLE device from a config entry."""
address = entry.unique_id
assert address is not None
data = SensorPushBluetoothDeviceData()
@callback
def _async_update_data(
service_info: BluetoothServiceInfo,
) -> PassiveBluetoothDataUpdate:
"""Update data from SensorPush Bluetooth."""
return sensor_update_to_bluetooth_data_update(data.update(service_info))
hass.data.setdefault(DOMAIN, {})[
entry.entry_id
] = PassiveBluetoothDataUpdateCoordinator(
hass,
_LOGGER,
update_method=_async_update_data,
address=address,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@ -3,14 +3,22 @@ from __future__ import annotations
from typing import Optional, Union
from sensorpush_ble import DeviceClass, DeviceKey, SensorDeviceInfo, SensorUpdate, Units
from sensorpush_ble import (
DeviceClass,
DeviceKey,
SensorDeviceInfo,
SensorPushBluetoothDeviceData,
SensorUpdate,
Units,
)
from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity,
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator,
PassiveBluetoothEntityKey,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -122,16 +130,23 @@ async def async_setup_entry(
coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
]
data = SensorPushBluetoothDeviceData()
processor = PassiveBluetoothDataProcessor(
lambda service_info: sensor_update_to_bluetooth_data_update(
data.update(service_info)
)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
entry.async_on_unload(
coordinator.async_add_entities_listener(
processor.async_add_entities_listener(
SensorPushBluetoothSensorEntity, async_add_entities
)
)
class SensorPushBluetoothSensorEntity(
PassiveBluetoothCoordinatorEntity[
PassiveBluetoothDataUpdateCoordinator[Optional[Union[float, int]]]
PassiveBluetoothProcessorEntity[
PassiveBluetoothDataProcessor[Optional[Union[float, int]]]
],
SensorEntity,
):
@ -140,4 +155,4 @@ class SensorPushBluetoothSensorEntity(
@property
def native_value(self) -> int | float | None:
"""Return the native value."""
return self.coordinator.entity_data.get(self.entity_key)
return self.processor.entity_data.get(self.entity_key)

View File

@ -3,19 +3,14 @@ from __future__ import annotations
import logging
from xiaomi_ble import XiaomiBluetoothDeviceData
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .sensor import sensor_update_to_bluetooth_data_update
PLATFORMS: list[Platform] = [Platform.SENSOR]
@ -26,22 +21,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Xiaomi BLE device from a config entry."""
address = entry.unique_id
assert address is not None
data = XiaomiBluetoothDeviceData()
@callback
def _async_update_data(
service_info: BluetoothServiceInfo,
) -> PassiveBluetoothDataUpdate:
"""Update data from Xiaomi Bluetooth."""
return sensor_update_to_bluetooth_data_update(data.update(service_info))
hass.data.setdefault(DOMAIN, {})[
entry.entry_id
] = PassiveBluetoothDataUpdateCoordinator(
hass,
_LOGGER,
update_method=_async_update_data,
address=address,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@ -3,14 +3,22 @@ from __future__ import annotations
from typing import Optional, Union
from xiaomi_ble import DeviceClass, DeviceKey, SensorDeviceInfo, SensorUpdate, Units
from xiaomi_ble import (
DeviceClass,
DeviceKey,
SensorDeviceInfo,
SensorUpdate,
Units,
XiaomiBluetoothDeviceData,
)
from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity,
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator,
PassiveBluetoothEntityKey,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -150,16 +158,23 @@ async def async_setup_entry(
coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
]
data = XiaomiBluetoothDeviceData()
processor = PassiveBluetoothDataProcessor(
lambda service_info: sensor_update_to_bluetooth_data_update(
data.update(service_info)
)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
entry.async_on_unload(
coordinator.async_add_entities_listener(
processor.async_add_entities_listener(
XiaomiBluetoothSensorEntity, async_add_entities
)
)
class XiaomiBluetoothSensorEntity(
PassiveBluetoothCoordinatorEntity[
PassiveBluetoothDataUpdateCoordinator[Optional[Union[float, int]]]
PassiveBluetoothProcessorEntity[
PassiveBluetoothDataProcessor[Optional[Union[float, int]]]
],
SensorEntity,
):
@ -168,4 +183,4 @@ class XiaomiBluetoothSensorEntity(
@property
def native_value(self) -> int | float | None:
"""Return the native value."""
return self.coordinator.entity_data.get(self.entity_key)
return self.processor.entity_data.get(self.entity_key)

View File

@ -8,16 +8,21 @@ from unittest.mock import MagicMock, patch
from home_assistant_bluetooth import BluetoothServiceInfo
import pytest
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntityDescription,
)
from homeassistant.components.bluetooth import (
DOMAIN,
UNAVAILABLE_TRACK_SECONDS,
BluetoothChange,
)
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity,
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator,
PassiveBluetoothEntityKey,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription
from homeassistant.const import TEMP_CELSIUS
@ -85,7 +90,7 @@ async def test_basic_usage(hass, mock_bleak_scanner_start):
return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator(
hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data
hass, _LOGGER, "aa:bb:cc:dd:ee:ff"
)
assert coordinator.available is False # no data yet
saved_callback = None
@ -95,10 +100,13 @@ async def test_basic_usage(hass, mock_bleak_scanner_start):
saved_callback = _callback
return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback,
):
unregister_processor = coordinator.async_register_processor(processor)
entity_key = PassiveBluetoothEntityKey("temperature", None)
entity_key_events = []
@ -110,22 +118,20 @@ async def test_basic_usage(hass, mock_bleak_scanner_start):
"""Mock entity key listener."""
entity_key_events.append(data)
cancel_async_add_entity_key_listener = (
coordinator.async_add_entity_key_listener(
cancel_async_add_entity_key_listener = processor.async_add_entity_key_listener(
_async_entity_key_listener,
entity_key,
)
)
def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None:
"""Mock an all listener."""
all_events.append(data)
cancel_listener = coordinator.async_add_listener(
cancel_listener = processor.async_add_listener(
_all_listener,
)
cancel_async_add_entities_listener = coordinator.async_add_entities_listener(
cancel_async_add_entities_listener = processor.async_add_entities_listener(
mock_entity,
mock_add_entities,
)
@ -164,6 +170,8 @@ async def test_basic_usage(hass, mock_bleak_scanner_start):
assert len(mock_entity.mock_calls) == 2
assert coordinator.available is True
unregister_processor()
async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
"""Test that the coordinator is unavailable after no data for a while."""
@ -182,7 +190,7 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator(
hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data
hass, _LOGGER, "aa:bb:cc:dd:ee:ff"
)
assert coordinator.available is False # no data yet
saved_callback = None
@ -192,23 +200,27 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
saved_callback = _callback
return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback,
):
unregister_processor = coordinator.async_register_processor(processor)
mock_entity = MagicMock()
mock_add_entities = MagicMock()
coordinator.async_add_entities_listener(
processor.async_add_entities_listener(
mock_entity,
mock_add_entities,
)
assert coordinator.available is False
assert processor.available is False
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert len(mock_add_entities.mock_calls) == 1
assert coordinator.available is True
assert processor.available is True
scanner = _get_underlying_scanner()
with patch(
@ -224,10 +236,12 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
)
await hass.async_block_till_done()
assert coordinator.available is False
assert processor.available is False
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert len(mock_add_entities.mock_calls) == 1
assert coordinator.available is True
assert processor.available is True
with patch(
"homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices",
@ -242,6 +256,9 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
)
await hass.async_block_till_done()
assert coordinator.available is False
assert processor.available is False
unregister_processor()
async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start):
@ -256,7 +273,7 @@ async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start):
return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator(
hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data
hass, _LOGGER, "aa:bb:cc:dd:ee:ff"
)
assert coordinator.available is False # no data yet
saved_callback = None
@ -266,10 +283,13 @@ async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start):
saved_callback = _callback
return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback,
):
unregister_processor = coordinator.async_register_processor(processor)
all_events = []
@ -277,7 +297,7 @@ async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start):
"""Mock an all listener."""
all_events.append(data)
coordinator.async_add_listener(
processor.async_add_listener(
_all_listener,
)
@ -289,6 +309,7 @@ async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start):
# We should stop processing events once hass is stopping
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert len(all_events) == 1
unregister_processor()
async def test_exception_from_update_method(hass, caplog, mock_bleak_scanner_start):
@ -309,7 +330,7 @@ async def test_exception_from_update_method(hass, caplog, mock_bleak_scanner_sta
return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator(
hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data
hass, _LOGGER, "aa:bb:cc:dd:ee:ff"
)
assert coordinator.available is False # no data yet
saved_callback = None
@ -319,23 +340,27 @@ async def test_exception_from_update_method(hass, caplog, mock_bleak_scanner_sta
saved_callback = _callback
return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback,
):
coordinator.async_add_listener(MagicMock())
unregister_processor = coordinator.async_register_processor(processor)
processor.async_add_listener(MagicMock())
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert coordinator.available is True
assert processor.available is True
# We should go unavailable once we get an exception
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert "Test exception" in caplog.text
assert coordinator.available is False
assert processor.available is False
# We should go available again once we get data again
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert coordinator.available is True
assert processor.available is True
unregister_processor()
async def test_bad_data_from_update_method(hass, mock_bleak_scanner_start):
@ -356,7 +381,7 @@ async def test_bad_data_from_update_method(hass, mock_bleak_scanner_start):
return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator(
hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data
hass, _LOGGER, "aa:bb:cc:dd:ee:ff"
)
assert coordinator.available is False # no data yet
saved_callback = None
@ -366,24 +391,28 @@ async def test_bad_data_from_update_method(hass, mock_bleak_scanner_start):
saved_callback = _callback
return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback,
):
coordinator.async_add_listener(MagicMock())
unregister_processor = coordinator.async_register_processor(processor)
processor.async_add_listener(MagicMock())
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert coordinator.available is True
assert processor.available is True
# We should go unavailable once we get bad data
with pytest.raises(ValueError):
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert coordinator.available is False
assert processor.available is False
# We should go available again once we get good data again
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert coordinator.available is True
assert processor.available is True
unregister_processor()
GOVEE_B5178_REMOTE_SERVICE_INFO = BluetoothServiceInfo(
@ -692,7 +721,7 @@ async def test_integration_with_entity(hass, mock_bleak_scanner_start):
return GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator(
hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data
hass, _LOGGER, "aa:bb:cc:dd:ee:ff"
)
assert coordinator.available is False # no data yet
saved_callback = None
@ -702,16 +731,19 @@ async def test_integration_with_entity(hass, mock_bleak_scanner_start):
saved_callback = _callback
return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback,
):
coordinator.async_add_listener(MagicMock())
coordinator.async_register_processor(processor)
processor.async_add_listener(MagicMock())
mock_add_entities = MagicMock()
coordinator.async_add_entities_listener(
PassiveBluetoothCoordinatorEntity,
processor.async_add_entities_listener(
PassiveBluetoothProcessorEntity,
mock_add_entities,
)
@ -736,7 +768,7 @@ async def test_integration_with_entity(hass, mock_bleak_scanner_start):
*mock_add_entities.mock_calls[1][1][0],
]
entity_one: PassiveBluetoothCoordinatorEntity = entities[0]
entity_one: PassiveBluetoothProcessorEntity = entities[0]
entity_one.hass = hass
assert entity_one.available is True
assert entity_one.unique_id == "aa:bb:cc:dd:ee:ff-temperature-remote"
@ -797,7 +829,7 @@ async def test_integration_with_entity_without_a_device(hass, mock_bleak_scanner
return NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator(
hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data
hass, _LOGGER, "aa:bb:cc:dd:ee:ff"
)
assert coordinator.available is False # no data yet
saved_callback = None
@ -807,15 +839,17 @@ async def test_integration_with_entity_without_a_device(hass, mock_bleak_scanner
saved_callback = _callback
return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback,
):
coordinator.async_register_processor(processor)
mock_add_entities = MagicMock()
coordinator.async_add_entities_listener(
PassiveBluetoothCoordinatorEntity,
processor.async_add_entities_listener(
PassiveBluetoothProcessorEntity,
mock_add_entities,
)
@ -828,7 +862,7 @@ async def test_integration_with_entity_without_a_device(hass, mock_bleak_scanner
assert len(mock_add_entities.mock_calls) == 1
entities = mock_add_entities.mock_calls[0][1][0]
entity_one: PassiveBluetoothCoordinatorEntity = entities[0]
entity_one: PassiveBluetoothProcessorEntity = entities[0]
entity_one.hass = hass
assert entity_one.available is True
assert entity_one.unique_id == "aa:bb:cc:dd:ee:ff-temperature"
@ -857,7 +891,7 @@ async def test_passive_bluetooth_entity_with_entity_platform(
return NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator(
hass, _LOGGER, "aa:bb:cc:dd:ee:ff", _async_generate_mock_data
hass, _LOGGER, "aa:bb:cc:dd:ee:ff"
)
assert coordinator.available is False # no data yet
saved_callback = None
@ -867,18 +901,19 @@ async def test_passive_bluetooth_entity_with_entity_platform(
saved_callback = _callback
return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback,
):
coordinator.async_register_processor(processor)
coordinator.async_add_entities_listener(
PassiveBluetoothCoordinatorEntity,
processor.async_add_entities_listener(
PassiveBluetoothProcessorEntity,
lambda entities: hass.async_create_task(
entity_platform.async_add_entities(entities)
),
)
saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
await hass.async_block_till_done()
saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
@ -891,3 +926,133 @@ async def test_passive_bluetooth_entity_with_entity_platform(
hass.states.get("test_domain.test_platform_aa_bb_cc_dd_ee_ff_pressure")
is not None
)
SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
devices={
None: DeviceInfo(
name="Test Device", model="Test Model", manufacturer="Test Manufacturer"
),
},
entity_data={
PassiveBluetoothEntityKey("pressure", None): 1234,
},
entity_names={
PassiveBluetoothEntityKey("pressure", None): "Pressure",
},
entity_descriptions={
PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription(
key="pressure",
native_unit_of_measurement="hPa",
device_class=SensorDeviceClass.PRESSURE,
),
},
)
BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
devices={
None: DeviceInfo(
name="Test Device", model="Test Model", manufacturer="Test Manufacturer"
),
},
entity_data={
PassiveBluetoothEntityKey("motion", None): True,
},
entity_names={
PassiveBluetoothEntityKey("motion", None): "Motion",
},
entity_descriptions={
PassiveBluetoothEntityKey("motion", None): BinarySensorEntityDescription(
key="motion",
device_class=BinarySensorDeviceClass.MOTION,
),
},
)
async def test_integration_multiple_entity_platforms(hass, mock_bleak_scanner_start):
"""Test integration of PassiveBluetoothDataUpdateCoordinator with multiple platforms."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
coordinator = PassiveBluetoothDataUpdateCoordinator(
hass, _LOGGER, "aa:bb:cc:dd:ee:ff"
)
assert coordinator.available is False # no data yet
saved_callback = None
def _async_register_callback(_hass, _callback, _matcher):
nonlocal saved_callback
saved_callback = _callback
return lambda: None
binary_sensor_processor = PassiveBluetoothDataProcessor(
lambda service_info: BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE
)
sesnor_processor = PassiveBluetoothDataProcessor(
lambda service_info: SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE
)
with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback,
):
coordinator.async_register_processor(binary_sensor_processor)
coordinator.async_register_processor(sesnor_processor)
binary_sensor_processor.async_add_listener(MagicMock())
sesnor_processor.async_add_listener(MagicMock())
mock_add_sensor_entities = MagicMock()
mock_add_binary_sensor_entities = MagicMock()
sesnor_processor.async_add_entities_listener(
PassiveBluetoothProcessorEntity,
mock_add_sensor_entities,
)
binary_sensor_processor.async_add_entities_listener(
PassiveBluetoothProcessorEntity,
mock_add_binary_sensor_entities,
)
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
# First call with just the remote sensor entities results in them being added
assert len(mock_add_binary_sensor_entities.mock_calls) == 1
assert len(mock_add_sensor_entities.mock_calls) == 1
binary_sesnor_entities = [
*mock_add_binary_sensor_entities.mock_calls[0][1][0],
]
sesnor_entities = [
*mock_add_sensor_entities.mock_calls[0][1][0],
]
sensor_entity_one: PassiveBluetoothProcessorEntity = sesnor_entities[0]
sensor_entity_one.hass = hass
assert sensor_entity_one.available is True
assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure"
assert sensor_entity_one.device_info == {
"identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")},
"manufacturer": "Test Manufacturer",
"model": "Test Model",
"name": "Test Device",
}
assert sensor_entity_one.entity_key == PassiveBluetoothEntityKey(
key="pressure", device_id=None
)
binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sesnor_entities[
0
]
binary_sensor_entity_one.hass = hass
assert binary_sensor_entity_one.available is True
assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion"
assert binary_sensor_entity_one.device_info == {
"identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")},
"manufacturer": "Test Manufacturer",
"model": "Test Model",
"name": "Test Device",
}
assert binary_sensor_entity_one.entity_key == PassiveBluetoothEntityKey(
key="motion", device_id=None
)