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 .coordinator import (
WithingsActivityDataUpdateCoordinator,
WithingsBedPresenceDataUpdateCoordinator,
WithingsDataUpdateCoordinator,
WithingsGoalsDataUpdateCoordinator,
@ -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:
@ -140,6 +142,7 @@ class WithingsData:
self.sleep_coordinator,
self.bed_presence_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),
bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client),
goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client),
activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client),
)
for coordinator in withings_data.coordinators:

View File

@ -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,
@ -81,7 +82,6 @@ class WithingsMeasurementDataUpdateCoordinator(
super().__init__(hass, client)
self.notification_categories = {
NotificationCategory.WEIGHT,
NotificationCategory.ACTIVITY,
NotificationCategory.PRESSURE,
}
self._previous_data: dict[MeasurementType, float] = {}
@ -185,3 +185,41 @@ class WithingsGoalsDataUpdateCoordinator(WithingsDataUpdateCoordinator[Goals]):
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:
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 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,
@ -15,6 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
Platform,
UnitOfLength,
UnitOfMass,
UnitOfSpeed,
@ -23,7 +25,9 @@ from homeassistant.const import (
)
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 (
@ -35,6 +39,7 @@ from .const import (
UOM_MMHG,
)
from .coordinator import (
WithingsActivityDataUpdateCoordinator,
WithingsDataUpdateCoordinator,
WithingsGoalsDataUpdateCoordinator,
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"
SLEEP_GOAL = "sleep"
WEIGHT_GOAL = "weight"
@ -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)
withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id]
measurement_coordinator = withings_data.measurement_coordinator
@ -512,6 +618,31 @@ async def async_setup_entry(
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
entities.extend(
@ -585,3 +716,23 @@ class WithingsGoalsSensor(WithingsSensor):
"""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()

View File

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

View File

@ -5,7 +5,7 @@ from typing import Any
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
@ -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]

View File

@ -16,7 +16,11 @@ from homeassistant.core import HomeAssistant
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"
@ -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 = [
@ -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(

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
# 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]
StateSnapshot({
'attributes': ReadOnlyDict({
@ -102,6 +133,23 @@
'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]
StateSnapshot({
'attributes': ReadOnlyDict({
@ -161,6 +209,22 @@
'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]
StateSnapshot({
'attributes': ReadOnlyDict({
@ -207,6 +271,22 @@
'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]
StateSnapshot({
'attributes': ReadOnlyDict({
@ -296,6 +376,22 @@
'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]
StateSnapshot({
'attributes': ReadOnlyDict({
@ -414,6 +510,22 @@
'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]
StateSnapshot({
'attributes': ReadOnlyDict({
@ -443,6 +555,22 @@
'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]
StateSnapshot({
'attributes': ReadOnlyDict({
@ -504,6 +632,21 @@
'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]
StateSnapshot({
'attributes': ReadOnlyDict({

View File

@ -6,15 +6,21 @@ from freezegun.api import FrozenDateTimeFactory
import pytest
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.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
@pytest.mark.freeze_time("2023-10-21")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
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
withings.get_measurement_in_period.return_value = load_measurements_fixture(
"withings/measurements.json"
)
withings.get_measurement_in_period.return_value = load_measurements_fixture()
freezer.tick(timedelta(minutes=10))
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_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))
async_fire_time_changed(hass)
await hass.async_block_till_done()
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