Refactor PassiveBluetoothDataUpdateCoordinator to support multiple platforms (#75642)

This commit is contained in:
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( class PassiveBluetoothDataUpdateCoordinator:
"_PassiveBluetoothDataUpdateCoordinatorT",
bound="PassiveBluetoothDataUpdateCoordinator[Any]",
)
class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
"""Passive bluetooth data update coordinator for bluetooth advertisements. """Passive bluetooth data update coordinator for bluetooth advertisements.
The coordinator is responsible for keeping track of the bluetooth data, The coordinator is responsible for dispatching the bluetooth data,
updating subscribers, and device availability. to each processor, and tracking devices.
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__( def __init__(
@ -88,45 +67,22 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
hass: HomeAssistant, hass: HomeAssistant,
logger: logging.Logger, logger: logging.Logger,
address: str, address: str,
update_method: Callable[[BluetoothServiceInfo], PassiveBluetoothDataUpdate[_T]],
) -> None: ) -> None:
"""Initialize the coordinator.""" """Initialize the coordinator."""
self.hass = hass self.hass = hass
self.logger = logger self.logger = logger
self.name: str | None = None self.name: str | None = None
self.address = address self.address = address
self._listeners: list[ self._processors: list[PassiveBluetoothDataProcessor] = []
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._cancel_track_unavailable: CALLBACK_TYPE | None = None self._cancel_track_unavailable: CALLBACK_TYPE | None = None
self._cancel_bluetooth_advertisements: CALLBACK_TYPE | None = None self._cancel_bluetooth_advertisements: CALLBACK_TYPE | None = None
self.present = False self._present = False
self.last_seen = 0.0 self.last_seen = 0.0
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if the device is available.""" """Return if the device is available."""
return self.present and self.last_update_success return self._present
@callback
def _async_handle_unavailable(self, _address: str) -> None:
"""Handle the device going unavailable."""
self.present = False
self.async_update_listeners(None)
@callback @callback
def _async_start(self) -> None: def _async_start(self) -> None:
@ -152,10 +108,121 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
self._cancel_track_unavailable() self._cancel_track_unavailable()
self._cancel_track_unavailable = None 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 @callback
def async_add_entities_listener( def async_add_entities_listener(
self, self,
entity_class: type[PassiveBluetoothCoordinatorEntity], entity_class: type[PassiveBluetoothProcessorEntity],
async_add_entites: AddEntitiesCallback, async_add_entites: AddEntitiesCallback,
) -> Callable[[], None]: ) -> Callable[[], None]:
"""Add a listener for new entities.""" """Add a listener for new entities."""
@ -168,7 +235,7 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
"""Listen for new entities.""" """Listen for new entities."""
if data is None: if data is None:
return return
entities: list[PassiveBluetoothCoordinatorEntity] = [] entities: list[PassiveBluetoothProcessorEntity] = []
for entity_key, description in data.entity_descriptions.items(): for entity_key, description in data.entity_descriptions.items():
if entity_key not in created: if entity_key not in created:
entities.append(entity_class(self, entity_key, description)) entities.append(entity_class(self, entity_key, description))
@ -189,22 +256,10 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
def remove_listener() -> None: def remove_listener() -> None:
"""Remove update listener.""" """Remove update listener."""
self._listeners.remove(update_callback) self._listeners.remove(update_callback)
self._async_handle_listeners_changed()
self._listeners.append(update_callback) self._listeners.append(update_callback)
self._async_handle_listeners_changed()
return remove_listener 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 @callback
def async_add_entity_key_listener( def async_add_entity_key_listener(
self, self,
@ -219,10 +274,8 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
self._entity_key_listeners[entity_key].remove(update_callback) self._entity_key_listeners[entity_key].remove(update_callback)
if not self._entity_key_listeners[entity_key]: if not self._entity_key_listeners[entity_key]:
del 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._entity_key_listeners.setdefault(entity_key, []).append(update_callback)
self._async_handle_listeners_changed()
return remove_listener return remove_listener
@callback @callback
@ -240,36 +293,32 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
update_callback(data) update_callback(data)
@callback @callback
def _async_handle_bluetooth_event( def async_handle_bluetooth_event(
self, self,
service_info: BluetoothServiceInfo, service_info: BluetoothServiceInfo,
change: BluetoothChange, change: BluetoothChange,
) -> None: ) -> None:
"""Handle a Bluetooth event.""" """Handle a Bluetooth event."""
self.last_seen = time.monotonic()
self.name = service_info.name
self.present = True
if self.hass.is_stopping:
return
try: try:
new_data = self.update_method(service_info) new_data = self.update_method(service_info)
except Exception as err: # pylint: disable=broad-except except Exception as err: # pylint: disable=broad-except
self.last_update_success = False self.last_update_success = False
self.logger.exception( self.coordinator.logger.exception(
"Unexpected error updating %s data: %s", self.name, err "Unexpected error updating %s data: %s", self.coordinator.name, err
) )
return return
if not isinstance(new_data, PassiveBluetoothDataUpdate): if not isinstance(new_data, PassiveBluetoothDataUpdate):
self.last_update_success = False # type: ignore[unreachable] self.last_update_success = False # type: ignore[unreachable]
raise ValueError( 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: if not self.last_update_success:
self.last_update_success = True 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.devices.update(new_data.devices)
self.entity_descriptions.update(new_data.entity_descriptions) self.entity_descriptions.update(new_data.entity_descriptions)
@ -278,29 +327,27 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
self.async_update_listeners(new_data) self.async_update_listeners(new_data)
class PassiveBluetoothCoordinatorEntity( class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]):
Entity, Generic[_PassiveBluetoothDataUpdateCoordinatorT] """A class for entities using PassiveBluetoothDataProcessor."""
):
"""A class for entities using PassiveBluetoothDataUpdateCoordinator."""
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_should_poll = False _attr_should_poll = False
def __init__( def __init__(
self, self,
coordinator: _PassiveBluetoothDataUpdateCoordinatorT, processor: _PassiveBluetoothDataProcessorT,
entity_key: PassiveBluetoothEntityKey, entity_key: PassiveBluetoothEntityKey,
description: EntityDescription, description: EntityDescription,
context: Any = None, context: Any = None,
) -> None: ) -> None:
"""Create the entity with a PassiveBluetoothDataUpdateCoordinator.""" """Create the entity with a PassiveBluetoothDataProcessor."""
self.entity_description = description self.entity_description = description
self.entity_key = entity_key self.entity_key = entity_key
self.coordinator = coordinator self.processor = processor
self.coordinator_context = context self.processor_context = context
address = coordinator.address address = processor.coordinator.address
device_id = entity_key.device_id device_id = entity_key.device_id
devices = coordinator.devices devices = processor.devices
key = entity_key.key key = entity_key.key
if device_id in devices: if device_id in devices:
base_device_info = devices[device_id] base_device_info = devices[device_id]
@ -317,26 +364,26 @@ class PassiveBluetoothCoordinatorEntity(
) )
self._attr_unique_id = f"{address}-{key}" self._attr_unique_id = f"{address}-{key}"
if ATTR_NAME not in self._attr_device_info: if ATTR_NAME not in self._attr_device_info:
self._attr_device_info[ATTR_NAME] = self.coordinator.name self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name
self._attr_name = coordinator.entity_names.get(entity_key) self._attr_name = processor.entity_names.get(entity_key)
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if entity is available.""" """Return if entity is available."""
return self.coordinator.available return self.processor.available
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""When entity is added to hass.""" """When entity is added to hass."""
await super().async_added_to_hass() await super().async_added_to_hass()
self.async_on_remove( self.async_on_remove(
self.coordinator.async_add_entity_key_listener( self.processor.async_add_entity_key_listener(
self._handle_coordinator_update, self.entity_key self._handle_processor_update, self.entity_key
) )
) )
@callback @callback
def _handle_coordinator_update( def _handle_processor_update(
self, new_data: PassiveBluetoothDataUpdate | None self, new_data: PassiveBluetoothDataUpdate | None
) -> None: ) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the processor."""
self.async_write_ha_state() self.async_write_ha_state()

View file

@ -3,19 +3,14 @@ from __future__ import annotations
import logging import logging
from inkbird_ble import INKBIRDBluetoothDeviceData
from homeassistant.components.bluetooth.passive_update_coordinator import ( from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothDataUpdateCoordinator,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from .const import DOMAIN from .const import DOMAIN
from .sensor import sensor_update_to_bluetooth_data_update
PLATFORMS: list[Platform] = [Platform.SENSOR] 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.""" """Set up INKBIRD BLE device from a config entry."""
address = entry.unique_id address = entry.unique_id
assert address is not None 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, {})[ hass.data.setdefault(DOMAIN, {})[
entry.entry_id entry.entry_id
] = PassiveBluetoothDataUpdateCoordinator( ] = PassiveBluetoothDataUpdateCoordinator(
hass, hass,
_LOGGER, _LOGGER,
update_method=_async_update_data,
address=address, address=address,
) )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 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 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 import config_entries
from homeassistant.components.bluetooth.passive_update_coordinator import ( from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity, PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate, PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothDataUpdateCoordinator,
PassiveBluetoothEntityKey, PassiveBluetoothEntityKey,
PassiveBluetoothProcessorEntity,
) )
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -121,16 +129,23 @@ async def async_setup_entry(
coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][ coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id 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( entry.async_on_unload(
coordinator.async_add_entities_listener( processor.async_add_entities_listener(
INKBIRDBluetoothSensorEntity, async_add_entities INKBIRDBluetoothSensorEntity, async_add_entities
) )
) )
class INKBIRDBluetoothSensorEntity( class INKBIRDBluetoothSensorEntity(
PassiveBluetoothCoordinatorEntity[ PassiveBluetoothProcessorEntity[
PassiveBluetoothDataUpdateCoordinator[Optional[Union[float, int]]] PassiveBluetoothDataProcessor[Optional[Union[float, int]]]
], ],
SensorEntity, SensorEntity,
): ):
@ -139,4 +154,4 @@ class INKBIRDBluetoothSensorEntity(
@property @property
def native_value(self) -> int | float | None: def native_value(self) -> int | float | None:
"""Return the native value.""" """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 import logging
from sensorpush_ble import SensorPushBluetoothDeviceData
from homeassistant.components.bluetooth.passive_update_coordinator import ( from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothDataUpdateCoordinator,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from .const import DOMAIN from .const import DOMAIN
from .sensor import sensor_update_to_bluetooth_data_update
PLATFORMS: list[Platform] = [Platform.SENSOR] 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.""" """Set up SensorPush BLE device from a config entry."""
address = entry.unique_id address = entry.unique_id
assert address is not None 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, {})[ hass.data.setdefault(DOMAIN, {})[
entry.entry_id entry.entry_id
] = PassiveBluetoothDataUpdateCoordinator( ] = PassiveBluetoothDataUpdateCoordinator(
hass, hass,
_LOGGER, _LOGGER,
update_method=_async_update_data,
address=address, address=address,
) )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 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 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 import config_entries
from homeassistant.components.bluetooth.passive_update_coordinator import ( from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity, PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate, PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothDataUpdateCoordinator,
PassiveBluetoothEntityKey, PassiveBluetoothEntityKey,
PassiveBluetoothProcessorEntity,
) )
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -122,16 +130,23 @@ async def async_setup_entry(
coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][ coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id 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( entry.async_on_unload(
coordinator.async_add_entities_listener( processor.async_add_entities_listener(
SensorPushBluetoothSensorEntity, async_add_entities SensorPushBluetoothSensorEntity, async_add_entities
) )
) )
class SensorPushBluetoothSensorEntity( class SensorPushBluetoothSensorEntity(
PassiveBluetoothCoordinatorEntity[ PassiveBluetoothProcessorEntity[
PassiveBluetoothDataUpdateCoordinator[Optional[Union[float, int]]] PassiveBluetoothDataProcessor[Optional[Union[float, int]]]
], ],
SensorEntity, SensorEntity,
): ):
@ -140,4 +155,4 @@ class SensorPushBluetoothSensorEntity(
@property @property
def native_value(self) -> int | float | None: def native_value(self) -> int | float | None:
"""Return the native value.""" """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 import logging
from xiaomi_ble import XiaomiBluetoothDeviceData
from homeassistant.components.bluetooth.passive_update_coordinator import ( from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothDataUpdateCoordinator,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from .const import DOMAIN from .const import DOMAIN
from .sensor import sensor_update_to_bluetooth_data_update
PLATFORMS: list[Platform] = [Platform.SENSOR] 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.""" """Set up Xiaomi BLE device from a config entry."""
address = entry.unique_id address = entry.unique_id
assert address is not None 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, {})[ hass.data.setdefault(DOMAIN, {})[
entry.entry_id entry.entry_id
] = PassiveBluetoothDataUpdateCoordinator( ] = PassiveBluetoothDataUpdateCoordinator(
hass, hass,
_LOGGER, _LOGGER,
update_method=_async_update_data,
address=address, address=address,
) )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 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 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 import config_entries
from homeassistant.components.bluetooth.passive_update_coordinator import ( from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity, PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate, PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothDataUpdateCoordinator,
PassiveBluetoothEntityKey, PassiveBluetoothEntityKey,
PassiveBluetoothProcessorEntity,
) )
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -150,16 +158,23 @@ async def async_setup_entry(
coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][ coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id 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( entry.async_on_unload(
coordinator.async_add_entities_listener( processor.async_add_entities_listener(
XiaomiBluetoothSensorEntity, async_add_entities XiaomiBluetoothSensorEntity, async_add_entities
) )
) )
class XiaomiBluetoothSensorEntity( class XiaomiBluetoothSensorEntity(
PassiveBluetoothCoordinatorEntity[ PassiveBluetoothProcessorEntity[
PassiveBluetoothDataUpdateCoordinator[Optional[Union[float, int]]] PassiveBluetoothDataProcessor[Optional[Union[float, int]]]
], ],
SensorEntity, SensorEntity,
): ):
@ -168,4 +183,4 @@ class XiaomiBluetoothSensorEntity(
@property @property
def native_value(self) -> int | float | None: def native_value(self) -> int | float | None:
"""Return the native value.""" """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 from home_assistant_bluetooth import BluetoothServiceInfo
import pytest import pytest
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntityDescription,
)
from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth import (
DOMAIN, DOMAIN,
UNAVAILABLE_TRACK_SECONDS, UNAVAILABLE_TRACK_SECONDS,
BluetoothChange, BluetoothChange,
) )
from homeassistant.components.bluetooth.passive_update_coordinator import ( from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity, PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate, PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothDataUpdateCoordinator,
PassiveBluetoothEntityKey, PassiveBluetoothEntityKey,
PassiveBluetoothProcessorEntity,
) )
from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription
from homeassistant.const import TEMP_CELSIUS 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 return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator( 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 assert coordinator.available is False # no data yet
saved_callback = None saved_callback = None
@ -95,40 +100,41 @@ async def test_basic_usage(hass, mock_bleak_scanner_start):
saved_callback = _callback saved_callback = _callback
return lambda: None return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch( with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback, _async_register_callback,
): ):
unregister_processor = coordinator.async_register_processor(processor)
entity_key = PassiveBluetoothEntityKey("temperature", None) entity_key = PassiveBluetoothEntityKey("temperature", None)
entity_key_events = [] entity_key_events = []
all_events = [] all_events = []
mock_entity = MagicMock() mock_entity = MagicMock()
mock_add_entities = MagicMock() mock_add_entities = MagicMock()
def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None: def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None:
"""Mock entity key listener.""" """Mock entity key listener."""
entity_key_events.append(data) entity_key_events.append(data)
cancel_async_add_entity_key_listener = ( cancel_async_add_entity_key_listener = processor.async_add_entity_key_listener(
coordinator.async_add_entity_key_listener( _async_entity_key_listener,
_async_entity_key_listener, entity_key,
entity_key, )
)
)
def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None:
"""Mock an all listener.""" """Mock an all listener."""
all_events.append(data) all_events.append(data)
cancel_listener = coordinator.async_add_listener( cancel_listener = processor.async_add_listener(
_all_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_entity,
mock_add_entities, mock_add_entities,
) )
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
@ -164,6 +170,8 @@ async def test_basic_usage(hass, mock_bleak_scanner_start):
assert len(mock_entity.mock_calls) == 2 assert len(mock_entity.mock_calls) == 2
assert coordinator.available is True assert coordinator.available is True
unregister_processor()
async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start): async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
"""Test that the coordinator is unavailable after no data for a while.""" """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 return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator( 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 assert coordinator.available is False # no data yet
saved_callback = None saved_callback = None
@ -192,23 +200,27 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
saved_callback = _callback saved_callback = _callback
return lambda: None return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch( with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback, _async_register_callback,
): ):
unregister_processor = coordinator.async_register_processor(processor)
mock_entity = MagicMock() mock_entity = MagicMock()
mock_add_entities = MagicMock() mock_add_entities = MagicMock()
coordinator.async_add_entities_listener( processor.async_add_entities_listener(
mock_entity, mock_entity,
mock_add_entities, mock_add_entities,
) )
assert coordinator.available is False assert coordinator.available is False
assert processor.available is False
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert len(mock_add_entities.mock_calls) == 1 assert len(mock_add_entities.mock_calls) == 1
assert coordinator.available is True assert coordinator.available is True
assert processor.available is True
scanner = _get_underlying_scanner() scanner = _get_underlying_scanner()
with patch( with patch(
@ -224,10 +236,12 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert coordinator.available is False assert coordinator.available is False
assert processor.available is False
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert len(mock_add_entities.mock_calls) == 1 assert len(mock_add_entities.mock_calls) == 1
assert coordinator.available is True assert coordinator.available is True
assert processor.available is True
with patch( with patch(
"homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices", "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() await hass.async_block_till_done()
assert coordinator.available is False assert coordinator.available is False
assert processor.available is False
unregister_processor()
async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start): 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 return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator( 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 assert coordinator.available is False # no data yet
saved_callback = None saved_callback = None
@ -266,20 +283,23 @@ async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start):
saved_callback = _callback saved_callback = _callback
return lambda: None return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch( with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback, _async_register_callback,
): ):
unregister_processor = coordinator.async_register_processor(processor)
all_events = [] all_events = []
def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None:
"""Mock an all listener.""" """Mock an all listener."""
all_events.append(data) all_events.append(data)
coordinator.async_add_listener( processor.async_add_listener(
_all_listener, _all_listener,
) )
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert len(all_events) == 1 assert len(all_events) == 1
@ -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 # We should stop processing events once hass is stopping
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert len(all_events) == 1 assert len(all_events) == 1
unregister_processor()
async def test_exception_from_update_method(hass, caplog, mock_bleak_scanner_start): 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 return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator( 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 assert coordinator.available is False # no data yet
saved_callback = None saved_callback = None
@ -319,23 +340,27 @@ async def test_exception_from_update_method(hass, caplog, mock_bleak_scanner_sta
saved_callback = _callback saved_callback = _callback
return lambda: None return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch( with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_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) 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 # We should go unavailable once we get an exception
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert "Test exception" in caplog.text 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 # We should go available again once we get data again
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) 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): 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 return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator( 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 assert coordinator.available is False # no data yet
saved_callback = None saved_callback = None
@ -366,24 +391,28 @@ async def test_bad_data_from_update_method(hass, mock_bleak_scanner_start):
saved_callback = _callback saved_callback = _callback
return lambda: None return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch( with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_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) 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 # We should go unavailable once we get bad data
with pytest.raises(ValueError): with pytest.raises(ValueError):
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) 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 # We should go available again once we get good data again
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) 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( 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 return GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator( 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 assert coordinator.available is False # no data yet
saved_callback = None saved_callback = None
@ -702,16 +731,19 @@ async def test_integration_with_entity(hass, mock_bleak_scanner_start):
saved_callback = _callback saved_callback = _callback
return lambda: None return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch( with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_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() mock_add_entities = MagicMock()
coordinator.async_add_entities_listener( processor.async_add_entities_listener(
PassiveBluetoothCoordinatorEntity, PassiveBluetoothProcessorEntity,
mock_add_entities, 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], *mock_add_entities.mock_calls[1][1][0],
] ]
entity_one: PassiveBluetoothCoordinatorEntity = entities[0] entity_one: PassiveBluetoothProcessorEntity = entities[0]
entity_one.hass = hass entity_one.hass = hass
assert entity_one.available is True assert entity_one.available is True
assert entity_one.unique_id == "aa:bb:cc:dd:ee:ff-temperature-remote" 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 return NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator( 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 assert coordinator.available is False # no data yet
saved_callback = None saved_callback = None
@ -807,17 +839,19 @@ async def test_integration_with_entity_without_a_device(hass, mock_bleak_scanner
saved_callback = _callback saved_callback = _callback
return lambda: None return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch( with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback, _async_register_callback,
): ):
coordinator.async_register_processor(processor)
mock_add_entities = MagicMock() mock_add_entities = MagicMock()
coordinator.async_add_entities_listener( processor.async_add_entities_listener(
PassiveBluetoothCoordinatorEntity, PassiveBluetoothProcessorEntity,
mock_add_entities, mock_add_entities,
) )
saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
# First call with just the remote sensor entities results in them being added # First call with just the remote sensor entities results in them being added
@ -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 assert len(mock_add_entities.mock_calls) == 1
entities = mock_add_entities.mock_calls[0][1][0] entities = mock_add_entities.mock_calls[0][1][0]
entity_one: PassiveBluetoothCoordinatorEntity = entities[0] entity_one: PassiveBluetoothProcessorEntity = entities[0]
entity_one.hass = hass entity_one.hass = hass
assert entity_one.available is True assert entity_one.available is True
assert entity_one.unique_id == "aa:bb:cc:dd:ee:ff-temperature" 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 return NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE
coordinator = PassiveBluetoothDataUpdateCoordinator( 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 assert coordinator.available is False # no data yet
saved_callback = None saved_callback = None
@ -867,18 +901,19 @@ async def test_passive_bluetooth_entity_with_entity_platform(
saved_callback = _callback saved_callback = _callback
return lambda: None return lambda: None
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
with patch( with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback, _async_register_callback,
): ):
coordinator.async_register_processor(processor)
coordinator.async_add_entities_listener( processor.async_add_entities_listener(
PassiveBluetoothCoordinatorEntity, PassiveBluetoothProcessorEntity,
lambda entities: hass.async_create_task( lambda entities: hass.async_create_task(
entity_platform.async_add_entities(entities) entity_platform.async_add_entities(entities)
), ),
) )
saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
await hass.async_block_till_done() await hass.async_block_till_done()
saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) 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") hass.states.get("test_domain.test_platform_aa_bb_cc_dd_ee_ff_pressure")
is not None 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
)