267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""Withings coordinator."""
|
|
import asyncio
|
|
from collections.abc import Callable
|
|
from datetime import timedelta
|
|
from typing import Any
|
|
|
|
from withings_api.common import (
|
|
AuthFailedException,
|
|
GetSleepSummaryField,
|
|
MeasureGroupAttribs,
|
|
MeasureType,
|
|
MeasureTypes,
|
|
NotifyAppli,
|
|
UnauthorizedException,
|
|
query_measure_groups,
|
|
)
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
from .api import ConfigEntryWithingsApi
|
|
from .const import LOGGER, Measurement
|
|
|
|
SUBSCRIBE_DELAY = timedelta(seconds=5)
|
|
UNSUBSCRIBE_DELAY = timedelta(seconds=1)
|
|
|
|
WITHINGS_MEASURE_TYPE_MAP: dict[
|
|
NotifyAppli | GetSleepSummaryField | MeasureType, Measurement
|
|
] = {
|
|
MeasureType.WEIGHT: Measurement.WEIGHT_KG,
|
|
MeasureType.FAT_MASS_WEIGHT: Measurement.FAT_MASS_KG,
|
|
MeasureType.FAT_FREE_MASS: Measurement.FAT_FREE_MASS_KG,
|
|
MeasureType.MUSCLE_MASS: Measurement.MUSCLE_MASS_KG,
|
|
MeasureType.BONE_MASS: Measurement.BONE_MASS_KG,
|
|
MeasureType.HEIGHT: Measurement.HEIGHT_M,
|
|
MeasureType.TEMPERATURE: Measurement.TEMP_C,
|
|
MeasureType.BODY_TEMPERATURE: Measurement.BODY_TEMP_C,
|
|
MeasureType.SKIN_TEMPERATURE: Measurement.SKIN_TEMP_C,
|
|
MeasureType.FAT_RATIO: Measurement.FAT_RATIO_PCT,
|
|
MeasureType.DIASTOLIC_BLOOD_PRESSURE: Measurement.DIASTOLIC_MMHG,
|
|
MeasureType.SYSTOLIC_BLOOD_PRESSURE: Measurement.SYSTOLIC_MMGH,
|
|
MeasureType.HEART_RATE: Measurement.HEART_PULSE_BPM,
|
|
MeasureType.SP02: Measurement.SPO2_PCT,
|
|
MeasureType.HYDRATION: Measurement.HYDRATION,
|
|
MeasureType.PULSE_WAVE_VELOCITY: Measurement.PWV,
|
|
GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY: (
|
|
Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY
|
|
),
|
|
GetSleepSummaryField.DEEP_SLEEP_DURATION: Measurement.SLEEP_DEEP_DURATION_SECONDS,
|
|
GetSleepSummaryField.DURATION_TO_SLEEP: Measurement.SLEEP_TOSLEEP_DURATION_SECONDS,
|
|
GetSleepSummaryField.DURATION_TO_WAKEUP: (
|
|
Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS
|
|
),
|
|
GetSleepSummaryField.HR_AVERAGE: Measurement.SLEEP_HEART_RATE_AVERAGE,
|
|
GetSleepSummaryField.HR_MAX: Measurement.SLEEP_HEART_RATE_MAX,
|
|
GetSleepSummaryField.HR_MIN: Measurement.SLEEP_HEART_RATE_MIN,
|
|
GetSleepSummaryField.LIGHT_SLEEP_DURATION: Measurement.SLEEP_LIGHT_DURATION_SECONDS,
|
|
GetSleepSummaryField.REM_SLEEP_DURATION: Measurement.SLEEP_REM_DURATION_SECONDS,
|
|
GetSleepSummaryField.RR_AVERAGE: Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE,
|
|
GetSleepSummaryField.RR_MAX: Measurement.SLEEP_RESPIRATORY_RATE_MAX,
|
|
GetSleepSummaryField.RR_MIN: Measurement.SLEEP_RESPIRATORY_RATE_MIN,
|
|
GetSleepSummaryField.SLEEP_SCORE: Measurement.SLEEP_SCORE,
|
|
GetSleepSummaryField.SNORING: Measurement.SLEEP_SNORING,
|
|
GetSleepSummaryField.SNORING_EPISODE_COUNT: Measurement.SLEEP_SNORING_EPISODE_COUNT,
|
|
GetSleepSummaryField.WAKEUP_COUNT: Measurement.SLEEP_WAKEUP_COUNT,
|
|
GetSleepSummaryField.WAKEUP_DURATION: Measurement.SLEEP_WAKEUP_DURATION_SECONDS,
|
|
NotifyAppli.BED_IN: Measurement.IN_BED,
|
|
}
|
|
|
|
UPDATE_INTERVAL = timedelta(minutes=10)
|
|
|
|
|
|
class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]]):
|
|
"""Base coordinator."""
|
|
|
|
in_bed: bool | None = None
|
|
config_entry: ConfigEntry
|
|
|
|
def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None:
|
|
"""Initialize the Withings data coordinator."""
|
|
super().__init__(hass, LOGGER, name="Withings", update_interval=UPDATE_INTERVAL)
|
|
self._client = client
|
|
|
|
async def async_subscribe_webhooks(self, webhook_url: str) -> None:
|
|
"""Subscribe to webhooks."""
|
|
await self.async_unsubscribe_webhooks()
|
|
|
|
current_webhooks = await self._client.async_notify_list()
|
|
|
|
subscribed_notifications = frozenset(
|
|
profile.appli
|
|
for profile in current_webhooks.profiles
|
|
if profile.callbackurl == webhook_url
|
|
)
|
|
|
|
notification_to_subscribe = (
|
|
set(NotifyAppli)
|
|
- subscribed_notifications
|
|
- {NotifyAppli.USER, NotifyAppli.UNKNOWN}
|
|
)
|
|
|
|
for notification in notification_to_subscribe:
|
|
LOGGER.debug(
|
|
"Subscribing %s for %s in %s seconds",
|
|
webhook_url,
|
|
notification,
|
|
SUBSCRIBE_DELAY.total_seconds(),
|
|
)
|
|
# Withings will HTTP HEAD the callback_url and needs some downtime
|
|
# between each call or there is a higher chance of failure.
|
|
await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds())
|
|
await self._client.async_notify_subscribe(webhook_url, notification)
|
|
self.update_interval = None
|
|
|
|
async def async_unsubscribe_webhooks(self) -> None:
|
|
"""Unsubscribe to webhooks."""
|
|
current_webhooks = await self._client.async_notify_list()
|
|
|
|
for webhook_configuration in current_webhooks.profiles:
|
|
LOGGER.debug(
|
|
"Unsubscribing %s for %s in %s seconds",
|
|
webhook_configuration.callbackurl,
|
|
webhook_configuration.appli,
|
|
UNSUBSCRIBE_DELAY.total_seconds(),
|
|
)
|
|
# Quick calls to Withings can result in the service returning errors.
|
|
# Give them some time to cool down.
|
|
await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds())
|
|
await self._client.async_notify_revoke(
|
|
webhook_configuration.callbackurl, webhook_configuration.appli
|
|
)
|
|
self.update_interval = UPDATE_INTERVAL
|
|
|
|
async def _async_update_data(self) -> dict[Measurement, Any]:
|
|
try:
|
|
measurements = await self._get_measurements()
|
|
sleep_summary = await self._get_sleep_summary()
|
|
except (UnauthorizedException, AuthFailedException) as exc:
|
|
raise ConfigEntryAuthFailed from exc
|
|
return {
|
|
**measurements,
|
|
**sleep_summary,
|
|
}
|
|
|
|
async def _get_measurements(self) -> dict[Measurement, Any]:
|
|
LOGGER.debug("Updating withings measures")
|
|
now = dt_util.utcnow()
|
|
startdate = now - timedelta(days=7)
|
|
|
|
response = await self._client.async_measure_get_meas(
|
|
None, None, startdate, now, None, startdate
|
|
)
|
|
|
|
# Sort from oldest to newest.
|
|
groups = sorted(
|
|
query_measure_groups(
|
|
response, MeasureTypes.ANY, MeasureGroupAttribs.UNAMBIGUOUS
|
|
),
|
|
key=lambda group: group.created.datetime,
|
|
reverse=False,
|
|
)
|
|
|
|
return {
|
|
WITHINGS_MEASURE_TYPE_MAP[measure.type]: round(
|
|
float(measure.value * pow(10, measure.unit)), 2
|
|
)
|
|
for group in groups
|
|
for measure in group.measures
|
|
if measure.type in WITHINGS_MEASURE_TYPE_MAP
|
|
}
|
|
|
|
async def _get_sleep_summary(self) -> dict[Measurement, Any]:
|
|
now = dt_util.now()
|
|
yesterday = now - timedelta(days=1)
|
|
yesterday_noon = dt_util.start_of_local_day(yesterday) + timedelta(hours=12)
|
|
yesterday_noon_utc = dt_util.as_utc(yesterday_noon)
|
|
|
|
response = await self._client.async_sleep_get_summary(
|
|
lastupdate=yesterday_noon_utc,
|
|
data_fields=[
|
|
GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY,
|
|
GetSleepSummaryField.DEEP_SLEEP_DURATION,
|
|
GetSleepSummaryField.DURATION_TO_SLEEP,
|
|
GetSleepSummaryField.DURATION_TO_WAKEUP,
|
|
GetSleepSummaryField.HR_AVERAGE,
|
|
GetSleepSummaryField.HR_MAX,
|
|
GetSleepSummaryField.HR_MIN,
|
|
GetSleepSummaryField.LIGHT_SLEEP_DURATION,
|
|
GetSleepSummaryField.REM_SLEEP_DURATION,
|
|
GetSleepSummaryField.RR_AVERAGE,
|
|
GetSleepSummaryField.RR_MAX,
|
|
GetSleepSummaryField.RR_MIN,
|
|
GetSleepSummaryField.SLEEP_SCORE,
|
|
GetSleepSummaryField.SNORING,
|
|
GetSleepSummaryField.SNORING_EPISODE_COUNT,
|
|
GetSleepSummaryField.WAKEUP_COUNT,
|
|
GetSleepSummaryField.WAKEUP_DURATION,
|
|
],
|
|
)
|
|
|
|
# Set the default to empty lists.
|
|
raw_values: dict[GetSleepSummaryField, list[int]] = {
|
|
field: [] for field in GetSleepSummaryField
|
|
}
|
|
|
|
# Collect the raw data.
|
|
for serie in response.series:
|
|
data = serie.data
|
|
|
|
for field in GetSleepSummaryField:
|
|
raw_values[field].append(dict(data)[field.value])
|
|
|
|
values: dict[GetSleepSummaryField, float] = {}
|
|
|
|
def average(data: list[int]) -> float:
|
|
return sum(data) / len(data)
|
|
|
|
def set_value(field: GetSleepSummaryField, func: Callable) -> None:
|
|
non_nones = [
|
|
value for value in raw_values.get(field, []) if value is not None
|
|
]
|
|
values[field] = func(non_nones) if non_nones else None
|
|
|
|
set_value(GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, average)
|
|
set_value(GetSleepSummaryField.DEEP_SLEEP_DURATION, sum)
|
|
set_value(GetSleepSummaryField.DURATION_TO_SLEEP, average)
|
|
set_value(GetSleepSummaryField.DURATION_TO_WAKEUP, average)
|
|
set_value(GetSleepSummaryField.HR_AVERAGE, average)
|
|
set_value(GetSleepSummaryField.HR_MAX, average)
|
|
set_value(GetSleepSummaryField.HR_MIN, average)
|
|
set_value(GetSleepSummaryField.LIGHT_SLEEP_DURATION, sum)
|
|
set_value(GetSleepSummaryField.REM_SLEEP_DURATION, sum)
|
|
set_value(GetSleepSummaryField.RR_AVERAGE, average)
|
|
set_value(GetSleepSummaryField.RR_MAX, average)
|
|
set_value(GetSleepSummaryField.RR_MIN, average)
|
|
set_value(GetSleepSummaryField.SLEEP_SCORE, max)
|
|
set_value(GetSleepSummaryField.SNORING, average)
|
|
set_value(GetSleepSummaryField.SNORING_EPISODE_COUNT, sum)
|
|
set_value(GetSleepSummaryField.WAKEUP_COUNT, sum)
|
|
set_value(GetSleepSummaryField.WAKEUP_DURATION, average)
|
|
|
|
return {
|
|
WITHINGS_MEASURE_TYPE_MAP[field]: round(value, 4)
|
|
if value is not None
|
|
else None
|
|
for field, value in values.items()
|
|
}
|
|
|
|
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()
|
|
|
|
elif notification_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}:
|
|
self.in_bed = notification_category == NotifyAppli.BED_IN
|
|
self.async_update_listeners()
|