Split Withings coordinators (#101766)

* Subscribe to Withings webhooks outside of coordinator

* Subscribe to Withings webhooks outside of coordinator

* Split Withings coordinator

* Split Withings coordinator

* Update homeassistant/components/withings/sensor.py

* Fix merge

* Rename MEASUREMENT_COORDINATOR

* Update homeassistant/components/withings/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Fix feedback

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/93301/head
Joost Lekkerkerker 2023-10-13 07:34:31 +02:00 committed by GitHub
parent 03210d7f81
commit d712a29052
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 163 additions and 47 deletions

View File

@ -43,8 +43,22 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
from homeassistant.helpers.typing import ConfigType
from .api import ConfigEntryWithingsApi
from .const import CONF_PROFILES, CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN, LOGGER
from .coordinator import WithingsDataUpdateCoordinator
from .const import (
BED_PRESENCE_COORDINATOR,
CONF_PROFILES,
CONF_USE_WEBHOOK,
DEFAULT_TITLE,
DOMAIN,
LOGGER,
MEASUREMENT_COORDINATOR,
SLEEP_COORDINATOR,
)
from .coordinator import (
WithingsBedPresenceDataUpdateCoordinator,
WithingsDataUpdateCoordinator,
WithingsMeasurementDataUpdateCoordinator,
WithingsSleepDataUpdateCoordinator,
)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
@ -128,11 +142,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass, entry
),
)
coordinator = WithingsDataUpdateCoordinator(hass, client)
coordinators: dict[str, WithingsDataUpdateCoordinator] = {
MEASUREMENT_COORDINATOR: WithingsMeasurementDataUpdateCoordinator(hass, client),
SLEEP_COORDINATOR: WithingsSleepDataUpdateCoordinator(hass, client),
BED_PRESENCE_COORDINATOR: WithingsBedPresenceDataUpdateCoordinator(
hass, client
),
}
await coordinator.async_config_entry_first_refresh()
for coordinator in coordinators.values():
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators
async def unregister_webhook(
_: Any,
@ -140,7 +161,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID])
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
await async_unsubscribe_webhooks(client)
coordinator.webhook_subscription_listener(False)
for coordinator in coordinators.values():
coordinator.webhook_subscription_listener(False)
async def register_webhook(
_: Any,
@ -166,11 +188,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
DOMAIN,
webhook_name,
entry.data[CONF_WEBHOOK_ID],
get_webhook_handler(coordinator),
get_webhook_handler(coordinators),
)
await async_subscribe_webhooks(client, webhook_url)
coordinator.webhook_subscription_listener(True)
for coordinator in coordinators.values():
coordinator.webhook_subscription_listener(True)
LOGGER.debug("Register Withings webhook: %s", webhook_url)
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook)
@ -287,7 +310,7 @@ def json_message_response(message: str, message_code: int) -> Response:
def get_webhook_handler(
coordinator: WithingsDataUpdateCoordinator,
coordinators: dict[str, WithingsDataUpdateCoordinator],
) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]:
"""Return webhook handler."""
@ -318,7 +341,9 @@ def get_webhook_handler(
except ValueError:
return json_message_response("Invalid appli provided", message_code=21)
await coordinator.async_webhook_data_updated(appli)
for coordinator in coordinators.values():
if appli in coordinator.notification_categories:
await coordinator.async_webhook_data_updated(appli)
return json_message_response("Success", message_code=0)

View File

@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import WithingsDataUpdateCoordinator
from .const import BED_PRESENCE_COORDINATOR, DOMAIN
from .coordinator import WithingsBedPresenceDataUpdateCoordinator
from .entity import WithingsEntity
@ -20,7 +20,9 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the sensor config entry."""
coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator: WithingsBedPresenceDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
][BED_PRESENCE_COORDINATOR]
entities = [WithingsBinarySensor(coordinator)]
@ -33,8 +35,9 @@ class WithingsBinarySensor(WithingsEntity, BinarySensorEntity):
_attr_icon = "mdi:bed"
_attr_translation_key = "in_bed"
_attr_device_class = BinarySensorDeviceClass.OCCUPANCY
coordinator: WithingsBedPresenceDataUpdateCoordinator
def __init__(self, coordinator: WithingsDataUpdateCoordinator) -> None:
def __init__(self, coordinator: WithingsBedPresenceDataUpdateCoordinator) -> None:
"""Initialize binary sensor."""
super().__init__(coordinator, "in_bed")

View File

@ -14,6 +14,10 @@ LOG_NAMESPACE = "homeassistant.components.withings"
PROFILE = "profile"
PUSH_HANDLER = "push_handler"
MEASUREMENT_COORDINATOR = "measurement_coordinator"
SLEEP_COORDINATOR = "sleep_coordinator"
BED_PRESENCE_COORDINATOR = "bed_presence_coordinator"
LOGGER = logging.getLogger(__package__)

View File

@ -1,7 +1,8 @@
"""Withings coordinator."""
from abc import abstractmethod
from collections.abc import Callable
from datetime import timedelta
from typing import Any
from typing import Any, TypeVar
from withings_api.common import (
AuthFailedException,
@ -66,40 +67,66 @@ WITHINGS_MEASURE_TYPE_MAP: dict[
NotifyAppli.BED_IN: Measurement.IN_BED,
}
_T = TypeVar("_T")
UPDATE_INTERVAL = timedelta(minutes=10)
class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]]):
class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]):
"""Base coordinator."""
in_bed: bool | None = None
config_entry: ConfigEntry
_default_update_interval: timedelta | None = UPDATE_INTERVAL
def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None:
"""Initialize the Withings data coordinator."""
super().__init__(hass, LOGGER, name="Withings", update_interval=UPDATE_INTERVAL)
super().__init__(
hass, LOGGER, name="Withings", update_interval=self._default_update_interval
)
self._client = client
self.notification_categories: set[NotifyAppli] = set()
def webhook_subscription_listener(self, connected: bool) -> None:
"""Call when webhook status changed."""
if connected:
self.update_interval = None
else:
self.update_interval = UPDATE_INTERVAL
self.update_interval = self._default_update_interval
async def _async_update_data(self) -> dict[Measurement, Any]:
async def async_webhook_data_updated(
self, notification_category: NotifyAppli
) -> None:
"""Update data when webhook is called."""
LOGGER.debug("Withings webhook triggered for %s", notification_category)
await self.async_request_refresh()
async def _async_update_data(self) -> _T:
try:
measurements = await self._get_measurements()
sleep_summary = await self._get_sleep_summary()
return await self._internal_update_data()
except (UnauthorizedException, AuthFailedException) as exc:
raise ConfigEntryAuthFailed from exc
return {
**measurements,
**sleep_summary,
@abstractmethod
async def _internal_update_data(self) -> _T:
"""Update coordinator data."""
class WithingsMeasurementDataUpdateCoordinator(
WithingsDataUpdateCoordinator[dict[Measurement, Any]]
):
"""Withings measurement coordinator."""
def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None:
"""Initialize the Withings data coordinator."""
super().__init__(hass, client)
self.notification_categories = {
NotifyAppli.WEIGHT,
NotifyAppli.ACTIVITY,
NotifyAppli.CIRCULATORY,
}
async def _get_measurements(self) -> dict[Measurement, Any]:
LOGGER.debug("Updating withings measures")
async def _internal_update_data(self) -> dict[Measurement, Any]:
"""Retrieve measurement data."""
now = dt_util.utcnow()
startdate = now - timedelta(days=7)
@ -125,7 +152,21 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]
if measure.type in WITHINGS_MEASURE_TYPE_MAP
}
async def _get_sleep_summary(self) -> dict[Measurement, Any]:
class WithingsSleepDataUpdateCoordinator(
WithingsDataUpdateCoordinator[dict[Measurement, Any]]
):
"""Withings sleep coordinator."""
def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None:
"""Initialize the Withings data coordinator."""
super().__init__(hass, client)
self.notification_categories = {
NotifyAppli.SLEEP,
}
async def _internal_update_data(self) -> dict[Measurement, Any]:
"""Retrieve sleep data."""
now = dt_util.now()
yesterday = now - timedelta(days=1)
yesterday_noon = dt_util.start_of_local_day(yesterday) + timedelta(hours=12)
@ -202,18 +243,27 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]
for field, value in values.items()
}
class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[None]):
"""Withings bed presence coordinator."""
in_bed: bool | None = None
_default_update_interval = None
def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None:
"""Initialize the Withings data coordinator."""
super().__init__(hass, client)
self.notification_categories = {
NotifyAppli.BED_IN,
NotifyAppli.BED_OUT,
}
async def async_webhook_data_updated(
self, notification_category: NotifyAppli
) -> None:
"""Update data when webhook is called."""
LOGGER.debug("Withings webhook triggered")
if notification_category in {
NotifyAppli.WEIGHT,
NotifyAppli.CIRCULATORY,
NotifyAppli.SLEEP,
}:
await self.async_request_refresh()
"""Only set new in bed value instead of refresh."""
self.in_bed = notification_category == NotifyAppli.BED_IN
self.async_update_listeners()
elif notification_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}:
self.in_bed = notification_category == NotifyAppli.BED_IN
self.async_update_listeners()
async def _internal_update_data(self) -> None:
"""Update coordinator data."""

View File

@ -25,14 +25,20 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
DOMAIN,
MEASUREMENT_COORDINATOR,
SCORE_POINTS,
SLEEP_COORDINATOR,
UOM_BEATS_PER_MINUTE,
UOM_BREATHS_PER_MINUTE,
UOM_FREQUENCY,
UOM_MMHG,
Measurement,
)
from .coordinator import WithingsDataUpdateCoordinator
from .coordinator import (
WithingsDataUpdateCoordinator,
WithingsMeasurementDataUpdateCoordinator,
WithingsSleepDataUpdateCoordinator,
)
from .entity import WithingsEntity
@ -51,7 +57,7 @@ class WithingsSensorEntityDescription(
"""Immutable class for describing withings data."""
SENSORS = [
MEASUREMENT_SENSORS = [
WithingsSensorEntityDescription(
key=Measurement.WEIGHT_KG.value,
measurement=Measurement.WEIGHT_KG,
@ -193,6 +199,8 @@ SENSORS = [
device_class=SensorDeviceClass.SPEED,
state_class=SensorStateClass.MEASUREMENT,
),
]
SLEEP_SENSORS = [
WithingsSensorEntityDescription(
key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value,
measurement=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY,
@ -369,9 +377,22 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the sensor config entry."""
coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
measurement_coordinator: WithingsMeasurementDataUpdateCoordinator = hass.data[
DOMAIN
][entry.entry_id][MEASUREMENT_COORDINATOR]
entities: list[SensorEntity] = []
entities.extend(
WithingsMeasurementSensor(measurement_coordinator, attribute)
for attribute in MEASUREMENT_SENSORS
)
sleep_coordinator: WithingsSleepDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
][SLEEP_COORDINATOR]
async_add_entities(WithingsSensor(coordinator, attribute) for attribute in SENSORS)
entities.extend(
WithingsSleepSensor(sleep_coordinator, attribute) for attribute in SLEEP_SENSORS
)
async_add_entities(entities)
class WithingsSensor(WithingsEntity, SensorEntity):
@ -400,3 +421,15 @@ class WithingsSensor(WithingsEntity, SensorEntity):
super().available
and self.entity_description.measurement in self.coordinator.data
)
class WithingsMeasurementSensor(WithingsSensor):
"""Implementation of a Withings measurement sensor."""
coordinator: WithingsMeasurementDataUpdateCoordinator
class WithingsSleepSensor(WithingsSensor):
"""Implementation of a Withings sleep sensor."""
coordinator: WithingsSleepDataUpdateCoordinator

View File

@ -223,13 +223,14 @@ async def test_triggering_reauth(
withings: AsyncMock,
polling_config_entry: MockConfigEntry,
error: Exception,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test triggering reauth."""
await setup_integration(hass, polling_config_entry, False)
withings.async_measure_get_meas.side_effect = error
future = dt_util.utcnow() + timedelta(minutes=10)
async_fire_time_changed(hass, future)
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()

View File

@ -8,7 +8,7 @@ from syrupy import SnapshotAssertion
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.withings.const import DOMAIN
from homeassistant.components.withings.sensor import SENSORS
from homeassistant.components.withings.sensor import MEASUREMENT_SENSORS, SLEEP_SENSORS
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@ -42,7 +42,7 @@ async def test_all_entities(
"""Test all entities."""
await setup_integration(hass, polling_config_entry)
for sensor in SENSORS:
for sensor in MEASUREMENT_SENSORS + SLEEP_SENSORS:
entity_id = await async_get_entity_id(hass, sensor.key, USER_ID, SENSOR_DOMAIN)
assert hass.states.get(entity_id) == snapshot