diff --git a/.coveragerc b/.coveragerc index 4835ec5a05b..3c7ade54b0e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1481,6 +1481,7 @@ omit = homeassistant/components/wiffi/sensor.py homeassistant/components/wiffi/wiffi_strings.py homeassistant/components/wirelesstag/* + homeassistant/components/withings/api.py homeassistant/components/wolflink/__init__.py homeassistant/components/wolflink/sensor.py homeassistant/components/worldtidesinfo/sensor.py diff --git a/homeassistant/components/withings/api.py b/homeassistant/components/withings/api.py new file mode 100644 index 00000000000..fff9767ebda --- /dev/null +++ b/homeassistant/components/withings/api.py @@ -0,0 +1,167 @@ +"""Api for Withings.""" +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +import logging +from typing import Any + +import arrow +import requests +from withings_api import AbstractWithingsApi, DateType +from withings_api.common import ( + GetSleepSummaryField, + MeasureGetMeasGroupCategory, + MeasureGetMeasResponse, + MeasureType, + NotifyAppli, + NotifyListResponse, + SleepGetSummaryResponse, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + AbstractOAuth2Implementation, + OAuth2Session, +) + +from .const import LOG_NAMESPACE + +_LOGGER = logging.getLogger(LOG_NAMESPACE) +_RETRY_COEFFICIENT = 0.5 + + +class ConfigEntryWithingsApi(AbstractWithingsApi): + """Withing API that uses HA resources.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + implementation: AbstractOAuth2Implementation, + ) -> None: + """Initialize object.""" + self._hass = hass + self.config_entry = config_entry + self._implementation = implementation + self.session = OAuth2Session(hass, config_entry, implementation) + + def _request( + self, path: str, params: dict[str, Any], method: str = "GET" + ) -> dict[str, Any]: + """Perform an async request.""" + asyncio.run_coroutine_threadsafe( + self.session.async_ensure_token_valid(), self._hass.loop + ).result() + + access_token = self.config_entry.data["token"]["access_token"] + response = requests.request( + method, + f"{self.URL}/{path}", + params=params, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=10, + ) + return response.json() + + async def _do_retry(self, func, attempts=3) -> Any: + """Retry a function call. + + Withings' API occasionally and incorrectly throws errors. + Retrying the call tends to work. + """ + exception = None + for attempt in range(1, attempts + 1): + _LOGGER.debug("Attempt %s of %s", attempt, attempts) + try: + return await func() + except Exception as exception1: # pylint: disable=broad-except + _LOGGER.debug( + "Failed attempt %s of %s (%s)", attempt, attempts, exception1 + ) + # Make each backoff pause a little bit longer + await asyncio.sleep(_RETRY_COEFFICIENT * attempt) + exception = exception1 + continue + + if exception: + raise exception + + async def async_measure_get_meas( + self, + meastype: MeasureType | None = None, + category: MeasureGetMeasGroupCategory | None = None, + startdate: DateType | None = arrow.utcnow(), + enddate: DateType | None = arrow.utcnow(), + offset: int | None = None, + lastupdate: DateType | None = arrow.utcnow(), + ) -> MeasureGetMeasResponse: + """Get measurements.""" + + return await self._do_retry( + await self._hass.async_add_executor_job( + self.measure_get_meas, + meastype, + category, + startdate, + enddate, + offset, + lastupdate, + ) + ) + + async def async_sleep_get_summary( + self, + data_fields: Iterable[GetSleepSummaryField], + startdateymd: DateType | None = arrow.utcnow(), + enddateymd: DateType | None = arrow.utcnow(), + offset: int | None = None, + lastupdate: DateType | None = arrow.utcnow(), + ) -> SleepGetSummaryResponse: + """Get sleep data.""" + + return await self._do_retry( + await self._hass.async_add_executor_job( + self.sleep_get_summary, + data_fields, + startdateymd, + enddateymd, + offset, + lastupdate, + ) + ) + + async def async_notify_list( + self, appli: NotifyAppli | None = None + ) -> NotifyListResponse: + """List webhooks.""" + + return await self._do_retry( + await self._hass.async_add_executor_job(self.notify_list, appli) + ) + + async def async_notify_subscribe( + self, + callbackurl: str, + appli: NotifyAppli | None = None, + comment: str | None = None, + ) -> None: + """Subscribe to webhook.""" + + return await self._do_retry( + await self._hass.async_add_executor_job( + self.notify_subscribe, callbackurl, appli, comment + ) + ) + + async def async_notify_revoke( + self, callbackurl: str | None = None, appli: NotifyAppli | None = None + ) -> None: + """Revoke webhook.""" + + return await self._do_retry( + await self._hass.async_add_executor_job( + self.notify_revoke, callbackurl, appli + ) + ) diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 98c98f1fa96..446fb4b58e5 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -13,8 +13,6 @@ import re from typing import Any from aiohttp.web import Response -import requests -from withings_api import AbstractWithingsApi from withings_api.common import ( AuthFailedException, GetSleepSummaryField, @@ -22,7 +20,6 @@ from withings_api.common import ( MeasureType, MeasureTypes, NotifyAppli, - SleepGetSummaryResponse, UnauthorizedException, query_measure_groups, ) @@ -33,18 +30,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.config_entry_oauth2_flow import ( - AbstractOAuth2Implementation, - OAuth2Session, -) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from . import const +from .api import ConfigEntryWithingsApi from .const import Measurement _LOGGER = logging.getLogger(const.LOG_NAMESPACE) -_RETRY_COEFFICIENT = 0.5 NOT_AUTHENTICATED_ERROR = re.compile( f"^{HTTPStatus.UNAUTHORIZED},.*", re.IGNORECASE, @@ -114,40 +107,6 @@ WITHINGS_MEASURE_TYPE_MAP: dict[ } -class ConfigEntryWithingsApi(AbstractWithingsApi): - """Withing API that uses HA resources.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - implementation: AbstractOAuth2Implementation, - ) -> None: - """Initialize object.""" - self._hass = hass - self.config_entry = config_entry - self._implementation = implementation - self.session = OAuth2Session(hass, config_entry, implementation) - - def _request( - self, path: str, params: dict[str, Any], method: str = "GET" - ) -> dict[str, Any]: - """Perform an async request.""" - asyncio.run_coroutine_threadsafe( - self.session.async_ensure_token_valid(), self._hass.loop - ).result() - - access_token = self.config_entry.data["token"]["access_token"] - response = requests.request( - method, - f"{self.URL}/{path}", - params=params, - headers={"Authorization": f"Bearer {access_token}"}, - timeout=10, - ) - return response.json() - - def json_message_response(message: str, message_code: int) -> Response: """Produce common json output.""" return HomeAssistantView.json({"message": message, "code": message_code}) @@ -271,34 +230,8 @@ class DataManager: 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 - _LOGGER.debug( - "Failed attempt %s of %s (%s)", attempt, attempts, exception1 - ) - # Make each backoff pause a little bit longer - await asyncio.sleep(_RETRY_COEFFICIENT * attempt) - exception = exception1 - continue - - if exception: - raise exception - async def async_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 @@ -311,7 +244,7 @@ class DataManager: self._subscribe_webhook_run_count += 1 # Get the current webhooks. - response = await self._hass.async_add_executor_job(self._api.notify_list) + response = await self._api.async_notify_list() subscribed_applis = frozenset( profile.appli @@ -338,17 +271,12 @@ class DataManager: # 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 - ) + await self._api.async_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) + response = await self._api.async_notify_list() # Revoke subscriptions. for profile in response.profiles: @@ -361,14 +289,15 @@ class DataManager: # 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 - ) + await self._api.async_notify_revoke(profile.callbackurl, profile.appli) async def async_get_all_data(self) -> dict[MeasureType, Any] | None: """Update all withings data.""" try: - return await self._do_retry(self._async_get_all_data) + return { + **await self.async_get_measures(), + **await self.async_get_sleep_summary(), + } except Exception as exception: # User is not authenticated. if isinstance( @@ -379,21 +308,14 @@ class DataManager: raise exception - async def _async_get_all_data(self) -> dict[Measurement, Any] | None: - _LOGGER.info("Updating all withings data") - return { - **await self.async_get_measures(), - **await self.async_get_sleep_summary(), - } - async def async_get_measures(self) -> dict[Measurement, Any]: """Get the measures data.""" _LOGGER.debug("Updating withings measures") now = dt_util.utcnow() startdate = now - datetime.timedelta(days=7) - response = await self._hass.async_add_executor_job( - self._api.measure_get_meas, None, None, startdate, now, None, startdate + response = await self._api.async_measure_get_meas( + None, None, startdate, now, None, startdate ) # Sort from oldest to newest. @@ -424,31 +346,28 @@ class DataManager: ) yesterday_noon_utc = dt_util.as_utc(yesterday_noon) - def get_sleep_summary() -> SleepGetSummaryResponse: - return self._api.sleep_get_summary( - lastupdate=yesterday_noon_utc, - data_fields=[ - GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, - GetSleepSummaryField.DEEP_SLEEP_DURATION, - GetSleepSummaryField.DURATION_TO_SLEEP, - GetSleepSummaryField.DURATION_TO_WAKEUP, - GetSleepSummaryField.HR_AVERAGE, - GetSleepSummaryField.HR_MAX, - GetSleepSummaryField.HR_MIN, - GetSleepSummaryField.LIGHT_SLEEP_DURATION, - GetSleepSummaryField.REM_SLEEP_DURATION, - GetSleepSummaryField.RR_AVERAGE, - GetSleepSummaryField.RR_MAX, - GetSleepSummaryField.RR_MIN, - GetSleepSummaryField.SLEEP_SCORE, - GetSleepSummaryField.SNORING, - GetSleepSummaryField.SNORING_EPISODE_COUNT, - GetSleepSummaryField.WAKEUP_COUNT, - GetSleepSummaryField.WAKEUP_DURATION, - ], - ) - - response = await self._hass.async_add_executor_job(get_sleep_summary) + response = await self._api.async_sleep_get_summary( + lastupdate=yesterday_noon_utc, + data_fields=[ + GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, + GetSleepSummaryField.DEEP_SLEEP_DURATION, + GetSleepSummaryField.DURATION_TO_SLEEP, + GetSleepSummaryField.DURATION_TO_WAKEUP, + GetSleepSummaryField.HR_AVERAGE, + GetSleepSummaryField.HR_MAX, + GetSleepSummaryField.HR_MIN, + GetSleepSummaryField.LIGHT_SLEEP_DURATION, + GetSleepSummaryField.REM_SLEEP_DURATION, + GetSleepSummaryField.RR_AVERAGE, + GetSleepSummaryField.RR_MAX, + GetSleepSummaryField.RR_MIN, + GetSleepSummaryField.SLEEP_SCORE, + GetSleepSummaryField.SNORING, + GetSleepSummaryField.SNORING_EPISODE_COUNT, + GetSleepSummaryField.WAKEUP_COUNT, + GetSleepSummaryField.WAKEUP_DURATION, + ], + ) # Set the default to empty lists. raw_values: dict[GetSleepSummaryField, list[int]] = { diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index a5e51c68c40..f1df0e3a65a 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -15,7 +15,7 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.withings.common import ConfigEntryWithingsApi +from homeassistant.components.withings.api import ConfigEntryWithingsApi from homeassistant.components.withings.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -112,13 +112,13 @@ def mock_withings(): mock.user_get_device.return_value = UserGetDeviceResponse( **load_json_object_fixture("withings/get_device.json") ) - mock.measure_get_meas.return_value = MeasureGetMeasResponse( + mock.async_measure_get_meas.return_value = MeasureGetMeasResponse( **load_json_object_fixture("withings/get_meas.json") ) - mock.sleep_get_summary.return_value = SleepGetSummaryResponse( + mock.async_sleep_get_summary.return_value = SleepGetSummaryResponse( **load_json_object_fixture("withings/get_sleep.json") ) - mock.notify_list.return_value = NotifyListResponse( + mock.async_notify_list.return_value = NotifyListResponse( **load_json_object_fixture("withings/notify_list.json") ) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 4e7eb812f0a..15f0fff808d 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -117,17 +117,19 @@ async def test_data_manager_webhook_subscription( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() - assert withings.notify_subscribe.call_count == 4 + assert withings.async_notify_subscribe.call_count == 4 webhook_url = "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" - withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT) - withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.CIRCULATORY) - withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.ACTIVITY) - withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.SLEEP) + withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT) + withings.async_notify_subscribe.assert_any_call( + webhook_url, NotifyAppli.CIRCULATORY + ) + withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.ACTIVITY) + withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.SLEEP) - withings.notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_IN) - withings.notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT) + withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_IN) + withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT) @pytest.mark.parametrize(