2019-08-31 12:30:59 +00:00
|
|
|
"""Common code for Withings."""
|
|
|
|
import datetime
|
2019-10-24 16:41:04 +00:00
|
|
|
from functools import partial
|
2019-08-31 12:30:59 +00:00
|
|
|
import logging
|
|
|
|
import re
|
|
|
|
import time
|
2019-10-24 16:41:04 +00:00
|
|
|
from typing import Any, Dict
|
|
|
|
|
|
|
|
from asyncio import run_coroutine_threadsafe
|
|
|
|
import requests
|
|
|
|
from withings_api import (
|
|
|
|
AbstractWithingsApi,
|
|
|
|
SleepGetResponse,
|
|
|
|
MeasureGetMeasResponse,
|
|
|
|
SleepGetSummaryResponse,
|
|
|
|
)
|
|
|
|
from withings_api.common import UnauthorizedException, AuthFailedException
|
2019-08-31 12:30:59 +00:00
|
|
|
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
2019-10-24 16:41:04 +00:00
|
|
|
from homeassistant.core import HomeAssistant
|
2019-08-31 12:30:59 +00:00
|
|
|
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
|
2019-10-24 16:41:04 +00:00
|
|
|
from homeassistant.helpers.config_entry_oauth2_flow import (
|
|
|
|
AbstractOAuth2Implementation,
|
|
|
|
OAuth2Session,
|
|
|
|
)
|
2019-08-31 12:30:59 +00:00
|
|
|
from homeassistant.util import dt, slugify
|
|
|
|
|
|
|
|
from . import const
|
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(const.LOG_NAMESPACE)
|
|
|
|
NOT_AUTHENTICATED_ERROR = re.compile(
|
2019-10-24 16:41:04 +00:00
|
|
|
# ".*(Error Code (100|101|102|200|401)|Missing access token parameter).*",
|
|
|
|
"^401,.*",
|
2019-08-31 12:30:59 +00:00
|
|
|
re.IGNORECASE,
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
class NotAuthenticatedError(HomeAssistantError):
|
|
|
|
"""Raise when not authenticated with the service."""
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
class ServiceError(HomeAssistantError):
|
|
|
|
"""Raise when the service has an error."""
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
class ThrottleData:
|
|
|
|
"""Throttle data."""
|
|
|
|
|
2019-10-24 16:41:04 +00:00
|
|
|
def __init__(self, interval: int, data: Any):
|
2019-08-31 12:30:59 +00:00
|
|
|
"""Constructor."""
|
|
|
|
self._time = int(time.time())
|
|
|
|
self._interval = interval
|
|
|
|
self._data = data
|
|
|
|
|
|
|
|
@property
|
2019-10-24 16:41:04 +00:00
|
|
|
def time(self) -> int:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""Get time created."""
|
|
|
|
return self._time
|
|
|
|
|
|
|
|
@property
|
2019-10-24 16:41:04 +00:00
|
|
|
def interval(self) -> int:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""Get interval."""
|
|
|
|
return self._interval
|
|
|
|
|
|
|
|
@property
|
2019-10-24 16:41:04 +00:00
|
|
|
def data(self) -> Any:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""Get data."""
|
|
|
|
return self._data
|
|
|
|
|
2019-10-24 16:41:04 +00:00
|
|
|
def is_expired(self) -> bool:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""Is this data expired."""
|
|
|
|
return int(time.time()) - self.time > self.interval
|
|
|
|
|
|
|
|
|
2019-10-24 16:41:04 +00:00
|
|
|
class ConfigEntryWithingsApi(AbstractWithingsApi):
|
|
|
|
"""Withing API that uses HA resources."""
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
hass: HomeAssistant,
|
|
|
|
config_entry: ConfigEntry,
|
|
|
|
implementation: AbstractOAuth2Implementation,
|
|
|
|
):
|
|
|
|
"""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]:
|
|
|
|
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,
|
|
|
|
"%s/%s" % (self.URL, path),
|
|
|
|
params=params,
|
|
|
|
headers={
|
|
|
|
"Authorization": "Bearer %s"
|
|
|
|
% self._config_entry.data["token"]["access_token"]
|
|
|
|
},
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
2019-08-31 12:30:59 +00:00
|
|
|
class WithingsDataManager:
|
|
|
|
"""A class representing an Withings cloud service connection."""
|
|
|
|
|
|
|
|
service_available = None
|
|
|
|
|
2019-10-24 16:41:04 +00:00
|
|
|
def __init__(self, hass: HomeAssistant, profile: str, api: ConfigEntryWithingsApi):
|
2019-08-31 12:30:59 +00:00
|
|
|
"""Constructor."""
|
|
|
|
self._hass = hass
|
|
|
|
self._api = api
|
|
|
|
self._profile = profile
|
|
|
|
self._slug = slugify(profile)
|
|
|
|
|
|
|
|
self._measures = None
|
|
|
|
self._sleep = None
|
|
|
|
self._sleep_summary = None
|
|
|
|
|
|
|
|
self.sleep_summary_last_update_parameter = None
|
|
|
|
self.throttle_data = {}
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
|
|
|
@property
|
2019-10-24 16:41:04 +00:00
|
|
|
def api(self) -> ConfigEntryWithingsApi:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""Get the api object."""
|
|
|
|
return self._api
|
|
|
|
|
|
|
|
@property
|
2019-10-24 16:41:04 +00:00
|
|
|
def measures(self) -> MeasureGetMeasResponse:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""Get the current measures data."""
|
|
|
|
return self._measures
|
|
|
|
|
|
|
|
@property
|
2019-10-24 16:41:04 +00:00
|
|
|
def sleep(self) -> SleepGetResponse:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""Get the current sleep data."""
|
|
|
|
return self._sleep
|
|
|
|
|
|
|
|
@property
|
2019-10-24 16:41:04 +00:00
|
|
|
def sleep_summary(self) -> SleepGetSummaryResponse:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""Get the current sleep summary data."""
|
|
|
|
return self._sleep_summary
|
|
|
|
|
|
|
|
@staticmethod
|
2019-10-24 16:41:04 +00:00
|
|
|
def get_throttle_interval() -> int:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""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
|
2019-10-24 16:41:04 +00:00
|
|
|
def print_service_unavailable() -> bool:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""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
|
|
|
|
|
2019-10-24 16:41:04 +00:00
|
|
|
return False
|
|
|
|
|
2019-08-31 12:30:59 +00:00
|
|
|
@staticmethod
|
2019-10-24 16:41:04 +00:00
|
|
|
def print_service_available() -> bool:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""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
|
|
|
|
|
2019-10-24 16:41:04 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
async def call(self, function, throttle_domain=None) -> Any:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""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()
|
|
|
|
)
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
# Update throttle data.
|
|
|
|
self.set_throttle_data(
|
|
|
|
throttle_domain, ThrottleData(self.get_throttle_interval(), result)
|
|
|
|
)
|
|
|
|
|
|
|
|
WithingsDataManager.print_service_available()
|
|
|
|
return result
|
|
|
|
|
|
|
|
except Exception as ex: # pylint: disable=broad-except
|
2019-10-24 16:41:04 +00:00
|
|
|
# Withings api encountered error.
|
|
|
|
if isinstance(ex, (UnauthorizedException, AuthFailedException)):
|
|
|
|
raise NotAuthenticatedError(ex)
|
|
|
|
|
|
|
|
# Oauth2 config flow failed to authenticate.
|
2019-08-31 12:30:59 +00:00
|
|
|
if NOT_AUTHENTICATED_ERROR.match(str(ex)):
|
|
|
|
raise NotAuthenticatedError(ex)
|
|
|
|
|
|
|
|
# Probably a network error.
|
|
|
|
WithingsDataManager.print_service_unavailable()
|
|
|
|
raise PlatformNotReady(ex)
|
|
|
|
|
2019-10-24 16:41:04 +00:00
|
|
|
async def check_authenticated(self) -> bool:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""Check if the user is authenticated."""
|
|
|
|
|
|
|
|
def function():
|
2019-10-24 16:41:04 +00:00
|
|
|
return bool(self._api.user_get_device())
|
2019-08-31 12:30:59 +00:00
|
|
|
|
|
|
|
return await self.call(function)
|
|
|
|
|
2019-10-24 16:41:04 +00:00
|
|
|
async def update_measures(self) -> MeasureGetMeasResponse:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""Update the measures data."""
|
|
|
|
|
|
|
|
def function():
|
2019-10-24 16:41:04 +00:00
|
|
|
return self._api.measure_get_meas()
|
2019-08-31 12:30:59 +00:00
|
|
|
|
|
|
|
self._measures = await self.call(function, throttle_domain="update_measures")
|
|
|
|
|
|
|
|
return self._measures
|
|
|
|
|
2019-10-24 16:41:04 +00:00
|
|
|
async def update_sleep(self) -> SleepGetResponse:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""Update the sleep data."""
|
|
|
|
end_date = int(time.time())
|
|
|
|
start_date = end_date - (6 * 60 * 60)
|
|
|
|
|
|
|
|
def function():
|
2019-10-24 16:41:04 +00:00
|
|
|
return self._api.sleep_get(startdate=start_date, enddate=end_date)
|
2019-08-31 12:30:59 +00:00
|
|
|
|
|
|
|
self._sleep = await self.call(function, throttle_domain="update_sleep")
|
|
|
|
|
|
|
|
return self._sleep
|
|
|
|
|
2019-10-24 16:41:04 +00:00
|
|
|
async def update_sleep_summary(self) -> SleepGetSummaryResponse:
|
2019-08-31 12:30:59 +00:00
|
|
|
"""Update the sleep summary data."""
|
|
|
|
now = dt.utcnow()
|
|
|
|
yesterday = now - datetime.timedelta(days=1)
|
|
|
|
yesterday_noon = datetime.datetime(
|
|
|
|
yesterday.year,
|
|
|
|
yesterday.month,
|
|
|
|
yesterday.day,
|
|
|
|
12,
|
|
|
|
0,
|
|
|
|
0,
|
|
|
|
0,
|
|
|
|
datetime.timezone.utc,
|
|
|
|
)
|
|
|
|
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Getting sleep summary data since: %s",
|
|
|
|
yesterday.strftime("%Y-%m-%d %H:%M:%S UTC"),
|
|
|
|
)
|
|
|
|
|
|
|
|
def function():
|
2019-10-24 16:41:04 +00:00
|
|
|
return self._api.sleep_get_summary(lastupdate=yesterday_noon)
|
2019-08-31 12:30:59 +00:00
|
|
|
|
|
|
|
self._sleep_summary = await self.call(
|
|
|
|
function, throttle_domain="update_sleep_summary"
|
|
|
|
)
|
|
|
|
|
|
|
|
return self._sleep_summary
|
|
|
|
|
|
|
|
|
|
|
|
def create_withings_data_manager(
|
2019-10-24 16:41:04 +00:00
|
|
|
hass: HomeAssistant,
|
|
|
|
config_entry: ConfigEntry,
|
|
|
|
implementation: AbstractOAuth2Implementation,
|
2019-08-31 12:30:59 +00:00
|
|
|
) -> WithingsDataManager:
|
|
|
|
"""Set up the sensor config entry."""
|
2019-10-24 16:41:04 +00:00
|
|
|
profile = config_entry.data.get(const.PROFILE)
|
2019-08-31 12:30:59 +00:00
|
|
|
|
2019-10-10 18:22:36 +00:00
|
|
|
_LOGGER.debug("Creating withings api instance")
|
2019-10-24 16:41:04 +00:00
|
|
|
api = ConfigEntryWithingsApi(
|
|
|
|
hass=hass, config_entry=config_entry, implementation=implementation
|
2019-08-31 12:30:59 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
_LOGGER.debug("Creating withings data manager for profile: %s", profile)
|
|
|
|
return WithingsDataManager(hass, profile, api)
|
|
|
|
|
|
|
|
|
|
|
|
def get_data_manager(
|
2019-10-24 16:41:04 +00:00
|
|
|
hass: HomeAssistant,
|
|
|
|
entry: ConfigEntry,
|
|
|
|
implementation: AbstractOAuth2Implementation,
|
2019-08-31 12:30:59 +00:00
|
|
|
) -> WithingsDataManager:
|
|
|
|
"""Get a data manager for a config entry.
|
|
|
|
|
|
|
|
If the data manager doesn't exist yet, it will be
|
|
|
|
created and cached for later use.
|
|
|
|
"""
|
2019-10-24 16:41:04 +00:00
|
|
|
entry_id = entry.entry_id
|
2019-08-31 12:30:59 +00:00
|
|
|
|
2019-10-24 16:41:04 +00:00
|
|
|
hass.data[const.DOMAIN] = hass.data.get(const.DOMAIN, {})
|
2019-08-31 12:30:59 +00:00
|
|
|
|
2019-10-24 16:41:04 +00:00
|
|
|
domain_dict = hass.data[const.DOMAIN]
|
|
|
|
domain_dict[const.DATA_MANAGER] = domain_dict.get(const.DATA_MANAGER, {})
|
2019-08-31 12:30:59 +00:00
|
|
|
|
2019-10-24 16:41:04 +00:00
|
|
|
dm_dict = domain_dict[const.DATA_MANAGER]
|
|
|
|
dm_dict[entry_id] = dm_dict.get(entry_id) or create_withings_data_manager(
|
|
|
|
hass, entry, implementation
|
|
|
|
)
|
2019-08-31 12:30:59 +00:00
|
|
|
|
2019-10-24 16:41:04 +00:00
|
|
|
return dm_dict[entry_id]
|