SleepIQ add core climate for SleepNumber Climate 360 beds (#134718)

This commit is contained in:
David Ferguson 2025-07-21 08:14:33 -04:00 committed by GitHub
parent 875219ccb5
commit 2d86fa079e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 261 additions and 3 deletions

View File

@ -4,6 +4,8 @@ DATA_SLEEPIQ = "data_sleepiq"
DOMAIN = "sleepiq"
ACTUATOR = "actuator"
CORE_CLIMATE_TIMER = "core_climate_timer"
CORE_CLIMATE = "core_climate"
BED = "bed"
FIRMNESS = "firmness"
ICON_EMPTY = "mdi:bed-empty"
@ -15,6 +17,8 @@ FOOT_WARMING_TIMER = "foot_warming_timer"
FOOT_WARMER = "foot_warmer"
ENTITY_TYPES = {
ACTUATOR: "Position",
CORE_CLIMATE_TIMER: "Core Climate Timer",
CORE_CLIMATE: "Core Climate",
FIRMNESS: "Firmness",
PRESSURE: "Pressure",
IS_IN_BED: "Is In Bed",

View File

@ -7,20 +7,28 @@ from dataclasses import dataclass
from typing import Any, cast
from asyncsleepiq import (
CoreTemps,
FootWarmingTemps,
SleepIQActuator,
SleepIQBed,
SleepIQCoreClimate,
SleepIQFootWarmer,
SleepIQSleeper,
)
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ACTUATOR,
CORE_CLIMATE_TIMER,
DOMAIN,
ENTITY_TYPES,
FIRMNESS,
@ -95,6 +103,27 @@ def _get_foot_warming_unique_id(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer)
return f"{bed.id}_{foot_warmer.side.value}_{FOOT_WARMING_TIMER}"
async def _async_set_core_climate_time(
core_climate: SleepIQCoreClimate, time: int
) -> None:
temperature = CoreTemps(core_climate.temperature)
if temperature != CoreTemps.OFF:
await core_climate.turn_on(temperature, time)
core_climate.timer = time
def _get_core_climate_name(bed: SleepIQBed, core_climate: SleepIQCoreClimate) -> str:
sleeper = sleeper_for_side(bed, core_climate.side)
return f"SleepNumber {bed.name} {sleeper.name} {ENTITY_TYPES[CORE_CLIMATE_TIMER]}"
def _get_core_climate_unique_id(
bed: SleepIQBed, core_climate: SleepIQCoreClimate
) -> str:
return f"{bed.id}_{core_climate.side.value}_{CORE_CLIMATE_TIMER}"
NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = {
FIRMNESS: SleepIQNumberEntityDescription(
key=FIRMNESS,
@ -132,6 +161,20 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = {
get_name_fn=_get_foot_warming_name,
get_unique_id_fn=_get_foot_warming_unique_id,
),
CORE_CLIMATE_TIMER: SleepIQNumberEntityDescription(
key=CORE_CLIMATE_TIMER,
native_min_value=0,
native_max_value=600,
native_step=30,
name=ENTITY_TYPES[CORE_CLIMATE_TIMER],
icon="mdi:timer",
value_fn=lambda core_climate: core_climate.timer,
set_value_fn=_async_set_core_climate_time,
get_name_fn=_get_core_climate_name,
get_unique_id_fn=_get_core_climate_unique_id,
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=NumberDeviceClass.DURATION,
),
}
@ -172,6 +215,15 @@ async def async_setup_entry(
)
for foot_warmer in bed.foundation.foot_warmers
)
entities.extend(
SleepIQNumberEntity(
data.data_coordinator,
bed,
core_climate,
NUMBER_DESCRIPTIONS[CORE_CLIMATE_TIMER],
)
for core_climate in bed.foundation.core_climates
)
async_add_entities(entities)

View File

@ -3,9 +3,11 @@
from __future__ import annotations
from asyncsleepiq import (
CoreTemps,
FootWarmingTemps,
Side,
SleepIQBed,
SleepIQCoreClimate,
SleepIQFootWarmer,
SleepIQPreset,
)
@ -15,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, FOOT_WARMER
from .const import CORE_CLIMATE, DOMAIN, FOOT_WARMER
from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator
from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side
@ -37,6 +39,10 @@ async def async_setup_entry(
SleepIQFootWarmingTempSelectEntity(data.data_coordinator, bed, foot_warmer)
for foot_warmer in bed.foundation.foot_warmers
)
entities.extend(
SleepIQCoreTempSelectEntity(data.data_coordinator, bed, core_climate)
for core_climate in bed.foundation.core_climates
)
async_add_entities(entities)
@ -115,3 +121,57 @@ class SleepIQFootWarmingTempSelectEntity(
self._attr_current_option = option
await self.coordinator.async_request_refresh()
self.async_write_ha_state()
class SleepIQCoreTempSelectEntity(
SleepIQSleeperEntity[SleepIQDataUpdateCoordinator], SelectEntity
):
"""Representation of a SleepIQ core climate temperature select entity."""
# Maps to translate between asyncsleepiq and HA's naming preference
SLEEPIQ_TO_HA_CORE_TEMP_MAP = {
CoreTemps.OFF: "off",
CoreTemps.HEATING_PUSH_LOW: "heating_low",
CoreTemps.HEATING_PUSH_MED: "heating_medium",
CoreTemps.HEATING_PUSH_HIGH: "heating_high",
CoreTemps.COOLING_PULL_LOW: "cooling_low",
CoreTemps.COOLING_PULL_MED: "cooling_medium",
CoreTemps.COOLING_PULL_HIGH: "cooling_high",
}
HA_TO_SLEEPIQ_CORE_TEMP_MAP = {v: k for k, v in SLEEPIQ_TO_HA_CORE_TEMP_MAP.items()}
_attr_icon = "mdi:heat-wave"
_attr_options = list(SLEEPIQ_TO_HA_CORE_TEMP_MAP.values())
_attr_translation_key = "core_temps"
def __init__(
self,
coordinator: SleepIQDataUpdateCoordinator,
bed: SleepIQBed,
core_climate: SleepIQCoreClimate,
) -> None:
"""Initialize the select entity."""
self.core_climate = core_climate
sleeper = sleeper_for_side(bed, core_climate.side)
super().__init__(coordinator, bed, sleeper, CORE_CLIMATE)
self._async_update_attrs()
@callback
def _async_update_attrs(self) -> None:
"""Update entity attributes."""
sleepiq_option = CoreTemps(self.core_climate.temperature)
self._attr_current_option = self.SLEEPIQ_TO_HA_CORE_TEMP_MAP[sleepiq_option]
async def async_select_option(self, option: str) -> None:
"""Change the current preset."""
temperature = self.HA_TO_SLEEPIQ_CORE_TEMP_MAP[option]
timer = self.core_climate.timer or 240
if temperature == CoreTemps.OFF:
await self.core_climate.turn_off()
else:
await self.core_climate.turn_on(temperature, timer)
self._attr_current_option = option
await self.coordinator.async_request_refresh()
self.async_write_ha_state()

View File

@ -33,6 +33,17 @@
"medium": "[%key:common::state::medium%]",
"high": "[%key:common::state::high%]"
}
},
"core_temps": {
"state": {
"off": "[%key:common::state::off%]",
"heating_low": "Heating low",
"heating_medium": "Heating medium",
"heating_high": "Heating high",
"cooling_low": "Cooling low",
"cooling_medium": "Cooling medium",
"cooling_high": "Cooling high"
}
}
}
}

