Use latest withings_api module (#27817)

* Using latest winthings_api module.
Drastically reduced complexity of tests.

* Removing import source.

* Fixing test requirements.

* Using requests_mock instead of responses module.

* Updating file formatting.

* Removing unused method.

* Adding support for new OAuth2 config flow.

* Addressing PR feedback.
Removing unecessary base_url from config, this is a potential breaking change.

* Addressing PR feedback.
This commit is contained in:
Robert Van Gorkom 2019-10-24 09:41:04 -07:00 committed by Paulus Schoutsen
parent fc09702cc3
commit 15bedd8f27
16 changed files with 998 additions and 1572 deletions

View File

@ -4,10 +4,11 @@ Support for the Withings API.
For more details about this platform, please refer to the documentation at For more details about this platform, please refer to the documentation at
""" """
import voluptuous as vol import voluptuous as vol
from withings_api import WithingsAuth
from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT, SOURCE_USER from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv, config_entry_oauth2_flow
from . import config_flow, const from . import config_flow, const
from .common import _LOGGER, get_data_manager, NotAuthenticatedError from .common import _LOGGER, get_data_manager, NotAuthenticatedError
@ -22,7 +23,6 @@ CONFIG_SCHEMA = vol.Schema(
vol.Required(const.CLIENT_SECRET): vol.All( vol.Required(const.CLIENT_SECRET): vol.All(
cv.string, vol.Length(min=1) cv.string, vol.Length(min=1)
), ),
vol.Optional(const.BASE_URL): cv.url,
vol.Required(const.PROFILES): vol.All( vol.Required(const.PROFILES): vol.All(
cv.ensure_list, cv.ensure_list,
vol.Unique(), vol.Unique(),
@ -36,50 +36,65 @@ CONFIG_SCHEMA = vol.Schema(
) )
async def async_setup(hass: HomeAssistantType, config: ConfigType): async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
"""Set up the Withings component.""" """Set up the Withings component."""
conf = config.get(DOMAIN) conf = config.get(DOMAIN, {})
if not conf: if not conf:
return True return True
hass.data[DOMAIN] = {const.CONFIG: conf} hass.data[DOMAIN] = {const.CONFIG: conf}
base_url = conf.get(const.BASE_URL, hass.config.api.base_url).rstrip("/") config_flow.WithingsFlowHandler.async_register_implementation(
hass.http.register_view(config_flow.WithingsAuthCallbackView)
config_flow.register_flow_implementation(
hass, hass,
config_entry_oauth2_flow.LocalOAuth2Implementation(
hass,
const.DOMAIN,
conf[const.CLIENT_ID], conf[const.CLIENT_ID],
conf[const.CLIENT_SECRET], conf[const.CLIENT_SECRET],
base_url, f"{WithingsAuth.URL}/oauth2_user/authorize2",
conf[const.PROFILES], f"{WithingsAuth.URL}/oauth2/token",
) ),
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data={}
)
) )
return True return True
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
"""Set up Withings from a config entry.""" """Set up Withings from a config entry."""
data_manager = get_data_manager(hass, 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(const.CREDENTIALS, {})
hass.config_entries.async_update_entry(
entry,
data={
"auth_implementation": const.DOMAIN,
"implementation": const.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"),
},
},
)
implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
data_manager = get_data_manager(hass, entry, implementation)
_LOGGER.debug("Confirming we're authenticated") _LOGGER.debug("Confirming we're authenticated")
try: try:
await data_manager.check_authenticated() await data_manager.check_authenticated()
except NotAuthenticatedError: except NotAuthenticatedError:
# Trigger new config flow. _LOGGER.error(
hass.async_create_task( "Withings auth tokens exired for profile %s, remove and re-add the integration",
hass.config_entries.flow.async_init( data_manager.profile,
const.DOMAIN,
context={"source": SOURCE_USER, const.PROFILE: data_manager.profile},
data={},
)
) )
return False return False
@ -90,6 +105,6 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
return True return True
async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
"""Unload Withings config entry.""" """Unload Withings config entry."""
return await hass.config_entries.async_forward_entry_unload(entry, "sensor") return await hass.config_entries.async_forward_entry_unload(entry, "sensor")

View File

