mirror of
https://github.com/home-assistant/core.git
synced 2026-11-05 13:11:29 -04:00
Add sleep coordinator and sensors to Google Health (#176781)
This commit is contained in:
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, patch
|
||||
from google_health_api.model import (
|
||||
BODY_FAT,
|
||||
DAILY_RESTING_HEART_RATE,
|
||||
SLEEP,
|
||||
WEIGHT,
|
||||
ActiveEnergyBurnedRollupValue,
|
||||
DailyRollupDataPoint,
|
||||
@@ -158,6 +159,11 @@ def mock_google_health_client() -> Generator[AsyncMock]:
|
||||
)
|
||||
client.body_fat = AsyncMock()
|
||||
client.body_fat.list.return_value = _list_fixture("body_fat.json", BODY_FAT)
|
||||
client.sleep = AsyncMock()
|
||||
client.sleep.list.return_value = _list_fixture("sleep.json", SLEEP)
|
||||
client.sleep.required_read_scopes = [
|
||||
"https://www.googleapis.com/auth/googlehealth.sleep.readonly"
|
||||
]
|
||||
client.get_identity.return_value = Identity.from_dict(
|
||||
load_json_object_fixture("identity.json", DOMAIN)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"dataPoints": [
|
||||
{
|
||||
"name": "users/me/dataTypes/sleep/dataPoints/session-12345",
|
||||
"sleep": {
|
||||
"interval": {
|
||||
"startTime": "2026-06-29T22:00:00Z",
|
||||
"endTime": "2026-06-30T06:00:00Z",
|
||||
"startUtcOffset": "-07:00",
|
||||
"endUtcOffset": "-07:00"
|
||||
},
|
||||
"summary": {
|
||||
"minutesToFallAsleep": 15,
|
||||
"minutesAfterWakeUp": 10,
|
||||
"minutesAsleep": 420,
|
||||
"minutesInSleepPeriod": 480,
|
||||
"minutesAwake": 60
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -326,6 +326,296 @@
|
||||
'state': '10500',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.google_health_time_after_waking_up-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.google_health_time_after_waking_up',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Time after waking up',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Time after waking up',
|
||||
'platform': 'google_health',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'sleep_after_wakeup',
|
||||
'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_sleep_after_wakeup',
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.google_health_time_after_waking_up-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'duration',
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Google Health Time after waking up',
|
||||
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.google_health_time_after_waking_up',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '10',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.google_health_time_asleep-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.google_health_time_asleep',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Time asleep',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Time asleep',
|
||||
'platform': 'google_health',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'sleep_asleep',
|
||||
'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_sleep_asleep',
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.google_health_time_asleep-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'duration',
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Google Health Time asleep',
|
||||
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.google_health_time_asleep',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '420',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.google_health_time_awake-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.google_health_time_awake',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Time awake',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Time awake',
|
||||
'platform': 'google_health',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'sleep_awake',
|
||||
'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_sleep_awake',
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.google_health_time_awake-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'duration',
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Google Health Time awake',
|
||||
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.google_health_time_awake',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '60',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.google_health_time_in_bed-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.google_health_time_in_bed',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Time in bed',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Time in bed',
|
||||
'platform': 'google_health',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'sleep_in_bed',
|
||||
'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_sleep_in_bed',
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.google_health_time_in_bed-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'duration',
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Google Health Time in bed',
|
||||
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.google_health_time_in_bed',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '480',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.google_health_time_to_fall_asleep-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.google_health_time_to_fall_asleep',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Time to fall asleep',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Time to fall asleep',
|
||||
'platform': 'google_health',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'sleep_to_fall_asleep',
|
||||
'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_sleep_to_fall_asleep',
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.google_health_time_to_fall_asleep-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'duration',
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Google Health Time to fall asleep',
|
||||
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.google_health_time_to_fall_asleep',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '15',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.google_health_total_calories-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
|
||||
@@ -64,6 +64,7 @@ async def test_full_flow(
|
||||
"&scope=https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly"
|
||||
"+https://www.googleapis.com/auth/googlehealth.profile.readonly"
|
||||
"+https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly"
|
||||
"+https://www.googleapis.com/auth/googlehealth.sleep.readonly"
|
||||
"+https://www.googleapis.com/auth/userinfo.profile"
|
||||
"&access_type=offline"
|
||||
"&prompt=consent"
|
||||
|
||||
@@ -37,6 +37,8 @@ async def test_setup_and_unload(
|
||||
assert hass.states.get("sensor.google_health_distance") is not None
|
||||
assert hass.states.get("sensor.google_health_weight") is not None
|
||||
assert hass.states.get("sensor.google_health_resting_heart_rate") is not None
|
||||
assert hass.states.get("sensor.google_health_time_asleep") is not None
|
||||
assert hass.states.get("sensor.google_health_time_awake") is not None
|
||||
|
||||
assert await hass.config_entries.async_unload(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
@@ -151,6 +153,30 @@ async def test_setup_missing_measurements_scope(
|
||||
assert hass.states.get("sensor.google_health_floors") is not None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_google_health_client")
|
||||
@pytest.mark.parametrize(
|
||||
"scopes",
|
||||
[
|
||||
[
|
||||
"https://www.googleapis.com/auth/googlehealth.profile.readonly",
|
||||
"https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly",
|
||||
"https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly",
|
||||
]
|
||||
],
|
||||
)
|
||||
async def test_setup_missing_sleep_scope(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Test setup succeeds but sleep sensor is not added if sleep scope is missing."""
|
||||
assert await integration_setup()
|
||||
assert config_entry.state is config_entries.ConfigEntryState.LOADED
|
||||
|
||||
assert hass.states.get("sensor.google_health_time_asleep") is None
|
||||
assert hass.states.get("sensor.google_health_time_awake") is None
|
||||
|
||||
|
||||
async def test_setup_oauth_implementation_unavailable(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from google_health_api.model import ListDataPointResult, _ListDataPointsModel
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
@@ -61,3 +62,20 @@ async def test_sensor_empty_rollup(
|
||||
floors_state = hass.states.get("sensor.google_health_floors")
|
||||
assert floors_state is not None
|
||||
assert floors_state.state == "0"
|
||||
|
||||
|
||||
async def test_sensor_empty_sleep(
|
||||
hass: HomeAssistant,
|
||||
mock_google_health_client: AsyncMock,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Test sleep sensors when the sleep endpoint returns no data."""
|
||||
mock_google_health_client.sleep.list.return_value = ListDataPointResult(
|
||||
_ListDataPointsModel(data_points=[])
|
||||
)
|
||||
|
||||
assert await integration_setup()
|
||||
|
||||
time_asleep_state = hass.states.get("sensor.google_health_time_asleep")
|
||||
assert time_asleep_state is not None
|
||||
assert time_asleep_state.state == "unknown"
|
||||
|
||||
Reference in New Issue
Block a user