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
This commit is contained in:
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/sensor.py
homeassistant/components/wiffi/wiffi_strings.py homeassistant/components/wiffi/wiffi_strings.py
homeassistant/components/wirelesstag/* homeassistant/components/wirelesstag/*
homeassistant/components/withings/api.py
homeassistant/components/wolflink/__init__.py homeassistant/components/wolflink/__init__.py
homeassistant/components/wolflink/sensor.py homeassistant/components/wolflink/sensor.py
homeassistant/components/worldtidesinfo/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.hdrs import METH_HEAD, METH_POST
from aiohttp.web import Request, Response from aiohttp.web import Request, Response
from aiowithings import NotificationCategory, WithingsClient
from aiowithings.util import to_enum
import voluptuous as vol import voluptuous as vol
from withings_api.common import NotifyAppli
from homeassistant.components import cloud from homeassistant.components import cloud
from homeassistant.components.application_credentials import ( from homeassistant.components.application_credentials import (
@ -29,6 +30,7 @@ from homeassistant.components.webhook import (
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_CLIENT_ID, CONF_CLIENT_ID,
CONF_CLIENT_SECRET, CONF_CLIENT_SECRET,
CONF_TOKEN, CONF_TOKEN,
@ -37,12 +39,16 @@ from homeassistant.const import (
Platform, Platform,
) )
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant 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.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .api import ConfigEntryWithingsApi
from .const import ( from .const import (
BED_PRESENCE_COORDINATOR, BED_PRESENCE_COORDINATOR,
CONF_PROFILES, CONF_PROFILES,
@ -134,14 +140,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.config_entries.async_update_entry( hass.config_entries.async_update_entry(
entry, data=new_data, unique_id=unique_id 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( async def _refresh_token() -> str:
hass=hass, await oauth_session.async_ensure_token_valid()
config_entry=entry, return oauth_session.token[CONF_ACCESS_TOKEN]
implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry client.refresh_token_function = _refresh_token
),
)
coordinators: dict[str, WithingsDataUpdateCoordinator] = { coordinators: dict[str, WithingsDataUpdateCoordinator] = {
MEASUREMENT_COORDINATOR: WithingsMeasurementDataUpdateCoordinator(hass, client), MEASUREMENT_COORDINATOR: WithingsMeasurementDataUpdateCoordinator(hass, client),
SLEEP_COORDINATOR: WithingsSleepDataUpdateCoordinator(hass, client), SLEEP_COORDINATOR: WithingsSleepDataUpdateCoordinator(hass, client),
@ -230,19 +238,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok return unload_ok
async def async_subscribe_webhooks( async def async_subscribe_webhooks(client: WithingsClient, webhook_url: str) -> None:
client: ConfigEntryWithingsApi, webhook_url: str
) -> None:
"""Subscribe to Withings webhooks.""" """Subscribe to Withings webhooks."""
await async_unsubscribe_webhooks(client) await async_unsubscribe_webhooks(client)
notification_to_subscribe = { notification_to_subscribe = {
NotifyAppli.WEIGHT, NotificationCategory.WEIGHT,
NotifyAppli.CIRCULATORY, NotificationCategory.PRESSURE,
NotifyAppli.ACTIVITY, NotificationCategory.ACTIVITY,
NotifyAppli.SLEEP, NotificationCategory.SLEEP,
NotifyAppli.BED_IN, NotificationCategory.IN_BED,
NotifyAppli.BED_OUT, NotificationCategory.OUT_BED,
} }
for notification in notification_to_subscribe: 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 # Withings will HTTP HEAD the callback_url and needs some downtime
# between each call or there is a higher chance of failure. # between each call or there is a higher chance of failure.
await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds()) 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.""" """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( LOGGER.debug(
"Unsubscribing %s for %s in %s seconds", "Unsubscribing %s for %s in %s seconds",
webhook_configuration.callbackurl, webhook_configuration.callback_url,
webhook_configuration.appli, webhook_configuration.notification_category,
UNSUBSCRIBE_DELAY.total_seconds(), UNSUBSCRIBE_DELAY.total_seconds(),
) )
# Quick calls to Withings can result in the service returning errors. # Quick calls to Withings can result in the service returning errors.
# Give them some time to cool down. # Give them some time to cool down.
await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds()) await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds())
await client.async_notify_revoke( await client.revoke_notification_configurations(
webhook_configuration.callbackurl, webhook_configuration.appli webhook_configuration.callback_url,
webhook_configuration.notification_category,
) )
@ -336,14 +343,15 @@ def get_webhook_handler(
"Parameter appli not provided", message_code=20 "Parameter appli not provided", message_code=20
) )
try: notification_category = to_enum(
appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type] NotificationCategory,
except ValueError: int(params.getone("appli")), # type: ignore[arg-type]
return json_message_response("Invalid appli provided", message_code=21) NotificationCategory.UNKNOWN,
)
for coordinator in coordinators.values(): for coordinator in coordinators.values():
if appli in coordinator.notification_categories: if notification_category in coordinator.notification_categories:
await coordinator.async_webhook_data_updated(appli) await coordinator.async_webhook_data_updated(notification_category)
return json_message_response("Success", message_code=0) 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 typing import Any
from withings_api import AbstractWithingsApi, WithingsAuth from aiowithings import AUTHORIZATION_URL, TOKEN_URL
from homeassistant.components.application_credentials import ( from homeassistant.components.application_credentials import (
AuthImplementation, AuthImplementation,
@ -24,8 +24,8 @@ async def async_get_auth_implementation(
DOMAIN, DOMAIN,
credential, credential,
authorization_server=AuthorizationServer( authorization_server=AuthorizationServer(
authorize_url=f"{WithingsAuth.URL}/oauth2_user/authorize2", authorize_url=AUTHORIZATION_URL,
token_url=f"{AbstractWithingsApi.URL}/v2/oauth2", token_url=TOKEN_URL,
), ),
) )

View File

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

View File

@ -1,5 +1,4 @@
"""Constants used by the Withings component.""" """Constants used by the Withings component."""
from enum import StrEnum
import logging import logging
DEFAULT_TITLE = "Withings" DEFAULT_TITLE = "Withings"
@ -21,45 +20,6 @@ BED_PRESENCE_COORDINATOR = "bed_presence_coordinator"
LOGGER = logging.getLogger(__package__) 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" SCORE_POINTS = "points"
UOM_BEATS_PER_MINUTE = "bpm" UOM_BEATS_PER_MINUTE = "bpm"
UOM_BREATHS_PER_MINUTE = "br/min" UOM_BREATHS_PER_MINUTE = "br/min"

View File

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

View File

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

View File

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

View File

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

View File

@ -3,19 +3,14 @@ from datetime import timedelta
import time import time
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from aiowithings import Device, MeasurementGroup, SleepSummary, WithingsClient
from aiowithings.models import NotificationConfiguration
import pytest import pytest
from withings_api import (
MeasureGetMeasResponse,
NotifyListResponse,
SleepGetSummaryResponse,
UserGetDeviceResponse,
)
from homeassistant.components.application_credentials import ( from homeassistant.components.application_credentials import (
ClientCredential, ClientCredential,
async_import_client_credential, async_import_client_credential,
) )
from homeassistant.components.withings.api import ConfigEntryWithingsApi
from homeassistant.components.withings.const import DOMAIN from homeassistant.components.withings.const import DOMAIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component 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(): def mock_withings():
"""Mock withings.""" """Mock withings."""
mock = AsyncMock(spec=ConfigEntryWithingsApi) devices_json = load_json_object_fixture("withings/get_device.json")
mock.user_get_device.return_value = UserGetDeviceResponse( devices = [Device.from_api(device) for device in devices_json["devices"]]
**load_json_object_fixture("withings/get_device.json")
) meas_json = load_json_object_fixture("withings/get_meas.json")
mock.async_measure_get_meas.return_value = MeasureGetMeasResponse( measurement_groups = [
**load_json_object_fixture("withings/get_meas.json") MeasurementGroup.from_api(measurement)
) for measurement in meas_json["measuregrps"]
mock.async_sleep_get_summary.return_value = SleepGetSummaryResponse( ]
**load_json_object_fixture("withings/get_sleep.json")
) sleep_json = load_json_object_fixture("withings/get_sleep.json")
mock.async_notify_list.return_value = NotifyListResponse( sleep_summaries = [
**load_json_object_fixture("withings/notify_list.json") 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( with patch(
"homeassistant.components.withings.ConfigEntryWithingsApi", "homeassistant.components.withings.WithingsClient",
return_value=mock, return_value=mock,
): ):
yield mock yield mock

View File

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

View File

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

View File

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

View File

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

View File

@ -57,7 +57,7 @@ async def test_update_failed(
"""Test all entities.""" """Test all entities."""
await setup_integration(hass, polling_config_entry, False) 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)) freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()