Add coordinator to Withings (#100378)

* Add coordinator to Withings

* Add coordinator to Withings

* Fix tests

* Remove common files

* Fix tests

* Fix tests

* Rename to Entity

* Fix

* Rename webhook handler

* Fix

* Fix external url

* Update homeassistant/components/withings/entity.py

Co-authored-by: Luke Lashley <conway220@gmail.com>

* Update homeassistant/components/withings/entity.py

Co-authored-by: Luke Lashley <conway220@gmail.com>

* Update homeassistant/components/withings/entity.py

Co-authored-by: Luke Lashley <conway220@gmail.com>

* Update homeassistant/components/withings/entity.py

Co-authored-by: Luke Lashley <conway220@gmail.com>

* fix imports

* Simplify

* Simplify

* Fix feedback

* Test if this makes changes clearer

* Test if this makes changes clearer

* Fix tests

* Remove name

* Fix feedback

---------

Co-authored-by: Luke Lashley <conway220@gmail.com>
This commit is contained in:
Joost Lekkerkerker 2023-09-26 09:17:11 +02:00 committed by GitHub
parent 8ba6fd7935
commit 4f63c7934b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 393 additions and 875 deletions

View file

@ -4,8 +4,9 @@ For more details about this platform, please refer to the documentation at
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from aiohttp.hdrs import METH_HEAD, METH_POST
from aiohttp.web import Request, Response
import voluptuous as vol
from withings_api.common import NotifyAppli
@ -15,6 +16,7 @@ from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.webhook import (
async_generate_id,
async_unregister as async_unregister_webhook,
@ -28,17 +30,13 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType
from . import const
from .common import (
async_get_data_manager,
async_remove_data_manager,
get_data_manager_by_webhook_id,
json_message_response,
)
from .api import ConfigEntryWithingsApi
from .common import WithingsDataUpdateCoordinator
from .const import CONF_USE_WEBHOOK, CONFIG, LOGGER
DOMAIN = const.DOMAIN
@ -56,7 +54,7 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_CLIENT_SECRET): vol.All(
cv.string, vol.Length(min=1)
),
vol.Optional(const.CONF_USE_WEBHOOK, default=False): cv.boolean,
vol.Optional(const.CONF_USE_WEBHOOK): cv.boolean,
vol.Optional(const.CONF_PROFILES): vol.All(
cv.ensure_list,
vol.Unique(),
@ -116,37 +114,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.config_entries.async_update_entry(
entry, data=new_data, options=new_options, unique_id=unique_id
)
use_webhook = hass.data[DOMAIN][CONFIG][CONF_USE_WEBHOOK]
if use_webhook is not None and use_webhook != entry.options[CONF_USE_WEBHOOK]:
if (
use_webhook := hass.data[DOMAIN][CONFIG].get(CONF_USE_WEBHOOK)
) is not None and use_webhook != entry.options[CONF_USE_WEBHOOK]:
new_options = entry.options.copy()
new_options |= {CONF_USE_WEBHOOK: use_webhook}
hass.config_entries.async_update_entry(entry, options=new_options)
data_manager = await async_get_data_manager(hass, entry)
LOGGER.debug("Confirming %s is authenticated to withings", entry.title)
await data_manager.poll_data_update_coordinator.async_config_entry_first_refresh()
webhook.async_register(
hass,
const.DOMAIN,
"Withings notify",
data_manager.webhook_config.id,
async_webhook_handler,
client = ConfigEntryWithingsApi(
hass=hass,
config_entry=entry,
implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
),
)
# Perform first webhook subscription check.
if data_manager.webhook_config.enabled:
data_manager.async_start_polling_webhook_subscriptions()
use_webhooks = entry.options[CONF_USE_WEBHOOK]
coordinator = WithingsDataUpdateCoordinator(hass, client, use_webhooks)
if use_webhooks:
@callback
def async_call_later_callback(now) -> None:
hass.async_create_task(
data_manager.subscription_update_coordinator.async_refresh()
)
hass.async_create_task(coordinator.async_subscribe_webhooks())
# Start subscription check in the background, outside this component's setup.
entry.async_on_unload(async_call_later(hass, 1, async_call_later_callback))
webhook.async_register(
hass,
DOMAIN,
"Withings notify",
entry.data[CONF_WEBHOOK_ID],
get_webhook_handler(coordinator),
)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
@ -156,19 +158,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Withings config entry."""
data_manager = await async_get_data_manager(hass, entry)
data_manager.async_stop_polling_webhook_subscriptions()
if entry.options[CONF_USE_WEBHOOK]:
async_unregister_webhook(hass, entry.data[CONF_WEBHOOK_ID])
async_unregister_webhook(hass, data_manager.webhook_config.id)
await asyncio.gather(
data_manager.async_unsubscribe_webhook(),
hass.config_entries.async_unload_platforms(entry, PLATFORMS),
)
async_remove_data_manager(hass, entry)
return True
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
@ -176,44 +171,45 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
await hass.config_entries.async_reload(entry.entry_id)
async def async_webhook_handler(
hass: HomeAssistant, webhook_id: str, request: Request
) -> Response | None:
"""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()
def json_message_response(message: str, message_code: int) -> Response:
"""Produce common json output."""
return HomeAssistantView.json({"message": message, "code": message_code})
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)
def get_webhook_handler(
coordinator: WithingsDataUpdateCoordinator,
) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]:
"""Return webhook handler."""
params = await request.post()
async def async_webhook_handler(
hass: HomeAssistant, webhook_id: str, request: Request
) -> Response | None:
# 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 == METH_HEAD:
return Response()
if "appli" not in params:
return json_message_response("Parameter appli not provided", message_code=20)
if request.method != METH_POST:
return json_message_response("Invalid method", message_code=2)
try:
appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type]
except ValueError:
return json_message_response("Invalid appli provided", message_code=21)
# Handle http post calls to the path.
if not request.body_exists:
return json_message_response("No request body", message_code=12)
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)
params = await request.post()
# Run this in the background and return immediately.
hass.async_create_task(data_manager.async_webhook_data_updated(appli))
if "appli" not in params:
return json_message_response(
"Parameter appli not provided", message_code=20
)
return json_message_response("Success", message_code=0)
try:
appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type]
except ValueError:
return json_message_response("Invalid appli provided", message_code=21)
await coordinator.async_webhook_data_updated(appli)
return json_message_response("Success", message_code=0)
return async_webhook_handler

View file

