Split bluetooth coordinator into two classes (#75675)
parent
19f82e5201
commit
da131beced
|
@ -1,65 +1,23 @@
|
|||
"""The Bluetooth integration."""
|
||||
"""Passive update coordinator for the Bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
import dataclasses
|
||||
from collections.abc import Callable, Generator
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Generic, TypeVar
|
||||
from typing import Any
|
||||
|
||||
from home_assistant_bluetooth import BluetoothServiceInfo
|
||||
|
||||
from homeassistant.const import ATTR_IDENTIFIERS, ATTR_NAME
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import (
|
||||
BluetoothCallbackMatcher,
|
||||
BluetoothChange,
|
||||
async_register_callback,
|
||||
async_track_unavailable,
|
||||
)
|
||||
from .const import DOMAIN
|
||||
from . import BluetoothChange
|
||||
from .update_coordinator import BasePassiveBluetoothCoordinator
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class PassiveBluetoothEntityKey:
|
||||
"""Key for a passive bluetooth entity.
|
||||
class PassiveBluetoothDataUpdateCoordinator(BasePassiveBluetoothCoordinator):
|
||||
"""Class to manage passive bluetooth advertisements.
|
||||
|
||||
Example:
|
||||
key: temperature
|
||||
device_id: outdoor_sensor_1
|
||||
"""
|
||||
|
||||
key: str
|
||||
device_id: str | None
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class PassiveBluetoothDataUpdate(Generic[_T]):
|
||||
"""Generic bluetooth data."""
|
||||
|
||||
devices: dict[str | None, DeviceInfo] = dataclasses.field(default_factory=dict)
|
||||
entity_descriptions: Mapping[
|
||||
PassiveBluetoothEntityKey, EntityDescription
|
||||
] = dataclasses.field(default_factory=dict)
|
||||
entity_names: Mapping[PassiveBluetoothEntityKey, str | None] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
entity_data: Mapping[PassiveBluetoothEntityKey, _T] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
|
||||
class PassiveBluetoothDataUpdateCoordinator:
|
||||
"""Passive bluetooth data update coordinator for bluetooth advertisements.
|
||||
|
||||
The coordinator is responsible for dispatching the bluetooth data,
|
||||
to each processor, and tracking devices.
|
||||
This coordinator is responsible for dispatching the bluetooth data
|
||||
and tracking devices.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
@ -68,78 +26,52 @@ class PassiveBluetoothDataUpdateCoordinator:
|
|||
logger: logging.Logger,
|
||||
address: str,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.hass = hass
|
||||
self.logger = logger
|
||||
self.name: str | None = None
|
||||
self.address = address
|
||||
self._processors: list[PassiveBluetoothDataProcessor] = []
|
||||
self._cancel_track_unavailable: CALLBACK_TYPE | None = None
|
||||
self._cancel_bluetooth_advertisements: CALLBACK_TYPE | None = None
|
||||
self._present = False
|
||||
self.last_seen = 0.0
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the device is available."""
|
||||
return self._present
|
||||
"""Initialize PassiveBluetoothDataUpdateCoordinator."""
|
||||
super().__init__(hass, logger, address)
|
||||
self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
|
||||
|
||||
@callback
|
||||
def _async_start(self) -> None:
|
||||
"""Start the callbacks."""
|
||||
self._cancel_bluetooth_advertisements = async_register_callback(
|
||||
self.hass,
|
||||
self._async_handle_bluetooth_event,
|
||||
BluetoothCallbackMatcher(address=self.address),
|
||||
)
|
||||
self._cancel_track_unavailable = async_track_unavailable(
|
||||
self.hass,
|
||||
self._async_handle_unavailable,
|
||||
self.address,
|
||||
)
|
||||
def async_update_listeners(self) -> None:
|
||||
"""Update all registered listeners."""
|
||||
for update_callback, _ in list(self._listeners.values()):
|
||||
update_callback()
|
||||
|
||||
@callback
|
||||
def _async_stop(self) -> None:
|
||||
"""Stop the callbacks."""
|
||||
if self._cancel_bluetooth_advertisements is not None:
|
||||
self._cancel_bluetooth_advertisements()
|
||||
self._cancel_bluetooth_advertisements = None
|
||||
if self._cancel_track_unavailable is not None:
|
||||
self._cancel_track_unavailable()
|
||||
self._cancel_track_unavailable = None
|
||||
def _async_handle_unavailable(self, address: str) -> None:
|
||||
"""Handle the device going unavailable."""
|
||||
super()._async_handle_unavailable(address)
|
||||
self.async_update_listeners()
|
||||
|
||||
@callback
|
||||
def async_register_processor(
|
||||
self, processor: PassiveBluetoothDataProcessor
|
||||
) -> Callable[[], None]:
|
||||
"""Register a processor that subscribes to updates."""
|
||||
processor.coordinator = self
|
||||
def async_start(self) -> CALLBACK_TYPE:
|
||||
"""Start the data updater."""
|
||||
self._async_start()
|
||||
|
||||
@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:
|
||||
def _async_cancel() -> None:
|
||||
self._async_stop()
|
||||
elif not running and self._processors:
|
||||
self._async_start()
|
||||
|
||||
return _async_cancel
|
||||
|
||||
@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()
|
||||
def async_add_listener(
|
||||
self, update_callback: CALLBACK_TYPE, context: Any = None
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for data updates."""
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove update listener."""
|
||||
self._listeners.pop(remove_listener)
|
||||
|
||||
self._listeners[remove_listener] = (update_callback, context)
|
||||
return remove_listener
|
||||
|
||||
def async_contexts(self) -> Generator[Any, None, None]:
|
||||
"""Return all registered contexts."""
|
||||
yield from (
|
||||
context for _, context in self._listeners.values() if context is not None
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_handle_bluetooth_event(
|
||||
|
@ -148,242 +80,19 @@ class PassiveBluetoothDataUpdateCoordinator:
|
|||
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)
|
||||
super()._async_handle_bluetooth_event(service_info, change)
|
||||
self.async_update_listeners()
|
||||
|
||||
|
||||
_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.
|
||||
"""
|
||||
class PassiveBluetoothCoordinatorEntity(CoordinatorEntity):
|
||||
"""A class for entities using DataUpdateCoordinator."""
|
||||
|
||||
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[PassiveBluetoothProcessorEntity],
|
||||
async_add_entites: AddEntitiesCallback,
|
||||
) -> Callable[[], None]:
|
||||
"""Add a listener for new entities."""
|
||||
created: set[PassiveBluetoothEntityKey] = set()
|
||||
|
||||
@callback
|
||||
def _async_add_or_update_entities(
|
||||
data: PassiveBluetoothDataUpdate[_T] | None,
|
||||
) -> None:
|
||||
"""Listen for new entities."""
|
||||
if data is None:
|
||||
return
|
||||
entities: list[PassiveBluetoothProcessorEntity] = []
|
||||
for entity_key, description in data.entity_descriptions.items():
|
||||
if entity_key not in created:
|
||||
entities.append(entity_class(self, entity_key, description))
|
||||
created.add(entity_key)
|
||||
if entities:
|
||||
async_add_entites(entities)
|
||||
|
||||
return self.async_add_listener(_async_add_or_update_entities)
|
||||
|
||||
@callback
|
||||
def async_add_listener(
|
||||
self,
|
||||
update_callback: Callable[[PassiveBluetoothDataUpdate[_T] | None], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for all updates."""
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove update listener."""
|
||||
self._listeners.remove(update_callback)
|
||||
|
||||
self._listeners.append(update_callback)
|
||||
return remove_listener
|
||||
|
||||
@callback
|
||||
def async_add_entity_key_listener(
|
||||
self,
|
||||
update_callback: Callable[[PassiveBluetoothDataUpdate[_T] | None], None],
|
||||
entity_key: PassiveBluetoothEntityKey,
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for updates by device key."""
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove update listener."""
|
||||
self._entity_key_listeners[entity_key].remove(update_callback)
|
||||
if not self._entity_key_listeners[entity_key]:
|
||||
del self._entity_key_listeners[entity_key]
|
||||
|
||||
self._entity_key_listeners.setdefault(entity_key, []).append(update_callback)
|
||||
return remove_listener
|
||||
|
||||
@callback
|
||||
def async_update_listeners(
|
||||
self, data: PassiveBluetoothDataUpdate[_T] | None
|
||||
) -> None:
|
||||
"""Update all registered listeners."""
|
||||
# Dispatch to listeners without a filter key
|
||||
for update_callback in self._listeners:
|
||||
update_callback(data)
|
||||
|
||||
# Dispatch to listeners with a filter key
|
||||
for listeners in self._entity_key_listeners.values():
|
||||
for update_callback in listeners:
|
||||
update_callback(data)
|
||||
|
||||
@callback
|
||||
def async_handle_bluetooth_event(
|
||||
self,
|
||||
service_info: BluetoothServiceInfo,
|
||||
change: BluetoothChange,
|
||||
) -> None:
|
||||
"""Handle a Bluetooth event."""
|
||||
try:
|
||||
new_data = self.update_method(service_info)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
self.last_update_success = False
|
||||
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.coordinator.name} returned {new_data} instead of a PassiveBluetoothDataUpdate"
|
||||
)
|
||||
|
||||
if not self.last_update_success:
|
||||
self.last_update_success = True
|
||||
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)
|
||||
self.entity_data.update(new_data.entity_data)
|
||||
self.entity_names.update(new_data.entity_names)
|
||||
self.async_update_listeners(new_data)
|
||||
|
||||
|
||||
class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]):
|
||||
"""A class for entities using PassiveBluetoothDataProcessor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
processor: _PassiveBluetoothDataProcessorT,
|
||||
entity_key: PassiveBluetoothEntityKey,
|
||||
description: EntityDescription,
|
||||
context: Any = None,
|
||||
) -> None:
|
||||
"""Create the entity with a PassiveBluetoothDataProcessor."""
|
||||
self.entity_description = description
|
||||
self.entity_key = entity_key
|
||||
self.processor = processor
|
||||
self.processor_context = context
|
||||
address = processor.coordinator.address
|
||||
device_id = entity_key.device_id
|
||||
devices = processor.devices
|
||||
key = entity_key.key
|
||||
if device_id in devices:
|
||||
base_device_info = devices[device_id]
|
||||
else:
|
||||
base_device_info = DeviceInfo({})
|
||||
if device_id:
|
||||
self._attr_device_info = base_device_info | DeviceInfo(
|
||||
{ATTR_IDENTIFIERS: {(DOMAIN, f"{address}-{device_id}")}}
|
||||
)
|
||||
self._attr_unique_id = f"{address}-{key}-{device_id}"
|
||||
else:
|
||||
self._attr_device_info = base_device_info | DeviceInfo(
|
||||
{ATTR_IDENTIFIERS: {(DOMAIN, address)}}
|
||||
)
|
||||
self._attr_unique_id = f"{address}-{key}"
|
||||
if ATTR_NAME not in self._attr_device_info:
|
||||
self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name
|
||||
self._attr_name = processor.entity_names.get(entity_key)
|
||||
async def async_update(self) -> None:
|
||||
"""All updates are passive."""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is 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.processor.async_add_entity_key_listener(
|
||||
self._handle_processor_update, self.entity_key
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_processor_update(
|
||||
self, new_data: PassiveBluetoothDataUpdate | None
|
||||
) -> None:
|
||||
"""Handle updated data from the processor."""
|
||||
self.async_write_ha_state()
|
||||
return self.coordinator.available
|
||||
|
|
|
@ -0,0 +1,346 @@
|
|||
"""Passive update processors for the Bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
import dataclasses
|
||||
import logging
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from home_assistant_bluetooth import BluetoothServiceInfo
|
||||
|
||||
from homeassistant.const import ATTR_IDENTIFIERS, ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import BluetoothChange
|
||||
from .const import DOMAIN
|
||||
from .update_coordinator import BasePassiveBluetoothCoordinator
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class PassiveBluetoothEntityKey:
|
||||
"""Key for a passive bluetooth entity.
|
||||
|
||||
Example:
|
||||
key: temperature
|
||||
device_id: outdoor_sensor_1
|
||||
"""
|
||||
|
||||
key: str
|
||||
device_id: str | None
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class PassiveBluetoothDataUpdate(Generic[_T]):
|
||||
"""Generic bluetooth data."""
|
||||
|
||||
devices: dict[str | None, DeviceInfo] = dataclasses.field(default_factory=dict)
|
||||
entity_descriptions: Mapping[
|
||||
PassiveBluetoothEntityKey, EntityDescription
|
||||
] = dataclasses.field(default_factory=dict)
|
||||
entity_names: Mapping[PassiveBluetoothEntityKey, str | None] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
entity_data: Mapping[PassiveBluetoothEntityKey, _T] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
|
||||
class PassiveBluetoothProcessorCoordinator(BasePassiveBluetoothCoordinator):
|
||||
"""Passive bluetooth processor coordinator for bluetooth advertisements.
|
||||
|
||||
The coordinator is responsible for dispatching the bluetooth data,
|
||||
to each processor, and tracking devices.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
logger: logging.Logger,
|
||||
address: str,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(hass, logger, address)
|
||||
self._processors: list[PassiveBluetoothDataProcessor] = []
|
||||
|
||||
@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."""
|
||||
super()._async_handle_unavailable(address)
|
||||
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."""
|
||||
super()._async_handle_bluetooth_event(service_info, change)
|
||||
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: PassiveBluetoothProcessorCoordinator
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
update_method: Callable[[BluetoothServiceInfo], PassiveBluetoothDataUpdate[_T]],
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.coordinator: PassiveBluetoothProcessorCoordinator
|
||||
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[PassiveBluetoothProcessorEntity],
|
||||
async_add_entites: AddEntitiesCallback,
|
||||
) -> Callable[[], None]:
|
||||
"""Add a listener for new entities."""
|
||||
created: set[PassiveBluetoothEntityKey] = set()
|
||||
|
||||
@callback
|
||||
def _async_add_or_update_entities(
|
||||
data: PassiveBluetoothDataUpdate[_T] | None,
|
||||
) -> None:
|
||||
"""Listen for new entities."""
|
||||
if data is None:
|
||||
return
|
||||
entities: list[PassiveBluetoothProcessorEntity] = []
|
||||
for entity_key, description in data.entity_descriptions.items():
|
||||
if entity_key not in created:
|
||||
entities.append(entity_class(self, entity_key, description))
|
||||
created.add(entity_key)
|
||||
if entities:
|
||||
async_add_entites(entities)
|
||||
|
||||
return self.async_add_listener(_async_add_or_update_entities)
|
||||
|
||||
@callback
|
||||
def async_add_listener(
|
||||
self,
|
||||
update_callback: Callable[[PassiveBluetoothDataUpdate[_T] | None], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for all updates."""
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove update listener."""
|
||||
self._listeners.remove(update_callback)
|
||||
|
||||
self._listeners.append(update_callback)
|
||||
return remove_listener
|
||||
|
||||
@callback
|
||||
def async_add_entity_key_listener(
|
||||
self,
|
||||
update_callback: Callable[[PassiveBluetoothDataUpdate[_T] | None], None],
|
||||
entity_key: PassiveBluetoothEntityKey,
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for updates by device key."""
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove update listener."""
|
||||
self._entity_key_listeners[entity_key].remove(update_callback)
|
||||
if not self._entity_key_listeners[entity_key]:
|
||||
del self._entity_key_listeners[entity_key]
|
||||
|
||||
self._entity_key_listeners.setdefault(entity_key, []).append(update_callback)
|
||||
return remove_listener
|
||||
|
||||
@callback
|
||||
def async_update_listeners(
|
||||
self, data: PassiveBluetoothDataUpdate[_T] | None
|
||||
) -> None:
|
||||
"""Update all registered listeners."""
|
||||
# Dispatch to listeners without a filter key
|
||||
for update_callback in self._listeners:
|
||||
update_callback(data)
|
||||
|
||||
# Dispatch to listeners with a filter key
|
||||
for listeners in self._entity_key_listeners.values():
|
||||
for update_callback in listeners:
|
||||
update_callback(data)
|
||||
|
||||
@callback
|
||||
def async_handle_bluetooth_event(
|
||||
self,
|
||||
service_info: BluetoothServiceInfo,
|
||||
change: BluetoothChange,
|
||||
) -> None:
|
||||
"""Handle a Bluetooth event."""
|
||||
try:
|
||||
new_data = self.update_method(service_info)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
self.last_update_success = False
|
||||
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.coordinator.name} returned {new_data} instead of a PassiveBluetoothDataUpdate"
|
||||
)
|
||||
|
||||
if not self.last_update_success:
|
||||
self.last_update_success = True
|
||||
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)
|
||||
self.entity_data.update(new_data.entity_data)
|
||||
self.entity_names.update(new_data.entity_names)
|
||||
self.async_update_listeners(new_data)
|
||||
|
||||
|
||||
class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]):
|
||||
"""A class for entities using PassiveBluetoothDataProcessor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
processor: _PassiveBluetoothDataProcessorT,
|
||||
entity_key: PassiveBluetoothEntityKey,
|
||||
description: EntityDescription,
|
||||
context: Any = None,
|
||||
) -> None:
|
||||
"""Create the entity with a PassiveBluetoothDataProcessor."""
|
||||
self.entity_description = description
|
||||
self.entity_key = entity_key
|
||||
self.processor = processor
|
||||
self.processor_context = context
|
||||
address = processor.coordinator.address
|
||||
device_id = entity_key.device_id
|
||||
devices = processor.devices
|
||||
key = entity_key.key
|
||||
if device_id in devices:
|
||||
base_device_info = devices[device_id]
|
||||
else:
|
||||
base_device_info = DeviceInfo({})
|
||||
if device_id:
|
||||
self._attr_device_info = base_device_info | DeviceInfo(
|
||||
{ATTR_IDENTIFIERS: {(DOMAIN, f"{address}-{device_id}")}}
|
||||
)
|
||||
self._attr_unique_id = f"{address}-{key}-{device_id}"
|
||||
else:
|
||||
self._attr_device_info = base_device_info | DeviceInfo(
|
||||
{ATTR_IDENTIFIERS: {(DOMAIN, address)}}
|
||||
)
|
||||
self._attr_unique_id = f"{address}-{key}"
|
||||
if ATTR_NAME not in self._attr_device_info:
|
||||
self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name
|
||||
self._attr_name = processor.entity_names.get(entity_key)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is 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.processor.async_add_entity_key_listener(
|
||||
self._handle_processor_update, self.entity_key
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_processor_update(
|
||||
self, new_data: PassiveBluetoothDataUpdate | None
|
||||
) -> None:
|
||||
"""Handle updated data from the processor."""
|
||||
self.async_write_ha_state()
|
|
@ -0,0 +1,84 @@
|
|||
"""Update coordinator for the Bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from home_assistant_bluetooth import BluetoothServiceInfo
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
|
||||
from . import (
|
||||
BluetoothCallbackMatcher,
|
||||
BluetoothChange,
|
||||
async_register_callback,
|
||||
async_track_unavailable,
|
||||
)
|
||||
|
||||
|
||||
class BasePassiveBluetoothCoordinator:
|
||||
"""Base class for passive bluetooth coordinator for bluetooth advertisements.
|
||||
|
||||
The coordinator is responsible for tracking devices.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
logger: logging.Logger,
|
||||
address: str,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.hass = hass
|
||||
self.logger = logger
|
||||
self.name: str | None = None
|
||||
self.address = address
|
||||
self._cancel_track_unavailable: CALLBACK_TYPE | None = None
|
||||
self._cancel_bluetooth_advertisements: CALLBACK_TYPE | None = None
|
||||
self._present = False
|
||||
self.last_seen = 0.0
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the device is available."""
|
||||
return self._present
|
||||
|
||||
@callback
|
||||
def _async_start(self) -> None:
|
||||
"""Start the callbacks."""
|
||||
self._cancel_bluetooth_advertisements = async_register_callback(
|
||||
self.hass,
|
||||
self._async_handle_bluetooth_event,
|
||||
BluetoothCallbackMatcher(address=self.address),
|
||||
)
|
||||
self._cancel_track_unavailable = async_track_unavailable(
|
||||
self.hass,
|
||||
self._async_handle_unavailable,
|
||||
self.address,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_stop(self) -> None:
|
||||
"""Stop the callbacks."""
|
||||
if self._cancel_bluetooth_advertisements is not None:
|
||||
self._cancel_bluetooth_advertisements()
|
||||
self._cancel_bluetooth_advertisements = None
|
||||
if self._cancel_track_unavailable is not None:
|
||||
self._cancel_track_unavailable()
|
||||
self._cancel_track_unavailable = None
|
||||
|
||||
@callback
|
||||
def _async_handle_unavailable(self, address: str) -> None:
|
||||
"""Handle the device going unavailable."""
|
||||
self._present = False
|
||||
|
||||
@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
|
|
@ -3,8 +3,8 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.bluetooth.passive_update_coordinator import (
|
||||
PassiveBluetoothDataUpdateCoordinator,
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothProcessorCoordinator,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
|
@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
assert address is not None
|
||||
hass.data.setdefault(DOMAIN, {})[
|
||||
entry.entry_id
|
||||
] = PassiveBluetoothDataUpdateCoordinator(
|
||||
] = PassiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address=address,
|
||||
|
|
|
@ -13,11 +13,11 @@ from inkbird_ble import (
|
|||
)
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.bluetooth.passive_update_coordinator import (
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothDataProcessor,
|
||||
PassiveBluetoothDataUpdate,
|
||||
PassiveBluetoothDataUpdateCoordinator,
|
||||
PassiveBluetoothEntityKey,
|
||||
PassiveBluetoothProcessorCoordinator,
|
||||
PassiveBluetoothProcessorEntity,
|
||||
)
|
||||
from homeassistant.components.sensor import (
|
||||
|
@ -126,7 +126,7 @@ async def async_setup_entry(
|
|||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the INKBIRD BLE sensors."""
|
||||
coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][
|
||||
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
]
|
||||
data = INKBIRDBluetoothDeviceData()
|
||||
|
|
|
@ -3,8 +3,8 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.bluetooth.passive_update_coordinator import (
|
||||
PassiveBluetoothDataUpdateCoordinator,
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothProcessorCoordinator,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
|
@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
assert address is not None
|
||||
hass.data.setdefault(DOMAIN, {})[
|
||||
entry.entry_id
|
||||
] = PassiveBluetoothDataUpdateCoordinator(
|
||||
] = PassiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address=address,
|
||||
|
|
|
@ -13,11 +13,11 @@ from sensorpush_ble import (
|
|||
)
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.bluetooth.passive_update_coordinator import (
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothDataProcessor,
|
||||
PassiveBluetoothDataUpdate,
|
||||
PassiveBluetoothDataUpdateCoordinator,
|
||||
PassiveBluetoothEntityKey,
|
||||
PassiveBluetoothProcessorCoordinator,
|
||||
PassiveBluetoothProcessorEntity,
|
||||
)
|
||||
from homeassistant.components.sensor import (
|
||||
|
@ -127,7 +127,7 @@ async def async_setup_entry(
|
|||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the SensorPush BLE sensors."""
|
||||
coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][
|
||||
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
]
|
||||
data = SensorPushBluetoothDeviceData()
|
||||
|
|
|
@ -3,8 +3,8 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.bluetooth.passive_update_coordinator import (
|
||||
PassiveBluetoothDataUpdateCoordinator,
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothProcessorCoordinator,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
|
@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
assert address is not None
|
||||
hass.data.setdefault(DOMAIN, {})[
|
||||
entry.entry_id
|
||||
] = PassiveBluetoothDataUpdateCoordinator(
|
||||
] = PassiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address=address,
|
||||
|
|
|
@ -13,11 +13,11 @@ from xiaomi_ble import (
|
|||
)
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.bluetooth.passive_update_coordinator import (
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothDataProcessor,
|
||||
PassiveBluetoothDataUpdate,
|
||||
PassiveBluetoothDataUpdateCoordinator,
|
||||
PassiveBluetoothEntityKey,
|
||||
PassiveBluetoothProcessorCoordinator,
|
||||
PassiveBluetoothProcessorEntity,
|
||||
)
|
||||
from homeassistant.components.sensor import (
|
||||
|
@ -155,7 +155,7 @@ async def async_setup_entry(
|
|||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Xiaomi BLE sensors."""
|
||||
coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][
|
||||
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
]
|
||||
data = XiaomiBluetoothDeviceData()
|
||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -28,7 +28,7 @@ async def test_sensors(hass):
|
|||
return lambda: None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
|
||||
"homeassistant.components.bluetooth.update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
|
|
@ -28,7 +28,7 @@ async def test_sensors(hass):
|
|||
return lambda: None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
|
||||
"homeassistant.components.bluetooth.update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
|
|
@ -28,7 +28,7 @@ async def test_sensors(hass):
|
|||
return lambda: None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
|
||||
"homeassistant.components.bluetooth.update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
@ -66,7 +66,7 @@ async def test_xiaomi_HHCCJCY01(hass):
|
|||
return lambda: None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
|
||||
"homeassistant.components.bluetooth.update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
|
Loading…
Reference in New Issue