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
3 changes: 3 additions & 0 deletions homeassistant/components/withings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
from homeassistant.helpers.typing import ConfigType

from .const import (
ACTIVITY_COORDINATOR,
BED_PRESENCE_COORDINATOR,
CONF_PROFILES,
CONF_USE_WEBHOOK,
Expand All @@ -62,6 +63,7 @@
SLEEP_COORDINATOR,
)
from .coordinator import (
WithingsActivityDataUpdateCoordinator,
WithingsBedPresenceDataUpdateCoordinator,
WithingsDataUpdateCoordinator,
WithingsGoalsDataUpdateCoordinator,
Expand Down Expand Up @@ -163,6 +165,7 @@ async def _refresh_token() -> str:
hass, client
),
GOALS_COORDINATOR: WithingsGoalsDataUpdateCoordinator(hass, client),
ACTIVITY_COORDINATOR: WithingsActivityDataUpdateCoordinator(hass, client),
}

for coordinator in coordinators.values():
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/withings/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
SLEEP_COORDINATOR = "sleep_coordinator"
BED_PRESENCE_COORDINATOR = "bed_presence_coordinator"
GOALS_COORDINATOR = "goals_coordinator"
ACTIVITY_COORDINATOR = "activity_coordinator"

LOGGER = logging.getLogger(__package__)

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
156 changes: 155 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,9 +25,12 @@
)
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 .const import (
ACTIVITY_COORDINATOR,
DOMAIN,
GOALS_COORDINATOR,
MEASUREMENT_COORDINATOR,
Expand All @@ -37,6 +42,7 @@
UOM_MMHG,
)
from .coordinator import (
WithingsActivityDataUpdateCoordinator,
WithingsDataUpdateCoordinator,
WithingsGoalsDataUpdateCoordinator,
WithingsMeasurementDataUpdateCoordinator,
Expand Down Expand Up @@ -398,6 +404,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 @@ -462,6 +567,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
measurement_coordinator: WithingsMeasurementDataUpdateCoordinator = hass.data[
DOMAIN
][entry.entry_id][MEASUREMENT_COORDINATOR]
Expand Down Expand Up @@ -516,6 +623,33 @@ def _async_goals_listener() -> None:

goals_coordinator.async_add_listener(_async_goals_listener)

activity_coordinator: WithingsActivityDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
][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: WithingsSleepDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
][SLEEP_COORDINATOR]
Expand Down Expand Up @@ -591,3 +725,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
13 changes: 11 additions & 2 deletions tests/components/withings/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from unittest.mock import AsyncMock, patch

from aiowithings import Device, Goals, MeasurementGroup, SleepSummary, WithingsClient
from aiowithings.models import NotificationConfiguration
from aiowithings.models import Activity, NotificationConfiguration
import pytest

from homeassistant.components.application_credentials import (
Expand All @@ -15,7 +15,11 @@
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component

from tests.common import MockConfigEntry, load_json_object_fixture
from tests.common import (
MockConfigEntry,
load_json_array_fixture,
load_json_object_fixture,
)

CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
Expand Down Expand Up @@ -148,6 +152,9 @@ def mock_withings():
for not_conf in notification_json["profiles"]
]

activity_json = load_json_array_fixture("withings/activity.json")
activities = [Activity.from_api(activity) for activity in activity_json]

goals_json = load_json_object_fixture("withings/goals.json")
goals = Goals.from_api(goals_json)

Expand All @@ -157,6 +164,8 @@ def mock_withings():
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
Loading
Loading