Skip to content

Commit 37f0f18

Browse files
rhcp011235claude
andauthored
Add sleep health metrics to SleepIQ integration (home-assistant#163403)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2fcbd77 commit 37f0f18

File tree

8 files changed

+744
-12
lines changed

8 files changed

+744
-12
lines changed

homeassistant/components/sleepiq/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
SleepIQData,
2727
SleepIQDataUpdateCoordinator,
2828
SleepIQPauseUpdateCoordinator,
29+
SleepIQSleepDataCoordinator,
2930
)
3031

3132
_LOGGER = logging.getLogger(__name__)
@@ -96,14 +97,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
9697

9798
coordinator = SleepIQDataUpdateCoordinator(hass, entry, gateway)
9899
pause_coordinator = SleepIQPauseUpdateCoordinator(hass, entry, gateway)
100+
sleep_data_coordinator = SleepIQSleepDataCoordinator(hass, entry, gateway)
99101

100102
# Call the SleepIQ API to refresh data
101103
await coordinator.async_config_entry_first_refresh()
102104
await pause_coordinator.async_config_entry_first_refresh()
105+
await sleep_data_coordinator.async_config_entry_first_refresh()
103106

104107
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SleepIQData(
105108
data_coordinator=coordinator,
106109
pause_coordinator=pause_coordinator,
110+
sleep_data_coordinator=sleep_data_coordinator,
107111
client=gateway,
108112
)
109113

homeassistant/components/sleepiq/const.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
SLEEP_NUMBER = "sleep_number"
1616
FOOT_WARMING_TIMER = "foot_warming_timer"
1717
FOOT_WARMER = "foot_warmer"
18+
SLEEP_SCORE = "sleep_score"
19+
SLEEP_DURATION = "sleep_duration"
20+
HEART_RATE = "heart_rate"
21+
RESPIRATORY_RATE = "respiratory_rate"
22+
HRV = "hrv"
1823
ENTITY_TYPES = {
1924
ACTUATOR: "Position",
2025
CORE_CLIMATE_TIMER: "Core Climate Timer",
@@ -25,6 +30,11 @@
2530
SLEEP_NUMBER: "SleepNumber",
2631
FOOT_WARMING_TIMER: "Foot Warming Timer",
2732
FOOT_WARMER: "Foot Warmer",
33+
SLEEP_SCORE: "Sleep Score",
34+
SLEEP_DURATION: "Sleep Duration",
35+
HEART_RATE: "Heart Rate Average",
36+
RESPIRATORY_RATE: "Respiratory Rate Average",
37+
HRV: "Heart Rate Variability",
2838
}
2939

3040
LEFT = "left"

homeassistant/components/sleepiq/coordinator.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,18 @@
55
from datetime import timedelta
66
import logging
77

8-
from asyncsleepiq import AsyncSleepIQ
8+
from asyncsleepiq import AsyncSleepIQ, SleepIQAPIException, SleepIQTimeoutException
99

1010
from homeassistant.config_entries import ConfigEntry
1111
from homeassistant.const import CONF_USERNAME
1212
from homeassistant.core import HomeAssistant
13-
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
13+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1414

1515
_LOGGER = logging.getLogger(__name__)
1616

1717
UPDATE_INTERVAL = timedelta(seconds=60)
1818
LONGER_UPDATE_INTERVAL = timedelta(minutes=5)
19+
SLEEP_DATA_UPDATE_INTERVAL = timedelta(hours=1) # Sleep data doesn't change frequently
1920

2021

2122
class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]):
@@ -74,10 +75,48 @@ async def _async_update_data(self) -> None:
7475
)
7576

7677

78+
class SleepIQSleepDataCoordinator(DataUpdateCoordinator[None]):
79+
"""SleepIQ sleep health data coordinator."""
80+
81+
config_entry: ConfigEntry
82+
83+
def __init__(
84+
self,
85+
hass: HomeAssistant,
86+
config_entry: ConfigEntry,
87+
client: AsyncSleepIQ,
88+
) -> None:
89+
"""Initialize coordinator."""
90+
super().__init__(
91+
hass,
92+
_LOGGER,
93+
config_entry=config_entry,
94+
name=f"{config_entry.data[CONF_USERNAME]}@SleepIQSleepData",
95+
update_interval=SLEEP_DATA_UPDATE_INTERVAL,
96+
)
97+
self.client = client
98+
99+
async def _async_update_data(self) -> None:
100+
"""Fetch sleep health data from API via asyncsleepiq library."""
101+
try:
102+
await asyncio.gather(
103+
*[
104+
sleeper.fetch_sleep_data()
105+
for bed in self.client.beds.values()
106+
for sleeper in bed.sleepers
107+
]
108+
)
109+
except SleepIQTimeoutException as err:
110+
raise UpdateFailed(f"Timed out fetching SleepIQ sleep data: {err}") from err
111+
except SleepIQAPIException as err:
112+
raise UpdateFailed(f"Failed to fetch SleepIQ sleep data: {err}") from err
113+
114+
77115
@dataclass
78116
class SleepIQData:
79117
"""Data for the sleepiq integration."""
80118

