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
Keilin Bickar 2023-12-26 17:31:00 -05:00 committed by GitHub
parent 51a50fc134
commit 615cd56f03
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 271 additions and 11 deletions

View File

@ -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"

View File

@ -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."""

View File

@ -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)

View File

@ -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()

View File

@ -23,5 +23,17 @@
}
}
}
},
"entity": {
"select": {
"foot_warmer_temp": {
"state": {
"off": "Off",
"low": "Low",
"medium": "Medium",
"high": "High"
}
}
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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)