@ -1,23 +1,36 @@
"""Common code for Withings.""" """Common code for Withings."""
import datetime import datetime
from functools import partial
import logging import logging
import re import re
import time import time
from typing import Any, Dict
import withings_api as withings from asyncio import run_coroutine_threadsafe
from oauthlib.oauth2.rfc6749.errors import MissingTokenError import requests
from requests_oauthlib import TokenUpdated from withings_api import (
AbstractWithingsApi,
SleepGetResponse,
MeasureGetMeasResponse,
SleepGetSummaryResponse,
)
from withings_api.common import UnauthorizedException, AuthFailedException
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.config_entry_oauth2_flow import (
AbstractOAuth2Implementation,
OAuth2Session,
)
from homeassistant.util import dt, slugify from homeassistant.util import dt, slugify
from . import const from . import const
_LOGGER = logging.getLogger(const.LOG_NAMESPACE) _LOGGER = logging.getLogger(const.LOG_NAMESPACE)
NOT_AUTHENTICATED_ERROR = re.compile( NOT_AUTHENTICATED_ERROR = re.compile(
".*(Error Code (100|101|102|200|401)|Missing access token parameter).*", # ".*(Error Code (100|101|102|200|401)|Missing access token parameter).*",
"^401,.*",
re.IGNORECASE, re.IGNORECASE,
) )
@ -37,40 +50,82 @@ class ServiceError(HomeAssistantError):
class ThrottleData: class ThrottleData:
"""Throttle data.""" """Throttle data."""
def __init__(self, interval: int, data): def __init__(self, interval: int, data: Any):
"""Constructor.""" """Constructor."""
self._time = int(time.time()) self._time = int(time.time())
self._interval = interval self._interval = interval
self._data = data self._data = data
@property @property
def time(self): def time(self) -> int:
"""Get time created.""" """Get time created."""
return self._time return self._time
@property @property
def interval(self): def interval(self) -> int:
"""Get interval.""" """Get interval."""
return self._interval return self._interval
@property @property
def data(self): def data(self) -> Any:
"""Get data.""" """Get data."""
return self._data return self._data
def is_expired(self): def is_expired(self) -> bool:
"""Is this data expired.""" """Is this data expired."""
return int(time.time()) - self.time > self.interval return int(time.time()) - self.time > self.interval
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()
class WithingsDataManager: class WithingsDataManager:
"""A class representing an Withings cloud service connection.""" """A class representing an Withings cloud service connection."""
service_available = None service_available = None
def __init__( def __init__(self, hass: HomeAssistant, profile: str, api: ConfigEntryWithingsApi):
self, hass: HomeAssistantType, profile: str, api: withings.WithingsApi
):
"""Constructor.""" """Constructor."""
self._hass = hass self._hass = hass
self._api = api self._api = api
@ -95,27 +150,27 @@ class WithingsDataManager:
return self._slug return self._slug
@property @property
def api(self): def api(self) -> ConfigEntryWithingsApi:
"""Get the api object.""" """Get the api object."""
return self._api return self._api
@property @property
def measures(self): def measures(self) -> MeasureGetMeasResponse:
"""Get the current measures data.""" """Get the current measures data."""
return self._measures return self._measures
@property @property
def sleep(self): def sleep(self) -> SleepGetResponse:
"""Get the current sleep data.""" """Get the current sleep data."""
return self._sleep return self._sleep
@property @property
def sleep_summary(self): def sleep_summary(self) -> SleepGetSummaryResponse:
"""Get the current sleep summary data.""" """Get the current sleep summary data."""
return self._sleep_summary return self._sleep_summary
@staticmethod @staticmethod
def get_throttle_interval(): def get_throttle_interval() -> int:
"""Get the throttle interval.""" """Get the throttle interval."""
return const.THROTTLE_INTERVAL return const.THROTTLE_INTERVAL
@ -128,22 +183,26 @@ class WithingsDataManager:
self.throttle_data[domain] = throttle_data self.throttle_data[domain] = throttle_data
@staticmethod @staticmethod
def print_service_unavailable(): def print_service_unavailable() -> bool:
"""Print the service is unavailable (once) to the log.""" """Print the service is unavailable (once) to the log."""
if WithingsDataManager.service_available is not False: if WithingsDataManager.service_available is not False:
_LOGGER.error("Looks like the service is not available at the moment") _LOGGER.error("Looks like the service is not available at the moment")
WithingsDataManager.service_available = False WithingsDataManager.service_available = False
return True return True
return False
@staticmethod @staticmethod
def print_service_available(): def print_service_available() -> bool:
"""Print the service is available (once) to to the log.""" """Print the service is available (once) to to the log."""
if WithingsDataManager.service_available is not True: if WithingsDataManager.service_available is not True:
_LOGGER.info("Looks like the service is available again") _LOGGER.info("Looks like the service is available again")
WithingsDataManager.service_available = True WithingsDataManager.service_available = True
return True return True
async def call(self, function, is_first_call=True, throttle_domain=None): return False
async def call(self, function, throttle_domain=None) -> Any:
"""Call an api method and handle the result.""" """Call an api method and handle the result."""
throttle_data = self.get_throttle_data(throttle_domain) throttle_data = self.get_throttle_data(throttle_domain)
@ -167,21 +226,12 @@ class WithingsDataManager:
WithingsDataManager.print_service_available() WithingsDataManager.print_service_available()
return result return result
except TokenUpdated: except Exception as ex: # pylint: disable=broad-except
WithingsDataManager.print_service_available() # Withings api encountered error.
if not is_first_call: if isinstance(ex, (UnauthorizedException, AuthFailedException)):
raise ServiceError(
"Stuck in a token update loop. This should never happen"
)
_LOGGER.info("Token updated, re-running call.")
return await self.call(function, False, throttle_domain)
except MissingTokenError as ex:
raise NotAuthenticatedError(ex) raise NotAuthenticatedError(ex)
except Exception as ex: # pylint: disable=broad-except # Oauth2 config flow failed to authenticate.
# Service error, probably not authenticated.
if NOT_AUTHENTICATED_ERROR.match(str(ex)): if NOT_AUTHENTICATED_ERROR.match(str(ex)):
raise NotAuthenticatedError(ex) raise NotAuthenticatedError(ex)
@ -189,37 +239,37 @@ class WithingsDataManager:
WithingsDataManager.print_service_unavailable() WithingsDataManager.print_service_unavailable()
raise PlatformNotReady(ex) raise PlatformNotReady(ex)
async def check_authenticated(self): async def check_authenticated(self) -> bool:
"""Check if the user is authenticated.""" """Check if the user is authenticated."""
def function(): def function():
return self._api.request("user", "getdevice", version="v2") return bool(self._api.user_get_device())
return await self.call(function) return await self.call(function)
async def update_measures(self): async def update_measures(self) -> MeasureGetMeasResponse:
"""Update the measures data.""" """Update the measures data."""
def function(): def function():
return self._api.get_measures() return self._api.measure_get_meas()
self._measures = await self.call(function, throttle_domain="update_measures") self._measures = await self.call(function, throttle_domain="update_measures")
return self._measures return self._measures
async def update_sleep(self): async def update_sleep(self) -> SleepGetResponse:
"""Update the sleep data.""" """Update the sleep data."""
end_date = int(time.time()) end_date = int(time.time())
start_date = end_date - (6 * 60 * 60) start_date = end_date - (6 * 60 * 60)
def function(): def function():
return self._api.get_sleep(startdate=start_date, enddate=end_date) return self._api.sleep_get(startdate=start_date, enddate=end_date)
self._sleep = await self.call(function, throttle_domain="update_sleep") self._sleep = await self.call(function, throttle_domain="update_sleep")
return self._sleep return self._sleep
async def update_sleep_summary(self): async def update_sleep_summary(self) -> SleepGetSummaryResponse:
"""Update the sleep summary data.""" """Update the sleep summary data."""
now = dt.utcnow() now = dt.utcnow()
yesterday = now - datetime.timedelta(days=1) yesterday = now - datetime.timedelta(days=1)
@ -240,7 +290,7 @@ class WithingsDataManager:
) )
def function(): def function():
return self._api.get_sleep_summary(lastupdate=yesterday_noon.timestamp()) return self._api.sleep_get_summary(lastupdate=yesterday_noon)
self._sleep_summary = await self.call( self._sleep_summary = await self.call(
function, throttle_domain="update_sleep_summary" function, throttle_domain="update_sleep_summary"
@ -250,36 +300,16 @@ class WithingsDataManager:
def create_withings_data_manager( def create_withings_data_manager(
hass: HomeAssistantType, entry: ConfigEntry hass: HomeAssistant,
config_entry: ConfigEntry,
implementation: AbstractOAuth2Implementation,
) -> WithingsDataManager: ) -> WithingsDataManager:
"""Set up the sensor config entry.""" """Set up the sensor config entry."""
entry_creds = entry.data.get(const.CREDENTIALS) or {} profile = config_entry.data.get(const.PROFILE)
profile = entry.data[const.PROFILE]
credentials = withings.WithingsCredentials(
entry_creds.get("access_token"),
entry_creds.get("token_expiry"),
entry_creds.get("token_type"),
entry_creds.get("refresh_token"),
entry_creds.get("user_id"),
entry_creds.get("client_id"),
entry_creds.get("consumer_secret"),
)
def credentials_saver(credentials_param):
_LOGGER.debug("Saving updated credentials of type %s", type(credentials_param))
# Sanitizing the data as sometimes a WithingsCredentials object
# is passed through from the API.
cred_data = credentials_param
if not isinstance(credentials_param, dict):
cred_data = credentials_param.__dict__
entry.data[const.CREDENTIALS] = cred_data
hass.config_entries.async_update_entry(entry, data={**entry.data})
_LOGGER.debug("Creating withings api instance") _LOGGER.debug("Creating withings api instance")
api = withings.WithingsApi( api = ConfigEntryWithingsApi(
credentials, refresh_cb=(lambda token: credentials_saver(api.credentials)) hass=hass, config_entry=config_entry, implementation=implementation
) )
_LOGGER.debug("Creating withings data manager for profile: %s", profile) _LOGGER.debug("Creating withings data manager for profile: %s", profile)
@ -287,24 +317,25 @@ def create_withings_data_manager(
def get_data_manager( def get_data_manager(
hass: HomeAssistantType, entry: ConfigEntry hass: HomeAssistant,
entry: ConfigEntry,
implementation: AbstractOAuth2Implementation,
) -> WithingsDataManager: ) -> WithingsDataManager:
"""Get a data manager for a config entry. """Get a data manager for a config entry.
If the data manager doesn't exist yet, it will be If the data manager doesn't exist yet, it will be
created and cached for later use. created and cached for later use.
""" """
profile = entry.data.get(const.PROFILE) entry_id = entry.entry_id
if not hass.data.get(const.DOMAIN): hass.data[const.DOMAIN] = hass.data.get(const.DOMAIN, {})
hass.data[const.DOMAIN] = {}
if not hass.data[const.DOMAIN].get(const.DATA_MANAGER): domain_dict = hass.data[const.DOMAIN]
hass.data[const.DOMAIN][const.DATA_MANAGER] = {} domain_dict[const.DATA_MANAGER] = domain_dict.get(const.DATA_MANAGER, {})
if not hass.data[const.DOMAIN][const.DATA_MANAGER].get(profile): dm_dict = domain_dict[const.DATA_MANAGER]
hass.data[const.DOMAIN][const.DATA_MANAGER][ dm_dict[entry_id] = dm_dict.get(entry_id) or create_withings_data_manager(
profile hass, entry, implementation
] = create_withings_data_manager(hass, entry) )
return hass.data[const.DOMAIN][const.DATA_MANAGER][profile] return dm_dict[entry_id]

View File

@ -1,192 +1,64 @@
"""Config flow for Withings.""" """Config flow for Withings."""
from collections import OrderedDict
import logging import logging
from typing import Optional
import aiohttp
import withings_api as withings
import voluptuous as vol import voluptuous as vol
from withings_api.common import AuthScope
from homeassistant import config_entries, data_entry_flow from homeassistant import config_entries
from homeassistant.components.http import HomeAssistantView from homeassistant.components.withings import const
from homeassistant.config_entries import ConfigEntry from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.core import callback
from . import const
DATA_FLOW_IMPL = "withings_flow_implementation"
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@callback
def register_flow_implementation(hass, client_id, client_secret, base_url, profiles):
"""Register a flow implementation.
hass: Home assistant object.
client_id: Client id.
client_secret: Client secret.
base_url: Base url of home assistant instance.
profiles: The profiles to work with.
"""
if DATA_FLOW_IMPL not in hass.data:
hass.data[DATA_FLOW_IMPL] = OrderedDict()
hass.data[DATA_FLOW_IMPL] = {
const.CLIENT_ID: client_id,
const.CLIENT_SECRET: client_secret,
const.BASE_URL: base_url,
const.PROFILES: profiles,
}
@config_entries.HANDLERS.register(const.DOMAIN) @config_entries.HANDLERS.register(const.DOMAIN)
class WithingsFlowHandler(config_entries.ConfigFlow): class WithingsFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler):
"""Handle a config flow.""" """Handle a config flow."""
VERSION = 1 DOMAIN = const.DOMAIN
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
_current_data = None
def __init__(self): @property
"""Initialize flow.""" def logger(self) -> logging.Logger:
self.flow_profile = None """Return logger."""
self.data = None return logging.getLogger(__name__)
def async_profile_config_entry(self, profile: str) -> Optional[ConfigEntry]: @property
"""Get a profile config entry.""" def extra_authorize_data(self) -> dict:
entries = self.hass.config_entries.async_entries(const.DOMAIN) """Extra data that needs to be appended to the authorize url."""
for entry in entries: return {
if entry.data.get(const.PROFILE) == profile: "scope": ",".join(
return entry [
AuthScope.USER_INFO.value,
return None AuthScope.USER_METRICS.value,
AuthScope.USER_ACTIVITY.value,
def get_auth_client(self, profile: str): ]
"""Get a new auth client."""
flow = self.hass.data[DATA_FLOW_IMPL]
client_id = flow[const.CLIENT_ID]
client_secret = flow[const.CLIENT_SECRET]
base_url = flow[const.BASE_URL].rstrip("/")
callback_uri = "{}/{}?flow_id={}&profile={}".format(
base_url.rstrip("/"),
const.AUTH_CALLBACK_PATH.lstrip("/"),
self.flow_id,
profile,
) )
}
return withings.WithingsAuth( async def async_oauth_create_entry(self, data: dict) -> dict:
client_id, """Override the create entry so user can select a profile."""
client_secret, self._current_data = data
callback_uri, return await self.async_step_profile(data)
scope=",".join(["user.info", "user.metrics", "user.activity"]),
)
async def async_step_import(self, user_input=None): async def async_step_profile(self, data: dict) -> dict:
"""Create user step.""" """Prompt the user to select a user profile."""
return await self.async_step_user(user_input) profile = data.get(const.PROFILE)
async def async_step_user(self, user_input=None): if profile:
"""Create an entry for selecting a profile.""" new_data = {**self._current_data, **{const.PROFILE: profile}}
flow = self.hass.data.get(DATA_FLOW_IMPL) self._current_data = None
return await self.async_step_finish(new_data)
if not flow:
return self.async_abort(reason="no_flows")
if user_input:
return await self.async_step_auth(user_input)
profiles = self.hass.data[const.DOMAIN][const.CONFIG][const.PROFILES]
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="profile",
data_schema=vol.Schema( data_schema=vol.Schema({vol.Required(const.PROFILE): vol.In(profiles)}),
{vol.Required(const.PROFILE): vol.In(flow.get(const.PROFILES))}
),
) )
async def async_step_auth(self, user_input=None): async def async_step_finish(self, data: dict) -> dict:
"""Create an entry for auth.""" """Finish the flow."""
if user_input.get(const.CODE): self._current_data = None
self.data = user_input
return self.async_external_step_done(next_step_id="finish")
profile = user_input.get(const.PROFILE) return self.async_create_entry(title=data[const.PROFILE], data=data)
auth_client = self.get_auth_client(profile)
url = auth_client.get_authorize_url()
return self.async_external_step(step_id="auth", url=url)
async def async_step_finish(self, user_input=None):
"""Received code for authentication."""
data = user_input or self.data or {}
_LOGGER.debug(
"Should close all flows below %s",
self.hass.config_entries.flow.async_progress(),
)
profile = data[const.PROFILE]
code = data[const.CODE]
return await self._async_create_session(profile, code)
async def _async_create_session(self, profile, code):
"""Create withings session and entries."""
auth_client = self.get_auth_client(profile)
_LOGGER.debug("Requesting credentials with code: %s.", code)
credentials = auth_client.get_credentials(code)
return self.async_create_entry(
title=profile,
data={const.PROFILE: profile, const.CREDENTIALS: credentials.__dict__},
)
class WithingsAuthCallbackView(HomeAssistantView):
"""Withings Authorization Callback View."""
requires_auth = False
url = const.AUTH_CALLBACK_PATH
name = const.AUTH_CALLBACK_NAME
def __init__(self):
"""Constructor."""
async def get(self, request):
"""Receive authorization code."""
hass = request.app["hass"]
code = request.query.get("code")
profile = request.query.get("profile")
flow_id = request.query.get("flow_id")
if not flow_id:
return aiohttp.web_response.Response(
status=400, text="'flow_id' argument not provided in url."
)
if not profile:
return aiohttp.web_response.Response(
status=400, text="'profile' argument not provided in url."
)
if not code:
return aiohttp.web_response.Response(
status=400, text="'code' argument not provided in url."
)
try:
await hass.config_entries.flow.async_configure(
flow_id, {const.PROFILE: profile, const.CODE: code}
)
return aiohttp.web_response.Response(
status=200,
headers={"content-type": "text/html"},
text="<script>window.close()</script>",
)
except data_entry_flow.UnknownFlow:
return aiohttp.web_response.Response(status=400, text="Unknown flow")

View File

