Add last workout sensors to Withings (#102541)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Robert Resch <robert@resch.dev>
This commit is contained in:
Joost Lekkerkerker 2023-10-24 11:07:47 +02:00 committed by GitHub
parent 57a10a2e0d
commit b42c47e800
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 793 additions and 16 deletions

View file

@ -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:

View file

@ -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

View file

@ -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)

View file

@ -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"
}
}
}