Use aiowithings (#101819)

* Subscribe to Withings webhooks outside of coordinator

* Subscribe to Withings webhooks outside of coordinator

* Split Withings coordinator

* Split Withings coordinator

* Use aiowithings

* Use aiowithings

* Use aiowithings

* Update homeassistant/components/withings/sensor.py

* Merge

* Remove startdate

* Minor fixes

* Bump to 0.4.1

* Fix snapshot

* Fix datapoint

* Bump to 0.4.2

* Bump to 0.4.3

* Bump to 0.4.4
pull/101998/head
Joost Lekkerkerker 2023-10-14 11:52:35 +02:00 committed by GitHub
parent 89d86fe983
commit 8a4fe5add1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 359 additions and 662 deletions

View File

@ -1514,7 +1514,6 @@ omit =
homeassistant/components/wiffi/sensor.py
homeassistant/components/wiffi/wiffi_strings.py
homeassistant/components/wirelesstag/*
homeassistant/components/withings/api.py
homeassistant/components/wolflink/__init__.py
homeassistant/components/wolflink/sensor.py
homeassistant/components/worldtidesinfo/sensor.py

View File

@ -12,8 +12,9 @@ from typing import Any
from aiohttp.hdrs import METH_HEAD, METH_POST
from aiohttp.web import Request, Response
from aiowithings import NotificationCategory, WithingsClient
from aiowithings.util import to_enum
import voluptuous as vol
from withings_api.common import NotifyAppli
from homeassistant.components import cloud
from homeassistant.components.application_credentials import (
@ -29,6 +30,7 @@ from homeassistant.components.webhook import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_TOKEN,
@ -37,12 +39,16 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from .api import ConfigEntryWithingsApi
from .const import (
BED_PRESENCE_COORDINATOR,
CONF_PROFILES,
@ -134,14 +140,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.config_entries.async_update_entry(
entry, data=new_data, unique_id=unique_id
)
session = async_get_clientsession(hass)
client = WithingsClient(session=session)
implementation = await async_get_config_entry_implementation(hass, entry)
oauth_session = OAuth2Session(hass, entry, implementation)
client = ConfigEntryWithingsApi(
hass=hass,
config_entry=entry,
implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
),
)
async def _refresh_token() -> str:
await oauth_session.async_ensure_token_valid()
return oauth_session.token[CONF_ACCESS_TOKEN]
client.refresh_token_function = _refresh_token
coordinators: dict[str, WithingsDataUpdateCoordinator] = {
MEASUREMENT_COORDINATOR: WithingsMeasurementDataUpdateCoordinator(hass, client),
SLEEP_COORDINATOR: WithingsSleepDataUpdateCoordinator(hass, client),
@ -230,19 +238,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
async def async_subscribe_webhooks(
client: ConfigEntryWithingsApi, webhook_url: str
) -> None:
async def async_subscribe_webhooks(client: WithingsClient, webhook_url: str) -> None:
"""Subscribe to Withings webhooks."""
await async_unsubscribe_webhooks(client)
notification_to_subscribe = {
NotifyAppli.WEIGHT,
NotifyAppli.CIRCULATORY,
NotifyAppli.ACTIVITY,
NotifyAppli.SLEEP,
NotifyAppli.BED_IN,
NotifyAppli.BED_OUT,
NotificationCategory.WEIGHT,
NotificationCategory.PRESSURE,
NotificationCategory.ACTIVITY,
NotificationCategory.SLEEP,
NotificationCategory.IN_BED,
NotificationCategory.OUT_BED,
}
for notification in notification_to_subscribe:
@ -255,25 +261,26 @@ async def async_subscribe_webhooks(
# 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 client.async_notify_subscribe(webhook_url, notification)
await client.subscribe_notification(webhook_url, notification)
async def async_unsubscribe_webhooks(client: ConfigEntryWithingsApi) -> None:
async def async_unsubscribe_webhooks(client: WithingsClient) -> None:
"""Unsubscribe to all Withings webhooks."""
current_webhooks = await client.async_notify_list()
current_webhooks = await client.list_notification_configurations()
for webhook_configuration in current_webhooks.profiles:
for webhook_configuration in current_webhooks:
LOGGER.debug(
"Unsubscribing %s for %s in %s seconds",
webhook_configuration.callbackurl,
webhook_configuration.appli,
webhook_configuration.callback_url,
webhook_configuration.notification_category,
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 client.async_notify_revoke(
webhook_configuration.callbackurl, webhook_configuration.appli
await client.revoke_notification_configurations(
webhook_configuration.callback_url,
webhook_configuration.notification_category,
)
@ -336,14 +343,15 @@ def get_webhook_handler(
"Parameter appli not provided", message_code=20
)
try:
appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type]
except ValueError:
return json_message_response("Invalid appli provided", message_code=21)
notification_category = to_enum(
NotificationCategory,
int(params.getone("appli")), # type: ignore[arg-type]
NotificationCategory.UNKNOWN,
)
for coordinator in coordinators.values():
if appli in coordinator.notification_categories:
await coordinator.async_webhook_data_updated(appli)
if notification_category in coordinator.notification_categories:
await coordinator.async_webhook_data_updated(notification_category)
return json_message_response("Success", message_code=0)

View File

@ -1,170 +0,0 @@
"""Api for Withings."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Iterable
from typing import Any
import arrow
import requests
from withings_api import AbstractWithingsApi, DateType
from withings_api.common import (
GetSleepSummaryField,
MeasureGetMeasGroupCategory,
MeasureGetMeasResponse,
MeasureType,
NotifyAppli,
NotifyListResponse,
SleepGetSummaryResponse,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
AbstractOAuth2Implementation,
OAuth2Session,
)
from .const import LOGGER
_RETRY_COEFFICIENT = 0.5
class ConfigEntryWithingsApi(AbstractWithingsApi):
"""Withing API that uses HA resources."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
implementation: AbstractOAuth2Implementation,
) -> None:
"""Initialize object."""
self._hass = hass
self.config_entry = config_entry
self._implementation = implementation
self.session = OAuth2Session(hass, config_entry, implementation)
def _request(
self, path: str, params: dict[str, Any], method: str = "GET"
) -> dict[str, Any]:
"""Perform an async request."""
asyncio.run_coroutine_threadsafe(
self.session.async_ensure_token_valid(), self._hass.loop
).result()
access_token = self.config_entry.data["token"]["access_token"]
response = requests.request(
method,
f"{self.URL}/{path}",
params=params,
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
return response.json()
async def _do_retry(self, func: Callable[[], Awaitable[Any]], attempts=3) -> Any:
"""Retry a function call.
Withings' API occasionally and incorrectly throws errors.
Retrying the call tends to work.
"""
exception = None
for attempt in range(1, attempts + 1):
LOGGER.debug("Attempt %s of %s", attempt, attempts)
try:
return await func()
except Exception as exception1: # pylint: disable=broad-except
LOGGER.debug(
"Failed attempt %s of %s (%s)", attempt, attempts, exception1
)
# Make each backoff pause a little bit longer
await asyncio.sleep(_RETRY_COEFFICIENT * attempt)
exception = exception1
continue
if exception:
raise exception
async def async_measure_get_meas(
self,
meastype: MeasureType | None = None,
category: MeasureGetMeasGroupCategory | None = None,
startdate: DateType | None = arrow.utcnow(),
enddate: DateType | None = arrow.utcnow(),
offset: int | None = None,
lastupdate: DateType | None = arrow.utcnow(),
) -> MeasureGetMeasResponse:
"""Get measurements."""
async def call_super() -> MeasureGetMeasResponse:
return await self._hass.async_add_executor_job(
self.measure_get_meas,
meastype,
category,
startdate,
enddate,
offset,
lastupdate,
)
return await self._do_retry(call_super)
async def async_sleep_get_summary(
self,
data_fields: Iterable[GetSleepSummaryField],
startdateymd: DateType | None = arrow.utcnow(),
enddateymd: DateType | None = arrow.utcnow(),
offset: int | None = None,
lastupdate: DateType | None = arrow.utcnow(),
) -> SleepGetSummaryResponse:
"""Get sleep data."""
async def call_super() -> SleepGetSummaryResponse:
return await self._hass.async_add_executor_job(
self.sleep_get_summary,
data_fields,
startdateymd,
enddateymd,
offset,
lastupdate,
)
return await self._do_retry(call_super)
async def async_notify_list(
self, appli: NotifyAppli | None = None
) -> NotifyListResponse:
"""List webhooks."""
async def call_super() -> NotifyListResponse:
return await self._hass.async_add_executor_job(self.notify_list, appli)
return await self._do_retry(call_super)
async def async_notify_subscribe(
self,
callbackurl: str,
appli: NotifyAppli | None = None,
comment: str | None = None,
) -> None:
"""Subscribe to webhook."""
async def call_super() -> None:
await self._hass.async_add_executor_job(
self.notify_subscribe, callbackurl, appli, comment
)
await self._do_retry(call_super)
async def async_notify_revoke(
self, callbackurl: str | None = None, appli: NotifyAppli | None = None
) -> None:
"""Revoke webhook."""
async def call_super() -> None:
await self._hass.async_add_executor_job(
self.notify_revoke, callbackurl, appli
)
await self._do_retry(call_super)

View File

@ -2,7 +2,7 @@
from typing import Any
from withings_api import AbstractWithingsApi, WithingsAuth
from aiowithings import AUTHORIZATION_URL, TOKEN_URL
from homeassistant.components.application_credentials import (
AuthImplementation,
@ -24,8 +24,8 @@ async def async_get_auth_implementation(
DOMAIN,
credential,
authorization_server=AuthorizationServer(
authorize_url=f"{WithingsAuth.URL}/oauth2_user/authorize2",
token_url=f"{AbstractWithingsApi.URL}/v2/oauth2",
authorize_url=AUTHORIZATION_URL,
token_url=TOKEN_URL,
),
)

View File

@ -5,7 +5,7 @@ from collections.abc import Mapping
import logging
from typing import Any
from withings_api.common import AuthScope
from aiowithings import AuthScope
from homeassistant.components.webhook import async_generate_id
from homeassistant.config_entries import ConfigEntry
@ -36,10 +36,10 @@ class WithingsFlowHandler(
return {
"scope": ",".join(
[
AuthScope.USER_INFO.value,
AuthScope.USER_METRICS.value,
AuthScope.USER_ACTIVITY.value,
AuthScope.USER_SLEEP_EVENTS.value,
AuthScope.USER_INFO,
AuthScope.USER_METRICS,
AuthScope.USER_ACTIVITY,
AuthScope.USER_SLEEP_EVENTS,
]
)
}

View File

@ -1,5 +1,4 @@
"""Constants used by the Withings component."""
from enum import StrEnum
import logging
DEFAULT_TITLE = "Withings"
@ -21,45 +20,6 @@ BED_PRESENCE_COORDINATOR = "bed_presence_coordinator"
LOGGER = logging.getLogger(__package__)
class Measurement(StrEnum):
"""Measurement supported by the withings integration."""
BODY_TEMP_C = "body_temperature_c"
BONE_MASS_KG = "bone_mass_kg"
DIASTOLIC_MMHG = "diastolic_blood_pressure_mmhg"
FAT_FREE_MASS_KG = "fat_free_mass_kg"
FAT_MASS_KG = "fat_mass_kg"
FAT_RATIO_PCT = "fat_ratio_pct"
HEART_PULSE_BPM = "heart_pulse_bpm"
HEIGHT_M = "height_m"
HYDRATION = "hydration"
IN_BED = "in_bed"
MUSCLE_MASS_KG = "muscle_mass_kg"
PWV = "pulse_wave_velocity"
SKIN_TEMP_C = "skin_temperature_c"
SLEEP_BREATHING_DISTURBANCES_INTENSITY = "sleep_breathing_disturbances_intensity"
SLEEP_DEEP_DURATION_SECONDS = "sleep_deep_duration_seconds"
SLEEP_HEART_RATE_AVERAGE = "sleep_heart_rate_average_bpm"
SLEEP_HEART_RATE_MAX = "sleep_heart_rate_max_bpm"
SLEEP_HEART_RATE_MIN = "sleep_heart_rate_min_bpm"
SLEEP_LIGHT_DURATION_SECONDS = "sleep_light_duration_seconds"
SLEEP_REM_DURATION_SECONDS = "sleep_rem_duration_seconds"
SLEEP_RESPIRATORY_RATE_AVERAGE = "sleep_respiratory_average_bpm"
SLEEP_RESPIRATORY_RATE_MAX = "sleep_respiratory_max_bpm"
SLEEP_RESPIRATORY_RATE_MIN = "sleep_respiratory_min_bpm"
SLEEP_SCORE = "sleep_score"
SLEEP_SNORING = "sleep_snoring"
SLEEP_SNORING_EPISODE_COUNT = "sleep_snoring_eposode_count"
SLEEP_TOSLEEP_DURATION_SECONDS = "sleep_tosleep_duration_seconds"
SLEEP_TOWAKEUP_DURATION_SECONDS = "sleep_towakeup_duration_seconds"
SLEEP_WAKEUP_COUNT = "sleep_wakeup_count"
SLEEP_WAKEUP_DURATION_SECONDS = "sleep_wakeup_duration_seconds"
SPO2_PCT = "spo2_pct"
SYSTOLIC_MMGH = "systolic_blood_pressure_mmhg"
TEMP_C = "temperature_c"
WEIGHT_KG = "weight_kg"
SCORE_POINTS = "points"
UOM_BEATS_PER_MINUTE = "bpm"
UOM_BREATHS_PER_MINUTE = "br/min"

View File

@ -1,19 +1,19 @@
"""Withings coordinator."""
from abc import abstractmethod
from collections.abc import Callable
from datetime import timedelta
from typing import Any, TypeVar
from typing import TypeVar
from withings_api.common import (
AuthFailedException,
GetSleepSummaryField,
MeasureGroupAttribs,
MeasureType,
MeasureTypes,
NotifyAppli,
UnauthorizedException,
query_measure_groups,
from aiowithings import (
MeasurementType,
NotificationCategory,
SleepSummary,
SleepSummaryDataFields,
WithingsAuthenticationFailedError,
WithingsClient,
WithingsUnauthorizedError,
aggregate_measurements,
)
from aiowithings.helpers import aggregate_sleep_summary
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@ -21,51 +21,7 @@ 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
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,
}
from .const import LOGGER
_T = TypeVar("_T")
@ -78,13 +34,13 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]):
config_entry: ConfigEntry
_default_update_interval: timedelta | None = UPDATE_INTERVAL
def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None:
def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None:
"""Initialize the Withings data coordinator."""
super().__init__(
hass, LOGGER, name="Withings", update_interval=self._default_update_interval
)
self._client = client
self.notification_categories: set[NotifyAppli] = set()
self.notification_categories: set[NotificationCategory] = set()
def webhook_subscription_listener(self, connected: bool) -> None:
"""Call when webhook status changed."""
@ -94,7 +50,7 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]):
self.update_interval = self._default_update_interval
async def async_webhook_data_updated(
self, notification_category: NotifyAppli
self, notification_category: NotificationCategory
) -> None:
"""Update data when webhook is called."""
LOGGER.debug("Withings webhook triggered for %s", notification_category)
@ -103,7 +59,7 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]):
async def _async_update_data(self) -> _T:
try:
return await self._internal_update_data()
except (UnauthorizedException, AuthFailedException) as exc:
except (WithingsUnauthorizedError, WithingsAuthenticationFailedError) as exc:
raise ConfigEntryAuthFailed from exc
@abstractmethod
@ -112,136 +68,71 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]):
class WithingsMeasurementDataUpdateCoordinator(
WithingsDataUpdateCoordinator[dict[Measurement, Any]]
WithingsDataUpdateCoordinator[dict[MeasurementType, float]]
):
"""Withings measurement coordinator."""
def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None:
def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None:
"""Initialize the Withings data coordinator."""
super().__init__(hass, client)
self.notification_categories = {
NotifyAppli.WEIGHT,
NotifyAppli.ACTIVITY,
NotifyAppli.CIRCULATORY,
NotificationCategory.WEIGHT,
NotificationCategory.ACTIVITY,
NotificationCategory.PRESSURE,
}
async def _internal_update_data(self) -> dict[Measurement, Any]:
async def _internal_update_data(self) -> dict[MeasurementType, float]:
"""Retrieve measurement data."""
now = dt_util.utcnow()
startdate = now - timedelta(days=7)
response = await self._client.async_measure_get_meas(
None, None, startdate, now, None, startdate
)
response = await self._client.get_measurement_in_period(startdate, now)
# 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
}
return aggregate_measurements(response)
class WithingsSleepDataUpdateCoordinator(
WithingsDataUpdateCoordinator[dict[Measurement, Any]]
WithingsDataUpdateCoordinator[SleepSummary | None]
):
"""Withings sleep coordinator."""
def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None:
def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None:
"""Initialize the Withings data coordinator."""
super().__init__(hass, client)
self.notification_categories = {
NotifyAppli.SLEEP,
NotificationCategory.SLEEP,
}
async def _internal_update_data(self) -> dict[Measurement, Any]:
async def _internal_update_data(self) -> SleepSummary | None:
"""Retrieve sleep data."""
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,
response = await self._client.get_sleep_summary_since(
sleep_summary_since=yesterday_noon_utc,
sleep_summary_data_fields=[
SleepSummaryDataFields.BREATHING_DISTURBANCES_INTENSITY,
SleepSummaryDataFields.DEEP_SLEEP_DURATION,
SleepSummaryDataFields.SLEEP_LATENCY,
SleepSummaryDataFields.WAKE_UP_LATENCY,
SleepSummaryDataFields.AVERAGE_HEART_RATE,
SleepSummaryDataFields.MIN_HEART_RATE,
SleepSummaryDataFields.MAX_HEART_RATE,
SleepSummaryDataFields.LIGHT_SLEEP_DURATION,
SleepSummaryDataFields.REM_SLEEP_DURATION,
SleepSummaryDataFields.AVERAGE_RESPIRATION_RATE,
SleepSummaryDataFields.MIN_RESPIRATION_RATE,
SleepSummaryDataFields.MAX_RESPIRATION_RATE,
SleepSummaryDataFields.SLEEP_SCORE,
SleepSummaryDataFields.SNORING,
SleepSummaryDataFields.SNORING_COUNT,
SleepSummaryDataFields.WAKE_UP_COUNT,
SleepSummaryDataFields.TOTAL_TIME_AWAKE,
],
)
# 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()
}
return aggregate_sleep_summary(response)
class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[None]):
@ -250,19 +141,19 @@ class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[Non
in_bed: bool | None = None
_default_update_interval = None
def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None:
def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None:
"""Initialize the Withings data coordinator."""
super().__init__(hass, client)
self.notification_categories = {
NotifyAppli.BED_IN,
NotifyAppli.BED_OUT,
NotificationCategory.IN_BED,
NotificationCategory.OUT_BED,
}
async def async_webhook_data_updated(
self, notification_category: NotifyAppli
self, notification_category: NotificationCategory
) -> None:
"""Only set new in bed value instead of refresh."""
self.in_bed = notification_category == NotifyAppli.BED_IN
self.in_bed = notification_category == NotificationCategory.IN_BED
self.async_update_listeners()
async def _internal_update_data(self) -> None:

View File

@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/withings",
"iot_class": "cloud_polling",
"loggers": ["withings_api"],
"requirements": ["withings-api==2.4.0"]
"requirements": ["aiowithings==0.4.4"]
}

View File

@ -1,9 +1,10 @@
"""Sensors flow for Withings."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from withings_api.common import GetSleepSummaryField, MeasureType
from aiowithings import MeasurementType, SleepSummary
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -22,6 +23,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
DOMAIN,
@ -32,7 +34,6 @@ from .const import (
UOM_BREATHS_PER_MINUTE,
UOM_FREQUENCY,
UOM_MMHG,
Measurement,
)
from .coordinator import (
WithingsDataUpdateCoordinator,
@ -43,146 +44,130 @@ from .entity import WithingsEntity
@dataclass
class WithingsEntityDescriptionMixin:
class WithingsMeasurementSensorEntityDescriptionMixin:
"""Mixin for describing withings data."""
measurement: Measurement
measure_type: GetSleepSummaryField | MeasureType
measurement_type: MeasurementType
@dataclass
class WithingsSensorEntityDescription(
SensorEntityDescription, WithingsEntityDescriptionMixin
class WithingsMeasurementSensorEntityDescription(
SensorEntityDescription, WithingsMeasurementSensorEntityDescriptionMixin
):
"""Immutable class for describing withings data."""
MEASUREMENT_SENSORS = [
WithingsSensorEntityDescription(
key=Measurement.WEIGHT_KG.value,
measurement=Measurement.WEIGHT_KG,
measure_type=MeasureType.WEIGHT,
WithingsMeasurementSensorEntityDescription(
key="weight_kg",
measurement_type=MeasurementType.WEIGHT,
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
device_class=SensorDeviceClass.WEIGHT,
state_class=SensorStateClass.MEASUREMENT,
),
WithingsSensorEntityDescription(
key=Measurement.FAT_MASS_KG.value,
measurement=Measurement.FAT_MASS_KG,
measure_type=MeasureType.FAT_MASS_WEIGHT,
WithingsMeasurementSensorEntityDescription(
key="fat_mass_kg",
measurement_type=MeasurementType.FAT_MASS_WEIGHT,
translation_key="fat_mass",
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
device_class=SensorDeviceClass.WEIGHT,
state_class=SensorStateClass.MEASUREMENT,
),
WithingsSensorEntityDescription(
key=Measurement.FAT_FREE_MASS_KG.value,
measurement=Measurement.FAT_FREE_MASS_KG,
measure_type=MeasureType.FAT_FREE_MASS,
WithingsMeasurementSensorEntityDescription(
key="fat_free_mass_kg",
measurement_type=MeasurementType.FAT_FREE_MASS,
translation_key="fat_free_mass",
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
device_class=SensorDeviceClass.WEIGHT,
state_class=SensorStateClass.MEASUREMENT,
),
WithingsSensorEntityDescription(
key=Measurement.MUSCLE_MASS_KG.value,
measurement=Measurement.MUSCLE_MASS_KG,
measure_type=MeasureType.MUSCLE_MASS,
WithingsMeasurementSensorEntityDescription(
key="muscle_mass_kg",
measurement_type=MeasurementType.MUSCLE_MASS,
translation_key="muscle_mass",
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
device_class=SensorDeviceClass.WEIGHT,
state_class=SensorStateClass.MEASUREMENT,
),
WithingsSensorEntityDescription(
key=Measurement.BONE_MASS_KG.value,
measurement=Measurement.BONE_MASS_KG,
measure_type=MeasureType.BONE_MASS,
WithingsMeasurementSensorEntityDescription(
key="bone_mass_kg",
measurement_type=MeasurementType.BONE_MASS,
translation_key="bone_mass",
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
device_class=SensorDeviceClass.WEIGHT,
state_class=SensorStateClass.MEASUREMENT,
),
WithingsSensorEntityDescription(
key=Measurement.HEIGHT_M.value,
measurement=Measurement.HEIGHT_M,
measure_type=MeasureType.HEIGHT,
WithingsMeasurementSensorEntityDescription(
key="height_m",
measurement_type=MeasurementType.HEIGHT,
translation_key="height",
native_unit_of_measurement=UnitOfLength.METERS,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.TEMP_C.value,
measurement=Measurement.TEMP_C,
measure_type=MeasureType.TEMPERATURE,
WithingsMeasurementSensorEntityDescription(
key="temperature_c",
measurement_type=MeasurementType.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
WithingsSensorEntityDescription(
key=Measurement.BODY_TEMP_C.value,
measurement=Measurement.BODY_TEMP_C,
measure_type=MeasureType.BODY_TEMPERATURE,
WithingsMeasurementSensorEntityDescription(
key="body_temperature_c",
measurement_type=MeasurementType.BODY_TEMPERATURE,
translation_key="body_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
WithingsSensorEntityDescription(
key=Measurement.SKIN_TEMP_C.value,
measurement=Measurement.SKIN_TEMP_C,
measure_type=MeasureType.SKIN_TEMPERATURE,
WithingsMeasurementSensorEntityDescription(
key="skin_temperature_c",
measurement_type=MeasurementType.SKIN_TEMPERATURE,
translation_key="skin_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
WithingsSensorEntityDescription(
key=Measurement.FAT_RATIO_PCT.value,
measurement=Measurement.FAT_RATIO_PCT,
measure_type=MeasureType.FAT_RATIO,
WithingsMeasurementSensorEntityDescription(
key="fat_ratio_pct",
measurement_type=MeasurementType.FAT_RATIO,
translation_key="fat_ratio",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
WithingsSensorEntityDescription(
key=Measurement.DIASTOLIC_MMHG.value,
measurement=Measurement.DIASTOLIC_MMHG,
measure_type=MeasureType.DIASTOLIC_BLOOD_PRESSURE,
WithingsMeasurementSensorEntityDescription(
key="diastolic_blood_pressure_mmhg",
measurement_type=MeasurementType.DIASTOLIC_BLOOD_PRESSURE,
translation_key="diastolic_blood_pressure",
native_unit_of_measurement=UOM_MMHG,
state_class=SensorStateClass.MEASUREMENT,
),
WithingsSensorEntityDescription(
key=Measurement.SYSTOLIC_MMGH.value,
measurement=Measurement.SYSTOLIC_MMGH,
measure_type=MeasureType.SYSTOLIC_BLOOD_PRESSURE,
WithingsMeasurementSensorEntityDescription(
key="systolic_blood_pressure_mmhg",
measurement_type=MeasurementType.SYSTOLIC_BLOOD_PRESSURE,
translation_key="systolic_blood_pressure",
native_unit_of_measurement=UOM_MMHG,
state_class=SensorStateClass.MEASUREMENT,
),
WithingsSensorEntityDescription(
key=Measurement.HEART_PULSE_BPM.value,
measurement=Measurement.HEART_PULSE_BPM,
measure_type=MeasureType.HEART_RATE,
WithingsMeasurementSensorEntityDescription(
key="heart_pulse_bpm",
measurement_type=MeasurementType.HEART_RATE,
translation_key="heart_pulse",
native_unit_of_measurement=UOM_BEATS_PER_MINUTE,
icon="mdi:heart-pulse",
state_class=SensorStateClass.MEASUREMENT,
),
WithingsSensorEntityDescription(
key=Measurement.SPO2_PCT.value,
measurement=Measurement.SPO2_PCT,
measure_type=MeasureType.SP02,
WithingsMeasurementSensorEntityDescription(
key="spo2_pct",
measurement_type=MeasurementType.SP02,
translation_key="spo2",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
WithingsSensorEntityDescription(
key=Measurement.HYDRATION.value,
measurement=Measurement.HYDRATION,
measure_type=MeasureType.HYDRATION,
WithingsMeasurementSensorEntityDescription(
key="hydration",
measurement_type=MeasurementType.HYDRATION,
translation_key="hydration",
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
device_class=SensorDeviceClass.WEIGHT,
@ -190,29 +175,42 @@ MEASUREMENT_SENSORS = [
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.PWV.value,
measurement=Measurement.PWV,
measure_type=MeasureType.PULSE_WAVE_VELOCITY,
WithingsMeasurementSensorEntityDescription(
key="pulse_wave_velocity",
measurement_type=MeasurementType.PULSE_WAVE_VELOCITY,
translation_key="pulse_wave_velocity",
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
device_class=SensorDeviceClass.SPEED,
state_class=SensorStateClass.MEASUREMENT,
),
]
@dataclass
class WithingsSleepSensorEntityDescriptionMixin:
"""Mixin for describing withings data."""
value_fn: Callable[[SleepSummary], StateType]
@dataclass
class WithingsSleepSensorEntityDescription(
SensorEntityDescription, WithingsSleepSensorEntityDescriptionMixin
):
"""Immutable class for describing withings data."""
SLEEP_SENSORS = [
WithingsSensorEntityDescription(
key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value,
measurement=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY,
measure_type=GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY,
WithingsSleepSensorEntityDescription(
key="sleep_breathing_disturbances_intensity",
value_fn=lambda sleep_summary: sleep_summary.breathing_disturbances_intensity,
translation_key="breathing_disturbances_intensity",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_DEEP_DURATION_SECONDS.value,
measurement=Measurement.SLEEP_DEEP_DURATION_SECONDS,
measure_type=GetSleepSummaryField.DEEP_SLEEP_DURATION,
WithingsSleepSensorEntityDescription(
key="sleep_deep_duration_seconds",
value_fn=lambda sleep_summary: sleep_summary.deep_sleep_duration,
translation_key="deep_sleep",
native_unit_of_measurement=UnitOfTime.SECONDS,
icon="mdi:sleep",
@ -220,10 +218,9 @@ SLEEP_SENSORS = [
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS.value,
measurement=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS,
measure_type=GetSleepSummaryField.DURATION_TO_SLEEP,
WithingsSleepSensorEntityDescription(
key="sleep_tosleep_duration_seconds",
value_fn=lambda sleep_summary: sleep_summary.sleep_latency,
translation_key="time_to_sleep",
native_unit_of_measurement=UnitOfTime.SECONDS,
icon="mdi:sleep",
@ -231,10 +228,9 @@ SLEEP_SENSORS = [
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS.value,
measurement=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS,
measure_type=GetSleepSummaryField.DURATION_TO_WAKEUP,
WithingsSleepSensorEntityDescription(
key="sleep_towakeup_duration_seconds",
value_fn=lambda sleep_summary: sleep_summary.wake_up_latency,
translation_key="time_to_wakeup",
native_unit_of_measurement=UnitOfTime.SECONDS,
icon="mdi:sleep-off",
@ -242,40 +238,36 @@ SLEEP_SENSORS = [
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_HEART_RATE_AVERAGE.value,
measurement=Measurement.SLEEP_HEART_RATE_AVERAGE,
measure_type=GetSleepSummaryField.HR_AVERAGE,
WithingsSleepSensorEntityDescription(
key="sleep_heart_rate_average_bpm",
value_fn=lambda sleep_summary: sleep_summary.average_heart_rate,
translation_key="average_heart_rate",
native_unit_of_measurement=UOM_BEATS_PER_MINUTE,
icon="mdi:heart-pulse",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_HEART_RATE_MAX.value,
measurement=Measurement.SLEEP_HEART_RATE_MAX,
measure_type=GetSleepSummaryField.HR_MAX,
WithingsSleepSensorEntityDescription(
key="sleep_heart_rate_max_bpm",
value_fn=lambda sleep_summary: sleep_summary.max_heart_rate,
translation_key="maximum_heart_rate",
native_unit_of_measurement=UOM_BEATS_PER_MINUTE,
icon="mdi:heart-pulse",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_HEART_RATE_MIN.value,
measurement=Measurement.SLEEP_HEART_RATE_MIN,
measure_type=GetSleepSummaryField.HR_MIN,
WithingsSleepSensorEntityDescription(
key="sleep_heart_rate_min_bpm",
value_fn=lambda sleep_summary: sleep_summary.min_heart_rate,
translation_key="minimum_heart_rate",
native_unit_of_measurement=UOM_BEATS_PER_MINUTE,
icon="mdi:heart-pulse",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_LIGHT_DURATION_SECONDS.value,
measurement=Measurement.SLEEP_LIGHT_DURATION_SECONDS,
measure_type=GetSleepSummaryField.LIGHT_SLEEP_DURATION,
WithingsSleepSensorEntityDescription(
key="sleep_light_duration_seconds",
value_fn=lambda sleep_summary: sleep_summary.light_sleep_duration,
translation_key="light_sleep",
native_unit_of_measurement=UnitOfTime.SECONDS,
icon="mdi:sleep",
@ -283,10 +275,9 @@ SLEEP_SENSORS = [
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_REM_DURATION_SECONDS.value,
measurement=Measurement.SLEEP_REM_DURATION_SECONDS,
measure_type=GetSleepSummaryField.REM_SLEEP_DURATION,
WithingsSleepSensorEntityDescription(
key="sleep_rem_duration_seconds",
value_fn=lambda sleep_summary: sleep_summary.rem_sleep_duration,
translation_key="rem_sleep",
native_unit_of_measurement=UnitOfTime.SECONDS,
icon="mdi:sleep",
@ -294,73 +285,65 @@ SLEEP_SENSORS = [
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE.value,
measurement=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE,
measure_type=GetSleepSummaryField.RR_AVERAGE,
WithingsSleepSensorEntityDescription(
key="sleep_respiratory_average_bpm",
value_fn=lambda sleep_summary: sleep_summary.average_respiration_rate,
translation_key="average_respiratory_rate",
native_unit_of_measurement=UOM_BREATHS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_RESPIRATORY_RATE_MAX.value,
measurement=Measurement.SLEEP_RESPIRATORY_RATE_MAX,
measure_type=GetSleepSummaryField.RR_MAX,
WithingsSleepSensorEntityDescription(
key="sleep_respiratory_max_bpm",
value_fn=lambda sleep_summary: sleep_summary.max_respiration_rate,
translation_key="maximum_respiratory_rate",
native_unit_of_measurement=UOM_BREATHS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_RESPIRATORY_RATE_MIN.value,
measurement=Measurement.SLEEP_RESPIRATORY_RATE_MIN,
measure_type=GetSleepSummaryField.RR_MIN,
WithingsSleepSensorEntityDescription(
key="sleep_respiratory_min_bpm",
value_fn=lambda sleep_summary: sleep_summary.min_respiration_rate,
translation_key="minimum_respiratory_rate",
native_unit_of_measurement=UOM_BREATHS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_SCORE.value,
measurement=Measurement.SLEEP_SCORE,
measure_type=GetSleepSummaryField.SLEEP_SCORE,
WithingsSleepSensorEntityDescription(
key="sleep_score",
value_fn=lambda sleep_summary: sleep_summary.sleep_score,
translation_key="sleep_score",
native_unit_of_measurement=SCORE_POINTS,
icon="mdi:medal",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_SNORING.value,
measurement=Measurement.SLEEP_SNORING,
measure_type=GetSleepSummaryField.SNORING,
WithingsSleepSensorEntityDescription(
key="sleep_snoring",
value_fn=lambda sleep_summary: sleep_summary.snoring,
translation_key="snoring",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_SNORING_EPISODE_COUNT.value,
measurement=Measurement.SLEEP_SNORING_EPISODE_COUNT,
measure_type=GetSleepSummaryField.SNORING_EPISODE_COUNT,
WithingsSleepSensorEntityDescription(
key="sleep_snoring_eposode_count",
value_fn=lambda sleep_summary: sleep_summary.snoring_count,
translation_key="snoring_episode_count",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_WAKEUP_COUNT.value,
measurement=Measurement.SLEEP_WAKEUP_COUNT,
measure_type=GetSleepSummaryField.WAKEUP_COUNT,
WithingsSleepSensorEntityDescription(
key="sleep_wakeup_count",
value_fn=lambda sleep_summary: sleep_summary.wake_up_count,
translation_key="wakeup_count",
native_unit_of_measurement=UOM_FREQUENCY,
icon="mdi:sleep-off",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_WAKEUP_DURATION_SECONDS.value,
measurement=Measurement.SLEEP_WAKEUP_DURATION_SECONDS,
measure_type=GetSleepSummaryField.WAKEUP_DURATION,
WithingsSleepSensorEntityDescription(
key="sleep_wakeup_duration_seconds",
value_fn=lambda sleep_summary: sleep_summary.total_time_awake,
translation_key="wakeup_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
icon="mdi:sleep-off",
@ -398,38 +381,51 @@ async def async_setup_entry(
class WithingsSensor(WithingsEntity, SensorEntity):
"""Implementation of a Withings sensor."""
entity_description: WithingsSensorEntityDescription
def __init__(
self,
coordinator: WithingsDataUpdateCoordinator,
entity_description: WithingsSensorEntityDescription,
entity_description: SensorEntityDescription,
) -> None:
"""Initialize sensor."""
super().__init__(coordinator, entity_description.key)
self.entity_description = entity_description
@property
def native_value(self) -> None | str | int | float:
"""Return the state of the entity."""
return self.coordinator.data[self.entity_description.measurement]
@property
def available(self) -> bool:
"""Return if the sensor is available."""
return (
super().available
and self.entity_description.measurement in self.coordinator.data
)
class WithingsMeasurementSensor(WithingsSensor):
"""Implementation of a Withings measurement sensor."""
coordinator: WithingsMeasurementDataUpdateCoordinator
entity_description: WithingsMeasurementSensorEntityDescription
@property
def native_value(self) -> float:
"""Return the state of the entity."""
return self.coordinator.data[self.entity_description.measurement_type]
@property
def available(self) -> bool:
"""Return if the sensor is available."""
return (
super().available
and self.entity_description.measurement_type in self.coordinator.data
)
class WithingsSleepSensor(WithingsSensor):
"""Implementation of a Withings sleep sensor."""
coordinator: WithingsSleepDataUpdateCoordinator
entity_description: WithingsSleepSensorEntityDescription
@property
def native_value(self) -> StateType:
"""Return the state of the entity."""
assert self.coordinator.data
return self.entity_description.value_fn(self.coordinator.data)
@property
def available(self) -> bool:
"""Return if the sensor is available."""
return super().available and self.coordinator.data is not None

View File

@ -386,6 +386,9 @@ aiowatttime==0.1.1
# homeassistant.components.webostv
aiowebostv==0.3.3
# homeassistant.components.withings
aiowithings==0.4.4
# homeassistant.components.yandex_transport
aioymaps==1.2.2
@ -2717,9 +2720,6 @@ wiffi==1.1.2
# homeassistant.components.wirelesstag
wirelesstagpy==0.8.1
# homeassistant.components.withings
withings-api==2.4.0
# homeassistant.components.wled
wled==0.16.0

View File

@ -361,6 +361,9 @@ aiowatttime==0.1.1
# homeassistant.components.webostv
aiowebostv==0.3.3
# homeassistant.components.withings
aiowithings==0.4.4
# homeassistant.components.yandex_transport
aioymaps==1.2.2
@ -2020,9 +2023,6 @@ whois==0.9.27
# homeassistant.components.wiffi
wiffi==1.1.2
# homeassistant.components.withings
withings-api==2.4.0
# homeassistant.components.wled
wled==0.16.0

View File

@ -3,19 +3,14 @@ from datetime import timedelta
import time
from unittest.mock import AsyncMock, patch
from aiowithings import Device, MeasurementGroup, SleepSummary, WithingsClient
from aiowithings.models import NotificationConfiguration
import pytest
from withings_api import (
MeasureGetMeasResponse,
NotifyListResponse,
SleepGetSummaryResponse,
UserGetDeviceResponse,
)
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.withings.api import ConfigEntryWithingsApi
from homeassistant.components.withings.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@ -133,22 +128,34 @@ def polling_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
def mock_withings():
"""Mock withings."""
mock = AsyncMock(spec=ConfigEntryWithingsApi)
mock.user_get_device.return_value = UserGetDeviceResponse(
**load_json_object_fixture("withings/get_device.json")
)
mock.async_measure_get_meas.return_value = MeasureGetMeasResponse(
**load_json_object_fixture("withings/get_meas.json")
)
mock.async_sleep_get_summary.return_value = SleepGetSummaryResponse(
**load_json_object_fixture("withings/get_sleep.json")
)
mock.async_notify_list.return_value = NotifyListResponse(
**load_json_object_fixture("withings/notify_list.json")
)
devices_json = load_json_object_fixture("withings/get_device.json")
devices = [Device.from_api(device) for device in devices_json["devices"]]
meas_json = load_json_object_fixture("withings/get_meas.json")
measurement_groups = [
MeasurementGroup.from_api(measurement)
for measurement in meas_json["measuregrps"]
]
sleep_json = load_json_object_fixture("withings/get_sleep.json")
sleep_summaries = [
SleepSummary.from_api(sleep_summary) for sleep_summary in sleep_json["series"]
]
notification_json = load_json_object_fixture("withings/notify_list.json")
notifications = [
NotificationConfiguration.from_api(not_conf)
for not_conf in notification_json["profiles"]
]
mock = AsyncMock(spec=WithingsClient)
mock.get_devices.return_value = devices
mock.get_measurement_in_period.return_value = measurement_groups
mock.get_sleep_summary_since.return_value = sleep_summaries
mock.list_notification_configurations.return_value = notifications
with patch(
"homeassistant.components.withings.ConfigEntryWithingsApi",
"homeassistant.components.withings.WithingsClient",
return_value=mock,
):
yield mock

View File

@ -1,3 +0,0 @@
{
"profiles": []
}

View File

@ -11,7 +11,7 @@
'entity_id': 'sensor.henk_weight',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '70.0',
'state': '70',
})
# ---
# name: test_all_entities.1
@ -26,7 +26,7 @@
'entity_id': 'sensor.henk_fat_mass',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '5.0',
'state': '5',
})
# ---
# name: test_all_entities.10
@ -40,7 +40,7 @@
'entity_id': 'sensor.henk_diastolic_blood_pressure',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '70.0',
'state': '70',
})
# ---
# name: test_all_entities.11
@ -54,7 +54,7 @@
'entity_id': 'sensor.henk_systolic_blood_pressure',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '100.0',
'state': '100',
})
# ---
# name: test_all_entities.12
@ -69,7 +69,7 @@
'entity_id': 'sensor.henk_heart_pulse',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '60.0',
'state': '60',
})
# ---
# name: test_all_entities.13
@ -114,7 +114,7 @@
'entity_id': 'sensor.henk_pulse_wave_velocity',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '100.0',
'state': '100',
})
# ---
# name: test_all_entities.16
@ -127,7 +127,7 @@
'entity_id': 'sensor.henk_breathing_disturbances_intensity',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '10.0',
'state': '10',
})
# ---
# name: test_all_entities.17
@ -159,7 +159,7 @@
'entity_id': 'sensor.henk_time_to_sleep',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '780.0',
'state': '780',
})
# ---
# name: test_all_entities.19
@ -175,7 +175,7 @@
'entity_id': 'sensor.henk_time_to_wakeup',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '996.0',
'state': '996',
})
# ---
# name: test_all_entities.2
@ -190,7 +190,7 @@
'entity_id': 'sensor.henk_fat_free_mass',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '60.0',
'state': '60',
})
# ---
# name: test_all_entities.20
@ -205,7 +205,7 @@
'entity_id': 'sensor.henk_average_heart_rate',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '83.2',
'state': '83',
})
# ---
# name: test_all_entities.21
@ -220,7 +220,7 @@
'entity_id': 'sensor.henk_maximum_heart_rate',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '108.4',
'state': '108',
})
# ---
# name: test_all_entities.22
@ -235,7 +235,7 @@
'entity_id': 'sensor.henk_minimum_heart_rate',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '58.0',
'state': '58',
})
# ---
# name: test_all_entities.23
@ -281,7 +281,7 @@
'entity_id': 'sensor.henk_average_respiratory_rate',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '14.2',
'state': '14',
})
# ---
# name: test_all_entities.26
@ -295,7 +295,7 @@
'entity_id': 'sensor.henk_maximum_respiratory_rate',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '20.0',
'state': '20',
})
# ---
# name: test_all_entities.27
@ -309,7 +309,7 @@
'entity_id': 'sensor.henk_minimum_respiratory_rate',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '10.0',
'state': '10',
})
# ---
# name: test_all_entities.28
@ -337,7 +337,7 @@
'entity_id': 'sensor.henk_snoring',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '1044.0',
'state': '1044',
})
# ---
# name: test_all_entities.3
@ -352,7 +352,7 @@
'entity_id': 'sensor.henk_muscle_mass',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '50.0',
'state': '50',
})
# ---
# name: test_all_entities.30
@ -396,7 +396,7 @@
'entity_id': 'sensor.henk_wakeup_time',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '3468.0',
'state': '3468',
})
# ---
# name: test_all_entities.33
@ -568,7 +568,7 @@
'entity_id': 'sensor.henk_bone_mass',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '10.0',
'state': '10',
})
# ---
# name: test_all_entities.40
@ -820,7 +820,7 @@
'entity_id': 'sensor.henk_height',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '2.0',
'state': '2',
})
# ---
# name: test_all_entities.50
@ -1065,7 +1065,7 @@
'entity_id': 'sensor.henk_temperature',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '40.0',
'state': '40',
})
# ---
# name: test_all_entities.60
@ -1220,7 +1220,7 @@
'entity_id': 'sensor.henk_body_temperature',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '40.0',
'state': '40',
})
# ---
# name: test_all_entities.8
@ -1235,7 +1235,7 @@
'entity_id': 'sensor.henk_skin_temperature',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '20.0',
'state': '20',
})
# ---
# name: test_all_entities.9

View File

@ -2,9 +2,9 @@
from unittest.mock import AsyncMock
from aiohttp.client_exceptions import ClientResponseError
from aiowithings import NotificationCategory
from freezegun.api import FrozenDateTimeFactory
import pytest
from withings_api.common import NotifyAppli
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
@ -36,7 +36,7 @@ async def test_binary_sensor(
resp = await call_webhook(
hass,
WEBHOOK_ID,
{"userid": USER_ID, "appli": NotifyAppli.BED_IN},
{"userid": USER_ID, "appli": NotificationCategory.IN_BED},
client,
)
assert resp.message_code == 0
@ -46,7 +46,7 @@ async def test_binary_sensor(
resp = await call_webhook(
hass,
WEBHOOK_ID,
{"userid": USER_ID, "appli": NotifyAppli.BED_OUT},
{"userid": USER_ID, "appli": NotificationCategory.OUT_BED},
client,
)
assert resp.message_code == 0
@ -73,6 +73,6 @@ async def test_polling_binary_sensor(
await call_webhook(
hass,
WEBHOOK_ID,
{"userid": USER_ID, "appli": NotifyAppli.BED_IN},
{"userid": USER_ID, "appli": NotificationCategory.IN_BED},
client,
)

View File

@ -4,11 +4,14 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import urlparse
from aiowithings import (
NotificationCategory,
WithingsAuthenticationFailedError,
WithingsUnauthorizedError,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
import voluptuous as vol
from withings_api import NotifyListResponse
from withings_api.common import AuthFailedException, NotifyAppli, UnauthorizedException
from homeassistant import config_entries
from homeassistant.components.cloud import CloudNotAvailable
@ -26,7 +29,6 @@ from tests.common import (
MockConfigEntry,
async_fire_time_changed,
async_mock_cloud_connection_status,
load_json_object_fixture,
)
from tests.components.cloud import mock_cloud
from tests.typing import ClientSessionGenerator
@ -126,19 +128,29 @@ async def test_data_manager_webhook_subscription(
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
await hass.async_block_till_done()
assert withings.async_notify_subscribe.call_count == 6
assert withings.subscribe_notification.call_count == 6
webhook_url = "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e"
withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT)
withings.async_notify_subscribe.assert_any_call(
webhook_url, NotifyAppli.CIRCULATORY
withings.subscribe_notification.assert_any_call(
webhook_url, NotificationCategory.WEIGHT
)
withings.subscribe_notification.assert_any_call(
webhook_url, NotificationCategory.PRESSURE
)
withings.subscribe_notification.assert_any_call(
webhook_url, NotificationCategory.ACTIVITY
)
withings.subscribe_notification.assert_any_call(
webhook_url, NotificationCategory.SLEEP
)
withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.ACTIVITY)
withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.SLEEP)
withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_IN)
withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT)
withings.revoke_notification_configurations.assert_any_call(
webhook_url, NotificationCategory.IN_BED
)
withings.revoke_notification_configurations.assert_any_call(
webhook_url, NotificationCategory.OUT_BED
)
async def test_webhook_subscription_polling_config(
@ -149,16 +161,16 @@ async def test_webhook_subscription_polling_config(
freezer: FrozenDateTimeFactory,
) -> None:
"""Test webhook subscriptions not run when polling."""
await setup_integration(hass, polling_config_entry)
await setup_integration(hass, polling_config_entry, False)
await hass_client_no_auth()
await hass.async_block_till_done()
freezer.tick(timedelta(seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert withings.notify_revoke.call_count == 0
assert withings.notify_subscribe.call_count == 0
assert withings.notify_list.call_count == 0
assert withings.revoke_notification_configurations.call_count == 0
assert withings.subscribe_notification.call_count == 0
assert withings.list_notification_configurations.call_count == 0
@pytest.mark.parametrize(
@ -200,22 +212,22 @@ async def test_webhooks_request_data(
client = await hass_client_no_auth()
assert withings.async_measure_get_meas.call_count == 1
assert withings.get_measurement_in_period.call_count == 1
await call_webhook(
hass,
WEBHOOK_ID,
{"userid": USER_ID, "appli": NotifyAppli.WEIGHT},
{"userid": USER_ID, "appli": NotificationCategory.WEIGHT},
client,
)
assert withings.async_measure_get_meas.call_count == 2
assert withings.get_measurement_in_period.call_count == 2
@pytest.mark.parametrize(
"error",
[
UnauthorizedException(401),
AuthFailedException(500),
WithingsUnauthorizedError(401),
WithingsAuthenticationFailedError(500),
],
)
async def test_triggering_reauth(
@ -228,7 +240,7 @@ async def test_triggering_reauth(
"""Test triggering reauth."""
await setup_integration(hass, polling_config_entry, False)
withings.async_measure_get_meas.side_effect = error
withings.get_measurement_in_period.side_effect = error
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
@ -384,7 +396,7 @@ async def test_setup_with_cloud(
"homeassistant.components.cloud.async_create_cloudhook",
return_value="https://hooks.nabu.casa/ABCD",
) as fake_create_cloudhook, patch(
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
"homeassistant.components.withings.async_get_config_entry_implementation",
), patch(
"homeassistant.components.cloud.async_delete_cloudhook"
) as fake_delete_cloudhook, patch(
@ -462,7 +474,7 @@ async def test_cloud_disconnect(
"homeassistant.components.cloud.async_create_cloudhook",
return_value="https://hooks.nabu.casa/ABCD",
), patch(
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
"homeassistant.components.withings.async_get_config_entry_implementation",
), patch(
"homeassistant.components.cloud.async_delete_cloudhook"
), patch(
@ -475,34 +487,31 @@ async def test_cloud_disconnect(
await hass.async_block_till_done()
withings.async_notify_list.return_value = NotifyListResponse(
**load_json_object_fixture("withings/empty_notify_list.json")
)
withings.list_notification_configurations.return_value = []
assert withings.async_notify_subscribe.call_count == 6
assert withings.subscribe_notification.call_count == 6
async_mock_cloud_connection_status(hass, False)
await hass.async_block_till_done()
assert withings.async_notify_revoke.call_count == 3
assert withings.revoke_notification_configurations.call_count == 3
async_mock_cloud_connection_status(hass, True)
await hass.async_block_till_done()
assert withings.async_notify_subscribe.call_count == 12
assert withings.subscribe_notification.call_count == 12
@pytest.mark.parametrize(
("body", "expected_code"),
[
[{"userid": 0, "appli": NotifyAppli.WEIGHT.value}, 0], # Success
[{"userid": 0, "appli": NotificationCategory.WEIGHT.value}, 0], # Success
[{"userid": None, "appli": 1}, 0], # Success, we ignore the user_id.
[{}, 12], # No request body.
[{"userid": "GG"}, 20], # appli not provided.
[{"userid": 0}, 20], # appli not provided.
[{"userid": 0, "appli": 99}, 21], # Invalid appli.
[
{"userid": 11, "appli": NotifyAppli.WEIGHT.value},
{"userid": 11, "appli": NotificationCategory.WEIGHT.value},
0,
], # Success, we ignore the user_id
],

View File

@ -57,7 +57,7 @@ async def test_update_failed(
"""Test all entities."""
await setup_integration(hass, polling_config_entry, False)
withings.async_measure_get_meas.side_effect = Exception
withings.get_measurement_in_period.side_effect = Exception
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()