Add sleep coordinator and sensors to Google Health (#176781)

This commit is contained in:
Allen Porter
2026-07-20 16:15:30 +02:00
committed by GitHub
parent ec24b750da
commit 54fbca528b
11 changed files with 518 additions and 4 deletions
@@ -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"