From d02adabe5dd2692985b187348e32b793014da36e Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:47:50 +0200 Subject: [PATCH] Add Start session action to Saunum integration (#162177) --- homeassistant/components/saunum/__init__.py | 12 ++ homeassistant/components/saunum/climate.py | 30 +++++ homeassistant/components/saunum/icons.json | 5 + .../components/saunum/quality_scale.yaml | 8 +- homeassistant/components/saunum/services.py | 41 +++++++ homeassistant/components/saunum/services.yaml | 23 ++++ homeassistant/components/saunum/strings.json | 23 ++++ tests/components/saunum/conftest.py | 50 ++++---- tests/components/saunum/test_services.py | 115 ++++++++++++++++++ 9 files changed, 277 insertions(+), 30 deletions(-) create mode 100644 homeassistant/components/saunum/services.py create mode 100644 homeassistant/components/saunum/services.yaml create mode 100644 tests/components/saunum/test_services.py diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py index e2988b4b713..f0a1a9161f4 100644 --- a/homeassistant/components/saunum/__init__.py +++ b/homeassistant/components/saunum/__init__.py @@ -8,8 +8,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .coordinator import LeilSaunaCoordinator +from .services import async_setup_services PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -19,9 +23,17 @@ PLATFORMS: list[Platform] = [ Platform.SENSOR, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Saunum component.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool: """Set up Saunum Leil Sauna from a config entry.""" host = entry.data[CONF_HOST] diff --git a/homeassistant/components/saunum/climate.py b/homeassistant/components/saunum/climate.py index 887559800e8..52fb7ed0212 100644 --- a/homeassistant/components/saunum/climate.py +++ b/homeassistant/components/saunum/climate.py @@ -238,3 +238,33 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity): ) from err await self.coordinator.async_request_refresh() + + async def async_start_session( + self, + duration: int = 120, + target_temperature: int = 80, + fan_duration: int = 10, + ) -> None: + """Start a sauna session with custom parameters.""" + if self.coordinator.data.door_open: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="door_open", + ) + + try: + # Set all parameters before starting the session + await self.coordinator.client.async_set_sauna_duration(duration) + await self.coordinator.client.async_set_target_temperature( + target_temperature + ) + await self.coordinator.client.async_set_fan_duration(fan_duration) + await self.coordinator.client.async_start_session() + except SaunumException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="start_session_failed", + translation_placeholders={"error": str(err)}, + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/saunum/icons.json b/homeassistant/components/saunum/icons.json index 713983b8114..39acc565e45 100644 --- a/homeassistant/components/saunum/icons.json +++ b/homeassistant/components/saunum/icons.json @@ -27,5 +27,10 @@ "default": "mdi:radiator" } } + }, + "services": { + "start_session": { + "service": "mdi:heat-wave" + } } } diff --git a/homeassistant/components/saunum/quality_scale.yaml b/homeassistant/components/saunum/quality_scale.yaml index 4a7d29777b4..eb0a70d6732 100644 --- a/homeassistant/components/saunum/quality_scale.yaml +++ b/homeassistant/components/saunum/quality_scale.yaml @@ -1,17 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: Integration does not register custom actions. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: Integration does not register custom actions. + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done diff --git a/homeassistant/components/saunum/services.py b/homeassistant/components/saunum/services.py new file mode 100644 index 00000000000..0a86da8386d --- /dev/null +++ b/homeassistant/components/saunum/services.py @@ -0,0 +1,41 @@ +"""Define services for the Saunum integration.""" + +from __future__ import annotations + +from pysaunum import MAX_DURATION, MAX_FAN_DURATION, MAX_TEMPERATURE, MIN_TEMPERATURE +import voluptuous as vol + +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import DOMAIN + +ATTR_DURATION = "duration" +ATTR_TARGET_TEMPERATURE = "target_temperature" +ATTR_FAN_DURATION = "fan_duration" + +SERVICE_START_SESSION = "start_session" + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services for the Saunum integration.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_START_SESSION, + entity_domain=CLIMATE_DOMAIN, + schema={ + vol.Optional(ATTR_DURATION, default=120): vol.All( + cv.positive_int, vol.Range(min=1, max=MAX_DURATION) + ), + vol.Optional(ATTR_TARGET_TEMPERATURE, default=80): vol.All( + cv.positive_int, vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) + ), + vol.Optional(ATTR_FAN_DURATION, default=10): vol.All( + cv.positive_int, vol.Range(min=1, max=MAX_FAN_DURATION) + ), + }, + func="async_start_session", + ) diff --git a/homeassistant/components/saunum/services.yaml b/homeassistant/components/saunum/services.yaml new file mode 100644 index 00000000000..7e7ad6db6c5 --- /dev/null +++ b/homeassistant/components/saunum/services.yaml @@ -0,0 +1,23 @@ +start_session: + target: + entity: + domain: climate + integration: saunum + fields: + duration: + required: false + selector: + duration: + target_temperature: + required: false + example: 80 + default: 80 + selector: + number: + min: 40 + max: 100 + unit_of_measurement: "°C" + fan_duration: + required: false + selector: + duration: diff --git a/homeassistant/components/saunum/strings.json b/homeassistant/components/saunum/strings.json index 945cff52c08..ca0631337b3 100644 --- a/homeassistant/components/saunum/strings.json +++ b/homeassistant/components/saunum/strings.json @@ -128,6 +128,9 @@ }, "set_temperature_failed": { "message": "Failed to set temperature to {temperature}" + }, + "start_session_failed": { + "message": "Failed to start sauna session: {error}" } }, "options": { @@ -146,5 +149,25 @@ "description": "Customize the names of the three sauna type preset modes" } } + }, + "services": { + "start_session": { + "description": "Starts a sauna session with custom duration, target temperature, and fan duration.", + "fields": { + "duration": { + "description": "Session duration in minutes.", + "name": "Duration" + }, + "fan_duration": { + "description": "Fan duration in minutes.", + "name": "Fan duration" + }, + "target_temperature": { + "description": "Target temperature in Celsius.", + "name": "Target temperature" + } + }, + "name": "Start session" + } } } diff --git a/tests/components/saunum/conftest.py b/tests/components/saunum/conftest.py index 78c6e12bbc5..4faf48bbfc2 100644 --- a/tests/components/saunum/conftest.py +++ b/tests/components/saunum/conftest.py @@ -42,7 +42,31 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_saunum_client_class() -> Generator[MagicMock]: +def mock_saunum_data() -> SaunumData: + """Return default mock Saunum data.""" + return SaunumData( + session_active=False, + sauna_type=0, + sauna_duration=120, + fan_duration=10, + target_temperature=80, + fan_speed=2, + light_on=False, + current_temperature=75.0, + on_time=3600, + heater_elements_active=0, + door_open=False, + alarm_door_open=False, + alarm_door_sensor=False, + alarm_thermal_cutoff=False, + alarm_internal_temp=False, + alarm_temp_sensor_short=False, + alarm_temp_sensor_open=False, + ) + + +@pytest.fixture +def mock_saunum_client_class(mock_saunum_data: SaunumData) -> Generator[MagicMock]: """Return a mocked Saunum client class for config flow and integration tests.""" with ( patch( @@ -54,29 +78,7 @@ def mock_saunum_client_class() -> Generator[MagicMock]: mock_client.is_connected = True mock_client_class.create = AsyncMock(return_value=mock_client) - - # Create mock data for async_get_data - mock_data = SaunumData( - session_active=False, - sauna_type=0, - sauna_duration=120, - fan_duration=10, - target_temperature=80, - fan_speed=2, - light_on=False, - current_temperature=75.0, - on_time=3600, - heater_elements_active=0, - door_open=False, - alarm_door_open=False, - alarm_door_sensor=False, - alarm_thermal_cutoff=False, - alarm_internal_temp=False, - alarm_temp_sensor_short=False, - alarm_temp_sensor_open=False, - ) - - mock_client.async_get_data.return_value = mock_data + mock_client.async_get_data.return_value = mock_saunum_data yield mock_client_class diff --git a/tests/components/saunum/test_services.py b/tests/components/saunum/test_services.py new file mode 100644 index 00000000000..0229df1b66b --- /dev/null +++ b/tests/components/saunum/test_services.py @@ -0,0 +1,115 @@ +"""Tests for Saunum services.""" + +from unittest.mock import MagicMock + +from pysaunum import SaunumData, SaunumException +import pytest + +from homeassistant.components.saunum.const import DOMAIN +from homeassistant.components.saunum.services import ( + ATTR_DURATION, + ATTR_FAN_DURATION, + ATTR_TARGET_TEMPERATURE, + SERVICE_START_SESSION, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE] + + +async def test_start_session_success( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_saunum_client: MagicMock, +) -> None: + """Test start_session service success.""" + await hass.services.async_call( + DOMAIN, + SERVICE_START_SESSION, + { + ATTR_ENTITY_ID: "climate.saunum_leil", + ATTR_DURATION: 120, + ATTR_TARGET_TEMPERATURE: 80, + ATTR_FAN_DURATION: 10, + }, + blocking=True, + ) + + mock_saunum_client.async_set_sauna_duration.assert_called_once_with(120) + mock_saunum_client.async_set_target_temperature.assert_called_once_with(80) + mock_saunum_client.async_set_fan_duration.assert_called_once_with(10) + mock_saunum_client.async_start_session.assert_called_once() + + +async def test_start_session_with_defaults( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_saunum_client: MagicMock, +) -> None: + """Test start_session service uses defaults when optional fields omitted.""" + await hass.services.async_call( + DOMAIN, + SERVICE_START_SESSION, + {ATTR_ENTITY_ID: "climate.saunum_leil"}, + blocking=True, + ) + + # Defaults: duration=120, target_temperature=80, fan_duration=10 + mock_saunum_client.async_set_sauna_duration.assert_called_once_with(120) + mock_saunum_client.async_set_target_temperature.assert_called_once_with(80) + mock_saunum_client.async_set_fan_duration.assert_called_once_with(10) + mock_saunum_client.async_start_session.assert_called_once() + + +async def test_start_session_door_open( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_saunum_client: MagicMock, + mock_saunum_data: SaunumData, +) -> None: + """Test start_session service fails when door is open.""" + mock_saunum_client.async_get_data.return_value = SaunumData( + **{**mock_saunum_data.__dict__, "door_open": True} + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_START_SESSION, + {ATTR_ENTITY_ID: "climate.saunum_leil"}, + blocking=True, + ) + + assert exc_info.value.translation_key == "door_open" + + +async def test_start_session_communication_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_saunum_client: MagicMock, +) -> None: + """Test start_session service handles communication error.""" + mock_saunum_client.async_set_sauna_duration.side_effect = SaunumException( + "Connection lost" + ) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_START_SESSION, + {ATTR_ENTITY_ID: "climate.saunum_leil"}, + blocking=True, + ) + + assert exc_info.value.translation_key == "start_session_failed"