From a6a6a7b69c0b24721f6e85aa360df530181776ea Mon Sep 17 00:00:00 2001 From: Robert Van Gorkom Date: Tue, 16 Jun 2020 11:16:18 -0700 Subject: [PATCH] Add Withings webhooks (#34447) Co-authored-by: Martin Hjelmare --- homeassistant/components/withings/__init__.py | 189 ++- .../components/withings/binary_sensor.py | 40 + homeassistant/components/withings/common.py | 1168 +++++++++++++---- .../components/withings/config_flow.py | 18 +- homeassistant/components/withings/const.py | 86 +- .../components/withings/manifest.json | 4 +- homeassistant/components/withings/sensor.py | 423 +----- .../components/withings/strings.json | 7 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/withings/common.py | 553 ++++---- tests/components/withings/conftest.py | 22 + .../components/withings/test_binary_sensor.py | 62 + tests/components/withings/test_common.py | 329 +++-- tests/components/withings/test_init.py | 416 ++---- tests/components/withings/test_sensor.py | 335 +++++ 16 files changed, 2201 insertions(+), 1455 deletions(-) create mode 100644 homeassistant/components/withings/binary_sensor.py create mode 100644 tests/components/withings/conftest.py create mode 100644 tests/components/withings/test_binary_sensor.py create mode 100644 tests/components/withings/test_sensor.py diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 93a6250ce03..bd8e118adc9 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -3,22 +3,39 @@ Support for the Withings API. For more details about this platform, please refer to the documentation at """ +import asyncio +from typing import Optional, cast + +from aiohttp.web import Request, Response import voluptuous as vol from withings_api import WithingsAuth +from withings_api.common import NotifyAppli, enum_or_raise +from homeassistant.components import webhook +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.webhook import ( + async_unregister as async_unregister_webhook, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv -from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.typing import ConfigType -from . import config_flow +from . import config_flow, const from .common import ( _LOGGER, - NotAuthenticatedError, WithingsLocalOAuth2Implementation, - get_data_manager, + async_get_data_manager, + async_remove_data_manager, + get_data_manager_by_webhook_id, + json_message_response, ) -from .const import CONF_PROFILES, CONFIG, CREDENTIALS, DOMAIN + +DOMAIN = const.DOMAIN CONFIG_SCHEMA = vol.Schema( { @@ -26,7 +43,8 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_CLIENT_ID): vol.All(cv.string, vol.Length(min=1)), vol.Required(CONF_CLIENT_SECRET): vol.All(cv.string, vol.Length(min=1)), - vol.Required(CONF_PROFILES): vol.All( + vol.Optional(const.CONF_USE_WEBHOOK, default=False): cv.boolean, + vol.Required(const.CONF_PROFILES): vol.All( cv.ensure_list, vol.Unique(), vol.Length(min=1), @@ -39,19 +57,21 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Withings component.""" conf = config.get(DOMAIN, {}) if not conf: return True - hass.data[DOMAIN] = {CONFIG: conf} + # Make the config available to the oauth2 config flow. + hass.data[DOMAIN] = {const.CONFIG: conf} + # Setup the oauth2 config flow. config_flow.WithingsFlowHandler.async_register_implementation( hass, WithingsLocalOAuth2Implementation( hass, - DOMAIN, + const.DOMAIN, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET], f"{WithingsAuth.URL}/oauth2_user/authorize2", @@ -62,52 +82,127 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Withings from a config entry.""" - # Upgrading existing token information to hass managed tokens. - if "auth_implementation" not in entry.data: - _LOGGER.debug("Upgrading existing config entry") - data = entry.data - creds = data.get(CREDENTIALS, {}) - hass.config_entries.async_update_entry( - entry, - data={ - "auth_implementation": DOMAIN, - "implementation": DOMAIN, - "profile": data.get("profile"), - "token": { - "access_token": creds.get("access_token"), - "refresh_token": creds.get("refresh_token"), - "expires_at": int(creds.get("token_expiry")), - "type": creds.get("token_type"), - "userid": creds.get("userid") or creds.get("user_id"), - }, - }, - ) + config_updates = {} - implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + # Add a unique id if it's an older config entry. + if entry.unique_id != entry.data["token"]["userid"]: + config_updates["unique_id"] = entry.data["token"]["userid"] + + # Add the webhook configuration. + if CONF_WEBHOOK_ID not in entry.data: + webhook_id = webhook.async_generate_id() + config_updates["data"] = { + **entry.data, + **{ + const.CONF_USE_WEBHOOK: hass.data[DOMAIN][const.CONFIG][ + const.CONF_USE_WEBHOOK + ], + CONF_WEBHOOK_ID: webhook_id, + const.CONF_WEBHOOK_URL: entry.data.get( + const.CONF_WEBHOOK_URL, + webhook.async_generate_url(hass, webhook_id), + ), + }, + } + + if config_updates: + hass.config_entries.async_update_entry(entry, **config_updates) + + data_manager = await async_get_data_manager(hass, entry) + + _LOGGER.debug("Confirming %s is authenticated to withings", data_manager.profile) + await data_manager.poll_data_update_coordinator.async_refresh() + if not data_manager.poll_data_update_coordinator.last_update_success: + raise ConfigEntryNotReady() + + webhook.async_register( + hass, + const.DOMAIN, + "Withings notify", + data_manager.webhook_config.id, + async_webhook_handler, ) - data_manager = get_data_manager(hass, entry, implementation) + # Perform first webhook subscription check. + if data_manager.webhook_config.enabled: + data_manager.async_start_polling_webhook_subscriptions() - _LOGGER.debug("Confirming we're authenticated") - try: - await data_manager.check_authenticated() - except NotAuthenticatedError: - _LOGGER.error( - "Withings auth tokens exired for profile %s, remove and re-add the integration", - data_manager.profile, - ) - return False + @callback + def async_call_later_callback(now) -> None: + hass.async_create_task( + data_manager.subscription_update_coordinator.async_refresh() + ) + + # Start subscription check in the background, outside this component's setup. + async_call_later(hass, 1, async_call_later_callback) hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "sensor") + hass.config_entries.async_forward_entry_setup(entry, BINARY_SENSOR_DOMAIN) + ) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, SENSOR_DOMAIN) ) return True -async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Withings config entry.""" - return await hass.config_entries.async_forward_entry_unload(entry, "sensor") + data_manager = await async_get_data_manager(hass, entry) + data_manager.async_stop_polling_webhook_subscriptions() + + async_unregister_webhook(hass, data_manager.webhook_config.id) + + await asyncio.gather( + data_manager.async_unsubscribe_webhook(), + hass.config_entries.async_forward_entry_unload(entry, BINARY_SENSOR_DOMAIN), + hass.config_entries.async_forward_entry_unload(entry, SENSOR_DOMAIN), + ) + + async_remove_data_manager(hass, entry) + + return True + + +async def async_webhook_handler( + hass: HomeAssistant, webhook_id: str, request: Request +) -> Optional[Response]: + """Handle webhooks calls.""" + # Handle http head calls to the path. + # When creating a notify subscription, Withings will check that the endpoint is running by sending a HEAD request. + if request.method.upper() == "HEAD": + return Response() + + if request.method.upper() != "POST": + return json_message_response("Invalid method.", message_code=2) + + # Handle http post calls to the path. + if not request.body_exists: + return json_message_response("No request body.", message_code=12) + + params = await request.post() + + if "appli" not in params: + return json_message_response("Parameter appli not provided", message_code=20) + + try: + appli = cast( + NotifyAppli, enum_or_raise(int(params.getone("appli")), NotifyAppli) + ) + except ValueError: + return json_message_response("Invalid appli provided", message_code=21) + + data_manager = get_data_manager_by_webhook_id(hass, webhook_id) + if not data_manager: + _LOGGER.error( + "Webhook id %s not handled by data manager. This is a bug and should be reported.", + webhook_id, + ) + return json_message_response("User not found", message_code=1) + + # Run this in the background and return immediately. + hass.async_create_task(data_manager.async_webhook_data_updated(appli)) + + return json_message_response("Success", message_code=0) diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py new file mode 100644 index 00000000000..0fb8d8411fd --- /dev/null +++ b/homeassistant/components/withings/binary_sensor.py @@ -0,0 +1,40 @@ +"""Sensors flow for Withings.""" +from typing import Callable, List + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_PRESENCE, + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDevice, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity + +from .common import BaseWithingsSensor, async_create_entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up the sensor config entry.""" + entities = await async_create_entities( + hass, entry, WithingsHealthBinarySensor, BINARY_SENSOR_DOMAIN + ) + + async_add_entities(entities, True) + + +class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorDevice): + """Implementation of a Withings sensor.""" + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self._state_data + + @property + def device_class(self) -> str: + """Provide the device class.""" + return DEVICE_CLASS_PRESENCE diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 1539b973cb8..b3b8a8801d9 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -1,41 +1,62 @@ """Common code for Withings.""" -from asyncio import run_coroutine_threadsafe +import asyncio +from dataclasses import dataclass import datetime -from functools import partial +from datetime import timedelta +from enum import Enum, IntEnum import logging import re -import time -from typing import Any, Dict +from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from aiohttp.web import Response import requests -from withings_api import ( - AbstractWithingsApi, - MeasureGetMeasResponse, - SleepGetResponse, +from withings_api import AbstractWithingsApi +from withings_api.common import ( + AuthFailedException, + GetSleepSummaryField, + MeasureGroupAttribs, + MeasureType, + MeasureTypes, + NotifyAppli, SleepGetSummaryResponse, + UnauthorizedException, + query_measure_groups, ) -from withings_api.common import AuthFailedException, UnauthorizedException +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady +from homeassistant.const import ( + CONF_WEBHOOK_ID, + MASS_KILOGRAMS, + SPEED_METERS_PER_SECOND, + TIME_SECONDS, + UNIT_PERCENTAGE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.config_entry_oauth2_flow import ( AUTH_CALLBACK_PATH, AbstractOAuth2Implementation, LocalOAuth2Implementation, OAuth2Session, ) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.helpers.network import get_url -from homeassistant.util import dt, slugify +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt from . import const +from .const import Measurement _LOGGER = logging.getLogger(const.LOG_NAMESPACE) -NOT_AUTHENTICATED_ERROR = re.compile( - # ".*(Error Code (100|101|102|200|401)|Missing access token parameter).*", - "^401,.*", - re.IGNORECASE, -) +NOT_AUTHENTICATED_ERROR = re.compile("^401,.*", re.IGNORECASE,) +DATA_UPDATED_SIGNAL = "withings_entity_state_updated" + +MeasurementData = Dict[Measurement, Any] class NotAuthenticatedError(HomeAssistantError): @@ -46,33 +67,403 @@ class ServiceError(HomeAssistantError): """Raise when the service has an error.""" -class ThrottleData: - """Throttle data.""" +class UpdateType(Enum): + """Data update type.""" - def __init__(self, interval: int, data: Any): - """Initialize throttle data.""" - self._time = int(time.time()) - self._interval = interval - self._data = data + POLL = "poll" + WEBHOOK = "webhook" - @property - def time(self) -> int: - """Get time created.""" - return self._time - @property - def interval(self) -> int: - """Get interval.""" - return self._interval +@dataclass +class WithingsAttribute: + """Immutable class for describing withings sensor data.""" - @property - def data(self) -> Any: - """Get data.""" - return self._data + measurement: Measurement + measute_type: Enum + friendly_name: str + unit_of_measurement: str + icon: Optional[str] + platform: str + enabled_by_default: bool + update_type: UpdateType - def is_expired(self) -> bool: - """Is this data expired.""" - return int(time.time()) - self.time > self.interval + +@dataclass +class WithingsData: + """Represents value and meta-data from the withings service.""" + + attribute: WithingsAttribute + value: Any + + +@dataclass +class WebhookConfig: + """Config for a webhook.""" + + id: str + url: str + enabled: bool + + +@dataclass +class StateData: + """State data held by data manager for retrieval by entities.""" + + unique_id: str + state: Any + + +WITHINGS_ATTRIBUTES = [ + WithingsAttribute( + Measurement.WEIGHT_KG, + MeasureType.WEIGHT, + "Weight", + MASS_KILOGRAMS, + "mdi:weight-kilogram", + SENSOR_DOMAIN, + True, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.FAT_MASS_KG, + MeasureType.FAT_MASS_WEIGHT, + "Fat Mass", + MASS_KILOGRAMS, + "mdi:weight-kilogram", + SENSOR_DOMAIN, + True, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.FAT_FREE_MASS_KG, + MeasureType.FAT_FREE_MASS, + "Fat Free Mass", + MASS_KILOGRAMS, + "mdi:weight-kilogram", + SENSOR_DOMAIN, + True, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.MUSCLE_MASS_KG, + MeasureType.MUSCLE_MASS, + "Muscle Mass", + MASS_KILOGRAMS, + "mdi:weight-kilogram", + SENSOR_DOMAIN, + True, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.BONE_MASS_KG, + MeasureType.BONE_MASS, + "Bone Mass", + MASS_KILOGRAMS, + "mdi:weight-kilogram", + SENSOR_DOMAIN, + True, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.HEIGHT_M, + MeasureType.HEIGHT, + "Height", + const.UOM_LENGTH_M, + "mdi:ruler", + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.TEMP_C, + MeasureType.TEMPERATURE, + "Temperature", + const.UOM_TEMP_C, + "mdi:thermometer", + SENSOR_DOMAIN, + True, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.BODY_TEMP_C, + MeasureType.BODY_TEMPERATURE, + "Body Temperature", + const.UOM_TEMP_C, + "mdi:thermometer", + SENSOR_DOMAIN, + True, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SKIN_TEMP_C, + MeasureType.SKIN_TEMPERATURE, + "Skin Temperature", + const.UOM_TEMP_C, + "mdi:thermometer", + SENSOR_DOMAIN, + True, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.FAT_RATIO_PCT, + MeasureType.FAT_RATIO, + "Fat Ratio", + UNIT_PERCENTAGE, + None, + SENSOR_DOMAIN, + True, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.DIASTOLIC_MMHG, + MeasureType.DIASTOLIC_BLOOD_PRESSURE, + "Diastolic Blood Pressure", + const.UOM_MMHG, + None, + SENSOR_DOMAIN, + True, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SYSTOLIC_MMGH, + MeasureType.SYSTOLIC_BLOOD_PRESSURE, + "Systolic Blood Pressure", + const.UOM_MMHG, + None, + SENSOR_DOMAIN, + True, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.HEART_PULSE_BPM, + MeasureType.HEART_RATE, + "Heart Pulse", + const.UOM_BEATS_PER_MINUTE, + "mdi:heart-pulse", + SENSOR_DOMAIN, + True, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SPO2_PCT, + MeasureType.SP02, + "SP02", + UNIT_PERCENTAGE, + None, + SENSOR_DOMAIN, + True, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.HYDRATION, + MeasureType.HYDRATION, + "Hydration", + UNIT_PERCENTAGE, + "mdi:water", + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.PWV, + MeasureType.PULSE_WAVE_VELOCITY, + "Pulse Wave Velocity", + SPEED_METERS_PER_SECOND, + None, + SENSOR_DOMAIN, + True, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, + GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, + "Breathing disturbances intensity", + "", + "", + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_DEEP_DURATION_SECONDS, + GetSleepSummaryField.DEEP_SLEEP_DURATION, + "Deep sleep", + TIME_SECONDS, + "mdi:sleep", + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, + GetSleepSummaryField.DURATION_TO_SLEEP, + "Time to sleep", + TIME_SECONDS, + "mdi:sleep", + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, + GetSleepSummaryField.DURATION_TO_WAKEUP, + "Time to wakeup", + TIME_SECONDS, + "mdi:sleep-off", + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_HEART_RATE_AVERAGE, + GetSleepSummaryField.HR_AVERAGE, + "Average heart rate", + const.UOM_BEATS_PER_MINUTE, + "mdi:heart-pulse", + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_HEART_RATE_MAX, + GetSleepSummaryField.HR_MAX, + "Maximum heart rate", + const.UOM_BEATS_PER_MINUTE, + "mdi:heart-pulse", + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_HEART_RATE_MIN, + GetSleepSummaryField.HR_MIN, + "Minimum heart rate", + const.UOM_BEATS_PER_MINUTE, + "mdi:heart-pulse", + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_LIGHT_DURATION_SECONDS, + GetSleepSummaryField.LIGHT_SLEEP_DURATION, + "Light sleep", + TIME_SECONDS, + "mdi:sleep", + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_REM_DURATION_SECONDS, + GetSleepSummaryField.REM_SLEEP_DURATION, + "REM sleep", + TIME_SECONDS, + "mdi:sleep", + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, + GetSleepSummaryField.RR_AVERAGE, + "Average respiratory rate", + const.UOM_BREATHS_PER_MINUTE, + None, + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_RESPIRATORY_RATE_MAX, + GetSleepSummaryField.RR_MAX, + "Maximum respiratory rate", + const.UOM_BREATHS_PER_MINUTE, + None, + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_RESPIRATORY_RATE_MIN, + GetSleepSummaryField.RR_MIN, + "Minimum respiratory rate", + const.UOM_BREATHS_PER_MINUTE, + None, + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_SCORE, + GetSleepSummaryField.SLEEP_SCORE, + "Sleep score", + "", + None, + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_SNORING, + GetSleepSummaryField.SNORING, + "Snoring", + "", + None, + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_SNORING_EPISODE_COUNT, + GetSleepSummaryField.SNORING_EPISODE_COUNT, + "Snoring episode count", + "", + None, + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_WAKEUP_COUNT, + GetSleepSummaryField.WAKEUP_COUNT, + "Wakeup count", + const.UOM_FREQUENCY, + "mdi:sleep-off", + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + WithingsAttribute( + Measurement.SLEEP_WAKEUP_DURATION_SECONDS, + GetSleepSummaryField.WAKEUP_DURATION, + "Wakeup time", + TIME_SECONDS, + "mdi:sleep-off", + SENSOR_DOMAIN, + False, + UpdateType.POLL, + ), + # Webhook measurements. + WithingsAttribute( + Measurement.IN_BED, + NotifyAppli.BED_IN, + "In bed", + "", + "mdi:bed", + BINARY_SENSOR_DOMAIN, + True, + UpdateType.WEBHOOK, + ), +] + +WITHINGS_MEASUREMENTS_MAP: Dict[Measurement, WithingsAttribute] = { + attr.measurement: attr for attr in WITHINGS_ATTRIBUTES +} + +WITHINGS_MEASURE_TYPE_MAP: Dict[ + Union[NotifyAppli, GetSleepSummaryField, MeasureType], WithingsAttribute +] = {attr.measute_type: attr for attr in WITHINGS_ATTRIBUTES} class ConfigEntryWithingsApi(AbstractWithingsApi): @@ -92,184 +483,308 @@ class ConfigEntryWithingsApi(AbstractWithingsApi): def _request( self, path: str, params: Dict[str, Any], method: str = "GET" - ) -> Dict[str, Any]: - return run_coroutine_threadsafe( - self.async_do_request(path, params, method), self._hass.loop - ).result() - - async def async_do_request( - self, path: str, params: Dict[str, Any], method: str = "GET" ) -> Dict[str, Any]: """Perform an async request.""" - await self.session.async_ensure_token_valid() - - response = await self._hass.async_add_executor_job( - partial( - requests.request, - method, - f"{self.URL}/{path}", - params=params, - headers={ - "Authorization": "Bearer %s" - % self._config_entry.data["token"]["access_token"] - }, - ) + asyncio.run_coroutine_threadsafe( + self.session.async_ensure_token_valid(), self._hass.loop ) + 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}"}, + ) return response.json() -class WithingsDataManager: - """A class representing an Withings cloud service connection.""" +def json_message_response(message: str, message_code: int) -> Response: + """Produce common json output.""" + return HomeAssistantView.json({"message": message, "code": message_code}, 200) - service_available = None - def __init__(self, hass: HomeAssistant, profile: str, api: ConfigEntryWithingsApi): - """Initialize data manager.""" +class WebhookAvailability(IntEnum): + """Represents various statuses of webhook availability.""" + + SUCCESS = 0 + CONNECT_ERROR = 1 + HTTP_ERROR = 2 + NOT_WEBHOOK = 3 + + +class WebhookUpdateCoordinator: + """Coordinates webhook data updates across listeners.""" + + def __init__(self, hass: HomeAssistant, user_id: int) -> None: + """Initialize the object.""" + self._hass = hass + self._user_id = user_id + self._listeners: List[CALLBACK_TYPE] = [] + self.data: MeasurementData = {} + + def async_add_listener(self, listener: CALLBACK_TYPE) -> Callable[[], None]: + """Add a listener.""" + self._listeners.append(listener) + + @callback + def remove_listener() -> None: + self.async_remove_listener(listener) + + return remove_listener + + def async_remove_listener(self, listener: CALLBACK_TYPE) -> None: + """Remove a listener.""" + self._listeners.remove(listener) + + def update_data(self, measurement: Measurement, value: Any) -> None: + """Update the data object and notify listeners the data has changed.""" + self.data[measurement] = value + self.notify_data_changed() + + def notify_data_changed(self) -> None: + """Notify all listeners the data has changed.""" + for listener in self._listeners: + listener() + + +class DataManager: + """Manage withing data.""" + + def __init__( + self, + hass: HomeAssistant, + profile: str, + api: ConfigEntryWithingsApi, + user_id: int, + webhook_config: WebhookConfig, + ): + """Initialize the data manager.""" self._hass = hass self._api = api + self._user_id = user_id self._profile = profile - self._slug = slugify(profile) + self._webhook_config = webhook_config + self._notify_subscribe_delay = datetime.timedelta(seconds=5) + self._notify_unsubscribe_delay = datetime.timedelta(seconds=1) - self._measures = None - self._sleep = None - self._sleep_summary = None + self._is_available = True + self._cancel_interval_update_interval: Optional[CALLBACK_TYPE] = None + self._cancel_configure_webhook_subscribe_interval: Optional[ + CALLBACK_TYPE + ] = None + self._api_notification_id = f"withings_{self._user_id}" - self.sleep_summary_last_update_parameter = None - self.throttle_data = {} + self.subscription_update_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="subscription_update_coordinator", + update_interval=timedelta(minutes=120), + update_method=self.async_subscribe_webhook, + ) + self.poll_data_update_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="poll_data_update_coordinator", + update_interval=timedelta(minutes=120) + if self._webhook_config.enabled + else timedelta(minutes=10), + update_method=self.async_get_all_data, + ) + self.webhook_update_coordinator = WebhookUpdateCoordinator( + self._hass, self._user_id + ) + self._cancel_subscription_update: Optional[Callable[[], None]] = None + self._subscribe_webhook_run_count = 0 + + @property + def webhook_config(self) -> WebhookConfig: + """Get the webhook config.""" + return self._webhook_config + + @property + def user_id(self) -> int: + """Get the user_id of the authenticated user.""" + return self._user_id @property def profile(self) -> str: """Get the profile.""" return self._profile - @property - def slug(self) -> str: - """Get the slugified profile the data is for.""" - return self._slug + def async_start_polling_webhook_subscriptions(self) -> None: + """Start polling webhook subscriptions (if enabled) to reconcile their setup.""" + self.async_stop_polling_webhook_subscriptions() - @property - def api(self) -> ConfigEntryWithingsApi: - """Get the api object.""" - return self._api + def empty_listener() -> None: + pass - @property - def measures(self) -> MeasureGetMeasResponse: - """Get the current measures data.""" - return self._measures - - @property - def sleep(self) -> SleepGetResponse: - """Get the current sleep data.""" - return self._sleep - - @property - def sleep_summary(self) -> SleepGetSummaryResponse: - """Get the current sleep summary data.""" - return self._sleep_summary - - @staticmethod - def get_throttle_interval() -> int: - """Get the throttle interval.""" - return const.THROTTLE_INTERVAL - - def get_throttle_data(self, domain: str) -> ThrottleData: - """Get throttlel data.""" - return self.throttle_data.get(domain) - - def set_throttle_data(self, domain: str, throttle_data: ThrottleData): - """Set throttle data.""" - self.throttle_data[domain] = throttle_data - - @staticmethod - def print_service_unavailable() -> bool: - """Print the service is unavailable (once) to the log.""" - if WithingsDataManager.service_available is not False: - _LOGGER.error("Looks like the service is not available at the moment") - WithingsDataManager.service_available = False - return True - - return False - - @staticmethod - def print_service_available() -> bool: - """Print the service is available (once) to to the log.""" - if WithingsDataManager.service_available is not True: - _LOGGER.info("Looks like the service is available again") - WithingsDataManager.service_available = True - return True - - return False - - async def call(self, function, throttle_domain=None) -> Any: - """Call an api method and handle the result.""" - throttle_data = self.get_throttle_data(throttle_domain) - - should_throttle = ( - throttle_domain and throttle_data and not throttle_data.is_expired() + self._cancel_subscription_update = self.subscription_update_coordinator.async_add_listener( + empty_listener ) + def async_stop_polling_webhook_subscriptions(self) -> None: + """Stop polling webhook subscriptions.""" + if self._cancel_subscription_update: + self._cancel_subscription_update() + self._cancel_subscription_update = None + + async def _do_retry(self, func, 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 + await asyncio.sleep(0.1) + exception = exception1 + continue + + if exception: + raise exception + + async def async_subscribe_webhook(self) -> None: + """Subscribe the webhook to withings data updates.""" + return await self._do_retry(self._async_subscribe_webhook) + + async def _async_subscribe_webhook(self) -> None: + _LOGGER.debug("Configuring withings webhook.") + + # On first startup, perform a fresh re-subscribe. Withings stops pushing data + # if the webhook fails enough times but they don't remove the old subscription + # config. This ensures the subscription is setup correctly and they start + # pushing again. + if self._subscribe_webhook_run_count == 0: + _LOGGER.debug("Refreshing withings webhook configs.") + await self.async_unsubscribe_webhook() + self._subscribe_webhook_run_count += 1 + + # Get the current webhooks. + response = await self._hass.async_add_executor_job(self._api.notify_list) + + subscribed_applis = frozenset( + [ + profile.appli + for profile in response.profiles + if profile.callbackurl == self._webhook_config.url + ] + ) + + # Determine what subscriptions need to be created. + ignored_applis = frozenset({NotifyAppli.USER}) + to_add_applis = frozenset( + [ + appli + for appli in NotifyAppli + if appli not in subscribed_applis and appli not in ignored_applis + ] + ) + + # Subscribe to each one. + for appli in to_add_applis: + _LOGGER.debug( + "Subscribing %s for %s in %s seconds", + self._webhook_config.url, + appli, + self._notify_subscribe_delay.total_seconds(), + ) + # Withings will HTTP HEAD the callback_url and needs some downtime + # between each call or there is a higher chance of failure. + await asyncio.sleep(self._notify_subscribe_delay.total_seconds()) + await self._hass.async_add_executor_job( + self._api.notify_subscribe, self._webhook_config.url, appli + ) + + async def async_unsubscribe_webhook(self) -> None: + """Unsubscribe webhook from withings data updates.""" + return await self._do_retry(self._async_unsubscribe_webhook) + + async def _async_unsubscribe_webhook(self) -> None: + # Get the current webhooks. + response = await self._hass.async_add_executor_job(self._api.notify_list) + + # Revoke subscriptions. + for profile in response.profiles: + _LOGGER.debug( + "Unsubscribing %s for %s in %s seconds", + profile.callbackurl, + profile.appli, + self._notify_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(self._notify_subscribe_delay.total_seconds()) + await self._hass.async_add_executor_job( + self._api.notify_revoke, profile.callbackurl, profile.appli + ) + + async def async_get_all_data(self) -> Optional[Dict[MeasureType, Any]]: + """Update all withings data.""" try: - if should_throttle: - _LOGGER.debug("Throttling call for domain: %s", throttle_domain) - result = throttle_data.data - else: - _LOGGER.debug("Running call.") - result = await self._hass.async_add_executor_job(function) + return await self._do_retry(self._async_get_all_data) + except Exception as exception: + # User is not authenticated. + if isinstance( + exception, (UnauthorizedException, AuthFailedException) + ) or NOT_AUTHENTICATED_ERROR.match(str(exception)): + context = { + const.PROFILE: self._profile, + "userid": self._user_id, + "source": "reauth", + } - # Update throttle data. - self.set_throttle_data( - throttle_domain, ThrottleData(self.get_throttle_interval(), result) + # Check if reauth flow already exists. + flow = next( + iter( + flow + for flow in self._hass.config_entries.flow.async_progress() + if flow.context == context + ), + None, ) + if flow: + return - WithingsDataManager.print_service_available() - return result + # Start a reauth flow. + await self._hass.config_entries.flow.async_init( + const.DOMAIN, context=context, + ) + return - except Exception as ex: - # Withings api encountered error. - if isinstance(ex, (UnauthorizedException, AuthFailedException)): - raise NotAuthenticatedError(ex) + raise exception - # Oauth2 config flow failed to authenticate. - if NOT_AUTHENTICATED_ERROR.match(str(ex)): - raise NotAuthenticatedError(ex) + async def _async_get_all_data(self) -> Optional[Dict[MeasureType, Any]]: + _LOGGER.info("Updating all withings data.") + return { + **await self.async_get_measures(), + **await self.async_get_sleep_summary(), + } - # Probably a network error. - WithingsDataManager.print_service_unavailable() - raise PlatformNotReady(ex) + async def async_get_measures(self) -> Dict[MeasureType, Any]: + """Get the measures data.""" + _LOGGER.debug("Updating withings measures") - async def check_authenticated(self) -> bool: - """Check if the user is authenticated.""" + response = await self._hass.async_add_executor_job(self._api.measure_get_meas) - def function(): - return bool(self._api.user_get_device()) + groups = query_measure_groups( + response, MeasureTypes.ANY, MeasureGroupAttribs.UNAMBIGUOUS + ) - return await self.call(function) + return { + WITHINGS_MEASURE_TYPE_MAP[measure.type].measurement: round( + float(measure.value * pow(10, measure.unit)), 2 + ) + for group in groups + for measure in group.measures + } - async def update_measures(self) -> MeasureGetMeasResponse: - """Update the measures data.""" - - def function(): - return self._api.measure_get_meas() - - self._measures = await self.call(function, throttle_domain="update_measures") - - return self._measures - - async def update_sleep(self) -> SleepGetResponse: - """Update the sleep data.""" - end_date = dt.now() - start_date = end_date - datetime.timedelta(hours=2) - - def function(): - return self._api.sleep_get(startdate=start_date, enddate=end_date) - - self._sleep = await self.call(function, throttle_domain="update_sleep") - - return self._sleep - - async def update_sleep_summary(self) -> SleepGetSummaryResponse: - """Update the sleep summary data.""" + async def async_get_sleep_summary(self) -> Dict[MeasureType, Any]: + """Get the sleep summary data.""" + _LOGGER.debug("Updating withing sleep summary") now = dt.utcnow() yesterday = now - datetime.timedelta(days=1) yesterday_noon = datetime.datetime( @@ -283,62 +798,275 @@ class WithingsDataManager: datetime.timezone.utc, ) - _LOGGER.debug( - "Getting sleep summary data since: %s", - yesterday.strftime("%Y-%m-%d %H:%M:%S UTC"), - ) - - def function(): + def get_sleep_summary() -> SleepGetSummaryResponse: return self._api.sleep_get_summary(lastupdate=yesterday_noon) - self._sleep_summary = await self.call( - function, throttle_domain="update_sleep_summary" + response = await self._hass.async_add_executor_job(get_sleep_summary) + + # 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(data._asdict()[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].measurement: round(value, 4) + if value is not None + else None + for field, value in values.items() + } + + async def async_webhook_data_updated(self, data_category: NotifyAppli) -> None: + """Handle scenario when data is updated from a webook.""" + _LOGGER.debug("Withings webhook triggered") + if data_category in { + NotifyAppli.WEIGHT, + NotifyAppli.CIRCULATORY, + NotifyAppli.SLEEP, + }: + await self.poll_data_update_coordinator.async_request_refresh() + + elif data_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}: + self.webhook_update_coordinator.update_data( + Measurement.IN_BED, data_category == NotifyAppli.BED_IN + ) + + +def get_attribute_unique_id(attribute: WithingsAttribute, user_id: int) -> str: + """Get a entity unique id for a user's attribute.""" + return f"withings_{user_id}_{attribute.measurement.value}" + + +async def async_get_entity_id( + hass: HomeAssistant, attribute: WithingsAttribute, user_id: int +) -> Optional[str]: + """Get an entity id for a user's attribute.""" + entity_registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() + unique_id = get_attribute_unique_id(attribute, user_id) + + entity_id = entity_registry.async_get_entity_id( + attribute.platform, const.DOMAIN, unique_id + ) + + if entity_id is None: + _LOGGER.error("Cannot find entity id for unique_id: %s", unique_id) + return None + + return entity_id + + +class BaseWithingsSensor(Entity): + """Base class for withings sensors.""" + + def __init__(self, data_manager: DataManager, attribute: WithingsAttribute) -> None: + """Initialize the Withings sensor.""" + self._data_manager = data_manager + self._attribute = attribute + self._profile = self._data_manager.profile + self._user_id = self._data_manager.user_id + self._name = f"Withings {self._attribute.measurement.value} {self._profile}" + self._unique_id = get_attribute_unique_id(self._attribute, self._user_id) + self._state_data: Optional[Any] = None + + @property + def should_poll(self) -> bool: + """Return False to indicate HA should not poll for changes.""" + return False + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self._attribute.update_type == UpdateType.POLL: + return self._data_manager.poll_data_update_coordinator.last_update_success + + return True + + @property + def unique_id(self) -> str: + """Return a unique, Home Assistant friendly identifier for this entity.""" + return self._unique_id + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement of this entity, if any.""" + return self._attribute.unit_of_measurement + + @property + def icon(self) -> str: + """Icon to use in the frontend, if any.""" + return self._attribute.icon + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self._attribute.enabled_by_default + + @callback + def _on_poll_data_updated(self) -> None: + self._update_state_data( + self._data_manager.poll_data_update_coordinator.data or {} ) - return self._sleep_summary + @callback + def _on_webhook_data_updated(self) -> None: + self._update_state_data( + self._data_manager.webhook_update_coordinator.data or {} + ) + + def _update_state_data(self, data: MeasurementData) -> None: + """Update the state data.""" + self._state_data = data.get(self._attribute.measurement) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register update dispatcher.""" + if self._attribute.update_type == UpdateType.POLL: + self.async_on_remove( + self._data_manager.poll_data_update_coordinator.async_add_listener( + self._on_poll_data_updated + ) + ) + self._on_poll_data_updated() + + elif self._attribute.update_type == UpdateType.WEBHOOK: + self.async_on_remove( + self._data_manager.webhook_update_coordinator.async_add_listener( + self._on_webhook_data_updated + ) + ) + self._on_webhook_data_updated() -def create_withings_data_manager( - hass: HomeAssistant, - config_entry: ConfigEntry, - implementation: AbstractOAuth2Implementation, -) -> WithingsDataManager: - """Set up the sensor config entry.""" - profile = config_entry.data.get(const.PROFILE) +async def async_get_data_manager( + hass: HomeAssistant, config_entry: ConfigEntry +) -> DataManager: + """Get the data manager for a config entry.""" + hass.data.setdefault(const.DOMAIN, {}) + hass.data[const.DOMAIN].setdefault(config_entry.entry_id, {}) + config_entry_data = hass.data[const.DOMAIN][config_entry.entry_id] - _LOGGER.debug("Creating withings api instance") - api = ConfigEntryWithingsApi( - hass=hass, config_entry=config_entry, implementation=implementation + if const.DATA_MANAGER not in config_entry_data: + profile = config_entry.data.get(const.PROFILE) + + _LOGGER.debug("Creating withings data manager for profile: %s", profile) + config_entry_data[const.DATA_MANAGER] = DataManager( + hass, + profile, + ConfigEntryWithingsApi( + hass=hass, + config_entry=config_entry, + implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, config_entry + ), + ), + config_entry.data["token"]["userid"], + WebhookConfig( + id=config_entry.data[CONF_WEBHOOK_ID], + url=config_entry.data[const.CONF_WEBHOOK_URL], + enabled=config_entry.data[const.CONF_USE_WEBHOOK], + ), + ) + + return config_entry_data[const.DATA_MANAGER] + + +def get_data_manager_by_webhook_id( + hass: HomeAssistant, webhook_id: str +) -> Optional[DataManager]: + """Get a data manager by it's webhook id.""" + return next( + iter( + [ + data_manager + for data_manager in get_all_data_managers(hass) + if data_manager.webhook_config.id == webhook_id + ] + ), + None, ) - _LOGGER.debug("Creating withings data manager for profile: %s", profile) - return WithingsDataManager(hass, profile, api) + +def get_all_data_managers(hass: HomeAssistant) -> Tuple[DataManager, ...]: + """Get all configured data managers.""" + return tuple( + [ + config_entry_data[const.DATA_MANAGER] + for config_entry_data in hass.data[const.DOMAIN].values() + if const.DATA_MANAGER in config_entry_data + ] + ) -def get_data_manager( +def async_remove_data_manager(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Remove a data manager for a config entry.""" + del hass.data[const.DOMAIN][config_entry.entry_id][const.DATA_MANAGER] + + +async def async_create_entities( hass: HomeAssistant, entry: ConfigEntry, - implementation: AbstractOAuth2Implementation, -) -> WithingsDataManager: - """Get a data manager for a config entry. + create_func: Callable[[DataManager, WithingsAttribute], Entity], + platform: str, +) -> List[Entity]: + """Create withings entities from config entry.""" + data_manager = await async_get_data_manager(hass, entry) - If the data manager doesn't exist yet, it will be - created and cached for later use. - """ - entry_id = entry.entry_id + return [ + create_func(data_manager, attribute) + for attribute in get_platform_attributes(platform) + ] - hass.data[const.DOMAIN] = hass.data.get(const.DOMAIN, {}) - domain_dict = hass.data[const.DOMAIN] - domain_dict[const.DATA_MANAGER] = domain_dict.get(const.DATA_MANAGER, {}) - - dm_dict = domain_dict[const.DATA_MANAGER] - dm_dict[entry_id] = dm_dict.get(entry_id) or create_withings_data_manager( - hass, entry, implementation +def get_platform_attributes(platform: str) -> Tuple[WithingsAttribute, ...]: + """Get withings attributes used for a specific platform.""" + return tuple( + [ + attribute + for attribute in WITHINGS_ATTRIBUTES + if attribute.platform == platform + ] ) - return dm_dict[entry_id] - class WithingsLocalOAuth2Implementation(LocalOAuth2Implementation): """Oauth2 implementation that only uses the external url.""" diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index e18a4b0337a..d07ed419fce 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -11,8 +11,9 @@ from homeassistant.helpers import config_entry_oauth2_flow _LOGGER = logging.getLogger(__name__) -@config_entries.HANDLERS.register(const.DOMAIN) -class WithingsFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): +class WithingsFlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=const.DOMAIN +): """Handle a config flow.""" DOMAIN = const.DOMAIN @@ -33,6 +34,7 @@ class WithingsFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): AuthScope.USER_INFO.value, AuthScope.USER_METRICS.value, AuthScope.USER_ACTIVITY.value, + AuthScope.USER_SLEEP_EVENTS.value, ] ) } @@ -57,8 +59,20 @@ class WithingsFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): data_schema=vol.Schema({vol.Required(const.PROFILE): vol.In(profiles)}), ) + async def async_step_reauth(self, data: dict) -> dict: + """Prompt user to re-authenticate.""" + if data is not None: + return await self.async_step_user() + + return self.async_show_form( + step_id="reauth", + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + description_placeholders={"profile": self.context["profile"]}, + ) + async def async_step_finish(self, data: dict) -> dict: """Finish the flow.""" self._current_data = None + await self.async_set_unique_id(data["token"]["userid"], raise_on_progress=False) return self.async_create_entry(title=data[const.PROFILE], data=data) diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index f2a29cfa3ca..c6cad929f81 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -1,61 +1,59 @@ """Constants used by the Withings component.""" +from enum import Enum + import homeassistant.const as const -DOMAIN = "withings" - CONF_PROFILES = "profiles" +CONF_USE_WEBHOOK = "use_webhook" DATA_MANAGER = "data_manager" -BASE_URL = "base_url" -CODE = "code" CONFIG = "config" -CREDENTIALS = "credentials" +DOMAIN = "withings" LOG_NAMESPACE = "homeassistant.components.withings" -MEASURES = "measures" PROFILE = "profile" +PUSH_HANDLER = "push_handler" +CONF_WEBHOOK_URL = "webhook_url" -AUTH_CALLBACK_PATH = "/api/withings/authorize" -AUTH_CALLBACK_NAME = "withings:authorize" -THROTTLE_INTERVAL = 60 -SCAN_INTERVAL = 60 +class Measurement(Enum): + """Measurement supported by the withings integration.""" -STATE_UNKNOWN = const.STATE_UNKNOWN -STATE_AWAKE = "awake" -STATE_DEEP = "deep" -STATE_LIGHT = "light" -STATE_REM = "rem" + 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" -MEAS_BODY_TEMP_C = "body_temperature_c" -MEAS_BONE_MASS_KG = "bone_mass_kg" -MEAS_DIASTOLIC_MMHG = "diastolic_blood_pressure_mmhg" -MEAS_FAT_FREE_MASS_KG = "fat_free_mass_kg" -MEAS_FAT_MASS_KG = "fat_mass_kg" -MEAS_FAT_RATIO_PCT = "fat_ratio_pct" -MEAS_HEART_PULSE_BPM = "heart_pulse_bpm" -MEAS_HEIGHT_M = "height_m" -MEAS_HYDRATION = "hydration" -MEAS_MUSCLE_MASS_KG = "muscle_mass_kg" -MEAS_PWV = "pulse_wave_velocity" -MEAS_SKIN_TEMP_C = "skin_temperature_c" -MEAS_SLEEP_DEEP_DURATION_SECONDS = "sleep_deep_duration_seconds" -MEAS_SLEEP_HEART_RATE_AVERAGE = "sleep_heart_rate_average_bpm" -MEAS_SLEEP_HEART_RATE_MAX = "sleep_heart_rate_max_bpm" -MEAS_SLEEP_HEART_RATE_MIN = "sleep_heart_rate_min_bpm" -MEAS_SLEEP_LIGHT_DURATION_SECONDS = "sleep_light_duration_seconds" -MEAS_SLEEP_REM_DURATION_SECONDS = "sleep_rem_duration_seconds" -MEAS_SLEEP_RESPIRATORY_RATE_AVERAGE = "sleep_respiratory_average_bpm" -MEAS_SLEEP_RESPIRATORY_RATE_MAX = "sleep_respiratory_max_bpm" -MEAS_SLEEP_RESPIRATORY_RATE_MIN = "sleep_respiratory_min_bpm" -MEAS_SLEEP_TOSLEEP_DURATION_SECONDS = "sleep_tosleep_duration_seconds" -MEAS_SLEEP_TOWAKEUP_DURATION_SECONDS = "sleep_towakeup_duration_seconds" -MEAS_SLEEP_WAKEUP_COUNT = "sleep_wakeup_count" -MEAS_SLEEP_WAKEUP_DURATION_SECONDS = "sleep_wakeup_duration_seconds" -MEAS_SPO2_PCT = "spo2_pct" -MEAS_SYSTOLIC_MMGH = "systolic_blood_pressure_mmhg" -MEAS_TEMP_C = "temperature_c" -MEAS_WEIGHT_KG = "weight_kg" UOM_BEATS_PER_MINUTE = "bpm" UOM_BREATHS_PER_MINUTE = f"br/{const.TIME_MINUTES}" diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 337a98ab404..ec981ff691c 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -3,7 +3,7 @@ "name": "Withings", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/withings", - "requirements": ["withings-api==2.1.3"], - "dependencies": ["api", "http", "webhook"], + "requirements": ["withings-api==2.1.6"], + "dependencies": ["http", "webhook"], "codeowners": ["@vangorra"] } diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 4061e3207cc..a7580faa3d0 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -1,34 +1,12 @@ """Sensors flow for Withings.""" from typing import Callable, List, Union -from withings_api.common import ( - GetSleepSummaryField, - MeasureGetMeasResponse, - MeasureGroupAttribs, - MeasureType, - SleepGetSummaryResponse, - get_measure_value, -) - +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - MASS_KILOGRAMS, - SPEED_METERS_PER_SECOND, - TIME_SECONDS, - UNIT_PERCENTAGE, -) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.entity import Entity -from homeassistant.util import slugify -from . import const -from .common import _LOGGER, WithingsDataManager, get_data_manager - -# There's only 3 calls (per profile) made to the withings api every 5 -# minutes (see throttle values). This component wouldn't benefit -# much from parallel updates. -PARALLEL_UPDATES = 1 +from .common import BaseWithingsSensor, async_create_entities async def async_setup_entry( @@ -37,401 +15,18 @@ async def async_setup_entry( async_add_entities: Callable[[List[Entity], bool], None], ) -> None: """Set up the sensor config entry.""" - implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + + entities = await async_create_entities( + hass, entry, WithingsHealthSensor, SENSOR_DOMAIN, ) - data_manager = get_data_manager(hass, entry, implementation) - user_id = entry.data["token"]["userid"] - - entities = create_sensor_entities(data_manager, user_id) async_add_entities(entities, True) -class WithingsAttribute: - """Base class for modeling withing data.""" - - def __init__( - self, - measurement: str, - measure_type, - friendly_name: str, - unit_of_measurement: str, - icon: str, - ) -> None: - """Initialize attribute.""" - self.measurement = measurement - self.measure_type = measure_type - self.friendly_name = friendly_name - self.unit_of_measurement = unit_of_measurement - self.icon = icon - - -class WithingsMeasureAttribute(WithingsAttribute): - """Model measure attributes.""" - - -class WithingsSleepSummaryAttribute(WithingsAttribute): - """Models sleep summary attributes.""" - - -WITHINGS_ATTRIBUTES = [ - WithingsMeasureAttribute( - const.MEAS_WEIGHT_KG, - MeasureType.WEIGHT, - "Weight", - MASS_KILOGRAMS, - "mdi:weight-kilogram", - ), - WithingsMeasureAttribute( - const.MEAS_FAT_MASS_KG, - MeasureType.FAT_MASS_WEIGHT, - "Fat Mass", - MASS_KILOGRAMS, - "mdi:weight-kilogram", - ), - WithingsMeasureAttribute( - const.MEAS_FAT_FREE_MASS_KG, - MeasureType.FAT_FREE_MASS, - "Fat Free Mass", - MASS_KILOGRAMS, - "mdi:weight-kilogram", - ), - WithingsMeasureAttribute( - const.MEAS_MUSCLE_MASS_KG, - MeasureType.MUSCLE_MASS, - "Muscle Mass", - MASS_KILOGRAMS, - "mdi:weight-kilogram", - ), - WithingsMeasureAttribute( - const.MEAS_BONE_MASS_KG, - MeasureType.BONE_MASS, - "Bone Mass", - MASS_KILOGRAMS, - "mdi:weight-kilogram", - ), - WithingsMeasureAttribute( - const.MEAS_HEIGHT_M, - MeasureType.HEIGHT, - "Height", - const.UOM_LENGTH_M, - "mdi:ruler", - ), - WithingsMeasureAttribute( - const.MEAS_TEMP_C, - MeasureType.TEMPERATURE, - "Temperature", - const.UOM_TEMP_C, - "mdi:thermometer", - ), - WithingsMeasureAttribute( - const.MEAS_BODY_TEMP_C, - MeasureType.BODY_TEMPERATURE, - "Body Temperature", - const.UOM_TEMP_C, - "mdi:thermometer", - ), - WithingsMeasureAttribute( - const.MEAS_SKIN_TEMP_C, - MeasureType.SKIN_TEMPERATURE, - "Skin Temperature", - const.UOM_TEMP_C, - "mdi:thermometer", - ), - WithingsMeasureAttribute( - const.MEAS_FAT_RATIO_PCT, - MeasureType.FAT_RATIO, - "Fat Ratio", - UNIT_PERCENTAGE, - None, - ), - WithingsMeasureAttribute( - const.MEAS_DIASTOLIC_MMHG, - MeasureType.DIASTOLIC_BLOOD_PRESSURE, - "Diastolic Blood Pressure", - const.UOM_MMHG, - None, - ), - WithingsMeasureAttribute( - const.MEAS_SYSTOLIC_MMGH, - MeasureType.SYSTOLIC_BLOOD_PRESSURE, - "Systolic Blood Pressure", - const.UOM_MMHG, - None, - ), - WithingsMeasureAttribute( - const.MEAS_HEART_PULSE_BPM, - MeasureType.HEART_RATE, - "Heart Pulse", - const.UOM_BEATS_PER_MINUTE, - "mdi:heart-pulse", - ), - WithingsMeasureAttribute( - const.MEAS_SPO2_PCT, MeasureType.SP02, "SP02", UNIT_PERCENTAGE, None - ), - WithingsMeasureAttribute( - const.MEAS_HYDRATION, - MeasureType.HYDRATION, - "Hydration", - UNIT_PERCENTAGE, - "mdi:water", - ), - WithingsMeasureAttribute( - const.MEAS_PWV, - MeasureType.PULSE_WAVE_VELOCITY, - "Pulse Wave Velocity", - SPEED_METERS_PER_SECOND, - None, - ), - WithingsSleepSummaryAttribute( - const.MEAS_SLEEP_WAKEUP_DURATION_SECONDS, - GetSleepSummaryField.WAKEUP_DURATION.value, - "Wakeup time", - TIME_SECONDS, - "mdi:sleep-off", - ), - WithingsSleepSummaryAttribute( - const.MEAS_SLEEP_LIGHT_DURATION_SECONDS, - GetSleepSummaryField.LIGHT_SLEEP_DURATION.value, - "Light sleep", - TIME_SECONDS, - "mdi:sleep", - ), - WithingsSleepSummaryAttribute( - const.MEAS_SLEEP_DEEP_DURATION_SECONDS, - GetSleepSummaryField.DEEP_SLEEP_DURATION.value, - "Deep sleep", - TIME_SECONDS, - "mdi:sleep", - ), - WithingsSleepSummaryAttribute( - const.MEAS_SLEEP_REM_DURATION_SECONDS, - GetSleepSummaryField.REM_SLEEP_DURATION.value, - "REM sleep", - TIME_SECONDS, - "mdi:sleep", - ), - WithingsSleepSummaryAttribute( - const.MEAS_SLEEP_WAKEUP_COUNT, - GetSleepSummaryField.WAKEUP_COUNT.value, - "Wakeup count", - const.UOM_FREQUENCY, - "mdi:sleep-off", - ), - WithingsSleepSummaryAttribute( - const.MEAS_SLEEP_TOSLEEP_DURATION_SECONDS, - GetSleepSummaryField.DURATION_TO_SLEEP.value, - "Time to sleep", - TIME_SECONDS, - "mdi:sleep", - ), - WithingsSleepSummaryAttribute( - const.MEAS_SLEEP_TOWAKEUP_DURATION_SECONDS, - GetSleepSummaryField.DURATION_TO_WAKEUP.value, - "Time to wakeup", - TIME_SECONDS, - "mdi:sleep-off", - ), - WithingsSleepSummaryAttribute( - const.MEAS_SLEEP_HEART_RATE_AVERAGE, - GetSleepSummaryField.HR_AVERAGE.value, - "Average heart rate", - const.UOM_BEATS_PER_MINUTE, - "mdi:heart-pulse", - ), - WithingsSleepSummaryAttribute( - const.MEAS_SLEEP_HEART_RATE_MIN, - GetSleepSummaryField.HR_MIN.value, - "Minimum heart rate", - const.UOM_BEATS_PER_MINUTE, - "mdi:heart-pulse", - ), - WithingsSleepSummaryAttribute( - const.MEAS_SLEEP_HEART_RATE_MAX, - GetSleepSummaryField.HR_MAX.value, - "Maximum heart rate", - const.UOM_BEATS_PER_MINUTE, - "mdi:heart-pulse", - ), - WithingsSleepSummaryAttribute( - const.MEAS_SLEEP_RESPIRATORY_RATE_AVERAGE, - GetSleepSummaryField.RR_AVERAGE.value, - "Average respiratory rate", - const.UOM_BREATHS_PER_MINUTE, - None, - ), - WithingsSleepSummaryAttribute( - const.MEAS_SLEEP_RESPIRATORY_RATE_MIN, - GetSleepSummaryField.RR_MIN.value, - "Minimum respiratory rate", - const.UOM_BREATHS_PER_MINUTE, - None, - ), - WithingsSleepSummaryAttribute( - const.MEAS_SLEEP_RESPIRATORY_RATE_MAX, - GetSleepSummaryField.RR_MAX.value, - "Maximum respiratory rate", - const.UOM_BREATHS_PER_MINUTE, - None, - ), -] - -WITHINGS_MEASUREMENTS_MAP = {attr.measurement: attr for attr in WITHINGS_ATTRIBUTES} - - -class WithingsHealthSensor(Entity): +class WithingsHealthSensor(BaseWithingsSensor): """Implementation of a Withings sensor.""" - def __init__( - self, - data_manager: WithingsDataManager, - attribute: WithingsAttribute, - user_id: str, - ) -> None: - """Initialize the Withings sensor.""" - self._data_manager = data_manager - self._attribute = attribute - self._state = None - - self._slug = self._data_manager.slug - self._user_id = user_id - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"Withings {self._attribute.measurement} {self._slug}" - - @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return ( - f"withings_{self._slug}_{self._user_id}_" - f"{slugify(self._attribute.measurement)}" - ) - - @property - def state(self) -> Union[str, int, float, None]: - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return self._attribute.unit_of_measurement - - @property - def icon(self) -> str: - """Icon to use in the frontend, if any.""" - return self._attribute.icon - - @property - def device_state_attributes(self) -> None: - """Get withings attributes.""" - return self._attribute.__dict__ - - async def async_update(self) -> None: - """Update the data.""" - _LOGGER.debug( - "Async update slug: %s, measurement: %s, user_id: %s", - self._slug, - self._attribute.measurement, - self._user_id, - ) - - if isinstance(self._attribute, WithingsMeasureAttribute): - _LOGGER.debug("Updating measures state") - await self._data_manager.update_measures() - await self.async_update_measure(self._data_manager.measures) - - elif isinstance(self._attribute, WithingsSleepSummaryAttribute): - _LOGGER.debug("Updating sleep summary state") - await self._data_manager.update_sleep_summary() - await self.async_update_sleep_summary(self._data_manager.sleep_summary) - - async def async_update_measure(self, data: MeasureGetMeasResponse) -> None: - """Update the measures data.""" - measure_type = self._attribute.measure_type - - _LOGGER.debug( - "Finding the unambiguous measure group with measure_type: %s", measure_type - ) - - value = get_measure_value(data, measure_type, MeasureGroupAttribs.UNAMBIGUOUS) - - if value is None: - _LOGGER.debug("Could not find a value, setting state to %s", None) - self._state = None - return - - self._state = round(value, 2) - - async def async_update_sleep_summary(self, data: SleepGetSummaryResponse) -> None: - """Update the sleep summary data.""" - if not data.series: - _LOGGER.debug("Sleep data has no series, setting state to %s", None) - self._state = None - return - - measurement = self._attribute.measurement - measure_type = self._attribute.measure_type - - _LOGGER.debug("Determining total value for: %s", measurement) - total = 0 - for serie in data.series: - data = serie.data - value = 0 - if measure_type == GetSleepSummaryField.REM_SLEEP_DURATION.value: - value = data.remsleepduration - elif measure_type == GetSleepSummaryField.WAKEUP_DURATION.value: - value = data.wakeupduration - elif measure_type == GetSleepSummaryField.LIGHT_SLEEP_DURATION.value: - value = data.lightsleepduration - elif measure_type == GetSleepSummaryField.DEEP_SLEEP_DURATION.value: - value = data.deepsleepduration - elif measure_type == GetSleepSummaryField.WAKEUP_COUNT.value: - value = data.wakeupcount - elif measure_type == GetSleepSummaryField.DURATION_TO_SLEEP.value: - value = data.durationtosleep - elif measure_type == GetSleepSummaryField.DURATION_TO_WAKEUP.value: - value = data.durationtowakeup - elif measure_type == GetSleepSummaryField.HR_AVERAGE.value: - value = data.hr_average - elif measure_type == GetSleepSummaryField.HR_MIN.value: - value = data.hr_min - elif measure_type == GetSleepSummaryField.HR_MAX.value: - value = data.hr_max - elif measure_type == GetSleepSummaryField.RR_AVERAGE.value: - value = data.rr_average - elif measure_type == GetSleepSummaryField.RR_MIN.value: - value = data.rr_min - elif measure_type == GetSleepSummaryField.RR_MAX.value: - value = data.rr_max - - # Sometimes a None is provided for value, default to 0. - total += value or 0 - - self._state = round(total, 4) - - -def create_sensor_entities( - data_manager: WithingsDataManager, user_id: str -) -> List[WithingsHealthSensor]: - """Create sensor entities.""" - entities = [] - - for attribute in WITHINGS_ATTRIBUTES: - _LOGGER.debug( - "Creating entity for measurement: %s, measure_type: %s," - "friendly_name: %s, unit_of_measurement: %s", - attribute.measurement, - attribute.measure_type, - attribute.friendly_name, - attribute.unit_of_measurement, - ) - - entity = WithingsHealthSensor(data_manager, attribute, user_id) - - entities.append(entity) - - return entities + def state(self) -> Union[None, str, int, float]: + """Return the state of the entity.""" + return self._state_data diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index b4f5123d5af..7141d1eb0d5 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -1,12 +1,17 @@ { "config": { + "flow_title": "Withings: {profile}", "step": { "profile": { "title": "User Profile.", "description": "Which profile did you select on the Withings website? It's important the profiles match, otherwise data will be mis-labeled.", "data": { "profile": "Profile" } }, - "pick_implementation": { "title": "Pick Authentication Method" } + "pick_implementation": { "title": "Pick Authentication Method" }, + "reauth": { + "title": "Re-authenticate {profile}", + "description": "The \"{profile}\" profile needs to be re-authenticated in order to continue receiving Withings data." + } }, "abort": { "authorize_url_timeout": "Timeout generating authorize url.", diff --git a/requirements_all.txt b/requirements_all.txt index d962f95b6f7..2fcbfc8efcb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2205,7 +2205,7 @@ wiffi==1.0.0 wirelesstagpy==0.4.0 # homeassistant.components.withings -withings-api==2.1.3 +withings-api==2.1.6 # homeassistant.components.wled wled==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 527d964ebf2..9ca42c023d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -923,7 +923,7 @@ watchdog==0.8.3 wiffi==1.0.0 # homeassistant.components.withings -withings-api==2.1.3 +withings-api==2.1.6 # homeassistant.components.wled wled==0.4.3 diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py index ca3fef6159e..8c770fff5e6 100644 --- a/tests/components/withings/common.py +++ b/tests/components/withings/common.py @@ -1,24 +1,32 @@ """Common data for for the withings component tests.""" -import re -import time -from typing import List +from dataclasses import dataclass +from typing import List, Optional, Tuple, Union +from urllib.parse import urlparse -import requests_mock -from withings_api import AbstractWithingsApi +from aiohttp.test_utils import TestClient +import arrow +import pytz from withings_api.common import ( - MeasureGetMeasGroupAttrib, - MeasureGetMeasGroupCategory, - MeasureType, - SleepModel, - SleepState, + MeasureGetMeasResponse, + NotifyAppli, + NotifyListResponse, + SleepGetSummaryResponse, + UserGetDeviceResponse, ) from homeassistant import data_entry_flow import homeassistant.components.api as api -import homeassistant.components.http as http +from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN +import homeassistant.components.webhook as webhook +from homeassistant.components.withings import async_unload_entry +from homeassistant.components.withings.common import ( + ConfigEntryWithingsApi, + DataManager, + get_all_data_managers, +) import homeassistant.components.withings.const as const from homeassistant.config import async_process_ha_core_config -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, @@ -28,364 +36,295 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import AUTH_CALLBACK_PATH from homeassistant.setup import async_setup_component -from homeassistant.util import slugify + +from tests.async_mock import MagicMock +from tests.test_util.aiohttp import AiohttpClientMocker -def get_entity_id(measure, profile) -> str: - """Get an entity id for a measure and profile.""" - return "sensor.{}_{}_{}".format(const.DOMAIN, measure, slugify(profile)) +@dataclass +class ProfileConfig: + """Data representing a user profile.""" + + profile: str + user_id: int + api_response_user_get_device: Union[UserGetDeviceResponse, Exception] + api_response_measure_get_meas: Union[MeasureGetMeasResponse, Exception] + api_response_sleep_get_summary: Union[SleepGetSummaryResponse, Exception] + api_response_notify_list: Union[NotifyListResponse, Exception] + api_response_notify_revoke: Optional[Exception] -def assert_state_equals( - hass: HomeAssistant, profile: str, measure: str, expected -) -> None: - """Assert the state of a withings sensor.""" - entity_id = get_entity_id(measure, profile) - state_obj = hass.states.get(entity_id) - - assert state_obj, f"Expected entity {entity_id} to exist but it did not" - - assert state_obj.state == str(expected), ( - f"Expected {expected} but was {state_obj.state} " - f"for measure {measure}, {entity_id}" +def new_profile_config( + profile: str, + user_id: int, + api_response_user_get_device: Optional[ + Union[UserGetDeviceResponse, Exception] + ] = None, + api_response_measure_get_meas: Optional[ + Union[MeasureGetMeasResponse, Exception] + ] = None, + api_response_sleep_get_summary: Optional[ + Union[SleepGetSummaryResponse, Exception] + ] = None, + api_response_notify_list: Optional[Union[NotifyListResponse, Exception]] = None, + api_response_notify_revoke: Optional[Exception] = None, +) -> ProfileConfig: + """Create a new profile config immutable object.""" + return ProfileConfig( + profile=profile, + user_id=user_id, + api_response_user_get_device=api_response_user_get_device + or UserGetDeviceResponse(devices=[]), + api_response_measure_get_meas=api_response_measure_get_meas + or MeasureGetMeasResponse( + measuregrps=[], + more=False, + offset=0, + timezone=pytz.UTC, + updatetime=arrow.get(12345), + ), + api_response_sleep_get_summary=api_response_sleep_get_summary + or SleepGetSummaryResponse(more=False, offset=0, series=[]), + api_response_notify_list=api_response_notify_list + or NotifyListResponse(profiles=[]), + api_response_notify_revoke=api_response_notify_revoke, ) -async def setup_hass(hass: HomeAssistant) -> dict: - """Configure Home Assistant.""" - profiles = ["Person0", "Person1", "Person2", "Person3", "Person4"] +@dataclass +class WebhookResponse: + """Response data from a webhook.""" - hass_config = { - "homeassistant": { - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, - CONF_EXTERNAL_URL: "http://example.local/", - }, - api.DOMAIN: {}, - http.DOMAIN: {"server_port": 8080}, - const.DOMAIN: { - CONF_CLIENT_ID: "my_client_id", - CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_PROFILES: profiles, - }, - } - - await async_process_ha_core_config(hass, hass_config.get("homeassistant")) - assert await async_setup_component(hass, http.DOMAIN, hass_config) - assert await async_setup_component(hass, api.DOMAIN, hass_config) - assert await async_setup_component(hass, const.DOMAIN, hass_config) - await hass.async_block_till_done() - - return hass_config + message: str + message_code: int -async def configure_integration( - hass: HomeAssistant, - aiohttp_client, - aioclient_mock, - profiles: List[str], - profile_index: int, - get_device_response: dict, - getmeasures_response: dict, - get_sleep_response: dict, - get_sleep_summary_response: dict, -) -> None: - """Configure the integration for a specific profile.""" - selected_profile = profiles[profile_index] +class ComponentFactory: + """Manages the setup and unloading of the withing component and profiles.""" - with requests_mock.mock() as rqmck: - rqmck.get( - re.compile(f"{AbstractWithingsApi.URL}/v2/user?.*action=getdevice(&.*|$)"), - status_code=200, - json=get_device_response, + def __init__( + self, + hass: HomeAssistant, + api_class_mock: MagicMock, + aiohttp_client, + aioclient_mock: AiohttpClientMocker, + ) -> None: + """Initialize the object.""" + self._hass = hass + self._api_class_mock = api_class_mock + self._aiohttp_client = aiohttp_client + self._aioclient_mock = aioclient_mock + self._client_id = None + self._client_secret = None + self._profile_configs: Tuple[ProfileConfig, ...] = () + + async def configure_component( + self, + client_id: str = "my_client_id", + client_secret: str = "my_client_secret", + profile_configs: Tuple[ProfileConfig, ...] = (), + ) -> None: + """Configure the wihings component.""" + self._client_id = client_id + self._client_secret = client_secret + self._profile_configs = profile_configs + + hass_config = { + "homeassistant": { + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, + CONF_EXTERNAL_URL: "http://127.0.0.1:8080/", + }, + api.DOMAIN: {}, + const.DOMAIN: { + CONF_CLIENT_ID: self._client_id, + CONF_CLIENT_SECRET: self._client_secret, + const.CONF_USE_WEBHOOK: True, + const.CONF_PROFILES: [ + profile_config.profile for profile_config in self._profile_configs + ], + }, + } + + await async_process_ha_core_config(self._hass, hass_config.get("homeassistant")) + assert await async_setup_component(self._hass, HA_DOMAIN, {}) + assert await async_setup_component(self._hass, webhook.DOMAIN, hass_config) + + assert await async_setup_component(self._hass, const.DOMAIN, hass_config) + await self._hass.async_block_till_done() + + @staticmethod + def _setup_api_method(api_method, value) -> None: + if isinstance(value, Exception): + api_method.side_effect = value + else: + api_method.return_value = value + + async def setup_profile(self, user_id: int) -> ConfigEntryWithingsApi: + """Set up a user profile through config flows.""" + profile_config = next( + iter( + [ + profile_config + for profile_config in self._profile_configs + if profile_config.user_id == user_id + ] + ) ) - rqmck.get( - re.compile(f"{AbstractWithingsApi.URL}/v2/sleep?.*action=get(&.*|$)"), - status_code=200, - json=get_sleep_response, + api_mock: ConfigEntryWithingsApi = MagicMock(spec=ConfigEntryWithingsApi) + ComponentFactory._setup_api_method( + api_mock.user_get_device, profile_config.api_response_user_get_device + ) + ComponentFactory._setup_api_method( + api_mock.sleep_get_summary, profile_config.api_response_sleep_get_summary + ) + ComponentFactory._setup_api_method( + api_mock.measure_get_meas, profile_config.api_response_measure_get_meas + ) + ComponentFactory._setup_api_method( + api_mock.notify_list, profile_config.api_response_notify_list + ) + ComponentFactory._setup_api_method( + api_mock.notify_revoke, profile_config.api_response_notify_revoke ) - rqmck.get( - re.compile( - f"{AbstractWithingsApi.URL}/v2/sleep?.*action=getsummary(&.*|$)" - ), - status_code=200, - json=get_sleep_summary_response, - ) - - rqmck.get( - re.compile(f"{AbstractWithingsApi.URL}/measure?.*action=getmeas(&.*|$)"), - status_code=200, - json=getmeasures_response, - ) + self._api_class_mock.reset_mocks() + self._api_class_mock.return_value = api_mock # Get the withings config flow. - result = await hass.config_entries.flow.async_init( + result = await self._hass.config_entries.flow.async_init( const.DOMAIN, context={"source": SOURCE_USER} ) assert result # pylint: disable=protected-access state = config_entry_oauth2_flow._encode_jwt( - hass, {"flow_id": result["flow_id"]} + self._hass, {"flow_id": result["flow_id"]} ) assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP assert result["url"] == ( "https://account.withings.com/oauth2_user/authorize2?" - "response_type=code&client_id=my_client_id&" - "redirect_uri=http://example.local/auth/external/callback&" + f"response_type=code&client_id={self._client_id}&" + "redirect_uri=http://127.0.0.1:8080/auth/external/callback&" f"state={state}" - "&scope=user.info,user.metrics,user.activity" + "&scope=user.info,user.metrics,user.activity,user.sleepevents" ) # Simulate user being redirected from withings site. - client = await aiohttp_client(hass.http.app) - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + client: TestClient = await self._aiohttp_client(self._hass.http.app) + resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" - aioclient_mock.post( + self._aioclient_mock.clear_requests() + self._aioclient_mock.post( "https://account.withings.com/oauth2/token", json={ "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, - "userid": "myuserid", + "userid": profile_config.user_id, }, ) # Present user with a list of profiles to choose from. - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await self._hass.config_entries.flow.async_configure(result["flow_id"]) assert result.get("type") == "form" assert result.get("step_id") == "profile" - assert result.get("data_schema").schema["profile"].container == profiles + assert result.get("data_schema").schema["profile"].container == [ + profile.profile for profile in self._profile_configs + ] # Select the user profile. - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {const.PROFILE: selected_profile} + result = await self._hass.config_entries.flow.async_configure( + result["flow_id"], {const.PROFILE: profile_config.profile} ) # Finish the config flow by calling it again. assert result.get("type") == "create_entry" assert result.get("result") config_data = result.get("result").data - assert config_data.get(const.PROFILE) == profiles[profile_index] + assert config_data.get(const.PROFILE) == profile_config.profile assert config_data.get("auth_implementation") == const.DOMAIN assert config_data.get("token") - # Ensure all the flows are complete. - flows = hass.config_entries.flow.async_progress() - assert not flows + # Wait for remaining tasks to complete. + await self._hass.async_block_till_done() + + # Mock the webhook. + data_manager = get_data_manager_by_user_id(self._hass, user_id) + self._aioclient_mock.clear_requests() + self._aioclient_mock.request( + "HEAD", data_manager.webhook_config.url, + ) + + return self._api_class_mock.return_value + + async def call_webhook(self, user_id: int, appli: NotifyAppli) -> WebhookResponse: + """Call the webhook to notify of data changes.""" + client: TestClient = await self._aiohttp_client(self._hass.http.app) + data_manager = get_data_manager_by_user_id(self._hass, user_id) + + resp = await client.post( + urlparse(data_manager.webhook_config.url).path, + data={"userid": user_id, "appli": appli.value}, + ) # Wait for remaining tasks to complete. - await hass.async_block_till_done() + await self._hass.async_block_till_done() + + data = await resp.json() + resp.close() + + return WebhookResponse(message=data["message"], message_code=data["code"]) + + async def unload(self, profile: ProfileConfig) -> None: + """Unload the component for a specific user.""" + config_entries = get_config_entries_for_user_id(self._hass, profile.user_id) + + for config_entry in config_entries: + await async_unload_entry(self._hass, config_entry) + + await self._hass.async_block_till_done() + + assert not get_data_manager_by_user_id(self._hass, profile.user_id) -WITHINGS_GET_DEVICE_RESPONSE_EMPTY = {"status": 0, "body": {"devices": []}} - - -WITHINGS_GET_DEVICE_RESPONSE = { - "status": 0, - "body": { - "devices": [ - { - "type": "type1", - "model": "model1", - "battery": "battery1", - "deviceid": "deviceid1", - "timezone": "UTC", - } +def get_config_entries_for_user_id( + hass: HomeAssistant, user_id: int +) -> Tuple[ConfigEntry]: + """Get a list of config entries that apply to a specific withings user.""" + return tuple( + [ + config_entry + for config_entry in hass.config_entries.async_entries(const.DOMAIN) + if config_entry.data.get("token", {}).get("userid") == user_id ] - }, -} + ) -WITHINGS_MEASURES_RESPONSE_EMPTY = { - "status": 0, - "body": {"updatetime": "2019-08-01", "timezone": "UTC", "measuregrps": []}, -} +def async_get_flow_for_user_id(hass: HomeAssistant, user_id: int) -> List[dict]: + """Get a flow for a user id.""" + return [ + flow + for flow in hass.config_entries.flow.async_progress() + if flow["handler"] == const.DOMAIN and flow["context"].get("userid") == user_id + ] -WITHINGS_MEASURES_RESPONSE = { - "status": 0, - "body": { - "updatetime": "2019-08-01", - "timezone": "UTC", - "measuregrps": [ - # Un-ambiguous groups. - { - "grpid": 1, - "attrib": MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER.real, - "date": time.time(), - "created": time.time(), - "category": MeasureGetMeasGroupCategory.REAL.real, - "deviceid": "DEV_ID", - "more": False, - "offset": 0, - "measures": [ - {"type": MeasureType.WEIGHT, "value": 70, "unit": 0}, - {"type": MeasureType.FAT_MASS_WEIGHT, "value": 5, "unit": 0}, - {"type": MeasureType.FAT_FREE_MASS, "value": 60, "unit": 0}, - {"type": MeasureType.MUSCLE_MASS, "value": 50, "unit": 0}, - {"type": MeasureType.BONE_MASS, "value": 10, "unit": 0}, - {"type": MeasureType.HEIGHT, "value": 2, "unit": 0}, - {"type": MeasureType.TEMPERATURE, "value": 40, "unit": 0}, - {"type": MeasureType.BODY_TEMPERATURE, "value": 40, "unit": 0}, - {"type": MeasureType.SKIN_TEMPERATURE, "value": 20, "unit": 0}, - {"type": MeasureType.FAT_RATIO, "value": 70, "unit": -3}, - { - "type": MeasureType.DIASTOLIC_BLOOD_PRESSURE, - "value": 70, - "unit": 0, - }, - { - "type": MeasureType.SYSTOLIC_BLOOD_PRESSURE, - "value": 100, - "unit": 0, - }, - {"type": MeasureType.HEART_RATE, "value": 60, "unit": 0}, - {"type": MeasureType.SP02, "value": 95, "unit": -2}, - {"type": MeasureType.HYDRATION, "value": 95, "unit": -2}, - {"type": MeasureType.PULSE_WAVE_VELOCITY, "value": 100, "unit": 0}, - ], - }, - # Ambiguous groups (we ignore these) - { - "grpid": 1, - "attrib": MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER.real, - "date": time.time(), - "created": time.time(), - "category": MeasureGetMeasGroupCategory.REAL.real, - "deviceid": "DEV_ID", - "more": False, - "offset": 0, - "measures": [ - {"type": MeasureType.WEIGHT, "value": 71, "unit": 0}, - {"type": MeasureType.FAT_MASS_WEIGHT, "value": 4, "unit": 0}, - {"type": MeasureType.FAT_FREE_MASS, "value": 40, "unit": 0}, - {"type": MeasureType.MUSCLE_MASS, "value": 51, "unit": 0}, - {"type": MeasureType.BONE_MASS, "value": 11, "unit": 0}, - {"type": MeasureType.HEIGHT, "value": 201, "unit": 0}, - {"type": MeasureType.TEMPERATURE, "value": 41, "unit": 0}, - {"type": MeasureType.BODY_TEMPERATURE, "value": 34, "unit": 0}, - {"type": MeasureType.SKIN_TEMPERATURE, "value": 21, "unit": 0}, - {"type": MeasureType.FAT_RATIO, "value": 71, "unit": -3}, - { - "type": MeasureType.DIASTOLIC_BLOOD_PRESSURE, - "value": 71, - "unit": 0, - }, - { - "type": MeasureType.SYSTOLIC_BLOOD_PRESSURE, - "value": 101, - "unit": 0, - }, - {"type": MeasureType.HEART_RATE, "value": 61, "unit": 0}, - {"type": MeasureType.SP02, "value": 98, "unit": -2}, - {"type": MeasureType.HYDRATION, "value": 96, "unit": -2}, - {"type": MeasureType.PULSE_WAVE_VELOCITY, "value": 102, "unit": 0}, - ], - }, - ], - }, -} - - -WITHINGS_SLEEP_RESPONSE_EMPTY = { - "status": 0, - "body": {"model": SleepModel.TRACKER.real, "series": []}, -} - - -WITHINGS_SLEEP_RESPONSE = { - "status": 0, - "body": { - "model": SleepModel.TRACKER.real, - "series": [ - { - "startdate": "2019-02-01 00:00:00", - "enddate": "2019-02-01 01:00:00", - "state": SleepState.AWAKE.real, - }, - { - "startdate": "2019-02-01 01:00:00", - "enddate": "2019-02-01 02:00:00", - "state": SleepState.LIGHT.real, - }, - { - "startdate": "2019-02-01 02:00:00", - "enddate": "2019-02-01 03:00:00", - "state": SleepState.REM.real, - }, - { - "startdate": "2019-02-01 03:00:00", - "enddate": "2019-02-01 04:00:00", - "state": SleepState.DEEP.real, - }, - ], - }, -} - - -WITHINGS_SLEEP_SUMMARY_RESPONSE_EMPTY = { - "status": 0, - "body": {"more": False, "offset": 0, "series": []}, -} - - -WITHINGS_SLEEP_SUMMARY_RESPONSE = { - "status": 0, - "body": { - "more": False, - "offset": 0, - "series": [ - { - "timezone": "UTC", - "model": SleepModel.SLEEP_MONITOR.real, - "startdate": "2019-02-01", - "enddate": "2019-02-02", - "date": "2019-02-02", - "modified": 12345, - "data": { - "wakeupduration": 110, - "lightsleepduration": 210, - "deepsleepduration": 310, - "remsleepduration": 410, - "wakeupcount": 510, - "durationtosleep": 610, - "durationtowakeup": 710, - "hr_average": 810, - "hr_min": 910, - "hr_max": 1010, - "rr_average": 1110, - "rr_min": 1210, - "rr_max": 1310, - }, - }, - { - "timezone": "UTC", - "model": SleepModel.SLEEP_MONITOR.real, - "startdate": "2019-02-01", - "enddate": "2019-02-02", - "date": "2019-02-02", - "modified": 12345, - "data": { - "wakeupduration": 210, - "lightsleepduration": 310, - "deepsleepduration": 410, - "remsleepduration": 510, - "wakeupcount": 610, - "durationtosleep": 710, - "durationtowakeup": 810, - "hr_average": 910, - "hr_min": 1010, - "hr_max": 1110, - "rr_average": 1210, - "rr_min": 1310, - "rr_max": 1410, - }, - }, - ], - }, -} +def get_data_manager_by_user_id( + hass: HomeAssistant, user_id: int +) -> Optional[DataManager]: + """Get a data manager by the user id.""" + return next( + iter( + [ + data_manager + for data_manager in get_all_data_managers(hass) + if data_manager.user_id == user_id + ] + ), + None, + ) diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py new file mode 100644 index 00000000000..c95abc8addd --- /dev/null +++ b/tests/components/withings/conftest.py @@ -0,0 +1,22 @@ +"""Fixtures for tests.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.core import HomeAssistant + +from .common import ComponentFactory + +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture() +def component_factory( + hass: HomeAssistant, aiohttp_client, aioclient_mock: AiohttpClientMocker +): + """Return a factory for initializing the withings component.""" + with patch( + "homeassistant.components.withings.common.ConfigEntryWithingsApi" + ) as api_class_mock: + yield ComponentFactory(hass, api_class_mock, aiohttp_client, aioclient_mock) diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py new file mode 100644 index 00000000000..b646c667472 --- /dev/null +++ b/tests/components/withings/test_binary_sensor.py @@ -0,0 +1,62 @@ +"""Tests for the Withings component.""" +from withings_api.common import NotifyAppli + +from homeassistant.components.withings.common import ( + WITHINGS_MEASUREMENTS_MAP, + async_get_entity_id, +) +from homeassistant.components.withings.const import Measurement +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from .common import ComponentFactory, new_profile_config + + +async def test_binary_sensor( + hass: HomeAssistant, component_factory: ComponentFactory +) -> None: + """Test binary sensor.""" + in_bed_attribute = WITHINGS_MEASUREMENTS_MAP[Measurement.IN_BED] + person0 = new_profile_config("person0", 0) + person1 = new_profile_config("person1", 1) + + entity_registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() + + await component_factory.configure_component(profile_configs=(person0, person1)) + assert not await async_get_entity_id(hass, in_bed_attribute, person0.user_id) + assert not await async_get_entity_id(hass, in_bed_attribute, person1.user_id) + + # person 0 + await component_factory.setup_profile(person0.user_id) + await component_factory.setup_profile(person1.user_id) + + entity_id0 = await async_get_entity_id(hass, in_bed_attribute, person0.user_id) + entity_id1 = await async_get_entity_id(hass, in_bed_attribute, person1.user_id) + assert entity_id0 + assert entity_id1 + + assert entity_registry.async_is_registered(entity_id0) + assert hass.states.get(entity_id0).state == STATE_OFF + + resp = await component_factory.call_webhook(person0.user_id, NotifyAppli.BED_IN) + assert resp.message_code == 0 + await hass.async_block_till_done() + assert hass.states.get(entity_id0).state == STATE_ON + + resp = await component_factory.call_webhook(person0.user_id, NotifyAppli.BED_OUT) + assert resp.message_code == 0 + await hass.async_block_till_done() + assert hass.states.get(entity_id0).state == STATE_OFF + + # person 1 + assert hass.states.get(entity_id1).state == STATE_OFF + + resp = await component_factory.call_webhook(person1.user_id, NotifyAppli.BED_IN) + assert resp.message_code == 0 + await hass.async_block_till_done() + assert hass.states.get(entity_id1).state == STATE_ON + + # Unload + await component_factory.unload(person0) + await component_factory.unload(person1) diff --git a/tests/components/withings/test_common.py b/tests/components/withings/test_common.py index f0528c36005..22f9e5ba0b6 100644 --- a/tests/components/withings/test_common.py +++ b/tests/components/withings/test_common.py @@ -1,135 +1,236 @@ """Tests for the Withings component.""" -from datetime import timedelta +import datetime +import re +from typing import Any +from urllib.parse import urlparse +from aiohttp.test_utils import TestClient +from asynctest import MagicMock import pytest -from withings_api import WithingsApi -from withings_api.common import TimeoutException, UnauthorizedException +import requests_mock +from withings_api.common import NotifyAppli, NotifyListProfile, NotifyListResponse from homeassistant.components.withings.common import ( - NotAuthenticatedError, - WithingsDataManager, + ConfigEntryWithingsApi, + DataManager, + WebhookConfig, ) -from homeassistant.exceptions import PlatformNotReady -from homeassistant.util import dt +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2Implementation -from tests.async_mock import MagicMock, patch +from tests.common import MockConfigEntry +from tests.components.withings.common import ( + ComponentFactory, + get_data_manager_by_user_id, + new_profile_config, +) +from tests.test_util.aiohttp import AiohttpClientMocker -@pytest.fixture(name="withings_api") -def withings_api_fixture() -> WithingsApi: - """Provide withings api.""" - withings_api = WithingsApi.__new__(WithingsApi) - withings_api.user_get_device = MagicMock() - withings_api.measure_get_meas = MagicMock() - withings_api.sleep_get = MagicMock() - withings_api.sleep_get_summary = MagicMock() - return withings_api +async def test_config_entry_withings_api(hass: HomeAssistant) -> None: + """Test ConfigEntryWithingsApi.""" + config_entry = MockConfigEntry( + data={"token": {"access_token": "mock_access_token", "expires_at": 1111111}} + ) + config_entry.add_to_hass(hass) + + implementation_mock = MagicMock(spec=AbstractOAuth2Implementation) + implementation_mock.async_refresh_token.return_value = { + "expires_at": 1111111, + "access_token": "mock_access_token", + } + + with requests_mock.mock() as rqmck: + rqmck.get( + re.compile(".*"), + status_code=200, + json={"status": 0, "body": {"message": "success"}}, + ) + + api = ConfigEntryWithingsApi(hass, config_entry, implementation_mock) + response = await hass.async_add_executor_job( + api.request, "test", {"arg1": "val1", "arg2": "val2"} + ) + assert response == {"message": "success"} -@pytest.fixture(name="data_manager") -def data_manager_fixture(hass, withings_api: WithingsApi) -> WithingsDataManager: - """Provide data manager.""" - return WithingsDataManager(hass, "My Profile", withings_api) - - -def test_print_service() -> None: - """Test method.""" - # Go from None to True - WithingsDataManager.service_available = None - assert WithingsDataManager.print_service_available() - assert WithingsDataManager.service_available is True - assert not WithingsDataManager.print_service_available() - assert not WithingsDataManager.print_service_available() - - # Go from True to False - assert WithingsDataManager.print_service_unavailable() - assert WithingsDataManager.service_available is False - assert not WithingsDataManager.print_service_unavailable() - assert not WithingsDataManager.print_service_unavailable() - - # Go from False to True - assert WithingsDataManager.print_service_available() - assert WithingsDataManager.service_available is True - assert not WithingsDataManager.print_service_available() - assert not WithingsDataManager.print_service_available() - - # Go from Non to False - WithingsDataManager.service_available = None - assert WithingsDataManager.print_service_unavailable() - assert WithingsDataManager.service_available is False - assert not WithingsDataManager.print_service_unavailable() - assert not WithingsDataManager.print_service_unavailable() - - -async def test_data_manager_call(data_manager: WithingsDataManager) -> None: - """Test method.""" - # Not authenticated 1. - test_function = MagicMock(side_effect=UnauthorizedException(401)) - with pytest.raises(NotAuthenticatedError): - await data_manager.call(test_function) - - # Not authenticated 2. - test_function = MagicMock(side_effect=TimeoutException(522)) - with pytest.raises(PlatformNotReady): - await data_manager.call(test_function) - - # Service error. - test_function = MagicMock(side_effect=PlatformNotReady()) - with pytest.raises(PlatformNotReady): - await data_manager.call(test_function) - - -async def test_data_manager_call_throttle_enabled( - data_manager: WithingsDataManager, +@pytest.mark.parametrize( + ["user_id", "arg_user_id", "arg_appli", "expected_code"], + [ + [0, 0, NotifyAppli.WEIGHT.value, 0], # Success + [0, None, 1, 0], # Success, we ignore the user_id. + [0, None, None, 12], # No request body. + [0, "GG", None, 20], # appli not provided. + [0, 0, None, 20], # appli not provided. + [0, 0, 99, 21], # Invalid appli. + [0, 11, NotifyAppli.WEIGHT.value, 0], # Success, we ignore the user_id + ], +) +async def test_webhook_post( + hass: HomeAssistant, + component_factory: ComponentFactory, + aiohttp_client, + user_id: int, + arg_user_id: Any, + arg_appli: Any, + expected_code: int, ) -> None: - """Test method.""" - hello_func = MagicMock(return_value="HELLO2") + """Test webhook callback.""" + person0 = new_profile_config("person0", user_id) - result = await data_manager.call(hello_func, throttle_domain="test") - assert result == "HELLO2" + await component_factory.configure_component(profile_configs=(person0,)) + await component_factory.setup_profile(person0.user_id) + data_manager = get_data_manager_by_user_id(hass, user_id) - result = await data_manager.call(hello_func, throttle_domain="test") - assert result == "HELLO2" + client: TestClient = await aiohttp_client(hass.http.app) - assert hello_func.call_count == 1 + post_data = {} + if arg_user_id is not None: + post_data["userid"] = arg_user_id + if arg_appli is not None: + post_data["appli"] = arg_appli - -async def test_data_manager_call_throttle_disabled( - data_manager: WithingsDataManager, -) -> None: - """Test method.""" - hello_func = MagicMock(return_value="HELLO2") - - result = await data_manager.call(hello_func) - assert result == "HELLO2" - - result = await data_manager.call(hello_func) - assert result == "HELLO2" - - assert hello_func.call_count == 2 - - -async def test_data_manager_update_sleep_date_range( - data_manager: WithingsDataManager, -) -> None: - """Test method.""" - patch_time_zone = patch( - "homeassistant.util.dt.DEFAULT_TIME_ZONE", - new=dt.get_time_zone("America/Belize"), + resp = await client.post( + urlparse(data_manager.webhook_config.url).path, data=post_data ) - with patch_time_zone: - update_start_time = dt.now() - await data_manager.update_sleep() + # Wait for remaining tasks to complete. + await hass.async_block_till_done() - call_args = data_manager.api.sleep_get.call_args_list[0][1] - startdate = call_args.get("startdate") - enddate = call_args.get("enddate") + data = await resp.json() + resp.close() - assert startdate.tzname() == "CST" + assert data["code"] == expected_code - assert enddate.tzname() == "CST" - assert startdate.tzname() == "CST" - assert update_start_time < enddate - assert enddate < update_start_time + timedelta(seconds=1) - assert enddate > startdate + +async def test_webhook_head( + hass: HomeAssistant, component_factory: ComponentFactory, aiohttp_client, +) -> None: + """Test head method on webhook view.""" + person0 = new_profile_config("person0", 0) + + await component_factory.configure_component(profile_configs=(person0,)) + await component_factory.setup_profile(person0.user_id) + data_manager = get_data_manager_by_user_id(hass, person0.user_id) + + client: TestClient = await aiohttp_client(hass.http.app) + resp = await client.head(urlparse(data_manager.webhook_config.url).path) + assert resp.status == 200 + + +async def test_webhook_put( + hass: HomeAssistant, component_factory: ComponentFactory, aiohttp_client, +) -> None: + """Test webhook callback.""" + person0 = new_profile_config("person0", 0) + + await component_factory.configure_component(profile_configs=(person0,)) + await component_factory.setup_profile(person0.user_id) + data_manager = get_data_manager_by_user_id(hass, person0.user_id) + + client: TestClient = await aiohttp_client(hass.http.app) + resp = await client.put(urlparse(data_manager.webhook_config.url).path) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + + assert resp.status == 200 + data = await resp.json() + assert data + assert data["code"] == 2 + + +async def test_data_manager_webhook_subscription( + hass: HomeAssistant, + component_factory: ComponentFactory, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test data manager webhook subscriptions.""" + person0 = new_profile_config("person0", 0) + await component_factory.configure_component(profile_configs=(person0,)) + + api: ConfigEntryWithingsApi = MagicMock(spec=ConfigEntryWithingsApi) + data_manager = DataManager( + hass, + "person0", + api, + 0, + WebhookConfig(id="1234", url="http://localhost/api/webhook/1234", enabled=True), + ) + + # pylint: disable=protected-access + data_manager._notify_subscribe_delay = datetime.timedelta(seconds=0) + data_manager._notify_unsubscribe_delay = datetime.timedelta(seconds=0) + + api.notify_list.return_value = NotifyListResponse( + profiles=( + NotifyListProfile( + appli=NotifyAppli.BED_IN, + callbackurl="https://not.my.callback/url", + expires=None, + comment=None, + ), + NotifyListProfile( + appli=NotifyAppli.BED_IN, + callbackurl=data_manager.webhook_config.url, + expires=None, + comment=None, + ), + NotifyListProfile( + appli=NotifyAppli.BED_OUT, + callbackurl=data_manager.webhook_config.url, + expires=None, + comment=None, + ), + ) + ) + + aioclient_mock.clear_requests() + aioclient_mock.request( + "HEAD", data_manager.webhook_config.url, status=200, + ) + + # Test subscribing + await data_manager.async_subscribe_webhook() + api.notify_subscribe.assert_any_call( + data_manager.webhook_config.url, NotifyAppli.WEIGHT + ) + api.notify_subscribe.assert_any_call( + data_manager.webhook_config.url, NotifyAppli.CIRCULATORY + ) + api.notify_subscribe.assert_any_call( + data_manager.webhook_config.url, NotifyAppli.ACTIVITY + ) + api.notify_subscribe.assert_any_call( + data_manager.webhook_config.url, NotifyAppli.SLEEP + ) + try: + api.notify_subscribe.assert_any_call( + data_manager.webhook_config.url, NotifyAppli.USER + ) + assert False + except AssertionError: + pass + try: + api.notify_subscribe.assert_any_call( + data_manager.webhook_config.url, NotifyAppli.BED_IN + ) + assert False + except AssertionError: + pass + try: + api.notify_subscribe.assert_any_call( + data_manager.webhook_config.url, NotifyAppli.BED_OUT + ) + assert False + except AssertionError: + pass + + # Test unsubscribing. + await data_manager.async_unsubscribe_webhook() + api.notify_revoke.assert_any_call( + data_manager.webhook_config.url, NotifyAppli.BED_IN + ) + api.notify_revoke.assert_any_call( + data_manager.webhook_config.url, NotifyAppli.BED_OUT + ) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index b65e175913d..29f8ca5e3b8 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -1,43 +1,28 @@ """Tests for the Withings component.""" -import re -import time - -import requests_mock +from asynctest import MagicMock, patch +import pytest import voluptuous as vol -from withings_api import AbstractWithingsApi -from withings_api.common import SleepModel, SleepState +from withings_api.common import UnauthorizedException -import homeassistant.components.http as http -from homeassistant.components.withings import ( - CONFIG_SCHEMA, - async_setup, - async_setup_entry, - const, -) -from homeassistant.config import async_process_ha_core_config -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, STATE_UNKNOWN +from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const +from homeassistant.components.withings.common import DataManager +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .common import ( - WITHINGS_GET_DEVICE_RESPONSE, - WITHINGS_GET_DEVICE_RESPONSE_EMPTY, - WITHINGS_MEASURES_RESPONSE, - WITHINGS_MEASURES_RESPONSE_EMPTY, - WITHINGS_SLEEP_RESPONSE, - WITHINGS_SLEEP_RESPONSE_EMPTY, - WITHINGS_SLEEP_SUMMARY_RESPONSE, - WITHINGS_SLEEP_SUMMARY_RESPONSE_EMPTY, - assert_state_equals, - configure_integration, - setup_hass, + ComponentFactory, + async_get_flow_for_user_id, + get_data_manager_by_user_id, + new_profile_config, ) -from tests.async_mock import MagicMock +from tests.common import MockConfigEntry -def config_schema_validate(withings_config) -> None: +def config_schema_validate(withings_config) -> dict: """Assert a schema config succeeds.""" - hass_config = {http.DOMAIN: {}, const.DOMAIN: withings_config} + hass_config = {const.DOMAIN: withings_config} return CONFIG_SCHEMA(hass_config) @@ -57,6 +42,7 @@ def test_config_schema_basic_config() -> None: { CONF_CLIENT_ID: "my_client_id", CONF_CLIENT_SECRET: "my_client_secret", + const.CONF_USE_WEBHOOK: True, const.CONF_PROFILES: ["Person 1", "Person 2"], } ) @@ -107,6 +93,43 @@ def test_config_schema_client_secret() -> None: ) +def test_config_schema_use_webhook() -> None: + """Test schema.""" + config_schema_validate( + { + CONF_CLIENT_ID: "my_client_id", + CONF_CLIENT_SECRET: "my_client_secret", + const.CONF_PROFILES: ["Person 1"], + } + ) + config = config_schema_validate( + { + CONF_CLIENT_ID: "my_client_id", + CONF_CLIENT_SECRET: "my_client_secret", + const.CONF_USE_WEBHOOK: True, + const.CONF_PROFILES: ["Person 1"], + } + ) + assert config[const.DOMAIN][const.CONF_USE_WEBHOOK] is True + config = config_schema_validate( + { + CONF_CLIENT_ID: "my_client_id", + CONF_CLIENT_SECRET: "my_client_secret", + const.CONF_USE_WEBHOOK: False, + const.CONF_PROFILES: ["Person 1"], + } + ) + assert config[const.DOMAIN][const.CONF_USE_WEBHOOK] is False + config_schema_assert_fail( + { + CONF_CLIENT_ID: "my_client_id", + CONF_CLIENT_SECRET: "my_client_secret", + const.CONF_USE_WEBHOOK: "A", + const.CONF_PROFILES: ["Person 1"], + } + ) + + def test_config_schema_profiles() -> None: """Test schema.""" config_schema_assert_fail( @@ -158,285 +181,74 @@ async def test_async_setup_no_config(hass: HomeAssistant) -> None: hass.async_create_task.assert_not_called() -async def test_upgrade_token( - hass: HomeAssistant, aiohttp_client, aioclient_mock -) -> None: - """Test upgrading from old config data format to new one.""" - config = await setup_hass(hass) - profiles = config[const.DOMAIN][const.CONF_PROFILES] - - await async_process_ha_core_config( - hass, {"internal_url": "http://example.local"}, - ) - - await configure_integration( - hass=hass, - aiohttp_client=aiohttp_client, - aioclient_mock=aioclient_mock, - profiles=profiles, - profile_index=0, - get_device_response=WITHINGS_GET_DEVICE_RESPONSE_EMPTY, - getmeasures_response=WITHINGS_MEASURES_RESPONSE_EMPTY, - get_sleep_response=WITHINGS_SLEEP_RESPONSE_EMPTY, - get_sleep_summary_response=WITHINGS_SLEEP_SUMMARY_RESPONSE_EMPTY, - ) - - entries = hass.config_entries.async_entries(const.DOMAIN) - assert entries - - entry = entries[0] - data = entry.data - token = data.get("token") - hass.config_entries.async_update_entry( - entry, - data={ - const.PROFILE: data.get(const.PROFILE), - const.CREDENTIALS: { - "access_token": token.get("access_token"), - "refresh_token": token.get("refresh_token"), - "token_expiry": token.get("expires_at"), - "token_type": token.get("type"), - "userid": token.get("userid"), - CONF_CLIENT_ID: token.get("my_client_id"), - "consumer_secret": token.get("my_consumer_secret"), - }, - }, - ) - - with requests_mock.mock() as rqmck: - rqmck.get( - re.compile(f"{AbstractWithingsApi.URL}/v2/user?.*action=getdevice(&.*|$)"), - status_code=200, - json=WITHINGS_GET_DEVICE_RESPONSE_EMPTY, - ) - - assert await async_setup_entry(hass, entry) - - entries = hass.config_entries.async_entries(const.DOMAIN) - assert entries - - data = entries[0].data - - assert data.get("auth_implementation") == const.DOMAIN - assert data.get("implementation") == const.DOMAIN - assert data.get(const.PROFILE) == profiles[0] - - token = data.get("token") - assert token - assert token.get("access_token") == "mock-access-token" - assert token.get("refresh_token") == "mock-refresh-token" - assert token.get("expires_at") > time.time() - assert token.get("type") == "Bearer" - assert token.get("userid") == "myuserid" - assert not token.get(CONF_CLIENT_ID) - assert not token.get("consumer_secret") - - +@pytest.mark.parametrize( + ["exception"], + [ + [UnauthorizedException("401")], + [UnauthorizedException("401")], + [Exception("401, this is the message")], + ], +) async def test_auth_failure( - hass: HomeAssistant, aiohttp_client, aioclient_mock + hass: HomeAssistant, component_factory: ComponentFactory, exception: Exception ) -> None: """Test auth failure.""" - config = await setup_hass(hass) - profiles = config[const.DOMAIN][const.CONF_PROFILES] - - await async_process_ha_core_config( - hass, {"internal_url": "http://example.local"}, + person0 = new_profile_config( + "person0", + 0, + api_response_user_get_device=exception, + api_response_measure_get_meas=exception, + api_response_sleep_get_summary=exception, ) - await configure_integration( - hass=hass, - aiohttp_client=aiohttp_client, - aioclient_mock=aioclient_mock, - profiles=profiles, - profile_index=0, - get_device_response=WITHINGS_GET_DEVICE_RESPONSE_EMPTY, - getmeasures_response=WITHINGS_MEASURES_RESPONSE_EMPTY, - get_sleep_response=WITHINGS_SLEEP_RESPONSE_EMPTY, - get_sleep_summary_response=WITHINGS_SLEEP_SUMMARY_RESPONSE_EMPTY, + await component_factory.configure_component(profile_configs=(person0,)) + assert not async_get_flow_for_user_id(hass, person0.user_id) + + await component_factory.setup_profile(person0.user_id) + data_manager = get_data_manager_by_user_id(hass, person0.user_id) + await data_manager.poll_data_update_coordinator.async_refresh() + + flows = async_get_flow_for_user_id(hass, person0.user_id) + assert flows + assert len(flows) == 1 + + flow = flows[0] + assert flow["handler"] == const.DOMAIN + assert flow["context"]["profile"] == person0.profile + assert flow["context"]["userid"] == person0.user_id + + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], user_input={} + ) + assert result + assert result["type"] == "external" + assert result["handler"] == const.DOMAIN + assert result["step_id"] == "auth" + + await component_factory.unload(person0) + + +async def test_set_config_unique_id( + hass: HomeAssistant, component_factory: ComponentFactory +) -> None: + """Test upgrading configs to use a unique id.""" + person0 = new_profile_config("person0", 0) + + await component_factory.configure_component(profile_configs=(person0,)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={"token": {"userid": "my_user_id"}, "profile": person0.profile}, ) - entries = hass.config_entries.async_entries(const.DOMAIN) - assert entries - - entry = entries[0] - hass.config_entries.async_update_entry( - entry, data={**entry.data, **{"new_item": 1}} - ) - - with requests_mock.mock() as rqmck: - rqmck.get( - re.compile(f"{AbstractWithingsApi.URL}/v2/user?.*action=getdevice(&.*|$)"), - status_code=200, - json={"status": 401, "body": {}}, + with patch("homeassistant.components.withings.async_get_data_manager") as mock: + data_manager: DataManager = MagicMock(spec=DataManager) + data_manager.poll_data_update_coordinator = MagicMock( + spec=DataUpdateCoordinator ) + data_manager.poll_data_update_coordinator.last_update_success = True + mock.return_value = data_manager + config_entry.add_to_hass(hass) - assert not (await async_setup_entry(hass, entry)) - - -async def test_full_setup(hass: HomeAssistant, aiohttp_client, aioclient_mock) -> None: - """Test the whole component lifecycle.""" - config = await setup_hass(hass) - profiles = config[const.DOMAIN][const.CONF_PROFILES] - - await async_process_ha_core_config( - hass, {"internal_url": "http://example.local"}, - ) - - await configure_integration( - hass=hass, - aiohttp_client=aiohttp_client, - aioclient_mock=aioclient_mock, - profiles=profiles, - profile_index=0, - get_device_response=WITHINGS_GET_DEVICE_RESPONSE, - getmeasures_response=WITHINGS_MEASURES_RESPONSE, - get_sleep_response=WITHINGS_SLEEP_RESPONSE, - get_sleep_summary_response=WITHINGS_SLEEP_SUMMARY_RESPONSE, - ) - - await configure_integration( - hass=hass, - aiohttp_client=aiohttp_client, - aioclient_mock=aioclient_mock, - profiles=profiles, - profile_index=1, - get_device_response=WITHINGS_GET_DEVICE_RESPONSE_EMPTY, - getmeasures_response=WITHINGS_MEASURES_RESPONSE_EMPTY, - get_sleep_response=WITHINGS_SLEEP_RESPONSE_EMPTY, - get_sleep_summary_response=WITHINGS_SLEEP_SUMMARY_RESPONSE_EMPTY, - ) - - await configure_integration( - hass=hass, - aiohttp_client=aiohttp_client, - aioclient_mock=aioclient_mock, - profiles=profiles, - profile_index=2, - get_device_response=WITHINGS_GET_DEVICE_RESPONSE_EMPTY, - getmeasures_response=WITHINGS_MEASURES_RESPONSE_EMPTY, - get_sleep_response={ - "status": 0, - "body": { - "model": SleepModel.TRACKER.real, - "series": [ - { - "startdate": "2019-02-01 00:00:00", - "enddate": "2019-02-01 01:00:00", - "state": SleepState.REM.real, - }, - { - "startdate": "2019-02-01 01:00:00", - "enddate": "2019-02-01 02:00:00", - "state": SleepState.AWAKE.real, - }, - ], - }, - }, - get_sleep_summary_response=WITHINGS_SLEEP_SUMMARY_RESPONSE_EMPTY, - ) - - await configure_integration( - hass=hass, - aiohttp_client=aiohttp_client, - aioclient_mock=aioclient_mock, - profiles=profiles, - profile_index=3, - get_device_response=WITHINGS_GET_DEVICE_RESPONSE_EMPTY, - getmeasures_response=WITHINGS_MEASURES_RESPONSE_EMPTY, - get_sleep_response={ - "status": 0, - "body": { - "model": SleepModel.TRACKER.real, - "series": [ - { - "startdate": "2019-02-01 01:00:00", - "enddate": "2019-02-01 02:00:00", - "state": SleepState.LIGHT.real, - }, - { - "startdate": "2019-02-01 00:00:00", - "enddate": "2019-02-01 01:00:00", - "state": SleepState.REM.real, - }, - ], - }, - }, - get_sleep_summary_response=WITHINGS_SLEEP_SUMMARY_RESPONSE_EMPTY, - ) - - await configure_integration( - hass=hass, - aiohttp_client=aiohttp_client, - aioclient_mock=aioclient_mock, - profiles=profiles, - profile_index=4, - get_device_response=WITHINGS_GET_DEVICE_RESPONSE_EMPTY, - getmeasures_response=WITHINGS_MEASURES_RESPONSE_EMPTY, - get_sleep_response={ - "status": 0, - "body": { - "model": SleepModel.TRACKER.real, - "series": [ - { - "startdate": "2019-02-01 00:00:00", - "enddate": "2019-02-01 01:00:00", - "state": SleepState.LIGHT.real, - }, - { - "startdate": "2019-02-01 02:00:00", - "enddate": "2019-02-01 03:00:00", - "state": SleepState.REM.real, - }, - { - "startdate": "2019-02-01 01:00:00", - "enddate": "2019-02-01 02:00:00", - "state": SleepState.AWAKE.real, - }, - ], - }, - }, - get_sleep_summary_response=WITHINGS_SLEEP_SUMMARY_RESPONSE_EMPTY, - ) - - # Test the states of the entities. - expected_states = ( - (profiles[0], const.MEAS_WEIGHT_KG, 70.0), - (profiles[0], const.MEAS_FAT_MASS_KG, 5.0), - (profiles[0], const.MEAS_FAT_FREE_MASS_KG, 60.0), - (profiles[0], const.MEAS_MUSCLE_MASS_KG, 50.0), - (profiles[0], const.MEAS_BONE_MASS_KG, 10.0), - (profiles[0], const.MEAS_HEIGHT_M, 2.0), - (profiles[0], const.MEAS_FAT_RATIO_PCT, 0.07), - (profiles[0], const.MEAS_DIASTOLIC_MMHG, 70.0), - (profiles[0], const.MEAS_SYSTOLIC_MMGH, 100.0), - (profiles[0], const.MEAS_HEART_PULSE_BPM, 60.0), - (profiles[0], const.MEAS_SPO2_PCT, 0.95), - (profiles[0], const.MEAS_HYDRATION, 0.95), - (profiles[0], const.MEAS_PWV, 100.0), - (profiles[0], const.MEAS_SLEEP_WAKEUP_DURATION_SECONDS, 320), - (profiles[0], const.MEAS_SLEEP_LIGHT_DURATION_SECONDS, 520), - (profiles[0], const.MEAS_SLEEP_DEEP_DURATION_SECONDS, 720), - (profiles[0], const.MEAS_SLEEP_REM_DURATION_SECONDS, 920), - (profiles[0], const.MEAS_SLEEP_WAKEUP_COUNT, 1120), - (profiles[0], const.MEAS_SLEEP_TOSLEEP_DURATION_SECONDS, 1320), - (profiles[0], const.MEAS_SLEEP_TOWAKEUP_DURATION_SECONDS, 1520), - (profiles[0], const.MEAS_SLEEP_HEART_RATE_AVERAGE, 1720), - (profiles[0], const.MEAS_SLEEP_HEART_RATE_MIN, 1920), - (profiles[0], const.MEAS_SLEEP_HEART_RATE_MAX, 2120), - (profiles[0], const.MEAS_SLEEP_RESPIRATORY_RATE_AVERAGE, 2320), - (profiles[0], const.MEAS_SLEEP_RESPIRATORY_RATE_MIN, 2520), - (profiles[0], const.MEAS_SLEEP_RESPIRATORY_RATE_MAX, 2720), - (profiles[1], const.MEAS_HYDRATION, STATE_UNKNOWN), - (profiles[3], const.MEAS_FAT_FREE_MASS_KG, STATE_UNKNOWN), - ) - for (profile, meas, value) in expected_states: - assert_state_equals(hass, profile, meas, value) - - # Tear down setup entries. - entries = hass.config_entries.async_entries(const.DOMAIN) - assert entries - - for entry in entries: - await hass.config_entries.async_unload(entry.entry_id) - - await hass.async_block_till_done() + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.unique_id == "my_user_id" diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py new file mode 100644 index 00000000000..abfc4758251 --- /dev/null +++ b/tests/components/withings/test_sensor.py @@ -0,0 +1,335 @@ +"""Tests for the Withings component.""" +import time +from typing import Any +from unittest.mock import patch + +import arrow +import pytz +from withings_api.common import ( + GetSleepSummaryData, + GetSleepSummarySerie, + MeasureGetMeasGroup, + MeasureGetMeasGroupAttrib, + MeasureGetMeasGroupCategory, + MeasureGetMeasMeasure, + MeasureGetMeasResponse, + MeasureType, + NotifyAppli, + SleepGetSummaryResponse, + SleepModel, +) + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.withings.common import ( + WITHINGS_MEASUREMENTS_MAP, + WithingsAttribute, + async_get_entity_id, + get_platform_attributes, +) +from homeassistant.components.withings.const import Measurement +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_registry import EntityRegistry + +from .common import ComponentFactory, new_profile_config + +PERSON0 = new_profile_config( + "person0", + 0, + api_response_measure_get_meas=MeasureGetMeasResponse( + measuregrps=( + MeasureGetMeasGroup( + attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, + category=MeasureGetMeasGroupCategory.REAL, + created=time.time(), + date=time.time(), + deviceid="DEV_ID", + grpid=1, + measures=( + MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=70), + MeasureGetMeasMeasure( + type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=5 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_FREE_MASS, unit=0, value=60 + ), + MeasureGetMeasMeasure( + type=MeasureType.MUSCLE_MASS, unit=0, value=50 + ), + MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=10), + MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=2), + MeasureGetMeasMeasure( + type=MeasureType.TEMPERATURE, unit=0, value=40 + ), + MeasureGetMeasMeasure( + type=MeasureType.BODY_TEMPERATURE, unit=0, value=40 + ), + MeasureGetMeasMeasure( + type=MeasureType.SKIN_TEMPERATURE, unit=0, value=20 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_RATIO, unit=-3, value=70 + ), + MeasureGetMeasMeasure( + type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=70 + ), + MeasureGetMeasMeasure( + type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=100 + ), + MeasureGetMeasMeasure( + type=MeasureType.HEART_RATE, unit=0, value=60 + ), + MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=95), + MeasureGetMeasMeasure( + type=MeasureType.HYDRATION, unit=-2, value=95 + ), + MeasureGetMeasMeasure( + type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=100 + ), + ), + ), + MeasureGetMeasGroup( + attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER_AMBIGUOUS, + category=MeasureGetMeasGroupCategory.REAL, + created=time.time(), + date=time.time(), + deviceid="DEV_ID", + grpid=1, + measures=( + MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), + MeasureGetMeasMeasure( + type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=4 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_FREE_MASS, unit=0, value=40 + ), + MeasureGetMeasMeasure( + type=MeasureType.MUSCLE_MASS, unit=0, value=51 + ), + MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), + MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=201), + MeasureGetMeasMeasure( + type=MeasureType.TEMPERATURE, unit=0, value=41 + ), + MeasureGetMeasMeasure( + type=MeasureType.BODY_TEMPERATURE, unit=0, value=34 + ), + MeasureGetMeasMeasure( + type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_RATIO, unit=-3, value=71 + ), + MeasureGetMeasMeasure( + type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 + ), + MeasureGetMeasMeasure( + type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 + ), + MeasureGetMeasMeasure( + type=MeasureType.HEART_RATE, unit=0, value=61 + ), + MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=98), + MeasureGetMeasMeasure( + type=MeasureType.HYDRATION, unit=-2, value=96 + ), + MeasureGetMeasMeasure( + type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=102 + ), + ), + ), + ), + more=False, + timezone=pytz.UTC, + updatetime=arrow.get("2019-08-01"), + offset=0, + ), + api_response_sleep_get_summary=SleepGetSummaryResponse( + more=False, + offset=0, + series=( + GetSleepSummarySerie( + timezone=pytz.UTC, + model=SleepModel.SLEEP_MONITOR, + startdate=arrow.get("2019-02-01"), + enddate=arrow.get("2019-02-01"), + date=arrow.get("2019-02-01"), + modified=arrow.get(12345), + data=GetSleepSummaryData( + breathing_disturbances_intensity=110, + deepsleepduration=111, + durationtosleep=112, + durationtowakeup=113, + hr_average=114, + hr_max=115, + hr_min=116, + lightsleepduration=117, + remsleepduration=118, + rr_average=119, + rr_max=120, + rr_min=121, + sleep_score=122, + snoring=123, + snoringepisodecount=124, + wakeupcount=125, + wakeupduration=126, + ), + ), + GetSleepSummarySerie( + timezone=pytz.UTC, + model=SleepModel.SLEEP_MONITOR, + startdate=arrow.get("2019-02-01"), + enddate=arrow.get("2019-02-01"), + date=arrow.get("2019-02-01"), + modified=arrow.get(12345), + data=GetSleepSummaryData( + breathing_disturbances_intensity=210, + deepsleepduration=211, + durationtosleep=212, + durationtowakeup=213, + hr_average=214, + hr_max=215, + hr_min=216, + lightsleepduration=217, + remsleepduration=218, + rr_average=219, + rr_max=220, + rr_min=221, + sleep_score=222, + snoring=223, + snoringepisodecount=224, + wakeupcount=225, + wakeupduration=226, + ), + ), + ), + ), +) + +EXPECTED_DATA = ( + (PERSON0, Measurement.WEIGHT_KG, 70.0), + (PERSON0, Measurement.FAT_MASS_KG, 5.0), + (PERSON0, Measurement.FAT_FREE_MASS_KG, 60.0), + (PERSON0, Measurement.MUSCLE_MASS_KG, 50.0), + (PERSON0, Measurement.BONE_MASS_KG, 10.0), + (PERSON0, Measurement.HEIGHT_M, 2.0), + (PERSON0, Measurement.FAT_RATIO_PCT, 0.07), + (PERSON0, Measurement.DIASTOLIC_MMHG, 70.0), + (PERSON0, Measurement.SYSTOLIC_MMGH, 100.0), + (PERSON0, Measurement.HEART_PULSE_BPM, 60.0), + (PERSON0, Measurement.SPO2_PCT, 0.95), + (PERSON0, Measurement.HYDRATION, 0.95), + (PERSON0, Measurement.PWV, 100.0), + (PERSON0, Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, 160.0), + (PERSON0, Measurement.SLEEP_DEEP_DURATION_SECONDS, 322), + (PERSON0, Measurement.SLEEP_HEART_RATE_AVERAGE, 164.0), + (PERSON0, Measurement.SLEEP_HEART_RATE_MAX, 165.0), + (PERSON0, Measurement.SLEEP_HEART_RATE_MIN, 166.0), + (PERSON0, Measurement.SLEEP_LIGHT_DURATION_SECONDS, 334), + (PERSON0, Measurement.SLEEP_REM_DURATION_SECONDS, 336), + (PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, 169.0), + (PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_MAX, 170.0), + (PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_MIN, 171.0), + (PERSON0, Measurement.SLEEP_SCORE, 222), + (PERSON0, Measurement.SLEEP_SNORING, 173.0), + (PERSON0, Measurement.SLEEP_SNORING_EPISODE_COUNT, 348), + (PERSON0, Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, 162.0), + (PERSON0, Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, 163.0), + (PERSON0, Measurement.SLEEP_WAKEUP_COUNT, 350), + (PERSON0, Measurement.SLEEP_WAKEUP_DURATION_SECONDS, 176.0), +) + + +def async_assert_state_equals( + entity_id: str, state_obj: State, expected: Any, attribute: WithingsAttribute +) -> None: + """Assert at given state matches what is expected.""" + assert state_obj, f"Expected entity {entity_id} to exist but it did not" + + assert state_obj.state == str(expected), ( + f"Expected {expected} but was {state_obj.state} " + f"for measure {attribute.measurement}, {entity_id}" + ) + + +async def test_sensor_default_enabled_entities( + hass: HomeAssistant, component_factory: ComponentFactory +) -> None: + """Test entities enabled by default.""" + entity_registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() + + await component_factory.configure_component(profile_configs=(PERSON0,)) + + # Assert entities should not exist yet. + for attribute in get_platform_attributes(SENSOR_DOMAIN): + assert not await async_get_entity_id(hass, attribute, PERSON0.user_id) + + # person 0 + await component_factory.setup_profile(PERSON0.user_id) + + # Assert entities should exist. + for attribute in get_platform_attributes(SENSOR_DOMAIN): + entity_id = await async_get_entity_id(hass, attribute, PERSON0.user_id) + assert entity_id + assert entity_registry.async_is_registered(entity_id) + + resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.SLEEP) + assert resp.message_code == 0 + + resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.WEIGHT) + assert resp.message_code == 0 + + for person, measurement, expected in EXPECTED_DATA: + attribute = WITHINGS_MEASUREMENTS_MAP[measurement] + entity_id = await async_get_entity_id(hass, attribute, person.user_id) + state_obj = hass.states.get(entity_id) + + if attribute.enabled_by_default: + async_assert_state_equals(entity_id, state_obj, expected, attribute) + else: + assert state_obj is None + + # Unload + await component_factory.unload(PERSON0) + + +async def test_all_entities( + hass: HomeAssistant, component_factory: ComponentFactory +) -> None: + """Test all entities.""" + entity_registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() + + with patch( + "homeassistant.components.withings.sensor.BaseWithingsSensor.entity_registry_enabled_default" + ) as enabled_by_default_mock: + enabled_by_default_mock.return_value = True + + await component_factory.configure_component(profile_configs=(PERSON0,)) + + # Assert entities should not exist yet. + for attribute in get_platform_attributes(SENSOR_DOMAIN): + assert not await async_get_entity_id(hass, attribute, PERSON0.user_id) + + # person 0 + await component_factory.setup_profile(PERSON0.user_id) + + # Assert entities should exist. + for attribute in get_platform_attributes(SENSOR_DOMAIN): + entity_id = await async_get_entity_id(hass, attribute, PERSON0.user_id) + assert entity_id + assert entity_registry.async_is_registered(entity_id) + + resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.SLEEP) + assert resp.message_code == 0 + + resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.WEIGHT) + assert resp.message_code == 0 + + for person, measurement, expected in EXPECTED_DATA: + attribute = WITHINGS_MEASUREMENTS_MAP[measurement] + entity_id = await async_get_entity_id(hass, attribute, person.user_id) + state_obj = hass.states.get(entity_id) + + async_assert_state_equals(entity_id, state_obj, expected, attribute) + + # Unload + await component_factory.unload(PERSON0)