mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Add activity sensors to Withings (#102501)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
164872e1af
commit
04b883a8e9
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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(
|
||||
|
282
tests/components/withings/fixtures/activity.json
Normal file
282
tests/components/withings/fixtures/activity.json
Normal 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
|
||||
}
|
||||
]
|
@ -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({
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user