Add Withings webhooks (#34447)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Robert Van Gorkom 2020-06-16 11:16:18 -07:00 committed by GitHub
parent 29df13abe9
commit a6a6a7b69c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 2201 additions and 1455 deletions

View file

@ -3,22 +3,39 @@ Support for the Withings API.
For more details about this platform, please refer to the documentation at
"""
import asyncio
from typing import Optional, cast
from aiohttp.web import Request, Response
import voluptuous as vol
from withings_api import WithingsAuth
from withings_api.common import NotifyAppli, enum_or_raise
from homeassistant.components import webhook
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.webhook import (
async_unregister as async_unregister_webhook,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType
from . import config_flow
from . import config_flow, const
from .common import (
_LOGGER,
NotAuthenticatedError,
WithingsLocalOAuth2Implementation,
get_data_manager,
async_get_data_manager,
async_remove_data_manager,
get_data_manager_by_webhook_id,
json_message_response,
)
from .const import CONF_PROFILES, CONFIG, CREDENTIALS, DOMAIN
DOMAIN = const.DOMAIN
CONFIG_SCHEMA = vol.Schema(
{
@ -26,7 +43,8 @@ CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_CLIENT_ID): vol.All(cv.string, vol.Length(min=1)),
vol.Required(CONF_CLIENT_SECRET): vol.All(cv.string, vol.Length(min=1)),
vol.Required(CONF_PROFILES): vol.All(
vol.Optional(const.CONF_USE_WEBHOOK, default=False): cv.boolean,
vol.Required(const.CONF_PROFILES): vol.All(
cv.ensure_list,
vol.Unique(),
vol.Length(min=1),
@ -39,19 +57,21 @@ CONFIG_SCHEMA = vol.Schema(
)
async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Withings component."""
conf = config.get(DOMAIN, {})
if not conf:
return True
hass.data[DOMAIN] = {CONFIG: conf}
# Make the config available to the oauth2 config flow.
hass.data[DOMAIN] = {const.CONFIG: conf}
# Setup the oauth2 config flow.
config_flow.WithingsFlowHandler.async_register_implementation(
hass,
WithingsLocalOAuth2Implementation(
hass,
DOMAIN,
const.DOMAIN,
conf[CONF_CLIENT_ID],
conf[CONF_CLIENT_SECRET],
f"{WithingsAuth.URL}/oauth2_user/authorize2",
@ -62,52 +82,127 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
return True
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Withings from a config 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(CREDENTIALS, {})
hass.config_entries.async_update_entry(
entry,
data={
"auth_implementation": DOMAIN,
"implementation": 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"),
},
},
)
config_updates = {}
implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
# Add a unique id if it's an older config entry.
if entry.unique_id != entry.data["token"]["userid"]:
config_updates["unique_id"] = entry.data["token"]["userid"]
# Add the webhook configuration.
if CONF_WEBHOOK_ID not in entry.data:
webhook_id = webhook.async_generate_id()
config_updates["data"] = {
**entry.data,
**{
const.CONF_USE_WEBHOOK: hass.data[DOMAIN][const.CONFIG][
const.CONF_USE_WEBHOOK
],
CONF_WEBHOOK_ID: webhook_id,
const.CONF_WEBHOOK_URL: entry.data.get(
const.CONF_WEBHOOK_URL,
webhook.async_generate_url(hass, webhook_id),
),
},
}
if config_updates:
hass.config_entries.async_update_entry(entry, **config_updates)
data_manager = await async_get_data_manager(hass, entry)
_LOGGER.debug("Confirming %s is authenticated to withings", data_manager.profile)
await data_manager.poll_data_update_coordinator.async_refresh()
if not data_manager.poll_data_update_coordinator.last_update_success:
raise ConfigEntryNotReady()
webhook.async_register(
hass,
const.DOMAIN,
"Withings notify",
data_manager.webhook_config.id,
async_webhook_handler,
)
data_manager = get_data_manager(hass, entry, implementation)
# Perform first webhook subscription check.
if data_manager.webhook_config.enabled:
data_manager.async_start_polling_webhook_subscriptions()
_LOGGER.debug("Confirming we're authenticated")
try:
await data_manager.check_authenticated()
except NotAuthenticatedError:
_LOGGER.error(
"Withings auth tokens exired for profile %s, remove and re-add the integration",
data_manager.profile,
)
return False
@callback
def async_call_later_callback(now) -> None:
hass.async_create_task(
data_manager.subscription_update_coordinator.async_refresh()
)
# Start subscription check in the background, outside this component's setup.
async_call_later(hass, 1, async_call_later_callback)
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, "sensor")
hass.config_entries.async_forward_entry_setup(entry, BINARY_SENSOR_DOMAIN)
)
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, SENSOR_DOMAIN)
)
return True
async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Withings config entry."""
return await hass.config_entries.async_forward_entry_unload(entry, "sensor")
data_manager = await async_get_data_manager(hass, entry)
data_manager.async_stop_polling_webhook_subscriptions()
async_unregister_webhook(hass, data_manager.webhook_config.id)
await asyncio.gather(
data_manager.async_unsubscribe_webhook(),
hass.config_entries.async_forward_entry_unload(entry, BINARY_SENSOR_DOMAIN),
hass.config_entries.async_forward_entry_unload(entry, SENSOR_DOMAIN),
)
async_remove_data_manager(hass, entry)
return True
async def async_webhook_handler(
hass: HomeAssistant, webhook_id: str, request: Request
) -> Optional[Response]:
"""Handle webhooks calls."""
# Handle http head calls to the path.
# When creating a notify subscription, Withings will check that the endpoint is running by sending a HEAD request.
if request.method.upper() == "HEAD":
return Response()
if request.method.upper() != "POST":
return json_message_response("Invalid method.", message_code=2)
# Handle http post calls to the path.
if not request.body_exists:
return json_message_response("No request body.", message_code=12)
params = await request.post()
if "appli" not in params:
return json_message_response("Parameter appli not provided", message_code=20)
try:
appli = cast(
NotifyAppli, enum_or_raise(int(params.getone("appli")), NotifyAppli)
)
except ValueError:
return json_message_response("Invalid appli provided", message_code=21)
data_manager = get_data_manager_by_webhook_id(hass, webhook_id)
if not data_manager:
_LOGGER.error(
"Webhook id %s not handled by data manager. This is a bug and should be reported.",
webhook_id,
)
return json_message_response("User not found", message_code=1)
# Run this in the background and return immediately.
hass.async_create_task(data_manager.async_webhook_data_updated(appli))
return json_message_response("Success", message_code=0)

View file

@ -0,0 +1,40 @@
"""Sensors flow for Withings."""
from typing import Callable, List
from homeassistant.components.binary_sensor import (
DEVICE_CLASS_PRESENCE,
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDevice,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
from .common import BaseWithingsSensor, async_create_entities
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: Callable[[List[Entity], bool], None],
) -> None:
"""Set up the sensor config entry."""
entities = await async_create_entities(
hass, entry, WithingsHealthBinarySensor, BINARY_SENSOR_DOMAIN
)
async_add_entities(entities, True)
class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorDevice):
"""Implementation of a Withings sensor."""
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self._state_data
@property
def device_class(self) -> str:
"""Provide the device class."""
return DEVICE_CLASS_PRESENCE

File diff suppressed because it is too large Load diff

View file

@ -11,8 +11,9 @@ from homeassistant.helpers import config_entry_oauth2_flow
_LOGGER = logging.getLogger(__name__)
@config_entries.HANDLERS.register(const.DOMAIN)
class WithingsFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler):
class WithingsFlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=const.DOMAIN
):
"""Handle a config flow."""
DOMAIN = const.DOMAIN
@ -33,6 +34,7 @@ class WithingsFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler):
AuthScope.USER_INFO.value,
AuthScope.USER_METRICS.value,
AuthScope.USER_ACTIVITY.value,
AuthScope.USER_SLEEP_EVENTS.value,
]
)
}
@ -57,8 +59,20 @@ class WithingsFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler):
data_schema=vol.Schema({vol.Required(const.PROFILE): vol.In(profiles)}),
)
async def async_step_reauth(self, data: dict) -> dict:
"""Prompt user to re-authenticate."""
if data is not None:
return await self.async_step_user()
return self.async_show_form(
step_id="reauth",
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
description_placeholders={"profile": self.context["profile"]},
)
async def async_step_finish(self, data: dict) -> dict:
"""Finish the flow."""
self._current_data = None
await self.async_set_unique_id(data["token"]["userid"], raise_on_progress=False)
return self.async_create_entry(title=data[const.PROFILE], data=data)

View file

@ -1,61 +1,59 @@
"""Constants used by the Withings component."""
from enum import Enum
import homeassistant.const as const
DOMAIN = "withings"
CONF_PROFILES = "profiles"
CONF_USE_WEBHOOK = "use_webhook"
DATA_MANAGER = "data_manager"
BASE_URL = "base_url"
CODE = "code"
CONFIG = "config"
CREDENTIALS = "credentials"
DOMAIN = "withings"
LOG_NAMESPACE = "homeassistant.components.withings"
MEASURES = "measures"
PROFILE = "profile"
PUSH_HANDLER = "push_handler"
CONF_WEBHOOK_URL = "webhook_url"
AUTH_CALLBACK_PATH = "/api/withings/authorize"
AUTH_CALLBACK_NAME = "withings:authorize"
THROTTLE_INTERVAL = 60
SCAN_INTERVAL = 60
class Measurement(Enum):
"""Measurement supported by the withings integration."""
STATE_UNKNOWN = const.STATE_UNKNOWN
STATE_AWAKE = "awake"
STATE_DEEP = "deep"
STATE_LIGHT = "light"
STATE_REM = "rem"
BODY_TEMP_C = "body_temperature_c"
BONE_MASS_KG = "bone_mass_kg"
DIASTOLIC_MMHG = "diastolic_blood_pressure_mmhg"
FAT_FREE_MASS_KG = "fat_free_mass_kg"
FAT_MASS_KG = "fat_mass_kg"
FAT_RATIO_PCT = "fat_ratio_pct"
HEART_PULSE_BPM = "heart_pulse_bpm"
HEIGHT_M = "height_m"
HYDRATION = "hydration"
IN_BED = "in_bed"
MUSCLE_MASS_KG = "muscle_mass_kg"
PWV = "pulse_wave_velocity"
SKIN_TEMP_C = "skin_temperature_c"
SLEEP_BREATHING_DISTURBANCES_INTENSITY = "sleep_breathing_disturbances_intensity"
SLEEP_DEEP_DURATION_SECONDS = "sleep_deep_duration_seconds"
SLEEP_HEART_RATE_AVERAGE = "sleep_heart_rate_average_bpm"
SLEEP_HEART_RATE_MAX = "sleep_heart_rate_max_bpm"
SLEEP_HEART_RATE_MIN = "sleep_heart_rate_min_bpm"
SLEEP_LIGHT_DURATION_SECONDS = "sleep_light_duration_seconds"
SLEEP_REM_DURATION_SECONDS = "sleep_rem_duration_seconds"
SLEEP_RESPIRATORY_RATE_AVERAGE = "sleep_respiratory_average_bpm"
SLEEP_RESPIRATORY_RATE_MAX = "sleep_respiratory_max_bpm"
SLEEP_RESPIRATORY_RATE_MIN = "sleep_respiratory_min_bpm"
SLEEP_SCORE = "sleep_score"
SLEEP_SNORING = "sleep_snoring"
SLEEP_SNORING_EPISODE_COUNT = "sleep_snoring_eposode_count"
SLEEP_TOSLEEP_DURATION_SECONDS = "sleep_tosleep_duration_seconds"
SLEEP_TOWAKEUP_DURATION_SECONDS = "sleep_towakeup_duration_seconds"
SLEEP_WAKEUP_COUNT = "sleep_wakeup_count"
SLEEP_WAKEUP_DURATION_SECONDS = "sleep_wakeup_duration_seconds"
SPO2_PCT = "spo2_pct"
SYSTOLIC_MMGH = "systolic_blood_pressure_mmhg"
TEMP_C = "temperature_c"
WEIGHT_KG = "weight_kg"
MEAS_BODY_TEMP_C = "body_temperature_c"
MEAS_BONE_MASS_KG = "bone_mass_kg"
MEAS_DIASTOLIC_MMHG = "diastolic_blood_pressure_mmhg"
MEAS_FAT_FREE_MASS_KG = "fat_free_mass_kg"
MEAS_FAT_MASS_KG = "fat_mass_kg"
MEAS_FAT_RATIO_PCT = "fat_ratio_pct"
MEAS_HEART_PULSE_BPM = "heart_pulse_bpm"
MEAS_HEIGHT_M = "height_m"
MEAS_HYDRATION = "hydration"
MEAS_MUSCLE_MASS_KG = "muscle_mass_kg"
MEAS_PWV = "pulse_wave_velocity"
MEAS_SKIN_TEMP_C = "skin_temperature_c"
MEAS_SLEEP_DEEP_DURATION_SECONDS = "sleep_deep_duration_seconds"
MEAS_SLEEP_HEART_RATE_AVERAGE = "sleep_heart_rate_average_bpm"
MEAS_SLEEP_HEART_RATE_MAX = "sleep_heart_rate_max_bpm"
MEAS_SLEEP_HEART_RATE_MIN = "sleep_heart_rate_min_bpm"
MEAS_SLEEP_LIGHT_DURATION_SECONDS = "sleep_light_duration_seconds"
MEAS_SLEEP_REM_DURATION_SECONDS = "sleep_rem_duration_seconds"
MEAS_SLEEP_RESPIRATORY_RATE_AVERAGE = "sleep_respiratory_average_bpm"
MEAS_SLEEP_RESPIRATORY_RATE_MAX = "sleep_respiratory_max_bpm"
MEAS_SLEEP_RESPIRATORY_RATE_MIN = "sleep_respiratory_min_bpm"
MEAS_SLEEP_TOSLEEP_DURATION_SECONDS = "sleep_tosleep_duration_seconds"
MEAS_SLEEP_TOWAKEUP_DURATION_SECONDS = "sleep_towakeup_duration_seconds"
MEAS_SLEEP_WAKEUP_COUNT = "sleep_wakeup_count"
MEAS_SLEEP_WAKEUP_DURATION_SECONDS = "sleep_wakeup_duration_seconds"
MEAS_SPO2_PCT = "spo2_pct"
MEAS_SYSTOLIC_MMGH = "systolic_blood_pressure_mmhg"
MEAS_TEMP_C = "temperature_c"
MEAS_WEIGHT_KG = "weight_kg"
UOM_BEATS_PER_MINUTE = "bpm"
UOM_BREATHS_PER_MINUTE = f"br/{const.TIME_MINUTES}"

View file

@ -3,7 +3,7 @@
"name": "Withings",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/withings",
"requirements": ["withings-api==2.1.3"],
"dependencies": ["api", "http", "webhook"],
"requirements": ["withings-api==2.1.6"],
"dependencies": ["http", "webhook"],
"codeowners": ["@vangorra"]
}

View file

@ -1,34 +1,12 @@
"""Sensors flow for Withings."""
from typing import Callable, List, Union
from withings_api.common import (
GetSleepSummaryField,
MeasureGetMeasResponse,
MeasureGroupAttribs,
MeasureType,
SleepGetSummaryResponse,
get_measure_value,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
MASS_KILOGRAMS,
SPEED_METERS_PER_SECOND,
TIME_SECONDS,
UNIT_PERCENTAGE,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.entity import Entity
from homeassistant.util import slugify
from . import const
from .common import _LOGGER, WithingsDataManager, get_data_manager
# There's only 3 calls (per profile) made to the withings api every 5
# minutes (see throttle values). This component wouldn't benefit
# much from parallel updates.
PARALLEL_UPDATES = 1
from .common import BaseWithingsSensor, async_create_entities
async def async_setup_entry(
@ -37,401 +15,18 @@ async def async_setup_entry(
async_add_entities: Callable[[List[Entity], bool], None],
) -> None:
"""Set up the sensor config entry."""
implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
entities = await async_create_entities(
hass, entry, WithingsHealthSensor, SENSOR_DOMAIN,
)
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)
class WithingsAttribute:
"""Base class for modeling withing data."""
def __init__(
self,
measurement: str,
measure_type,
friendly_name: str,
unit_of_measurement: str,
icon: str,
) -> None:
"""Initialize attribute."""
self.measurement = measurement
self.measure_type = measure_type
self.friendly_name = friendly_name
self.unit_of_measurement = unit_of_measurement
self.icon = icon
class WithingsMeasureAttribute(WithingsAttribute):
"""Model measure attributes."""
class WithingsSleepSummaryAttribute(WithingsAttribute):
"""Models sleep summary attributes."""
WITHINGS_ATTRIBUTES = [
WithingsMeasureAttribute(
const.MEAS_WEIGHT_KG,
MeasureType.WEIGHT,
"Weight",
MASS_KILOGRAMS,
"mdi:weight-kilogram",
),
WithingsMeasureAttribute(
const.MEAS_FAT_MASS_KG,
MeasureType.FAT_MASS_WEIGHT,
"Fat Mass",
MASS_KILOGRAMS,
"mdi:weight-kilogram",
),
WithingsMeasureAttribute(
const.MEAS_FAT_FREE_MASS_KG,
MeasureType.FAT_FREE_MASS,
"Fat Free Mass",
MASS_KILOGRAMS,
"mdi:weight-kilogram",
),
WithingsMeasureAttribute(
const.MEAS_MUSCLE_MASS_KG,
MeasureType.MUSCLE_MASS,
"Muscle Mass",
MASS_KILOGRAMS,
"mdi:weight-kilogram",
),
WithingsMeasureAttribute(
const.MEAS_BONE_MASS_KG,
MeasureType.BONE_MASS,
"Bone Mass",
MASS_KILOGRAMS,
"mdi:weight-kilogram",
),
WithingsMeasureAttribute(
const.MEAS_HEIGHT_M,
MeasureType.HEIGHT,
"Height",
const.UOM_LENGTH_M,
"mdi:ruler",
),
WithingsMeasureAttribute(
const.MEAS_TEMP_C,
MeasureType.TEMPERATURE,
"Temperature",
const.UOM_TEMP_C,
"mdi:thermometer",
),
WithingsMeasureAttribute(
const.MEAS_BODY_TEMP_C,
MeasureType.BODY_TEMPERATURE,
"Body Temperature",
const.UOM_TEMP_C,
"mdi:thermometer",
),
WithingsMeasureAttribute(
const.MEAS_SKIN_TEMP_C,
MeasureType.SKIN_TEMPERATURE,
"Skin Temperature",
const.UOM_TEMP_C,
"mdi:thermometer",
),
WithingsMeasureAttribute(
const.MEAS_FAT_RATIO_PCT,
MeasureType.FAT_RATIO,
"Fat Ratio",
UNIT_PERCENTAGE,
None,
),
WithingsMeasureAttribute(
const.MEAS_DIASTOLIC_MMHG,
MeasureType.DIASTOLIC_BLOOD_PRESSURE,
"Diastolic Blood Pressure",
const.UOM_MMHG,
None,
),
WithingsMeasureAttribute(
const.MEAS_SYSTOLIC_MMGH,
MeasureType.SYSTOLIC_BLOOD_PRESSURE,
"Systolic Blood Pressure",
const.UOM_MMHG,
None,
),
WithingsMeasureAttribute(
const.MEAS_HEART_PULSE_BPM,
MeasureType.HEART_RATE,
"Heart Pulse",
const.UOM_BEATS_PER_MINUTE,
"mdi:heart-pulse",
),
WithingsMeasureAttribute(
const.MEAS_SPO2_PCT, MeasureType.SP02, "SP02", UNIT_PERCENTAGE, None
),
WithingsMeasureAttribute(
const.MEAS_HYDRATION,
MeasureType.HYDRATION,
"Hydration",
UNIT_PERCENTAGE,
"mdi:water",
),
WithingsMeasureAttribute(
const.MEAS_PWV,
MeasureType.PULSE_WAVE_VELOCITY,
"Pulse Wave Velocity",
SPEED_METERS_PER_SECOND,
None,
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_WAKEUP_DURATION_SECONDS,
GetSleepSummaryField.WAKEUP_DURATION.value,
"Wakeup time",
TIME_SECONDS,
"mdi:sleep-off",
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_LIGHT_DURATION_SECONDS,
GetSleepSummaryField.LIGHT_SLEEP_DURATION.value,
"Light sleep",
TIME_SECONDS,
"mdi:sleep",
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_DEEP_DURATION_SECONDS,
GetSleepSummaryField.DEEP_SLEEP_DURATION.value,
"Deep sleep",
TIME_SECONDS,
"mdi:sleep",
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_REM_DURATION_SECONDS,
GetSleepSummaryField.REM_SLEEP_DURATION.value,
"REM sleep",
TIME_SECONDS,
"mdi:sleep",
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_WAKEUP_COUNT,
GetSleepSummaryField.WAKEUP_COUNT.value,
"Wakeup count",
const.UOM_FREQUENCY,
"mdi:sleep-off",
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_TOSLEEP_DURATION_SECONDS,
GetSleepSummaryField.DURATION_TO_SLEEP.value,
"Time to sleep",
TIME_SECONDS,
"mdi:sleep",
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_TOWAKEUP_DURATION_SECONDS,
GetSleepSummaryField.DURATION_TO_WAKEUP.value,
"Time to wakeup",
TIME_SECONDS,
"mdi:sleep-off",
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_HEART_RATE_AVERAGE,
GetSleepSummaryField.HR_AVERAGE.value,
"Average heart rate",
const.UOM_BEATS_PER_MINUTE,
"mdi:heart-pulse",
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_HEART_RATE_MIN,
GetSleepSummaryField.HR_MIN.value,
"Minimum heart rate",
const.UOM_BEATS_PER_MINUTE,
"mdi:heart-pulse",
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_HEART_RATE_MAX,
GetSleepSummaryField.HR_MAX.value,
"Maximum heart rate",
const.UOM_BEATS_PER_MINUTE,
"mdi:heart-pulse",
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_RESPIRATORY_RATE_AVERAGE,
GetSleepSummaryField.RR_AVERAGE.value,
"Average respiratory rate",
const.UOM_BREATHS_PER_MINUTE,
None,
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_RESPIRATORY_RATE_MIN,
GetSleepSummaryField.RR_MIN.value,
"Minimum respiratory rate",
const.UOM_BREATHS_PER_MINUTE,
None,
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_RESPIRATORY_RATE_MAX,
GetSleepSummaryField.RR_MAX.value,
"Maximum respiratory rate",
const.UOM_BREATHS_PER_MINUTE,
None,
),
]
WITHINGS_MEASUREMENTS_MAP = {attr.measurement: attr for attr in WITHINGS_ATTRIBUTES}
class WithingsHealthSensor(Entity):
class WithingsHealthSensor(BaseWithingsSensor):
"""Implementation of a Withings sensor."""
def __init__(
self,
data_manager: WithingsDataManager,
attribute: WithingsAttribute,
user_id: str,
) -> None:
"""Initialize the Withings sensor."""
self._data_manager = data_manager
self._attribute = attribute
self._state = None
self._slug = self._data_manager.slug
self._user_id = user_id
@property
def name(self) -> str:
"""Return the name of the sensor."""
return f"Withings {self._attribute.measurement} {self._slug}"
@property
def unique_id(self) -> str:
"""Return a unique, Home Assistant friendly identifier for this entity."""
return (
f"withings_{self._slug}_{self._user_id}_"
f"{slugify(self._attribute.measurement)}"
)
@property
def state(self) -> Union[str, int, float, None]:
"""Return the state of the sensor."""
return self._state
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of this entity, if any."""
return self._attribute.unit_of_measurement
@property
def icon(self) -> str:
"""Icon to use in the frontend, if any."""
return self._attribute.icon
@property
def device_state_attributes(self) -> None:
"""Get withings attributes."""
return self._attribute.__dict__
async def async_update(self) -> None:
"""Update the data."""
_LOGGER.debug(
"Async update slug: %s, measurement: %s, user_id: %s",
self._slug,
self._attribute.measurement,
self._user_id,
)
if isinstance(self._attribute, WithingsMeasureAttribute):
_LOGGER.debug("Updating measures state")
await self._data_manager.update_measures()
await self.async_update_measure(self._data_manager.measures)
elif isinstance(self._attribute, WithingsSleepSummaryAttribute):
_LOGGER.debug("Updating sleep summary state")
await self._data_manager.update_sleep_summary()
await self.async_update_sleep_summary(self._data_manager.sleep_summary)
async def async_update_measure(self, data: MeasureGetMeasResponse) -> None:
"""Update the measures data."""
measure_type = self._attribute.measure_type
_LOGGER.debug(
"Finding the unambiguous measure group with measure_type: %s", measure_type
)
value = get_measure_value(data, measure_type, MeasureGroupAttribs.UNAMBIGUOUS)
if value is None:
_LOGGER.debug("Could not find a value, setting state to %s", None)
self._state = None
return
self._state = round(value, 2)
async def async_update_sleep_summary(self, data: SleepGetSummaryResponse) -> None:
"""Update the sleep summary data."""
if not data.series:
_LOGGER.debug("Sleep data has no series, setting state to %s", None)
self._state = None
return
measurement = self._attribute.measurement
measure_type = self._attribute.measure_type
_LOGGER.debug("Determining total value for: %s", measurement)
total = 0
for serie in data.series:
data = serie.data
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)
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
def state(self) -> Union[None, str, int, float]:
"""Return the state of the entity."""
return self._state_data

