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:
parent
8ba6fd7935
commit
4f63c7934b
12 changed files with 393 additions and 875 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue