Add Withings webhooks (#34447)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
29df13abe9
commit
a6a6a7b69c
16 changed files with 2201 additions and 1455 deletions
|
@ -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)
|
||||
|
|
40
homeassistant/components/withings/binary_sensor.py
Normal file
40
homeassistant/components/withings/binary_sensor.py
Normal 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
|
@ -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)
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
22
tests/components/withings/conftest.py
Normal file
22
tests/components/withings/conftest.py
Normal 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)
|
62
tests/components/withings/test_binary_sensor.py
Normal file
62
tests/components/withings/test_binary_sensor.py
Normal 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)
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
|
|
335
tests/components/withings/test_sensor.py
Normal file
335
tests/components/withings/test_sensor.py
Normal 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)
|
Loading…
Add table
Add a link
Reference in a new issue