81119
data_coordinator: SleepIQDataUpdateCoordinator
82120
pause_coordinator: SleepIQPauseUpdateCoordinator
121+
sleep_data_coordinator: SleepIQSleepDataCoordinator
83122
client: AsyncSleepIQ

homeassistant/components/sleepiq/entity.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,17 @@
1111
from homeassistant.helpers.update_coordinator import CoordinatorEntity
1212

1313
from .const import ENTITY_TYPES, ICON_OCCUPIED
14-
from .coordinator import SleepIQDataUpdateCoordinator, SleepIQPauseUpdateCoordinator
15-
16-
type _DataCoordinatorType = SleepIQDataUpdateCoordinator | SleepIQPauseUpdateCoordinator
14+
from .coordinator import (
15+
SleepIQDataUpdateCoordinator,
16+
SleepIQPauseUpdateCoordinator,
17+
SleepIQSleepDataCoordinator,
18+
)
19+
20+
type _DataCoordinatorType = (
21+
SleepIQDataUpdateCoordinator
22+
| SleepIQPauseUpdateCoordinator
23+
| SleepIQSleepDataCoordinator
24+
)
1725

1826

1927
def device_from_bed(bed: SleepIQBed) -> DeviceInfo:
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"entity": {
3+
"sensor": {
4+
"heart_rate_avg": {
5+
"default": "mdi:heart-pulse"
6+
},
7+
"hrv": {
8+
"default": "mdi:heart-flash"
9+
},
10+
"respiratory_rate_avg": {
11+
"default": "mdi:lungs"
12+
},
13+
"sleep_duration": {
14+
"default": "mdi:sleep"
15+
},
16+
"sleep_score": {
17+
"default": "mdi:sleep"
18+
}
19+
}
20+
}
21+
}

homeassistant/components/sleepiq/sensor.py

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,31 @@
88
from asyncsleepiq import SleepIQBed, SleepIQSleeper
99

1010
from homeassistant.components.sensor import (
11+
SensorDeviceClass,
1112
SensorEntity,
1213
SensorEntityDescription,
1314
SensorStateClass,
1415
)
1516
from homeassistant.config_entries import ConfigEntry
17+
from homeassistant.const import UnitOfTime
1618
from homeassistant.core import HomeAssistant, callback
1719
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
1820

19-
from .const import DOMAIN, PRESSURE, SLEEP_NUMBER
20-
from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator
21+
from .const import (
22+
DOMAIN,
23+
HEART_RATE,
24+
HRV,
25+
PRESSURE,
26+
RESPIRATORY_RATE,
27+
SLEEP_DURATION,
28+
SLEEP_NUMBER,
29+
SLEEP_SCORE,
30+
)
31+
from .coordinator import (
32+
SleepIQData,
33+
SleepIQDataUpdateCoordinator,
34+
SleepIQSleepDataCoordinator,
35+
)
2136
from .entity import SleepIQSleeperEntity
2237

2338

@@ -28,7 +43,7 @@ class SleepIQSensorEntityDescription(SensorEntityDescription):
2843
value_fn: Callable[[SleepIQSleeper], float | int | None]
2944

3045

