From b42c47e800e6be6a370d33d602948331b151980b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Oct 2023 11:07:47 +0200 Subject: [PATCH] Add last workout sensors to Withings (#102541) Co-authored-by: J. Nick Koston Co-authored-by: Robert Resch --- homeassistant/components/withings/__init__.py | 4 + .../components/withings/coordinator.py | 37 ++ homeassistant/components/withings/sensor.py | 146 +++++++- .../components/withings/strings.json | 72 ++++ tests/components/withings/__init__.py | 12 +- tests/components/withings/conftest.py | 5 + .../withings/fixtures/workouts.json | 327 ++++++++++++++++++ .../withings/snapshots/test_sensor.ambr | 158 ++++++++- tests/components/withings/test_sensor.py | 48 +++ 9 files changed, 793 insertions(+), 16 deletions(-) create mode 100644 tests/components/withings/fixtures/workouts.json diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 92cec96ce97..2158b169844 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -59,6 +59,7 @@ from .coordinator import ( WithingsGoalsDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator, WithingsSleepDataUpdateCoordinator, + WithingsWorkoutDataUpdateCoordinator, ) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -133,6 +134,7 @@ class WithingsData: bed_presence_coordinator: WithingsBedPresenceDataUpdateCoordinator goals_coordinator: WithingsGoalsDataUpdateCoordinator activity_coordinator: WithingsActivityDataUpdateCoordinator + workout_coordinator: WithingsWorkoutDataUpdateCoordinator coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set) def __post_init__(self) -> None: @@ -143,6 +145,7 @@ class WithingsData: self.bed_presence_coordinator, self.goals_coordinator, self.activity_coordinator, + self.workout_coordinator, } @@ -176,6 +179,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client), goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client), activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client), + workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, client), ) for coordinator in withings_data.coordinators: diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 7964a755b4d..35eeb6e62b6 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -13,6 +13,7 @@ from aiowithings import ( WithingsAuthenticationFailedError, WithingsClient, WithingsUnauthorizedError, + Workout, aggregate_measurements, ) @@ -224,3 +225,39 @@ class WithingsActivityDataUpdateCoordinator( if self._previous_data and self._previous_data.date == today: return self._previous_data return None + + +class WithingsWorkoutDataUpdateCoordinator( + WithingsDataUpdateCoordinator[Workout | None] +): + """Withings workout coordinator.""" + + _previous_data: Workout | 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) -> Workout | None: + """Retrieve latest workout.""" + if self._last_valid_update is None: + now = dt_util.utcnow() + startdate = now - timedelta(days=14) + workouts = await self._client.get_workouts_in_period( + startdate.date(), now.date() + ) + else: + workouts = await self._client.get_workouts_since(self._last_valid_update) + if not workouts: + return self._previous_data + latest_workout = max(workouts, key=lambda workout: workout.end_date) + if ( + self._previous_data is None + or self._previous_data.end_date >= latest_workout.end_date + ): + self._previous_data = latest_workout + self._last_valid_update = latest_workout.end_date + return self._previous_data diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index a531bf49986..4729671fa3b 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -5,7 +5,14 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from aiowithings import Activity, Goals, MeasurementType, SleepSummary +from aiowithings import ( + Activity, + Goals, + MeasurementType, + SleepSummary, + Workout, + WorkoutCategory, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -44,6 +51,7 @@ from .coordinator import ( WithingsGoalsDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator, WithingsSleepDataUpdateCoordinator, + WithingsWorkoutDataUpdateCoordinator, ) from .entity import WithingsEntity @@ -420,7 +428,7 @@ ACTIVITY_SENSORS = [ value_fn=lambda activity: activity.steps, translation_key="activity_steps_today", icon="mdi:shoe-print", - native_unit_of_measurement="Steps", + native_unit_of_measurement="steps", state_class=SensorStateClass.TOTAL, ), WithingsActivitySensorEntityDescription( @@ -438,7 +446,7 @@ ACTIVITY_SENSORS = [ value_fn=lambda activity: activity.floors_climbed, translation_key="activity_floors_climbed_today", icon="mdi:stairs-up", - native_unit_of_measurement="Floors", + native_unit_of_measurement="floors", state_class=SensorStateClass.TOTAL, ), WithingsActivitySensorEntityDescription( @@ -485,7 +493,7 @@ ACTIVITY_SENSORS = [ value_fn=lambda activity: activity.active_calories_burnt, suggested_display_precision=1, translation_key="activity_active_calories_burnt_today", - native_unit_of_measurement="Calories", + native_unit_of_measurement="calories", state_class=SensorStateClass.TOTAL, ), WithingsActivitySensorEntityDescription( @@ -493,7 +501,7 @@ ACTIVITY_SENSORS = [ value_fn=lambda activity: activity.total_calories_burnt, suggested_display_precision=1, translation_key="activity_total_calories_burnt_today", - native_unit_of_measurement="Calories", + native_unit_of_measurement="calories", state_class=SensorStateClass.TOTAL, ), ] @@ -524,7 +532,7 @@ GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { value_fn=lambda goals: goals.steps, icon="mdi:shoe-print", translation_key="step_goal", - native_unit_of_measurement="Steps", + native_unit_of_measurement="steps", state_class=SensorStateClass.MEASUREMENT, ), SLEEP_GOAL: WithingsGoalsSensorEntityDescription( @@ -548,6 +556,84 @@ GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { } +@dataclass +class WithingsWorkoutSensorEntityDescriptionMixin: + """Mixin for describing withings data.""" + + value_fn: Callable[[Workout], StateType] + + +@dataclass +class WithingsWorkoutSensorEntityDescription( + SensorEntityDescription, WithingsWorkoutSensorEntityDescriptionMixin +): + """Immutable class for describing withings data.""" + + +_WORKOUT_CATEGORY = [ + workout_category.name.lower() for workout_category in WorkoutCategory +] + + +WORKOUT_SENSORS = [ + WithingsWorkoutSensorEntityDescription( + key="workout_type", + value_fn=lambda workout: workout.category.name.lower(), + device_class=SensorDeviceClass.ENUM, + translation_key="workout_type", + options=_WORKOUT_CATEGORY, + ), + WithingsWorkoutSensorEntityDescription( + key="workout_active_calories_burnt", + value_fn=lambda workout: workout.active_calories_burnt, + translation_key="workout_active_calories_burnt", + suggested_display_precision=1, + native_unit_of_measurement="calories", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_distance", + value_fn=lambda workout: workout.distance, + translation_key="workout_distance", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + suggested_display_precision=0, + icon="mdi:map-marker-distance", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_floors_climbed", + value_fn=lambda workout: workout.floors_climbed, + translation_key="workout_floors_climbed", + icon="mdi:stairs-up", + native_unit_of_measurement="floors", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_intensity", + value_fn=lambda workout: workout.intensity, + translation_key="workout_intensity", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_pause_duration", + value_fn=lambda workout: workout.pause_duration or 0, + translation_key="workout_pause_duration", + icon="mdi:timer-pause", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + ), + WithingsWorkoutSensorEntityDescription( + key="workout_duration", + value_fn=lambda workout: ( + workout.end_date - workout.start_date + ).total_seconds(), + translation_key="workout_duration", + icon="mdi:timer", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + ), +] + + def get_current_goals(goals: Goals) -> set[str]: """Return a list of present goals.""" result = set() @@ -656,7 +742,7 @@ async def async_setup_entry( for attribute in SLEEP_SENSORS ) else: - remove_listener: Callable[[], None] + remove_sleep_listener: Callable[[], None] def _async_add_sleep_entities() -> None: """Add sleep entities.""" @@ -665,12 +751,39 @@ async def async_setup_entry( WithingsSleepSensor(sleep_coordinator, attribute) for attribute in SLEEP_SENSORS ) - remove_listener() + remove_sleep_listener() - remove_listener = sleep_coordinator.async_add_listener( + remove_sleep_listener = sleep_coordinator.async_add_listener( _async_add_sleep_entities ) + workout_coordinator = withings_data.workout_coordinator + + workout_entities_setup_before = ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, f"withings_{entry.unique_id}_workout_type" + ) + + if workout_coordinator.data is not None or workout_entities_setup_before: + entities.extend( + WithingsWorkoutSensor(workout_coordinator, attribute) + for attribute in WORKOUT_SENSORS + ) + else: + remove_workout_listener: Callable[[], None] + + def _async_add_workout_entities() -> None: + """Add workout entities.""" + if workout_coordinator.data is not None: + async_add_entities( + WithingsWorkoutSensor(workout_coordinator, attribute) + for attribute in WORKOUT_SENSORS + ) + remove_workout_listener() + + remove_workout_listener = workout_coordinator.async_add_listener( + _async_add_workout_entities + ) + async_add_entities(entities) @@ -755,3 +868,18 @@ class WithingsActivitySensor(WithingsSensor): def last_reset(self) -> datetime: """These values reset every day.""" return dt_util.start_of_local_day() + + +class WithingsWorkoutSensor(WithingsSensor): + """Implementation of a Withings workout sensor.""" + + coordinator: WithingsWorkoutDataUpdateCoordinator + + entity_description: WithingsWorkoutSensorEntityDescription + + @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) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index a6a832d8394..dcb63f22a2e 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -170,6 +170,78 @@ }, "activity_total_calories_burnt_today": { "name": "Total calories burnt today" + }, + "workout_type": { + "name": "Last workout type", + "state": { + "walk": "Walking", + "run": "Running", + "hiking": "Hiking", + "skating": "Skating", + "bmx": "BMX", + "bicycling": "Bicycling", + "swimming": "Swimming", + "surfing": "Surfing", + "kitesurfing": "Kitesurfing", + "windsurfing": "Windsurfing", + "bodyboard": "Bodyboard", + "tennis": "Tennis", + "table_tennis": "Table tennis", + "squash": "Squash", + "badminton": "Badminton", + "lift_weights": "Lift weights", + "calisthenics": "Calisthenics", + "elliptical": "Elliptical", + "pilates": "Pilates", + "basket_ball": "Basket ball", + "soccer": "Soccer", + "football": "Football", + "rugby": "Rugby", + "volley_ball": "Volley ball", + "waterpolo": "Waterpolo", + "horse_riding": "Horse riding", + "golf": "Golf", + "yoga": "Yoga", + "dancing": "Dancing", + "boxing": "Boxing", + "fencing": "Fencing", + "wrestling": "Wrestling", + "martial_arts": "Martial arts", + "skiing": "Skiing", + "snowboarding": "Snowboarding", + "other": "Other", + "no_activity": "No activity", + "rowing": "Rowing", + "zumba": "Zumba", + "baseball": "Baseball", + "handball": "Handball", + "hockey": "Hockey", + "ice_hockey": "Ice hockey", + "climbing": "Climbing", + "ice_skating": "Ice skating", + "multi_sport": "Multi sport", + "indoor_walk": "Indoor walking", + "indoor_running": "Indoor running", + "indoor_cycling": "Indoor cycling" + } + }, + "workout_active_calories_burnt": { + "name": "Calories burnt last workout" + }, + "workout_distance": { + "name": "Distance travelled last workout" + }, + "workout_floors_climbed": { + "name": "Floors climbed last workout" + }, + "workout_intensity": { + "name": "Last workout intensity" + }, + "workout_pause_duration": { + "name": "Pause during last workout" + }, + "workout_duration": { + "name": "Last workout duration" } } } diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 8d8207cdf9a..cd0e9994f74 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -5,7 +5,7 @@ from typing import Any from urllib.parse import urlparse from aiohttp.test_utils import TestClient -from aiowithings import Activity, Goals, MeasurementGroup, SleepSummary +from aiowithings import Activity, Goals, MeasurementGroup, SleepSummary, Workout from freezegun.api import FrozenDateTimeFactory from homeassistant.components.webhook import async_generate_url @@ -89,11 +89,19 @@ def load_measurements_fixture( def load_activity_fixture( fixture: str = "withings/activity.json", ) -> list[Activity]: - """Return measurement from fixture.""" + """Return activities from fixture.""" activity_json = load_json_array_fixture(fixture) return [Activity.from_api(activity) for activity in activity_json] +def load_workout_fixture( + fixture: str = "withings/workouts.json", +) -> list[Workout]: + """Return workouts from fixture.""" + workouts_json = load_json_array_fixture(fixture) + return [Workout.from_api(workout) for workout in workouts_json] + + def load_sleep_fixture( fixture: str = "withings/sleep_summaries.json", ) -> list[SleepSummary]: diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index b040ccd2b58..7f15c5e0252 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -21,6 +21,7 @@ from tests.components.withings import ( load_goals_fixture, load_measurements_fixture, load_sleep_fixture, + load_workout_fixture, ) CLIENT_ID = "1234" @@ -144,6 +145,8 @@ def mock_withings(): NotificationConfiguration.from_api(not_conf) for not_conf in notification_json ] + workouts = load_workout_fixture() + activities = load_activity_fixture() mock = AsyncMock(spec=WithingsClient) @@ -155,6 +158,8 @@ def mock_withings(): mock.get_activities_since.return_value = activities mock.get_activities_in_period.return_value = activities mock.list_notification_configurations.return_value = notifications + mock.get_workouts_since.return_value = workouts + mock.get_workouts_in_period.return_value = workouts with patch( "homeassistant.components.withings.WithingsClient", diff --git a/tests/components/withings/fixtures/workouts.json b/tests/components/withings/fixtures/workouts.json new file mode 100644 index 00000000000..d5edcc75580 --- /dev/null +++ b/tests/components/withings/fixtures/workouts.json @@ -0,0 +1,327 @@ +[ + { + "id": 3661300277, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1693336011, + "enddate": 1693336513, + "date": "2023-08-29", + "deviceid": null, + "data": { + "calories": 47, + "intensity": 30, + "manual_distance": 60, + "manual_calories": 70, + "hr_average": 80, + "hr_min": 70, + "hr_max": 80, + "hr_zone_0": 100, + "hr_zone_1": 200, + "hr_zone_2": 300, + "hr_zone_3": 400, + "pause_duration": 80, + "steps": 779, + "distance": 680, + "elevation": 10, + "algo_pause_duration": null, + "spo2_average": 15 + }, + "modified": 1693481873 + }, + { + "id": 3661300290, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1693469307, + "enddate": 1693469924, + "date": "2023-08-31", + "deviceid": null, + "data": { + "algo_pause_duration": null + }, + "modified": 1693481873 + }, + { + "id": 3661300269, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1691164839, + "enddate": 1691165719, + "date": "2023-08-04", + "deviceid": null, + "data": { + "calories": 82, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 1450, + "distance": 1294, + "elevation": 18, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1693481873 + }, + { + "id": 3743596080, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1695425635, + "enddate": 1695426661, + "date": "2023-09-23", + "deviceid": null, + "data": { + "calories": 97, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 1650, + "distance": 1405, + "elevation": 19, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3743596073, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1694715649, + "enddate": 1694716306, + "date": "2023-09-14", + "deviceid": null, + "data": { + "calories": 62, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 1076, + "distance": 917, + "elevation": 15, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3743596085, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1695426953, + "enddate": 1695427093, + "date": "2023-09-23", + "deviceid": null, + "data": { + "calories": 13, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 216, + "distance": 185, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3743596072, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1694713351, + "enddate": 1694715327, + "date": "2023-09-14", + "deviceid": null, + "data": { + "calories": 187, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 3339, + "distance": 2908, + "elevation": 49, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3752609171, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696835569, + "enddate": 1696835767, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 18, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 291, + "distance": 261, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + }, + { + "id": 3752609178, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696844383, + "enddate": 1696844638, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 24, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 267, + "distance": 232, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + }, + { + "id": 3752609174, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696842803, + "enddate": 1696843032, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 21, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 403, + "distance": 359, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + }, + { + "id": 3752609174, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696842803, + "enddate": 1696843032, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 21, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 403, + "distance": 359, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + } +] diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 75d87a23a9c..59d9b470247 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -5,7 +5,7 @@ 'friendly_name': 'henk Active calories burnt today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': 'Calories', + 'unit_of_measurement': 'calories', }), 'context': , 'entity_id': 'sensor.henk_active_calories_burnt_today', @@ -103,6 +103,19 @@ 'state': '9', }) # --- +# name: test_all_entities[sensor.henk_calories_burnt_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Calories burnt last workout', + 'unit_of_measurement': 'calories', + }), + 'context': , + 'entity_id': 'sensor.henk_calories_burnt_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '24', + }) +# --- # name: test_all_entities[sensor.henk_deep_sleep] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -133,6 +146,21 @@ 'state': '70', }) # --- +# name: test_all_entities[sensor.henk_distance_travelled_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'henk Distance travelled last workout', + 'icon': 'mdi:map-marker-distance', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_distance_travelled_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '232', + }) +# --- # name: test_all_entities[sensor.henk_distance_travelled_today] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -209,6 +237,20 @@ 'state': '0.07', }) # --- +# name: test_all_entities[sensor.henk_floors_climbed_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Floors climbed last workout', + 'icon': 'mdi:stairs-up', + 'unit_of_measurement': 'floors', + }), + 'context': , + 'entity_id': 'sensor.henk_floors_climbed_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '4', + }) +# --- # name: test_all_entities[sensor.henk_floors_climbed_today] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -216,7 +258,7 @@ 'icon': 'mdi:stairs-up', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': 'Floors', + 'unit_of_measurement': 'floors', }), 'context': , 'entity_id': 'sensor.henk_floors_climbed_today', @@ -302,6 +344,97 @@ 'state': '100', }) # --- +# name: test_all_entities[sensor.henk_last_workout_duration] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Last workout duration', + 'icon': 'mdi:timer', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_last_workout_duration', + 'last_changed': , + 'last_updated': , + 'state': '255.0', + }) +# --- +# name: test_all_entities[sensor.henk_last_workout_intensity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Last workout intensity', + }), + 'context': , + 'entity_id': 'sensor.henk_last_workout_intensity', + 'last_changed': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_all_entities[sensor.henk_last_workout_type] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'henk Last workout type', + 'options': list([ + 'walk', + 'run', + 'hiking', + 'skating', + 'bmx', + 'bicycling', + 'swimming', + 'surfing', + 'kitesurfing', + 'windsurfing', + 'bodyboard', + 'tennis', + 'table_tennis', + 'squash', + 'badminton', + 'lift_weights', + 'calisthenics', + 'elliptical', + 'pilates', + 'basket_ball', + 'soccer', + 'football', + 'rugby', + 'volley_ball', + 'waterpolo', + 'horse_riding', + 'golf', + 'yoga', + 'dancing', + 'boxing', + 'fencing', + 'wrestling', + 'martial_arts', + 'skiing', + 'snowboarding', + 'other', + 'no_activity', + 'rowing', + 'zumba', + 'baseball', + 'handball', + 'hockey', + 'ice_hockey', + 'climbing', + 'ice_skating', + 'multi_sport', + 'indoor_walk', + 'indoor_running', + 'indoor_cycling', + ]), + }), + 'context': , + 'entity_id': 'sensor.henk_last_workout_type', + 'last_changed': , + 'last_updated': , + 'state': 'walk', + }) +# --- # name: test_all_entities[sensor.henk_light_sleep] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -407,6 +540,21 @@ 'state': '50', }) # --- +# name: test_all_entities[sensor.henk_pause_during_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Pause during last workout', + 'icon': 'mdi:timer-pause', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_pause_during_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_all_entities[sensor.henk_pulse_wave_velocity] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -546,7 +694,7 @@ 'friendly_name': 'henk Step goal', 'icon': 'mdi:shoe-print', 'state_class': , - 'unit_of_measurement': 'Steps', + 'unit_of_measurement': 'steps', }), 'context': , 'entity_id': 'sensor.henk_step_goal', @@ -562,7 +710,7 @@ 'icon': 'mdi:shoe-print', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': 'Steps', + 'unit_of_measurement': 'steps', }), 'context': , 'entity_id': 'sensor.henk_steps_today', @@ -638,7 +786,7 @@ 'friendly_name': 'henk Total calories burnt today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': 'Calories', + 'unit_of_measurement': 'calories', }), 'context': , 'entity_id': 'sensor.henk_total_calories_burnt_today', diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index d7add6905e5..0bf6b323146 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -15,6 +15,7 @@ from . import ( load_goals_fixture, load_measurements_fixture, load_sleep_fixture, + load_workout_fixture, setup_integration, ) @@ -293,3 +294,50 @@ async def test_sleep_sensors_created_when_receive_sleep_data( await hass.async_block_till_done() assert hass.states.get("sensor.henk_deep_sleep") + + +async def test_workout_sensors_created_when_existed( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test workout sensors will be added if they existed before.""" + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_last_workout_type") + assert hass.states.get("sensor.henk_last_workout_type").state != STATE_UNKNOWN + + withings.get_workouts_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_last_workout_type").state == STATE_UNKNOWN + + +async def test_workout_sensors_created_when_receive_workout_data( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test workout sensors will be added if we receive workout data.""" + withings.get_workouts_in_period.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_last_workout_type") is None + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_last_workout_type") is None + + withings.get_workouts_in_period.return_value = load_workout_fixture() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_last_workout_type")