diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 225ff5603c4..a17dffd22e8 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -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) diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 629114247ce..24698f90809 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -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") diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 545c7bfcb26..bc3e26765a4 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -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__) diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 2ec2804814b..f5963ad6ebf 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -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.""" diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index bb615dfb7ca..200ad7aedd5 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -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 diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index ab83bbcfb36..a3509c8547b 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -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() diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index febf0a1a5d9..f5d15e5dea9 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -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