Add activity sensors to Withings (#102501)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Joost Lekkerkerker 2023-10-22 23:47:18 +02:00 committed by GitHub
parent 164872e1af
commit 04b883a8e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 769 additions and 13 deletions

View File

@ -53,6 +53,7 @@ from homeassistant.helpers.typing import ConfigType
from .const import CONF_PROFILES, CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN, LOGGER from .const import CONF_PROFILES, CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN, LOGGER
from .coordinator import ( from .coordinator import (
WithingsActivityDataUpdateCoordinator,
WithingsBedPresenceDataUpdateCoordinator, WithingsBedPresenceDataUpdateCoordinator,
WithingsDataUpdateCoordinator, WithingsDataUpdateCoordinator,
WithingsGoalsDataUpdateCoordinator, WithingsGoalsDataUpdateCoordinator,
@ -131,6 +132,7 @@ class WithingsData:
sleep_coordinator: WithingsSleepDataUpdateCoordinator sleep_coordinator: WithingsSleepDataUpdateCoordinator
bed_presence_coordinator: WithingsBedPresenceDataUpdateCoordinator bed_presence_coordinator: WithingsBedPresenceDataUpdateCoordinator
goals_coordinator: WithingsGoalsDataUpdateCoordinator goals_coordinator: WithingsGoalsDataUpdateCoordinator
activity_coordinator: WithingsActivityDataUpdateCoordinator
coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set) coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set)
def __post_init__(self) -> None: def __post_init__(self) -> None:
@ -140,6 +142,7 @@ class WithingsData:
self.sleep_coordinator, self.sleep_coordinator,
self.bed_presence_coordinator, self.bed_presence_coordinator,
self.goals_coordinator, self.goals_coordinator,
self.activity_coordinator,
} }
@ -172,6 +175,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, client), sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, client),
bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client), bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client),
goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client), goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client),
activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client),
) )
for coordinator in withings_data.coordinators: for coordinator in withings_data.coordinators:

View File

