From 6831be67f4eeabe24b7ca1414c7c813581c50a5b Mon Sep 17 00:00:00 2001 From: Mike Fugate Date: Sat, 12 Mar 2022 14:58:03 -0500 Subject: [PATCH] Add number entities to control SleepIQ actuator positions (#67770) --- homeassistant/components/sleepiq/const.py | 4 +- .../components/sleepiq/coordinator.py | 8 +- homeassistant/components/sleepiq/entity.py | 4 +- homeassistant/components/sleepiq/number.py | 147 +++++++++++++++--- tests/components/sleepiq/conftest.py | 35 ++++- tests/components/sleepiq/test_number.py | 72 ++++++++- 6 files changed, 239 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/sleepiq/const.py b/homeassistant/components/sleepiq/const.py index 2aabf29ef54..4eb6148f9b8 100644 --- a/homeassistant/components/sleepiq/const.py +++ b/homeassistant/components/sleepiq/const.py @@ -3,6 +3,7 @@ DATA_SLEEPIQ = "data_sleepiq" DOMAIN = "sleepiq" +ACTUATOR = "actuator" BED = "bed" FIRMNESS = "firmness" ICON_EMPTY = "mdi:bed-empty" @@ -10,7 +11,8 @@ ICON_OCCUPIED = "mdi:bed" IS_IN_BED = "is_in_bed" PRESSURE = "pressure" SLEEP_NUMBER = "sleep_number" -SENSOR_TYPES = { +ENTITY_TYPES = { + ACTUATOR: "Position", FIRMNESS: "Firmness", PRESSURE: "Pressure", IS_IN_BED: "Is In Bed", diff --git a/homeassistant/components/sleepiq/coordinator.py b/homeassistant/components/sleepiq/coordinator.py index ef84b17ba9b..47512d23ce9 100644 --- a/homeassistant/components/sleepiq/coordinator.py +++ b/homeassistant/components/sleepiq/coordinator.py @@ -34,9 +34,11 @@ class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]): self.client = client async def _async_update_data(self) -> None: - tasks = [self.client.fetch_bed_statuses()] + [ - bed.foundation.update_lights() for bed in self.client.beds.values() - ] + tasks = ( + [self.client.fetch_bed_statuses()] + + [bed.foundation.update_lights() for bed in self.client.beds.values()] + + [bed.foundation.update_actuators() for bed in self.client.beds.values()] + ) await asyncio.gather(*tasks) diff --git a/homeassistant/components/sleepiq/entity.py b/homeassistant/components/sleepiq/entity.py index 7fa14f2cbe7..e610119e2a0 100644 --- a/homeassistant/components/sleepiq/entity.py +++ b/homeassistant/components/sleepiq/entity.py @@ -11,7 +11,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import ICON_OCCUPIED, SENSOR_TYPES +from .const import ENTITY_TYPES, ICON_OCCUPIED def device_from_bed(bed: SleepIQBed) -> DeviceInfo: @@ -77,5 +77,5 @@ class SleepIQSleeperEntity(SleepIQBedEntity): self.sleeper = sleeper super().__init__(coordinator, bed) - self._attr_name = f"SleepNumber {bed.name} {sleeper.name} {SENSOR_TYPES[name]}" + self._attr_name = f"SleepNumber {bed.name} {sleeper.name} {ENTITY_TYPES[name]}" self._attr_unique_id = f"{bed.id}_{sleeper.name}_{name}" diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 39140b51a5b..fb17336ccb3 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -1,15 +1,98 @@ """Support for SleepIQ SleepNumber firmness number entities.""" -from asyncsleepiq import SleepIQBed, SleepIQSleeper +from __future__ import annotations -from homeassistant.components.number import NumberEntity +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any, cast + +from asyncsleepiq import SleepIQActuator, SleepIQBed, 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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, FIRMNESS +from .const import ACTUATOR, DOMAIN, ENTITY_TYPES, FIRMNESS, ICON_OCCUPIED from .coordinator import SleepIQData -from .entity import SleepIQSleeperEntity +from .entity import SleepIQBedEntity + + +@dataclass +class SleepIQNumberEntityDescriptionMixin: + """Mixin to describe a SleepIQ number entity.""" + + value_fn: Callable[[Any], float] + set_value_fn: Callable[[Any, int], Coroutine[None, None, None]] + get_name_fn: Callable[[SleepIQBed, Any], str] + get_unique_id_fn: Callable[[SleepIQBed, Any], str] + + +@dataclass +class SleepIQNumberEntityDescription( + NumberEntityDescription, SleepIQNumberEntityDescriptionMixin +): + """Class to describe a SleepIQ number entity.""" + + +async def _async_set_firmness(sleeper: SleepIQSleeper, firmness: int) -> None: + await sleeper.set_sleepnumber(firmness) + + +async def _async_set_actuator_position( + actuator: SleepIQActuator, position: int +) -> None: + await actuator.set_position(position) + + +def _get_actuator_name(bed: SleepIQBed, actuator: SleepIQActuator) -> str: + if actuator.side: + return f"SleepNumber {bed.name} {actuator.side_full} {actuator.actuator_full} {ENTITY_TYPES[ACTUATOR]}" + + return f"SleepNumber {bed.name} {actuator.actuator_full} {ENTITY_TYPES[ACTUATOR]}" + + +def _get_actuator_unique_id(bed: SleepIQBed, actuator: SleepIQActuator) -> str: + if actuator.side: + return f"{bed.id}_{actuator.side}_{actuator.actuator}" + + return f"{bed.id}_{actuator.actuator}" + + +def _get_sleeper_name(bed: SleepIQBed, sleeper: SleepIQSleeper) -> str: + return f"SleepNumber {bed.name} {sleeper.name} {ENTITY_TYPES[FIRMNESS]}" + + +def _get_sleeper_unique_id(bed: SleepIQBed, sleeper: SleepIQSleeper) -> str: + return f"{sleeper.sleeper_id}_{FIRMNESS}" + + +NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { + FIRMNESS: SleepIQNumberEntityDescription( + key=FIRMNESS, + min_value=5, + max_value=100, + step=5, + name=ENTITY_TYPES[FIRMNESS], + icon=ICON_OCCUPIED, + value_fn=lambda sleeper: cast(float, sleeper.sleep_number), + set_value_fn=_async_set_firmness, + get_name_fn=_get_sleeper_name, + get_unique_id_fn=_get_sleeper_unique_id, + ), + ACTUATOR: SleepIQNumberEntityDescription( + key=ACTUATOR, + min_value=0, + max_value=100, + step=1, + name=ENTITY_TYPES[ACTUATOR], + icon=ICON_OCCUPIED, + value_fn=lambda actuator: cast(float, actuator.position), + set_value_fn=_async_set_actuator_position, + get_name_fn=_get_actuator_name, + get_unique_id_fn=_get_actuator_unique_id, + ), +} async def async_setup_entry( @@ -19,37 +102,59 @@ async def async_setup_entry( ) -> None: """Set up the SleepIQ bed sensors.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - SleepNumberFirmnessEntity(data.data_coordinator, bed, sleeper) - for bed in data.client.beds.values() - for sleeper in bed.sleepers - ) + + entities = [] + for bed in data.client.beds.values(): + for sleeper in bed.sleepers: + entities.append( + SleepIQNumberEntity( + data.data_coordinator, + bed, + sleeper, + NUMBER_DESCRIPTIONS[FIRMNESS], + ) + ) + for actuator in bed.foundation.actuators: + entities.append( + SleepIQNumberEntity( + data.data_coordinator, + bed, + actuator, + NUMBER_DESCRIPTIONS[ACTUATOR], + ) + ) + + async_add_entities(entities) -class SleepNumberFirmnessEntity(SleepIQSleeperEntity, NumberEntity): - """Representation of an SleepIQ Entity with CoordinatorEntity.""" +class SleepIQNumberEntity(SleepIQBedEntity, NumberEntity): + """Representation of a SleepIQ number entity.""" _attr_icon = "mdi:bed" - _attr_max_value: float = 100 - _attr_min_value: float = 5 - _attr_step: float = 5 def __init__( self, coordinator: DataUpdateCoordinator, bed: SleepIQBed, - sleeper: SleepIQSleeper, + device: Any, + description: SleepIQNumberEntityDescription, ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, bed, sleeper, FIRMNESS) + """Initialize the number.""" + self.description = description + self.device = device + + self._attr_name = description.get_name_fn(bed, device) + self._attr_unique_id = description.get_unique_id_fn(bed, device) + + super().__init__(coordinator, bed) @callback def _async_update_attrs(self) -> None: - """Update sensor attributes.""" - self._attr_value = float(self.sleeper.sleep_number) + """Update number attributes.""" + self._attr_value = float(self.description.value_fn(self.device)) async def async_set_value(self, value: float) -> None: - """Set the firmness value.""" - await self.sleeper.set_sleepnumber(int(value)) + """Set the number value.""" + await self.description.set_value_fn(self.device, int(value)) self._attr_value = value self.async_write_ha_state() diff --git a/tests/components/sleepiq/conftest.py b/tests/components/sleepiq/conftest.py index 5b51b0f4670..a6f6ba78aba 100644 --- a/tests/components/sleepiq/conftest.py +++ b/tests/components/sleepiq/conftest.py @@ -3,7 +3,13 @@ from __future__ import annotations from unittest.mock import create_autospec, patch -from asyncsleepiq import SleepIQBed, SleepIQFoundation, SleepIQLight, SleepIQSleeper +from asyncsleepiq import ( + SleepIQActuator, + SleepIQBed, + SleepIQFoundation, + SleepIQLight, + SleepIQSleeper, +) import pytest from homeassistant.components.sleepiq import DOMAIN @@ -16,12 +22,13 @@ from tests.common import MockConfigEntry BED_ID = "123456" BED_NAME = "Test Bed" BED_NAME_LOWER = BED_NAME.lower().replace(" ", "_") +SLEEPER_L_ID = "98765" +SLEEPER_R_ID = "43219" SLEEPER_L_NAME = "SleeperL" SLEEPER_R_NAME = "Sleeper R" SLEEPER_L_NAME_LOWER = SLEEPER_L_NAME.lower().replace(" ", "_") SLEEPER_R_NAME_LOWER = SLEEPER_R_NAME.lower().replace(" ", "_") - SLEEPIQ_CONFIG = { CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password", @@ -49,12 +56,14 @@ def mock_asyncsleepiq(): sleeper_l.in_bed = True sleeper_l.sleep_number = 40 sleeper_l.pressure = 1000 + sleeper_l.sleeper_id = SLEEPER_L_ID sleeper_r.side = "R" sleeper_r.name = SLEEPER_R_NAME sleeper_r.in_bed = False sleeper_r.sleep_number = 80 sleeper_r.pressure = 1400 + sleeper_r.sleeper_id = SLEEPER_R_ID bed.foundation = create_autospec(SleepIQFoundation) light_1 = create_autospec(SleepIQLight) @@ -65,6 +74,28 @@ def mock_asyncsleepiq(): light_2.is_on = False bed.foundation.lights = [light_1, light_2] + actuator_h_r = create_autospec(SleepIQActuator) + actuator_h_l = create_autospec(SleepIQActuator) + actuator_f = create_autospec(SleepIQActuator) + bed.foundation.actuators = [actuator_h_r, actuator_h_l, actuator_f] + + actuator_h_r.side = "R" + actuator_h_r.side_full = "Right" + actuator_h_r.actuator = "H" + actuator_h_r.actuator_full = "Head" + actuator_h_r.position = 60 + + actuator_h_l.side = "L" + actuator_h_l.side_full = "Left" + actuator_h_l.actuator = "H" + actuator_h_l.actuator_full = "Head" + actuator_h_l.position = 50 + + actuator_f.side = None + actuator_f.actuator = "F" + actuator_f.actuator_full = "Foot" + actuator_f.position = 10 + yield client diff --git a/tests/components/sleepiq/test_number.py b/tests/components/sleepiq/test_number.py index f00bef2b4cb..be9221f3b12 100644 --- a/tests/components/sleepiq/test_number.py +++ b/tests/components/sleepiq/test_number.py @@ -8,8 +8,10 @@ from tests.components.sleepiq.conftest import ( BED_ID, BED_NAME, BED_NAME_LOWER, + SLEEPER_L_ID, SLEEPER_L_NAME, SLEEPER_L_NAME_LOWER, + SLEEPER_R_ID, SLEEPER_R_NAME, SLEEPER_R_NAME_LOWER, setup_platform, @@ -35,7 +37,7 @@ async def test_firmness(hass, mock_asyncsleepiq): f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_firmness" ) assert entry - assert entry.unique_id == f"{BED_ID}_{SLEEPER_L_NAME}_firmness" + assert entry.unique_id == f"{SLEEPER_L_ID}_firmness" state = hass.states.get( f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_firmness" @@ -51,7 +53,7 @@ async def test_firmness(hass, mock_asyncsleepiq): f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_firmness" ) assert entry - assert entry.unique_id == f"{BED_ID}_{SLEEPER_R_NAME}_firmness" + assert entry.unique_id == f"{SLEEPER_R_ID}_firmness" await hass.services.async_call( DOMAIN, @@ -66,3 +68,69 @@ async def test_firmness(hass, mock_asyncsleepiq): mock_asyncsleepiq.beds[BED_ID].sleepers[0].set_sleepnumber.assert_called_once() mock_asyncsleepiq.beds[BED_ID].sleepers[0].set_sleepnumber.assert_called_with(42) + + +async def test_actuators(hass, mock_asyncsleepiq): + """Test the SleepIQ actuator position values for a bed with adjustable head and foot.""" + entry = await setup_platform(hass, DOMAIN) + entity_registry = er.async_get(hass) + + state = hass.states.get(f"number.sleepnumber_{BED_NAME_LOWER}_right_head_position") + assert state.state == "60.0" + assert state.attributes.get(ATTR_ICON) == "mdi:bed" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} Right Head Position" + ) + + entry = entity_registry.async_get( + f"number.sleepnumber_{BED_NAME_LOWER}_right_head_position" + ) + assert entry + assert entry.unique_id == f"{BED_ID}_R_H" + + state = hass.states.get(f"number.sleepnumber_{BED_NAME_LOWER}_left_head_position") + assert state.state == "50.0" + assert state.attributes.get(ATTR_ICON) == "mdi:bed" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} Left Head Position" + ) + + entry = entity_registry.async_get( + f"number.sleepnumber_{BED_NAME_LOWER}_left_head_position" + ) + assert entry + assert entry.unique_id == f"{BED_ID}_L_H" + + state = hass.states.get(f"number.sleepnumber_{BED_NAME_LOWER}_foot_position") + assert state.state == "10.0" + assert state.attributes.get(ATTR_ICON) == "mdi:bed" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} Foot Position" + ) + + entry = entity_registry.async_get( + f"number.sleepnumber_{BED_NAME_LOWER}_foot_position" + ) + assert entry + assert entry.unique_id == f"{BED_ID}_F" + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.sleepnumber_{BED_NAME_LOWER}_right_head_position", + ATTR_VALUE: 42, + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.actuators[ + 0 + ].set_position.assert_called_once() + mock_asyncsleepiq.beds[BED_ID].foundation.actuators[ + 0 + ].set_position.assert_called_with(42)