@ -19,6 +19,7 @@ AUTH_CALLBACK_PATH = "/api/withings/authorize"
AUTH_CALLBACK_NAME = "withings:authorize" AUTH_CALLBACK_NAME = "withings:authorize"
THROTTLE_INTERVAL = 60 THROTTLE_INTERVAL = 60
SCAN_INTERVAL = 60
STATE_UNKNOWN = const.STATE_UNKNOWN STATE_UNKNOWN = const.STATE_UNKNOWN
STATE_AWAKE = "awake" STATE_AWAKE = "awake"
@ -26,40 +27,6 @@ STATE_DEEP = "deep"
STATE_LIGHT = "light" STATE_LIGHT = "light"
STATE_REM = "rem" STATE_REM = "rem"
MEASURE_TYPE_BODY_TEMP = 71
MEASURE_TYPE_BONE_MASS = 88
MEASURE_TYPE_DIASTOLIC_BP = 9
MEASURE_TYPE_FAT_MASS = 8
MEASURE_TYPE_FAT_MASS_FREE = 5
MEASURE_TYPE_FAT_RATIO = 6
MEASURE_TYPE_HEART_PULSE = 11
MEASURE_TYPE_HEIGHT = 4
MEASURE_TYPE_HYDRATION = 77
MEASURE_TYPE_MUSCLE_MASS = 76
MEASURE_TYPE_PWV = 91
MEASURE_TYPE_SKIN_TEMP = 73
MEASURE_TYPE_SLEEP_DEEP_DURATION = "deepsleepduration"
MEASURE_TYPE_SLEEP_HEART_RATE_AVERAGE = "hr_average"
MEASURE_TYPE_SLEEP_HEART_RATE_MAX = "hr_max"
MEASURE_TYPE_SLEEP_HEART_RATE_MIN = "hr_min"
MEASURE_TYPE_SLEEP_LIGHT_DURATION = "lightsleepduration"
MEASURE_TYPE_SLEEP_REM_DURATION = "remsleepduration"
MEASURE_TYPE_SLEEP_RESPIRATORY_RATE_AVERAGE = "rr_average"
MEASURE_TYPE_SLEEP_RESPIRATORY_RATE_MAX = "rr_max"
MEASURE_TYPE_SLEEP_RESPIRATORY_RATE_MIN = "rr_min"
MEASURE_TYPE_SLEEP_STATE_AWAKE = 0
MEASURE_TYPE_SLEEP_STATE_DEEP = 2
MEASURE_TYPE_SLEEP_STATE_LIGHT = 1
MEASURE_TYPE_SLEEP_STATE_REM = 3
MEASURE_TYPE_SLEEP_TOSLEEP_DURATION = "durationtosleep"
MEASURE_TYPE_SLEEP_TOWAKEUP_DURATION = "durationtowakeup"
MEASURE_TYPE_SLEEP_WAKEUP_DURATION = "wakeupduration"
MEASURE_TYPE_SLEEP_WAKUP_COUNT = "wakeupcount"
MEASURE_TYPE_SPO2 = 54
MEASURE_TYPE_SYSTOLIC_BP = 10
MEASURE_TYPE_TEMP = 12
MEASURE_TYPE_WEIGHT = 1
MEAS_BODY_TEMP_C = "body_temperature_c" MEAS_BODY_TEMP_C = "body_temperature_c"
MEAS_BONE_MASS_KG = "bone_mass_kg" MEAS_BONE_MASS_KG = "bone_mass_kg"
MEAS_DIASTOLIC_MMHG = "diastolic_blood_pressure_mmhg" MEAS_DIASTOLIC_MMHG = "diastolic_blood_pressure_mmhg"

View File

@ -4,7 +4,7 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/withings", "documentation": "https://www.home-assistant.io/integrations/withings",
"requirements": [ "requirements": [
"withings-api==2.0.0b8" "withings-api==2.1.2"
], ],
"dependencies": [ "dependencies": [
"api", "api",

View File

@ -1,10 +1,22 @@
"""Sensors flow for Withings.""" """Sensors flow for Withings."""
import typing as types from typing import Callable, List, Union
from withings_api.common import (
MeasureType,
GetSleepSummaryField,
MeasureGetMeasResponse,
SleepGetResponse,
SleepGetSummaryResponse,
get_measure_value,
MeasureGroupAttribs,
SleepState,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util import slugify from homeassistant.util import slugify
from homeassistant.helpers import config_entry_oauth2_flow
from . import const from . import const
from .common import _LOGGER, WithingsDataManager, get_data_manager from .common import _LOGGER, WithingsDataManager, get_data_manager
@ -16,57 +28,22 @@ PARALLEL_UPDATES = 1
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistantType, hass: HomeAssistant,
entry: ConfigEntry, entry: ConfigEntry,
async_add_entities: types.Callable[[types.List[Entity], bool], None], async_add_entities: Callable[[List[Entity], bool], None],
): ) -> None:
"""Set up the sensor config entry.""" """Set up the sensor config entry."""
data_manager = get_data_manager(hass, entry) implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation(
entities = create_sensor_entities(data_manager) hass, entry
)
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) async_add_entities(entities, True)
def get_measures():
"""Get all the measures.
This function exists to be easily mockable so we can test
one measure at a time. This becomes necessary when integration
testing throttle functionality in the data manager.
"""
return list(WITHINGS_MEASUREMENTS_MAP)
def create_sensor_entities(data_manager: WithingsDataManager):
"""Create sensor entities."""
entities = []
measures = get_measures()
for attribute in WITHINGS_ATTRIBUTES:
if attribute.measurement not in measures:
_LOGGER.debug(
"Skipping measurement %s as it is not in the"
"list of measurements to use",
attribute.measurement,
)
continue
_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)
entities.append(entity)
return entities
class WithingsAttribute: class WithingsAttribute:
"""Base class for modeling withing data.""" """Base class for modeling withing data."""
@ -107,104 +84,104 @@ class WithingsSleepSummaryAttribute(WithingsAttribute):
WITHINGS_ATTRIBUTES = [ WITHINGS_ATTRIBUTES = [
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_WEIGHT_KG, const.MEAS_WEIGHT_KG,
const.MEASURE_TYPE_WEIGHT, MeasureType.WEIGHT,
"Weight", "Weight",
const.UOM_MASS_KG, const.UOM_MASS_KG,
"mdi:weight-kilogram", "mdi:weight-kilogram",
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_FAT_MASS_KG, const.MEAS_FAT_MASS_KG,
const.MEASURE_TYPE_FAT_MASS, MeasureType.FAT_MASS_WEIGHT,
"Fat Mass", "Fat Mass",
const.UOM_MASS_KG, const.UOM_MASS_KG,
"mdi:weight-kilogram", "mdi:weight-kilogram",
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_FAT_FREE_MASS_KG, const.MEAS_FAT_FREE_MASS_KG,
const.MEASURE_TYPE_FAT_MASS_FREE, MeasureType.FAT_FREE_MASS,
"Fat Free Mass", "Fat Free Mass",
const.UOM_MASS_KG, const.UOM_MASS_KG,
"mdi:weight-kilogram", "mdi:weight-kilogram",
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_MUSCLE_MASS_KG, const.MEAS_MUSCLE_MASS_KG,
const.MEASURE_TYPE_MUSCLE_MASS, MeasureType.MUSCLE_MASS,
"Muscle Mass", "Muscle Mass",
const.UOM_MASS_KG, const.UOM_MASS_KG,
"mdi:weight-kilogram", "mdi:weight-kilogram",
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_BONE_MASS_KG, const.MEAS_BONE_MASS_KG,
const.MEASURE_TYPE_BONE_MASS, MeasureType.BONE_MASS,
"Bone Mass", "Bone Mass",
const.UOM_MASS_KG, const.UOM_MASS_KG,
"mdi:weight-kilogram", "mdi:weight-kilogram",
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_HEIGHT_M, const.MEAS_HEIGHT_M,
const.MEASURE_TYPE_HEIGHT, MeasureType.HEIGHT,
"Height", "Height",
const.UOM_LENGTH_M, const.UOM_LENGTH_M,
"mdi:ruler", "mdi:ruler",
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_TEMP_C, const.MEAS_TEMP_C,
const.MEASURE_TYPE_TEMP, MeasureType.TEMPERATURE,
"Temperature", "Temperature",
const.UOM_TEMP_C, const.UOM_TEMP_C,
"mdi:thermometer", "mdi:thermometer",
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_BODY_TEMP_C, const.MEAS_BODY_TEMP_C,
const.MEASURE_TYPE_BODY_TEMP, MeasureType.BODY_TEMPERATURE,
"Body Temperature", "Body Temperature",
const.UOM_TEMP_C, const.UOM_TEMP_C,
"mdi:thermometer", "mdi:thermometer",
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_SKIN_TEMP_C, const.MEAS_SKIN_TEMP_C,
const.MEASURE_TYPE_SKIN_TEMP, MeasureType.SKIN_TEMPERATURE,
"Skin Temperature", "Skin Temperature",
const.UOM_TEMP_C, const.UOM_TEMP_C,
"mdi:thermometer", "mdi:thermometer",
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_FAT_RATIO_PCT, const.MEAS_FAT_RATIO_PCT,
const.MEASURE_TYPE_FAT_RATIO, MeasureType.FAT_RATIO,
"Fat Ratio", "Fat Ratio",
const.UOM_PERCENT, const.UOM_PERCENT,
None, None,
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_DIASTOLIC_MMHG, const.MEAS_DIASTOLIC_MMHG,
const.MEASURE_TYPE_DIASTOLIC_BP, MeasureType.DIASTOLIC_BLOOD_PRESSURE,
"Diastolic Blood Pressure", "Diastolic Blood Pressure",
const.UOM_MMHG, const.UOM_MMHG,
None, None,
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_SYSTOLIC_MMGH, const.MEAS_SYSTOLIC_MMGH,
const.MEASURE_TYPE_SYSTOLIC_BP, MeasureType.SYSTOLIC_BLOOD_PRESSURE,
"Systolic Blood Pressure", "Systolic Blood Pressure",
const.UOM_MMHG, const.UOM_MMHG,
None, None,
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_HEART_PULSE_BPM, const.MEAS_HEART_PULSE_BPM,
const.MEASURE_TYPE_HEART_PULSE, MeasureType.HEART_RATE,
"Heart Pulse", "Heart Pulse",
const.UOM_BEATS_PER_MINUTE, const.UOM_BEATS_PER_MINUTE,
"mdi:heart-pulse", "mdi:heart-pulse",
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_SPO2_PCT, const.MEASURE_TYPE_SPO2, "SP02", const.UOM_PERCENT, None const.MEAS_SPO2_PCT, MeasureType.SP02, "SP02", const.UOM_PERCENT, None
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_HYDRATION, const.MEASURE_TYPE_HYDRATION, "Hydration", "", "mdi:water" const.MEAS_HYDRATION, MeasureType.HYDRATION, "Hydration", "", "mdi:water"
), ),
WithingsMeasureAttribute( WithingsMeasureAttribute(
const.MEAS_PWV, const.MEAS_PWV,
const.MEASURE_TYPE_PWV, MeasureType.PULSE_WAVE_VELOCITY,
"Pulse Wave Velocity", "Pulse Wave Velocity",
const.UOM_METERS_PER_SECOND, const.UOM_METERS_PER_SECOND,
None, None,
@ -214,91 +191,91 @@ WITHINGS_ATTRIBUTES = [
), ),
WithingsSleepSummaryAttribute( WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_WAKEUP_DURATION_SECONDS, const.MEAS_SLEEP_WAKEUP_DURATION_SECONDS,
const.MEASURE_TYPE_SLEEP_WAKEUP_DURATION, GetSleepSummaryField.WAKEUP_DURATION.value,
"Wakeup time", "Wakeup time",
const.UOM_SECONDS, const.UOM_SECONDS,
"mdi:sleep-off", "mdi:sleep-off",
), ),
WithingsSleepSummaryAttribute( WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_LIGHT_DURATION_SECONDS, const.MEAS_SLEEP_LIGHT_DURATION_SECONDS,
const.MEASURE_TYPE_SLEEP_LIGHT_DURATION, GetSleepSummaryField.LIGHT_SLEEP_DURATION.value,
"Light sleep", "Light sleep",
const.UOM_SECONDS, const.UOM_SECONDS,
"mdi:sleep", "mdi:sleep",
), ),
WithingsSleepSummaryAttribute( WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_DEEP_DURATION_SECONDS, const.MEAS_SLEEP_DEEP_DURATION_SECONDS,
const.MEASURE_TYPE_SLEEP_DEEP_DURATION, GetSleepSummaryField.DEEP_SLEEP_DURATION.value,
"Deep sleep", "Deep sleep",
const.UOM_SECONDS, const.UOM_SECONDS,
"mdi:sleep", "mdi:sleep",
), ),
WithingsSleepSummaryAttribute( WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_REM_DURATION_SECONDS, const.MEAS_SLEEP_REM_DURATION_SECONDS,
const.MEASURE_TYPE_SLEEP_REM_DURATION, GetSleepSummaryField.REM_SLEEP_DURATION.value,
"REM sleep", "REM sleep",
const.UOM_SECONDS, const.UOM_SECONDS,
"mdi:sleep", "mdi:sleep",
), ),
WithingsSleepSummaryAttribute( WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_WAKEUP_COUNT, const.MEAS_SLEEP_WAKEUP_COUNT,
const.MEASURE_TYPE_SLEEP_WAKUP_COUNT, GetSleepSummaryField.WAKEUP_COUNT.value,
"Wakeup count", "Wakeup count",
const.UOM_FREQUENCY, const.UOM_FREQUENCY,
"mdi:sleep-off", "mdi:sleep-off",
), ),
WithingsSleepSummaryAttribute( WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_TOSLEEP_DURATION_SECONDS, const.MEAS_SLEEP_TOSLEEP_DURATION_SECONDS,
const.MEASURE_TYPE_SLEEP_TOSLEEP_DURATION, GetSleepSummaryField.DURATION_TO_SLEEP.value,
"Time to sleep", "Time to sleep",
const.UOM_SECONDS, const.UOM_SECONDS,
"mdi:sleep", "mdi:sleep",
), ),
WithingsSleepSummaryAttribute( WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_TOWAKEUP_DURATION_SECONDS, const.MEAS_SLEEP_TOWAKEUP_DURATION_SECONDS,
const.MEASURE_TYPE_SLEEP_TOWAKEUP_DURATION, GetSleepSummaryField.DURATION_TO_WAKEUP.value,
"Time to wakeup", "Time to wakeup",
const.UOM_SECONDS, const.UOM_SECONDS,
"mdi:sleep-off", "mdi:sleep-off",
), ),
WithingsSleepSummaryAttribute( WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_HEART_RATE_AVERAGE, const.MEAS_SLEEP_HEART_RATE_AVERAGE,
const.MEASURE_TYPE_SLEEP_HEART_RATE_AVERAGE, GetSleepSummaryField.HR_AVERAGE.value,
"Average heart rate", "Average heart rate",
const.UOM_BEATS_PER_MINUTE, const.UOM_BEATS_PER_MINUTE,
"mdi:heart-pulse", "mdi:heart-pulse",
), ),
WithingsSleepSummaryAttribute( WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_HEART_RATE_MIN, const.MEAS_SLEEP_HEART_RATE_MIN,
const.MEASURE_TYPE_SLEEP_HEART_RATE_MIN, GetSleepSummaryField.HR_MIN.value,
"Minimum heart rate", "Minimum heart rate",
const.UOM_BEATS_PER_MINUTE, const.UOM_BEATS_PER_MINUTE,
"mdi:heart-pulse", "mdi:heart-pulse",
), ),
WithingsSleepSummaryAttribute( WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_HEART_RATE_MAX, const.MEAS_SLEEP_HEART_RATE_MAX,
const.MEASURE_TYPE_SLEEP_HEART_RATE_MAX, GetSleepSummaryField.HR_MAX.value,
"Maximum heart rate", "Maximum heart rate",
const.UOM_BEATS_PER_MINUTE, const.UOM_BEATS_PER_MINUTE,
"mdi:heart-pulse", "mdi:heart-pulse",
), ),
WithingsSleepSummaryAttribute( WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_RESPIRATORY_RATE_AVERAGE, const.MEAS_SLEEP_RESPIRATORY_RATE_AVERAGE,
const.MEASURE_TYPE_SLEEP_RESPIRATORY_RATE_AVERAGE, GetSleepSummaryField.RR_AVERAGE.value,
"Average respiratory rate", "Average respiratory rate",
const.UOM_BREATHS_PER_MINUTE, const.UOM_BREATHS_PER_MINUTE,
None, None,
), ),
WithingsSleepSummaryAttribute( WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_RESPIRATORY_RATE_MIN, const.MEAS_SLEEP_RESPIRATORY_RATE_MIN,
const.MEASURE_TYPE_SLEEP_RESPIRATORY_RATE_MIN, GetSleepSummaryField.RR_MIN.value,
"Minimum respiratory rate", "Minimum respiratory rate",
const.UOM_BREATHS_PER_MINUTE, const.UOM_BREATHS_PER_MINUTE,
None, None,
), ),
WithingsSleepSummaryAttribute( WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_RESPIRATORY_RATE_MAX, const.MEAS_SLEEP_RESPIRATORY_RATE_MAX,
const.MEASURE_TYPE_SLEEP_RESPIRATORY_RATE_MAX, GetSleepSummaryField.RR_MAX.value,
"Maximum respiratory rate", "Maximum respiratory rate",
const.UOM_BREATHS_PER_MINUTE, const.UOM_BREATHS_PER_MINUTE,
None, None,
@ -312,7 +289,10 @@ class WithingsHealthSensor(Entity):
"""Implementation of a Withings sensor.""" """Implementation of a Withings sensor."""
def __init__( def __init__(
self, data_manager: WithingsDataManager, attribute: WithingsAttribute self,
data_manager: WithingsDataManager,
attribute: WithingsAttribute,
user_id: str,
) -> None: ) -> None:
"""Initialize the Withings sensor.""" """Initialize the Withings sensor."""
self._data_manager = data_manager self._data_manager = data_manager
@ -320,7 +300,7 @@ class WithingsHealthSensor(Entity):
self._state = None self._state = None
self._slug = self._data_manager.slug self._slug = self._data_manager.slug
self._user_id = self._data_manager.api.get_credentials().user_id self._user_id = user_id
@property @property
def name(self) -> str: def name(self) -> str:
@ -335,7 +315,7 @@ class WithingsHealthSensor(Entity):
) )
@property @property
def state(self): def state(self) -> Union[str, int, float, None]:
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self._state return self._state
@ -350,7 +330,7 @@ class WithingsHealthSensor(Entity):
return self._attribute.icon return self._attribute.icon
@property @property
def device_state_attributes(self): def device_state_attributes(self) -> None:
"""Get withings attributes.""" """Get withings attributes."""
return self._attribute.__dict__ return self._attribute.__dict__
@ -378,71 +358,45 @@ class WithingsHealthSensor(Entity):
await self._data_manager.update_sleep_summary() await self._data_manager.update_sleep_summary()
await self.async_update_sleep_summary(self._data_manager.sleep_summary) await self.async_update_sleep_summary(self._data_manager.sleep_summary)
async def async_update_measure(self, data) -> None: async def async_update_measure(self, data: MeasureGetMeasResponse) -> None:
"""Update the measures data.""" """Update the measures data."""
if data is None:
_LOGGER.error("Provided data is None. Setting state to %s", None)
self._state = None
return
measure_type = self._attribute.measure_type measure_type = self._attribute.measure_type
_LOGGER.debug( _LOGGER.debug(
"Finding the unambiguous measure group with measure_type: %s", measure_type "Finding the unambiguous measure group with measure_type: %s", measure_type
) )
measure_groups = [
g
for g in data
if (not g.is_ambiguous() and g.get_measure(measure_type) is not None)
]
if not measure_groups: value = get_measure_value(data, measure_type, MeasureGroupAttribs.UNAMBIGUOUS)
_LOGGER.debug("No measure groups found, setting state to %s", None)
if value is None:
_LOGGER.debug("Could not find a value, setting state to %s", None)
self._state = None self._state = None
return return
_LOGGER.debug( self._state = round(value, 2)
"Sorting list of %s measure groups by date created (DESC)",
len(measure_groups),
)
measure_groups.sort(key=(lambda g: g.created), reverse=True)
self._state = round(measure_groups[0].get_measure(measure_type), 4) async def async_update_sleep_state(self, data: SleepGetResponse) -> None:
async def async_update_sleep_state(self, data) -> None:
"""Update the sleep state data.""" """Update the sleep state data."""
if data is None:
_LOGGER.error("Provided data is None. Setting state to %s", None)
self._state = None
return
if not data.series: if not data.series:
_LOGGER.debug("No sleep data, setting state to %s", None) _LOGGER.debug("No sleep data, setting state to %s", None)
self._state = None self._state = None
return return
series = sorted(data.series, key=lambda o: o.enddate, reverse=True) serie = data.series[len(data.series) - 1]
state = None
if serie.state == SleepState.AWAKE:
state = const.STATE_AWAKE
elif serie.state == SleepState.LIGHT:
state = const.STATE_LIGHT
elif serie.state == SleepState.DEEP:
state = const.STATE_DEEP
elif serie.state == SleepState.REM:
state = const.STATE_REM
serie = series[0] self._state = state
if serie.state == const.MEASURE_TYPE_SLEEP_STATE_AWAKE: async def async_update_sleep_summary(self, data: SleepGetSummaryResponse) -> None:
self._state = const.STATE_AWAKE
elif serie.state == const.MEASURE_TYPE_SLEEP_STATE_LIGHT:
self._state = const.STATE_LIGHT
elif serie.state == const.MEASURE_TYPE_SLEEP_STATE_DEEP:
self._state = const.STATE_DEEP
elif serie.state == const.MEASURE_TYPE_SLEEP_STATE_REM:
self._state = const.STATE_REM
else:
self._state = None
async def async_update_sleep_summary(self, data) -> None:
"""Update the sleep summary data.""" """Update the sleep summary data."""
if data is None:
_LOGGER.error("Provided data is None. Setting state to %s", None)
self._state = None
return
if not data.series: if not data.series:
_LOGGER.debug("Sleep data has no series, setting state to %s", None) _LOGGER.debug("Sleep data has no series, setting state to %s", None)
self._state = None self._state = None
@ -454,7 +408,59 @@ class WithingsHealthSensor(Entity):
_LOGGER.debug("Determining total value for: %s", measurement) _LOGGER.debug("Determining total value for: %s", measurement)
total = 0 total = 0
for serie in data.series: for serie in data.series:
if hasattr(serie, measure_type): data = serie.data
total += getattr(serie, measure_type) 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) 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