@ -1,9 +1,10 @@
"""Withings coordinator.""" """Withings coordinator."""
from abc import abstractmethod from abc import abstractmethod
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
from typing import TypeVar from typing import TypeVar
from aiowithings import ( from aiowithings import (
Activity,
Goals, Goals,
MeasurementType, MeasurementType,
NotificationCategory, NotificationCategory,
@ -81,7 +82,6 @@ class WithingsMeasurementDataUpdateCoordinator(
super().__init__(hass, client) super().__init__(hass, client)
self.notification_categories = { self.notification_categories = {
NotificationCategory.WEIGHT, NotificationCategory.WEIGHT,
NotificationCategory.ACTIVITY,
NotificationCategory.PRESSURE, NotificationCategory.PRESSURE,
} }
self._previous_data: dict[MeasurementType, float] = {} self._previous_data: dict[MeasurementType, float] = {}
@ -185,3 +185,41 @@ class WithingsGoalsDataUpdateCoordinator(WithingsDataUpdateCoordinator[Goals]):
async def _internal_update_data(self) -> Goals: async def _internal_update_data(self) -> Goals:
"""Retrieve goals data.""" """Retrieve goals data."""
return await self._client.get_goals() 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

View File

@ -3,8 +3,9 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass 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 ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -15,6 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
Platform,
UnitOfLength, UnitOfLength,
UnitOfMass, UnitOfMass,
UnitOfSpeed, UnitOfSpeed,
@ -23,7 +25,9 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from . import WithingsData from . import WithingsData
from .const import ( from .const import (
@ -35,6 +39,7 @@ from .const import (
UOM_MMHG, UOM_MMHG,
) )
from .coordinator import ( from .coordinator import (
WithingsActivityDataUpdateCoordinator,
WithingsDataUpdateCoordinator, WithingsDataUpdateCoordinator,
WithingsGoalsDataUpdateCoordinator, WithingsGoalsDataUpdateCoordinator,
WithingsMeasurementDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator,
@ -396,6 +401,105 @@ SLEEP_SENSORS = [
] ]
@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" STEP_GOAL = "steps"
SLEEP_GOAL = "sleep" SLEEP_GOAL = "sleep"
WEIGHT_GOAL = "weight" WEIGHT_GOAL = "weight"
@ -460,6 +564,8 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the sensor config entry.""" """Set up the sensor config entry."""
ent_reg = er.async_get(hass)
withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id] withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id]
measurement_coordinator = withings_data.measurement_coordinator measurement_coordinator = withings_data.measurement_coordinator
@ -512,6 +618,31 @@ async def async_setup_entry(
goals_coordinator.async_add_listener(_async_goals_listener) 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 sleep_coordinator = withings_data.sleep_coordinator
entities.extend( entities.extend(
@ -585,3 +716,23 @@ class WithingsGoalsSensor(WithingsSensor):
"""Return the state of the entity.""" """Return the state of the entity."""
assert self.coordinator.data assert self.coordinator.data
return self.entity_description.value_fn(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()

View File

@ -143,6 +143,33 @@
}, },
"weight_goal": { "weight_goal": {
"name": "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"
} }
} }
} }

View File

@ -5,7 +5,7 @@ from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from aiowithings import Goals, MeasurementGroup from aiowithings import Activity, Goals, MeasurementGroup
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.webhook import async_generate_url from homeassistant.components.webhook import async_generate_url
@ -84,3 +84,11 @@ def load_measurements_fixture(
"""Return measurement from fixture.""" """Return measurement from fixture."""
meas_json = load_json_array_fixture(fixture) meas_json = load_json_array_fixture(fixture)
return [MeasurementGroup.from_api(measurement) for measurement in meas_json] 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]

View File

@ -16,7 +16,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, load_json_array_fixture 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_ID = "1234"
CLIENT_SECRET = "5678" CLIENT_SECRET = "5678"
@ -132,7 +136,7 @@ def mock_withings():
devices_json = load_json_array_fixture("withings/devices.json") devices_json = load_json_array_fixture("withings/devices.json")
devices = [Device.from_api(device) for device in 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_json = load_json_array_fixture("withings/sleep_summaries.json")
sleep_summaries = [ sleep_summaries = [
@ -144,12 +148,16 @@ def mock_withings():
NotificationConfiguration.from_api(not_conf) for not_conf in notification_json NotificationConfiguration.from_api(not_conf) for not_conf in notification_json
] ]
activities = load_activity_fixture()
mock = AsyncMock(spec=WithingsClient) mock = AsyncMock(spec=WithingsClient)
mock.get_devices.return_value = devices 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_in_period.return_value = measurement_groups
mock.get_measurement_since.return_value = measurement_groups mock.get_measurement_since.return_value = measurement_groups
mock.get_sleep_summary_since.return_value = sleep_summaries 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 mock.list_notification_configurations.return_value = notifications
with patch( with patch(

View File

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

View File

@ -1,4 +1,35 @@
# serializer version: 1 # 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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'Calories',
}),
'context': <ANY>,
'entity_id': 'sensor.henk_active_calories_burnt_today',
'last_changed': <ANY>,
'last_updated': <ANY>,
'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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
}),
'context': <ANY>,
'entity_id': 'sensor.henk_active_time_today',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '1907',
})
# ---
# name: test_all_entities[sensor.henk_average_heart_rate] # name: test_all_entities[sensor.henk_average_heart_rate]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
@ -102,6 +133,23 @@
'state': '70', '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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfLength.METERS: 'm'>,
}),
'context': <ANY>,
'entity_id': 'sensor.henk_distance_travelled_today',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '1020.121',
})
# ---
# name: test_all_entities[sensor.henk_extracellular_water] # name: test_all_entities[sensor.henk_extracellular_water]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
@ -161,6 +209,22 @@
'state': '0.07', '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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'Floors',
}),
'context': <ANY>,
'entity_id': 'sensor.henk_floors_climbed_today',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_all_entities[sensor.henk_heart_pulse] # name: test_all_entities[sensor.henk_heart_pulse]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
@ -207,6 +271,22 @@
'state': '0.95', '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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
}),
'context': <ANY>,
'entity_id': 'sensor.henk_intense_activity_today',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '420',
})
# ---
# name: test_all_entities[sensor.henk_intracellular_water] # name: test_all_entities[sensor.henk_intracellular_water]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
@ -296,6 +376,22 @@
'state': '10', '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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
}),
'context': <ANY>,
'entity_id': 'sensor.henk_moderate_activity_today',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '1487',
})
# ---
# name: test_all_entities[sensor.henk_muscle_mass] # name: test_all_entities[sensor.henk_muscle_mass]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
@ -414,6 +510,22 @@
'state': '87', '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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
}),
'context': <ANY>,
'entity_id': 'sensor.henk_soft_activity_today',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '1516',
})
# ---
# name: test_all_entities[sensor.henk_spo2] # name: test_all_entities[sensor.henk_spo2]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
@ -443,6 +555,22 @@
'state': '10000', '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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'Steps',
}),
'context': <ANY>,
'entity_id': 'sensor.henk_steps_today',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '1155',
})
# ---
# name: test_all_entities[sensor.henk_systolic_blood_pressure] # name: test_all_entities[sensor.henk_systolic_blood_pressure]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
@ -504,6 +632,21 @@
'state': '996', '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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'Calories',
}),
'context': <ANY>,
'entity_id': 'sensor.henk_total_calories_burnt_today',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '2444.149',
})
# ---
# name: test_all_entities[sensor.henk_vascular_age] # name: test_all_entities[sensor.henk_vascular_age]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({

View File

@ -6,15 +6,21 @@ from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy import SnapshotAssertion 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.core import HomeAssistant
from homeassistant.helpers import entity_registry as er 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 from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.mark.freeze_time("2023-10-21")
@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities( async def test_all_entities(
hass: HomeAssistant, 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 assert hass.states.get("sensor.henk_fat_mass") is None
withings.get_measurement_in_period.return_value = load_measurements_fixture( withings.get_measurement_in_period.return_value = load_measurements_fixture()
"withings/measurements.json"
)
freezer.tick(timedelta(minutes=10)) freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass) 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_step_goal") is None
assert hass.states.get("sensor.henk_weight_goal") is not 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)) freezer.tick(timedelta(hours=1))
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.states.get("sensor.henk_step_goal") is not None 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