View file

@ -1,12 +1,17 @@
{
"config": {
"flow_title": "Withings: {profile}",
"step": {
"profile": {
"title": "User Profile.",
"description": "Which profile did you select on the Withings website? It's important the profiles match, otherwise data will be mis-labeled.",
"data": { "profile": "Profile" }
},
"pick_implementation": { "title": "Pick Authentication Method" }
"pick_implementation": { "title": "Pick Authentication Method" },
"reauth": {
"title": "Re-authenticate {profile}",
"description": "The \"{profile}\" profile needs to be re-authenticated in order to continue receiving Withings data."
}
},
"abort": {
"authorize_url_timeout": "Timeout generating authorize url.",

View file

@ -2205,7 +2205,7 @@ wiffi==1.0.0
wirelesstagpy==0.4.0
# homeassistant.components.withings
withings-api==2.1.3
withings-api==2.1.6
# homeassistant.components.wled
wled==0.4.3

View file

@ -923,7 +923,7 @@ watchdog==0.8.3
wiffi==1.0.0
# homeassistant.components.withings
withings-api==2.1.3
withings-api==2.1.6
# homeassistant.components.wled
wled==0.4.3

View file

@ -1,24 +1,32 @@
"""Common data for for the withings component tests."""
import re
import time
from typing import List
from dataclasses import dataclass
from typing import List, Optional, Tuple, Union
from urllib.parse import urlparse
import requests_mock
from withings_api import AbstractWithingsApi
from aiohttp.test_utils import TestClient
import arrow
import pytz
from withings_api.common import (
MeasureGetMeasGroupAttrib,
MeasureGetMeasGroupCategory,
MeasureType,
SleepModel,
SleepState,
MeasureGetMeasResponse,
NotifyAppli,
NotifyListResponse,
SleepGetSummaryResponse,
UserGetDeviceResponse,
)
from homeassistant import data_entry_flow
import homeassistant.components.api as api
import homeassistant.components.http as http
from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN
import homeassistant.components.webhook as webhook
from homeassistant.components.withings import async_unload_entry
from homeassistant.components.withings.common import (
ConfigEntryWithingsApi,
DataManager,
get_all_data_managers,
)
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.config_entries import SOURCE_USER, ConfigEntry
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
@ -28,364 +36,295 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.config_entry_oauth2_flow import AUTH_CALLBACK_PATH
from homeassistant.setup import async_setup_component
from homeassistant.util import slugify
from tests.async_mock import MagicMock
from tests.test_util.aiohttp import AiohttpClientMocker
def get_entity_id(measure, profile) -> str:
"""Get an entity id for a measure and profile."""
return "sensor.{}_{}_{}".format(const.DOMAIN, measure, slugify(profile))
@dataclass
class ProfileConfig:
"""Data representing a user profile."""
profile: str
user_id: int
api_response_user_get_device: Union[UserGetDeviceResponse, Exception]
api_response_measure_get_meas: Union[MeasureGetMeasResponse, Exception]
api_response_sleep_get_summary: Union[SleepGetSummaryResponse, Exception]
api_response_notify_list: Union[NotifyListResponse, Exception]
api_response_notify_revoke: Optional[Exception]
def assert_state_equals(
hass: HomeAssistant, profile: str, measure: str, expected
) -> 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, f"Expected entity {entity_id} to exist but it did not"
assert state_obj.state == str(expected), (
f"Expected {expected} but was {state_obj.state} "
f"for measure {measure}, {entity_id}"
def new_profile_config(
profile: str,
user_id: int,
api_response_user_get_device: Optional[
Union[UserGetDeviceResponse, Exception]
] = None,
api_response_measure_get_meas: Optional[
Union[MeasureGetMeasResponse, Exception]
] = None,
api_response_sleep_get_summary: Optional[
Union[SleepGetSummaryResponse, Exception]
] = None,
api_response_notify_list: Optional[Union[NotifyListResponse, Exception]] = None,
api_response_notify_revoke: Optional[Exception] = None,
) -> ProfileConfig:
"""Create a new profile config immutable object."""
return ProfileConfig(
profile=profile,
user_id=user_id,
api_response_user_get_device=api_response_user_get_device
or UserGetDeviceResponse(devices=[]),
api_response_measure_get_meas=api_response_measure_get_meas
or MeasureGetMeasResponse(
measuregrps=[],
more=False,
offset=0,
timezone=pytz.UTC,
updatetime=arrow.get(12345),
),
api_response_sleep_get_summary=api_response_sleep_get_summary
or SleepGetSummaryResponse(more=False, offset=0, series=[]),
api_response_notify_list=api_response_notify_list
or NotifyListResponse(profiles=[]),
api_response_notify_revoke=api_response_notify_revoke,
)
async def setup_hass(hass: HomeAssistant) -> dict:
"""Configure Home Assistant."""
profiles = ["Person0", "Person1", "Person2", "Person3", "Person4"]
@dataclass
class WebhookResponse:
"""Response data from a webhook."""
hass_config = {
"homeassistant": {
CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC,
CONF_EXTERNAL_URL: "http://example.local/",
},
api.DOMAIN: {},
http.DOMAIN: {"server_port": 8080},
const.DOMAIN: {
CONF_CLIENT_ID: "my_client_id",
CONF_CLIENT_SECRET: "my_client_secret",
const.CONF_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
message: str
message_code: int
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]
class ComponentFactory:
"""Manages the setup and unloading of the withing component and profiles."""
with requests_mock.mock() as rqmck:
rqmck.get(
re.compile(f"{AbstractWithingsApi.URL}/v2/user?.*action=getdevice(&.*|$)"),
status_code=200,
json=get_device_response,
def __init__(
self,
hass: HomeAssistant,
api_class_mock: MagicMock,
aiohttp_client,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Initialize the object."""
self._hass = hass
self._api_class_mock = api_class_mock
self._aiohttp_client = aiohttp_client
self._aioclient_mock = aioclient_mock
self._client_id = None
self._client_secret = None
self._profile_configs: Tuple[ProfileConfig, ...] = ()
async def configure_component(
self,
client_id: str = "my_client_id",
client_secret: str = "my_client_secret",
profile_configs: Tuple[ProfileConfig, ...] = (),
) -> None:
"""Configure the wihings component."""
self._client_id = client_id
self._client_secret = client_secret
self._profile_configs = profile_configs
hass_config = {
"homeassistant": {
CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC,
CONF_EXTERNAL_URL: "http://127.0.0.1:8080/",
},
api.DOMAIN: {},
const.DOMAIN: {
CONF_CLIENT_ID: self._client_id,
CONF_CLIENT_SECRET: self._client_secret,
const.CONF_USE_WEBHOOK: True,
const.CONF_PROFILES: [
profile_config.profile for profile_config in self._profile_configs
],
},
}
await async_process_ha_core_config(self._hass, hass_config.get("homeassistant"))
assert await async_setup_component(self._hass, HA_DOMAIN, {})
assert await async_setup_component(self._hass, webhook.DOMAIN, hass_config)
assert await async_setup_component(self._hass, const.DOMAIN, hass_config)
await self._hass.async_block_till_done()
@staticmethod
def _setup_api_method(api_method, value) -> None:
if isinstance(value, Exception):
api_method.side_effect = value
else:
api_method.return_value = value
async def setup_profile(self, user_id: int) -> ConfigEntryWithingsApi:
"""Set up a user profile through config flows."""
profile_config = next(
iter(
[
profile_config
for profile_config in self._profile_configs
if profile_config.user_id == user_id
]
)
)
rqmck.get(
re.compile(f"{AbstractWithingsApi.URL}/v2/sleep?.*action=get(&.*|$)"),
status_code=200,
json=get_sleep_response,
api_mock: ConfigEntryWithingsApi = MagicMock(spec=ConfigEntryWithingsApi)
ComponentFactory._setup_api_method(
api_mock.user_get_device, profile_config.api_response_user_get_device
)
ComponentFactory._setup_api_method(
api_mock.sleep_get_summary, profile_config.api_response_sleep_get_summary
)
ComponentFactory._setup_api_method(
api_mock.measure_get_meas, profile_config.api_response_measure_get_meas
)
ComponentFactory._setup_api_method(
api_mock.notify_list, profile_config.api_response_notify_list
)
ComponentFactory._setup_api_method(
api_mock.notify_revoke, profile_config.api_response_notify_revoke
)
rqmck.get(
re.compile(
f"{AbstractWithingsApi.URL}/v2/sleep?.*action=getsummary(&.*|$)"
),
status_code=200,
json=get_sleep_summary_response,
)
rqmck.get(
re.compile(f"{AbstractWithingsApi.URL}/measure?.*action=getmeas(&.*|$)"),
status_code=200,
json=getmeasures_response,
)
self._api_class_mock.reset_mocks()
self._api_class_mock.return_value = api_mock
# Get the withings config flow.
result = await hass.config_entries.flow.async_init(
result = await self._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"]}
self._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://example.local/auth/external/callback&"
f"response_type=code&client_id={self._client_id}&"
"redirect_uri=http://127.0.0.1:8080/auth/external/callback&"
f"state={state}"
"&scope=user.info,user.metrics,user.activity"
"&scope=user.info,user.metrics,user.activity,user.sleepevents"
)
# 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}")
client: TestClient = await self._aiohttp_client(self._hass.http.app)
resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
self._aioclient_mock.clear_requests()
self._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",
"userid": profile_config.user_id,
},
)
# Present user with a list of profiles to choose from.
result = await hass.config_entries.flow.async_configure(result["flow_id"])
result = await self._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
assert result.get("data_schema").schema["profile"].container == [
profile.profile for profile in self._profile_configs
]
# Select the user profile.
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {const.PROFILE: selected_profile}
result = await self._hass.config_entries.flow.async_configure(
result["flow_id"], {const.PROFILE: profile_config.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(const.PROFILE) == profile_config.profile
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 self._hass.async_block_till_done()
# Mock the webhook.
data_manager = get_data_manager_by_user_id(self._hass, user_id)
self._aioclient_mock.clear_requests()
self._aioclient_mock.request(
"HEAD", data_manager.webhook_config.url,
)
return self._api_class_mock.return_value
async def call_webhook(self, user_id: int, appli: NotifyAppli) -> WebhookResponse:
"""Call the webhook to notify of data changes."""
client: TestClient = await self._aiohttp_client(self._hass.http.app)
data_manager = get_data_manager_by_user_id(self._hass, user_id)
resp = await client.post(
urlparse(data_manager.webhook_config.url).path,
data={"userid": user_id, "appli": appli.value},
)
# Wait for remaining tasks to complete.
await hass.async_block_till_done()
await self._hass.async_block_till_done()
data = await resp.json()
resp.close()
return WebhookResponse(message=data["message"], message_code=data["code"])
async def unload(self, profile: ProfileConfig) -> None:
"""Unload the component for a specific user."""
config_entries = get_config_entries_for_user_id(self._hass, profile.user_id)
for config_entry in config_entries:
await async_unload_entry(self._hass, config_entry)
await self._hass.async_block_till_done()
assert not get_data_manager_by_user_id(self._hass, profile.user_id)
WITHINGS_GET_DEVICE_RESPONSE_EMPTY = {"status": 0, "body": {"devices": []}}
WITHINGS_GET_DEVICE_RESPONSE = {
"status": 0,
"body": {
"devices": [
{
"type": "type1",
"model": "model1",
"battery": "battery1",
"deviceid": "deviceid1",
"timezone": "UTC",
}
def get_config_entries_for_user_id(
hass: HomeAssistant, user_id: int
) -> Tuple[ConfigEntry]:
"""Get a list of config entries that apply to a specific withings user."""
return tuple(
[
config_entry
for config_entry in hass.config_entries.async_entries(const.DOMAIN)
if config_entry.data.get("token", {}).get("userid") == user_id
]
},
}
)
WITHINGS_MEASURES_RESPONSE_EMPTY = {
"status": 0,
"body": {"updatetime": "2019-08-01", "timezone": "UTC", "measuregrps": []},
}
def async_get_flow_for_user_id(hass: HomeAssistant, user_id: int) -> List[dict]:
"""Get a flow for a user id."""
return [
flow
for flow in hass.config_entries.flow.async_progress()
if flow["handler"] == const.DOMAIN and flow["context"].get("userid") == user_id
]
WITHINGS_MEASURES_RESPONSE = {
"status": 0,
"body": {
"updatetime": "2019-08-01",
"timezone": "UTC",
"measuregrps": [
# Un-ambiguous groups.
{
"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": 70, "unit": 0},
{"type": MeasureType.FAT_MASS_WEIGHT, "value": 5, "unit": 0},
{"type": MeasureType.FAT_FREE_MASS, "value": 60, "unit": 0},
{"type": MeasureType.MUSCLE_MASS, "value": 50, "unit": 0},
{"type": MeasureType.BONE_MASS, "value": 10, "unit": 0},
{"type": MeasureType.HEIGHT, "value": 2, "unit": 0},
{"type": MeasureType.TEMPERATURE, "value": 40, "unit": 0},
{"type": MeasureType.BODY_TEMPERATURE, "value": 40, "unit": 0},
{"type": MeasureType.SKIN_TEMPERATURE, "value": 20, "unit": 0},
{"type": MeasureType.FAT_RATIO, "value": 70, "unit": -3},
{
"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,
},
},
],
},
}
def get_data_manager_by_user_id(
hass: HomeAssistant, user_id: int
) -> Optional[DataManager]:
"""Get a data manager by the user id."""
return next(
iter(
[
data_manager
for data_manager in get_all_data_managers(hass)
if data_manager.user_id == user_id
]
),
None,
)

View file

@ -0,0 +1,22 @@
"""Fixtures for tests."""
from unittest.mock import patch
import pytest
from homeassistant.core import HomeAssistant
from .common import ComponentFactory
from tests.test_util.aiohttp import AiohttpClientMocker
@pytest.fixture()
def component_factory(
hass: HomeAssistant, aiohttp_client, aioclient_mock: AiohttpClientMocker
):
"""Return a factory for initializing the withings component."""
with patch(
"homeassistant.components.withings.common.ConfigEntryWithingsApi"
) as api_class_mock:
yield ComponentFactory(hass, api_class_mock, aiohttp_client, aioclient_mock)

View file

@ -0,0 +1,62 @@
"""Tests for the Withings component."""
from withings_api.common import NotifyAppli
from homeassistant.components.withings.common import (
WITHINGS_MEASUREMENTS_MAP,
async_get_entity_id,
)
from homeassistant.components.withings.const import Measurement
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_registry import EntityRegistry
from .common import ComponentFactory, new_profile_config
async def test_binary_sensor(
hass: HomeAssistant, component_factory: ComponentFactory
) -> None:
"""Test binary sensor."""
in_bed_attribute = WITHINGS_MEASUREMENTS_MAP[Measurement.IN_BED]
person0 = new_profile_config("person0", 0)
person1 = new_profile_config("person1", 1)
entity_registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry()
await component_factory.configure_component(profile_configs=(person0, person1))
assert not await async_get_entity_id(hass, in_bed_attribute, person0.user_id)
assert not await async_get_entity_id(hass, in_bed_attribute, person1.user_id)
# person 0
await component_factory.setup_profile(person0.user_id)
await component_factory.setup_profile(person1.user_id)
entity_id0 = await async_get_entity_id(hass, in_bed_attribute, person0.user_id)
entity_id1 = await async_get_entity_id(hass, in_bed_attribute, person1.user_id)
assert entity_id0
assert entity_id1
assert entity_registry.async_is_registered(entity_id0)
assert hass.states.get(entity_id0).state == STATE_OFF
resp = await component_factory.call_webhook(person0.user_id, NotifyAppli.BED_IN)
assert resp.message_code == 0
await hass.async_block_till_done()
assert hass.states.get(entity_id0).state == STATE_ON
resp = await component_factory.call_webhook(person0.user_id, NotifyAppli.BED_OUT)
assert resp.message_code == 0
await hass.async_block_till_done()
assert hass.states.get(entity_id0).state == STATE_OFF
# person 1
assert hass.states.get(entity_id1).state == STATE_OFF
resp = await component_factory.call_webhook(person1.user_id, NotifyAppli.BED_IN)
assert resp.message_code == 0
await hass.async_block_till_done()
assert hass.states.get(entity_id1).state == STATE_ON
# Unload
await component_factory.unload(person0)
await component_factory.unload(person1)

View file

@ -1,135 +1,236 @@
"""Tests for the Withings component."""
from datetime import timedelta
import datetime
import re
from typing import Any
from urllib.parse import urlparse
from aiohttp.test_utils import TestClient
from asynctest import MagicMock
import pytest
from withings_api import WithingsApi
from withings_api.common import TimeoutException, UnauthorizedException
import requests_mock
from withings_api.common import NotifyAppli, NotifyListProfile, NotifyListResponse
from homeassistant.components.withings.common import (
NotAuthenticatedError,
WithingsDataManager,
ConfigEntryWithingsApi,
DataManager,
WebhookConfig,
)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.util import dt
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2Implementation
from tests.async_mock import MagicMock, patch
from tests.common import MockConfigEntry
from tests.components.withings.common import (
ComponentFactory,
get_data_manager_by_user_id,
new_profile_config,
)
from tests.test_util.aiohttp import AiohttpClientMocker
@pytest.fixture(name="withings_api")
def withings_api_fixture() -> WithingsApi:
"""Provide withings api."""
withings_api = WithingsApi.__new__(WithingsApi)
withings_api.user_get_device = MagicMock()
withings_api.measure_get_meas = MagicMock()
withings_api.sleep_get = MagicMock()
withings_api.sleep_get_summary = MagicMock()
return withings_api
async def test_config_entry_withings_api(hass: HomeAssistant) -> None:
"""Test ConfigEntryWithingsApi."""
config_entry = MockConfigEntry(
data={"token": {"access_token": "mock_access_token", "expires_at": 1111111}}
)
config_entry.add_to_hass(hass)
implementation_mock = MagicMock(spec=AbstractOAuth2Implementation)
implementation_mock.async_refresh_token.return_value = {
"expires_at": 1111111,
"access_token": "mock_access_token",
}
with requests_mock.mock() as rqmck:
rqmck.get(
re.compile(".*"),
status_code=200,
json={"status": 0, "body": {"message": "success"}},
)
api = ConfigEntryWithingsApi(hass, config_entry, implementation_mock)
response = await hass.async_add_executor_job(
api.request, "test", {"arg1": "val1", "arg2": "val2"}
)
assert response == {"message": "success"}
@pytest.fixture(name="data_manager")
def data_manager_fixture(hass, withings_api: WithingsApi) -> WithingsDataManager:
"""Provide data manager."""
return WithingsDataManager(hass, "My Profile", withings_api)
def test_print_service() -> None:
"""Test method."""
# Go from None to True
WithingsDataManager.service_available = None
assert WithingsDataManager.print_service_available()
assert WithingsDataManager.service_available is True
assert not WithingsDataManager.print_service_available()
assert not WithingsDataManager.print_service_available()
# Go from True to False
assert WithingsDataManager.print_service_unavailable()
assert WithingsDataManager.service_available is False
assert not WithingsDataManager.print_service_unavailable()
assert not WithingsDataManager.print_service_unavailable()
# Go from False to True
assert WithingsDataManager.print_service_available()
assert WithingsDataManager.service_available is True
assert not WithingsDataManager.print_service_available()
assert not WithingsDataManager.print_service_available()
# Go from Non to False
WithingsDataManager.service_available = None
assert WithingsDataManager.print_service_unavailable()
assert WithingsDataManager.service_available is False
assert not WithingsDataManager.print_service_unavailable()
assert not WithingsDataManager.print_service_unavailable()
async def test_data_manager_call(data_manager: WithingsDataManager) -> None:
"""Test method."""
# Not authenticated 1.
test_function = MagicMock(side_effect=UnauthorizedException(401))
with pytest.raises(NotAuthenticatedError):
await data_manager.call(test_function)
# Not authenticated 2.
test_function = MagicMock(side_effect=TimeoutException(522))
with pytest.raises(PlatformNotReady):
await data_manager.call(test_function)
# Service error.
test_function = MagicMock(side_effect=PlatformNotReady())
with pytest.raises(PlatformNotReady):
await data_manager.call(test_function)
async def test_data_manager_call_throttle_enabled(
data_manager: WithingsDataManager,
@pytest.mark.parametrize(
["user_id", "arg_user_id", "arg_appli", "expected_code"],
[
[0, 0, NotifyAppli.WEIGHT.value, 0], # Success
[0, None, 1, 0], # Success, we ignore the user_id.
[0, None, None, 12], # No request body.
[0, "GG", None, 20], # appli not provided.
[0, 0, None, 20], # appli not provided.
[0, 0, 99, 21], # Invalid appli.
[0, 11, NotifyAppli.WEIGHT.value, 0], # Success, we ignore the user_id
],
)
async def test_webhook_post(
hass: HomeAssistant,
component_factory: ComponentFactory,
aiohttp_client,
user_id: int,
arg_user_id: Any,
arg_appli: Any,
expected_code: int,
) -> None:
"""Test method."""
hello_func = MagicMock(return_value="HELLO2")
"""Test webhook callback."""
person0 = new_profile_config("person0", user_id)
result = await data_manager.call(hello_func, throttle_domain="test")
assert result == "HELLO2"
await component_factory.configure_component(profile_configs=(person0,))
await component_factory.setup_profile(person0.user_id)
data_manager = get_data_manager_by_user_id(hass, user_id)
result = await data_manager.call(hello_func, throttle_domain="test")
assert result == "HELLO2"
client: TestClient = await aiohttp_client(hass.http.app)
assert hello_func.call_count == 1
post_data = {}
if arg_user_id is not None:
post_data["userid"] = arg_user_id
if arg_appli is not None:
post_data["appli"] = arg_appli
async def test_data_manager_call_throttle_disabled(
data_manager: WithingsDataManager,
) -> None:
"""Test method."""
hello_func = MagicMock(return_value="HELLO2")
result = await data_manager.call(hello_func)
assert result == "HELLO2"
result = await data_manager.call(hello_func)
assert result == "HELLO2"
assert hello_func.call_count == 2
async def test_data_manager_update_sleep_date_range(
data_manager: WithingsDataManager,
) -> None:
"""Test method."""
patch_time_zone = patch(
"homeassistant.util.dt.DEFAULT_TIME_ZONE",
new=dt.get_time_zone("America/Belize"),
resp = await client.post(
urlparse(data_manager.webhook_config.url).path, data=post_data
)
with patch_time_zone:
update_start_time = dt.now()
await data_manager.update_sleep()
# Wait for remaining tasks to complete.
await hass.async_block_till_done()
call_args = data_manager.api.sleep_get.call_args_list[0][1]
startdate = call_args.get("startdate")
enddate = call_args.get("enddate")
data = await resp.json()
resp.close()
assert startdate.tzname() == "CST"
assert data["code"] == expected_code
assert enddate.tzname() == "CST"
assert startdate.tzname() == "CST"
assert update_start_time < enddate
assert enddate < update_start_time + timedelta(seconds=1)
assert enddate > startdate
async def test_webhook_head(
hass: HomeAssistant, component_factory: ComponentFactory, aiohttp_client,
) -> None:
"""Test head method on webhook view."""
person0 = new_profile_config("person0", 0)
await component_factory.configure_component(profile_configs=(person0,))
await component_factory.setup_profile(person0.user_id)
data_manager = get_data_manager_by_user_id(hass, person0.user_id)
client: TestClient = await aiohttp_client(hass.http.app)
resp = await client.head(urlparse(data_manager.webhook_config.url).path)
assert resp.status == 200
async def test_webhook_put(
hass: HomeAssistant, component_factory: ComponentFactory, aiohttp_client,
) -> None:
"""Test webhook callback."""
person0 = new_profile_config("person0", 0)
await component_factory.configure_component(profile_configs=(person0,))
await component_factory.setup_profile(person0.user_id)
data_manager = get_data_manager_by_user_id(hass, person0.user_id)
client: TestClient = await aiohttp_client(hass.http.app)
resp = await client.put(urlparse(data_manager.webhook_config.url).path)
# Wait for remaining tasks to complete.
await hass.async_block_till_done()
assert resp.status == 200
data = await resp.json()
assert data
assert data["code"] == 2
async def test_data_manager_webhook_subscription(
hass: HomeAssistant,
component_factory: ComponentFactory,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test data manager webhook subscriptions."""
person0 = new_profile_config("person0", 0)
await component_factory.configure_component(profile_configs=(person0,))
api: ConfigEntryWithingsApi = MagicMock(spec=ConfigEntryWithingsApi)
data_manager = DataManager(
hass,
"person0",
api,
0,
WebhookConfig(id="1234", url="http://localhost/api/webhook/1234", enabled=True),
)
# pylint: disable=protected-access
data_manager._notify_subscribe_delay = datetime.timedelta(seconds=0)
data_manager._notify_unsubscribe_delay = datetime.timedelta(seconds=0)
api.notify_list.return_value = NotifyListResponse(
profiles=(
NotifyListProfile(
appli=NotifyAppli.BED_IN,
callbackurl="https://not.my.callback/url",
expires=None,
comment=None,
),
NotifyListProfile(
appli=NotifyAppli.BED_IN,
callbackurl=data_manager.webhook_config.url,
expires=None,
comment=None,
),
NotifyListProfile(
appli=NotifyAppli.BED_OUT,
callbackurl=data_manager.webhook_config.url,
expires=None,
comment=None,
),
)
)
aioclient_mock.clear_requests()
aioclient_mock.request(
"HEAD", data_manager.webhook_config.url, status=200,
)
# Test subscribing
await data_manager.async_subscribe_webhook()
api.notify_subscribe.assert_any_call(
data_manager.webhook_config.url, NotifyAppli.WEIGHT
)
api.notify_subscribe.assert_any_call(
data_manager.webhook_config.url, NotifyAppli.CIRCULATORY
)
api.notify_subscribe.assert_any_call(
data_manager.webhook_config.url, NotifyAppli.ACTIVITY
)
api.notify_subscribe.assert_any_call(
data_manager.webhook_config.url, NotifyAppli.SLEEP
)
try:
api.notify_subscribe.assert_any_call(
data_manager.webhook_config.url, NotifyAppli.USER
)
assert False
except AssertionError:
pass
try:
api.notify_subscribe.assert_any_call(
data_manager.webhook_config.url, NotifyAppli.BED_IN
)
assert False
except AssertionError:
pass
try:
api.notify_subscribe.assert_any_call(
data_manager.webhook_config.url, NotifyAppli.BED_OUT
)
assert False
except AssertionError:
pass
# Test unsubscribing.
await data_manager.async_unsubscribe_webhook()
api.notify_revoke.assert_any_call(
data_manager.webhook_config.url, NotifyAppli.BED_IN
)
api.notify_revoke.assert_any_call(
data_manager.webhook_config.url, NotifyAppli.BED_OUT
)

View file

@ -1,43 +1,28 @@
"""Tests for the Withings component."""
import re
import time
import requests_mock
from asynctest import MagicMock, patch
import pytest
import voluptuous as vol
from withings_api import AbstractWithingsApi
from withings_api.common import SleepModel, SleepState
from withings_api.common import UnauthorizedException
import homeassistant.components.http as http
from homeassistant.components.withings import (
CONFIG_SCHEMA,
async_setup,
async_setup_entry,
const,
)
from homeassistant.config import async_process_ha_core_config
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, STATE_UNKNOWN
from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const
from homeassistant.components.withings.common import DataManager
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .common import (
WITHINGS_GET_DEVICE_RESPONSE,
WITHINGS_GET_DEVICE_RESPONSE_EMPTY,
WITHINGS_MEASURES_RESPONSE,
WITHINGS_MEASURES_RESPONSE_EMPTY,
WITHINGS_SLEEP_RESPONSE,
WITHINGS_SLEEP_RESPONSE_EMPTY,
WITHINGS_SLEEP_SUMMARY_RESPONSE,
WITHINGS_SLEEP_SUMMARY_RESPONSE_EMPTY,
assert_state_equals,
configure_integration,
setup_hass,
ComponentFactory,
async_get_flow_for_user_id,
get_data_manager_by_user_id,
new_profile_config,
)
from tests.async_mock import MagicMock
from tests.common import MockConfigEntry
def config_schema_validate(withings_config) -> None:
def config_schema_validate(withings_config) -> dict:
"""Assert a schema config succeeds."""
hass_config = {http.DOMAIN: {}, const.DOMAIN: withings_config}
hass_config = {const.DOMAIN: withings_config}
return CONFIG_SCHEMA(hass_config)
@ -57,6 +42,7 @@ def test_config_schema_basic_config() -> None:
{
CONF_CLIENT_ID: "my_client_id",
CONF_CLIENT_SECRET: "my_client_secret",
const.CONF_USE_WEBHOOK: True,
const.CONF_PROFILES: ["Person 1", "Person 2"],
}
)
@ -107,6 +93,43 @@ def test_config_schema_client_secret() -> None:
)
def test_config_schema_use_webhook() -> None:
"""Test schema."""
config_schema_validate(
{
CONF_CLIENT_ID: "my_client_id",
CONF_CLIENT_SECRET: "my_client_secret",
const.CONF_PROFILES: ["Person 1"],
}
)
config = config_schema_validate(
{
CONF_CLIENT_ID: "my_client_id",
CONF_CLIENT_SECRET: "my_client_secret",
const.CONF_USE_WEBHOOK: True,
const.CONF_PROFILES: ["Person 1"],
}
)
assert config[const.DOMAIN][const.CONF_USE_WEBHOOK] is True
config = config_schema_validate(
{
CONF_CLIENT_ID: "my_client_id",
CONF_CLIENT_SECRET: "my_client_secret",
const.CONF_USE_WEBHOOK: False,
const.CONF_PROFILES: ["Person 1"],
}
)
assert config[const.DOMAIN][const.CONF_USE_WEBHOOK] is False
config_schema_assert_fail(
{
CONF_CLIENT_ID: "my_client_id",
CONF_CLIENT_SECRET: "my_client_secret",
const.CONF_USE_WEBHOOK: "A",
const.CONF_PROFILES: ["Person 1"],
}
)
def test_config_schema_profiles() -> None:
"""Test schema."""
config_schema_assert_fail(
@ -158,285 +181,74 @@ async def test_async_setup_no_config(hass: HomeAssistant) -> None:
hass.async_create_task.assert_not_called()
async def test_upgrade_token(
hass: HomeAssistant, aiohttp_client, aioclient_mock
) -> None:
"""Test upgrading from old config data format to new one."""
config = await setup_hass(hass)
profiles = config[const.DOMAIN][const.CONF_PROFILES]
await async_process_ha_core_config(
hass, {"internal_url": "http://example.local"},
)
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]
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"),
CONF_CLIENT_ID: token.get("my_client_id"),
"consumer_secret": token.get("my_consumer_secret"),
},
},
)
with requests_mock.mock() as rqmck:
rqmck.get(
re.compile(f"{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(CONF_CLIENT_ID)
assert not token.get("consumer_secret")
@pytest.mark.parametrize(
["exception"],
[
[UnauthorizedException("401")],
[UnauthorizedException("401")],
[Exception("401, this is the message")],
],
)
async def test_auth_failure(
hass: HomeAssistant, aiohttp_client, aioclient_mock
hass: HomeAssistant, component_factory: ComponentFactory, exception: Exception
) -> None:
"""Test auth failure."""
config = await setup_hass(hass)
profiles = config[const.DOMAIN][const.CONF_PROFILES]
await async_process_ha_core_config(
hass, {"internal_url": "http://example.local"},
person0 = new_profile_config(
"person0",
0,
api_response_user_get_device=exception,
api_response_measure_get_meas=exception,
api_response_sleep_get_summary=exception,
)
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,
await component_factory.configure_component(profile_configs=(person0,))
assert not async_get_flow_for_user_id(hass, person0.user_id)
await component_factory.setup_profile(person0.user_id)
data_manager = get_data_manager_by_user_id(hass, person0.user_id)
await data_manager.poll_data_update_coordinator.async_refresh()
flows = async_get_flow_for_user_id(hass, person0.user_id)
assert flows
assert len(flows) == 1
flow = flows[0]
assert flow["handler"] == const.DOMAIN
assert flow["context"]["profile"] == person0.profile
assert flow["context"]["userid"] == person0.user_id
result = await hass.config_entries.flow.async_configure(
flow["flow_id"], user_input={}
)
assert result
assert result["type"] == "external"
assert result["handler"] == const.DOMAIN
assert result["step_id"] == "auth"
await component_factory.unload(person0)
async def test_set_config_unique_id(
hass: HomeAssistant, component_factory: ComponentFactory
) -> None:
"""Test upgrading configs to use a unique id."""
person0 = new_profile_config("person0", 0)
await component_factory.configure_component(profile_configs=(person0,))
config_entry = MockConfigEntry(
domain=DOMAIN,
data={"token": {"userid": "my_user_id"}, "profile": person0.profile},
)
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(f"{AbstractWithingsApi.URL}/v2/user?.*action=getdevice(&.*|$)"),
status_code=200,
json={"status": 401, "body": {}},
with patch("homeassistant.components.withings.async_get_data_manager") as mock:
data_manager: DataManager = MagicMock(spec=DataManager)
data_manager.poll_data_update_coordinator = MagicMock(
spec=DataUpdateCoordinator
)
data_manager.poll_data_update_coordinator.last_update_success = True
mock.return_value = data_manager
config_entry.add_to_hass(hass)
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.CONF_PROFILES]
await async_process_ha_core_config(
hass, {"internal_url": "http://example.local"},
)
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.REM.real,
},
{
"startdate": "2019-02-01 01:00:00",
"enddate": "2019-02-01 02: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 01:00:00",
"enddate": "2019-02-01 02:00:00",
"state": SleepState.LIGHT.real,
},
{
"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,
)
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.LIGHT.real,
},
{
"startdate": "2019-02-01 02:00:00",
"enddate": "2019-02-01 03:00:00",
"state": SleepState.REM.real,
},
{
"startdate": "2019-02-01 01:00:00",
"enddate": "2019-02-01 02:00:00",
"state": SleepState.AWAKE.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[1], const.MEAS_HYDRATION, STATE_UNKNOWN),
(profiles[3], const.MEAS_FAT_FREE_MASS_KG, STATE_UNKNOWN),
)
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:
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_setup(config_entry.entry_id)
assert config_entry.unique_id == "my_user_id"

View file

@ -0,0 +1,335 @@
"""Tests for the Withings component."""
import time
from typing import Any
from unittest.mock import patch
import arrow
import pytz
from withings_api.common import (
GetSleepSummaryData,
GetSleepSummarySerie,
MeasureGetMeasGroup,
MeasureGetMeasGroupAttrib,
MeasureGetMeasGroupCategory,
MeasureGetMeasMeasure,
MeasureGetMeasResponse,
MeasureType,
NotifyAppli,
SleepGetSummaryResponse,
SleepModel,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.withings.common import (
WITHINGS_MEASUREMENTS_MAP,
WithingsAttribute,
async_get_entity_id,
get_platform_attributes,
)
from homeassistant.components.withings.const import Measurement
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.entity_registry import EntityRegistry
from .common import ComponentFactory, new_profile_config
PERSON0 = new_profile_config(
"person0",
0,
api_response_measure_get_meas=MeasureGetMeasResponse(
measuregrps=(
MeasureGetMeasGroup(
attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER,
category=MeasureGetMeasGroupCategory.REAL,
created=time.time(),
date=time.time(),
deviceid="DEV_ID",
grpid=1,
measures=(
MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=70),
MeasureGetMeasMeasure(
type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=5
),
MeasureGetMeasMeasure(
type=MeasureType.FAT_FREE_MASS, unit=0, value=60
),
MeasureGetMeasMeasure(
type=MeasureType.MUSCLE_MASS, unit=0, value=50
),
MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=10),
MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=2),
MeasureGetMeasMeasure(
type=MeasureType.TEMPERATURE, unit=0, value=40
),
MeasureGetMeasMeasure(
type=MeasureType.BODY_TEMPERATURE, unit=0, value=40
),
MeasureGetMeasMeasure(
type=MeasureType.SKIN_TEMPERATURE, unit=0, value=20
),
MeasureGetMeasMeasure(
type=MeasureType.FAT_RATIO, unit=-3, value=70
),
MeasureGetMeasMeasure(
type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=70
),
MeasureGetMeasMeasure(
type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=100
),
MeasureGetMeasMeasure(
type=MeasureType.HEART_RATE, unit=0, value=60
),
MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=95),
MeasureGetMeasMeasure(
type=MeasureType.HYDRATION, unit=-2, value=95
),
MeasureGetMeasMeasure(
type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=100
),
),
),
MeasureGetMeasGroup(
attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER_AMBIGUOUS,
category=MeasureGetMeasGroupCategory.REAL,
created=time.time(),
date=time.time(),
deviceid="DEV_ID",
grpid=1,
measures=(
MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71),
MeasureGetMeasMeasure(
type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=4
),
MeasureGetMeasMeasure(
type=MeasureType.FAT_FREE_MASS, unit=0, value=40
),
MeasureGetMeasMeasure(
type=MeasureType.MUSCLE_MASS, unit=0, value=51
),
MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11),
MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=201),
MeasureGetMeasMeasure(
type=MeasureType.TEMPERATURE, unit=0, value=41
),
MeasureGetMeasMeasure(
type=MeasureType.BODY_TEMPERATURE, unit=0, value=34
),
MeasureGetMeasMeasure(
type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21
),
MeasureGetMeasMeasure(
type=MeasureType.FAT_RATIO, unit=-3, value=71
),
MeasureGetMeasMeasure(
type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71
),
MeasureGetMeasMeasure(
type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101
),
MeasureGetMeasMeasure(
type=MeasureType.HEART_RATE, unit=0, value=61
),
MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=98),
MeasureGetMeasMeasure(
type=MeasureType.HYDRATION, unit=-2, value=96
),
MeasureGetMeasMeasure(
type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=102
),
),
),
),
more=False,
timezone=pytz.UTC,
updatetime=arrow.get("2019-08-01"),
offset=0,
),
api_response_sleep_get_summary=SleepGetSummaryResponse(
more=False,
offset=0,
series=(
GetSleepSummarySerie(
timezone=pytz.UTC,
model=SleepModel.SLEEP_MONITOR,
startdate=arrow.get("2019-02-01"),
enddate=arrow.get("2019-02-01"),
date=arrow.get("2019-02-01"),
modified=arrow.get(12345),
data=GetSleepSummaryData(
breathing_disturbances_intensity=110,
deepsleepduration=111,
durationtosleep=112,
durationtowakeup=113,
hr_average=114,
hr_max=115,
hr_min=116,
lightsleepduration=117,
remsleepduration=118,
rr_average=119,
rr_max=120,
rr_min=121,
sleep_score=122,
snoring=123,
snoringepisodecount=124,
wakeupcount=125,
wakeupduration=126,
),
),
GetSleepSummarySerie(
timezone=pytz.UTC,
model=SleepModel.SLEEP_MONITOR,
startdate=arrow.get("2019-02-01"),
enddate=arrow.get("2019-02-01"),
date=arrow.get("2019-02-01"),
modified=arrow.get(12345),
data=GetSleepSummaryData(
breathing_disturbances_intensity=210,
deepsleepduration=211,
durationtosleep=212,
durationtowakeup=213,
hr_average=214,
hr_max=215,
hr_min=216,
lightsleepduration=217,
remsleepduration=218,
rr_average=219,
rr_max=220,
rr_min=221,
sleep_score=222,
snoring=223,
snoringepisodecount=224,
wakeupcount=225,
wakeupduration=226,
),
),
),
),
)
EXPECTED_DATA = (
(PERSON0, Measurement.WEIGHT_KG, 70.0),
(PERSON0, Measurement.FAT_MASS_KG, 5.0),
(PERSON0, Measurement.FAT_FREE_MASS_KG, 60.0),
(PERSON0, Measurement.MUSCLE_MASS_KG, 50.0),
(PERSON0, Measurement.BONE_MASS_KG, 10.0),
(PERSON0, Measurement.HEIGHT_M, 2.0),
(PERSON0, Measurement.FAT_RATIO_PCT, 0.07),
(PERSON0, Measurement.DIASTOLIC_MMHG, 70.0),
(PERSON0, Measurement.SYSTOLIC_MMGH, 100.0),
(PERSON0, Measurement.HEART_PULSE_BPM, 60.0),
(PERSON0, Measurement.SPO2_PCT, 0.95),
(PERSON0, Measurement.HYDRATION, 0.95),
(PERSON0, Measurement.PWV, 100.0),
(PERSON0, Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, 160.0),
(PERSON0, Measurement.SLEEP_DEEP_DURATION_SECONDS, 322),
(PERSON0, Measurement.SLEEP_HEART_RATE_AVERAGE, 164.0),
(PERSON0, Measurement.SLEEP_HEART_RATE_MAX, 165.0),
(PERSON0, Measurement.SLEEP_HEART_RATE_MIN, 166.0),
(PERSON0, Measurement.SLEEP_LIGHT_DURATION_SECONDS, 334),
(PERSON0, Measurement.SLEEP_REM_DURATION_SECONDS, 336),
(PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, 169.0),
(PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_MAX, 170.0),
(PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_MIN, 171.0),
(PERSON0, Measurement.SLEEP_SCORE, 222),
(PERSON0, Measurement.SLEEP_SNORING, 173.0),
(PERSON0, Measurement.SLEEP_SNORING_EPISODE_COUNT, 348),
(PERSON0, Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, 162.0),
(PERSON0, Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, 163.0),
(PERSON0, Measurement.SLEEP_WAKEUP_COUNT, 350),
(PERSON0, Measurement.SLEEP_WAKEUP_DURATION_SECONDS, 176.0),
)
def async_assert_state_equals(
entity_id: str, state_obj: State, expected: Any, attribute: WithingsAttribute
) -> None:
"""Assert at given state matches what is expected."""
assert state_obj, f"Expected entity {entity_id} to exist but it did not"
assert state_obj.state == str(expected), (
f"Expected {expected} but was {state_obj.state} "
f"for measure {attribute.measurement}, {entity_id}"
)
async def test_sensor_default_enabled_entities(
hass: HomeAssistant, component_factory: ComponentFactory
) -> None:
"""Test entities enabled by default."""
entity_registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry()
await component_factory.configure_component(profile_configs=(PERSON0,))
# Assert entities should not exist yet.
for attribute in get_platform_attributes(SENSOR_DOMAIN):
assert not await async_get_entity_id(hass, attribute, PERSON0.user_id)
# person 0
await component_factory.setup_profile(PERSON0.user_id)
# Assert entities should exist.
for attribute in get_platform_attributes(SENSOR_DOMAIN):
entity_id = await async_get_entity_id(hass, attribute, PERSON0.user_id)
assert entity_id
assert entity_registry.async_is_registered(entity_id)
resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.SLEEP)
assert resp.message_code == 0
resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.WEIGHT)
assert resp.message_code == 0
for person, measurement, expected in EXPECTED_DATA:
attribute = WITHINGS_MEASUREMENTS_MAP[measurement]
entity_id = await async_get_entity_id(hass, attribute, person.user_id)
state_obj = hass.states.get(entity_id)
if attribute.enabled_by_default:
async_assert_state_equals(entity_id, state_obj, expected, attribute)
else:
assert state_obj is None
# Unload
await component_factory.unload(PERSON0)
async def test_all_entities(
hass: HomeAssistant, component_factory: ComponentFactory
) -> None:
"""Test all entities."""
entity_registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry()
with patch(
"homeassistant.components.withings.sensor.BaseWithingsSensor.entity_registry_enabled_default"
) as enabled_by_default_mock:
enabled_by_default_mock.return_value = True
await component_factory.configure_component(profile_configs=(PERSON0,))
# Assert entities should not exist yet.
for attribute in get_platform_attributes(SENSOR_DOMAIN):
assert not await async_get_entity_id(hass, attribute, PERSON0.user_id)
# person 0
await component_factory.setup_profile(PERSON0.user_id)
# Assert entities should exist.
for attribute in get_platform_attributes(SENSOR_DOMAIN):
entity_id = await async_get_entity_id(hass, attribute, PERSON0.user_id)
assert entity_id
assert entity_registry.async_is_registered(entity_id)
resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.SLEEP)
assert resp.message_code == 0
resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.WEIGHT)
assert resp.message_code == 0
for person, measurement, expected in EXPECTED_DATA:
attribute = WITHINGS_MEASUREMENTS_MAP[measurement]
entity_id = await async_get_entity_id(hass, attribute, person.user_id)
state_obj = hass.states.get(entity_id)
async_assert_state_equals(entity_id, state_obj, expected, attribute)
# Unload
await component_factory.unload(PERSON0)