@ -14,9 +14,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .common import UpdateType, async_get_data_manager
from .const import Measurement
from .entity import BaseWithingsSensor, WithingsEntityDescription
from .common import WithingsDataUpdateCoordinator
from .const import DOMAIN, Measurement
from .entity import WithingsEntity, WithingsEntityDescription
@dataclass
@ -34,7 +34,6 @@ BINARY_SENSORS = [
measure_type=NotifyAppli.BED_IN,
translation_key="in_bed",
icon="mdi:bed",
update_type=UpdateType.WEBHOOK,
device_class=BinarySensorDeviceClass.OCCUPANCY,
),
]
@ -46,17 +45,17 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the sensor config entry."""
data_manager = await async_get_data_manager(hass, entry)
coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities = [
WithingsHealthBinarySensor(data_manager, attribute)
for attribute in BINARY_SENSORS
]
if coordinator.use_webhooks:
entities = [
WithingsBinarySensor(coordinator, attribute) for attribute in BINARY_SENSORS
]
async_add_entities(entities, True)
async_add_entities(entities)
class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorEntity):
class WithingsBinarySensor(WithingsEntity, BinarySensorEntity):
"""Implementation of a Withings sensor."""
entity_description: WithingsBinarySensorEntityDescription
@ -64,4 +63,4 @@ class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorEntity):
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self._state_data
return self.coordinator.in_bed

View file

@ -1,17 +1,9 @@
"""Common code for Withings."""
from __future__ import annotations
"""Withings coordinator."""
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
import datetime
from datetime import timedelta
from enum import IntEnum, StrEnum
from http import HTTPStatus
import re
from typing import Any
from aiohttp.web import Response
from withings_api.common import (
AuthFailedException,
GetSleepSummaryField,
@ -23,43 +15,19 @@ from withings_api.common import (
query_measure_groups,
)
from homeassistant.components import webhook
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.webhook import async_generate_url
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from . import const
from .api import ConfigEntryWithingsApi
from .const import LOGGER, Measurement
NOT_AUTHENTICATED_ERROR = re.compile(
f"^{HTTPStatus.UNAUTHORIZED},.*",
re.IGNORECASE,
)
DATA_UPDATED_SIGNAL = "withings_entity_state_updated"
SUBSCRIBE_DELAY = datetime.timedelta(seconds=5)
UNSUBSCRIBE_DELAY = datetime.timedelta(seconds=1)
class UpdateType(StrEnum):
"""Data update type."""
POLL = "poll"
WEBHOOK = "webhook"
@dataclass
class WebhookConfig:
"""Config for a webhook."""
id: str
url: str
enabled: bool
SUBSCRIBE_DELAY = timedelta(seconds=5)
UNSUBSCRIBE_DELAY = timedelta(seconds=1)
WITHINGS_MEASURE_TYPE_MAP: dict[
NotifyAppli | GetSleepSummaryField | MeasureType, Measurement
@ -105,214 +73,91 @@ WITHINGS_MEASURE_TYPE_MAP: dict[
}
def json_message_response(message: str, message_code: int) -> Response:
"""Produce common json output."""
return HomeAssistantView.json({"message": message, "code": message_code})
class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]]):
"""Base coordinator."""
class WebhookAvailability(IntEnum):
"""Represents various statuses of webhook availability."""
SUCCESS = 0
CONNECT_ERROR = 1
HTTP_ERROR = 2
NOT_WEBHOOK = 3
class WebhookUpdateCoordinator:
"""Coordinates webhook data updates across listeners."""
def __init__(self, hass: HomeAssistant, user_id: int) -> None:
"""Initialize the object."""
self._hass = hass
self._user_id = user_id
self._listeners: list[CALLBACK_TYPE] = []
self.data: dict[Measurement, Any] = {}
def async_add_listener(self, listener: CALLBACK_TYPE) -> Callable[[], None]:
"""Add a listener."""
self._listeners.append(listener)
@callback
def remove_listener() -> None:
self.async_remove_listener(listener)
return remove_listener
def async_remove_listener(self, listener: CALLBACK_TYPE) -> None:
"""Remove a listener."""
self._listeners.remove(listener)
def update_data(self, measurement: Measurement, value: Any) -> None:
"""Update the data object and notify listeners the data has changed."""
self.data[measurement] = value
self.notify_data_changed()
def notify_data_changed(self) -> None:
"""Notify all listeners the data has changed."""
for listener in self._listeners:
listener()
class DataManager:
"""Manage withing data."""
in_bed: bool | None = None
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
api: ConfigEntryWithingsApi,
user_id: int,
webhook_config: WebhookConfig,
self, hass: HomeAssistant, client: ConfigEntryWithingsApi, use_webhooks: bool
) -> None:
"""Initialize the data manager."""
self._hass = hass
self._api = api
self._user_id = user_id
self._webhook_config = webhook_config
self._notify_subscribe_delay = SUBSCRIBE_DELAY
self._notify_unsubscribe_delay = UNSUBSCRIBE_DELAY
self._is_available = True
self._cancel_interval_update_interval: CALLBACK_TYPE | None = None
self._cancel_configure_webhook_subscribe_interval: CALLBACK_TYPE | None = None
self._api_notification_id = f"withings_{self._user_id}"
self.subscription_update_coordinator = DataUpdateCoordinator(
hass,
LOGGER,
name="subscription_update_coordinator",
update_interval=timedelta(minutes=120),
update_method=self.async_subscribe_webhook,
"""Initialize the Withings data coordinator."""
update_interval: timedelta | None = timedelta(minutes=10)
if use_webhooks:
update_interval = None
super().__init__(hass, LOGGER, name="Withings", update_interval=update_interval)
self._client = client
self._webhook_url = async_generate_url(
hass, self.config_entry.data[CONF_WEBHOOK_ID]
)
self.poll_data_update_coordinator = DataUpdateCoordinator[
dict[MeasureType, Any] | None
](
hass,
LOGGER,
name="poll_data_update_coordinator",
update_interval=timedelta(minutes=120)
if self._webhook_config.enabled
else timedelta(minutes=10),
update_method=self.async_get_all_data,
)
self.webhook_update_coordinator = WebhookUpdateCoordinator(
self._hass, self._user_id
)
self._cancel_subscription_update: Callable[[], None] | None = None
self._subscribe_webhook_run_count = 0
self.use_webhooks = use_webhooks
@property
def webhook_config(self) -> WebhookConfig:
"""Get the webhook config."""
return self._webhook_config
async def async_subscribe_webhooks(self) -> None:
"""Subscribe to webhooks."""
await self.async_unsubscribe_webhooks()
@property
def user_id(self) -> int:
"""Get the user_id of the authenticated user."""
return self._user_id
current_webhooks = await self._client.async_notify_list()
def async_start_polling_webhook_subscriptions(self) -> None:
"""Start polling webhook subscriptions (if enabled) to reconcile their setup."""
self.async_stop_polling_webhook_subscriptions()
def empty_listener() -> None:
pass
self._cancel_subscription_update = (
self.subscription_update_coordinator.async_add_listener(empty_listener)
)
def async_stop_polling_webhook_subscriptions(self) -> None:
"""Stop polling webhook subscriptions."""
if self._cancel_subscription_update:
self._cancel_subscription_update()
self._cancel_subscription_update = None
async def async_subscribe_webhook(self) -> None:
"""Subscribe the webhook to withings data updates."""
LOGGER.debug("Configuring withings webhook")
# On first startup, perform a fresh re-subscribe. Withings stops pushing data
# if the webhook fails enough times but they don't remove the old subscription
# config. This ensures the subscription is setup correctly and they start
# pushing again.
if self._subscribe_webhook_run_count == 0:
LOGGER.debug("Refreshing withings webhook configs")
await self.async_unsubscribe_webhook()
self._subscribe_webhook_run_count += 1
# Get the current webhooks.
response = await self._api.async_notify_list()
subscribed_applis = frozenset(
subscribed_notifications = frozenset(
profile.appli
for profile in response.profiles
if profile.callbackurl == self._webhook_config.url
for profile in current_webhooks.profiles
if profile.callbackurl == self._webhook_url
)
# Determine what subscriptions need to be created.
ignored_applis = frozenset({NotifyAppli.USER, NotifyAppli.UNKNOWN})
to_add_applis = frozenset(
appli
for appli in NotifyAppli
if appli not in subscribed_applis and appli not in ignored_applis
notification_to_subscribe = (
set(NotifyAppli)
- subscribed_notifications
- {NotifyAppli.USER, NotifyAppli.UNKNOWN}
)
# Subscribe to each one.
for appli in to_add_applis:
for notification in notification_to_subscribe:
LOGGER.debug(
"Subscribing %s for %s in %s seconds",
self._webhook_config.url,
appli,
self._notify_subscribe_delay.total_seconds(),
self._webhook_url,
notification,
SUBSCRIBE_DELAY.total_seconds(),
)
# Withings will HTTP HEAD the callback_url and needs some downtime
# between each call or there is a higher chance of failure.
await asyncio.sleep(self._notify_subscribe_delay.total_seconds())
await self._api.async_notify_subscribe(self._webhook_config.url, appli)
await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds())
await self._client.async_notify_subscribe(self._webhook_url, notification)
async def async_unsubscribe_webhook(self) -> None:
"""Unsubscribe webhook from withings data updates."""
# Get the current webhooks.
response = await self._api.async_notify_list()
async def async_unsubscribe_webhooks(self) -> None:
"""Unsubscribe to webhooks."""
current_webhooks = await self._client.async_notify_list()
# Revoke subscriptions.
for profile in response.profiles:
for webhook_configuration in current_webhooks.profiles:
LOGGER.debug(
"Unsubscribing %s for %s in %s seconds",
profile.callbackurl,
profile.appli,
self._notify_unsubscribe_delay.total_seconds(),
webhook_configuration.callbackurl,
webhook_configuration.appli,
UNSUBSCRIBE_DELAY.total_seconds(),
)
# Quick calls to Withings can result in the service returning errors.
# Give them some time to cool down.
await asyncio.sleep(self._notify_subscribe_delay.total_seconds())
await self._api.async_notify_revoke(profile.callbackurl, profile.appli)
await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds())
await self._client.async_notify_revoke(
webhook_configuration.callbackurl, webhook_configuration.appli
)
async def async_get_all_data(self) -> dict[MeasureType, Any] | None:
"""Update all withings data."""
async def _async_update_data(self) -> dict[Measurement, Any]:
try:
return {
**await self.async_get_measures(),
**await self.async_get_sleep_summary(),
}
except Exception as exception:
# User is not authenticated.
if isinstance(
exception, (UnauthorizedException, AuthFailedException)
) or NOT_AUTHENTICATED_ERROR.match(str(exception)):
self._api.config_entry.async_start_reauth(self._hass)
return None
measurements = await self._get_measurements()
sleep_summary = await self._get_sleep_summary()
except (UnauthorizedException, AuthFailedException) as exc:
raise ConfigEntryAuthFailed from exc
return {
**measurements,
**sleep_summary,
}
raise exception
async def async_get_measures(self) -> dict[Measurement, Any]:
"""Get the measures data."""
async def _get_measurements(self) -> dict[Measurement, Any]:
LOGGER.debug("Updating withings measures")
now = dt_util.utcnow()
startdate = now - datetime.timedelta(days=7)
startdate = now - timedelta(days=7)
response = await self._api.async_measure_get_meas(
response = await self._client.async_measure_get_meas(
None, None, startdate, now, None, startdate
)
@ -334,17 +179,13 @@ class DataManager:
if measure.type in WITHINGS_MEASURE_TYPE_MAP
}
async def async_get_sleep_summary(self) -> dict[Measurement, Any]:
"""Get the sleep summary data."""
LOGGER.debug("Updating withing sleep summary")
async def _get_sleep_summary(self) -> dict[Measurement, Any]:
now = dt_util.now()
yesterday = now - datetime.timedelta(days=1)
yesterday_noon = dt_util.start_of_local_day(yesterday) + datetime.timedelta(
hours=12
)
yesterday = now - timedelta(days=1)
yesterday_noon = dt_util.start_of_local_day(yesterday) + timedelta(hours=12)
yesterday_noon_utc = dt_util.as_utc(yesterday_noon)
response = await self._api.async_sleep_get_summary(
response = await self._client.async_sleep_get_summary(
lastupdate=yesterday_noon_utc,
data_fields=[
GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY,
@ -415,81 +256,18 @@ class DataManager:
for field, value in values.items()
}
async def async_webhook_data_updated(self, data_category: NotifyAppli) -> None:
"""Handle scenario when data is updated from a webook."""
async def async_webhook_data_updated(
self, notification_category: NotifyAppli
) -> None:
"""Update data when webhook is called."""
LOGGER.debug("Withings webhook triggered")
if data_category in {
if notification_category in {
NotifyAppli.WEIGHT,
NotifyAppli.CIRCULATORY,
NotifyAppli.SLEEP,
}:
await self.poll_data_update_coordinator.async_request_refresh()
await self.async_request_refresh()
elif data_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}:
self.webhook_update_coordinator.update_data(
Measurement.IN_BED, data_category == NotifyAppli.BED_IN
)
async def async_get_data_manager(
hass: HomeAssistant, config_entry: ConfigEntry
) -> DataManager:
"""Get the data manager for a config entry."""
hass.data.setdefault(const.DOMAIN, {})
hass.data[const.DOMAIN].setdefault(config_entry.entry_id, {})
config_entry_data = hass.data[const.DOMAIN][config_entry.entry_id]
if const.DATA_MANAGER not in config_entry_data:
LOGGER.debug(
"Creating withings data manager for profile: %s", config_entry.title
)
config_entry_data[const.DATA_MANAGER] = DataManager(
hass,
ConfigEntryWithingsApi(
hass=hass,
config_entry=config_entry,
implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, config_entry
),
),
config_entry.data["token"]["userid"],
WebhookConfig(
id=config_entry.data[CONF_WEBHOOK_ID],
url=webhook.async_generate_url(
hass, config_entry.data[CONF_WEBHOOK_ID]
),
enabled=config_entry.options[const.CONF_USE_WEBHOOK],
),
)
return config_entry_data[const.DATA_MANAGER]
def get_data_manager_by_webhook_id(
hass: HomeAssistant, webhook_id: str
) -> DataManager | None:
"""Get a data manager by it's webhook id."""
return next(
iter(
[
data_manager
for data_manager in get_all_data_managers(hass)
if data_manager.webhook_config.id == webhook_id
]
),
None,
)
def get_all_data_managers(hass: HomeAssistant) -> tuple[DataManager, ...]:
"""Get all configured data managers."""
return tuple(
config_entry_data[const.DATA_MANAGER]
for config_entry_data in hass.data[const.DOMAIN].values()
if const.DATA_MANAGER in config_entry_data
)
def async_remove_data_manager(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Remove a data manager for a config entry."""
del hass.data[const.DOMAIN][config_entry.entry_id][const.DATA_MANAGER]
elif notification_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}:
self.in_bed = notification_category == NotifyAppli.BED_IN
self.async_update_listeners()

View file

@ -2,15 +2,14 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from withings_api.common import GetSleepSummaryField, MeasureType, NotifyAppli
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .common import DataManager, UpdateType
from .common import WithingsDataUpdateCoordinator
from .const import DOMAIN, Measurement
@ -20,7 +19,6 @@ class WithingsEntityDescriptionMixin:
measurement: Measurement
measure_type: NotifyAppli | GetSleepSummaryField | MeasureType
update_type: UpdateType
@dataclass
@ -28,72 +26,22 @@ class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixi
"""Immutable class for describing withings data."""
class BaseWithingsSensor(Entity):
"""Base class for withings sensors."""
class WithingsEntity(CoordinatorEntity[WithingsDataUpdateCoordinator]):
"""Base class for withings entities."""
_attr_should_poll = False
entity_description: WithingsEntityDescription
_attr_has_entity_name = True
def __init__(
self, data_manager: DataManager, description: WithingsEntityDescription
self,
coordinator: WithingsDataUpdateCoordinator,
description: WithingsEntityDescription,
) -> None:
"""Initialize the Withings sensor."""
self._data_manager = data_manager
"""Initialize the Withings entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = (
f"withings_{data_manager.user_id}_{description.measurement.value}"
)
self._state_data: Any | None = None
self._attr_unique_id = f"withings_{coordinator.config_entry.unique_id}_{description.measurement.value}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(data_manager.user_id))}, manufacturer="Withings"
identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))},
manufacturer="Withings",
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
if self.entity_description.update_type == UpdateType.POLL:
return self._data_manager.poll_data_update_coordinator.last_update_success
if self.entity_description.update_type == UpdateType.WEBHOOK:
return self._data_manager.webhook_config.enabled and (
self.entity_description.measurement
in self._data_manager.webhook_update_coordinator.data
)
return True
@callback
def _on_poll_data_updated(self) -> None:
self._update_state_data(
self._data_manager.poll_data_update_coordinator.data or {}
)
@callback
def _on_webhook_data_updated(self) -> None:
self._update_state_data(
self._data_manager.webhook_update_coordinator.data or {}
)
def _update_state_data(self, data: dict[Measurement, Any]) -> None:
"""Update the state data."""
self._state_data = data.get(self.entity_description.measurement)
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register update dispatcher."""
if self.entity_description.update_type == UpdateType.POLL:
self.async_on_remove(
self._data_manager.poll_data_update_coordinator.async_add_listener(
self._on_poll_data_updated
)
)
self._on_poll_data_updated()
elif self.entity_description.update_type == UpdateType.WEBHOOK:
self.async_on_remove(
self._data_manager.webhook_update_coordinator.async_add_listener(
self._on_webhook_data_updated
)
)
self._on_webhook_data_updated()

View file

@ -23,8 +23,9 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .common import UpdateType, async_get_data_manager
from .common import WithingsDataUpdateCoordinator
from .const import (
DOMAIN,
SCORE_POINTS,
UOM_BEATS_PER_MINUTE,
UOM_BREATHS_PER_MINUTE,
@ -32,7 +33,7 @@ from .const import (
UOM_MMHG,
Measurement,
)
from .entity import BaseWithingsSensor, WithingsEntityDescription
from .entity import WithingsEntity, WithingsEntityDescription
@dataclass
@ -50,7 +51,6 @@ SENSORS = [
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
device_class=SensorDeviceClass.WEIGHT,
state_class=SensorStateClass.MEASUREMENT,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.FAT_MASS_KG.value,
@ -60,7 +60,6 @@ SENSORS = [
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
device_class=SensorDeviceClass.WEIGHT,
state_class=SensorStateClass.MEASUREMENT,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.FAT_FREE_MASS_KG.value,
@ -70,7 +69,6 @@ SENSORS = [
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
device_class=SensorDeviceClass.WEIGHT,
state_class=SensorStateClass.MEASUREMENT,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.MUSCLE_MASS_KG.value,
@ -80,7 +78,6 @@ SENSORS = [
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
device_class=SensorDeviceClass.WEIGHT,
state_class=SensorStateClass.MEASUREMENT,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.BONE_MASS_KG.value,
@ -90,7 +87,6 @@ SENSORS = [
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
device_class=SensorDeviceClass.WEIGHT,
state_class=SensorStateClass.MEASUREMENT,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.HEIGHT_M.value,
@ -101,7 +97,6 @@ SENSORS = [
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.TEMP_C.value,
@ -110,7 +105,6 @@ SENSORS = [
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.BODY_TEMP_C.value,
@ -120,7 +114,6 @@ SENSORS = [
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SKIN_TEMP_C.value,
@ -130,7 +123,6 @@ SENSORS = [
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.FAT_RATIO_PCT.value,
@ -139,7 +131,6 @@ SENSORS = [
translation_key="fat_ratio",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.DIASTOLIC_MMHG.value,
@ -148,7 +139,6 @@ SENSORS = [
translation_key="diastolic_blood_pressure",
native_unit_of_measurement=UOM_MMHG,
state_class=SensorStateClass.MEASUREMENT,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SYSTOLIC_MMGH.value,
@ -157,7 +147,6 @@ SENSORS = [
translation_key="systolic_blood_pressure",
native_unit_of_measurement=UOM_MMHG,
state_class=SensorStateClass.MEASUREMENT,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.HEART_PULSE_BPM.value,
@ -167,7 +156,6 @@ SENSORS = [
native_unit_of_measurement=UOM_BEATS_PER_MINUTE,
icon="mdi:heart-pulse",
state_class=SensorStateClass.MEASUREMENT,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SPO2_PCT.value,
@ -176,7 +164,6 @@ SENSORS = [
translation_key="spo2",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.HYDRATION.value,
@ -188,7 +175,6 @@ SENSORS = [
icon="mdi:water",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.PWV.value,
@ -198,7 +184,6 @@ SENSORS = [
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
device_class=SensorDeviceClass.SPEED,
state_class=SensorStateClass.MEASUREMENT,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value,
@ -207,7 +192,6 @@ SENSORS = [
translation_key="breathing_disturbances_intensity",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_DEEP_DURATION_SECONDS.value,
@ -219,7 +203,6 @@ SENSORS = [
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS.value,
@ -231,7 +214,6 @@ SENSORS = [
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS.value,
@ -243,7 +225,6 @@ SENSORS = [
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_HEART_RATE_AVERAGE.value,
@ -254,7 +235,6 @@ SENSORS = [
icon="mdi:heart-pulse",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_HEART_RATE_MAX.value,
@ -266,7 +246,6 @@ SENSORS = [
icon="mdi:heart-pulse",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_HEART_RATE_MIN.value,
@ -277,7 +256,6 @@ SENSORS = [
icon="mdi:heart-pulse",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_LIGHT_DURATION_SECONDS.value,
@ -289,7 +267,6 @@ SENSORS = [
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_REM_DURATION_SECONDS.value,
@ -301,7 +278,6 @@ SENSORS = [
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE.value,
@ -311,7 +287,6 @@ SENSORS = [
native_unit_of_measurement=UOM_BREATHS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_RESPIRATORY_RATE_MAX.value,
@ -321,7 +296,6 @@ SENSORS = [
native_unit_of_measurement=UOM_BREATHS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_RESPIRATORY_RATE_MIN.value,
@ -331,7 +305,6 @@ SENSORS = [
native_unit_of_measurement=UOM_BREATHS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_SCORE.value,
@ -342,7 +315,6 @@ SENSORS = [
icon="mdi:medal",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_SNORING.value,
@ -351,7 +323,6 @@ SENSORS = [
translation_key="snoring",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_SNORING_EPISODE_COUNT.value,
@ -360,7 +331,6 @@ SENSORS = [
translation_key="snoring_episode_count",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_WAKEUP_COUNT.value,
@ -371,7 +341,6 @@ SENSORS = [
icon="mdi:sleep-off",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
WithingsSensorEntityDescription(
key=Measurement.SLEEP_WAKEUP_DURATION_SECONDS.value,
@ -383,7 +352,6 @@ SENSORS = [
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
update_type=UpdateType.POLL,
),
]
@ -394,14 +362,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the sensor config entry."""
data_manager = await async_get_data_manager(hass, entry)
coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities = [WithingsHealthSensor(data_manager, attribute) for attribute in SENSORS]
async_add_entities(entities, True)
async_add_entities(WithingsSensor(coordinator, attribute) for attribute in SENSORS)
class WithingsHealthSensor(BaseWithingsSensor, SensorEntity):
class WithingsSensor(WithingsEntity, SensorEntity):
"""Implementation of a Withings sensor."""
entity_description: WithingsSensorEntityDescription
@ -409,4 +375,12 @@ class WithingsHealthSensor(BaseWithingsSensor, SensorEntity):
@property
def native_value(self) -> None | str | int | float:
"""Return the state of the entity."""
return self._state_data
return self.coordinator.data[self.entity_description.measurement]
@property
def available(self) -> bool:
"""Return if the sensor is available."""
return (
super().available
and self.entity_description.measurement in self.coordinator.data
)