View File

@ -2,19 +2,13 @@
"config": { "config": {
"title": "Withings", "title": "Withings",
"step": { "step": {
"user": { "profile": {
"title": "User Profile.", "title": "User Profile.",
"description": "Select a user profile to which you want Home Assistant to map with a Withings profile. On the withings page, be sure to select the same user or data will not be labeled correctly.", "description": "Which profile did you select on the Withings website? It's important the profiles match, otherwise data will be mis-labeled.",
"data": { "data": {
"profile": "Profile" "profile": "Profile"
} }
} }
},
"create_entry": {
"default": "Successfully authenticated with Withings for the selected profile."
},
"abort": {
"no_flows": "You need to configure Withings before being able to authenticate with it. Please read the documentation."
} }
} }
} }

View File

@ -1984,7 +1984,7 @@ websockets==6.0
wirelesstagpy==0.4.0 wirelesstagpy==0.4.0
# homeassistant.components.withings # homeassistant.components.withings
withings-api==2.0.0b8 withings-api==2.1.2
# homeassistant.components.wunderlist # homeassistant.components.wunderlist
wunderpy2==0.1.6 wunderpy2==0.1.6

View File

@ -20,3 +20,4 @@ pytest-sugar==0.9.2
pytest-timeout==1.3.3 pytest-timeout==1.3.3
pytest==5.2.1 pytest==5.2.1
requests_mock==1.7.0 requests_mock==1.7.0
responses==0.10.6

View File

@ -21,6 +21,7 @@ pytest-sugar==0.9.2
pytest-timeout==1.3.3 pytest-timeout==1.3.3
pytest==5.2.1 pytest==5.2.1
requests_mock==1.7.0 requests_mock==1.7.0
responses==0.10.6
# homeassistant.components.homekit # homeassistant.components.homekit
@ -629,7 +630,7 @@ watchdog==0.8.3
websockets==6.0 websockets==6.0
# homeassistant.components.withings # homeassistant.components.withings
withings-api==2.0.0b8 withings-api==2.1.2
# homeassistant.components.bluesound # homeassistant.components.bluesound
# homeassistant.components.startca # homeassistant.components.startca

View File

