Add Support for SleepIQ Foot Warmers (#105931)
* Add foot warmer support * Add Tests for foot warmers * Move attr options out of constructor * Change options to lowercase * Update test and translations * Switch back to entity * Update homeassistant/components/sleepiq/strings.json --------- Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>pull/105955/head
parent
51a50fc134
commit
615cd56f03
|
@ -11,12 +11,16 @@ ICON_OCCUPIED = "mdi:bed"
|
|||
IS_IN_BED = "is_in_bed"
|
||||
PRESSURE = "pressure"
|
||||
SLEEP_NUMBER = "sleep_number"
|
||||
FOOT_WARMING_TIMER = "foot_warming_timer"
|
||||
FOOT_WARMER = "foot_warmer"
|
||||
ENTITY_TYPES = {
|
||||
ACTUATOR: "Position",
|
||||
FIRMNESS: "Firmness",
|
||||
PRESSURE: "Pressure",
|
||||
IS_IN_BED: "Is In Bed",
|
||||
SLEEP_NUMBER: "SleepNumber",
|
||||
FOOT_WARMING_TIMER: "Foot Warming Timer",
|
||||
FOOT_WARMER: "Foot Warmer",
|
||||
}
|
||||
|
||||
LEFT = "left"
|
||||
|
|
|
@ -29,6 +29,14 @@ def device_from_bed(bed: SleepIQBed) -> DeviceInfo:
|
|||
)
|
||||
|
||||
|
||||
def sleeper_for_side(bed: SleepIQBed, side: str) -> SleepIQSleeper:
|
||||
"""Find the sleeper for a side or the first sleeper."""
|
||||
for sleeper in bed.sleepers:
|
||||
if sleeper.side == side:
|
||||
return sleeper
|
||||
return bed.sleepers[0]
|
||||
|
||||
|
||||
class SleepIQEntity(Entity):
|
||||
"""Implementation of a SleepIQ entity."""
|
||||
|
||||
|
|
|
@ -5,16 +5,23 @@ from collections.abc import Callable, Coroutine
|
|||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
from asyncsleepiq import SleepIQActuator, SleepIQBed, SleepIQSleeper
|
||||
from asyncsleepiq import SleepIQActuator, SleepIQBed, SleepIQFootWarmer, SleepIQSleeper
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import ACTUATOR, DOMAIN, ENTITY_TYPES, FIRMNESS, ICON_OCCUPIED
|
||||
from .const import (
|
||||
ACTUATOR,
|
||||
DOMAIN,
|
||||
ENTITY_TYPES,
|
||||
FIRMNESS,
|
||||
FOOT_WARMING_TIMER,
|
||||
ICON_OCCUPIED,
|
||||
)
|
||||
from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator
|
||||
from .entity import SleepIQBedEntity
|
||||
from .entity import SleepIQBedEntity, sleeper_for_side
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
@ -69,6 +76,21 @@ def _get_sleeper_unique_id(bed: SleepIQBed, sleeper: SleepIQSleeper) -> str:
|
|||
return f"{sleeper.sleeper_id}_{FIRMNESS}"
|
||||
|
||||
|
||||
async def _async_set_foot_warmer_time(
|
||||
foot_warmer: SleepIQFootWarmer, time: int
|
||||
) -> None:
|
||||
foot_warmer.timer = time
|
||||
|
||||
|
||||
def _get_foot_warming_name(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer) -> str:
|
||||
sleeper = sleeper_for_side(bed, foot_warmer.side)
|
||||
return f"SleepNumber {bed.name} {sleeper.name} {ENTITY_TYPES[FOOT_WARMING_TIMER]}"
|
||||
|
||||
|
||||
def _get_foot_warming_unique_id(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer) -> str:
|
||||
return f"{bed.id}_{foot_warmer.side.value}_{FOOT_WARMING_TIMER}"
|
||||
|
||||
|
||||
NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = {
|
||||
FIRMNESS: SleepIQNumberEntityDescription(
|
||||
key=FIRMNESS,
|
||||
|
@ -94,6 +116,18 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = {
|
|||
get_name_fn=_get_actuator_name,
|
||||
get_unique_id_fn=_get_actuator_unique_id,
|
||||
),
|
||||
FOOT_WARMING_TIMER: SleepIQNumberEntityDescription(
|
||||
key=FOOT_WARMING_TIMER,
|
||||
native_min_value=30,
|
||||
native_max_value=360,
|
||||
native_step=30,
|
||||
name=ENTITY_TYPES[FOOT_WARMING_TIMER],
|
||||
icon="mdi:timer",
|
||||
value_fn=lambda foot_warmer: foot_warmer.timer,
|
||||
set_value_fn=_async_set_foot_warmer_time,
|
||||
get_name_fn=_get_foot_warming_name,
|
||||
get_unique_id_fn=_get_foot_warming_unique_id,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
@ -125,6 +159,15 @@ async def async_setup_entry(
|
|||
NUMBER_DESCRIPTIONS[ACTUATOR],
|
||||
)
|
||||
)
|
||||
for foot_warmer in bed.foundation.foot_warmers:
|
||||
entities.append(
|
||||
SleepIQNumberEntity(
|
||||
data.data_coordinator,
|
||||
bed,
|
||||
foot_warmer,
|
||||
NUMBER_DESCRIPTIONS[FOOT_WARMING_TIMER],
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
@ -148,6 +191,8 @@ class SleepIQNumberEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], Number
|
|||
|
||||
self._attr_name = description.get_name_fn(bed, device)
|
||||
self._attr_unique_id = description.get_unique_id_fn(bed, device)
|
||||
if description.icon:
|
||||
self._attr_icon = description.icon
|
||||
|
||||
super().__init__(coordinator, bed)
|
||||
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
"""Support for SleepIQ foundation preset selection."""
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncsleepiq import Side, SleepIQBed, SleepIQPreset
|
||||
from asyncsleepiq import (
|
||||
FootWarmingTemps,
|
||||
Side,
|
||||
SleepIQBed,
|
||||
SleepIQFootWarmer,
|
||||
SleepIQPreset,
|
||||
)
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, FOOT_WARMER
|
||||
from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator
|
||||
from .entity import SleepIQBedEntity
|
||||
from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
@ -20,11 +26,17 @@ async def async_setup_entry(
|
|||
) -> None:
|
||||
"""Set up the SleepIQ foundation preset select entities."""
|
||||
data: SleepIQData = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
SleepIQSelectEntity(data.data_coordinator, bed, preset)
|
||||
for bed in data.client.beds.values()
|
||||
for preset in bed.foundation.presets
|
||||
)
|
||||
entities: list[SleepIQBedEntity] = []
|
||||
for bed in data.client.beds.values():
|
||||
for preset in bed.foundation.presets:
|
||||
entities.append(SleepIQSelectEntity(data.data_coordinator, bed, preset))
|
||||
for foot_warmer in bed.foundation.foot_warmers:
|
||||
entities.append(
|
||||
SleepIQFootWarmingTempSelectEntity(
|
||||
data.data_coordinator, bed, foot_warmer
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class SleepIQSelectEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], SelectEntity):
|
||||
|
@ -59,3 +71,46 @@ class SleepIQSelectEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], Select
|
|||
await self.preset.set_preset(option)
|
||||
self._attr_current_option = option
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class SleepIQFootWarmingTempSelectEntity(
|
||||
SleepIQSleeperEntity[SleepIQDataUpdateCoordinator], SelectEntity
|
||||
):
|
||||
"""Representation of a SleepIQ foot warming temperature select entity."""
|
||||
|
||||
_attr_icon = "mdi:heat-wave"
|
||||
_attr_options = [e.name.lower() for e in FootWarmingTemps]
|
||||
_attr_translation_key = "foot_warmer_temp"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: SleepIQDataUpdateCoordinator,
|
||||
bed: SleepIQBed,
|
||||
foot_warmer: SleepIQFootWarmer,
|
||||
) -> None:
|
||||
"""Initialize the select entity."""
|
||||
self.foot_warmer = foot_warmer
|
||||
sleeper = sleeper_for_side(bed, foot_warmer.side)
|
||||
super().__init__(coordinator, bed, sleeper, FOOT_WARMER)
|
||||
self._async_update_attrs()
|
||||
|
||||
@callback
|
||||
def _async_update_attrs(self) -> None:
|
||||
"""Update entity attributes."""
|
||||
self._attr_current_option = FootWarmingTemps(
|
||||
self.foot_warmer.temperature
|
||||
).name.lower()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the current preset."""
|
||||
temperature = FootWarmingTemps[option.upper()]
|
||||
timer = self.foot_warmer.timer or 120
|
||||
|
||||
if temperature == 0:
|
||||
await self.foot_warmer.turn_off()
|
||||
else:
|
||||
await self.foot_warmer.turn_on(temperature, timer)
|
||||
|
||||
self._attr_current_option = option
|
||||
await self.coordinator.async_request_refresh()
|
||||
self.async_write_ha_state()
|
||||
|
|
|
@ -23,5 +23,17 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"select": {
|
||||
"foot_warmer_temp": {
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,9 +6,11 @@ from unittest.mock import AsyncMock, MagicMock, create_autospec, patch
|
|||
|
||||
from asyncsleepiq import (
|
||||
BED_PRESETS,
|
||||
FootWarmingTemps,
|
||||
Side,
|
||||
SleepIQActuator,
|
||||
SleepIQBed,
|
||||
SleepIQFootWarmer,
|
||||
SleepIQFoundation,
|
||||
SleepIQLight,
|
||||
SleepIQPreset,
|
||||
|
@ -34,6 +36,7 @@ SLEEPER_L_NAME_LOWER = SLEEPER_L_NAME.lower().replace(" ", "_")
|
|||
SLEEPER_R_NAME_LOWER = SLEEPER_R_NAME.lower().replace(" ", "_")
|
||||
PRESET_L_STATE = "Watch TV"
|
||||
PRESET_R_STATE = "Flat"
|
||||
FOOT_WARM_TIME = 120
|
||||
|
||||
SLEEPIQ_CONFIG = {
|
||||
CONF_USERNAME: "user@email.com",
|
||||
|
@ -86,6 +89,7 @@ def mock_bed() -> MagicMock:
|
|||
light_2.is_on = False
|
||||
bed.foundation.lights = [light_1, light_2]
|
||||
|
||||
bed.foundation.foot_warmers = []
|
||||
return bed
|
||||
|
||||
|
||||
|
@ -120,6 +124,8 @@ def mock_asyncsleepiq_single_foundation(
|
|||
preset.side = Side.NONE
|
||||
preset.side_full = "Right"
|
||||
preset.options = BED_PRESETS
|
||||
|
||||
mock_bed.foundation.foot_warmers = []
|
||||
yield client
|
||||
|
||||
|
||||
|
@ -166,6 +172,18 @@ def mock_asyncsleepiq(mock_bed: MagicMock) -> Generator[MagicMock, None, None]:
|
|||
preset_r.side_full = "Right"
|
||||
preset_r.options = BED_PRESETS
|
||||
|
||||
foot_warmer_l = create_autospec(SleepIQFootWarmer)
|
||||
foot_warmer_r = create_autospec(SleepIQFootWarmer)
|
||||
mock_bed.foundation.foot_warmers = [foot_warmer_l, foot_warmer_r]
|
||||
|
||||
foot_warmer_l.side = Side.LEFT
|
||||
foot_warmer_l.timer = FOOT_WARM_TIME
|
||||
foot_warmer_l.temperature = FootWarmingTemps.MEDIUM
|
||||
|
||||
foot_warmer_r.side = Side.RIGHT
|
||||
foot_warmer_r.timer = FOOT_WARM_TIME
|
||||
foot_warmer_r.temperature = FootWarmingTemps.OFF
|
||||
|
||||
yield client
|
||||
|
||||
|
||||
|
|
|
@ -156,3 +156,41 @@ async def test_actuators(hass: HomeAssistant, mock_asyncsleepiq) -> None:
|
|||
mock_asyncsleepiq.beds[BED_ID].foundation.actuators[
|
||||
0
|
||||
].set_position.assert_called_with(42)
|
||||
|
||||
|
||||
async def test_foot_warmer_timer(hass: HomeAssistant, mock_asyncsleepiq) -> None:
|
||||
"""Test the SleepIQ foot warmer number values for a bed with two sides."""
|
||||
entry = await setup_platform(hass, DOMAIN)
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
state = hass.states.get(
|
||||
f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warming_timer"
|
||||
)
|
||||
assert state.state == "120.0"
|
||||
assert state.attributes.get(ATTR_ICON) == "mdi:timer"
|
||||
assert state.attributes.get(ATTR_MIN) == 30
|
||||
assert state.attributes.get(ATTR_MAX) == 360
|
||||
assert state.attributes.get(ATTR_STEP) == 30
|
||||
assert (
|
||||
state.attributes.get(ATTR_FRIENDLY_NAME)
|
||||
== f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Foot Warming Timer"
|
||||
)
|
||||
|
||||
entry = entity_registry.async_get(
|
||||
f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warming_timer"
|
||||
)
|
||||
assert entry
|
||||
assert entry.unique_id == f"{BED_ID}_L_foot_warming_timer"
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
{
|
||||
ATTR_ENTITY_ID: f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warming_timer",
|
||||
ATTR_VALUE: 300,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[0].timer == 300
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
"""Tests for the SleepIQ select platform."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from asyncsleepiq import FootWarmingTemps
|
||||
|
||||
from homeassistant.components.select import DOMAIN, SERVICE_SELECT_OPTION
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
|
@ -15,8 +17,15 @@ from .conftest import (
|
|||
BED_ID,
|
||||
BED_NAME,
|
||||
BED_NAME_LOWER,
|
||||
FOOT_WARM_TIME,
|
||||
PRESET_L_STATE,
|
||||
PRESET_R_STATE,
|
||||
SLEEPER_L_ID,
|
||||
SLEEPER_L_NAME,
|
||||
SLEEPER_L_NAME_LOWER,
|
||||
SLEEPER_R_ID,
|
||||
SLEEPER_R_NAME,
|
||||
SLEEPER_R_NAME_LOWER,
|
||||
setup_platform,
|
||||
)
|
||||
|
||||
|
@ -115,3 +124,74 @@ async def test_single_foundation_preset(
|
|||
mock_asyncsleepiq_single_foundation.beds[BED_ID].foundation.presets[
|
||||
0
|
||||
].set_preset.assert_called_with("Zero G")
|
||||
|
||||
|
||||
async def test_foot_warmer(hass: HomeAssistant, mock_asyncsleepiq: MagicMock) -> None:
|
||||
"""Test the SleepIQ select entity for foot warmers."""
|
||||
entry = await setup_platform(hass, DOMAIN)
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
state = hass.states.get(
|
||||
f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warmer"
|
||||
)
|
||||
assert state.state == FootWarmingTemps.MEDIUM.name.lower()
|
||||
assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave"
|
||||
assert (
|
||||
state.attributes.get(ATTR_FRIENDLY_NAME)
|
||||
== f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Foot Warmer"
|
||||
)
|
||||
|
||||
entry = entity_registry.async_get(
|
||||
f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warmer"
|
||||
)
|
||||
assert entry
|
||||
assert entry.unique_id == f"{SLEEPER_L_ID}_foot_warmer"
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SELECT_OPTION,
|
||||
{
|
||||
ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warmer",
|
||||
ATTR_OPTION: "off",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[
|
||||
0
|
||||
].turn_off.assert_called_once()
|
||||
|
||||
state = hass.states.get(
|
||||
f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_foot_warmer"
|
||||
)
|
||||
assert state.state == FootWarmingTemps.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} Foot Warmer"
|
||||
)
|
||||
|
||||
entry = entity_registry.async_get(
|
||||
f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_foot_warmer"
|
||||
)
|
||||
assert entry
|
||||
assert entry.unique_id == f"{SLEEPER_R_ID}_foot_warmer"
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SELECT_OPTION,
|
||||
{
|
||||
ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_foot_warmer",
|
||||
ATTR_OPTION: "high",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[
|
||||
1
|
||||
].turn_on.assert_called_once()
|
||||
mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[
|
||||
1
|
||||
].turn_on.assert_called_with(FootWarmingTemps.HIGH, FOOT_WARM_TIME)
|
||||
|
|
Loading…
Reference in New Issue