diff --git a/.coveragerc b/.coveragerc index 41b46796373..74729b059f1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index a17dffd22e8..05f2db4b18d 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -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) diff --git a/homeassistant/components/withings/api.py b/homeassistant/components/withings/api.py deleted file mode 100644 index f9739d3fb6f..00000000000 --- a/homeassistant/components/withings/api.py +++ /dev/null @@ -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) diff --git a/homeassistant/components/withings/application_credentials.py b/homeassistant/components/withings/application_credentials.py index 1d5b52466c4..ce96ed782dd 100644 --- a/homeassistant/components/withings/application_credentials.py +++ b/homeassistant/components/withings/application_credentials.py @@ -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, ), ) diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index 8cab297b96a..31c40bf9791 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -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, ] ) } diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index bc3e26765a4..4eeaa56c76d 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -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" diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index f5963ad6ebf..b87cb550a13 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -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: diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index edc8aab83b7..9ed7dea08ad 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -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"] } diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 200ad7aedd5..535dafc763e 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index b1759ec6479..eb40661478f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c745e407561..7628ab06bbd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index ad310639b43..5c4a1db1182 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -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 diff --git a/tests/components/withings/fixtures/empty_notify_list.json b/tests/components/withings/fixtures/empty_notify_list.json deleted file mode 100644 index c905c95e4cb..00000000000 --- a/tests/components/withings/fixtures/empty_notify_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "profiles": [] -} diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 833ac4148a0..886cf86f034 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -11,7 +11,7 @@ 'entity_id': 'sensor.henk_weight', 'last_changed': , 'last_updated': , - 'state': '70.0', + 'state': '70', }) # --- # name: test_all_entities.1 @@ -26,7 +26,7 @@ 'entity_id': 'sensor.henk_fat_mass', 'last_changed': , 'last_updated': , - 'state': '5.0', + 'state': '5', }) # --- # name: test_all_entities.10 @@ -40,7 +40,7 @@ 'entity_id': 'sensor.henk_diastolic_blood_pressure', 'last_changed': , 'last_updated': , - 'state': '70.0', + 'state': '70', }) # --- # name: test_all_entities.11 @@ -54,7 +54,7 @@ 'entity_id': 'sensor.henk_systolic_blood_pressure', 'last_changed': , 'last_updated': , - 'state': '100.0', + 'state': '100', }) # --- # name: test_all_entities.12 @@ -69,7 +69,7 @@ 'entity_id': 'sensor.henk_heart_pulse', 'last_changed': , 'last_updated': , - 'state': '60.0', + 'state': '60', }) # --- # name: test_all_entities.13 @@ -114,7 +114,7 @@ 'entity_id': 'sensor.henk_pulse_wave_velocity', 'last_changed': , 'last_updated': , - 'state': '100.0', + 'state': '100', }) # --- # name: test_all_entities.16 @@ -127,7 +127,7 @@ 'entity_id': 'sensor.henk_breathing_disturbances_intensity', 'last_changed': , 'last_updated': , - 'state': '10.0', + 'state': '10', }) # --- # name: test_all_entities.17 @@ -159,7 +159,7 @@ 'entity_id': 'sensor.henk_time_to_sleep', 'last_changed': , 'last_updated': , - 'state': '780.0', + 'state': '780', }) # --- # name: test_all_entities.19 @@ -175,7 +175,7 @@ 'entity_id': 'sensor.henk_time_to_wakeup', 'last_changed': , 'last_updated': , - 'state': '996.0', + 'state': '996', }) # --- # name: test_all_entities.2 @@ -190,7 +190,7 @@ 'entity_id': 'sensor.henk_fat_free_mass', 'last_changed': , 'last_updated': , - 'state': '60.0', + 'state': '60', }) # --- # name: test_all_entities.20 @@ -205,7 +205,7 @@ 'entity_id': 'sensor.henk_average_heart_rate', 'last_changed': , 'last_updated': , - 'state': '83.2', + 'state': '83', }) # --- # name: test_all_entities.21 @@ -220,7 +220,7 @@ 'entity_id': 'sensor.henk_maximum_heart_rate', 'last_changed': , 'last_updated': , - 'state': '108.4', + 'state': '108', }) # --- # name: test_all_entities.22 @@ -235,7 +235,7 @@ 'entity_id': 'sensor.henk_minimum_heart_rate', 'last_changed': , 'last_updated': , - 'state': '58.0', + 'state': '58', }) # --- # name: test_all_entities.23 @@ -281,7 +281,7 @@ 'entity_id': 'sensor.henk_average_respiratory_rate', 'last_changed': , 'last_updated': , - 'state': '14.2', + 'state': '14', }) # --- # name: test_all_entities.26 @@ -295,7 +295,7 @@ 'entity_id': 'sensor.henk_maximum_respiratory_rate', 'last_changed': , 'last_updated': , - 'state': '20.0', + 'state': '20', }) # --- # name: test_all_entities.27 @@ -309,7 +309,7 @@ 'entity_id': 'sensor.henk_minimum_respiratory_rate', 'last_changed': , 'last_updated': , - 'state': '10.0', + 'state': '10', }) # --- # name: test_all_entities.28 @@ -337,7 +337,7 @@ 'entity_id': 'sensor.henk_snoring', 'last_changed': , 'last_updated': , - 'state': '1044.0', + 'state': '1044', }) # --- # name: test_all_entities.3 @@ -352,7 +352,7 @@ 'entity_id': 'sensor.henk_muscle_mass', 'last_changed': , 'last_updated': , - 'state': '50.0', + 'state': '50', }) # --- # name: test_all_entities.30 @@ -396,7 +396,7 @@ 'entity_id': 'sensor.henk_wakeup_time', 'last_changed': , 'last_updated': , - 'state': '3468.0', + 'state': '3468', }) # --- # name: test_all_entities.33 @@ -568,7 +568,7 @@ 'entity_id': 'sensor.henk_bone_mass', 'last_changed': , 'last_updated': , - 'state': '10.0', + 'state': '10', }) # --- # name: test_all_entities.40 @@ -820,7 +820,7 @@ 'entity_id': 'sensor.henk_height', 'last_changed': , 'last_updated': , - 'state': '2.0', + 'state': '2', }) # --- # name: test_all_entities.50 @@ -1065,7 +1065,7 @@ 'entity_id': 'sensor.henk_temperature', 'last_changed': , 'last_updated': , - 'state': '40.0', + 'state': '40', }) # --- # name: test_all_entities.60 @@ -1220,7 +1220,7 @@ 'entity_id': 'sensor.henk_body_temperature', 'last_changed': , 'last_updated': , - 'state': '40.0', + 'state': '40', }) # --- # name: test_all_entities.8 @@ -1235,7 +1235,7 @@ 'entity_id': 'sensor.henk_skin_temperature', 'last_changed': , 'last_updated': , - 'state': '20.0', + 'state': '20', }) # --- # name: test_all_entities.9 diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index aa757486f86..5054bf46daa 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -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, ) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index a3509c8547b..7576802999e 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -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 ], diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index f5d15e5dea9..dd3dee1bb4d 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -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()