31-
SENSORS: tuple[SleepIQSensorEntityDescription, ...] = (
46+
BED_SENSORS: tuple[SleepIQSensorEntityDescription, ...] = (
3247
SleepIQSensorEntityDescription(
3348
key=PRESSURE,
3449
translation_key="pressure",
@@ -43,6 +58,57 @@ class SleepIQSensorEntityDescription(SensorEntityDescription):
4358
),
4459
)
4560

61+
SLEEP_HEALTH_SENSORS: tuple[SleepIQSensorEntityDescription, ...] = (
62+
SleepIQSensorEntityDescription(
63+
key=SLEEP_SCORE,
64+
translation_key="sleep_score",
65+
state_class=SensorStateClass.MEASUREMENT,
66+
native_unit_of_measurement="score",
67+
value_fn=lambda sleeper: (
68+
sleeper.sleep_data.sleep_score if sleeper.sleep_data else None
69+
),
70+
),
71+
SleepIQSensorEntityDescription(
72+
key=SLEEP_DURATION,
73+
translation_key="sleep_duration",
74+
device_class=SensorDeviceClass.DURATION,
75+
state_class=SensorStateClass.MEASUREMENT,
76+
native_unit_of_measurement=UnitOfTime.HOURS,
77+
suggested_display_precision=1,
78+
value_fn=lambda sleeper: (
79+
round(sleeper.sleep_data.duration / 3600, 1)
80+
if sleeper.sleep_data and sleeper.sleep_data.duration
81+
else None
82+
),
83+
),
84+
SleepIQSensorEntityDescription(
85+
key=HEART_RATE,
86+
translation_key="heart_rate_avg",
87+
state_class=SensorStateClass.MEASUREMENT,
88+
native_unit_of_measurement="bpm",
89+
value_fn=lambda sleeper: (
90+
sleeper.sleep_data.heart_rate if sleeper.sleep_data else None
91+
),
92+
),
93+
SleepIQSensorEntityDescription(
94+
key=RESPIRATORY_RATE,
95+
translation_key="respiratory_rate_avg",
96+
state_class=SensorStateClass.MEASUREMENT,
97+
native_unit_of_measurement="brpm",
98+
value_fn=lambda sleeper: (
99+
sleeper.sleep_data.respiratory_rate if sleeper.sleep_data else None
100+
),
101+
),
102+
SleepIQSensorEntityDescription(
103+
key=HRV,
104+
translation_key="hrv",
105+
device_class=SensorDeviceClass.DURATION,
106+
state_class=SensorStateClass.MEASUREMENT,
107+
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
108+
value_fn=lambda sleeper: sleeper.sleep_data.hrv if sleeper.sleep_data else None,
109+
),
110+
)
111+
46112

47113
async def async_setup_entry(
48114
hass: HomeAssistant,
@@ -51,24 +117,37 @@ async def async_setup_entry(
51117
) -> None:
52118
"""Set up the SleepIQ bed sensors."""
53119
data: SleepIQData = hass.data[DOMAIN][entry.entry_id]
54-
async_add_entities(
120+
121+
entities: list[SensorEntity] = []
122+
123+
entities.extend(
55124
SleepIQSensorEntity(data.data_coordinator, bed, sleeper, description)
56125
for bed in data.client.beds.values()
57126
for sleeper in bed.sleepers
58-
for description in SENSORS
127+
for description in BED_SENSORS
59128
)
60129

130+
entities.extend(
131+
SleepIQSensorEntity(data.sleep_data_coordinator, bed, sleeper, description)
132+
for bed in data.client.beds.values()
133+
for sleeper in bed.sleepers
134+
for description in SLEEP_HEALTH_SENSORS
135+
)
136+
137+
async_add_entities(entities)
138+
61139

62140
class SleepIQSensorEntity(
63-
SleepIQSleeperEntity[SleepIQDataUpdateCoordinator], SensorEntity
141+
SleepIQSleeperEntity[SleepIQDataUpdateCoordinator | SleepIQSleepDataCoordinator],
142+
SensorEntity,
64143
):
65144
"""Representation of a SleepIQ sensor."""
66145

67146
entity_description: SleepIQSensorEntityDescription
68147

69148
def __init__(
70149
self,
71-
coordinator: SleepIQDataUpdateCoordinator,
150+
coordinator: SleepIQDataUpdateCoordinator | SleepIQSleepDataCoordinator,
72151
bed: SleepIQBed,
73152
sleeper: SleepIQSleeper,
74153
description: SleepIQSensorEntityDescription,

tests/components/sleepiq/conftest.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
CoreTemps,
1111
FootWarmingTemps,
1212
Side,
13+
SleepData,
1314
SleepIQActuator,
1415
SleepIQBed,
1516
SleepIQCoreClimate,
@@ -76,13 +77,27 @@ def mock_bed() -> MagicMock:
7677
sleeper_l.sleep_number = 40
7778
sleeper_l.pressure = 1000
7879
sleeper_l.sleeper_id = SLEEPER_L_ID
80+
sleeper_l.sleep_data = SleepData(
81+
duration=28800, # 8 hours in seconds
82+
sleep_score=85,
83+
heart_rate=60,
84+
respiratory_rate=14,
85+
hrv=68,
86+
)
7987

8088
sleeper_r.side = Side.RIGHT
8189
sleeper_r.name = SLEEPER_R_NAME
8290
sleeper_r.in_bed = False
8391
sleeper_r.sleep_number = 80
8492
sleeper_r.pressure = 1400
8593
sleeper_r.sleeper_id = SLEEPER_R_ID
94+
sleeper_r.sleep_data = SleepData(
95+
duration=25200, # 7 hours in seconds
96+
sleep_score=78,
97+
heart_rate=65,
98+
respiratory_rate=15,
99+
hrv=72,
100+
)
86101

87102
bed.foundation = create_autospec(SleepIQFoundation)
88103
light_1 = create_autospec(SleepIQLight)

0 commit comments

Comments
 (0)