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>
This commit is contained in:
Joost Lekkerkerker 2023-10-13 07:34:31 +02:00 committed by GitHub
parent 03210d7f81
commit d712a29052
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 163 additions and 47 deletions

View File

@ -43,8 +43,22 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .api import ConfigEntryWithingsApi from .api import ConfigEntryWithingsApi
from .const import CONF_PROFILES, CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN, LOGGER from .const import (
from .coordinator import WithingsDataUpdateCoordinator 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] PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
@ -128,11 +142,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass, entry 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( async def unregister_webhook(
_: Any, _: 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]) LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID])
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
await async_unsubscribe_webhooks(client) await async_unsubscribe_webhooks(client)
coordinator.webhook_subscription_listener(False) for coordinator in coordinators.values():
coordinator.webhook_subscription_listener(False)
async def register_webhook( async def register_webhook(
_: Any, _: Any,
@ -166,11 +188,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
DOMAIN, DOMAIN,
webhook_name, webhook_name,
entry.data[CONF_WEBHOOK_ID], entry.data[CONF_WEBHOOK_ID],
get_webhook_handler(coordinator), get_webhook_handler(coordinators),
) )
await async_subscribe_webhooks(client, webhook_url) 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) LOGGER.debug("Register Withings webhook: %s", webhook_url)
entry.async_on_unload( entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) 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( def get_webhook_handler(
coordinator: WithingsDataUpdateCoordinator, coordinators: dict[str, WithingsDataUpdateCoordinator],
) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: ) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]:
"""Return webhook handler.""" """Return webhook handler."""
@ -318,7 +341,9 @@ def get_webhook_handler(
except ValueError: except ValueError:
return json_message_response("Invalid appli provided", message_code=21) 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) return json_message_response("Success", message_code=0)

View File

@ -9,8 +9,8 @@ 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 .const import DOMAIN from .const import BED_PRESENCE_COORDINATOR, DOMAIN
from .coordinator import WithingsDataUpdateCoordinator from .coordinator import WithingsBedPresenceDataUpdateCoordinator
from .entity import WithingsEntity from .entity import WithingsEntity
@ -20,7 +20,9 @@ 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."""
coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator: WithingsBedPresenceDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
][BED_PRESENCE_COORDINATOR]
entities = [WithingsBinarySensor(coordinator)] entities = [WithingsBinarySensor(coordinator)]
@ -33,8 +35,9 @@ class WithingsBinarySensor(WithingsEntity, BinarySensorEntity):
_attr_icon = "mdi:bed" _attr_icon = "mdi:bed"
_attr_translation_key = "in_bed" _attr_translation_key = "in_bed"
_attr_device_class = BinarySensorDeviceClass.OCCUPANCY _attr_device_class = BinarySensorDeviceClass.OCCUPANCY
coordinator: WithingsBedPresenceDataUpdateCoordinator
def __init__(self, coordinator: WithingsDataUpdateCoordinator) -> None: def __init__(self, coordinator: WithingsBedPresenceDataUpdateCoordinator) -> None:
"""Initialize binary sensor.""" """Initialize binary sensor."""
super().__init__(coordinator, "in_bed") super().__init__(coordinator, "in_bed")

View File

@ -14,6 +14,10 @@ LOG_NAMESPACE = "homeassistant.components.withings"
PROFILE = "profile" PROFILE = "profile"
PUSH_HANDLER = "push_handler" PUSH_HANDLER = "push_handler"
MEASUREMENT_COORDINATOR = "measurement_coordinator"
SLEEP_COORDINATOR = "sleep_coordinator"
BED_PRESENCE_COORDINATOR = "bed_presence_coordinator"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)

View File

@ -1,7 +1,8 @@
"""Withings coordinator.""" """Withings coordinator."""
from abc import abstractmethod
from collections.abc import Callable from collections.abc import Callable
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any, TypeVar
from withings_api.common import ( from withings_api.common import (
AuthFailedException, AuthFailedException,
@ -66,40 +67,66 @@ WITHINGS_MEASURE_TYPE_MAP: dict[
NotifyAppli.BED_IN: Measurement.IN_BED, NotifyAppli.BED_IN: Measurement.IN_BED,
} }
_T = TypeVar("_T")
UPDATE_INTERVAL = timedelta(minutes=10) UPDATE_INTERVAL = timedelta(minutes=10)
class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]]): class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]):
"""Base coordinator.""" """Base coordinator."""
in_bed: bool | None = None
config_entry: ConfigEntry config_entry: ConfigEntry
_default_update_interval: timedelta | None = UPDATE_INTERVAL
def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None: def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None:
"""Initialize the Withings data coordinator.""" """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._client = client
self.notification_categories: set[NotifyAppli] = set()
def webhook_subscription_listener(self, connected: bool) -> None: def webhook_subscription_listener(self, connected: bool) -> None:
"""Call when webhook status changed.""" """Call when webhook status changed."""
if connected: if connected:
self.update_interval = None self.update_interval = None
else: 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: try:
measurements = await self._get_measurements() return await self._internal_update_data()
sleep_summary = await self._get_sleep_summary()
except (UnauthorizedException, AuthFailedException) as exc: except (UnauthorizedException, AuthFailedException) as exc:
raise ConfigEntryAuthFailed from exc raise ConfigEntryAuthFailed from exc
return {
**measurements, @abstractmethod
**sleep_summary, 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]: async def _internal_update_data(self) -> dict[Measurement, Any]:
LOGGER.debug("Updating withings measures") """Retrieve measurement data."""
now = dt_util.utcnow() now = dt_util.utcnow()
startdate = now - timedelta(days=7) startdate = now - timedelta(days=7)
@ -125,7 +152,21 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]
if measure.type in WITHINGS_MEASURE_TYPE_MAP 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() now = dt_util.now()
yesterday = now - timedelta(days=1) yesterday = now - timedelta(days=1)
yesterday_noon = dt_util.start_of_local_day(yesterday) + timedelta(hours=12) 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() 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( async def async_webhook_data_updated(
self, notification_category: NotifyAppli self, notification_category: NotifyAppli
) -> None: ) -> None:
"""Update data when webhook is called.""" """Only set new in bed value instead of refresh."""
LOGGER.debug("Withings webhook triggered") self.in_bed = notification_category == NotifyAppli.BED_IN
if notification_category in { self.async_update_listeners()
NotifyAppli.WEIGHT,
NotifyAppli.CIRCULATORY,
NotifyAppli.SLEEP,
}:
await self.async_request_refresh()
elif notification_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}: async def _internal_update_data(self) -> None:
self.in_bed = notification_category == NotifyAppli.BED_IN """Update coordinator data."""
self.async_update_listeners()

