Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add activity sensors to Withings #102501

Merged
merged 12 commits into from
Oct 22, 2023
4 changes: 4 additions & 0 deletions homeassistant/components/withings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@

from .const import CONF_PROFILES, CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN, LOGGER
from .coordinator import (
WithingsActivityDataUpdateCoordinator,
WithingsBedPresenceDataUpdateCoordinator,
WithingsDataUpdateCoordinator,
WithingsGoalsDataUpdateCoordinator,
Expand Down Expand Up @@ -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:
Expand All @@ -140,6 +142,7 @@ def __post_init__(self) -> None:
self.sleep_coordinator,
self.bed_presence_coordinator,
self.goals_coordinator,
self.activity_coordinator,
}


Expand Down Expand Up @@ -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:
Expand Down
42 changes: 40 additions & 2 deletions homeassistant/components/withings/coordinator.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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] = {}
Expand Down Expand Up @@ -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:
joostlek marked this conversation as resolved.
Show resolved Hide resolved
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
153 changes: 152 additions & 1 deletion homeassistant/components/withings/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,6 +16,7 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
Platform,
UnitOfLength,
UnitOfMass,
UnitOfSpeed,
Expand All @@ -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 (
Expand All @@ -35,6 +39,7 @@
UOM_MMHG,
)
from .coordinator import (
WithingsActivityDataUpdateCoordinator,
WithingsDataUpdateCoordinator,
WithingsGoalsDataUpdateCoordinator,
WithingsMeasurementDataUpdateCoordinator,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)

joostlek marked this conversation as resolved.
Show resolved Hide resolved
withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id]

measurement_coordinator = withings_data.measurement_coordinator
Expand Down Expand Up @@ -512,6 +618,31 @@ def _async_goals_listener() -> None:

goals_coordinator.async_add_listener(_async_goals_listener)

activity_coordinator = withings_data.activity_coordinator
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


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(
Expand Down Expand Up @@ -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()
27 changes: 27 additions & 0 deletions homeassistant/components/withings/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
10 changes: 9 additions & 1 deletion tests/components/withings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
14 changes: 11 additions & 3 deletions tests/components/withings/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = [
Expand All @@ -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(
Expand Down