mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
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:
parent
b858658516
commit
6fc1407613
@ -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
|
||||
|
167
homeassistant/components/withings/api.py
Normal file
167
homeassistant/components/withings/api.py
Normal 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
|
||||
)
|
||||
)
|
@ -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]] = {
|
||||
|
@ -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")
|
||||
)
|
||||
|
||||
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user