View File

@ -25,14 +25,20 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import (
DOMAIN, DOMAIN,
MEASUREMENT_COORDINATOR,
SCORE_POINTS, SCORE_POINTS,
SLEEP_COORDINATOR,
UOM_BEATS_PER_MINUTE, UOM_BEATS_PER_MINUTE,
UOM_BREATHS_PER_MINUTE, UOM_BREATHS_PER_MINUTE,
UOM_FREQUENCY, UOM_FREQUENCY,
UOM_MMHG, UOM_MMHG,
Measurement, Measurement,
) )
from .coordinator import WithingsDataUpdateCoordinator from .coordinator import (
WithingsDataUpdateCoordinator,
WithingsMeasurementDataUpdateCoordinator,
WithingsSleepDataUpdateCoordinator,
)
from .entity import WithingsEntity from .entity import WithingsEntity
@ -51,7 +57,7 @@ class WithingsSensorEntityDescription(
"""Immutable class for describing withings data.""" """Immutable class for describing withings data."""
SENSORS = [ MEASUREMENT_SENSORS = [
WithingsSensorEntityDescription( WithingsSensorEntityDescription(
key=Measurement.WEIGHT_KG.value, key=Measurement.WEIGHT_KG.value,
measurement=Measurement.WEIGHT_KG, measurement=Measurement.WEIGHT_KG,
@ -193,6 +199,8 @@ SENSORS = [
device_class=SensorDeviceClass.SPEED, device_class=SensorDeviceClass.SPEED,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),
]
SLEEP_SENSORS = [
WithingsSensorEntityDescription( WithingsSensorEntityDescription(
key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value, key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value,
measurement=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, measurement=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY,
@ -369,9 +377,22 @@ 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."""
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): class WithingsSensor(WithingsEntity, SensorEntity):
@ -400,3 +421,15 @@ class WithingsSensor(WithingsEntity, SensorEntity):
super().available super().available
and self.entity_description.measurement in self.coordinator.data 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

View File

@ -223,13 +223,14 @@ async def test_triggering_reauth(
withings: AsyncMock, withings: AsyncMock,
polling_config_entry: MockConfigEntry, polling_config_entry: MockConfigEntry,
error: Exception, error: Exception,
freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test triggering reauth.""" """Test triggering reauth."""
await setup_integration(hass, polling_config_entry, False) await setup_integration(hass, polling_config_entry, False)
withings.async_measure_get_meas.side_effect = error withings.async_measure_get_meas.side_effect = error
future = dt_util.utcnow() + timedelta(minutes=10) freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass, future) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress() flows = hass.config_entries.flow.async_progress()

View File

@ -8,7 +8,7 @@ from syrupy import SnapshotAssertion
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.withings.const import 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.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
@ -42,7 +42,7 @@ async def test_all_entities(
"""Test all entities.""" """Test all entities."""
await setup_integration(hass, polling_config_entry) 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) entity_id = await async_get_entity_id(hass, sensor.key, USER_ID, SENSOR_DOMAIN)
assert hass.states.get(entity_id) == snapshot assert hass.states.get(entity_id) == snapshot