diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index ecf951db4ac2f0..92cec96ce97142 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -53,6 +53,7 @@ from .const import CONF_PROFILES, CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN, LOGGER from .coordinator import ( + WithingsActivityDataUpdateCoordinator, WithingsBedPresenceDataUpdateCoordinator, WithingsDataUpdateCoordinator, WithingsGoalsDataUpdateCoordinator, @@ -131,6 +132,7 @@ class WithingsData: sleep_coordinator: WithingsSleepDataUpdateCoordinator bed_presence_coordinator: WithingsBedPresenceDataUpdateCoordinator goals_coordinator: WithingsGoalsDataUpdateCoordinator + activity_coordinator: WithingsActivityDataUpdateCoordinator coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set) def __post_init__(self) -> None: @@ -140,6 +142,7 @@ def __post_init__(self) -> None: self.sleep_coordinator, self.bed_presence_coordinator, self.goals_coordinator, + self.activity_coordinator, } @@ -172,6 +175,7 @@ async def _refresh_token() -> str: sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, client), bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client), goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client), + activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client), ) for coordinator in withings_data.coordinators: diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 2700b833cea918..3b39dddb27edb3 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -1,9 +1,10 @@ """Withings coordinator.""" from abc import abstractmethod -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from typing import TypeVar from aiowithings import ( + Activity, Goals, MeasurementType, NotificationCategory, @@ -81,7 +82,6 @@ def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: super().__init__(hass, client) self.notification_categories = { NotificationCategory.WEIGHT, - NotificationCategory.ACTIVITY, NotificationCategory.PRESSURE, } self._previous_data: dict[MeasurementType, float] = {} @@ -185,3 +185,41 @@ def webhook_subscription_listener(self, connected: bool) -> None: async def _internal_update_data(self) -> Goals: """Retrieve goals data.""" return await self._client.get_goals() + + +class WithingsActivityDataUpdateCoordinator( + WithingsDataUpdateCoordinator[Activity | None] +): + """Withings activity coordinator.""" + + _previous_data: Activity | None = None + + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + """Initialize the Withings data coordinator.""" + super().__init__(hass, client) + self.notification_categories = { + NotificationCategory.ACTIVITY, + } + + async def _internal_update_data(self) -> Activity | None: + """Retrieve latest activity.""" + if self._last_valid_update is None: + now = dt_util.utcnow() + startdate = now - timedelta(days=14) + activities = await self._client.get_activities_in_period( + startdate.date(), now.date() + ) + else: + activities = await self._client.get_activities_since( + self._last_valid_update + ) + + today = date.today() + for activity in activities: + if activity.date == today: + self._previous_data = activity + self._last_valid_update = activity.modified + return activity + if self._previous_data and self._previous_data.date == today: + return self._previous_data + return None diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 1530054ad69e90..0d841c4bb2c0c7 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -3,8 +3,9 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime -from aiowithings import Goals, MeasurementType, SleepSummary +from aiowithings import Activity, Goals, MeasurementType, SleepSummary from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + Platform, UnitOfLength, UnitOfMass, UnitOfSpeed, @@ -23,7 +25,9 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util from . import WithingsData from .const import ( @@ -35,6 +39,7 @@ UOM_MMHG, ) from .coordinator import ( + WithingsActivityDataUpdateCoordinator, WithingsDataUpdateCoordinator, WithingsGoalsDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator, @@ -396,6 +401,105 @@ class WithingsSleepSensorEntityDescription( ] +@dataclass +class WithingsActivitySensorEntityDescriptionMixin: + """Mixin for describing withings data.""" + + value_fn: Callable[[Activity], StateType] + + +@dataclass +class WithingsActivitySensorEntityDescription( + SensorEntityDescription, WithingsActivitySensorEntityDescriptionMixin +): + """Immutable class for describing withings data.""" + + +ACTIVITY_SENSORS = [ + WithingsActivitySensorEntityDescription( + key="activity_steps_today", + value_fn=lambda activity: activity.steps, + translation_key="activity_steps_today", + icon="mdi:shoe-print", + native_unit_of_measurement="Steps", + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_distance_today", + value_fn=lambda activity: activity.distance, + translation_key="activity_distance_today", + suggested_display_precision=0, + icon="mdi:map-marker-distance", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_floors_climbed_today", + value_fn=lambda activity: activity.floors_climbed, + translation_key="activity_floors_climbed_today", + icon="mdi:stairs-up", + native_unit_of_measurement="Floors", + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_soft_duration_today", + value_fn=lambda activity: activity.soft_activity, + translation_key="activity_soft_duration_today", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + WithingsActivitySensorEntityDescription( + key="activity_moderate_duration_today", + value_fn=lambda activity: activity.moderate_activity, + translation_key="activity_moderate_duration_today", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + WithingsActivitySensorEntityDescription( + key="activity_intense_duration_today", + value_fn=lambda activity: activity.intense_activity, + translation_key="activity_intense_duration_today", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + WithingsActivitySensorEntityDescription( + key="activity_active_duration_today", + value_fn=lambda activity: activity.total_time_active, + translation_key="activity_active_duration_today", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_active_calories_burnt_today", + value_fn=lambda activity: activity.active_calories_burnt, + suggested_display_precision=1, + translation_key="activity_active_calories_burnt_today", + native_unit_of_measurement="Calories", + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_total_calories_burnt_today", + value_fn=lambda activity: activity.total_calories_burnt, + suggested_display_precision=1, + translation_key="activity_total_calories_burnt_today", + native_unit_of_measurement="Calories", + state_class=SensorStateClass.TOTAL, + ), +] + + STEP_GOAL = "steps" SLEEP_GOAL = "sleep" WEIGHT_GOAL = "weight" @@ -460,6 +564,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" + ent_reg = er.async_get(hass) + withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id] measurement_coordinator = withings_data.measurement_coordinator @@ -512,6 +618,31 @@ def _async_goals_listener() -> None: goals_coordinator.async_add_listener(_async_goals_listener) + activity_coordinator = withings_data.activity_coordinator + + activity_callback: Callable[[], None] | None = None + + activity_entities_setup_before = ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, f"withings_{entry.unique_id}_activity_steps_today" + ) + + def _async_add_activity_entities() -> None: + """Add activity entities.""" + if activity_coordinator.data is not None or activity_entities_setup_before: + async_add_entities( + WithingsActivitySensor(activity_coordinator, attribute) + for attribute in ACTIVITY_SENSORS + ) + if activity_callback: + activity_callback() + + if activity_coordinator.data is not None or activity_entities_setup_before: + _async_add_activity_entities() + else: + activity_callback = activity_coordinator.async_add_listener( + _async_add_activity_entities + ) + sleep_coordinator = withings_data.sleep_coordinator entities.extend( @@ -585,3 +716,23 @@ def native_value(self) -> StateType: """Return the state of the entity.""" assert self.coordinator.data return self.entity_description.value_fn(self.coordinator.data) + + +class WithingsActivitySensor(WithingsSensor): + """Implementation of a Withings activity sensor.""" + + coordinator: WithingsActivityDataUpdateCoordinator + + entity_description: WithingsActivitySensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + if not self.coordinator.data: + return None + return self.entity_description.value_fn(self.coordinator.data) + + @property + def last_reset(self) -> datetime: + """These values reset every day.""" + return dt_util.start_of_local_day() diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index fcb94d6979abba..a6a832d83949cb 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -143,6 +143,33 @@ }, "weight_goal": { "name": "Weight goal" + }, + "activity_steps_today": { + "name": "Steps today" + }, + "activity_distance_today": { + "name": "Distance travelled today" + }, + "activity_floors_climbed_today": { + "name": "Floors climbed today" + }, + "activity_soft_duration_today": { + "name": "Soft activity today" + }, + "activity_moderate_duration_today": { + "name": "Moderate activity today" + }, + "activity_intense_duration_today": { + "name": "Intense activity today" + }, + "activity_active_duration_today": { + "name": "Active time today" + }, + "activity_active_calories_burnt_today": { + "name": "Active calories burnt today" + }, + "activity_total_calories_burnt_today": { + "name": "Total calories burnt today" } } } diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 2425a5bd600ffc..56bee0c30db2a8 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -5,7 +5,7 @@ from urllib.parse import urlparse from aiohttp.test_utils import TestClient -from aiowithings import Goals, MeasurementGroup +from aiowithings import Activity, Goals, MeasurementGroup from freezegun.api import FrozenDateTimeFactory from homeassistant.components.webhook import async_generate_url @@ -84,3 +84,11 @@ def load_measurements_fixture( """Return measurement from fixture.""" meas_json = load_json_array_fixture(fixture) return [MeasurementGroup.from_api(measurement) for measurement in meas_json] + + +def load_activity_fixture( + fixture: str = "withings/activity.json", +) -> list[Activity]: + """Return measurement from fixture.""" + activity_json = load_json_array_fixture(fixture) + return [Activity.from_api(activity) for activity in activity_json] diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 8a824d8491732d..066a9eed031fa1 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -16,7 +16,11 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_json_array_fixture -from tests.components.withings import load_goals_fixture, load_measurements_fixture +from tests.components.withings import ( + load_activity_fixture, + load_goals_fixture, + load_measurements_fixture, +) CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -132,7 +136,7 @@ def mock_withings(): devices_json = load_json_array_fixture("withings/devices.json") devices = [Device.from_api(device) for device in devices_json] - measurement_groups = load_measurements_fixture("withings/measurements.json") + measurement_groups = load_measurements_fixture() sleep_json = load_json_array_fixture("withings/sleep_summaries.json") sleep_summaries = [ @@ -144,12 +148,16 @@ def mock_withings(): NotificationConfiguration.from_api(not_conf) for not_conf in notification_json ] + activities = load_activity_fixture() + mock = AsyncMock(spec=WithingsClient) mock.get_devices.return_value = devices - mock.get_goals.return_value = load_goals_fixture("withings/goals.json") + mock.get_goals.return_value = load_goals_fixture() mock.get_measurement_in_period.return_value = measurement_groups mock.get_measurement_since.return_value = measurement_groups mock.get_sleep_summary_since.return_value = sleep_summaries + mock.get_activities_since.return_value = activities + mock.get_activities_in_period.return_value = activities mock.list_notification_configurations.return_value = notifications with patch( diff --git a/tests/components/withings/fixtures/activity.json b/tests/components/withings/fixtures/activity.json new file mode 100644 index 00000000000000..8ba9f526afa3e0 --- /dev/null +++ b/tests/components/withings/fixtures/activity.json @@ -0,0 +1,282 @@ +[ + { + "steps": 1892, + "distance": 1607.93, + "elevation": 0, + "soft": 4981, + "moderate": 158, + "intense": 0, + "active": 158, + "calories": 204.796, + "totalcalories": 2454.481, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-08", + "modified": 1697038118, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 2576, + "distance": 2349.617, + "elevation": 0, + "soft": 1255, + "moderate": 1211, + "intense": 0, + "active": 1211, + "calories": 134.967, + "totalcalories": 2351.652, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-09", + "modified": 1697038118, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 1827, + "distance": 1595.537, + "elevation": 0, + "soft": 2194, + "moderate": 569, + "intense": 0, + "active": 569, + "calories": 110.223, + "totalcalories": 2313.98, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-10", + "modified": 1697057517, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 3801, + "distance": 3307.985, + "elevation": 0, + "soft": 5146, + "moderate": 963, + "intense": 0, + "active": 963, + "calories": 240.89, + "totalcalories": 2385.746, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-11", + "modified": 1697842183, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 2501, + "distance": 2158.186, + "elevation": 0, + "soft": 1854, + "moderate": 998, + "intense": 0, + "active": 998, + "calories": 113.123, + "totalcalories": 2317.396, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-12", + "modified": 1697842183, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 6787, + "distance": 6008.779, + "elevation": 0, + "soft": 3773, + "moderate": 2831, + "intense": 36, + "active": 2867, + "calories": 263.371, + "totalcalories": 2380.669, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-13", + "modified": 1697842183, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 1232, + "distance": 1050.925, + "elevation": 0, + "soft": 2950, + "moderate": 196, + "intense": 0, + "active": 196, + "calories": 124.754, + "totalcalories": 2311.674, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-14", + "modified": 1697842183, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 851, + "distance": 723.139, + "elevation": 0, + "soft": 1634, + "moderate": 83, + "intense": 0, + "active": 83, + "calories": 68.121, + "totalcalories": 2294.325, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-15", + "modified": 1697842184, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 654, + "distance": 557.509, + "elevation": 0, + "soft": 1558, + "moderate": 124, + "intense": 0, + "active": 124, + "calories": 66.707, + "totalcalories": 2292.897, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-16", + "modified": 1697842184, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 566, + "distance": 482.185, + "elevation": 0, + "soft": 1085, + "moderate": 52, + "intense": 0, + "active": 52, + "calories": 45.126, + "totalcalories": 2287.08, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-17", + "modified": 1697842184, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 2204, + "distance": 1901.651, + "elevation": 0, + "soft": 1393, + "moderate": 941, + "intense": 0, + "active": 941, + "calories": 92.585, + "totalcalories": 2302.971, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-18", + "modified": 1697842185, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 95, + "distance": 80.63, + "elevation": 0, + "soft": 543, + "moderate": 0, + "intense": 0, + "active": 0, + "calories": 21.541, + "totalcalories": 2277.668, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-19", + "modified": 1697842185, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 1209, + "distance": 1028.559, + "elevation": 0, + "soft": 1864, + "moderate": 292, + "intense": 0, + "active": 292, + "calories": 85.497, + "totalcalories": 2303.788, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-20", + "modified": 1697884856, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 1155, + "distance": 1020.121, + "elevation": 0, + "soft": 1516, + "moderate": 1487, + "intense": 420, + "active": 1907, + "calories": 221.132, + "totalcalories": 2444.149, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-21", + "modified": 1697888004, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + } +] diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 3546a24d2fe6e6..cf8ff0a462bc0f 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -1,4 +1,35 @@ # serializer version: 1 +# name: test_all_entities[sensor.henk_active_calories_burnt_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Active calories burnt today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': 'Calories', + }), + 'context': , + 'entity_id': 'sensor.henk_active_calories_burnt_today', + 'last_changed': , + 'last_updated': , + 'state': '221.132', + }) +# --- +# name: test_all_entities[sensor.henk_active_time_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Active time today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_active_time_today', + 'last_changed': , + 'last_updated': , + 'state': '1907', + }) +# --- # name: test_all_entities[sensor.henk_average_heart_rate] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -102,6 +133,23 @@ 'state': '70', }) # --- +# name: test_all_entities[sensor.henk_distance_travelled_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'henk Distance travelled today', + 'icon': 'mdi:map-marker-distance', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_distance_travelled_today', + 'last_changed': , + 'last_updated': , + 'state': '1020.121', + }) +# --- # name: test_all_entities[sensor.henk_extracellular_water] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -161,6 +209,22 @@ 'state': '0.07', }) # --- +# name: test_all_entities[sensor.henk_floors_climbed_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Floors climbed today', + 'icon': 'mdi:stairs-up', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': 'Floors', + }), + 'context': , + 'entity_id': 'sensor.henk_floors_climbed_today', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_all_entities[sensor.henk_heart_pulse] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -207,6 +271,22 @@ 'state': '0.95', }) # --- +# name: test_all_entities[sensor.henk_intense_activity_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Intense activity today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_intense_activity_today', + 'last_changed': , + 'last_updated': , + 'state': '420', + }) +# --- # name: test_all_entities[sensor.henk_intracellular_water] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -296,6 +376,22 @@ 'state': '10', }) # --- +# name: test_all_entities[sensor.henk_moderate_activity_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Moderate activity today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_moderate_activity_today', + 'last_changed': , + 'last_updated': , + 'state': '1487', + }) +# --- # name: test_all_entities[sensor.henk_muscle_mass] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -414,6 +510,22 @@ 'state': '87', }) # --- +# name: test_all_entities[sensor.henk_soft_activity_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Soft activity today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_soft_activity_today', + 'last_changed': , + 'last_updated': , + 'state': '1516', + }) +# --- # name: test_all_entities[sensor.henk_spo2] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -443,6 +555,22 @@ 'state': '10000', }) # --- +# name: test_all_entities[sensor.henk_steps_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Steps today', + 'icon': 'mdi:shoe-print', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': 'Steps', + }), + 'context': , + 'entity_id': 'sensor.henk_steps_today', + 'last_changed': , + 'last_updated': , + 'state': '1155', + }) +# --- # name: test_all_entities[sensor.henk_systolic_blood_pressure] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -504,6 +632,21 @@ 'state': '996', }) # --- +# name: test_all_entities[sensor.henk_total_calories_burnt_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Total calories burnt today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': 'Calories', + }), + 'context': , + 'entity_id': 'sensor.henk_total_calories_burnt_today', + 'last_changed': , + 'last_updated': , + 'state': '2444.149', + }) +# --- # name: test_all_entities[sensor.henk_vascular_age] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 6cf33c45c9d804..1acfc324d8127a 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -6,15 +6,21 @@ import pytest from syrupy import SnapshotAssertion -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import load_goals_fixture, load_measurements_fixture, setup_integration +from . import ( + load_activity_fixture, + load_goals_fixture, + load_measurements_fixture, + setup_integration, +) from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.freeze_time("2023-10-21") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( hass: HomeAssistant, @@ -115,9 +121,7 @@ async def test_update_new_measurement_creates_new_sensor( assert hass.states.get("sensor.henk_fat_mass") is None - withings.get_measurement_in_period.return_value = load_measurements_fixture( - "withings/measurements.json" - ) + withings.get_measurement_in_period.return_value = load_measurements_fixture() freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) @@ -141,10 +145,101 @@ async def test_update_new_goals_creates_new_sensor( assert hass.states.get("sensor.henk_step_goal") is None assert hass.states.get("sensor.henk_weight_goal") is not None - withings.get_goals.return_value = load_goals_fixture("withings/goals.json") + withings.get_goals.return_value = load_goals_fixture() freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.henk_step_goal") is not None + + +async def test_activity_sensors_unknown_next_day( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test activity sensors will return unknown the next day.""" + freezer.move_to("2023-10-21") + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_steps_today") is not None + + withings.get_activities_since.return_value = [] + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today").state == STATE_UNKNOWN + + +async def test_activity_sensors_same_result_same_day( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test activity sensors will return the same result if old data is updated.""" + freezer.move_to("2023-10-21") + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_steps_today").state == "1155" + + withings.get_activities_since.return_value = [] + + freezer.tick(timedelta(hours=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today").state == "1155" + + +async def test_activity_sensors_created_when_existed( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test activity sensors will be added if they existed before.""" + freezer.move_to("2023-10-21") + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_steps_today") is not None + assert hass.states.get("sensor.henk_steps_today").state != STATE_UNKNOWN + + withings.get_activities_in_period.return_value = [] + + await hass.config_entries.async_reload(polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today").state == STATE_UNKNOWN + + +async def test_activity_sensors_created_when_receive_activity_data( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test activity sensors will be added if we receive activity data.""" + freezer.move_to("2023-10-21") + withings.get_activities_in_period.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_steps_today") is None + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today") is None + + withings.get_activities_in_period.return_value = load_activity_fixture() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today") is not None