mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
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
@ -4,8 +4,9 @@ For more details about this platform, please refer to the documentation at
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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
|
from aiohttp.web import Request, Response
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from withings_api.common import NotifyAppli
|
from withings_api.common import NotifyAppli
|
||||||
@ -15,6 +16,7 @@ from homeassistant.components.application_credentials import (
|
|||||||
ClientCredential,
|
ClientCredential,
|
||||||
async_import_client_credential,
|
async_import_client_credential,
|
||||||
)
|
)
|
||||||
|
from homeassistant.components.http import HomeAssistantView
|
||||||
from homeassistant.components.webhook import (
|
from homeassistant.components.webhook import (
|
||||||
async_generate_id,
|
async_generate_id,
|
||||||
async_unregister as async_unregister_webhook,
|
async_unregister as async_unregister_webhook,
|
||||||
@ -28,17 +30,13 @@ from homeassistant.const import (
|
|||||||
Platform,
|
Platform,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
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.event import async_call_later
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from . import const
|
from . import const
|
||||||
from .common import (
|
from .api import ConfigEntryWithingsApi
|
||||||
async_get_data_manager,
|
from .common import WithingsDataUpdateCoordinator
|
||||||
async_remove_data_manager,
|
|
||||||
get_data_manager_by_webhook_id,
|
|
||||||
json_message_response,
|
|
||||||
)
|
|
||||||
from .const import CONF_USE_WEBHOOK, CONFIG, LOGGER
|
from .const import CONF_USE_WEBHOOK, CONFIG, LOGGER
|
||||||
|
|
||||||
DOMAIN = const.DOMAIN
|
DOMAIN = const.DOMAIN
|
||||||
@ -56,7 +54,7 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
vol.Optional(CONF_CLIENT_SECRET): vol.All(
|
vol.Optional(CONF_CLIENT_SECRET): vol.All(
|
||||||
cv.string, vol.Length(min=1)
|
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(
|
vol.Optional(const.CONF_PROFILES): vol.All(
|
||||||
cv.ensure_list,
|
cv.ensure_list,
|
||||||
vol.Unique(),
|
vol.Unique(),
|
||||||
@ -116,37 +114,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.config_entries.async_update_entry(
|
hass.config_entries.async_update_entry(
|
||||||
entry, data=new_data, options=new_options, unique_id=unique_id
|
entry, data=new_data, options=new_options, unique_id=unique_id
|
||||||
)
|
)
|
||||||
use_webhook = hass.data[DOMAIN][CONFIG][CONF_USE_WEBHOOK]
|
if (
|
||||||
if use_webhook is not None and use_webhook != entry.options[CONF_USE_WEBHOOK]:
|
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 = entry.options.copy()
|
||||||
new_options |= {CONF_USE_WEBHOOK: use_webhook}
|
new_options |= {CONF_USE_WEBHOOK: use_webhook}
|
||||||
hass.config_entries.async_update_entry(entry, options=new_options)
|
hass.config_entries.async_update_entry(entry, options=new_options)
|
||||||
|
|
||||||
data_manager = await async_get_data_manager(hass, entry)
|
client = ConfigEntryWithingsApi(
|
||||||
|
hass=hass,
|
||||||
LOGGER.debug("Confirming %s is authenticated to withings", entry.title)
|
config_entry=entry,
|
||||||
await data_manager.poll_data_update_coordinator.async_config_entry_first_refresh()
|
implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||||
|
hass, entry
|
||||||
webhook.async_register(
|
),
|
||||||
hass,
|
|
||||||
const.DOMAIN,
|
|
||||||
"Withings notify",
|
|
||||||
data_manager.webhook_config.id,
|
|
||||||
async_webhook_handler,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Perform first webhook subscription check.
|
use_webhooks = entry.options[CONF_USE_WEBHOOK]
|
||||||
if data_manager.webhook_config.enabled:
|
coordinator = WithingsDataUpdateCoordinator(hass, client, use_webhooks)
|
||||||
data_manager.async_start_polling_webhook_subscriptions()
|
if use_webhooks:
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_call_later_callback(now) -> None:
|
def async_call_later_callback(now) -> None:
|
||||||
hass.async_create_task(
|
hass.async_create_task(coordinator.async_subscribe_webhooks())
|
||||||
data_manager.subscription_update_coordinator.async_refresh()
|
|
||||||
|
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start subscription check in the background, outside this component's setup.
|
await coordinator.async_config_entry_first_refresh()
|
||||||
entry.async_on_unload(async_call_later(hass, 1, async_call_later_callback))
|
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
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:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload Withings config entry."""
|
"""Unload Withings config entry."""
|
||||||
data_manager = await async_get_data_manager(hass, entry)
|
if entry.options[CONF_USE_WEBHOOK]:
|
||||||
data_manager.async_stop_polling_webhook_subscriptions()
|
async_unregister_webhook(hass, entry.data[CONF_WEBHOOK_ID])
|
||||||
|
|
||||||
async_unregister_webhook(hass, data_manager.webhook_config.id)
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
await asyncio.gather(
|
return unload_ok
|
||||||
data_manager.async_unsubscribe_webhook(),
|
|
||||||
hass.config_entries.async_unload_platforms(entry, PLATFORMS),
|
|
||||||
)
|
|
||||||
|
|
||||||
async_remove_data_manager(hass, entry)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
@ -176,16 +171,25 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|||||||
await hass.config_entries.async_reload(entry.entry_id)
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
def json_message_response(message: str, message_code: int) -> Response:
|
||||||
|
"""Produce common json output."""
|
||||||
|
return HomeAssistantView.json({"message": message, "code": message_code})
|
||||||
|
|
||||||
|
|
||||||
|
def get_webhook_handler(
|
||||||
|
coordinator: WithingsDataUpdateCoordinator,
|
||||||
|
) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]:
|
||||||
|
"""Return webhook handler."""
|
||||||
|
|
||||||
async def async_webhook_handler(
|
async def async_webhook_handler(
|
||||||
hass: HomeAssistant, webhook_id: str, request: Request
|
hass: HomeAssistant, webhook_id: str, request: Request
|
||||||
) -> Response | None:
|
) -> Response | None:
|
||||||
"""Handle webhooks calls."""
|
|
||||||
# Handle http head calls to the path.
|
# 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.
|
# When creating a notify subscription, Withings will check that the endpoint is running by sending a HEAD request.
|
||||||
if request.method.upper() == "HEAD":
|
if request.method == METH_HEAD:
|
||||||
return Response()
|
return Response()
|
||||||
|
|
||||||
if request.method.upper() != "POST":
|
if request.method != METH_POST:
|
||||||
return json_message_response("Invalid method", message_code=2)
|
return json_message_response("Invalid method", message_code=2)
|
||||||
|
|
||||||
# Handle http post calls to the path.
|
# Handle http post calls to the path.
|
||||||
@ -195,25 +199,17 @@ async def async_webhook_handler(
|
|||||||
params = await request.post()
|
params = await request.post()
|
||||||
|
|
||||||
if "appli" not in params:
|
if "appli" not in params:
|
||||||
return json_message_response("Parameter appli not provided", message_code=20)
|
return json_message_response(
|
||||||
|
"Parameter appli not provided", message_code=20
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type]
|
appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type]
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return json_message_response("Invalid appli provided", message_code=21)
|
return json_message_response("Invalid appli provided", message_code=21)
|
||||||
|
|
||||||
data_manager = get_data_manager_by_webhook_id(hass, webhook_id)
|
await coordinator.async_webhook_data_updated(appli)
|
||||||
if not data_manager:
|
|
||||||
LOGGER.error(
|
|
||||||
(
|
|
||||||
"Webhook id %s not handled by data manager. This is a bug and should be"
|
|
||||||
" reported"
|
|
||||||
),
|
|
||||||
webhook_id,
|
|
||||||
)
|
|
||||||
return json_message_response("User not found", message_code=1)
|
|
||||||
|
|
||||||
# Run this in the background and return immediately.
|
|
||||||
hass.async_create_task(data_manager.async_webhook_data_updated(appli))
|
|
||||||
|
|
||||||
return json_message_response("Success", message_code=0)
|
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.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .common import UpdateType, async_get_data_manager
|
from .common import WithingsDataUpdateCoordinator
|
||||||
from .const import Measurement
|
from .const import DOMAIN, Measurement
|
||||||
from .entity import BaseWithingsSensor, WithingsEntityDescription
|
from .entity import WithingsEntity, WithingsEntityDescription
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -34,7 +34,6 @@ BINARY_SENSORS = [
|
|||||||
measure_type=NotifyAppli.BED_IN,
|
measure_type=NotifyAppli.BED_IN,
|
||||||
translation_key="in_bed",
|
translation_key="in_bed",
|
||||||
icon="mdi:bed",
|
icon="mdi:bed",
|
||||||
update_type=UpdateType.WEBHOOK,
|
|
||||||
device_class=BinarySensorDeviceClass.OCCUPANCY,
|
device_class=BinarySensorDeviceClass.OCCUPANCY,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@ -46,17 +45,17 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the sensor config entry."""
|
"""Set up the sensor config entry."""
|
||||||
data_manager = await async_get_data_manager(hass, entry)
|
coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
|
||||||
|
if coordinator.use_webhooks:
|
||||||
entities = [
|
entities = [
|
||||||
WithingsHealthBinarySensor(data_manager, attribute)
|
WithingsBinarySensor(coordinator, attribute) for attribute in BINARY_SENSORS
|
||||||
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."""
|
"""Implementation of a Withings sensor."""
|
||||||
|
|
||||||
entity_description: WithingsBinarySensorEntityDescription
|
entity_description: WithingsBinarySensorEntityDescription
|
||||||
@ -64,4 +63,4 @@ class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorEntity):
|
|||||||
@property
|
@property
|
||||||
def is_on(self) -> bool | None:
|
def is_on(self) -> bool | None:
|
||||||
"""Return true if the binary sensor is on."""
|
"""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."""
|
"""Withings coordinator."""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
|
||||||
import datetime
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from enum import IntEnum, StrEnum
|
|
||||||
from http import HTTPStatus
|
|
||||||
import re
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp.web import Response
|
|
||||||
from withings_api.common import (
|
from withings_api.common import (
|
||||||
AuthFailedException,
|
AuthFailedException,
|
||||||
GetSleepSummaryField,
|
GetSleepSummaryField,
|
||||||
@ -23,43 +15,19 @@ from withings_api.common import (
|
|||||||
query_measure_groups,
|
query_measure_groups,
|
||||||
)
|
)
|
||||||
|
|
||||||
from homeassistant.components import webhook
|
from homeassistant.components.webhook import async_generate_url
|
||||||
from homeassistant.components.http import HomeAssistantView
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_WEBHOOK_ID
|
from homeassistant.const import CONF_WEBHOOK_ID
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import config_entry_oauth2_flow
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from . import const
|
|
||||||
from .api import ConfigEntryWithingsApi
|
from .api import ConfigEntryWithingsApi
|
||||||
from .const import LOGGER, Measurement
|
from .const import LOGGER, Measurement
|
||||||
|
|
||||||
NOT_AUTHENTICATED_ERROR = re.compile(
|
SUBSCRIBE_DELAY = timedelta(seconds=5)
|
||||||
f"^{HTTPStatus.UNAUTHORIZED},.*",
|
UNSUBSCRIBE_DELAY = timedelta(seconds=1)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
WITHINGS_MEASURE_TYPE_MAP: dict[
|
WITHINGS_MEASURE_TYPE_MAP: dict[
|
||||||
NotifyAppli | GetSleepSummaryField | MeasureType, Measurement
|
NotifyAppli | GetSleepSummaryField | MeasureType, Measurement
|
||||||
@ -105,214 +73,91 @@ WITHINGS_MEASURE_TYPE_MAP: dict[
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def json_message_response(message: str, message_code: int) -> Response:
|
class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]]):
|
||||||
"""Produce common json output."""
|
"""Base coordinator."""
|
||||||
return HomeAssistantView.json({"message": message, "code": message_code})
|
|
||||||
|
|
||||||
|
in_bed: bool | None = None
|
||||||
class WebhookAvailability(IntEnum):
|
config_entry: ConfigEntry
|
||||||
"""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."""
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self, hass: HomeAssistant, client: ConfigEntryWithingsApi, use_webhooks: bool
|
||||||
hass: HomeAssistant,
|
|
||||||
api: ConfigEntryWithingsApi,
|
|
||||||
user_id: int,
|
|
||||||
webhook_config: WebhookConfig,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the data manager."""
|
"""Initialize the Withings data coordinator."""
|
||||||
self._hass = hass
|
update_interval: timedelta | None = timedelta(minutes=10)
|
||||||
self._api = api
|
if use_webhooks:
|
||||||
self._user_id = user_id
|
update_interval = None
|
||||||
self._webhook_config = webhook_config
|
super().__init__(hass, LOGGER, name="Withings", update_interval=update_interval)
|
||||||
self._notify_subscribe_delay = SUBSCRIBE_DELAY
|
self._client = client
|
||||||
self._notify_unsubscribe_delay = UNSUBSCRIBE_DELAY
|
self._webhook_url = async_generate_url(
|
||||||
|
hass, self.config_entry.data[CONF_WEBHOOK_ID]
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
self.poll_data_update_coordinator = DataUpdateCoordinator[
|
self.use_webhooks = use_webhooks
|
||||||
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
|
|
||||||
|
|
||||||
@property
|
async def async_subscribe_webhooks(self) -> None:
|
||||||
def webhook_config(self) -> WebhookConfig:
|
"""Subscribe to webhooks."""
|
||||||
"""Get the webhook config."""
|
await self.async_unsubscribe_webhooks()
|
||||||
return self._webhook_config
|
|
||||||
|
|
||||||
@property
|
current_webhooks = await self._client.async_notify_list()
|
||||||
def user_id(self) -> int:
|
|
||||||
"""Get the user_id of the authenticated user."""
|
|
||||||
return self._user_id
|
|
||||||
|
|
||||||
def async_start_polling_webhook_subscriptions(self) -> None:
|
subscribed_notifications = frozenset(
|
||||||
"""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(
|
|
||||||
profile.appli
|
profile.appli
|
||||||
for profile in response.profiles
|
for profile in current_webhooks.profiles
|
||||||
if profile.callbackurl == self._webhook_config.url
|
if profile.callbackurl == self._webhook_url
|
||||||
)
|
)
|
||||||
|
|
||||||
# Determine what subscriptions need to be created.
|
notification_to_subscribe = (
|
||||||
ignored_applis = frozenset({NotifyAppli.USER, NotifyAppli.UNKNOWN})
|
set(NotifyAppli)
|
||||||
to_add_applis = frozenset(
|
- subscribed_notifications
|
||||||
appli
|
- {NotifyAppli.USER, NotifyAppli.UNKNOWN}
|
||||||
for appli in NotifyAppli
|
|
||||||
if appli not in subscribed_applis and appli not in ignored_applis
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Subscribe to each one.
|
for notification in notification_to_subscribe:
|
||||||
for appli in to_add_applis:
|
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Subscribing %s for %s in %s seconds",
|
"Subscribing %s for %s in %s seconds",
|
||||||
self._webhook_config.url,
|
self._webhook_url,
|
||||||
appli,
|
notification,
|
||||||
self._notify_subscribe_delay.total_seconds(),
|
SUBSCRIBE_DELAY.total_seconds(),
|
||||||
)
|
)
|
||||||
# Withings will HTTP HEAD the callback_url and needs some downtime
|
# Withings will HTTP HEAD the callback_url and needs some downtime
|
||||||
# between each call or there is a higher chance of failure.
|
# between each call or there is a higher chance of failure.
|
||||||
await asyncio.sleep(self._notify_subscribe_delay.total_seconds())
|
await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds())
|
||||||
await self._api.async_notify_subscribe(self._webhook_config.url, appli)
|
await self._client.async_notify_subscribe(self._webhook_url, notification)
|
||||||
|
|
||||||
async def async_unsubscribe_webhook(self) -> None:
|
async def async_unsubscribe_webhooks(self) -> None:
|
||||||
"""Unsubscribe webhook from withings data updates."""
|
"""Unsubscribe to webhooks."""
|
||||||
# Get the current webhooks.
|
current_webhooks = await self._client.async_notify_list()
|
||||||
response = await self._api.async_notify_list()
|
|
||||||
|
|
||||||
# Revoke subscriptions.
|
for webhook_configuration in current_webhooks.profiles:
|
||||||
for profile in response.profiles:
|
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Unsubscribing %s for %s in %s seconds",
|
"Unsubscribing %s for %s in %s seconds",
|
||||||
profile.callbackurl,
|
webhook_configuration.callbackurl,
|
||||||
profile.appli,
|
webhook_configuration.appli,
|
||||||
self._notify_unsubscribe_delay.total_seconds(),
|
UNSUBSCRIBE_DELAY.total_seconds(),
|
||||||
)
|
)
|
||||||
# Quick calls to Withings can result in the service returning errors.
|
# Quick calls to Withings can result in the service returning errors.
|
||||||
# Give them some time to cool down.
|
# Give them some time to cool down.
|
||||||
await asyncio.sleep(self._notify_subscribe_delay.total_seconds())
|
await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds())
|
||||||
await self._api.async_notify_revoke(profile.callbackurl, profile.appli)
|
await self._client.async_notify_revoke(
|
||||||
|
webhook_configuration.callbackurl, webhook_configuration.appli
|
||||||
|
)
|
||||||
|
|
||||||
async def async_get_all_data(self) -> dict[MeasureType, Any] | None:
|
async def _async_update_data(self) -> dict[Measurement, Any]:
|
||||||
"""Update all withings data."""
|
|
||||||
try:
|
try:
|
||||||
|
measurements = await self._get_measurements()
|
||||||
|
sleep_summary = await self._get_sleep_summary()
|
||||||
|
except (UnauthorizedException, AuthFailedException) as exc:
|
||||||
|
raise ConfigEntryAuthFailed from exc
|
||||||
return {
|
return {
|
||||||
**await self.async_get_measures(),
|
**measurements,
|
||||||
**await self.async_get_sleep_summary(),
|
**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
|
|
||||||
|
|
||||||
raise exception
|
async def _get_measurements(self) -> dict[Measurement, Any]:
|
||||||
|
|
||||||
async def async_get_measures(self) -> dict[Measurement, Any]:
|
|
||||||
"""Get the measures data."""
|
|
||||||
LOGGER.debug("Updating withings measures")
|
LOGGER.debug("Updating withings measures")
|
||||||
now = dt_util.utcnow()
|
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
|
None, None, startdate, now, None, startdate
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -334,17 +179,13 @@ class DataManager:
|
|||||||
if measure.type in WITHINGS_MEASURE_TYPE_MAP
|
if measure.type in WITHINGS_MEASURE_TYPE_MAP
|
||||||
}
|
}
|
||||||
|
|
||||||
async def async_get_sleep_summary(self) -> dict[Measurement, Any]:
|
async def _get_sleep_summary(self) -> dict[Measurement, Any]:
|
||||||
"""Get the sleep summary data."""
|
|
||||||
LOGGER.debug("Updating withing sleep summary")
|
|
||||||
now = dt_util.now()
|
now = dt_util.now()
|
||||||
yesterday = now - datetime.timedelta(days=1)
|
yesterday = now - timedelta(days=1)
|
||||||
yesterday_noon = dt_util.start_of_local_day(yesterday) + datetime.timedelta(
|
yesterday_noon = dt_util.start_of_local_day(yesterday) + timedelta(hours=12)
|
||||||
hours=12
|
|
||||||
)
|
|
||||||
yesterday_noon_utc = dt_util.as_utc(yesterday_noon)
|
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,
|
lastupdate=yesterday_noon_utc,
|
||||||
data_fields=[
|
data_fields=[
|
||||||
GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY,
|
GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY,
|
||||||
@ -415,81 +256,18 @@ class DataManager:
|
|||||||
for field, value in values.items()
|
for field, value in values.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
async def async_webhook_data_updated(self, data_category: NotifyAppli) -> None:
|
async def async_webhook_data_updated(
|
||||||
"""Handle scenario when data is updated from a webook."""
|
self, notification_category: NotifyAppli
|
||||||
|
) -> None:
|
||||||
|
"""Update data when webhook is called."""
|
||||||
LOGGER.debug("Withings webhook triggered")
|
LOGGER.debug("Withings webhook triggered")
|
||||||
if data_category in {
|
if notification_category in {
|
||||||
NotifyAppli.WEIGHT,
|
NotifyAppli.WEIGHT,
|
||||||
NotifyAppli.CIRCULATORY,
|
NotifyAppli.CIRCULATORY,
|
||||||
NotifyAppli.SLEEP,
|
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}:
|
elif notification_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}:
|
||||||
self.webhook_update_coordinator.update_data(
|
self.in_bed = notification_category == NotifyAppli.BED_IN
|
||||||
Measurement.IN_BED, data_category == NotifyAppli.BED_IN
|
self.async_update_listeners()
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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]
|
|
||||||
|
@ -2,15 +2,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from withings_api.common import GetSleepSummaryField, MeasureType, NotifyAppli
|
from withings_api.common import GetSleepSummaryField, MeasureType, NotifyAppli
|
||||||
|
|
||||||
from homeassistant.core import callback
|
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
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
|
from .const import DOMAIN, Measurement
|
||||||
|
|
||||||
|
|
||||||
@ -20,7 +19,6 @@ class WithingsEntityDescriptionMixin:
|
|||||||
|
|
||||||
measurement: Measurement
|
measurement: Measurement
|
||||||
measure_type: NotifyAppli | GetSleepSummaryField | MeasureType
|
measure_type: NotifyAppli | GetSleepSummaryField | MeasureType
|
||||||
update_type: UpdateType
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -28,72 +26,22 @@ class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixi
|
|||||||
"""Immutable class for describing withings data."""
|
"""Immutable class for describing withings data."""
|
||||||
|
|
||||||
|
|
||||||
class BaseWithingsSensor(Entity):
|
class WithingsEntity(CoordinatorEntity[WithingsDataUpdateCoordinator]):
|
||||||
"""Base class for withings sensors."""
|
"""Base class for withings entities."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
|
||||||
entity_description: WithingsEntityDescription
|
entity_description: WithingsEntityDescription
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, data_manager: DataManager, description: WithingsEntityDescription
|
self,
|
||||||
|
coordinator: WithingsDataUpdateCoordinator,
|
||||||
|
description: WithingsEntityDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the Withings sensor."""
|
"""Initialize the Withings entity."""
|
||||||
self._data_manager = data_manager
|
super().__init__(coordinator)
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
self._attr_unique_id = (
|
self._attr_unique_id = f"withings_{coordinator.config_entry.unique_id}_{description.measurement.value}"
|
||||||
f"withings_{data_manager.user_id}_{description.measurement.value}"
|
|
||||||
)
|
|
||||||
self._state_data: Any | None = None
|
|
||||||
self._attr_device_info = DeviceInfo(
|
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.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .common import UpdateType, async_get_data_manager
|
from .common import WithingsDataUpdateCoordinator
|
||||||
from .const import (
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
SCORE_POINTS,
|
SCORE_POINTS,
|
||||||
UOM_BEATS_PER_MINUTE,
|
UOM_BEATS_PER_MINUTE,
|
||||||
UOM_BREATHS_PER_MINUTE,
|
UOM_BREATHS_PER_MINUTE,
|
||||||
@ -32,7 +33,7 @@ from .const import (
|
|||||||
UOM_MMHG,
|
UOM_MMHG,
|
||||||
Measurement,
|
Measurement,
|
||||||
)
|
)
|
||||||
from .entity import BaseWithingsSensor, WithingsEntityDescription
|
from .entity import WithingsEntity, WithingsEntityDescription
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -50,7 +51,6 @@ SENSORS = [
|
|||||||
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
|
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
|
||||||
device_class=SensorDeviceClass.WEIGHT,
|
device_class=SensorDeviceClass.WEIGHT,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.FAT_MASS_KG.value,
|
key=Measurement.FAT_MASS_KG.value,
|
||||||
@ -60,7 +60,6 @@ SENSORS = [
|
|||||||
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
|
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
|
||||||
device_class=SensorDeviceClass.WEIGHT,
|
device_class=SensorDeviceClass.WEIGHT,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.FAT_FREE_MASS_KG.value,
|
key=Measurement.FAT_FREE_MASS_KG.value,
|
||||||
@ -70,7 +69,6 @@ SENSORS = [
|
|||||||
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
|
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
|
||||||
device_class=SensorDeviceClass.WEIGHT,
|
device_class=SensorDeviceClass.WEIGHT,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.MUSCLE_MASS_KG.value,
|
key=Measurement.MUSCLE_MASS_KG.value,
|
||||||
@ -80,7 +78,6 @@ SENSORS = [
|
|||||||
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
|
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
|
||||||
device_class=SensorDeviceClass.WEIGHT,
|
device_class=SensorDeviceClass.WEIGHT,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.BONE_MASS_KG.value,
|
key=Measurement.BONE_MASS_KG.value,
|
||||||
@ -90,7 +87,6 @@ SENSORS = [
|
|||||||
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
|
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
|
||||||
device_class=SensorDeviceClass.WEIGHT,
|
device_class=SensorDeviceClass.WEIGHT,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.HEIGHT_M.value,
|
key=Measurement.HEIGHT_M.value,
|
||||||
@ -101,7 +97,6 @@ SENSORS = [
|
|||||||
device_class=SensorDeviceClass.DISTANCE,
|
device_class=SensorDeviceClass.DISTANCE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.TEMP_C.value,
|
key=Measurement.TEMP_C.value,
|
||||||
@ -110,7 +105,6 @@ SENSORS = [
|
|||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.BODY_TEMP_C.value,
|
key=Measurement.BODY_TEMP_C.value,
|
||||||
@ -120,7 +114,6 @@ SENSORS = [
|
|||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SKIN_TEMP_C.value,
|
key=Measurement.SKIN_TEMP_C.value,
|
||||||
@ -130,7 +123,6 @@ SENSORS = [
|
|||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.FAT_RATIO_PCT.value,
|
key=Measurement.FAT_RATIO_PCT.value,
|
||||||
@ -139,7 +131,6 @@ SENSORS = [
|
|||||||
translation_key="fat_ratio",
|
translation_key="fat_ratio",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.DIASTOLIC_MMHG.value,
|
key=Measurement.DIASTOLIC_MMHG.value,
|
||||||
@ -148,7 +139,6 @@ SENSORS = [
|
|||||||
translation_key="diastolic_blood_pressure",
|
translation_key="diastolic_blood_pressure",
|
||||||
native_unit_of_measurement=UOM_MMHG,
|
native_unit_of_measurement=UOM_MMHG,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SYSTOLIC_MMGH.value,
|
key=Measurement.SYSTOLIC_MMGH.value,
|
||||||
@ -157,7 +147,6 @@ SENSORS = [
|
|||||||
translation_key="systolic_blood_pressure",
|
translation_key="systolic_blood_pressure",
|
||||||
native_unit_of_measurement=UOM_MMHG,
|
native_unit_of_measurement=UOM_MMHG,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.HEART_PULSE_BPM.value,
|
key=Measurement.HEART_PULSE_BPM.value,
|
||||||
@ -167,7 +156,6 @@ SENSORS = [
|
|||||||
native_unit_of_measurement=UOM_BEATS_PER_MINUTE,
|
native_unit_of_measurement=UOM_BEATS_PER_MINUTE,
|
||||||
icon="mdi:heart-pulse",
|
icon="mdi:heart-pulse",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SPO2_PCT.value,
|
key=Measurement.SPO2_PCT.value,
|
||||||
@ -176,7 +164,6 @@ SENSORS = [
|
|||||||
translation_key="spo2",
|
translation_key="spo2",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.HYDRATION.value,
|
key=Measurement.HYDRATION.value,
|
||||||
@ -188,7 +175,6 @@ SENSORS = [
|
|||||||
icon="mdi:water",
|
icon="mdi:water",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.PWV.value,
|
key=Measurement.PWV.value,
|
||||||
@ -198,7 +184,6 @@ SENSORS = [
|
|||||||
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
|
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
|
||||||
device_class=SensorDeviceClass.SPEED,
|
device_class=SensorDeviceClass.SPEED,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value,
|
key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value,
|
||||||
@ -207,7 +192,6 @@ SENSORS = [
|
|||||||
translation_key="breathing_disturbances_intensity",
|
translation_key="breathing_disturbances_intensity",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_DEEP_DURATION_SECONDS.value,
|
key=Measurement.SLEEP_DEEP_DURATION_SECONDS.value,
|
||||||
@ -219,7 +203,6 @@ SENSORS = [
|
|||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS.value,
|
key=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS.value,
|
||||||
@ -231,7 +214,6 @@ SENSORS = [
|
|||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS.value,
|
key=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS.value,
|
||||||
@ -243,7 +225,6 @@ SENSORS = [
|
|||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_HEART_RATE_AVERAGE.value,
|
key=Measurement.SLEEP_HEART_RATE_AVERAGE.value,
|
||||||
@ -254,7 +235,6 @@ SENSORS = [
|
|||||||
icon="mdi:heart-pulse",
|
icon="mdi:heart-pulse",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_HEART_RATE_MAX.value,
|
key=Measurement.SLEEP_HEART_RATE_MAX.value,
|
||||||
@ -266,7 +246,6 @@ SENSORS = [
|
|||||||
icon="mdi:heart-pulse",
|
icon="mdi:heart-pulse",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_HEART_RATE_MIN.value,
|
key=Measurement.SLEEP_HEART_RATE_MIN.value,
|
||||||
@ -277,7 +256,6 @@ SENSORS = [
|
|||||||
icon="mdi:heart-pulse",
|
icon="mdi:heart-pulse",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_LIGHT_DURATION_SECONDS.value,
|
key=Measurement.SLEEP_LIGHT_DURATION_SECONDS.value,
|
||||||
@ -289,7 +267,6 @@ SENSORS = [
|
|||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_REM_DURATION_SECONDS.value,
|
key=Measurement.SLEEP_REM_DURATION_SECONDS.value,
|
||||||
@ -301,7 +278,6 @@ SENSORS = [
|
|||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE.value,
|
key=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE.value,
|
||||||
@ -311,7 +287,6 @@ SENSORS = [
|
|||||||
native_unit_of_measurement=UOM_BREATHS_PER_MINUTE,
|
native_unit_of_measurement=UOM_BREATHS_PER_MINUTE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_RESPIRATORY_RATE_MAX.value,
|
key=Measurement.SLEEP_RESPIRATORY_RATE_MAX.value,
|
||||||
@ -321,7 +296,6 @@ SENSORS = [
|
|||||||
native_unit_of_measurement=UOM_BREATHS_PER_MINUTE,
|
native_unit_of_measurement=UOM_BREATHS_PER_MINUTE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_RESPIRATORY_RATE_MIN.value,
|
key=Measurement.SLEEP_RESPIRATORY_RATE_MIN.value,
|
||||||
@ -331,7 +305,6 @@ SENSORS = [
|
|||||||
native_unit_of_measurement=UOM_BREATHS_PER_MINUTE,
|
native_unit_of_measurement=UOM_BREATHS_PER_MINUTE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_SCORE.value,
|
key=Measurement.SLEEP_SCORE.value,
|
||||||
@ -342,7 +315,6 @@ SENSORS = [
|
|||||||
icon="mdi:medal",
|
icon="mdi:medal",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_SNORING.value,
|
key=Measurement.SLEEP_SNORING.value,
|
||||||
@ -351,7 +323,6 @@ SENSORS = [
|
|||||||
translation_key="snoring",
|
translation_key="snoring",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_SNORING_EPISODE_COUNT.value,
|
key=Measurement.SLEEP_SNORING_EPISODE_COUNT.value,
|
||||||
@ -360,7 +331,6 @@ SENSORS = [
|
|||||||
translation_key="snoring_episode_count",
|
translation_key="snoring_episode_count",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_WAKEUP_COUNT.value,
|
key=Measurement.SLEEP_WAKEUP_COUNT.value,
|
||||||
@ -371,7 +341,6 @@ SENSORS = [
|
|||||||
icon="mdi:sleep-off",
|
icon="mdi:sleep-off",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
WithingsSensorEntityDescription(
|
WithingsSensorEntityDescription(
|
||||||
key=Measurement.SLEEP_WAKEUP_DURATION_SECONDS.value,
|
key=Measurement.SLEEP_WAKEUP_DURATION_SECONDS.value,
|
||||||
@ -383,7 +352,6 @@ SENSORS = [
|
|||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
update_type=UpdateType.POLL,
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -394,14 +362,12 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the sensor config entry."""
|
"""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(WithingsSensor(coordinator, attribute) for attribute in SENSORS)
|
||||||
|
|
||||||
async_add_entities(entities, True)
|
|
||||||
|
|
||||||
|
|
||||||
class WithingsHealthSensor(BaseWithingsSensor, SensorEntity):
|
class WithingsSensor(WithingsEntity, SensorEntity):
|
||||||
"""Implementation of a Withings sensor."""
|
"""Implementation of a Withings sensor."""
|
||||||
|
|
||||||
entity_description: WithingsSensorEntityDescription
|
entity_description: WithingsSensorEntityDescription
|
||||||
@ -409,4 +375,12 @@ class WithingsHealthSensor(BaseWithingsSensor, SensorEntity):
|
|||||||
@property
|
@property
|
||||||
def native_value(self) -> None | str | int | float:
|
def native_value(self) -> None | str | int | float:
|
||||||
"""Return the state of the entity."""
|
"""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 typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from aiohttp.test_utils import TestClient
|
||||||
|
|
||||||
from homeassistant.components.webhook import async_generate_url
|
from homeassistant.components.webhook import async_generate_url
|
||||||
from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN
|
from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN
|
||||||
from homeassistant.config import async_process_ha_core_config
|
from homeassistant.config import async_process_ha_core_config
|
||||||
@ -21,7 +23,7 @@ class WebhookResponse:
|
|||||||
|
|
||||||
|
|
||||||
async def call_webhook(
|
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:
|
) -> WebhookResponse:
|
||||||
"""Call the webhook."""
|
"""Call the webhook."""
|
||||||
webhook_url = async_generate_url(hass, webhook_id)
|
webhook_url = async_generate_url(hass, webhook_id)
|
||||||
@ -34,7 +36,7 @@ async def call_webhook(
|
|||||||
# Wait for remaining tasks to complete.
|
# Wait for remaining tasks to complete.
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
data: dict[str, Any] = await resp.json()
|
data = await resp.json()
|
||||||
resp.close()
|
resp.close()
|
||||||
|
|
||||||
return WebhookResponse(message=data["message"], message_code=data["code"])
|
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(
|
await async_process_ha_core_config(
|
||||||
hass,
|
hass,
|
||||||
{"internal_url": "http://example.local:8123"},
|
{"external_url": "http://example.local:8123"},
|
||||||
)
|
)
|
||||||
|
|
||||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
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.core import HomeAssistant
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from .common import ComponentFactory
|
|
||||||
|
|
||||||
from tests.common import MockConfigEntry, load_json_object_fixture
|
from tests.common import MockConfigEntry, load_json_object_fixture
|
||||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
|
||||||
|
|
||||||
CLIENT_ID = "1234"
|
CLIENT_ID = "1234"
|
||||||
CLIENT_SECRET = "5678"
|
CLIENT_SECRET = "5678"
|
||||||
@ -38,22 +35,6 @@ USER_ID = 12345
|
|||||||
WEBHOOK_ID = "55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e"
|
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")
|
@pytest.fixture(name="scopes")
|
||||||
def mock_scopes() -> list[str]:
|
def mock_scopes() -> list[str]:
|
||||||
"""Fixture to set the scopes present in the OAuth token."""
|
"""Fixture to set the scopes present in the OAuth token."""
|
||||||
@ -78,8 +59,8 @@ def mock_expires_at() -> int:
|
|||||||
return time.time() + 3600
|
return time.time() + 3600
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="config_entry")
|
@pytest.fixture
|
||||||
def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
|
def webhook_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
|
||||||
"""Create Withings entry in Home Assistant."""
|
"""Create Withings entry in Home Assistant."""
|
||||||
return MockConfigEntry(
|
return MockConfigEntry(
|
||||||
domain=DOMAIN,
|
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")
|
@pytest.fixture(name="withings")
|
||||||
def mock_withings():
|
def mock_withings():
|
||||||
"""Mock withings."""
|
"""Mock withings."""
|
||||||
@ -123,7 +130,7 @@ def mock_withings():
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.withings.common.ConfigEntryWithingsApi",
|
"homeassistant.components.withings.ConfigEntryWithingsApi",
|
||||||
return_value=mock,
|
return_value=mock,
|
||||||
):
|
):
|
||||||
yield mock
|
yield mock
|
||||||
@ -135,7 +142,8 @@ def disable_webhook_delay():
|
|||||||
|
|
||||||
mock = AsyncMock()
|
mock = AsyncMock()
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.withings.common.SUBSCRIBE_DELAY", timedelta(seconds=0)
|
"homeassistant.components.withings.common.SUBSCRIBE_DELAY",
|
||||||
|
timedelta(seconds=0),
|
||||||
), patch(
|
), patch(
|
||||||
"homeassistant.components.withings.common.UNSUBSCRIBE_DELAY",
|
"homeassistant.components.withings.common.UNSUBSCRIBE_DELAY",
|
||||||
timedelta(seconds=0),
|
timedelta(seconds=0),
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
"""Tests for the Withings component."""
|
"""Tests for the Withings component."""
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from aiohttp.client_exceptions import ClientResponseError
|
||||||
|
import pytest
|
||||||
from withings_api.common import NotifyAppli
|
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 homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from . import call_webhook, enable_webhooks, setup_integration
|
from . import call_webhook, enable_webhooks, setup_integration
|
||||||
@ -17,18 +19,18 @@ async def test_binary_sensor(
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
withings: AsyncMock,
|
withings: AsyncMock,
|
||||||
disable_webhook_delay,
|
disable_webhook_delay,
|
||||||
config_entry: MockConfigEntry,
|
webhook_config_entry: MockConfigEntry,
|
||||||
hass_client_no_auth: ClientSessionGenerator,
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test binary sensor."""
|
"""Test binary sensor."""
|
||||||
await enable_webhooks(hass)
|
await enable_webhooks(hass)
|
||||||
await setup_integration(hass, config_entry)
|
await setup_integration(hass, webhook_config_entry)
|
||||||
|
|
||||||
client = await hass_client_no_auth()
|
client = await hass_client_no_auth()
|
||||||
|
|
||||||
entity_id = "binary_sensor.henk_in_bed"
|
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(
|
resp = await call_webhook(
|
||||||
hass,
|
hass,
|
||||||
@ -49,3 +51,28 @@ async def test_binary_sensor(
|
|||||||
assert resp.message_code == 0
|
assert resp.message_code == 0
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert hass.states.get(entity_id).state == STATE_OFF
|
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,
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
current_request_with_host: None,
|
current_request_with_host: None,
|
||||||
withings: AsyncMock,
|
withings: AsyncMock,
|
||||||
config_entry: MockConfigEntry,
|
polling_config_entry: MockConfigEntry,
|
||||||
disable_webhook_delay,
|
disable_webhook_delay,
|
||||||
aioclient_mock: AiohttpClientMocker,
|
aioclient_mock: AiohttpClientMocker,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test setup a non-unique profile."""
|
"""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(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": SOURCE_USER}
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
)
|
)
|
||||||
@ -136,21 +136,21 @@ async def test_config_reauth_profile(
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
hass_client_no_auth: ClientSessionGenerator,
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
aioclient_mock: AiohttpClientMocker,
|
aioclient_mock: AiohttpClientMocker,
|
||||||
config_entry: MockConfigEntry,
|
polling_config_entry: MockConfigEntry,
|
||||||
withings: AsyncMock,
|
withings: AsyncMock,
|
||||||
disable_webhook_delay,
|
disable_webhook_delay,
|
||||||
current_request_with_host,
|
current_request_with_host,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test reauth an existing profile reauthenticates the config entry."""
|
"""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(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
context={
|
context={
|
||||||
"source": SOURCE_REAUTH,
|
"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["type"] == "form"
|
||||||
assert result["step_id"] == "reauth_confirm"
|
assert result["step_id"] == "reauth_confirm"
|
||||||
@ -199,21 +199,21 @@ async def test_config_reauth_wrong_account(
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
hass_client_no_auth: ClientSessionGenerator,
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
aioclient_mock: AiohttpClientMocker,
|
aioclient_mock: AiohttpClientMocker,
|
||||||
config_entry: MockConfigEntry,
|
polling_config_entry: MockConfigEntry,
|
||||||
withings: AsyncMock,
|
withings: AsyncMock,
|
||||||
disable_webhook_delay,
|
disable_webhook_delay,
|
||||||
current_request_with_host,
|
current_request_with_host,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test reauth with wrong account."""
|
"""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(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
context={
|
context={
|
||||||
"source": SOURCE_REAUTH,
|
"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["type"] == "form"
|
||||||
assert result["step_id"] == "reauth_confirm"
|
assert result["step_id"] == "reauth_confirm"
|
||||||
@ -262,15 +262,15 @@ async def test_options_flow(
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
hass_client_no_auth: ClientSessionGenerator,
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
aioclient_mock: AiohttpClientMocker,
|
aioclient_mock: AiohttpClientMocker,
|
||||||
config_entry: MockConfigEntry,
|
polling_config_entry: MockConfigEntry,
|
||||||
withings: AsyncMock,
|
withings: AsyncMock,
|
||||||
disable_webhook_delay,
|
disable_webhook_delay,
|
||||||
current_request_with_host,
|
current_request_with_host,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test options flow."""
|
"""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()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert result["type"] == FlowResultType.FORM
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
@ -4,18 +4,20 @@ from typing import Any
|
|||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
import pytest
|
import pytest
|
||||||
import voluptuous as vol
|
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.webhook import async_generate_url
|
||||||
from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const
|
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.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from . import enable_webhooks, setup_integration
|
from . import call_webhook, enable_webhooks, setup_integration
|
||||||
from .conftest import WEBHOOK_ID
|
from .conftest import USER_ID, WEBHOOK_ID
|
||||||
|
|
||||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||||
from tests.typing import ClientSessionGenerator
|
from tests.typing import ClientSessionGenerator
|
||||||
@ -106,12 +108,12 @@ async def test_data_manager_webhook_subscription(
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
withings: AsyncMock,
|
withings: AsyncMock,
|
||||||
disable_webhook_delay,
|
disable_webhook_delay,
|
||||||
config_entry: MockConfigEntry,
|
webhook_config_entry: MockConfigEntry,
|
||||||
hass_client_no_auth: ClientSessionGenerator,
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test data manager webhook subscriptions."""
|
"""Test data manager webhook subscriptions."""
|
||||||
await enable_webhooks(hass)
|
await enable_webhooks(hass)
|
||||||
await setup_integration(hass, config_entry)
|
await setup_integration(hass, webhook_config_entry)
|
||||||
await hass_client_no_auth()
|
await hass_client_no_auth()
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
|
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)
|
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(
|
@pytest.mark.parametrize(
|
||||||
"method",
|
"method",
|
||||||
[
|
[
|
||||||
@ -142,13 +165,14 @@ async def test_data_manager_webhook_subscription(
|
|||||||
async def test_requests(
|
async def test_requests(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
withings: AsyncMock,
|
withings: AsyncMock,
|
||||||
config_entry: MockConfigEntry,
|
webhook_config_entry: MockConfigEntry,
|
||||||
hass_client_no_auth: ClientSessionGenerator,
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
method: str,
|
method: str,
|
||||||
disable_webhook_delay,
|
disable_webhook_delay,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test we handle request methods Withings sends."""
|
"""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()
|
client = await hass_client_no_auth()
|
||||||
webhook_url = async_generate_url(hass, WEBHOOK_ID)
|
webhook_url = async_generate_url(hass, WEBHOOK_ID)
|
||||||
|
|
||||||
@ -159,6 +183,59 @@ async def test_requests(
|
|||||||
assert response.status == 200
|
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(
|
@pytest.mark.parametrize(
|
||||||
("config_entry"),
|
("config_entry"),
|
||||||
[
|
[
|
||||||
@ -220,7 +297,7 @@ async def test_config_flow_upgrade(
|
|||||||
async def test_webhook_post(
|
async def test_webhook_post(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
withings: AsyncMock,
|
withings: AsyncMock,
|
||||||
config_entry: MockConfigEntry,
|
webhook_config_entry: MockConfigEntry,
|
||||||
hass_client_no_auth: ClientSessionGenerator,
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
disable_webhook_delay,
|
disable_webhook_delay,
|
||||||
body: dict[str, Any],
|
body: dict[str, Any],
|
||||||
@ -228,7 +305,8 @@ async def test_webhook_post(
|
|||||||
current_request_with_host: None,
|
current_request_with_host: None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test webhook callback."""
|
"""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()
|
client = await hass_client_no_auth()
|
||||||
webhook_url = async_generate_url(hass, WEBHOOK_ID)
|
webhook_url = async_generate_url(hass, WEBHOOK_ID)
|
||||||
|
|
||||||
|
@ -1,24 +1,26 @@
|
|||||||
"""Tests for the Withings component."""
|
"""Tests for the Withings component."""
|
||||||
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
import pytest
|
import pytest
|
||||||
from syrupy import SnapshotAssertion
|
from syrupy import SnapshotAssertion
|
||||||
from withings_api.common import NotifyAppli
|
from withings_api.common import NotifyAppli
|
||||||
|
|
||||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
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.entity import WithingsEntityDescription
|
||||||
from homeassistant.components.withings.sensor import SENSORS
|
from homeassistant.components.withings.sensor import SENSORS
|
||||||
|
from homeassistant.const import STATE_UNAVAILABLE
|
||||||
from homeassistant.core import HomeAssistant, State
|
from homeassistant.core import HomeAssistant, State
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.entity_registry import EntityRegistry
|
from homeassistant.helpers.entity_registry import EntityRegistry
|
||||||
|
|
||||||
from . import call_webhook, setup_integration
|
from . import call_webhook, enable_webhooks, setup_integration
|
||||||
from .common import async_get_entity_id
|
|
||||||
from .conftest import USER_ID, WEBHOOK_ID
|
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
|
from tests.typing import ClientSessionGenerator
|
||||||
|
|
||||||
WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = {
|
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(
|
def async_assert_state_equals(
|
||||||
entity_id: str,
|
entity_id: str,
|
||||||
state_obj: State,
|
state_obj: State,
|
||||||
@ -79,12 +94,13 @@ def async_assert_state_equals(
|
|||||||
async def test_sensor_default_enabled_entities(
|
async def test_sensor_default_enabled_entities(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
withings: AsyncMock,
|
withings: AsyncMock,
|
||||||
config_entry: MockConfigEntry,
|
webhook_config_entry: MockConfigEntry,
|
||||||
disable_webhook_delay,
|
disable_webhook_delay,
|
||||||
hass_client_no_auth: ClientSessionGenerator,
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test entities enabled by default."""
|
"""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)
|
entity_registry: EntityRegistry = er.async_get(hass)
|
||||||
|
|
||||||
client = await hass_client_no_auth()
|
client = await hass_client_no_auth()
|
||||||
@ -122,11 +138,31 @@ async def test_all_entities(
|
|||||||
snapshot: SnapshotAssertion,
|
snapshot: SnapshotAssertion,
|
||||||
withings: AsyncMock,
|
withings: AsyncMock,
|
||||||
disable_webhook_delay,
|
disable_webhook_delay,
|
||||||
config_entry: MockConfigEntry,
|
polling_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test all entities."""
|
"""Test all entities."""
|
||||||
await setup_integration(hass, config_entry)
|
await setup_integration(hass, polling_config_entry)
|
||||||
|
|
||||||
for sensor in SENSORS:
|
for sensor in SENSORS:
|
||||||
entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN)
|
entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN)
|
||||||
assert hass.states.get(entity_id) == snapshot
|
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…
x
Reference in New Issue
Block a user