View File

@ -7,10 +7,12 @@ from unittest.mock import AsyncMock, MagicMock, create_autospec, patch
from asyncsleepiq import (
BED_PRESETS,
CoreTemps,
FootWarmingTemps,
Side,
SleepIQActuator,
SleepIQBed,
SleepIQCoreClimate,
SleepIQFootWarmer,
SleepIQFoundation,
SleepIQLight,
@ -29,6 +31,7 @@ from tests.common import MockConfigEntry
BED_ID = "123456"
BED_NAME = "Test Bed"
BED_NAME_LOWER = BED_NAME.lower().replace(" ", "_")
CORE_CLIMATE_TIME = 240
SLEEPER_L_ID = "98765"
SLEEPER_R_ID = "43219"
SLEEPER_L_NAME = "SleeperL"
@ -91,6 +94,7 @@ def mock_bed() -> MagicMock:
bed.foundation.lights = [light_1, light_2]
bed.foundation.foot_warmers = []
bed.foundation.core_climates = []
return bed
@ -127,6 +131,7 @@ def mock_asyncsleepiq_single_foundation(
preset.options = BED_PRESETS
mock_bed.foundation.foot_warmers = []
mock_bed.foundation.core_climates = []
yield client
@ -185,6 +190,18 @@ def mock_asyncsleepiq(mock_bed: MagicMock) -> Generator[MagicMock]:
foot_warmer_r.timer = FOOT_WARM_TIME
foot_warmer_r.temperature = FootWarmingTemps.OFF
core_climate_l = create_autospec(SleepIQCoreClimate)
core_climate_r = create_autospec(SleepIQCoreClimate)
mock_bed.foundation.core_climates = [core_climate_l, core_climate_r]
core_climate_l.side = Side.LEFT
core_climate_l.timer = CORE_CLIMATE_TIME
core_climate_l.temperature = CoreTemps.COOLING_PULL_MED
core_climate_r.side = Side.RIGHT
core_climate_r.timer = CORE_CLIMATE_TIME
core_climate_r.temperature = CoreTemps.OFF
yield client

View File

@ -198,3 +198,42 @@ async def test_foot_warmer_timer(
await hass.async_block_till_done()
assert mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[0].timer == 300
async def test_core_climate_timer(
hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq
) -> None:
"""Test the SleepIQ core climate number values for a bed with two sides."""
entry = await setup_platform(hass, NUMBER_DOMAIN)
state = hass.states.get(
f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer"
)
assert state.state == "240.0"
assert state.attributes.get(ATTR_ICON) == "mdi:timer"
assert state.attributes.get(ATTR_MIN) == 0
assert state.attributes.get(ATTR_MAX) == 600
assert state.attributes.get(ATTR_STEP) == 30
assert (
state.attributes.get(ATTR_FRIENDLY_NAME)
== f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Core Climate Timer"
)
entry = entity_registry.async_get(
f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer"
)
assert entry
assert entry.unique_id == f"{BED_ID}_L_core_climate_timer"
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer",
ATTR_VALUE: 420,
},
blocking=True,
)
await hass.async_block_till_done()
assert mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[0].timer == 420

View File

@ -2,7 +2,7 @@
from unittest.mock import MagicMock
from asyncsleepiq import FootWarmingTemps
from asyncsleepiq import CoreTemps, FootWarmingTemps
from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
@ -21,6 +21,7 @@ from .conftest import (
BED_ID,
BED_NAME,
BED_NAME_LOWER,
CORE_CLIMATE_TIME,
FOOT_WARM_TIME,
PRESET_L_STATE,
PRESET_R_STATE,
@ -204,3 +205,77 @@ async def test_foot_warmer(
mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[
1
].turn_on.assert_called_with(FootWarmingTemps.HIGH, FOOT_WARM_TIME)
async def test_core_climate(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_asyncsleepiq: MagicMock,
) -> None:
"""Test the SleepIQ select entity for core climate."""
entry = await setup_platform(hass, SELECT_DOMAIN)
state = hass.states.get(
f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate"
)
assert state.state == "cooling_medium"
assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave"
assert (
state.attributes.get(ATTR_FRIENDLY_NAME)
== f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Core Climate"
)
entry = entity_registry.async_get(
f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate"
)
assert entry
assert entry.unique_id == f"{SLEEPER_L_ID}_core_climate"
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate",
ATTR_OPTION: "off",
},
blocking=True,
)
await hass.async_block_till_done()
mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[
0
].turn_off.assert_called_once()
state = hass.states.get(
f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate"
)
assert state.state == CoreTemps.OFF.name.lower()
assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave"
assert (
state.attributes.get(ATTR_FRIENDLY_NAME)
== f"SleepNumber {BED_NAME} {SLEEPER_R_NAME} Core Climate"
)
entry = entity_registry.async_get(
f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate"
)
assert entry
assert entry.unique_id == f"{SLEEPER_R_ID}_core_climate"
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate",
ATTR_OPTION: "heating_high",
},
blocking=True,
)
await hass.async_block_till_done()
mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[
1
].turn_on.assert_called_once()
mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[
1
].turn_on.assert_called_with(CoreTemps.HEATING_PUSH_HIGH, CORE_CLIMATE_TIME)