Split Withings coordinators (#101766)
* Subscribe to Withings webhooks outside of coordinator * Subscribe to Withings webhooks outside of coordinator * Split Withings coordinator * Split Withings coordinator * Update homeassistant/components/withings/sensor.py * Fix merge * Rename MEASUREMENT_COORDINATOR * Update homeassistant/components/withings/__init__.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Apply suggestions from code review Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Fix feedback --------- Co-authored-by: Martin Hjelmare <marhje52@gmail.com>pull/93301/head
parent
03210d7f81
commit
d712a29052
|
@ -43,8 +43,22 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
|
|||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .api import ConfigEntryWithingsApi
|
||||
from .const import CONF_PROFILES, CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN, LOGGER
|
||||
from .coordinator import WithingsDataUpdateCoordinator
|
||||
from .const import (
|
||||
BED_PRESENCE_COORDINATOR,
|
||||
CONF_PROFILES,
|
||||
CONF_USE_WEBHOOK,
|
||||
DEFAULT_TITLE,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MEASUREMENT_COORDINATOR,
|
||||
SLEEP_COORDINATOR,
|
||||
)
|
||||
from .coordinator import (
|
||||
WithingsBedPresenceDataUpdateCoordinator,
|
||||
WithingsDataUpdateCoordinator,
|
||||
WithingsMeasurementDataUpdateCoordinator,
|
||||
WithingsSleepDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
|
@ -128,11 +142,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
hass, entry
|
||||
),
|
||||
)
|
||||
coordinator = WithingsDataUpdateCoordinator(hass, client)
|
||||
coordinators: dict[str, WithingsDataUpdateCoordinator] = {
|
||||
MEASUREMENT_COORDINATOR: WithingsMeasurementDataUpdateCoordinator(hass, client),
|
||||
SLEEP_COORDINATOR: WithingsSleepDataUpdateCoordinator(hass, client),
|
||||
BED_PRESENCE_COORDINATOR: WithingsBedPresenceDataUpdateCoordinator(
|
||||
hass, client
|
||||
),
|
||||
}
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
for coordinator in coordinators.values():
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators
|
||||
|
||||
async def unregister_webhook(
|
||||
_: Any,
|
||||
|
@ -140,7 +161,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID])
|
||||
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
|
||||
await async_unsubscribe_webhooks(client)
|
||||
coordinator.webhook_subscription_listener(False)
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.webhook_subscription_listener(False)
|
||||
|
||||
async def register_webhook(
|
||||
_: Any,
|
||||
|
@ -166,11 +188,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
DOMAIN,
|
||||
webhook_name,
|
||||
entry.data[CONF_WEBHOOK_ID],
|
||||
get_webhook_handler(coordinator),
|
||||
get_webhook_handler(coordinators),
|
||||
)
|
||||
|
||||
await async_subscribe_webhooks(client, webhook_url)
|
||||
coordinator.webhook_subscription_listener(True)
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.webhook_subscription_listener(True)
|
||||
LOGGER.debug("Register Withings webhook: %s", webhook_url)
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook)
|
||||
|
@ -287,7 +310,7 @@ def json_message_response(message: str, message_code: int) -> Response:
|
|||
|
||||
|
||||
def get_webhook_handler(
|
||||
coordinator: WithingsDataUpdateCoordinator,
|
||||
coordinators: dict[str, WithingsDataUpdateCoordinator],
|
||||
) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]:
|
||||
"""Return webhook handler."""
|
||||
|
||||
|
@ -318,7 +341,9 @@ def get_webhook_handler(
|
|||
except ValueError:
|
||||
return json_message_response("Invalid appli provided", message_code=21)
|
||||
|
||||
await coordinator.async_webhook_data_updated(appli)
|
||||
for coordinator in coordinators.values():
|
||||
if appli in coordinator.notification_categories:
|
||||
await coordinator.async_webhook_data_updated(appli)
|
||||
|
||||
return json_message_response("Success", message_code=0)
|
||||
|
||||
|
|
|
@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry
|
|||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import WithingsDataUpdateCoordinator
|
||||
from .const import BED_PRESENCE_COORDINATOR, DOMAIN
|
||||
from .coordinator import WithingsBedPresenceDataUpdateCoordinator
|
||||
from .entity import WithingsEntity
|
||||
|
||||
|
||||
|
@ -20,7 +20,9 @@ async def async_setup_entry(
|
|||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the sensor config entry."""
|
||||
coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: WithingsBedPresenceDataUpdateCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
][BED_PRESENCE_COORDINATOR]
|
||||
|
||||
entities = [WithingsBinarySensor(coordinator)]
|
||||
|
||||
|
@ -33,8 +35,9 @@ class WithingsBinarySensor(WithingsEntity, BinarySensorEntity):
|
|||
_attr_icon = "mdi:bed"
|
||||
_attr_translation_key = "in_bed"
|
||||
_attr_device_class = BinarySensorDeviceClass.OCCUPANCY
|
||||
coordinator: WithingsBedPresenceDataUpdateCoordinator
|
||||
|
||||
def __init__(self, coordinator: WithingsDataUpdateCoordinator) -> None:
|
||||
def __init__(self, coordinator: WithingsBedPresenceDataUpdateCoordinator) -> None:
|
||||
"""Initialize binary sensor."""
|
||||
super().__init__(coordinator, "in_bed")
|
||||
|
||||
|
|
|
@ -14,6 +14,10 @@ LOG_NAMESPACE = "homeassistant.components.withings"
|
|||
PROFILE = "profile"
|
||||
PUSH_HANDLER = "push_handler"
|
||||
|
||||
MEASUREMENT_COORDINATOR = "measurement_coordinator"
|
||||
SLEEP_COORDINATOR = "sleep_coordinator"
|
||||
BED_PRESENCE_COORDINATOR = "bed_presence_coordinator"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
"""Withings coordinator."""
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from withings_api.common import (
|
||||
AuthFailedException,
|
||||
|
@ -66,40 +67,66 @@ WITHINGS_MEASURE_TYPE_MAP: dict[
|
|||
NotifyAppli.BED_IN: Measurement.IN_BED,
|
||||
}
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
UPDATE_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
|
||||
class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]]):
|
||||
class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]):
|
||||
"""Base coordinator."""
|
||||
|
||||
in_bed: bool | None = None
|
||||
config_entry: ConfigEntry
|
||||
_default_update_interval: timedelta | None = UPDATE_INTERVAL
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None:
|
||||
"""Initialize the Withings data coordinator."""
|
||||
super().__init__(hass, LOGGER, name="Withings", update_interval=UPDATE_INTERVAL)
|
||||
super().__init__(
|
||||
hass, LOGGER, name="Withings", update_interval=self._default_update_interval
|
||||
)
|
||||
self._client = client
|
||||
self.notification_categories: set[NotifyAppli] = set()
|
||||
|
||||
def webhook_subscription_listener(self, connected: bool) -> None:
|
||||
"""Call when webhook status changed."""
|
||||
if connected:
|
||||
self.update_interval = None
|
||||
else:
|
||||
self.update_interval = UPDATE_INTERVAL
|
||||
self.update_interval = self._default_update_interval
|
||||
|
||||
async def _async_update_data(self) -> dict[Measurement, Any]:
|
||||
async def async_webhook_data_updated(
|
||||
self, notification_category: NotifyAppli
|
||||
) -> None:
|
||||
"""Update data when webhook is called."""
|
||||
LOGGER.debug("Withings webhook triggered for %s", notification_category)
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def _async_update_data(self) -> _T:
|
||||
try:
|
||||
measurements = await self._get_measurements()
|
||||
sleep_summary = await self._get_sleep_summary()
|
||||
return await self._internal_update_data()
|
||||
except (UnauthorizedException, AuthFailedException) as exc:
|
||||
raise ConfigEntryAuthFailed from exc
|
||||
return {
|
||||
**measurements,
|
||||
**sleep_summary,
|
||||
|
||||
@abstractmethod
|
||||
async def _internal_update_data(self) -> _T:
|
||||
"""Update coordinator data."""
|
||||
|
||||
|
||||
class WithingsMeasurementDataUpdateCoordinator(
|
||||
WithingsDataUpdateCoordinator[dict[Measurement, Any]]
|
||||
):
|
||||
"""Withings measurement coordinator."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None:
|
||||
"""Initialize the Withings data coordinator."""
|
||||
super().__init__(hass, client)
|
||||
self.notification_categories = {
|
||||
NotifyAppli.WEIGHT,
|
||||
NotifyAppli.ACTIVITY,
|
||||
NotifyAppli.CIRCULATORY,
|
||||
}
|
||||
|
||||
async def _get_measurements(self) -> dict[Measurement, Any]:
|
||||
LOGGER.debug("Updating withings measures")
|
||||
async def _internal_update_data(self) -> dict[Measurement, Any]:
|
||||
"""Retrieve measurement data."""
|
||||
now = dt_util.utcnow()
|
||||
startdate = now - timedelta(days=7)
|
||||
|
||||
|
@ -125,7 +152,21 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]
|
|||
if measure.type in WITHINGS_MEASURE_TYPE_MAP
|
||||
}
|
||||
|
||||
async def _get_sleep_summary(self) -> dict[Measurement, Any]:
|
||||
|
||||
class WithingsSleepDataUpdateCoordinator(
|
||||
WithingsDataUpdateCoordinator[dict[Measurement, Any]]
|
||||
):
|
||||
"""Withings sleep coordinator."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None:
|
||||
"""Initialize the Withings data coordinator."""
|
||||
super().__init__(hass, client)
|
||||
self.notification_categories = {
|
||||
NotifyAppli.SLEEP,
|
||||
}
|
||||
|
||||
async def _internal_update_data(self) -> dict[Measurement, Any]:
|
||||
"""Retrieve sleep data."""
|
||||
now = dt_util.now()
|
||||
yesterday = now - timedelta(days=1)
|
||||
yesterday_noon = dt_util.start_of_local_day(yesterday) + timedelta(hours=12)
|
||||
|
@ -202,18 +243,27 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]
|
|||
for field, value in values.items()
|
||||
}
|
||||
|
||||
|
||||
class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[None]):
|
||||
"""Withings bed presence coordinator."""
|
||||
|
||||
in_bed: bool | None = None
|
||||
_default_update_interval = None
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None:
|
||||
"""Initialize the Withings data coordinator."""
|
||||
super().__init__(hass, client)
|
||||
self.notification_categories = {
|
||||
NotifyAppli.BED_IN,
|
||||
NotifyAppli.BED_OUT,
|
||||
}
|
||||
|
||||
async def async_webhook_data_updated(
|
||||
self, notification_category: NotifyAppli
|
||||
) -> None:
|
||||
"""Update data when webhook is called."""
|
||||
LOGGER.debug("Withings webhook triggered")
|
||||
if notification_category in {
|
||||
NotifyAppli.WEIGHT,
|
||||
NotifyAppli.CIRCULATORY,
|
||||
NotifyAppli.SLEEP,
|
||||
}:
|
||||
await self.async_request_refresh()
|
||||
"""Only set new in bed value instead of refresh."""
|
||||
self.in_bed = notification_category == NotifyAppli.BED_IN
|
||||
self.async_update_listeners()
|
||||
|
||||
elif notification_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}:
|
||||
self.in_bed = notification_category == NotifyAppli.BED_IN
|
||||
self.async_update_listeners()
|
||||
async def _internal_update_data(self) -> None:
|
||||
"""Update coordinator data."""
|
||||
|
|
|
@ -25,14 +25,20 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
MEASUREMENT_COORDINATOR,
|
||||
SCORE_POINTS,
|
||||
SLEEP_COORDINATOR,
|
||||
UOM_BEATS_PER_MINUTE,
|
||||
UOM_BREATHS_PER_MINUTE,
|
||||
UOM_FREQUENCY,
|
||||
UOM_MMHG,
|
||||
Measurement,
|
||||
)
|
||||
from .coordinator import WithingsDataUpdateCoordinator
|
||||
from .coordinator import (
|
||||
WithingsDataUpdateCoordinator,
|
||||
WithingsMeasurementDataUpdateCoordinator,
|
||||
WithingsSleepDataUpdateCoordinator,
|
||||
)
|
||||
from .entity import WithingsEntity
|
||||
|
||||
|
||||
|
@ -51,7 +57,7 @@ class WithingsSensorEntityDescription(
|
|||
"""Immutable class for describing withings data."""
|
||||
|
||||
|
||||
SENSORS = [
|
||||
MEASUREMENT_SENSORS = [
|
||||
WithingsSensorEntityDescription(
|
||||
key=Measurement.WEIGHT_KG.value,
|
||||
measurement=Measurement.WEIGHT_KG,
|
||||
|
@ -193,6 +199,8 @@ SENSORS = [
|
|||
device_class=SensorDeviceClass.SPEED,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
]
|
||||
SLEEP_SENSORS = [
|
||||
WithingsSensorEntityDescription(
|
||||
key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value,
|
||||
measurement=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY,
|
||||
|
@ -369,9 +377,22 @@ async def async_setup_entry(
|
|||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the sensor config entry."""
|
||||
coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
measurement_coordinator: WithingsMeasurementDataUpdateCoordinator = hass.data[
|
||||
DOMAIN
|
||||
][entry.entry_id][MEASUREMENT_COORDINATOR]
|
||||
entities: list[SensorEntity] = []
|
||||
entities.extend(
|
||||
WithingsMeasurementSensor(measurement_coordinator, attribute)
|
||||
for attribute in MEASUREMENT_SENSORS
|
||||
)
|
||||
sleep_coordinator: WithingsSleepDataUpdateCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
][SLEEP_COORDINATOR]
|
||||
|
||||
async_add_entities(WithingsSensor(coordinator, attribute) for attribute in SENSORS)
|
||||
entities.extend(
|
||||
WithingsSleepSensor(sleep_coordinator, attribute) for attribute in SLEEP_SENSORS
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class WithingsSensor(WithingsEntity, SensorEntity):
|
||||
|
@ -400,3 +421,15 @@ class WithingsSensor(WithingsEntity, SensorEntity):
|
|||
super().available
|
||||
and self.entity_description.measurement in self.coordinator.data
|
||||
)
|
||||
|
||||
|
||||
class WithingsMeasurementSensor(WithingsSensor):
|
||||
"""Implementation of a Withings measurement sensor."""
|
||||
|
||||
coordinator: WithingsMeasurementDataUpdateCoordinator
|
||||
|
||||
|
||||
class WithingsSleepSensor(WithingsSensor):
|
||||
"""Implementation of a Withings sleep sensor."""
|
||||
|
||||
coordinator: WithingsSleepDataUpdateCoordinator
|
||||
|
|
|
@ -223,13 +223,14 @@ async def test_triggering_reauth(
|
|||
withings: AsyncMock,
|
||||
polling_config_entry: MockConfigEntry,
|
||||
error: Exception,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test triggering reauth."""
|
||||
await setup_integration(hass, polling_config_entry, False)
|
||||
|
||||
withings.async_measure_get_meas.side_effect = error
|
||||
future = dt_util.utcnow() + timedelta(minutes=10)
|
||||
async_fire_time_changed(hass, future)
|
||||
freezer.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
|
|
|
@ -8,7 +8,7 @@ from syrupy import SnapshotAssertion
|
|||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.withings.const import DOMAIN
|
||||
from homeassistant.components.withings.sensor import SENSORS
|
||||
from homeassistant.components.withings.sensor import MEASUREMENT_SENSORS, SLEEP_SENSORS
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
@ -42,7 +42,7 @@ async def test_all_entities(
|
|||
"""Test all entities."""
|
||||
await setup_integration(hass, polling_config_entry)
|
||||
|
||||
for sensor in SENSORS:
|
||||
for sensor in MEASUREMENT_SENSORS + SLEEP_SENSORS:
|
||||
entity_id = await async_get_entity_id(hass, sensor.key, USER_ID, SENSOR_DOMAIN)
|
||||
assert hass.states.get(entity_id) == snapshot
|
||||
|
||||
|
|
Loading…
Reference in New Issue