From 54fbca528b223fae69888876d52cd275ffe4cb08 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 20 Jul 2026 07:15:30 -0700 Subject: [PATCH] Add sleep coordinator and sensors to Google Health (#176781) --- .../components/google_health/__init__.py | 27 +- .../components/google_health/const.py | 1 + .../components/google_health/coordinator.py | 37 +++ .../components/google_health/sensor.py | 79 ++++- .../components/google_health/strings.json | 15 + tests/components/google_health/conftest.py | 6 + .../google_health/fixtures/sleep.json | 22 ++ .../google_health/snapshots/test_sensor.ambr | 290 ++++++++++++++++++ .../google_health/test_config_flow.py | 1 + tests/components/google_health/test_init.py | 26 ++ tests/components/google_health/test_sensor.py | 18 ++ 11 files changed, 518 insertions(+), 4 deletions(-) create mode 100644 tests/components/google_health/fixtures/sleep.json diff --git a/homeassistant/components/google_health/__init__.py b/homeassistant/components/google_health/__init__.py index a389edc4bfa3..f76bb4247da2 100644 --- a/homeassistant/components/google_health/__init__.py +++ b/homeassistant/components/google_health/__init__.py @@ -1,6 +1,8 @@ """The Google Health integration.""" +import asyncio from dataclasses import dataclass +from typing import Any from google_health_api import GoogleHealthApi from google_health_api.const import HealthApiScope @@ -18,7 +20,12 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( from . import api from .const import DOMAIN -from .coordinator import GoogleHealthActivityCoordinator, GoogleHealthBodyCoordinator +from .coordinator import ( + GoogleHealthActivityCoordinator, + GoogleHealthBodyCoordinator, + GoogleHealthDataUpdateCoordinator, + GoogleHealthSleepCoordinator, +) _PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -29,6 +36,7 @@ class GoogleHealthData: activity_coordinator: GoogleHealthActivityCoordinator | None = None body_coordinator: GoogleHealthBodyCoordinator | None = None + sleep_coordinator: GoogleHealthSleepCoordinator | None = None type GoogleHealthConfigEntry = ConfigEntry[GoogleHealthData] @@ -61,19 +69,32 @@ async def async_setup_entry( api_client = GoogleHealthApi(auth) + coordinators: list[GoogleHealthDataUpdateCoordinator[Any]] = [] + activity_coordinator = None if all(scope in scopes for scope in api_client.steps.required_read_scopes): activity_coordinator = GoogleHealthActivityCoordinator(hass, entry, api_client) - await activity_coordinator.async_config_entry_first_refresh() + coordinators.append(activity_coordinator) body_coordinator = None if all(scope in scopes for scope in api_client.weight.required_read_scopes): body_coordinator = GoogleHealthBodyCoordinator(hass, entry, api_client) - await body_coordinator.async_config_entry_first_refresh() + coordinators.append(body_coordinator) + + sleep_coordinator = None + if all(scope in scopes for scope in api_client.sleep.required_read_scopes): + sleep_coordinator = GoogleHealthSleepCoordinator(hass, entry, api_client) + coordinators.append(sleep_coordinator) + + if coordinators: + await asyncio.gather( + *(coord.async_config_entry_first_refresh() for coord in coordinators) + ) entry.runtime_data = GoogleHealthData( activity_coordinator=activity_coordinator, body_coordinator=body_coordinator, + sleep_coordinator=sleep_coordinator, ) await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) diff --git a/homeassistant/components/google_health/const.py b/homeassistant/components/google_health/const.py index fc4660cc0695..14bbf3b6771f 100644 --- a/homeassistant/components/google_health/const.py +++ b/homeassistant/components/google_health/const.py @@ -17,5 +17,6 @@ OAUTH_SCOPES = [ HealthApiScope.ACTIVITY_READ, HealthApiScope.PROFILE_READ, HealthApiScope.MEASUREMENTS_READ, + HealthApiScope.SLEEP_READ, HealthApiScope.USERINFO_PROFILE, ] diff --git a/homeassistant/components/google_health/coordinator.py b/homeassistant/components/google_health/coordinator.py index a9b515b998aa..9ce303ace24a 100644 --- a/homeassistant/components/google_health/coordinator.py +++ b/homeassistant/components/google_health/coordinator.py @@ -18,6 +18,7 @@ from google_health_api.model import ( DailyRestingHeartRate, DistanceRollupValue, FloorsRollupValue, + Sleep, StepsRollupValue, TotalCaloriesRollupValue, Weight, @@ -211,3 +212,39 @@ class GoogleHealthBodyCoordinator( resting_heart_rate=resting_heart_rate, body_fat=body_fat, ) + + +@dataclass +class GoogleHealthSleepData: + """Class to hold sleep data.""" + + sleep: Sleep | None = None + + +class GoogleHealthSleepCoordinator( + GoogleHealthDataUpdateCoordinator[GoogleHealthSleepData] +): + """Coordinator to fetch sleep data from Google Health API.""" + + def __init__( + self, + hass: HomeAssistant, + entry: GoogleHealthConfigEntry, + api_client: GoogleHealthApi, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_sleep", + update_interval=BODY_POLLING_INTERVAL, + entry=entry, + api_client=api_client, + ) + + @override + async def _async_fetch_data(self) -> GoogleHealthSleepData: + """Fetch latest sleep session.""" + sleep_result = await self.api.sleep.list(page_size=DEFAULT_PAGE_SIZE) + sleep = sleep_result.data_points[0].data if sleep_result.data_points else None + return GoogleHealthSleepData(sleep=sleep) diff --git a/homeassistant/components/google_health/sensor.py b/homeassistant/components/google_health/sensor.py index 004f84bce3de..5c6a245dd697 100644 --- a/homeassistant/components/google_health/sensor.py +++ b/homeassistant/components/google_health/sensor.py @@ -10,7 +10,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfMass +from homeassistant.const import ( + PERCENTAGE, + UnitOfEnergy, + UnitOfLength, + UnitOfMass, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,6 +29,7 @@ from .coordinator import ( GoogleHealthActivityCoordinator, GoogleHealthBodyCoordinator, GoogleHealthDataUpdateCoordinator, + GoogleHealthSleepCoordinator, ) PARALLEL_UPDATES = 0 @@ -118,6 +125,71 @@ BODY_SENSORS: list[ ), ] +SLEEP_SENSORS: list[ + GoogleHealthSensorEntityDescription[GoogleHealthSleepCoordinator, Any] +] = [ + GoogleHealthSensorEntityDescription[GoogleHealthSleepCoordinator, int | None]( + key="sleep_asleep", + translation_key="sleep_asleep", + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: ( + data.sleep.summary.minutes_asleep + if data and data.sleep and data.sleep.summary + else None + ), + ), + GoogleHealthSensorEntityDescription[GoogleHealthSleepCoordinator, int | None]( + key="sleep_awake", + translation_key="sleep_awake", + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: ( + data.sleep.summary.minutes_awake + if data and data.sleep and data.sleep.summary + else None + ), + ), + GoogleHealthSensorEntityDescription[GoogleHealthSleepCoordinator, int | None]( + key="sleep_in_bed", + translation_key="sleep_in_bed", + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: ( + data.sleep.summary.minutes_in_sleep_period + if data and data.sleep and data.sleep.summary + else None + ), + ), + GoogleHealthSensorEntityDescription[GoogleHealthSleepCoordinator, int | None]( + key="sleep_to_fall_asleep", + translation_key="sleep_to_fall_asleep", + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: ( + data.sleep.summary.minutes_to_fall_asleep + if data and data.sleep and data.sleep.summary + else None + ), + ), + GoogleHealthSensorEntityDescription[GoogleHealthSleepCoordinator, int | None]( + key="sleep_after_wakeup", + translation_key="sleep_after_wakeup", + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: ( + data.sleep.summary.minutes_after_wake_up + if data and data.sleep and data.sleep.summary + else None + ), + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -138,6 +210,11 @@ async def async_setup_entry( GoogleHealthSensor(body_coordinator, entry.entry_id, description) for description in BODY_SENSORS ) + if (sleep_coordinator := data.sleep_coordinator) is not None: + entities.extend( + GoogleHealthSensor(sleep_coordinator, entry.entry_id, description) + for description in SLEEP_SENSORS + ) if entities: async_add_entities(entities) diff --git a/homeassistant/components/google_health/strings.json b/homeassistant/components/google_health/strings.json index c1676e068b59..cc6488314911 100644 --- a/homeassistant/components/google_health/strings.json +++ b/homeassistant/components/google_health/strings.json @@ -49,6 +49,21 @@ "resting_heart_rate": { "name": "Resting heart rate" }, + "sleep_after_wakeup": { + "name": "Time after waking up" + }, + "sleep_asleep": { + "name": "Time asleep" + }, + "sleep_awake": { + "name": "Time awake" + }, + "sleep_in_bed": { + "name": "Time in bed" + }, + "sleep_to_fall_asleep": { + "name": "Time to fall asleep" + }, "steps": { "name": "Steps", "unit_of_measurement": "steps" diff --git a/tests/components/google_health/conftest.py b/tests/components/google_health/conftest.py index 783ad31df148..e4096280f5e0 100644 --- a/tests/components/google_health/conftest.py +++ b/tests/components/google_health/conftest.py @@ -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) ) diff --git a/tests/components/google_health/fixtures/sleep.json b/tests/components/google_health/fixtures/sleep.json new file mode 100644 index 000000000000..1cc71aacab59 --- /dev/null +++ b/tests/components/google_health/fixtures/sleep.json @@ -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 + } + } + } + ] +} diff --git a/tests/components/google_health/snapshots/test_sensor.ambr b/tests/components/google_health/snapshots/test_sensor.ambr index 6dbd4ffb0cdb..5c7f7bf79946 100644 --- a/tests/components/google_health/snapshots/test_sensor.ambr +++ b/tests/components/google_health/snapshots/test_sensor.ambr @@ -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({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Time after waking up', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_all_entities[sensor.google_health_time_after_waking_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Google Health Time after waking up', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_time_after_waking_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[sensor.google_health_time_asleep-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Time asleep', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_all_entities[sensor.google_health_time_asleep-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Google Health Time asleep', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_time_asleep', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '420', + }) +# --- +# name: test_all_entities[sensor.google_health_time_awake-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Time awake', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_all_entities[sensor.google_health_time_awake-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Google Health Time awake', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_time_awake', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_entities[sensor.google_health_time_in_bed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Time in bed', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_all_entities[sensor.google_health_time_in_bed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Google Health Time in bed', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_time_in_bed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '480', + }) +# --- +# name: test_all_entities[sensor.google_health_time_to_fall_asleep-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Time to fall asleep', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_all_entities[sensor.google_health_time_to_fall_asleep-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Google Health Time to fall asleep', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_time_to_fall_asleep', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- # name: test_all_entities[sensor.google_health_total_calories-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/google_health/test_config_flow.py b/tests/components/google_health/test_config_flow.py index 194aaeab3bb1..7c35b9ba908c 100644 --- a/tests/components/google_health/test_config_flow.py +++ b/tests/components/google_health/test_config_flow.py @@ -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" diff --git a/tests/components/google_health/test_init.py b/tests/components/google_health/test_init.py index 766d26d1c209..d7c13ca62b2d 100644 --- a/tests/components/google_health/test_init.py +++ b/tests/components/google_health/test_init.py @@ -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, diff --git a/tests/components/google_health/test_sensor.py b/tests/components/google_health/test_sensor.py index f14a8017c907..bb7491fb98a6 100644 --- a/tests/components/google_health/test_sensor.py +++ b/tests/components/google_health/test_sensor.py @@ -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"