Add a coordinator to Point (#126775)

* Add a coordinator to Point

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix
This commit is contained in:
Joost Lekkerkerker 2025-03-30 20:58:40 +02:00 committed by GitHub
parent 5106548f2c
commit 9c869fa701
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 179 additions and 250 deletions

View File

@ -1,7 +1,5 @@
"""Support for Minut Point."""
import asyncio
from dataclasses import dataclass
from http import HTTPStatus
import logging
@ -29,26 +27,18 @@ from homeassistant.helpers import (
config_validation as cv,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from . import api
from .const import (
CONF_WEBHOOK_URL,
DOMAIN,
EVENT_RECEIVED,
POINT_DISCOVERY_NEW,
SCAN_INTERVAL,
SIGNAL_UPDATE_ENTITY,
SIGNAL_WEBHOOK,
)
from .const import CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, SIGNAL_WEBHOOK
from .coordinator import PointDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
type PointConfigEntry = ConfigEntry[PointData]
type PointConfigEntry = ConfigEntry[PointDataUpdateCoordinator]
CONFIG_SCHEMA = vol.Schema(
{
@ -131,9 +121,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo
point_session = PointSession(auth)
client = MinutPointClient(hass, entry, point_session)
hass.async_create_task(client.update())
entry.runtime_data = PointData(client)
coordinator = PointDataUpdateCoordinator(hass, point_session)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await async_setup_webhook(hass, entry, point_session)
await hass.config_entries.async_forward_entry_setups(
@ -176,7 +168,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bo
if unload_ok := await hass.config_entries.async_unload_platforms(
entry, [*PLATFORMS, Platform.ALARM_CONTROL_PANEL]
):
session: PointSession = entry.runtime_data.client
session = entry.runtime_data.point
if CONF_WEBHOOK_ID in entry.data:
webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID])
await session.remove_webhook()
@ -197,87 +189,3 @@ async def handle_webhook(
data["webhook_id"] = webhook_id
async_dispatcher_send(hass, SIGNAL_WEBHOOK, data, data.get("hook_id"))
hass.bus.async_fire(EVENT_RECEIVED, data)
class MinutPointClient:
"""Get the latest data and update the states."""
def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, session: PointSession
) -> None:
"""Initialize the Minut data object."""
self._known_devices: set[str] = set()
self._known_homes: set[str] = set()
self._hass = hass
self._config_entry = config_entry
self._is_available = True
self._client = session
async_track_time_interval(self._hass, self.update, SCAN_INTERVAL)
async def update(self, *args):
"""Periodically poll the cloud for current state."""
await self._sync()
async def _sync(self):
"""Update local list of devices."""
if not await self._client.update():
self._is_available = False
_LOGGER.warning("Device is unavailable")
async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY)
return
self._is_available = True
for home_id in self._client.homes:
if home_id not in self._known_homes:
async_dispatcher_send(
self._hass,
POINT_DISCOVERY_NEW.format(Platform.ALARM_CONTROL_PANEL),
home_id,
)
self._known_homes.add(home_id)
for device in self._client.devices:
if device.device_id not in self._known_devices:
for platform in PLATFORMS:
async_dispatcher_send(
self._hass,
POINT_DISCOVERY_NEW.format(platform),
device.device_id,
)
self._known_devices.add(device.device_id)
async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY)
def device(self, device_id):
"""Return device representation."""
return self._client.device(device_id)
def is_available(self, device_id):
"""Return device availability."""
if not self._is_available:
return False
return device_id in self._client.device_ids
async def remove_webhook(self):
"""Remove the session webhook."""
return await self._client.remove_webhook()
@property
def homes(self):
"""Return known homes."""
return self._client.homes
async def async_alarm_disarm(self, home_id):
"""Send alarm disarm command."""
return await self._client.alarm_disarm(home_id)
async def async_alarm_arm(self, home_id):
"""Send alarm arm command."""
return await self._client.alarm_arm(home_id)
@dataclass
class PointData:
"""Point Data."""
client: MinutPointClient
entry_lock: asyncio.Lock = asyncio.Lock()

View File

@ -2,23 +2,22 @@
from __future__ import annotations
from collections.abc import Callable
import logging
from pypoint import PointSession
from homeassistant.components.alarm_control_panel import (
DOMAIN as ALARM_CONTROL_PANEL_DOMAIN,
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MinutPointClient
from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK
from . import PointConfigEntry
from .const import DOMAIN as POINT_DOMAIN, SIGNAL_WEBHOOK
_LOGGER = logging.getLogger(__name__)
@ -32,21 +31,20 @@ EVENT_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: PointConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Point's alarm_control_panel based on a config entry."""
coordinator = config_entry.runtime_data
async def async_discover_home(home_id):
def async_discover_home(home_id: str) -> None:
"""Discover and add a discovered home."""
client = config_entry.runtime_data.client
async_add_entities([MinutPointAlarmControl(client, home_id)], True)
async_add_entities([MinutPointAlarmControl(coordinator.point, home_id)])
async_dispatcher_connect(
hass,
POINT_DISCOVERY_NEW.format(ALARM_CONTROL_PANEL_DOMAIN, POINT_DOMAIN),
async_discover_home,
)
coordinator.new_home_callback = async_discover_home
for home_id in coordinator.point.homes:
async_discover_home(home_id)
class MinutPointAlarmControl(AlarmControlPanelEntity):
@ -55,12 +53,11 @@ class MinutPointAlarmControl(AlarmControlPanelEntity):
_attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY
_attr_code_arm_required = False
def __init__(self, point_client: MinutPointClient, home_id: str) -> None:
def __init__(self, point: PointSession, home_id: str) -> None:
"""Initialize the entity."""
self._client = point_client
self._client = point
self._home_id = home_id
self._async_unsub_hook_dispatcher_connect: Callable[[], None] | None = None
self._home = point_client.homes[self._home_id]
self._home = point.homes[self._home_id]
self._attr_name = self._home["name"]
self._attr_unique_id = f"point.{home_id}"
@ -73,16 +70,10 @@ class MinutPointAlarmControl(AlarmControlPanelEntity):
async def async_added_to_hass(self) -> None:
"""Call when entity is added to HOme Assistant."""
await super().async_added_to_hass()
self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect(
self.hass, SIGNAL_WEBHOOK, self._webhook_event
self.async_on_remove(
async_dispatcher_connect(self.hass, SIGNAL_WEBHOOK, self._webhook_event)
)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect dispatcher listener when removed."""
await super().async_will_remove_from_hass()
if self._async_unsub_hook_dispatcher_connect:
self._async_unsub_hook_dispatcher_connect()
@callback
def _webhook_event(self, data, webhook):
"""Process new event from the webhook."""
@ -107,12 +98,12 @@ class MinutPointAlarmControl(AlarmControlPanelEntity):
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
status = await self._client.async_alarm_disarm(self._home_id)
status = await self._client.alarm_disarm(self._home_id)
if status:
self._home["alarm_status"] = "off"
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
status = await self._client.async_alarm_arm(self._home_id)
status = await self._client.alarm_arm(self._home_id)
if status:
self._home["alarm_status"] = "on"

View File

@ -3,26 +3,27 @@
from __future__ import annotations
import logging
from typing import Any
from pypoint import EVENTS
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK
from . import PointConfigEntry
from .const import SIGNAL_WEBHOOK
from .coordinator import PointDataUpdateCoordinator
from .entity import MinutPointEntity
_LOGGER = logging.getLogger(__name__)
DEVICES = {
DEVICES: dict[str, Any] = {
"alarm": {"icon": "mdi:alarm-bell"},
"battery": {"device_class": BinarySensorDeviceClass.BATTERY},
"button_press": {"icon": "mdi:gesture-tap-button"},
@ -42,69 +43,60 @@ DEVICES = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: PointConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Point's binary sensors based on a config entry."""
async def async_discover_sensor(device_id):
coordinator = config_entry.runtime_data
def async_discover_sensor(device_id: str) -> None:
"""Discover and add a discovered sensor."""
client = config_entry.runtime_data.client
async_add_entities(
(
MinutPointBinarySensor(client, device_id, device_name)
for device_name in DEVICES
if device_name in EVENTS
),
True,
MinutPointBinarySensor(coordinator, device_id, device_name)
for device_name in DEVICES
if device_name in EVENTS
)
async_dispatcher_connect(
hass,
POINT_DISCOVERY_NEW.format(BINARY_SENSOR_DOMAIN, POINT_DOMAIN),
async_discover_sensor,
coordinator.new_device_callbacks.append(async_discover_sensor)
async_add_entities(
MinutPointBinarySensor(coordinator, device_id, device_name)
for device_name in DEVICES
if device_name in EVENTS
for device_id in coordinator.point.device_ids
)
class MinutPointBinarySensor(MinutPointEntity, BinarySensorEntity):
"""The platform class required by Home Assistant."""
def __init__(self, point_client, device_id, device_name):
def __init__(
self, coordinator: PointDataUpdateCoordinator, device_id: str, key: str
) -> None:
"""Initialize the binary sensor."""
super().__init__(
point_client,
device_id,
DEVICES[device_name].get("device_class", device_name),
)
self._device_name = device_name
self._async_unsub_hook_dispatcher_connect = None
self._events = EVENTS[device_name]
self._attr_unique_id = f"point.{device_id}-{device_name}"
self._attr_icon = DEVICES[self._device_name].get("icon")
self._attr_device_class = DEVICES[key].get("device_class", key)
super().__init__(coordinator, device_id)
self._device_name = key
self._events = EVENTS[key]
self._attr_unique_id = f"point.{device_id}-{key}"
self._attr_icon = DEVICES[key].get("icon")
async def async_added_to_hass(self) -> None:
"""Call when entity is added to HOme Assistant."""
await super().async_added_to_hass()
self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect(
self.hass, SIGNAL_WEBHOOK, self._webhook_event
self.async_on_remove(
async_dispatcher_connect(self.hass, SIGNAL_WEBHOOK, self._webhook_event)
)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect dispatcher listener when removed."""
await super().async_will_remove_from_hass()
if self._async_unsub_hook_dispatcher_connect:
self._async_unsub_hook_dispatcher_connect()
async def _update_callback(self):
def _handle_coordinator_update(self) -> None:
"""Update the value of the sensor."""
if not self.is_updated:
return
if self.device_class == BinarySensorDeviceClass.CONNECTIVITY:
# connectivity is the other way around.
self._attr_is_on = self._events[0] not in self.device.ongoing_events
else:
self._attr_is_on = self._events[0] in self.device.ongoing_events
self.async_write_ha_state()
super()._handle_coordinator_update()
@callback
def _webhook_event(self, data, webhook):

View File

@ -0,0 +1,70 @@
"""Define a data update coordinator for Point."""
from collections.abc import Callable
from datetime import datetime
import logging
from typing import Any
from pypoint import PointSession
from tempora.utc import fromtimestamp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.dt import parse_datetime
from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
class PointDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
"""Class to manage fetching Point data from the API."""
def __init__(self, hass: HomeAssistant, point: PointSession) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
self.point = point
self.device_updates: dict[str, datetime] = {}
self._known_devices: set[str] = set()
self._known_homes: set[str] = set()
self.new_home_callback: Callable[[str], None] | None = None
self.new_device_callbacks: list[Callable[[str], None]] = []
self.data: dict[str, dict[str, Any]] = {}
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
if not await self.point.update():
raise UpdateFailed("Failed to fetch data from Point")
if new_homes := set(self.point.homes) - self._known_homes:
_LOGGER.debug("Found new homes: %s", new_homes)
for home_id in new_homes:
if self.new_home_callback:
self.new_home_callback(home_id)
self._known_homes.update(new_homes)
device_ids = {device.device_id for device in self.point.devices}
if new_devices := device_ids - self._known_devices:
_LOGGER.debug("Found new devices: %s", new_devices)
for device_id in new_devices:
for callback in self.new_device_callbacks:
callback(device_id)
self._known_devices.update(new_devices)
for device in self.point.devices:
last_updated = parse_datetime(device.last_update)
if (
not last_updated
or device.device_id not in self.device_updates
or self.device_updates[device.device_id] < last_updated
):
self.device_updates[device.device_id] = last_updated or fromtimestamp(0)
self.data[device.device_id] = {
k: await device.sensor(k)
for k in ("temperature", "humidity", "sound_pressure")
}
return self.data

View File

@ -2,31 +2,27 @@
import logging
from pypoint import Device, PointSession
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import as_local
from .const import DOMAIN, SIGNAL_UPDATE_ENTITY
from .const import DOMAIN
from .coordinator import PointDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
class MinutPointEntity(Entity):
class MinutPointEntity(CoordinatorEntity[PointDataUpdateCoordinator]):
"""Base Entity used by the sensors."""
_attr_should_poll = False
def __init__(self, point_client, device_id, device_class) -> None:
def __init__(self, coordinator: PointDataUpdateCoordinator, device_id: str) -> None:
"""Initialize the entity."""
self._async_unsub_dispatcher_connect = None
self._client = point_client
self._id = device_id
super().__init__(coordinator)
self.device_id = device_id
self._name = self.device.name
self._attr_device_class = device_class
self._updated = utc_from_timestamp(0)
self._attr_unique_id = f"point.{device_id}-{device_class}"
device = self.device.device
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])},
@ -37,59 +33,32 @@ class MinutPointEntity(Entity):
sw_version=device["firmware"]["installed"],
via_device=(DOMAIN, device["home"]),
)
if device_class:
self._attr_name = f"{self._name} {device_class.capitalize()}"
def __str__(self) -> str:
"""Return string representation of device."""
return f"MinutPoint {self.name}"
async def async_added_to_hass(self):
"""Call when entity is added to hass."""
_LOGGER.debug("Created device %s", self)
self._async_unsub_dispatcher_connect = async_dispatcher_connect(
self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback
)
await self._update_callback()
async def async_will_remove_from_hass(self) -> None:
"""Disconnect dispatcher listener when removed."""
if self._async_unsub_dispatcher_connect:
self._async_unsub_dispatcher_connect()
if self.device_class:
self._attr_name = f"{self._name} {self.device_class.capitalize()}"
async def _update_callback(self):
"""Update the value of the sensor."""
@property
def client(self) -> PointSession:
"""Return the client object."""
return self.coordinator.point
@property
def available(self) -> bool:
"""Return true if device is not offline."""
return self._client.is_available(self.device_id)
return super().available and self.device_id in self.client.device_ids
@property
def device(self):
def device(self) -> Device:
"""Return the representation of the device."""
return self._client.device(self.device_id)
@property
def device_id(self):
"""Return the id of the device."""
return self._id
return self.client.device(self.device_id)
@property
def extra_state_attributes(self):
"""Return status of device."""
attrs = self.device.device_status
attrs["last_heard_from"] = as_local(self.last_update).strftime(
"%Y-%m-%d %H:%M:%S"
)
attrs["last_heard_from"] = as_local(
self.coordinator.device_updates[self.device_id]
).strftime("%Y-%m-%d %H:%M:%S")
return attrs
@property
def is_updated(self):
"""Return true if sensor have been updated."""
return self.last_update > self._updated
@property
def last_update(self):
"""Return the last_update time for the device."""
return parse_datetime(self.device.last_update)

View File

@ -5,19 +5,17 @@ from __future__ import annotations
import logging
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfSoundPressure, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import parse_datetime
from homeassistant.helpers.typing import StateType
from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW
from . import PointConfigEntry
from .coordinator import PointDataUpdateCoordinator
from .entity import MinutPointEntity
_LOGGER = logging.getLogger(__name__)
@ -37,7 +35,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE,
),
SensorEntityDescription(
key="sound",
key="sound_pressure",
suggested_display_precision=1,
device_class=SensorDeviceClass.SOUND_PRESSURE,
native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
@ -47,26 +45,26 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: PointConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Point's sensors based on a config entry."""
async def async_discover_sensor(device_id):
coordinator = config_entry.runtime_data
def async_discover_sensor(device_id: str) -> None:
"""Discover and add a discovered sensor."""
client = config_entry.runtime_data.client
async_add_entities(
[
MinutPointSensor(client, device_id, description)
for description in SENSOR_TYPES
],
True,
MinutPointSensor(coordinator, device_id, description)
for description in SENSOR_TYPES
)
async_dispatcher_connect(
hass,
POINT_DISCOVERY_NEW.format(SENSOR_DOMAIN, POINT_DOMAIN),
async_discover_sensor,
coordinator.new_device_callbacks.append(async_discover_sensor)
async_add_entities(
MinutPointSensor(coordinator, device_id, description)
for device_id in coordinator.data
for description in SENSOR_TYPES
)
@ -74,16 +72,17 @@ class MinutPointSensor(MinutPointEntity, SensorEntity):
"""The platform class required by Home Assistant."""
def __init__(
self, point_client, device_id, description: SensorEntityDescription
self,
coordinator: PointDataUpdateCoordinator,
device_id: str,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(point_client, device_id, description.device_class)
self.entity_description = description
super().__init__(coordinator, device_id)
self._attr_unique_id = f"point.{device_id}-{description.key}"
async def _update_callback(self):
"""Update the value of the sensor."""
_LOGGER.debug("Update sensor value for %s", self)
if self.is_updated:
self._attr_native_value = await self.device.sensor(self.device_class)
self._updated = parse_datetime(self.device.last_update)
self.async_write_ha_state()
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.coordinator.data[self.device_id].get(self.entity_description.key)