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