Add Withings webhooks (#34447)

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

View file

@ -3,22 +3,39 @@ Support for the Withings API.
For more details about this platform, please refer to the documentation at 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") # Add a unique id if it's an older config entry.
data = entry.data if entry.unique_id != entry.data["token"]["userid"]:
creds = data.get(CREDENTIALS, {}) config_updates["unique_id"] = entry.data["token"]["userid"]
hass.config_entries.async_update_entry(
entry, # Add the webhook configuration.
data={ if CONF_WEBHOOK_ID not in entry.data:
"auth_implementation": DOMAIN, webhook_id = webhook.async_generate_id()
"implementation": DOMAIN, config_updates["data"] = {
"profile": data.get("profile"), **entry.data,
"token": { **{
"access_token": creds.get("access_token"), const.CONF_USE_WEBHOOK: hass.data[DOMAIN][const.CONFIG][
"refresh_token": creds.get("refresh_token"), const.CONF_USE_WEBHOOK
"expires_at": int(creds.get("token_expiry")), ],
"type": creds.get("token_type"), CONF_WEBHOOK_ID: webhook_id,
"userid": creds.get("userid") or creds.get("user_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,
) )
implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation( # Perform first webhook subscription check.
hass, entry if data_manager.webhook_config.enabled:
data_manager.async_start_polling_webhook_subscriptions()
@callback
def async_call_later_callback(now) -> None:
hass.async_create_task(
data_manager.subscription_update_coordinator.async_refresh()
) )
data_manager = get_data_manager(hass, entry, implementation) # Start subscription check in the background, outside this component's setup.
async_call_later(hass, 1, async_call_later_callback)
_LOGGER.debug("Confirming we're authenticated")
try:
await data_manager.check_authenticated()
except NotAuthenticatedError:
_LOGGER.error(
"Withings auth tokens exired for profile %s, remove and re-add the integration",
data_manager.profile,
)
return False
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)

View file

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

File diff suppressed because it is too large Load diff

View file

@ -11,8 +11,9 @@ from homeassistant.helpers import config_entry_oauth2_flow
_LOGGER = logging.getLogger(__name__) _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)

View file

@ -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}"

View file

@ -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"]
} }

View file

@ -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

View file

@ -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.",

View file

@ -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

View file

@ -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

View file

@ -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."""
message: str
message_code: int
class ComponentFactory:
"""Manages the setup and unloading of the withing component and profiles."""
def __init__(
self,
hass: HomeAssistant,
api_class_mock: MagicMock,
aiohttp_client,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Initialize the object."""
self._hass = hass
self._api_class_mock = api_class_mock
self._aiohttp_client = aiohttp_client
self._aioclient_mock = aioclient_mock
self._client_id = None
self._client_secret = None
self._profile_configs: Tuple[ProfileConfig, ...] = ()
async def configure_component(
self,
client_id: str = "my_client_id",
client_secret: str = "my_client_secret",
profile_configs: Tuple[ProfileConfig, ...] = (),
) -> None:
"""Configure the wihings component."""
self._client_id = client_id
self._client_secret = client_secret
self._profile_configs = profile_configs
hass_config = { hass_config = {
"homeassistant": { "homeassistant": {
CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC,
CONF_EXTERNAL_URL: "http://example.local/", CONF_EXTERNAL_URL: "http://127.0.0.1:8080/",
}, },
api.DOMAIN: {}, api.DOMAIN: {},
http.DOMAIN: {"server_port": 8080},
const.DOMAIN: { const.DOMAIN: {
CONF_CLIENT_ID: "my_client_id", CONF_CLIENT_ID: self._client_id,
CONF_CLIENT_SECRET: "my_client_secret", CONF_CLIENT_SECRET: self._client_secret,
const.CONF_PROFILES: profiles, const.CONF_USE_WEBHOOK: True,
const.CONF_PROFILES: [
profile_config.profile for profile_config in self._profile_configs
],
}, },
} }
await async_process_ha_core_config(hass, hass_config.get("homeassistant")) await async_process_ha_core_config(self._hass, hass_config.get("homeassistant"))
assert await async_setup_component(hass, http.DOMAIN, hass_config) assert await async_setup_component(self._hass, HA_DOMAIN, {})
assert await async_setup_component(hass, api.DOMAIN, hass_config) assert await async_setup_component(self._hass, webhook.DOMAIN, hass_config)
assert await async_setup_component(hass, const.DOMAIN, hass_config)
await hass.async_block_till_done()
return 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 configure_integration( async def setup_profile(self, user_id: int) -> ConfigEntryWithingsApi:
hass: HomeAssistant, """Set up a user profile through config flows."""
aiohttp_client, profile_config = next(
aioclient_mock, iter(
profiles: List[str], [
profile_index: int, profile_config
get_device_response: dict, for profile_config in self._profile_configs
getmeasures_response: dict, if profile_config.user_id == user_id
get_sleep_response: dict, ]
get_sleep_summary_response: dict, )
) -> None:
"""Configure the integration for a specific profile."""
selected_profile = profiles[profile_index]
with requests_mock.mock() as rqmck:
rqmck.get(
re.compile(f"{AbstractWithingsApi.URL}/v2/user?.*action=getdevice(&.*|$)"),
status_code=200,
json=get_device_response,
) )
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,
},
},
],
},
}

View file

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

View file

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

View file

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

View file

@ -1,43 +1,28 @@
"""Tests for the Withings component.""" """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}}
) )
data_manager.poll_data_update_coordinator.last_update_success = True
mock.return_value = data_manager
config_entry.add_to_hass(hass)
with requests_mock.mock() as rqmck: await hass.config_entries.async_setup(config_entry.entry_id)
rqmck.get( assert config_entry.unique_id == "my_user_id"
re.compile(f"{AbstractWithingsApi.URL}/v2/user?.*action=getdevice(&.*|$)"),
status_code=200,
json={"status": 401, "body": {}},
)
assert not (await async_setup_entry(hass, entry))
async def test_full_setup(hass: HomeAssistant, aiohttp_client, aioclient_mock) -> None:
"""Test the whole component lifecycle."""
config = await setup_hass(hass)
profiles = config[const.DOMAIN][const.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()

View file

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