@ -1,213 +1,383 @@
"""Common data for for the withings component tests.""" """Common data for for the withings component tests."""
import re
import time import time
from typing import List
import withings_api as withings import requests_mock
from withings_api import AbstractWithingsApi
from withings_api.common import (
MeasureGetMeasGroupAttrib,
MeasureGetMeasGroupCategory,
MeasureType,
SleepModel,
SleepState,
)
from homeassistant import data_entry_flow
import homeassistant.components.api as api
import homeassistant.components.http as http
import homeassistant.components.withings.const as const 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.const import CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_METRIC
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.setup import async_setup_component
from homeassistant.util import slugify
def new_sleep_data(model, series): def get_entity_id(measure, profile) -> str:
"""Create simple dict to simulate api data.""" """Get an entity id for a measure and profile."""
return {"series": series, "model": model} return "sensor.{}_{}_{}".format(const.DOMAIN, measure, slugify(profile))
def new_sleep_data_serie(startdate, enddate, state): def assert_state_equals(
"""Create simple dict to simulate api data.""" hass: HomeAssistant, profile: str, measure: str, expected
return {"startdate": startdate, "enddate": enddate, "state": state} ) -> 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, "Expected entity {} to exist but it did not".format(entity_id)
def new_sleep_summary(timezone, model, startdate, enddate, date, modified, data): assert state_obj.state == str(
"""Create simple dict to simulate api data.""" expected
return { ), "Expected {} but was {} for measure {}, {}".format(
"timezone": timezone, expected, state_obj.state, measure, entity_id
"model": model,
"startdate": startdate,
"enddate": enddate,
"date": date,
"modified": modified,
"data": data,
}
def new_sleep_summary_detail(
wakeupduration,
lightsleepduration,
deepsleepduration,
remsleepduration,
wakeupcount,
durationtosleep,
durationtowakeup,
hr_average,
hr_min,
hr_max,
rr_average,
rr_min,
rr_max,
):
"""Create simple dict to simulate api data."""
return {
"wakeupduration": wakeupduration,
"lightsleepduration": lightsleepduration,
"deepsleepduration": deepsleepduration,
"remsleepduration": remsleepduration,
"wakeupcount": wakeupcount,
"durationtosleep": durationtosleep,
"durationtowakeup": durationtowakeup,
"hr_average": hr_average,
"hr_min": hr_min,
"hr_max": hr_max,
"rr_average": rr_average,
"rr_min": rr_min,
"rr_max": rr_max,
}
def new_measure_group(
grpid, attrib, date, created, category, deviceid, more, offset, measures
):
"""Create simple dict to simulate api data."""
return {
"grpid": grpid,
"attrib": attrib,
"date": date,
"created": created,
"category": category,
"deviceid": deviceid,
"measures": measures,
"more": more,
"offset": offset,
"comment": "blah", # deprecated
}
def new_measure(type_str, value, unit):
"""Create simple dict to simulate api data."""
return {
"value": value,
"type": type_str,
"unit": unit,
"algo": -1, # deprecated
"fm": -1, # deprecated
"fw": -1, # deprecated
}
def withings_sleep_response(states):
"""Create a sleep response based on states."""
data = []
for state in states:
data.append(
new_sleep_data_serie(
"2019-02-01 0{}:00:00".format(str(len(data))),
"2019-02-01 0{}:00:00".format(str(len(data) + 1)),
state,
)
) )
return withings.WithingsSleep(new_sleep_data("aa", data))
async def setup_hass(hass: HomeAssistant) -> dict:
"""Configure home assistant."""
profiles = ["Person0", "Person1", "Person2", "Person3", "Person4"]
hass_config = {
"homeassistant": {CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC},
api.DOMAIN: {"base_url": "http://localhost/"},
http.DOMAIN: {"server_port": 8080},
const.DOMAIN: {
const.CLIENT_ID: "my_client_id",
const.CLIENT_SECRET: "my_client_secret",
const.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
WITHINGS_MEASURES_RESPONSE = withings.WithingsMeasures( 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]
with requests_mock.mock() as rqmck:
rqmck.get(
re.compile(AbstractWithingsApi.URL + "/v2/user?.*action=getdevice(&.*|$)"),
status_code=200,
json=get_device_response,
)
rqmck.get(
re.compile(AbstractWithingsApi.URL + "/v2/sleep?.*action=get(&.*|$)"),
status_code=200,
json=get_sleep_response,
)
rqmck.get(
re.compile(
AbstractWithingsApi.URL + "/v2/sleep?.*action=getsummary(&.*|$)"
),
status_code=200,
json=get_sleep_summary_response,
)
rqmck.get(
re.compile(AbstractWithingsApi.URL + "/measure?.*action=getmeas(&.*|$)"),
status_code=200,
json=getmeasures_response,
)
# Get the withings config flow.
result = await 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"]}
)
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://127.0.0.1:8080/auth/external/callback&"
f"state={state}"
"&scope=user.info,user.metrics,user.activity"
)
# 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}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
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",
},
)
# Present user with a list of profiles to choose from.
result = await 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
# Select the user profile.
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {const.PROFILE: selected_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("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 hass.async_block_till_done()
WITHINGS_GET_DEVICE_RESPONSE_EMPTY = {"status": 0, "body": {"devices": []}}
WITHINGS_GET_DEVICE_RESPONSE = {
"status": 0,
"body": {
"devices": [
{ {
"updatetime": "", "type": "type1",
"timezone": "", "model": "model1",
"battery": "battery1",
"deviceid": "deviceid1",
"timezone": "UTC",
}
]
},
}
WITHINGS_MEASURES_RESPONSE_EMPTY = {
"status": 0,
"body": {"updatetime": "2019-08-01", "timezone": "UTC", "measuregrps": []},
}
WITHINGS_MEASURES_RESPONSE = {
"status": 0,
"body": {
"updatetime": "2019-08-01",
"timezone": "UTC",
"measuregrps": [ "measuregrps": [
# Un-ambiguous groups. # Un-ambiguous groups.
new_measure_group(
1,
0,
time.time(),
time.time(),
1,
"DEV_ID",
False,
0,
[
new_measure(const.MEASURE_TYPE_WEIGHT, 70, 0),
new_measure(const.MEASURE_TYPE_FAT_MASS, 5, 0),
new_measure(const.MEASURE_TYPE_FAT_MASS_FREE, 60, 0),
new_measure(const.MEASURE_TYPE_MUSCLE_MASS, 50, 0),
new_measure(const.MEASURE_TYPE_BONE_MASS, 10, 0),
new_measure(const.MEASURE_TYPE_HEIGHT, 2, 0),
new_measure(const.MEASURE_TYPE_TEMP, 40, 0),
new_measure(const.MEASURE_TYPE_BODY_TEMP, 35, 0),
new_measure(const.MEASURE_TYPE_SKIN_TEMP, 20, 0),
new_measure(const.MEASURE_TYPE_FAT_RATIO, 70, -3),
new_measure(const.MEASURE_TYPE_DIASTOLIC_BP, 70, 0),
new_measure(const.MEASURE_TYPE_SYSTOLIC_BP, 100, 0),
new_measure(const.MEASURE_TYPE_HEART_PULSE, 60, 0),
new_measure(const.MEASURE_TYPE_SPO2, 95, -2),
new_measure(const.MEASURE_TYPE_HYDRATION, 95, -2),
new_measure(const.MEASURE_TYPE_PWV, 100, 0),
],
),
# Ambiguous groups (we ignore these)
new_measure_group(
1,
1,
time.time(),
time.time(),
1,
"DEV_ID",
False,
0,
[
new_measure(const.MEASURE_TYPE_WEIGHT, 71, 0),
new_measure(const.MEASURE_TYPE_FAT_MASS, 4, 0),
new_measure(const.MEASURE_TYPE_MUSCLE_MASS, 51, 0),
new_measure(const.MEASURE_TYPE_BONE_MASS, 11, 0),
new_measure(const.MEASURE_TYPE_HEIGHT, 201, 0),
new_measure(const.MEASURE_TYPE_TEMP, 41, 0),
new_measure(const.MEASURE_TYPE_BODY_TEMP, 34, 0),
new_measure(const.MEASURE_TYPE_SKIN_TEMP, 21, 0),
new_measure(const.MEASURE_TYPE_FAT_RATIO, 71, -3),
new_measure(const.MEASURE_TYPE_DIASTOLIC_BP, 71, 0),
new_measure(const.MEASURE_TYPE_SYSTOLIC_BP, 101, 0),
new_measure(const.MEASURE_TYPE_HEART_PULSE, 61, 0),
new_measure(const.MEASURE_TYPE_SPO2, 98, -2),
new_measure(const.MEASURE_TYPE_HYDRATION, 96, -2),
new_measure(const.MEASURE_TYPE_PWV, 102, 0),
],
),
],
}
)
WITHINGS_SLEEP_RESPONSE = withings_sleep_response(
[
const.MEASURE_TYPE_SLEEP_STATE_AWAKE,
const.MEASURE_TYPE_SLEEP_STATE_LIGHT,
const.MEASURE_TYPE_SLEEP_STATE_REM,
const.MEASURE_TYPE_SLEEP_STATE_DEEP,
]
)
WITHINGS_SLEEP_SUMMARY_RESPONSE = withings.WithingsSleepSummary(
{ {
"series": [ "grpid": 1,
new_sleep_summary( "attrib": MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER.real,
"UTC", "date": time.time(),
32, "created": time.time(),
"2019-02-01", "category": MeasureGetMeasGroupCategory.REAL.real,
"2019-02-02", "deviceid": "DEV_ID",
"2019-02-02", "more": False,
"12345", "offset": 0,
new_sleep_summary_detail( "measures": [
110, 210, 310, 410, 510, 610, 710, 810, 910, 1010, 1110, 1210, 1310 {"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},
new_sleep_summary( {"type": MeasureType.MUSCLE_MASS, "value": 50, "unit": 0},
"UTC", {"type": MeasureType.BONE_MASS, "value": 10, "unit": 0},
32, {"type": MeasureType.HEIGHT, "value": 2, "unit": 0},
"2019-02-01", {"type": MeasureType.TEMPERATURE, "value": 40, "unit": 0},
"2019-02-02", {"type": MeasureType.BODY_TEMPERATURE, "value": 40, "unit": 0},
"2019-02-02", {"type": MeasureType.SKIN_TEMPERATURE, "value": 20, "unit": 0},
"12345", {"type": MeasureType.FAT_RATIO, "value": 70, "unit": -3},
new_sleep_summary_detail( {
210, 310, 410, 510, 610, 710, 810, 910, 1010, 1110, 1210, 1310, 1410 "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,
},
},
],
},
} }
)

View File

@ -1,350 +0,0 @@
"""Fixtures for withings tests."""
import time
from typing import Awaitable, Callable, List
import asynctest
import withings_api as withings
import pytest
import homeassistant.components.api as api
import homeassistant.components.http as http
import homeassistant.components.withings.const as const
from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN
from homeassistant.config import async_process_ha_core_config
from homeassistant.const import CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_METRIC
from homeassistant.setup import async_setup_component
from .common import (
WITHINGS_MEASURES_RESPONSE,
WITHINGS_SLEEP_RESPONSE,
WITHINGS_SLEEP_SUMMARY_RESPONSE,
)
class WithingsFactoryConfig:
"""Configuration for withings test fixture."""
PROFILE_1 = "Person 1"
PROFILE_2 = "Person 2"
def __init__(
self,
api_config: dict = None,
http_config: dict = None,
measures: List[str] = None,
unit_system: str = None,
throttle_interval: int = const.THROTTLE_INTERVAL,
withings_request_response="DATA",
withings_measures_response: withings.WithingsMeasures = WITHINGS_MEASURES_RESPONSE,
withings_sleep_response: withings.WithingsSleep = WITHINGS_SLEEP_RESPONSE,
withings_sleep_summary_response: withings.WithingsSleepSummary = WITHINGS_SLEEP_SUMMARY_RESPONSE,
) -> None:
"""Constructor."""
self._throttle_interval = throttle_interval
self._withings_request_response = withings_request_response
self._withings_measures_response = withings_measures_response
self._withings_sleep_response = withings_sleep_response
self._withings_sleep_summary_response = withings_sleep_summary_response
self._withings_config = {
const.CLIENT_ID: "my_client_id",
const.CLIENT_SECRET: "my_client_secret",
const.PROFILES: [
WithingsFactoryConfig.PROFILE_1,
WithingsFactoryConfig.PROFILE_2,
],
}
self._api_config = api_config or {"base_url": "http://localhost/"}
self._http_config = http_config or {}
self._measures = measures
assert self._withings_config, "withings_config must be set."
assert isinstance(
self._withings_config, dict
), "withings_config must be a dict."
assert isinstance(self._api_config, dict), "api_config must be a dict."
assert isinstance(self._http_config, dict), "http_config must be a dict."
self._hass_config = {
"homeassistant": {CONF_UNIT_SYSTEM: unit_system or CONF_UNIT_SYSTEM_METRIC},
api.DOMAIN: self._api_config,
http.DOMAIN: self._http_config,
DOMAIN: self._withings_config,
}
@property
def withings_config(self):
"""Get withings component config."""
return self._withings_config
@property
def api_config(self):
"""Get api component config."""
return self._api_config
@property
def http_config(self):
"""Get http component config."""
return self._http_config
@property
def measures(self):
"""Get the measures."""
return self._measures
@property
def hass_config(self):
"""Home assistant config."""
return self._hass_config
@property
def throttle_interval(self):
"""Throttle interval."""
return self._throttle_interval
@property
def withings_request_response(self):
"""Request response."""
return self._withings_request_response
@property
def withings_measures_response(self) -> withings.WithingsMeasures:
"""Measures response."""
return self._withings_measures_response
@property
def withings_sleep_response(self) -> withings.WithingsSleep:
"""Sleep response."""
return self._withings_sleep_response
@property
def withings_sleep_summary_response(self) -> withings.WithingsSleepSummary:
"""Sleep summary response."""
return self._withings_sleep_summary_response
class WithingsFactoryData:
"""Data about the configured withing test component."""
def __init__(
self,
hass,
flow_id,
withings_auth_get_credentials_mock,
withings_api_request_mock,
withings_api_get_measures_mock,
withings_api_get_sleep_mock,
withings_api_get_sleep_summary_mock,
data_manager_get_throttle_interval_mock,
):
"""Constructor."""
self._hass = hass
self._flow_id = flow_id
self._withings_auth_get_credentials_mock = withings_auth_get_credentials_mock
self._withings_api_request_mock = withings_api_request_mock
self._withings_api_get_measures_mock = withings_api_get_measures_mock
self._withings_api_get_sleep_mock = withings_api_get_sleep_mock
self._withings_api_get_sleep_summary_mock = withings_api_get_sleep_summary_mock
self._data_manager_get_throttle_interval_mock = (
data_manager_get_throttle_interval_mock
)
@property
def hass(self):
"""Get hass instance."""
return self._hass
@property
def flow_id(self):
"""Get flow id."""
return self._flow_id
@property
def withings_auth_get_credentials_mock(self):
"""Get auth credentials mock."""
return self._withings_auth_get_credentials_mock
@property
def withings_api_request_mock(self):
"""Get request mock."""
return self._withings_api_request_mock
@property
def withings_api_get_measures_mock(self):
"""Get measures mock."""
return self._withings_api_get_measures_mock
@property
def withings_api_get_sleep_mock(self):
"""Get sleep mock."""
return self._withings_api_get_sleep_mock
@property
def withings_api_get_sleep_summary_mock(self):
"""Get sleep summary mock."""
return self._withings_api_get_sleep_summary_mock
@property
def data_manager_get_throttle_interval_mock(self):
"""Get throttle mock."""
return self._data_manager_get_throttle_interval_mock
async def configure_user(self):
"""Present a form with user profiles."""
step = await self.hass.config_entries.flow.async_configure(self.flow_id, None)
assert step["step_id"] == "user"
async def configure_profile(self, profile: str):
"""Select the user profile. Present a form with authorization link."""
print("CONFIG_PROFILE:", profile)
step = await self.hass.config_entries.flow.async_configure(
self.flow_id, {const.PROFILE: profile}
)
assert step["step_id"] == "auth"
async def configure_code(self, profile: str, code: str):
"""Handle authorization code. Create config entries."""
step = await self.hass.config_entries.flow.async_configure(
self.flow_id, {const.PROFILE: profile, const.CODE: code}
)
assert step["type"] == "external_done"
await self.hass.async_block_till_done()
step = await self.hass.config_entries.flow.async_configure(
self.flow_id, {const.PROFILE: profile, const.CODE: code}
)
assert step["type"] == "create_entry"
await self.hass.async_block_till_done()
async def configure_all(self, profile: str, code: str):
"""Configure all flow steps."""
await self.configure_user()
await self.configure_profile(profile)
await self.configure_code(profile, code)
WithingsFactory = Callable[[WithingsFactoryConfig], Awaitable[WithingsFactoryData]]
@pytest.fixture(name="withings_factory")
def withings_factory_fixture(request, hass) -> WithingsFactory:
"""Home assistant platform fixture."""
patches = []
async def factory(config: WithingsFactoryConfig) -> WithingsFactoryData:
CONFIG_SCHEMA(config.hass_config.get(DOMAIN))
await async_process_ha_core_config(
hass, config.hass_config.get("homeassistant")
)
assert await async_setup_component(hass, http.DOMAIN, config.hass_config)
assert await async_setup_component(hass, api.DOMAIN, config.hass_config)
withings_auth_get_credentials_patch = asynctest.patch(
"withings_api.WithingsAuth.get_credentials",
return_value=withings.WithingsCredentials(
access_token="my_access_token",
token_expiry=time.time() + 600,
token_type="my_token_type",
refresh_token="my_refresh_token",
user_id="my_user_id",
client_id=config.withings_config.get(const.CLIENT_ID),
consumer_secret=config.withings_config.get(const.CLIENT_SECRET),
),
)
withings_auth_get_credentials_mock = withings_auth_get_credentials_patch.start()
withings_api_request_patch = asynctest.patch(
"withings_api.WithingsApi.request",
return_value=config.withings_request_response,
)
withings_api_request_mock = withings_api_request_patch.start()
withings_api_get_measures_patch = asynctest.patch(
"withings_api.WithingsApi.get_measures",
return_value=config.withings_measures_response,
)
withings_api_get_measures_mock = withings_api_get_measures_patch.start()
withings_api_get_sleep_patch = asynctest.patch(
"withings_api.WithingsApi.get_sleep",
return_value=config.withings_sleep_response,
)
withings_api_get_sleep_mock = withings_api_get_sleep_patch.start()
withings_api_get_sleep_summary_patch = asynctest.patch(
"withings_api.WithingsApi.get_sleep_summary",
return_value=config.withings_sleep_summary_response,
)
withings_api_get_sleep_summary_mock = (
withings_api_get_sleep_summary_patch.start()
)
data_manager_get_throttle_interval_patch = asynctest.patch(
"homeassistant.components.withings.common.WithingsDataManager"
".get_throttle_interval",
return_value=config.throttle_interval,
)
data_manager_get_throttle_interval_mock = (
data_manager_get_throttle_interval_patch.start()
)
get_measures_patch = asynctest.patch(
"homeassistant.components.withings.sensor.get_measures",
return_value=config.measures,
)
get_measures_patch.start()
patches.extend(
[
withings_auth_get_credentials_patch,
withings_api_request_patch,
withings_api_get_measures_patch,
withings_api_get_sleep_patch,
withings_api_get_sleep_summary_patch,
data_manager_get_throttle_interval_patch,
get_measures_patch,
]
)
# Collect the flow id.
tasks = []
orig_async_create_task = hass.async_create_task
def create_task(*args):
task = orig_async_create_task(*args)
tasks.append(task)
return task
async_create_task_patch = asynctest.patch.object(
hass, "async_create_task", side_effect=create_task
)
with async_create_task_patch:
assert await async_setup_component(hass, DOMAIN, config.hass_config)
await hass.async_block_till_done()
flow_id = tasks[2].result()["flow_id"]
return WithingsFactoryData(
hass,
flow_id,
withings_auth_get_credentials_mock,
withings_api_request_mock,
withings_api_get_measures_mock,
withings_api_get_sleep_mock,
withings_api_get_sleep_summary_mock,
data_manager_get_throttle_interval_mock,
)
def cleanup():
for patch in patches:
patch.stop()
request.addfinalizer(cleanup)
return factory

View File

@ -1,34 +1,33 @@
"""Tests for the Withings component.""" """Tests for the Withings component."""
from asynctest import MagicMock from asynctest import MagicMock
import withings_api as withings
from oauthlib.oauth2.rfc6749.errors import MissingTokenError
import pytest
from requests_oauthlib import TokenUpdated
import pytest
from withings_api import WithingsApi
from withings_api.common import UnauthorizedException, TimeoutException
from homeassistant.exceptions import PlatformNotReady
from homeassistant.components.withings.common import ( from homeassistant.components.withings.common import (
NotAuthenticatedError, NotAuthenticatedError,
ServiceError,
WithingsDataManager, WithingsDataManager,
) )
from homeassistant.exceptions import PlatformNotReady
@pytest.fixture(name="withings_api") @pytest.fixture(name="withings_api")
def withings_api_fixture(): def withings_api_fixture() -> WithingsApi:
"""Provide withings api.""" """Provide withings api."""
withings_api = withings.WithingsApi.__new__(withings.WithingsApi) withings_api = WithingsApi.__new__(WithingsApi)
withings_api.get_measures = MagicMock() withings_api.get_measures = MagicMock()
withings_api.get_sleep = MagicMock() withings_api.get_sleep = MagicMock()
return withings_api return withings_api
@pytest.fixture(name="data_manager") @pytest.fixture(name="data_manager")
def data_manager_fixture(hass, withings_api: withings.WithingsApi): def data_manager_fixture(hass, withings_api: WithingsApi) -> WithingsDataManager:
"""Provide data manager.""" """Provide data manager."""
return WithingsDataManager(hass, "My Profile", withings_api) return WithingsDataManager(hass, "My Profile", withings_api)
def test_print_service(): def test_print_service() -> None:
"""Test method.""" """Test method."""
# Go from None to True # Go from None to True
WithingsDataManager.service_available = None WithingsDataManager.service_available = None
@ -57,54 +56,27 @@ def test_print_service():
assert not WithingsDataManager.print_service_unavailable() assert not WithingsDataManager.print_service_unavailable()
async def test_data_manager_call(data_manager): async def test_data_manager_call(data_manager: WithingsDataManager) -> None:
"""Test method.""" """Test method."""
# Token refreshed.
def hello_func():
return "HELLO2"
function = MagicMock(side_effect=[TokenUpdated("my_token"), hello_func()])
result = await data_manager.call(function)
assert result == "HELLO2"
assert function.call_count == 2
# Too many token refreshes.
function = MagicMock(
side_effect=[TokenUpdated("my_token"), TokenUpdated("my_token")]
)
try:
result = await data_manager.call(function)
assert False, "This should not have ran."
except ServiceError:
assert True
assert function.call_count == 2
# Not authenticated 1. # Not authenticated 1.
test_function = MagicMock(side_effect=MissingTokenError("Error Code 401")) test_function = MagicMock(side_effect=UnauthorizedException(401))
try: with pytest.raises(NotAuthenticatedError):
result = await data_manager.call(test_function) await data_manager.call(test_function)
assert False, "An exception should have been thrown."
except NotAuthenticatedError:
assert True
# Not authenticated 2. # Not authenticated 2.
test_function = MagicMock(side_effect=Exception("Error Code 401")) test_function = MagicMock(side_effect=TimeoutException(522))
try: with pytest.raises(PlatformNotReady):
result = await data_manager.call(test_function) await data_manager.call(test_function)
assert False, "An exception should have been thrown."
except NotAuthenticatedError:
assert True
# Service error. # Service error.
test_function = MagicMock(side_effect=PlatformNotReady()) test_function = MagicMock(side_effect=PlatformNotReady())
try: with pytest.raises(PlatformNotReady):
result = await data_manager.call(test_function) await data_manager.call(test_function)
assert False, "An exception should have been thrown."
except PlatformNotReady:
assert True
async def test_data_manager_call_throttle_enabled(data_manager): async def test_data_manager_call_throttle_enabled(
data_manager: WithingsDataManager
) -> None:
"""Test method.""" """Test method."""
hello_func = MagicMock(return_value="HELLO2") hello_func = MagicMock(return_value="HELLO2")
@ -117,7 +89,9 @@ async def test_data_manager_call_throttle_enabled(data_manager):
assert hello_func.call_count == 1 assert hello_func.call_count == 1
async def test_data_manager_call_throttle_disabled(data_manager): async def test_data_manager_call_throttle_disabled(
data_manager: WithingsDataManager
) -> None:
"""Test method.""" """Test method."""
hello_func = MagicMock(return_value="HELLO2") hello_func = MagicMock(return_value="HELLO2")

View File

@ -1,162 +0,0 @@
"""Tests for the Withings config flow."""
from aiohttp.web_request import BaseRequest
from asynctest import CoroutineMock, MagicMock
import pytest
from homeassistant import data_entry_flow
from homeassistant.components.withings import const
from homeassistant.components.withings.config_flow import (
register_flow_implementation,
WithingsFlowHandler,
WithingsAuthCallbackView,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import HomeAssistantType
@pytest.fixture(name="flow_handler")
def flow_handler_fixture(hass: HomeAssistantType):
"""Provide flow handler."""
flow_handler = WithingsFlowHandler()
flow_handler.hass = hass
return flow_handler
def test_flow_handler_init(flow_handler: WithingsFlowHandler):
"""Test the init of the flow handler."""
assert not flow_handler.flow_profile
def test_flow_handler_async_profile_config_entry(
hass: HomeAssistantType, flow_handler: WithingsFlowHandler
):
"""Test profile config entry."""
config_entries = [
ConfigEntry(
version=1,
domain=const.DOMAIN,
title="AAA",
data={},
source="source",
connection_class="connection_class",
system_options={},
),
ConfigEntry(
version=1,
domain=const.DOMAIN,
title="Person 1",
data={const.PROFILE: "Person 1"},
source="source",
connection_class="connection_class",
system_options={},
),
ConfigEntry(
version=1,
domain=const.DOMAIN,
title="BBB",
data={},
source="source",
connection_class="connection_class",
system_options={},
),
]
hass.config_entries.async_entries = MagicMock(return_value=config_entries)
config_entry = flow_handler.async_profile_config_entry
assert not config_entry("GGGG")
hass.config_entries.async_entries.assert_called_with(const.DOMAIN)
assert not config_entry("CCC")
hass.config_entries.async_entries.assert_called_with(const.DOMAIN)
assert config_entry("Person 1") == config_entries[1]
hass.config_entries.async_entries.assert_called_with(const.DOMAIN)
def test_flow_handler_get_auth_client(
hass: HomeAssistantType, flow_handler: WithingsFlowHandler
):
"""Test creation of an auth client."""
register_flow_implementation(
hass, "my_client_id", "my_client_secret", "http://localhost/", ["Person 1"]
)
client = flow_handler.get_auth_client("Person 1")
assert client.client_id == "my_client_id"
assert client.consumer_secret == "my_client_secret"
assert client.callback_uri.startswith(
"http://localhost/api/withings/authorize?flow_id="
)
assert client.callback_uri.endswith("&profile=Person 1")
assert client.scope == "user.info,user.metrics,user.activity"
async def test_auth_callback_view_get(hass: HomeAssistantType):
"""Test get api path."""
view = WithingsAuthCallbackView()
hass.config_entries.flow.async_configure = CoroutineMock(return_value="AAAA")
request = MagicMock(spec=BaseRequest)
request.app = {"hass": hass}
# No args
request.query = {}
response = await view.get(request)
assert response.status == 400
hass.config_entries.flow.async_configure.assert_not_called()
hass.config_entries.flow.async_configure.reset_mock()
# Checking flow_id
request.query = {"flow_id": "my_flow_id"}
response = await view.get(request)
assert response.status == 400
hass.config_entries.flow.async_configure.assert_not_called()
hass.config_entries.flow.async_configure.reset_mock()
# Checking flow_id and profile
request.query = {"flow_id": "my_flow_id", "profile": "my_profile"}
response = await view.get(request)
assert response.status == 400
hass.config_entries.flow.async_configure.assert_not_called()
hass.config_entries.flow.async_configure.reset_mock()
# Checking flow_id, profile, code
request.query = {
"flow_id": "my_flow_id",
"profile": "my_profile",
"code": "my_code",
}
response = await view.get(request)
assert response.status == 200
hass.config_entries.flow.async_configure.assert_called_with(
"my_flow_id", {const.PROFILE: "my_profile", const.CODE: "my_code"}
)
hass.config_entries.flow.async_configure.reset_mock()
# Exception thrown
hass.config_entries.flow.async_configure = CoroutineMock(
side_effect=data_entry_flow.UnknownFlow()
)
request.query = {
"flow_id": "my_flow_id",
"profile": "my_profile",
"code": "my_code",
}
response = await view.get(request)
assert response.status == 400
hass.config_entries.flow.async_configure.assert_called_with(
"my_flow_id", {const.PROFILE: "my_profile", const.CODE: "my_code"}
)
hass.config_entries.flow.async_configure.reset_mock()
async def test_init_without_config(hass):
"""Try initializin a configg flow without it being configured."""
result = await hass.config_entries.flow.async_init(
"withings", context={"source": "user"}
)
assert result["type"] == "abort"
assert result["reason"] == "no_flows"

View File

@ -1,29 +1,46 @@
"""Tests for the Withings component.""" """Tests for the Withings component."""
import re
import time
from asynctest import MagicMock from asynctest import MagicMock
import requests_mock
import voluptuous as vol import voluptuous as vol
from withings_api import AbstractWithingsApi
from withings_api.common import SleepModel, SleepState
import homeassistant.components.api as api
import homeassistant.components.http as http import homeassistant.components.http as http
from homeassistant.components.withings import async_setup, const, CONFIG_SCHEMA from homeassistant.components.withings import (
async_setup,
async_setup_entry,
const,
CONFIG_SCHEMA,
)
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from .conftest import WithingsFactory, WithingsFactoryConfig from .common import (
assert_state_equals,
BASE_HASS_CONFIG = { configure_integration,
http.DOMAIN: {}, setup_hass,
api.DOMAIN: {"base_url": "http://localhost/"}, WITHINGS_GET_DEVICE_RESPONSE,
const.DOMAIN: None, WITHINGS_GET_DEVICE_RESPONSE_EMPTY,
} WITHINGS_SLEEP_RESPONSE,
WITHINGS_SLEEP_RESPONSE_EMPTY,
WITHINGS_SLEEP_SUMMARY_RESPONSE,
WITHINGS_SLEEP_SUMMARY_RESPONSE_EMPTY,
WITHINGS_MEASURES_RESPONSE,
WITHINGS_MEASURES_RESPONSE_EMPTY,
)
def config_schema_validate(withings_config): def config_schema_validate(withings_config) -> None:
"""Assert a schema config succeeds.""" """Assert a schema config succeeds."""
hass_config = BASE_HASS_CONFIG.copy() hass_config = {http.DOMAIN: {}, const.DOMAIN: withings_config}
hass_config[const.DOMAIN] = withings_config
return CONFIG_SCHEMA(hass_config) return CONFIG_SCHEMA(hass_config)
def config_schema_assert_fail(withings_config): def config_schema_assert_fail(withings_config) -> None:
"""Assert a schema config will fail.""" """Assert a schema config will fail."""
try: try:
config_schema_validate(withings_config) config_schema_validate(withings_config)
@ -32,7 +49,7 @@ def config_schema_assert_fail(withings_config):
assert True assert True
def test_config_schema_basic_config(): def test_config_schema_basic_config() -> None:
"""Test schema.""" """Test schema."""
config_schema_validate( config_schema_validate(
{ {
@ -43,7 +60,7 @@ def test_config_schema_basic_config():
) )
def test_config_schema_client_id(): def test_config_schema_client_id() -> None:
"""Test schema.""" """Test schema."""
config_schema_assert_fail( config_schema_assert_fail(
{ {
@ -67,7 +84,7 @@ def test_config_schema_client_id():
) )
def test_config_schema_client_secret(): def test_config_schema_client_secret() -> None:
"""Test schema.""" """Test schema."""
config_schema_assert_fail( config_schema_assert_fail(
{const.CLIENT_ID: "my_client_id", const.PROFILES: ["Person 1"]} {const.CLIENT_ID: "my_client_id", const.PROFILES: ["Person 1"]}
@ -88,7 +105,7 @@ def test_config_schema_client_secret():
) )
def test_config_schema_profiles(): def test_config_schema_profiles() -> None:
"""Test schema.""" """Test schema."""
config_schema_assert_fail( config_schema_assert_fail(
{const.CLIENT_ID: "my_client_id", const.CLIENT_SECRET: "my_client_secret"} {const.CLIENT_ID: "my_client_id", const.CLIENT_SECRET: "my_client_secret"}
@ -130,50 +147,7 @@ def test_config_schema_profiles():
) )
def test_config_schema_base_url(): async def test_async_setup_no_config(hass: HomeAssistant) -> None:
"""Test schema."""
config_schema_validate(
{
const.CLIENT_ID: "my_client_id",
const.CLIENT_SECRET: "my_client_secret",
const.PROFILES: ["Person 1"],
}
)
config_schema_assert_fail(
{
const.CLIENT_ID: "my_client_id",
const.CLIENT_SECRET: "my_client_secret",
const.BASE_URL: 123,
const.PROFILES: ["Person 1"],
}
)
config_schema_assert_fail(
{
const.CLIENT_ID: "my_client_id",
const.CLIENT_SECRET: "my_client_secret",
const.BASE_URL: "",
const.PROFILES: ["Person 1"],
}
)
config_schema_assert_fail(
{
const.CLIENT_ID: "my_client_id",
const.CLIENT_SECRET: "my_client_secret",
const.BASE_URL: "blah blah",
const.PROFILES: ["Person 1"],
}
)
config_schema_validate(
{
const.CLIENT_ID: "my_client_id",
const.CLIENT_SECRET: "my_client_secret",
const.BASE_URL: "https://www.blah.blah.blah/blah/blah",
const.PROFILES: ["Person 1"],
}
)
async def test_async_setup_no_config(hass):
"""Test method.""" """Test method."""
hass.async_create_task = MagicMock() hass.async_create_task = MagicMock()
@ -182,15 +156,258 @@ async def test_async_setup_no_config(hass):
hass.async_create_task.assert_not_called() hass.async_create_task.assert_not_called()
async def test_async_setup_teardown(withings_factory: WithingsFactory, hass): async def test_upgrade_token(
"""Test method.""" hass: HomeAssistant, aiohttp_client, aioclient_mock
data = await withings_factory(WithingsFactoryConfig(measures=[const.MEAS_TEMP_C])) ) -> None:
"""Test upgrading from old config data format to new one."""
config = await setup_hass(hass)
profiles = config[const.DOMAIN][const.PROFILES]
profile = WithingsFactoryConfig.PROFILE_1 await configure_integration(
await data.configure_all(profile, "authorization_code") 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) entries = hass.config_entries.async_entries(const.DOMAIN)
assert entries 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"),
"client_id": token.get("my_client_id"),
"consumer_secret": token.get("my_consumer_secret"),
},
},
)
with requests_mock.mock() as rqmck:
rqmck.get(
re.compile(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("client_id")
assert not token.get("consumer_secret")
async def test_auth_failure(
hass: HomeAssistant, aiohttp_client, aioclient_mock
) -> None:
"""Test auth failure."""
config = await setup_hass(hass)
profiles = config[const.DOMAIN][const.PROFILES]
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]
hass.config_entries.async_update_entry(
entry, data={**entry.data, **{"new_item": 1}}
)
with requests_mock.mock() as rqmck:
rqmck.get(
re.compile(AbstractWithingsApi.URL + "/v2/user?.*action=getdevice(&.*|$)"),
status_code=200,
json={"status": 401, "body": {}},
)
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.PROFILES]
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.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 00:00:00",
"enddate": "2019-02-01 01:00:00",
"state": SleepState.LIGHT.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.REM.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[0], const.MEAS_SLEEP_STATE, const.STATE_DEEP),
(profiles[1], const.MEAS_SLEEP_STATE, STATE_UNKNOWN),
(profiles[1], const.MEAS_HYDRATION, STATE_UNKNOWN),
(profiles[2], const.MEAS_SLEEP_STATE, const.STATE_AWAKE),
(profiles[3], const.MEAS_SLEEP_STATE, const.STATE_LIGHT),
(profiles[3], const.MEAS_FAT_FREE_MASS_KG, STATE_UNKNOWN),
(profiles[4], const.MEAS_SLEEP_STATE, const.STATE_REM),
)
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: for entry in entries:
await hass.config_entries.async_unload(entry.entry_id) await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()

View File

@ -1,310 +0,0 @@
"""Tests for the Withings component."""
from unittest.mock import MagicMock, patch
import asynctest
from withings_api import (
WithingsApi,
WithingsMeasures,
WithingsSleep,
WithingsSleepSummary,
)
import pytest
from homeassistant.components.withings import DOMAIN
from homeassistant.components.withings.common import NotAuthenticatedError
import homeassistant.components.withings.const as const
from homeassistant.components.withings.sensor import async_setup_entry
from homeassistant.config_entries import ConfigEntry, SOURCE_USER
from homeassistant.const import STATE_UNKNOWN
from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util import slugify
from .common import withings_sleep_response
from .conftest import WithingsFactory, WithingsFactoryConfig
def get_entity_id(measure, profile):
"""Get an entity id for a measure and profile."""
return "sensor.{}_{}_{}".format(DOMAIN, measure, slugify(profile))
def assert_state_equals(hass: HomeAssistantType, profile: str, measure: str, expected):
"""Assert the state of a withings sensor."""
entity_id = get_entity_id(measure, profile)
state_obj = hass.states.get(entity_id)
assert state_obj, "Expected entity {} to exist but it did not".format(entity_id)
assert state_obj.state == str(
expected
), "Expected {} but was {} for measure {}".format(
expected, state_obj.state, measure
)
async def test_health_sensor_properties(withings_factory: WithingsFactory):
"""Test method."""
data = await withings_factory(WithingsFactoryConfig(measures=[const.MEAS_HEIGHT_M]))
await data.configure_all(WithingsFactoryConfig.PROFILE_1, "authorization_code")
state = data.hass.states.get("sensor.withings_height_m_person_1")
state_dict = state.as_dict()
assert state_dict.get("state") == "2"
assert state_dict.get("attributes") == {
"measurement": "height_m",
"measure_type": 4,
"friendly_name": "Withings height_m person_1",
"unit_of_measurement": "m",
"icon": "mdi:ruler",
}
SENSOR_TEST_DATA = [
(const.MEAS_WEIGHT_KG, 70),
(const.MEAS_FAT_MASS_KG, 5),
(const.MEAS_FAT_FREE_MASS_KG, 60),
(const.MEAS_MUSCLE_MASS_KG, 50),
(const.MEAS_BONE_MASS_KG, 10),
(const.MEAS_HEIGHT_M, 2),
(const.MEAS_FAT_RATIO_PCT, 0.07),
(const.MEAS_DIASTOLIC_MMHG, 70),
(const.MEAS_SYSTOLIC_MMGH, 100),
(const.MEAS_HEART_PULSE_BPM, 60),
(const.MEAS_SPO2_PCT, 0.95),
(const.MEAS_HYDRATION, 0.95),
(const.MEAS_PWV, 100),
(const.MEAS_SLEEP_WAKEUP_DURATION_SECONDS, 320),
(const.MEAS_SLEEP_LIGHT_DURATION_SECONDS, 520),
(const.MEAS_SLEEP_DEEP_DURATION_SECONDS, 720),
(const.MEAS_SLEEP_REM_DURATION_SECONDS, 920),
(const.MEAS_SLEEP_WAKEUP_COUNT, 1120),
(const.MEAS_SLEEP_TOSLEEP_DURATION_SECONDS, 1320),
(const.MEAS_SLEEP_TOWAKEUP_DURATION_SECONDS, 1520),
(const.MEAS_SLEEP_HEART_RATE_AVERAGE, 1720),
(const.MEAS_SLEEP_HEART_RATE_MIN, 1920),
(const.MEAS_SLEEP_HEART_RATE_MAX, 2120),
(const.MEAS_SLEEP_RESPIRATORY_RATE_AVERAGE, 2320),
(const.MEAS_SLEEP_RESPIRATORY_RATE_MIN, 2520),
(const.MEAS_SLEEP_RESPIRATORY_RATE_MAX, 2720),
]
@pytest.mark.parametrize("measure,expected", SENSOR_TEST_DATA)
async def test_health_sensor_throttled(
withings_factory: WithingsFactory, measure, expected
):
"""Test method."""
data = await withings_factory(WithingsFactoryConfig(measures=measure))
profile = WithingsFactoryConfig.PROFILE_1
await data.configure_all(profile, "authorization_code")
# Checking initial data.
assert_state_equals(data.hass, profile, measure, expected)
# Encountering a throttled data.
await async_update_entity(data.hass, get_entity_id(measure, profile))
assert_state_equals(data.hass, profile, measure, expected)
NONE_SENSOR_TEST_DATA = [
(const.MEAS_WEIGHT_KG, STATE_UNKNOWN),
(const.MEAS_SLEEP_STATE, STATE_UNKNOWN),
(const.MEAS_SLEEP_RESPIRATORY_RATE_MAX, STATE_UNKNOWN),
]
@pytest.mark.parametrize("measure,expected", NONE_SENSOR_TEST_DATA)
async def test_health_sensor_state_none(
withings_factory: WithingsFactory, measure, expected
):
"""Test method."""
data = await withings_factory(
WithingsFactoryConfig(
measures=measure,
withings_measures_response=None,
withings_sleep_response=None,
withings_sleep_summary_response=None,
)
)
profile = WithingsFactoryConfig.PROFILE_1
await data.configure_all(profile, "authorization_code")
# Checking initial data.
assert_state_equals(data.hass, profile, measure, expected)
# Encountering a throttled data.
await async_update_entity(data.hass, get_entity_id(measure, profile))
assert_state_equals(data.hass, profile, measure, expected)
EMPTY_SENSOR_TEST_DATA = [
(const.MEAS_WEIGHT_KG, STATE_UNKNOWN),
(const.MEAS_SLEEP_STATE, STATE_UNKNOWN),
(const.MEAS_SLEEP_RESPIRATORY_RATE_MAX, STATE_UNKNOWN),
]
@pytest.mark.parametrize("measure,expected", EMPTY_SENSOR_TEST_DATA)
async def test_health_sensor_state_empty(
withings_factory: WithingsFactory, measure, expected
):
"""Test method."""
data = await withings_factory(
WithingsFactoryConfig(
measures=measure,
withings_measures_response=WithingsMeasures({"measuregrps": []}),
withings_sleep_response=WithingsSleep({"series": []}),
withings_sleep_summary_response=WithingsSleepSummary({"series": []}),
)
)
profile = WithingsFactoryConfig.PROFILE_1
await data.configure_all(profile, "authorization_code")
# Checking initial data.
assert_state_equals(data.hass, profile, measure, expected)
# Encountering a throttled data.
await async_update_entity(data.hass, get_entity_id(measure, profile))
assert_state_equals(data.hass, profile, measure, expected)
SLEEP_STATES_TEST_DATA = [
(
const.STATE_AWAKE,
[const.MEASURE_TYPE_SLEEP_STATE_DEEP, const.MEASURE_TYPE_SLEEP_STATE_AWAKE],
),
(
const.STATE_LIGHT,
[const.MEASURE_TYPE_SLEEP_STATE_DEEP, const.MEASURE_TYPE_SLEEP_STATE_LIGHT],
),
(
const.STATE_REM,
[const.MEASURE_TYPE_SLEEP_STATE_DEEP, const.MEASURE_TYPE_SLEEP_STATE_REM],
),
(
const.STATE_DEEP,
[const.MEASURE_TYPE_SLEEP_STATE_LIGHT, const.MEASURE_TYPE_SLEEP_STATE_DEEP],
),
(const.STATE_UNKNOWN, [const.MEASURE_TYPE_SLEEP_STATE_LIGHT, "blah,"]),
]
@pytest.mark.parametrize("expected,sleep_states", SLEEP_STATES_TEST_DATA)
async def test_sleep_state_throttled(
withings_factory: WithingsFactory, expected, sleep_states
):
"""Test method."""
measure = const.MEAS_SLEEP_STATE
data = await withings_factory(
WithingsFactoryConfig(
measures=[measure],
withings_sleep_response=withings_sleep_response(sleep_states),
)
)
profile = WithingsFactoryConfig.PROFILE_1
await data.configure_all(profile, "authorization_code")
# Check initial data.
assert_state_equals(data.hass, profile, measure, expected)
# Encountering a throttled data.
await async_update_entity(data.hass, get_entity_id(measure, profile))
assert_state_equals(data.hass, profile, measure, expected)
async def test_async_setup_check_credentials(
hass: HomeAssistantType, withings_factory: WithingsFactory
):
"""Test method."""
check_creds_patch = asynctest.patch(
"homeassistant.components.withings.common.WithingsDataManager"
".check_authenticated",
side_effect=NotAuthenticatedError(),
)
async_init_patch = asynctest.patch.object(
hass.config_entries.flow,
"async_init",
wraps=hass.config_entries.flow.async_init,
)
with check_creds_patch, async_init_patch as async_init_mock:
data = await withings_factory(
WithingsFactoryConfig(measures=[const.MEAS_HEIGHT_M])
)
profile = WithingsFactoryConfig.PROFILE_1
await data.configure_all(profile, "authorization_code")
async_init_mock.assert_called_with(
const.DOMAIN,
context={"source": SOURCE_USER, const.PROFILE: profile},
data={},
)
async def test_async_setup_entry_credentials_saver(hass: HomeAssistantType):
"""Test method."""
expected_creds = {
"access_token": "my_access_token2",
"refresh_token": "my_refresh_token2",
"token_type": "my_token_type2",
"expires_in": "2",
}
original_withings_api = WithingsApi
withings_api_instance = None
def new_withings_api(*args, **kwargs):
nonlocal withings_api_instance
withings_api_instance = original_withings_api(*args, **kwargs)
withings_api_instance.request = MagicMock()
return withings_api_instance
withings_api_patch = patch("withings_api.WithingsApi", side_effect=new_withings_api)
session_patch = patch("requests_oauthlib.OAuth2Session")
client_patch = patch("oauthlib.oauth2.WebApplicationClient")
update_entry_patch = patch.object(
hass.config_entries,
"async_update_entry",
wraps=hass.config_entries.async_update_entry,
)
with session_patch, client_patch, withings_api_patch, update_entry_patch:
async_add_entities = MagicMock()
hass.config_entries.async_update_entry = MagicMock()
config_entry = ConfigEntry(
version=1,
domain=const.DOMAIN,
title="my title",
data={
const.PROFILE: "Person 1",
const.CREDENTIALS: {
"access_token": "my_access_token",
"refresh_token": "my_refresh_token",
"token_type": "my_token_type",
"token_expiry": "9999999999",
},
},
source="source",
connection_class="conn_class",
system_options={},
)
await async_setup_entry(hass, config_entry, async_add_entities)
withings_api_instance.set_token(expected_creds)
new_creds = config_entry.data[const.CREDENTIALS]
assert new_creds["access_token"] == "my_access_token2"