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
|
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
|
import voluptuous as vol
|
||||||
from withings_api import WithingsAuth
|
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.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
|
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID
|
||||||
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
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 (
|
from .common import (
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
NotAuthenticatedError,
|
|
||||||
WithingsLocalOAuth2Implementation,
|
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(
|
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_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_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,
|
cv.ensure_list,
|
||||||
vol.Unique(),
|
vol.Unique(),
|
||||||
vol.Length(min=1),
|
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."""
|
"""Set up the Withings component."""
|
||||||
conf = config.get(DOMAIN, {})
|
conf = config.get(DOMAIN, {})
|
||||||
if not conf:
|
if not conf:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
hass.data[DOMAIN] = {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(
|
config_flow.WithingsFlowHandler.async_register_implementation(
|
||||||
hass,
|
hass,
|
||||||
WithingsLocalOAuth2Implementation(
|
WithingsLocalOAuth2Implementation(
|
||||||
hass,
|
hass,
|
||||||
DOMAIN,
|
const.DOMAIN,
|
||||||
conf[CONF_CLIENT_ID],
|
conf[CONF_CLIENT_ID],
|
||||||
conf[CONF_CLIENT_SECRET],
|
conf[CONF_CLIENT_SECRET],
|
||||||
f"{WithingsAuth.URL}/oauth2_user/authorize2",
|
f"{WithingsAuth.URL}/oauth2_user/authorize2",
|
||||||
|
@ -62,52 +82,127 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
|
||||||
return True
|
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."""
|
"""Set up Withings from a config entry."""
|
||||||
# Upgrading existing token information to hass managed tokens.
|
config_updates = {}
|
||||||
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"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
# Add a unique id if it's an older config entry.
|
||||||
hass, 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")
|
@callback
|
||||||
try:
|
def async_call_later_callback(now) -> None:
|
||||||
await data_manager.check_authenticated()
|
hass.async_create_task(
|
||||||
except NotAuthenticatedError:
|
data_manager.subscription_update_coordinator.async_refresh()
|
||||||
_LOGGER.error(
|
)
|
||||||
"Withings auth tokens exired for profile %s, remove and re-add the integration",
|
|
||||||
data_manager.profile,
|
# Start subscription check in the background, outside this component's setup.
|
||||||
)
|
async_call_later(hass, 1, async_call_later_callback)
|
||||||
return False
|
|
||||||
|
|
||||||
hass.async_create_task(
|
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
|
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."""
|
"""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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@config_entries.HANDLERS.register(const.DOMAIN)
|
class WithingsFlowHandler(
|
||||||
class WithingsFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler):
|
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=const.DOMAIN
|
||||||
|
):
|
||||||
"""Handle a config flow."""
|
"""Handle a config flow."""
|
||||||
|
|
||||||
DOMAIN = const.DOMAIN
|
DOMAIN = const.DOMAIN
|
||||||
|
@ -33,6 +34,7 @@ class WithingsFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler):
|
||||||
AuthScope.USER_INFO.value,
|
AuthScope.USER_INFO.value,
|
||||||
AuthScope.USER_METRICS.value,
|
AuthScope.USER_METRICS.value,
|
||||||
AuthScope.USER_ACTIVITY.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)}),
|
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:
|
async def async_step_finish(self, data: dict) -> dict:
|
||||||
"""Finish the flow."""
|
"""Finish the flow."""
|
||||||
self._current_data = None
|
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)
|
return self.async_create_entry(title=data[const.PROFILE], data=data)
|
||||||
|
|
|
@ -1,61 +1,59 @@
|
||||||
"""Constants used by the Withings component."""
|
"""Constants used by the Withings component."""
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
import homeassistant.const as const
|
import homeassistant.const as const
|
||||||
|
|
||||||
DOMAIN = "withings"
|
|
||||||
|
|
||||||
CONF_PROFILES = "profiles"
|
CONF_PROFILES = "profiles"
|
||||||
|
CONF_USE_WEBHOOK = "use_webhook"
|
||||||
|
|
||||||
DATA_MANAGER = "data_manager"
|
DATA_MANAGER = "data_manager"
|
||||||
|
|
||||||
BASE_URL = "base_url"
|
|
||||||
CODE = "code"
|
|
||||||
CONFIG = "config"
|
CONFIG = "config"
|
||||||
CREDENTIALS = "credentials"
|
DOMAIN = "withings"
|
||||||
LOG_NAMESPACE = "homeassistant.components.withings"
|
LOG_NAMESPACE = "homeassistant.components.withings"
|
||||||
MEASURES = "measures"
|
|
||||||
PROFILE = "profile"
|
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
|
class Measurement(Enum):
|
||||||
SCAN_INTERVAL = 60
|
"""Measurement supported by the withings integration."""
|
||||||
|
|
||||||
STATE_UNKNOWN = const.STATE_UNKNOWN
|
BODY_TEMP_C = "body_temperature_c"
|
||||||
STATE_AWAKE = "awake"
|
BONE_MASS_KG = "bone_mass_kg"
|
||||||
STATE_DEEP = "deep"
|
DIASTOLIC_MMHG = "diastolic_blood_pressure_mmhg"
|
||||||
STATE_LIGHT = "light"
|
FAT_FREE_MASS_KG = "fat_free_mass_kg"
|
||||||
STATE_REM = "rem"
|
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_BEATS_PER_MINUTE = "bpm"
|
||||||
UOM_BREATHS_PER_MINUTE = f"br/{const.TIME_MINUTES}"
|
UOM_BREATHS_PER_MINUTE = f"br/{const.TIME_MINUTES}"
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
"name": "Withings",
|
"name": "Withings",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/withings",
|
"documentation": "https://www.home-assistant.io/integrations/withings",
|
||||||
"requirements": ["withings-api==2.1.3"],
|
"requirements": ["withings-api==2.1.6"],
|
||||||
"dependencies": ["api", "http", "webhook"],
|
"dependencies": ["http", "webhook"],
|
||||||
"codeowners": ["@vangorra"]
|
"codeowners": ["@vangorra"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,34 +1,12 @@
|
||||||
"""Sensors flow for Withings."""
|
"""Sensors flow for Withings."""
|
||||||
from typing import Callable, List, Union
|
from typing import Callable, List, Union
|
||||||
|
|
||||||
from withings_api.common import (
|
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||||
GetSleepSummaryField,
|
|
||||||
MeasureGetMeasResponse,
|
|
||||||
MeasureGroupAttribs,
|
|
||||||
MeasureType,
|
|
||||||
SleepGetSummaryResponse,
|
|
||||||
get_measure_value,
|
|
||||||
)
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
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.core import HomeAssistant
|
||||||
from homeassistant.helpers import config_entry_oauth2_flow
|
|
||||||
from homeassistant.helpers.entity import Entity
|
from homeassistant.helpers.entity import Entity
|
||||||
from homeassistant.util import slugify
|
|
||||||
|
|
||||||
from . import const
|
from .common import BaseWithingsSensor, async_create_entities
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
|
@ -37,401 +15,18 @@ async def async_setup_entry(
|
||||||
async_add_entities: Callable[[List[Entity], bool], None],
|
async_add_entities: Callable[[List[Entity], bool], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the sensor config entry."""
|
"""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)
|
async_add_entities(entities, True)
|
||||||
|
|
||||||
|
|
||||||
class WithingsAttribute:
|
class WithingsHealthSensor(BaseWithingsSensor):
|
||||||
"""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):
|
|
||||||
"""Implementation of a Withings sensor."""
|
"""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
|
@property
|
||||||
def name(self) -> str:
|
def state(self) -> Union[None, str, int, float]:
|
||||||
"""Return the name of the sensor."""
|
"""Return the state of the entity."""
|
||||||
return f"Withings {self._attribute.measurement} {self._slug}"
|
return self._state_data
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
|
"flow_title": "Withings: {profile}",
|
||||||
"step": {
|
"step": {
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "User 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.",
|
"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" }
|
"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": {
|
"abort": {
|
||||||
"authorize_url_timeout": "Timeout generating authorize url.",
|
"authorize_url_timeout": "Timeout generating authorize url.",
|
||||||
|
|
|
@ -2205,7 +2205,7 @@ wiffi==1.0.0
|
||||||
wirelesstagpy==0.4.0
|
wirelesstagpy==0.4.0
|
||||||
|
|
||||||
# homeassistant.components.withings
|
# homeassistant.components.withings
|
||||||
withings-api==2.1.3
|
withings-api==2.1.6
|
||||||
|
|
||||||
# homeassistant.components.wled
|
# homeassistant.components.wled
|
||||||
wled==0.4.3
|
wled==0.4.3
|
||||||
|
|
|
@ -923,7 +923,7 @@ watchdog==0.8.3
|
||||||
wiffi==1.0.0
|
wiffi==1.0.0
|
||||||
|
|
||||||
# homeassistant.components.withings
|
# homeassistant.components.withings
|
||||||
withings-api==2.1.3
|
withings-api==2.1.6
|
||||||
|
|
||||||
# homeassistant.components.wled
|
# homeassistant.components.wled
|
||||||
wled==0.4.3
|
wled==0.4.3
|
||||||
|
|
|
@ -1,24 +1,32 @@
|
||||||
"""Common data for for the withings component tests."""
|
"""Common data for for the withings component tests."""
|
||||||
import re
|
from dataclasses import dataclass
|
||||||
import time
|
from typing import List, Optional, Tuple, Union
|
||||||
from typing import List
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import requests_mock
|
from aiohttp.test_utils import TestClient
|
||||||
from withings_api import AbstractWithingsApi
|
import arrow
|
||||||
|
import pytz
|
||||||
from withings_api.common import (
|
from withings_api.common import (
|
||||||
MeasureGetMeasGroupAttrib,
|
MeasureGetMeasResponse,
|
||||||
MeasureGetMeasGroupCategory,
|
NotifyAppli,
|
||||||
MeasureType,
|
NotifyListResponse,
|
||||||
SleepModel,
|
SleepGetSummaryResponse,
|
||||||
SleepState,
|
UserGetDeviceResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
from homeassistant import data_entry_flow
|
from homeassistant import data_entry_flow
|
||||||
import homeassistant.components.api as api
|
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
|
import homeassistant.components.withings.const as const
|
||||||
from homeassistant.config import async_process_ha_core_config
|
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 (
|
from homeassistant.const import (
|
||||||
CONF_CLIENT_ID,
|
CONF_CLIENT_ID,
|
||||||
CONF_CLIENT_SECRET,
|
CONF_CLIENT_SECRET,
|
||||||
|
@ -28,364 +36,295 @@ from homeassistant.const import (
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import config_entry_oauth2_flow
|
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.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:
|
@dataclass
|
||||||
"""Get an entity id for a measure and profile."""
|
class ProfileConfig:
|
||||||
return "sensor.{}_{}_{}".format(const.DOMAIN, measure, slugify(profile))
|
"""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(
|
def new_profile_config(
|
||||||
hass: HomeAssistant, profile: str, measure: str, expected
|
profile: str,
|
||||||
) -> None:
|
user_id: int,
|
||||||
"""Assert the state of a withings sensor."""
|
api_response_user_get_device: Optional[
|
||||||
entity_id = get_entity_id(measure, profile)
|
Union[UserGetDeviceResponse, Exception]
|
||||||
state_obj = hass.states.get(entity_id)
|
] = None,
|
||||||
|
api_response_measure_get_meas: Optional[
|
||||||
assert state_obj, f"Expected entity {entity_id} to exist but it did not"
|
Union[MeasureGetMeasResponse, Exception]
|
||||||
|
] = None,
|
||||||
assert state_obj.state == str(expected), (
|
api_response_sleep_get_summary: Optional[
|
||||||
f"Expected {expected} but was {state_obj.state} "
|
Union[SleepGetSummaryResponse, Exception]
|
||||||
f"for measure {measure}, {entity_id}"
|
] = 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:
|
@dataclass
|
||||||
"""Configure Home Assistant."""
|
class WebhookResponse:
|
||||||
profiles = ["Person0", "Person1", "Person2", "Person3", "Person4"]
|
"""Response data from a webhook."""
|
||||||
|
|
||||||
hass_config = {
|
message: str
|
||||||
"homeassistant": {
|
message_code: int
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def configure_integration(
|
class ComponentFactory:
|
||||||
hass: HomeAssistant,
|
"""Manages the setup and unloading of the withing component and profiles."""
|
||||||
aiohttp_client,
|
|
||||||
aioclient_mock,
|
|
||||||
profiles: List[str],
|
|
||||||
profile_index: int,
|
|
||||||
get_device_response: dict,
|
|
||||||
getmeasures_response: dict,
|
|
||||||
get_sleep_response: dict,
|
|
||||||
get_sleep_summary_response: dict,
|
|
||||||
) -> None:
|
|
||||||
"""Configure the integration for a specific profile."""
|
|
||||||
selected_profile = profiles[profile_index]
|
|
||||||
|
|
||||||
with requests_mock.mock() as rqmck:
|
def __init__(
|
||||||
rqmck.get(
|
self,
|
||||||
re.compile(f"{AbstractWithingsApi.URL}/v2/user?.*action=getdevice(&.*|$)"),
|
hass: HomeAssistant,
|
||||||
status_code=200,
|
api_class_mock: MagicMock,
|
||||||
json=get_device_response,
|
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(
|
api_mock: ConfigEntryWithingsApi = MagicMock(spec=ConfigEntryWithingsApi)
|
||||||
re.compile(f"{AbstractWithingsApi.URL}/v2/sleep?.*action=get(&.*|$)"),
|
ComponentFactory._setup_api_method(
|
||||||
status_code=200,
|
api_mock.user_get_device, profile_config.api_response_user_get_device
|
||||||
json=get_sleep_response,
|
)
|
||||||
|
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(
|
self._api_class_mock.reset_mocks()
|
||||||
re.compile(
|
self._api_class_mock.return_value = api_mock
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get the withings config flow.
|
# 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}
|
const.DOMAIN, context={"source": SOURCE_USER}
|
||||||
)
|
)
|
||||||
assert result
|
assert result
|
||||||
# pylint: disable=protected-access
|
# pylint: disable=protected-access
|
||||||
state = config_entry_oauth2_flow._encode_jwt(
|
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["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP
|
||||||
assert result["url"] == (
|
assert result["url"] == (
|
||||||
"https://account.withings.com/oauth2_user/authorize2?"
|
"https://account.withings.com/oauth2_user/authorize2?"
|
||||||
"response_type=code&client_id=my_client_id&"
|
f"response_type=code&client_id={self._client_id}&"
|
||||||
"redirect_uri=http://example.local/auth/external/callback&"
|
"redirect_uri=http://127.0.0.1:8080/auth/external/callback&"
|
||||||
f"state={state}"
|
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.
|
# Simulate user being redirected from withings site.
|
||||||
client = await aiohttp_client(hass.http.app)
|
client: TestClient = await self._aiohttp_client(self._hass.http.app)
|
||||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}")
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
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",
|
"https://account.withings.com/oauth2/token",
|
||||||
json={
|
json={
|
||||||
"refresh_token": "mock-refresh-token",
|
"refresh_token": "mock-refresh-token",
|
||||||
"access_token": "mock-access-token",
|
"access_token": "mock-access-token",
|
||||||
"type": "Bearer",
|
"type": "Bearer",
|
||||||
"expires_in": 60,
|
"expires_in": 60,
|
||||||
"userid": "myuserid",
|
"userid": profile_config.user_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Present user with a list of profiles to choose from.
|
# 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("type") == "form"
|
||||||
assert result.get("step_id") == "profile"
|
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.
|
# Select the user profile.
|
||||||
result = await hass.config_entries.flow.async_configure(
|
result = await self._hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"], {const.PROFILE: selected_profile}
|
result["flow_id"], {const.PROFILE: profile_config.profile}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Finish the config flow by calling it again.
|
# Finish the config flow by calling it again.
|
||||||
assert result.get("type") == "create_entry"
|
assert result.get("type") == "create_entry"
|
||||||
assert result.get("result")
|
assert result.get("result")
|
||||||
config_data = result.get("result").data
|
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("auth_implementation") == const.DOMAIN
|
||||||
assert config_data.get("token")
|
assert config_data.get("token")
|
||||||
|
|
||||||
# Ensure all the flows are complete.
|
# Wait for remaining tasks to complete.
|
||||||
flows = hass.config_entries.flow.async_progress()
|
await self._hass.async_block_till_done()
|
||||||
assert not flows
|
|
||||||
|
# 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.
|
# 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": []}}
|
def get_config_entries_for_user_id(
|
||||||
|
hass: HomeAssistant, user_id: int
|
||||||
|
) -> Tuple[ConfigEntry]:
|
||||||
WITHINGS_GET_DEVICE_RESPONSE = {
|
"""Get a list of config entries that apply to a specific withings user."""
|
||||||
"status": 0,
|
return tuple(
|
||||||
"body": {
|
[
|
||||||
"devices": [
|
config_entry
|
||||||
{
|
for config_entry in hass.config_entries.async_entries(const.DOMAIN)
|
||||||
"type": "type1",
|
if config_entry.data.get("token", {}).get("userid") == user_id
|
||||||
"model": "model1",
|
|
||||||
"battery": "battery1",
|
|
||||||
"deviceid": "deviceid1",
|
|
||||||
"timezone": "UTC",
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
)
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
WITHINGS_MEASURES_RESPONSE_EMPTY = {
|
def async_get_flow_for_user_id(hass: HomeAssistant, user_id: int) -> List[dict]:
|
||||||
"status": 0,
|
"""Get a flow for a user id."""
|
||||||
"body": {"updatetime": "2019-08-01", "timezone": "UTC", "measuregrps": []},
|
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 = {
|
def get_data_manager_by_user_id(
|
||||||
"status": 0,
|
hass: HomeAssistant, user_id: int
|
||||||
"body": {
|
) -> Optional[DataManager]:
|
||||||
"updatetime": "2019-08-01",
|
"""Get a data manager by the user id."""
|
||||||
"timezone": "UTC",
|
return next(
|
||||||
"measuregrps": [
|
iter(
|
||||||
# Un-ambiguous groups.
|
[
|
||||||
{
|
data_manager
|
||||||
"grpid": 1,
|
for data_manager in get_all_data_managers(hass)
|
||||||
"attrib": MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER.real,
|
if data_manager.user_id == user_id
|
||||||
"date": time.time(),
|
]
|
||||||
"created": time.time(),
|
),
|
||||||
"category": MeasureGetMeasGroupCategory.REAL.real,
|
None,
|
||||||
"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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
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."""
|
"""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
|
import pytest
|
||||||
from withings_api import WithingsApi
|
import requests_mock
|
||||||
from withings_api.common import TimeoutException, UnauthorizedException
|
from withings_api.common import NotifyAppli, NotifyListProfile, NotifyListResponse
|
||||||
|
|
||||||
from homeassistant.components.withings.common import (
|
from homeassistant.components.withings.common import (
|
||||||
NotAuthenticatedError,
|
ConfigEntryWithingsApi,
|
||||||
WithingsDataManager,
|
DataManager,
|
||||||
|
WebhookConfig,
|
||||||
)
|
)
|
||||||
from homeassistant.exceptions import PlatformNotReady
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.util import dt
|
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")
|
async def test_config_entry_withings_api(hass: HomeAssistant) -> None:
|
||||||
def withings_api_fixture() -> WithingsApi:
|
"""Test ConfigEntryWithingsApi."""
|
||||||
"""Provide withings api."""
|
config_entry = MockConfigEntry(
|
||||||
withings_api = WithingsApi.__new__(WithingsApi)
|
data={"token": {"access_token": "mock_access_token", "expires_at": 1111111}}
|
||||||
withings_api.user_get_device = MagicMock()
|
)
|
||||||
withings_api.measure_get_meas = MagicMock()
|
config_entry.add_to_hass(hass)
|
||||||
withings_api.sleep_get = MagicMock()
|
|
||||||
withings_api.sleep_get_summary = MagicMock()
|
implementation_mock = MagicMock(spec=AbstractOAuth2Implementation)
|
||||||
return withings_api
|
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")
|
@pytest.mark.parametrize(
|
||||||
def data_manager_fixture(hass, withings_api: WithingsApi) -> WithingsDataManager:
|
["user_id", "arg_user_id", "arg_appli", "expected_code"],
|
||||||
"""Provide data manager."""
|
[
|
||||||
return WithingsDataManager(hass, "My Profile", withings_api)
|
[0, 0, NotifyAppli.WEIGHT.value, 0], # Success
|
||||||
|
[0, None, 1, 0], # Success, we ignore the user_id.
|
||||||
|
[0, None, None, 12], # No request body.
|
||||||
def test_print_service() -> None:
|
[0, "GG", None, 20], # appli not provided.
|
||||||
"""Test method."""
|
[0, 0, None, 20], # appli not provided.
|
||||||
# Go from None to True
|
[0, 0, 99, 21], # Invalid appli.
|
||||||
WithingsDataManager.service_available = None
|
[0, 11, NotifyAppli.WEIGHT.value, 0], # Success, we ignore the user_id
|
||||||
assert WithingsDataManager.print_service_available()
|
],
|
||||||
assert WithingsDataManager.service_available is True
|
)
|
||||||
assert not WithingsDataManager.print_service_available()
|
async def test_webhook_post(
|
||||||
assert not WithingsDataManager.print_service_available()
|
hass: HomeAssistant,
|
||||||
|
component_factory: ComponentFactory,
|
||||||
# Go from True to False
|
aiohttp_client,
|
||||||
assert WithingsDataManager.print_service_unavailable()
|
user_id: int,
|
||||||
assert WithingsDataManager.service_available is False
|
arg_user_id: Any,
|
||||||
assert not WithingsDataManager.print_service_unavailable()
|
arg_appli: Any,
|
||||||
assert not WithingsDataManager.print_service_unavailable()
|
expected_code: int,
|
||||||
|
|
||||||
# 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,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test method."""
|
"""Test webhook callback."""
|
||||||
hello_func = MagicMock(return_value="HELLO2")
|
person0 = new_profile_config("person0", user_id)
|
||||||
|
|
||||||
result = await data_manager.call(hello_func, throttle_domain="test")
|
await component_factory.configure_component(profile_configs=(person0,))
|
||||||
assert result == "HELLO2"
|
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")
|
client: TestClient = await aiohttp_client(hass.http.app)
|
||||||
assert result == "HELLO2"
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
async def test_data_manager_call_throttle_disabled(
|
urlparse(data_manager.webhook_config.url).path, data=post_data
|
||||||
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"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch_time_zone:
|
# Wait for remaining tasks to complete.
|
||||||
update_start_time = dt.now()
|
await hass.async_block_till_done()
|
||||||
await data_manager.update_sleep()
|
|
||||||
|
|
||||||
call_args = data_manager.api.sleep_get.call_args_list[0][1]
|
data = await resp.json()
|
||||||
startdate = call_args.get("startdate")
|
resp.close()
|
||||||
enddate = call_args.get("enddate")
|
|
||||||
|
|
||||||
assert startdate.tzname() == "CST"
|
assert data["code"] == expected_code
|
||||||
|
|
||||||
assert enddate.tzname() == "CST"
|
|
||||||
assert startdate.tzname() == "CST"
|
async def test_webhook_head(
|
||||||
assert update_start_time < enddate
|
hass: HomeAssistant, component_factory: ComponentFactory, aiohttp_client,
|
||||||
assert enddate < update_start_time + timedelta(seconds=1)
|
) -> None:
|
||||||
assert enddate > startdate
|
"""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."""
|
"""Tests for the Withings component."""
|
||||||
import re
|
from asynctest import MagicMock, patch
|
||||||
import time
|
import pytest
|
||||||
|
|
||||||
import requests_mock
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from withings_api import AbstractWithingsApi
|
from withings_api.common import UnauthorizedException
|
||||||
from withings_api.common import SleepModel, SleepState
|
|
||||||
|
|
||||||
import homeassistant.components.http as http
|
from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const
|
||||||
from homeassistant.components.withings import (
|
from homeassistant.components.withings.common import DataManager
|
||||||
CONFIG_SCHEMA,
|
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
|
||||||
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.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
WITHINGS_GET_DEVICE_RESPONSE,
|
ComponentFactory,
|
||||||
WITHINGS_GET_DEVICE_RESPONSE_EMPTY,
|
async_get_flow_for_user_id,
|
||||||
WITHINGS_MEASURES_RESPONSE,
|
get_data_manager_by_user_id,
|
||||||
WITHINGS_MEASURES_RESPONSE_EMPTY,
|
new_profile_config,
|
||||||
WITHINGS_SLEEP_RESPONSE,
|
|
||||||
WITHINGS_SLEEP_RESPONSE_EMPTY,
|
|
||||||
WITHINGS_SLEEP_SUMMARY_RESPONSE,
|
|
||||||
WITHINGS_SLEEP_SUMMARY_RESPONSE_EMPTY,
|
|
||||||
assert_state_equals,
|
|
||||||
configure_integration,
|
|
||||||
setup_hass,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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."""
|
"""Assert a schema config succeeds."""
|
||||||
hass_config = {http.DOMAIN: {}, const.DOMAIN: withings_config}
|
hass_config = {const.DOMAIN: withings_config}
|
||||||
|
|
||||||
return CONFIG_SCHEMA(hass_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_ID: "my_client_id",
|
||||||
CONF_CLIENT_SECRET: "my_client_secret",
|
CONF_CLIENT_SECRET: "my_client_secret",
|
||||||
|
const.CONF_USE_WEBHOOK: True,
|
||||||
const.CONF_PROFILES: ["Person 1", "Person 2"],
|
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:
|
def test_config_schema_profiles() -> None:
|
||||||
"""Test schema."""
|
"""Test schema."""
|
||||||
config_schema_assert_fail(
|
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()
|
hass.async_create_task.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
async def test_upgrade_token(
|
@pytest.mark.parametrize(
|
||||||
hass: HomeAssistant, aiohttp_client, aioclient_mock
|
["exception"],
|
||||||
) -> None:
|
[
|
||||||
"""Test upgrading from old config data format to new one."""
|
[UnauthorizedException("401")],
|
||||||
config = await setup_hass(hass)
|
[UnauthorizedException("401")],
|
||||||
profiles = config[const.DOMAIN][const.CONF_PROFILES]
|
[Exception("401, this is the message")],
|
||||||
|
],
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
async def test_auth_failure(
|
async def test_auth_failure(
|
||||||
hass: HomeAssistant, aiohttp_client, aioclient_mock
|
hass: HomeAssistant, component_factory: ComponentFactory, exception: Exception
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test auth failure."""
|
"""Test auth failure."""
|
||||||
config = await setup_hass(hass)
|
person0 = new_profile_config(
|
||||||
profiles = config[const.DOMAIN][const.CONF_PROFILES]
|
"person0",
|
||||||
|
0,
|
||||||
await async_process_ha_core_config(
|
api_response_user_get_device=exception,
|
||||||
hass, {"internal_url": "http://example.local"},
|
api_response_measure_get_meas=exception,
|
||||||
|
api_response_sleep_get_summary=exception,
|
||||||
)
|
)
|
||||||
|
|
||||||
await configure_integration(
|
await component_factory.configure_component(profile_configs=(person0,))
|
||||||
hass=hass,
|
assert not async_get_flow_for_user_id(hass, person0.user_id)
|
||||||
aiohttp_client=aiohttp_client,
|
|
||||||
aioclient_mock=aioclient_mock,
|
await component_factory.setup_profile(person0.user_id)
|
||||||
profiles=profiles,
|
data_manager = get_data_manager_by_user_id(hass, person0.user_id)
|
||||||
profile_index=0,
|
await data_manager.poll_data_update_coordinator.async_refresh()
|
||||||
get_device_response=WITHINGS_GET_DEVICE_RESPONSE_EMPTY,
|
|
||||||
getmeasures_response=WITHINGS_MEASURES_RESPONSE_EMPTY,
|
flows = async_get_flow_for_user_id(hass, person0.user_id)
|
||||||
get_sleep_response=WITHINGS_SLEEP_RESPONSE_EMPTY,
|
assert flows
|
||||||
get_sleep_summary_response=WITHINGS_SLEEP_SUMMARY_RESPONSE_EMPTY,
|
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)
|
with patch("homeassistant.components.withings.async_get_data_manager") as mock:
|
||||||
assert entries
|
data_manager: DataManager = MagicMock(spec=DataManager)
|
||||||
|
data_manager.poll_data_update_coordinator = MagicMock(
|
||||||
entry = entries[0]
|
spec=DataUpdateCoordinator
|
||||||
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": {}},
|
|
||||||
)
|
)
|
||||||
|
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))
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
assert config_entry.unique_id == "my_user_id"
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
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