View file

@ -3,6 +3,8 @@ from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
from aiohttp.test_utils import TestClient
from homeassistant.components.webhook import async_generate_url
from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN
from homeassistant.config import async_process_ha_core_config
@ -21,7 +23,7 @@ class WebhookResponse:
async def call_webhook(
hass: HomeAssistant, webhook_id: str, data: dict[str, Any], client
hass: HomeAssistant, webhook_id: str, data: dict[str, Any], client: TestClient
) -> WebhookResponse:
"""Call the webhook."""
webhook_url = async_generate_url(hass, webhook_id)
@ -34,7 +36,7 @@ async def call_webhook(
# Wait for remaining tasks to complete.
await hass.async_block_till_done()
data: dict[str, Any] = await resp.json()
data = await resp.json()
resp.close()
return WebhookResponse(message=data["message"], message_code=data["code"])
@ -46,7 +48,7 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry)
await async_process_ha_core_config(
hass,
{"internal_url": "http://example.local:8123"},
{"external_url": "http://example.local:8123"},
)
await hass.config_entries.async_setup(config_entry.entry_id)

View file

@ -1,328 +0,0 @@
"""Common data for for the withings component tests."""
from __future__ import annotations
from dataclasses import dataclass
from http import HTTPStatus
from unittest.mock import MagicMock
from urllib.parse import urlparse
from aiohttp.test_utils import TestClient
import arrow
from withings_api.common import (
MeasureGetMeasResponse,
NotifyAppli,
NotifyListResponse,
SleepGetSummaryResponse,
UserGetDeviceResponse,
)
from homeassistant import data_entry_flow
import homeassistant.components.api as api
from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN
import homeassistant.components.webhook as webhook
from homeassistant.components.withings.common import (
ConfigEntryWithingsApi,
DataManager,
get_all_data_managers,
)
import homeassistant.components.withings.const as const
from homeassistant.components.withings.entity import WithingsEntityDescription
from homeassistant.config import async_process_ha_core_config
from homeassistant.config_entries import SOURCE_USER, ConfigEntry
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_EXTERNAL_URL,
CONF_UNIT_SYSTEM,
CONF_UNIT_SYSTEM_METRIC,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow, entity_registry as er
from homeassistant.helpers.config_entry_oauth2_flow import AUTH_CALLBACK_PATH
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry
from tests.components.withings import WebhookResponse
from tests.test_util.aiohttp import AiohttpClientMocker
@dataclass
class ProfileConfig:
"""Data representing a user profile."""
profile: str
user_id: int
api_response_user_get_device: UserGetDeviceResponse | Exception
api_response_measure_get_meas: MeasureGetMeasResponse | Exception
api_response_sleep_get_summary: SleepGetSummaryResponse | Exception
api_response_notify_list: NotifyListResponse | Exception
api_response_notify_revoke: Exception | None
def new_profile_config(
profile: str,
user_id: int,
api_response_user_get_device: UserGetDeviceResponse | Exception | None = None,
api_response_measure_get_meas: MeasureGetMeasResponse | Exception | None = None,
api_response_sleep_get_summary: SleepGetSummaryResponse | Exception | None = None,
api_response_notify_list: NotifyListResponse | Exception | None = None,
api_response_notify_revoke: Exception | None = 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=dt_util.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,
)
class ComponentFactory:
"""Manages the setup and unloading of the withing component and profiles."""
def __init__(
self,
hass: HomeAssistant,
api_class_mock: MagicMock,
hass_client_no_auth,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Initialize the object."""
self._hass = hass
self._api_class_mock = api_class_mock
self._hass_client = hass_client_no_auth
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,
},
}
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
]
)
)
api_mock: ConfigEntryWithingsApi = MagicMock(spec=ConfigEntryWithingsApi)
api_mock.config_entry = MockConfigEntry(
domain=const.DOMAIN,
data={"profile": profile_config.profile},
)
ComponentFactory._setup_api_method(
api_mock.user_get_device, profile_config.api_response_user_get_device
)
ComponentFactory._setup_api_method(
api_mock.sleep_get_summary, profile_config.api_response_sleep_get_summary
)
ComponentFactory._setup_api_method(
api_mock.measure_get_meas, profile_config.api_response_measure_get_meas
)
ComponentFactory._setup_api_method(
api_mock.notify_list, profile_config.api_response_notify_list
)
ComponentFactory._setup_api_method(
api_mock.notify_revoke, profile_config.api_response_notify_revoke
)
self._api_class_mock.reset_mocks()
self._api_class_mock.return_value = api_mock
# Get the withings config flow.
result = await self._hass.config_entries.flow.async_init(
const.DOMAIN, context={"source": SOURCE_USER}
)
assert result
state = config_entry_oauth2_flow._encode_jwt(
self._hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP
assert result["url"] == (
"https://account.withings.com/oauth2_user/authorize2?"
f"response_type=code&client_id={self._client_id}&"
"redirect_uri=https://example.com/auth/external/callback&"
f"state={state}"
"&scope=user.info,user.metrics,user.activity,user.sleepevents"
)
# Simulate user being redirected from withings site.
client: TestClient = await self._hass_client()
resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}")
assert resp.status == HTTPStatus.OK
assert resp.headers["content-type"] == "text/html; charset=utf-8"
self._aioclient_mock.clear_requests()
self._aioclient_mock.post(
"https://wbsapi.withings.net/v2/oauth2",
json={
"body": {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
"userid": profile_config.user_id,
},
},
)
# Present user with a list of profiles to choose from.
result = await self._hass.config_entries.flow.async_configure(result["flow_id"])
assert result.get("type") == "form"
assert result.get("step_id") == "profile"
assert "profile" in result.get("data_schema").schema
# Provide the user profile.
result = await self._hass.config_entries.flow.async_configure(
result["flow_id"], {const.PROFILE: profile_config.profile}
)
# Finish the config flow by calling it again.
assert result.get("type") == "create_entry"
assert result.get("result")
config_data = result.get("result").data
assert config_data.get(const.PROFILE) == profile_config.profile
assert config_data.get("auth_implementation") == const.DOMAIN
assert config_data.get("token")
# Wait for remaining tasks to complete.
await self._hass.async_block_till_done()
# Mock the webhook.
data_manager = get_data_manager_by_user_id(self._hass, user_id)
self._aioclient_mock.clear_requests()
self._aioclient_mock.request(
"HEAD",
data_manager.webhook_config.url,
)
return self._api_class_mock.return_value
async def call_webhook(self, user_id: int, appli: NotifyAppli) -> WebhookResponse:
"""Call the webhook to notify of data changes."""
client: TestClient = await self._hass_client()
data_manager = get_data_manager_by_user_id(self._hass, user_id)
resp = await client.post(
urlparse(data_manager.webhook_config.url).path,
data={"userid": user_id, "appli": appli.value},
)
# Wait for remaining tasks to complete.
await 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 config_entry.async_unload(self._hass)
await self._hass.async_block_till_done()
assert not get_data_manager_by_user_id(self._hass, profile.user_id)
def get_config_entries_for_user_id(
hass: HomeAssistant, user_id: int
) -> tuple[ConfigEntry]:
"""Get a list of config entries that apply to a specific withings user."""
return tuple(
config_entry
for config_entry in hass.config_entries.async_entries(const.DOMAIN)
if config_entry.data.get("token", {}).get("userid") == user_id
)
def get_data_manager_by_user_id(
hass: HomeAssistant, user_id: int
) -> DataManager | None:
"""Get a data manager by the user id."""
return next(
iter(
[
data_manager
for data_manager in get_all_data_managers(hass)
if data_manager.user_id == user_id
]
),
None,
)
async def async_get_entity_id(
hass: HomeAssistant,
description: WithingsEntityDescription,
user_id: int,
platform: str,
) -> str | None:
"""Get an entity id for a user's attribute."""
entity_registry = er.async_get(hass)
unique_id = f"withings_{user_id}_{description.measurement.value}"
return entity_registry.async_get_entity_id(platform, const.DOMAIN, unique_id)

View file

@ -20,10 +20,7 @@ from homeassistant.components.withings.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from .common import ComponentFactory
from tests.common import MockConfigEntry, load_json_object_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
@ -38,22 +35,6 @@ USER_ID = 12345
WEBHOOK_ID = "55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e"
@pytest.fixture
def component_factory(
hass: HomeAssistant,
hass_client_no_auth,
aioclient_mock: AiohttpClientMocker,
current_request_with_host: None,
):
"""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, hass_client_no_auth, aioclient_mock
)
@pytest.fixture(name="scopes")
def mock_scopes() -> list[str]:
"""Fixture to set the scopes present in the OAuth token."""
@ -78,8 +59,8 @@ def mock_expires_at() -> int:
return time.time() + 3600
@pytest.fixture(name="config_entry")
def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
@pytest.fixture
def webhook_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
"""Create Withings entry in Home Assistant."""
return MockConfigEntry(
domain=DOMAIN,
@ -104,6 +85,32 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
)
@pytest.fixture
def polling_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
"""Create Withings entry in Home Assistant."""
return MockConfigEntry(
domain=DOMAIN,
title=TITLE,
unique_id=str(USER_ID),
data={
"auth_implementation": DOMAIN,
"token": {
"status": 0,
"userid": str(USER_ID),
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"expires_at": expires_at,
"scope": ",".join(scopes),
},
"profile": TITLE,
"webhook_id": WEBHOOK_ID,
},
options={
"use_webhook": False,
},
)
@pytest.fixture(name="withings")
def mock_withings():
"""Mock withings."""
@ -123,7 +130,7 @@ def mock_withings():
)
with patch(
"homeassistant.components.withings.common.ConfigEntryWithingsApi",
"homeassistant.components.withings.ConfigEntryWithingsApi",
return_value=mock,
):
yield mock
@ -135,7 +142,8 @@ def disable_webhook_delay():
mock = AsyncMock()
with patch(
"homeassistant.components.withings.common.SUBSCRIBE_DELAY", timedelta(seconds=0)
"homeassistant.components.withings.common.SUBSCRIBE_DELAY",
timedelta(seconds=0),
), patch(
"homeassistant.components.withings.common.UNSUBSCRIBE_DELAY",
timedelta(seconds=0),

View file

@ -1,9 +1,11 @@
"""Tests for the Withings component."""
from unittest.mock import AsyncMock
from aiohttp.client_exceptions import ClientResponseError
import pytest
from withings_api.common import NotifyAppli
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from . import call_webhook, enable_webhooks, setup_integration
@ -17,18 +19,18 @@ async def test_binary_sensor(
hass: HomeAssistant,
withings: AsyncMock,
disable_webhook_delay,
config_entry: MockConfigEntry,
webhook_config_entry: MockConfigEntry,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test binary sensor."""
await enable_webhooks(hass)
await setup_integration(hass, config_entry)
await setup_integration(hass, webhook_config_entry)
client = await hass_client_no_auth()
entity_id = "binary_sensor.henk_in_bed"
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
assert hass.states.get(entity_id).state == STATE_UNKNOWN
resp = await call_webhook(
hass,
@ -49,3 +51,28 @@ async def test_binary_sensor(
assert resp.message_code == 0
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_OFF
async def test_polling_binary_sensor(
hass: HomeAssistant,
withings: AsyncMock,
disable_webhook_delay,
polling_config_entry: MockConfigEntry,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test binary sensor."""
await setup_integration(hass, polling_config_entry)
client = await hass_client_no_auth()
entity_id = "binary_sensor.henk_in_bed"
assert hass.states.get(entity_id) is None
with pytest.raises(ClientResponseError):
await call_webhook(
hass,
WEBHOOK_ID,
{"userid": USER_ID, "appli": NotifyAppli.BED_IN},
client,
)

View file

@ -83,12 +83,12 @@ async def test_config_non_unique_profile(
hass_client_no_auth: ClientSessionGenerator,
current_request_with_host: None,
withings: AsyncMock,
config_entry: MockConfigEntry,
polling_config_entry: MockConfigEntry,
disable_webhook_delay,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test setup a non-unique profile."""
await setup_integration(hass, config_entry)
await setup_integration(hass, polling_config_entry)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
@ -136,21 +136,21 @@ async def test_config_reauth_profile(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
config_entry: MockConfigEntry,
polling_config_entry: MockConfigEntry,
withings: AsyncMock,
disable_webhook_delay,
current_request_with_host,
) -> None:
"""Test reauth an existing profile reauthenticates the config entry."""
await setup_integration(hass, config_entry)
await setup_integration(hass, polling_config_entry)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": config_entry.entry_id,
"entry_id": polling_config_entry.entry_id,
},
data=config_entry.data,
data=polling_config_entry.data,
)
assert result["type"] == "form"
assert result["step_id"] == "reauth_confirm"
@ -199,21 +199,21 @@ async def test_config_reauth_wrong_account(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
config_entry: MockConfigEntry,
polling_config_entry: MockConfigEntry,
withings: AsyncMock,
disable_webhook_delay,
current_request_with_host,
) -> None:
"""Test reauth with wrong account."""
await setup_integration(hass, config_entry)
await setup_integration(hass, polling_config_entry)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": config_entry.entry_id,
"entry_id": polling_config_entry.entry_id,
},
data=config_entry.data,
data=polling_config_entry.data,
)
assert result["type"] == "form"
assert result["step_id"] == "reauth_confirm"
@ -262,15 +262,15 @@ async def test_options_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
config_entry: MockConfigEntry,
polling_config_entry: MockConfigEntry,
withings: AsyncMock,
disable_webhook_delay,
current_request_with_host,
) -> None:
"""Test options flow."""
await setup_integration(hass, config_entry)
await setup_integration(hass, polling_config_entry)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
result = await hass.config_entries.options.async_init(polling_config_entry.entry_id)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.FORM

