Extract Withings API specifics in own class (#100363)

* Extract Withings API specifics in own class

* Extract Withings API specifics in own class

* Ignore api test coverage

* fix feedback
This commit is contained in:
Joost Lekkerkerker 2023-09-14 13:31:54 +02:00 committed by GitHub
parent b858658516
commit 6fc1407613
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 214 additions and 125 deletions

View File

@ -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

View File

@ -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
)
)

View File

@ -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]] = {

View File

@ -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")
)

View File

@ -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(