From 615cd56f035c901c81bc7592d9504505d120cbbc Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Tue, 26 Dec 2023 17:31:00 -0500 Subject: [PATCH] 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 --- homeassistant/components/sleepiq/const.py | 4 + homeassistant/components/sleepiq/entity.py | 8 ++ homeassistant/components/sleepiq/number.py | 51 +++++++++++- homeassistant/components/sleepiq/select.py | 71 ++++++++++++++-- homeassistant/components/sleepiq/strings.json | 12 +++ tests/components/sleepiq/conftest.py | 18 +++++ tests/components/sleepiq/test_number.py | 38 +++++++++ tests/components/sleepiq/test_select.py | 80 +++++++++++++++++++ 8 files changed, 271 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sleepiq/const.py b/homeassistant/components/sleepiq/const.py index 4eb6148f9b8..4243684cd52 100644 --- a/homeassistant/components/sleepiq/const.py +++ b/homeassistant/components/sleepiq/const.py @@ -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" diff --git a/homeassistant/components/sleepiq/entity.py b/homeassistant/components/sleepiq/entity.py index 38d8eb32051..9a0342aa7ac 100644 --- a/homeassistant/components/sleepiq/entity.py +++ b/homeassistant/components/sleepiq/entity.py @@ -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.""" diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index b1819d7088d..520e11bb331 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -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) diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index 1609dc2e116..df8d854c9da 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -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() diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index 7a9a4c58464..bdafbfb6c77 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -23,5 +23,17 @@ } } } + }, + "entity": { + "select": { + "foot_warmer_temp": { + "state": { + "off": "Off", + "low": "Low", + "medium": "Medium", + "high": "High" + } + } + } } } diff --git a/tests/components/sleepiq/conftest.py b/tests/components/sleepiq/conftest.py index 05104546f0d..58718edcafb 100644 --- a/tests/components/sleepiq/conftest.py +++ b/tests/components/sleepiq/conftest.py @@ -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 diff --git a/tests/components/sleepiq/test_number.py b/tests/components/sleepiq/test_number.py index fe03a4d9c3f..4676cf94174 100644 --- a/tests/components/sleepiq/test_number.py +++ b/tests/components/sleepiq/test_number.py @@ -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 diff --git a/tests/components/sleepiq/test_select.py b/tests/components/sleepiq/test_select.py index d0e2a0e828d..c4ec3896bd7 100644 --- a/tests/components/sleepiq/test_select.py +++ b/tests/components/sleepiq/test_select.py @@ -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)