View file

@ -4,18 +4,20 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock
from urllib.parse import urlparse
from freezegun.api import FrozenDateTimeFactory
import pytest
import voluptuous as vol
from withings_api.common import NotifyAppli
from withings_api.common import AuthFailedException, NotifyAppli, UnauthorizedException
from homeassistant import config_entries
from homeassistant.components.webhook import async_generate_url
from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import enable_webhooks, setup_integration
from .conftest import WEBHOOK_ID
from . import call_webhook, enable_webhooks, setup_integration
from .conftest import USER_ID, WEBHOOK_ID
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.typing import ClientSessionGenerator
@ -106,12 +108,12 @@ async def test_data_manager_webhook_subscription(
hass: HomeAssistant,
withings: AsyncMock,
disable_webhook_delay,
config_entry: MockConfigEntry,
webhook_config_entry: MockConfigEntry,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test data manager webhook subscriptions."""
await enable_webhooks(hass)
await setup_integration(hass, config_entry)
await setup_integration(hass, webhook_config_entry)
await hass_client_no_auth()
await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
@ -132,6 +134,27 @@ async def test_data_manager_webhook_subscription(
withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT)
async def test_webhook_subscription_polling_config(
hass: HomeAssistant,
withings: AsyncMock,
disable_webhook_delay,
polling_config_entry: MockConfigEntry,
hass_client_no_auth: ClientSessionGenerator,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test webhook subscriptions not run when polling."""
await setup_integration(hass, polling_config_entry)
await hass_client_no_auth()
await hass.async_block_till_done()
freezer.tick(timedelta(seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert withings.notify_revoke.call_count == 0
assert withings.notify_subscribe.call_count == 0
assert withings.notify_list.call_count == 0
@pytest.mark.parametrize(
"method",
[
@ -142,13 +165,14 @@ async def test_data_manager_webhook_subscription(
async def test_requests(
hass: HomeAssistant,
withings: AsyncMock,
config_entry: MockConfigEntry,
webhook_config_entry: MockConfigEntry,
hass_client_no_auth: ClientSessionGenerator,
method: str,
disable_webhook_delay,
) -> None:
"""Test we handle request methods Withings sends."""
await setup_integration(hass, config_entry)
await enable_webhooks(hass)
await setup_integration(hass, webhook_config_entry)
client = await hass_client_no_auth()
webhook_url = async_generate_url(hass, WEBHOOK_ID)
@ -159,6 +183,59 @@ async def test_requests(
assert response.status == 200
async def test_webhooks_request_data(
hass: HomeAssistant,
withings: AsyncMock,
webhook_config_entry: MockConfigEntry,
hass_client_no_auth: ClientSessionGenerator,
disable_webhook_delay,
) -> None:
"""Test calling a webhook requests data."""
await enable_webhooks(hass)
await setup_integration(hass, webhook_config_entry)
client = await hass_client_no_auth()
assert withings.async_measure_get_meas.call_count == 1
await call_webhook(
hass,
WEBHOOK_ID,
{"userid": USER_ID, "appli": NotifyAppli.WEIGHT},
client,
)
assert withings.async_measure_get_meas.call_count == 2
@pytest.mark.parametrize(
"error",
[
UnauthorizedException(401),
AuthFailedException(500),
],
)
async def test_triggering_reauth(
hass: HomeAssistant,
withings: AsyncMock,
polling_config_entry: MockConfigEntry,
error: Exception,
) -> None:
"""Test triggering reauth."""
await setup_integration(hass, polling_config_entry)
withings.async_measure_get_meas.side_effect = error
future = dt_util.utcnow() + timedelta(minutes=10)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
flow = flows[0]
assert flow["step_id"] == "reauth_confirm"
assert flow["handler"] == DOMAIN
assert flow["context"]["source"] == config_entries.SOURCE_REAUTH
@pytest.mark.parametrize(
("config_entry"),
[
@ -220,7 +297,7 @@ async def test_config_flow_upgrade(
async def test_webhook_post(
hass: HomeAssistant,
withings: AsyncMock,
config_entry: MockConfigEntry,
webhook_config_entry: MockConfigEntry,
hass_client_no_auth: ClientSessionGenerator,
disable_webhook_delay,
body: dict[str, Any],
@ -228,7 +305,8 @@ async def test_webhook_post(
current_request_with_host: None,
) -> None:
"""Test webhook callback."""
await setup_integration(hass, config_entry)
await enable_webhooks(hass)
await setup_integration(hass, webhook_config_entry)
client = await hass_client_no_auth()
webhook_url = async_generate_url(hass, WEBHOOK_ID)

View file

@ -1,24 +1,26 @@
"""Tests for the Withings component."""
from datetime import timedelta
from typing import Any
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
from withings_api.common import NotifyAppli
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.withings.const import Measurement
from homeassistant.components.withings.const import DOMAIN, Measurement
from homeassistant.components.withings.entity import WithingsEntityDescription
from homeassistant.components.withings.sensor import SENSORS
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_registry import EntityRegistry
from . import call_webhook, setup_integration
from .common import async_get_entity_id
from . import call_webhook, enable_webhooks, setup_integration
from .conftest import USER_ID, WEBHOOK_ID
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.typing import ClientSessionGenerator
WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = {
@ -60,6 +62,19 @@ EXPECTED_DATA = (
)
async def async_get_entity_id(
hass: HomeAssistant,
description: WithingsEntityDescription,
user_id: int,
platform: str,
) -> str | None:
"""Get an entity id for a user's attribute."""
entity_registry = er.async_get(hass)
unique_id = f"withings_{user_id}_{description.measurement.value}"
return entity_registry.async_get_entity_id(platform, DOMAIN, unique_id)
def async_assert_state_equals(
entity_id: str,
state_obj: State,
@ -79,12 +94,13 @@ def async_assert_state_equals(
async def test_sensor_default_enabled_entities(
hass: HomeAssistant,
withings: AsyncMock,
config_entry: MockConfigEntry,
webhook_config_entry: MockConfigEntry,
disable_webhook_delay,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test entities enabled by default."""
await setup_integration(hass, config_entry)
await enable_webhooks(hass)
await setup_integration(hass, webhook_config_entry)
entity_registry: EntityRegistry = er.async_get(hass)
client = await hass_client_no_auth()
@ -122,11 +138,31 @@ async def test_all_entities(
snapshot: SnapshotAssertion,
withings: AsyncMock,
disable_webhook_delay,
config_entry: MockConfigEntry,
polling_config_entry: MockConfigEntry,
) -> None:
"""Test all entities."""
await setup_integration(hass, config_entry)
await setup_integration(hass, polling_config_entry)
for sensor in SENSORS:
entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN)
assert hass.states.get(entity_id) == snapshot
async def test_update_failed(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
withings: AsyncMock,
polling_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test all entities."""
await setup_integration(hass, polling_config_entry)
withings.async_measure_get_meas.side_effect = Exception
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("sensor.henk_weight")
assert state is not None
assert state.state == STATE_UNAVAILABLE