diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py index db783e0e5d3..e9bea5f7c06 100644 --- a/homeassistant/components/saunum/__init__.py +++ b/homeassistant/components/saunum/__init__.py @@ -15,6 +15,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, ] diff --git a/homeassistant/components/saunum/icons.json b/homeassistant/components/saunum/icons.json index 854fcee7ff5..186f86a6d86 100644 --- a/homeassistant/components/saunum/icons.json +++ b/homeassistant/components/saunum/icons.json @@ -1,5 +1,13 @@ { "entity": { + "number": { + "fan_duration": { + "default": "mdi:fan-clock" + }, + "sauna_duration": { + "default": "mdi:clock-edit-outline" + } + }, "sensor": { "heater_elements_active": { "default": "mdi:radiator" diff --git a/homeassistant/components/saunum/number.py b/homeassistant/components/saunum/number.py new file mode 100644 index 00000000000..cd12df201cc --- /dev/null +++ b/homeassistant/components/saunum/number.py @@ -0,0 +1,143 @@ +"""Number platform for Saunum Leil Sauna Control Unit.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from pysaunum import ( + MAX_DURATION, + MAX_FAN_DURATION, + MIN_DURATION, + MIN_FAN_DURATION, + SaunumClient, + SaunumData, + SaunumException, +) + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LeilSaunaConfigEntry +from .const import DOMAIN +from .entity import LeilSaunaEntity + +if TYPE_CHECKING: + from .coordinator import LeilSaunaCoordinator + +PARALLEL_UPDATES = 0 + +# Default values when device returns None or invalid data +DEFAULT_DURATION_MIN = 120 +DEFAULT_FAN_DURATION_MIN = 15 + + +@dataclass(frozen=True, kw_only=True) +class LeilSaunaNumberEntityDescription(NumberEntityDescription): + """Describes Saunum Leil Sauna number entity.""" + + value_fn: Callable[[SaunumData], int | float | None] + set_value_fn: Callable[[SaunumClient, float], Awaitable[None]] + + +NUMBERS: tuple[LeilSaunaNumberEntityDescription, ...] = ( + LeilSaunaNumberEntityDescription( + key="sauna_duration", + translation_key="sauna_duration", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + native_min_value=1, + native_max_value=MAX_DURATION, + native_step=1, + value_fn=lambda data: ( + duration + if (duration := data.sauna_duration) is not None and duration > MIN_DURATION + else DEFAULT_DURATION_MIN + ), + set_value_fn=lambda client, value: client.async_set_sauna_duration(int(value)), + ), + LeilSaunaNumberEntityDescription( + key="fan_duration", + translation_key="fan_duration", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + native_min_value=1, + native_max_value=MAX_FAN_DURATION, + native_step=1, + value_fn=lambda data: ( + fan_dur + if (fan_dur := data.fan_duration) is not None and fan_dur > MIN_FAN_DURATION + else DEFAULT_FAN_DURATION_MIN + ), + set_value_fn=lambda client, value: client.async_set_fan_duration(int(value)), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LeilSaunaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Saunum Leil Sauna number entities.""" + coordinator = entry.runtime_data + + async_add_entities( + LeilSaunaNumber(coordinator, description) for description in NUMBERS + ) + + +class LeilSaunaNumber(LeilSaunaEntity, NumberEntity): + """Representation of a Saunum Leil Sauna number entity.""" + + entity_description: LeilSaunaNumberEntityDescription + + def __init__( + self, + coordinator: LeilSaunaCoordinator, + description: LeilSaunaNumberEntityDescription, + ) -> None: + """Initialize the number entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{description.key}" + self.entity_description = description + + @property + def native_value(self) -> float | None: + """Return the current value.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + # Prevent changing certain settings when session is active + session_active = self.coordinator.data.session_active + if session_active and self.entity_description.key in ( + "sauna_duration", + "fan_duration", + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key=f"session_active_cannot_change_{self.entity_description.key}", + ) + + try: + await self.entity_description.set_value_fn(self.coordinator.client, value) + except SaunumException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_value_failed", + translation_placeholders={ + "entity": self.entity_description.key, + "value": str(value), + }, + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/saunum/strings.json b/homeassistant/components/saunum/strings.json index bc7bf3803ed..e72fad37fa6 100644 --- a/homeassistant/components/saunum/strings.json +++ b/homeassistant/components/saunum/strings.json @@ -55,6 +55,14 @@ "name": "[%key:component::light::title%]" } }, + "number": { + "fan_duration": { + "name": "Fan duration" + }, + "sauna_duration": { + "name": "Sauna duration" + } + }, "sensor": { "heater_elements_active": { "name": "Heater elements active", @@ -72,6 +80,12 @@ "door_open": { "message": "Cannot start sauna session when sauna door is open" }, + "session_active_cannot_change_fan_duration": { + "message": "Cannot change fan duration while session is active" + }, + "session_active_cannot_change_sauna_duration": { + "message": "Cannot change sauna duration while session is active" + }, "session_not_active": { "message": "Cannot change fan mode when sauna session is not active" }, @@ -86,6 +100,9 @@ }, "set_temperature_failed": { "message": "Failed to set temperature to {temperature}" + }, + "set_value_failed": { + "message": "Failed to set {entity} to {value}" } } } diff --git a/tests/components/saunum/snapshots/test_number.ambr b/tests/components/saunum/snapshots/test_number.ambr new file mode 100644 index 00000000000..80f63e8443d --- /dev/null +++ b/tests/components/saunum/snapshots/test_number.ambr @@ -0,0 +1,119 @@ +# serializer version: 1 +# name: test_entities[number.saunum_leil_fan_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 30, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.saunum_leil_fan_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fan duration', + 'platform': 'saunum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fan_duration', + 'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA-fan_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.saunum_leil_fan_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Saunum Leil Fan duration', + 'max': 30, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.saunum_leil_fan_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_entities[number.saunum_leil_sauna_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 720, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.saunum_leil_sauna_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sauna duration', + 'platform': 'saunum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sauna_duration', + 'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA-sauna_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.saunum_leil_sauna_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Saunum Leil Sauna duration', + 'max': 720, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.saunum_leil_sauna_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- diff --git a/tests/components/saunum/test_number.py b/tests/components/saunum/test_number.py new file mode 100644 index 00000000000..dea753e0f40 --- /dev/null +++ b/tests/components/saunum/test_number.py @@ -0,0 +1,201 @@ +"""Test the Saunum number platform.""" + +from __future__ import annotations + +from dataclasses import replace +from unittest.mock import MagicMock + +from pysaunum import SaunumException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.NUMBER] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_set_sauna_duration( + hass: HomeAssistant, + mock_saunum_client: MagicMock, +) -> None: + """Test setting sauna duration.""" + entity_id = "number.saunum_leil_sauna_duration" + + # Verify initial state + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "120" + + # Set new duration + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 180}, + blocking=True, + ) + + # Verify the client method was called + mock_saunum_client.async_set_sauna_duration.assert_called_once_with(180) + + +@pytest.mark.usefixtures("init_integration") +async def test_set_fan_duration( + hass: HomeAssistant, + mock_saunum_client: MagicMock, +) -> None: + """Test setting fan duration.""" + entity_id = "number.saunum_leil_fan_duration" + + # Verify initial state + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "10" + + # Set new duration + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 15}, + blocking=True, + ) + + # Verify the client method was called + mock_saunum_client.async_set_fan_duration.assert_called_once_with(15) + + +@pytest.mark.usefixtures("init_integration") +async def test_set_value_failure( + hass: HomeAssistant, + mock_saunum_client: MagicMock, +) -> None: + """Test error handling when setting value fails.""" + entity_id = "number.saunum_leil_sauna_duration" + + # Make the set operation fail + mock_saunum_client.async_set_sauna_duration.side_effect = SaunumException( + "Write error" + ) + + # Attempt to set value should raise HomeAssistantError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 180}, + blocking=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_set_value_while_session_active( + hass: HomeAssistant, + mock_saunum_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error when trying to change duration while session is active.""" + entity_id = "number.saunum_leil_sauna_duration" + + # Update mock data to have session active + base_data = mock_saunum_client.async_get_data.return_value + mock_saunum_client.async_get_data.return_value = replace( + base_data, + session_active=True, + ) + + # Trigger coordinator update + coordinator = mock_config_entry.runtime_data + await coordinator.async_refresh() + await hass.async_block_till_done() + + # Attempt to set value should raise ServiceValidationError + with pytest.raises( + ServiceValidationError, + match="Cannot change sauna duration while session is active", + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 180}, + blocking=True, + ) + + +async def test_number_with_default_duration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_saunum_client: MagicMock, +) -> None: + """Test number entities use default when device returns None.""" + # Set duration to None (device hasn't set it yet) + base_data = mock_saunum_client.async_get_data.return_value + mock_saunum_client.async_get_data.return_value = replace( + base_data, + sauna_duration=None, + fan_duration=None, + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Should show default values + sauna_duration_state = hass.states.get("number.saunum_leil_sauna_duration") + assert sauna_duration_state is not None + assert sauna_duration_state.state == "120" # DEFAULT_DURATION_MIN + + fan_duration_state = hass.states.get("number.saunum_leil_fan_duration") + assert fan_duration_state is not None + assert fan_duration_state.state == "15" # DEFAULT_FAN_DURATION_MIN + + +async def test_number_with_valid_duration_from_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_saunum_client: MagicMock, +) -> None: + """Test number entities use actual values from device when valid.""" + base_data = mock_saunum_client.async_get_data.return_value + mock_saunum_client.async_get_data.return_value = replace( + base_data, + sauna_duration=90, + fan_duration=20, + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Should show actual device values + sauna_duration_state = hass.states.get("number.saunum_leil_sauna_duration") + assert sauna_duration_state is not None + assert sauna_duration_state.state == "90" + + fan_duration_state = hass.states.get("number.saunum_leil_fan_duration") + assert fan_duration_state is not None + assert fan_duration_state.state == "20"