From 54fc9632975c04f6bd18adcec7345ba26ffc1a77 Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Sun, 18 Jan 2026 14:43:11 +0200 Subject: [PATCH] Add configurable sauna types to Saunum integration (#159782) --- homeassistant/components/saunum/__init__.py | 3 +- homeassistant/components/saunum/climate.py | 84 +++- .../components/saunum/config_flow.py | 64 ++- homeassistant/components/saunum/const.py | 10 + .../components/saunum/diagnostics.py | 1 + homeassistant/components/saunum/icons.json | 14 + homeassistant/components/saunum/strings.json | 40 +- .../saunum/snapshots/test_climate.ambr | 17 +- .../saunum/snapshots/test_diagnostics.ambr | 2 + tests/components/saunum/test_climate.py | 373 +++++++++++------- tests/components/saunum/test_config_flow.py | 86 +++- tests/components/saunum/test_number.py | 2 +- 12 files changed, 547 insertions(+), 149 deletions(-) diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py index 748c842e9ae..19fc364157f 100644 --- a/homeassistant/components/saunum/__init__.py +++ b/homeassistant/components/saunum/__init__.py @@ -44,7 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - coordinator = entry.runtime_data - coordinator.client.close() + entry.runtime_data.client.close() return unload_ok diff --git a/homeassistant/components/saunum/climate.py b/homeassistant/components/saunum/climate.py index 85fa57e369d..887559800e8 100644 --- a/homeassistant/components/saunum/climate.py +++ b/homeassistant/components/saunum/climate.py @@ -22,8 +22,17 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import LeilSaunaConfigEntry -from .const import DELAYED_REFRESH_SECONDS, DOMAIN +from . import LeilSaunaConfigEntry, LeilSaunaCoordinator +from .const import ( + DEFAULT_PRESET_NAME_TYPE_1, + DEFAULT_PRESET_NAME_TYPE_2, + DEFAULT_PRESET_NAME_TYPE_3, + DELAYED_REFRESH_SECONDS, + DOMAIN, + OPT_PRESET_NAME_TYPE_1, + OPT_PRESET_NAME_TYPE_2, + OPT_PRESET_NAME_TYPE_3, +) from .entity import LeilSaunaEntity PARALLEL_UPDATES = 1 @@ -52,9 +61,12 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity): """Representation of a Saunum Leil Sauna climate entity.""" _attr_name = None + _attr_translation_key = "saunum_climate" _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_precision = PRECISION_WHOLE @@ -62,6 +74,38 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity): _attr_min_temp = MIN_TEMPERATURE _attr_max_temp = MAX_TEMPERATURE _attr_fan_modes = [FAN_OFF, FAN_LOW, FAN_MEDIUM, FAN_HIGH] + _preset_name_map: dict[int, str] + + def __init__(self, coordinator: LeilSaunaCoordinator) -> None: + """Initialize the climate entity.""" + super().__init__(coordinator) + self._update_preset_names() + + def _update_preset_names(self) -> None: + """Update preset names from config entry options.""" + options = self.coordinator.config_entry.options + self._preset_name_map = { + 0: options.get(OPT_PRESET_NAME_TYPE_1, DEFAULT_PRESET_NAME_TYPE_1), + 1: options.get(OPT_PRESET_NAME_TYPE_2, DEFAULT_PRESET_NAME_TYPE_2), + 2: options.get(OPT_PRESET_NAME_TYPE_3, DEFAULT_PRESET_NAME_TYPE_3), + } + self._attr_preset_modes = list(self._preset_name_map.values()) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.config_entry.add_update_listener( + self._async_update_listener + ) + ) + + async def _async_update_listener( + self, hass: HomeAssistant, entry: LeilSaunaConfigEntry + ) -> None: + """Handle options update.""" + self._update_preset_names() + self.async_write_ha_state() @property def current_temperature(self) -> float | None: @@ -100,6 +144,14 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity): else HVACAction.IDLE ) + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + sauna_type = self.coordinator.data.sauna_type + if sauna_type is not None and sauna_type in self._preset_name_map: + return self._preset_name_map[sauna_type] + return self._preset_name_map[0] + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new HVAC mode.""" if hvac_mode == HVACMode.HEAT and self.coordinator.data.door_open: @@ -160,3 +212,29 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity): ) from err await self.coordinator.async_request_refresh() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode (sauna type).""" + if self.coordinator.data.session_active: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="preset_session_active", + ) + + # Find the sauna type value from the preset name + sauna_type_value = 0 # Default to type 1 + for type_value, type_name in self._preset_name_map.items(): + if type_name == preset_mode: + sauna_type_value = type_value + break + + try: + await self.coordinator.client.async_set_sauna_type(sauna_type_value) + except SaunumException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_preset_failed", + translation_placeholders={"preset_mode": preset_mode}, + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/saunum/config_flow.py b/homeassistant/components/saunum/config_flow.py index cb93cb70117..2b8a88de8f4 100644 --- a/homeassistant/components/saunum/config_flow.py +++ b/homeassistant/components/saunum/config_flow.py @@ -8,11 +8,26 @@ from typing import Any from pysaunum import SaunumClient, SaunumException import voluptuous as vol -from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_HOST +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from .const import DOMAIN +from . import LeilSaunaConfigEntry +from .const import ( + DEFAULT_PRESET_NAME_TYPE_1, + DEFAULT_PRESET_NAME_TYPE_2, + DEFAULT_PRESET_NAME_TYPE_3, + DOMAIN, + OPT_PRESET_NAME_TYPE_1, + OPT_PRESET_NAME_TYPE_2, + OPT_PRESET_NAME_TYPE_3, +) _LOGGER = logging.getLogger(__name__) @@ -45,6 +60,14 @@ class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + @staticmethod + @callback + def async_get_options_flow( + config_entry: LeilSaunaConfigEntry, + ) -> LeilSaunaOptionsFlow: + """Get the options flow for this handler.""" + return LeilSaunaOptionsFlow() + async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -82,3 +105,40 @@ class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=STEP_USER_DATA_SCHEMA, errors=errors, ) + + +class LeilSaunaOptionsFlow(OptionsFlow): + """Handle options flow for Saunum Leil Sauna Control Unit.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options for preset mode names.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + OPT_PRESET_NAME_TYPE_1, + default=self.config_entry.options.get( + OPT_PRESET_NAME_TYPE_1, DEFAULT_PRESET_NAME_TYPE_1 + ), + ): cv.string, + vol.Optional( + OPT_PRESET_NAME_TYPE_2, + default=self.config_entry.options.get( + OPT_PRESET_NAME_TYPE_2, DEFAULT_PRESET_NAME_TYPE_2 + ), + ): cv.string, + vol.Optional( + OPT_PRESET_NAME_TYPE_3, + default=self.config_entry.options.get( + OPT_PRESET_NAME_TYPE_3, DEFAULT_PRESET_NAME_TYPE_3 + ), + ): cv.string, + } + ), + ) diff --git a/homeassistant/components/saunum/const.py b/homeassistant/components/saunum/const.py index 0c841313ad2..beb5589c79c 100644 --- a/homeassistant/components/saunum/const.py +++ b/homeassistant/components/saunum/const.py @@ -7,3 +7,13 @@ DOMAIN: Final = "saunum" DEFAULT_SCAN_INTERVAL: Final = timedelta(seconds=60) DELAYED_REFRESH_SECONDS: Final = timedelta(seconds=3) + +# Option keys for preset names +OPT_PRESET_NAME_TYPE_1: Final = "preset_name_type_1" +OPT_PRESET_NAME_TYPE_2: Final = "preset_name_type_2" +OPT_PRESET_NAME_TYPE_3: Final = "preset_name_type_3" + +# Default preset names (translation keys) +DEFAULT_PRESET_NAME_TYPE_1: Final = "type_1" +DEFAULT_PRESET_NAME_TYPE_2: Final = "type_2" +DEFAULT_PRESET_NAME_TYPE_3: Final = "type_3" diff --git a/homeassistant/components/saunum/diagnostics.py b/homeassistant/components/saunum/diagnostics.py index 2f348dfa50c..5e42e926d33 100644 --- a/homeassistant/components/saunum/diagnostics.py +++ b/homeassistant/components/saunum/diagnostics.py @@ -23,6 +23,7 @@ async def async_get_config_entry_diagnostics( # Build diagnostics data diagnostics_data: dict[str, Any] = { "config": async_redact_data(entry.data, REDACT_CONFIG), + "options": dict(entry.options), "client_info": {"connected": coordinator.client.is_connected}, "coordinator_info": { "last_update_success": coordinator.last_update_success, diff --git a/homeassistant/components/saunum/icons.json b/homeassistant/components/saunum/icons.json index 186f86a6d86..713983b8114 100644 --- a/homeassistant/components/saunum/icons.json +++ b/homeassistant/components/saunum/icons.json @@ -1,5 +1,19 @@ { "entity": { + "climate": { + "saunum_climate": { + "state_attributes": { + "preset_mode": { + "default": "mdi:heat-wave", + "state": { + "type_1": "mdi:numeric-1-box-outline", + "type_2": "mdi:numeric-2-box-outline", + "type_3": "mdi:numeric-3-box-outline" + } + } + } + } + }, "number": { "fan_duration": { "default": "mdi:fan-clock" diff --git a/homeassistant/components/saunum/strings.json b/homeassistant/components/saunum/strings.json index a2c4d8e51db..945cff52c08 100644 --- a/homeassistant/components/saunum/strings.json +++ b/homeassistant/components/saunum/strings.json @@ -50,6 +50,19 @@ "name": "Thermal cutoff alarm" } }, + "climate": { + "saunum_climate": { + "state_attributes": { + "preset_mode": { + "state": { + "type_1": "Sauna Type 1", + "type_2": "Sauna Type 2", + "type_3": "Sauna Type 3" + } + } + } + } + }, "light": { "light": { "name": "[%key:component::light::title%]" @@ -80,11 +93,14 @@ "door_open": { "message": "Cannot start sauna session when sauna door is open" }, + "preset_session_active": { + "message": "Cannot change preset mode while sauna session is active" + }, "session_active_cannot_change_fan_duration": { - "message": "Cannot change fan duration while session is active" + "message": "Cannot change fan duration while sauna session is active" }, "session_active_cannot_change_sauna_duration": { - "message": "Cannot change sauna duration while session is active" + "message": "Cannot change sauna duration while sauna session is active" }, "session_not_active": { "message": "Cannot change fan mode when sauna session is not active" @@ -104,11 +120,31 @@ "set_light_on_failed": { "message": "Failed to turn on light" }, + "set_preset_failed": { + "message": "Failed to set preset to {preset_mode}" + }, "set_sauna_duration_failed": { "message": "Failed to set sauna duration" }, "set_temperature_failed": { "message": "Failed to set temperature to {temperature}" } + }, + "options": { + "step": { + "init": { + "data": { + "preset_name_type_1": "Preset name for sauna type 1", + "preset_name_type_2": "Preset name for sauna type 2", + "preset_name_type_3": "Preset name for sauna type 3" + }, + "data_description": { + "preset_name_type_1": "Custom name for sauna type 1 preset mode", + "preset_name_type_2": "Custom name for sauna type 2 preset mode", + "preset_name_type_3": "Custom name for sauna type 3 preset mode" + }, + "description": "Customize the names of the three sauna type preset modes" + } + } } } diff --git a/tests/components/saunum/snapshots/test_climate.ambr b/tests/components/saunum/snapshots/test_climate.ambr index 47a9b17cc88..4286c288515 100644 --- a/tests/components/saunum/snapshots/test_climate.ambr +++ b/tests/components/saunum/snapshots/test_climate.ambr @@ -17,6 +17,11 @@ ]), 'max_temp': 100, 'min_temp': 40, + 'preset_modes': list([ + 'type_1', + 'type_2', + 'type_3', + ]), 'target_temp_step': 1.0, }), 'config_entry_id': , @@ -43,8 +48,8 @@ 'platform': 'saunum', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'saunum_climate', 'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA', 'unit_of_measurement': None, }) @@ -68,7 +73,13 @@ ]), 'max_temp': 100, 'min_temp': 40, - 'supported_features': , + 'preset_mode': 'type_1', + 'preset_modes': list([ + 'type_1', + 'type_2', + 'type_3', + ]), + 'supported_features': , 'target_temp_step': 1.0, 'temperature': 80, }), diff --git a/tests/components/saunum/snapshots/test_diagnostics.ambr b/tests/components/saunum/snapshots/test_diagnostics.ambr index 1e1fce24d29..4e3c1ccc8d6 100644 --- a/tests/components/saunum/snapshots/test_diagnostics.ambr +++ b/tests/components/saunum/snapshots/test_diagnostics.ambr @@ -33,5 +33,7 @@ 'last_update_success': True, 'update_interval': '0:01:00', }), + 'options': dict({ + }), }) # --- diff --git a/tests/components/saunum/test_climate.py b/tests/components/saunum/test_climate.py index 269dba0907b..6a4c917070d 100644 --- a/tests/components/saunum/test_climate.py +++ b/tests/components/saunum/test_climate.py @@ -14,6 +14,7 @@ from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, + ATTR_PRESET_MODE, DOMAIN as CLIMATE_DOMAIN, FAN_HIGH, FAN_LOW, @@ -21,10 +22,16 @@ from homeassistant.components.climate import ( FAN_OFF, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, HVACAction, HVACMode, ) +from homeassistant.components.saunum.const import ( + OPT_PRESET_NAME_TYPE_1, + OPT_PRESET_NAME_TYPE_2, + OPT_PRESET_NAME_TYPE_3, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -88,18 +95,36 @@ async def test_climate_service_calls( expected_args: tuple, ) -> None: """Test climate service calls.""" - entity_id = "climate.saunum_leil" - await hass.services.async_call( CLIMATE_DOMAIN, service, - {ATTR_ENTITY_ID: entity_id, **service_data}, + {ATTR_ENTITY_ID: "climate.saunum_leil", **service_data}, blocking=True, ) getattr(mock_saunum_client, client_method).assert_called_once_with(*expected_args) +@pytest.mark.usefixtures("init_integration") +async def test_hvac_mode_door_open_validation( + hass: HomeAssistant, + mock_saunum_client, +) -> None: + """Test HVAC mode validation error when door is open.""" + mock_saunum_client.async_get_data.return_value.door_open = True + + with pytest.raises( + ServiceValidationError, + match="Cannot start sauna session when sauna door is open", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.saunum_leil", ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + @pytest.mark.parametrize( ("heater_elements_active", "expected_hvac_action"), [ @@ -107,29 +132,25 @@ async def test_climate_service_calls( (0, HVACAction.IDLE), ], ) -async def test_climate_hvac_actions( +async def test_hvac_actions( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_saunum_client, heater_elements_active: int, expected_hvac_action: HVACAction, ) -> None: - """Test climate HVAC actions when session is active.""" - # Get the existing mock data and modify only what we need + """Test HVAC actions when session is active.""" mock_saunum_client.async_get_data.return_value.session_active = True mock_saunum_client.async_get_data.return_value.heater_elements_active = ( heater_elements_active ) 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() - entity_id = "climate.saunum_leil" - state = hass.states.get(entity_id) + state = hass.states.get("climate.saunum_leil") assert state is not None - assert state.state == HVACMode.HEAT assert state.attributes.get(ATTR_HVAC_ACTION) == expected_hvac_action @@ -146,7 +167,7 @@ async def test_climate_hvac_actions( (35.0, 30, 35, 30), ], ) -async def test_climate_temperature_edge_cases( +async def test_temperature_attributes( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_saunum_client, @@ -155,8 +176,7 @@ async def test_climate_temperature_edge_cases( expected_current: float | None, expected_target: int, ) -> None: - """Test climate with edge case temperature values.""" - # Get the existing mock data and modify only what we need + """Test temperature attribute handling with edge cases.""" base_data = mock_saunum_client.async_get_data.return_value mock_saunum_client.async_get_data.return_value = replace( base_data, @@ -165,14 +185,11 @@ async def test_climate_temperature_edge_cases( ) 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() - entity_id = "climate.saunum_leil" - state = hass.states.get(entity_id) + state = hass.states.get("climate.saunum_leil") assert state is not None - assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == expected_current assert state.attributes.get(ATTR_TEMPERATURE) == expected_target @@ -205,85 +222,93 @@ async def test_entity_unavailable_on_update_failure( assert state.state == STATE_UNAVAILABLE -@pytest.mark.usefixtures("init_integration") -async def test_hvac_mode_error_handling( - hass: HomeAssistant, - mock_saunum_client, -) -> None: - """Test error handling when setting HVAC mode fails.""" - entity_id = "climate.saunum_leil" - - # Make the client method raise an exception - mock_saunum_client.async_start_session.side_effect = SaunumException( - "Communication error" - ) - - # Try to call the service and expect HomeAssistantError - with pytest.raises(HomeAssistantError) as exc_info: - await hass.services.async_call( - CLIMATE_DOMAIN, +@pytest.mark.parametrize( + ("service", "service_data", "mock_method", "side_effect", "translation_key"), + [ + ( SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, - blocking=True, - ) - - # Verify the exception has the correct translation key - assert exc_info.value.translation_key == "set_hvac_mode_failed" - assert exc_info.value.translation_domain == "saunum" - - -@pytest.mark.usefixtures("init_integration") -async def test_hvac_mode_door_open_validation( - hass: HomeAssistant, - mock_saunum_client, -) -> None: - """Test validation error when trying to heat with door open.""" - entity_id = "climate.saunum_leil" - - # Set door to open - mock_saunum_client.async_get_data.return_value.door_open = True - - # Try to turn on heating with door open - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, - blocking=True, - ) - - # Verify the exception has the correct translation key - assert exc_info.value.translation_key == "door_open" - assert exc_info.value.translation_domain == "saunum" - - -@pytest.mark.usefixtures("init_integration") -async def test_temperature_error_handling( - hass: HomeAssistant, - mock_saunum_client, -) -> None: - """Test error handling when setting temperature fails.""" - entity_id = "climate.saunum_leil" - - # Make the client method raise an exception - mock_saunum_client.async_set_target_temperature.side_effect = SaunumException( - "Communication error" - ) - - # Try to call the service and expect HomeAssistantError - with pytest.raises(HomeAssistantError) as exc_info: - await hass.services.async_call( - CLIMATE_DOMAIN, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + "async_start_session", + SaunumException("Communication error"), + "set_hvac_mode_failed", + ), + ( SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 85}, + {ATTR_TEMPERATURE: 85}, + "async_set_target_temperature", + SaunumException("Communication error"), + "set_temperature_failed", + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: "type_2"}, + "async_set_sauna_type", + SaunumException("Communication error"), + "set_preset_failed", + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_service_error_handling( + hass: HomeAssistant, + mock_saunum_client, + service: str, + service_data: dict, + mock_method: str, + side_effect: Exception, + translation_key: str, +) -> None: + """Test error handling when service calls fail.""" + entity_id = "climate.saunum_leil" + + getattr(mock_saunum_client, mock_method).side_effect = side_effect + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, blocking=True, ) - # Verify the exception has the correct translation key - assert exc_info.value.translation_key == "set_temperature_failed" + assert exc_info.value.translation_key == translation_key assert exc_info.value.translation_domain == "saunum" +@pytest.mark.usefixtures("init_integration") +async def test_fan_mode_service_call( + hass: HomeAssistant, + mock_saunum_client, +) -> None: + """Test setting fan mode.""" + mock_saunum_client.async_get_data.return_value.session_active = True + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.saunum_leil", ATTR_FAN_MODE: FAN_LOW}, + blocking=True, + ) + + mock_saunum_client.async_set_fan_speed.assert_called_once_with(1) + + +@pytest.mark.usefixtures("init_integration") +async def test_preset_mode_service_call( + hass: HomeAssistant, + mock_saunum_client, +) -> None: + """Test setting preset mode.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.saunum_leil", ATTR_PRESET_MODE: "type_2"}, + blocking=True, + ) + + mock_saunum_client.async_set_sauna_type.assert_called_once_with(1) + + @pytest.mark.parametrize( ("fan_speed", "fan_mode"), [ @@ -294,79 +319,157 @@ async def test_temperature_error_handling( (None, None), ], ) -async def test_fan_mode_read( +async def test_fan_mode_attributes( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_saunum_client, fan_speed: int | None, fan_mode: str | None, ) -> None: - """Test fan mode states mapping from device.""" - # Set up initial state with the fan_speed and active session + """Test fan mode attribute mapping from device.""" mock_saunum_client.async_get_data.return_value.fan_speed = fan_speed mock_saunum_client.async_get_data.return_value.session_active = True 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() - entity_id = "climate.saunum_leil" - - # Test reading fan mode - state = hass.states.get(entity_id) + state = hass.states.get("climate.saunum_leil") assert state is not None assert state.attributes.get(ATTR_FAN_MODE) == fan_mode -@pytest.mark.parametrize( - ("fan_speed", "fan_mode"), - [ - (0, FAN_OFF), - (1, FAN_LOW), - (2, FAN_MEDIUM), - (3, FAN_HIGH), - ], -) -async def test_fan_mode_write( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_saunum_client, - fan_speed: int, - fan_mode: str, -) -> None: - """Test setting fan mode.""" - # Ensure session is active so fan mode can be changed - mock_saunum_client.async_get_data.return_value.session_active = True - - 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() - - entity_id = "climate.saunum_leil" - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: fan_mode}, - blocking=True, - ) - - mock_saunum_client.async_set_fan_speed.assert_called_once_with(fan_speed) - - @pytest.mark.usefixtures("init_integration") -async def test_fan_mode_session_not_active_error( +async def test_fan_mode_validation_error( hass: HomeAssistant, mock_saunum_client, ) -> None: """Test fan mode validation error when session is not active.""" - # Set session state to inactive mock_saunum_client.async_get_data.return_value.session_active = False + with pytest.raises( + ServiceValidationError, + match="Cannot change fan mode when sauna session is not active", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.saunum_leil", ATTR_FAN_MODE: FAN_LOW}, + blocking=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_preset_mode_validation_error( + hass: HomeAssistant, + mock_saunum_client, +) -> None: + """Test preset mode validation error when session is active.""" + mock_saunum_client.async_get_data.return_value.session_active = True + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.saunum_leil", ATTR_PRESET_MODE: "type_2"}, + blocking=True, + ) + + assert exc_info.value.translation_key == "preset_session_active" + assert exc_info.value.translation_domain == "saunum" + + +@pytest.mark.parametrize( + ("sauna_type", "expected_preset"), + [ + (0, "type_1"), + (1, "type_2"), + (2, "type_3"), + (None, "type_1"), + ], +) +async def test_preset_mode_attributes_default_names( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_saunum_client, + sauna_type: int | None, + expected_preset: str, +) -> None: + """Test preset mode attributes with default names.""" + mock_saunum_client.async_get_data.return_value.sauna_type = sauna_type + + 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() + + state = hass.states.get("climate.saunum_leil") + assert state is not None + assert state.attributes.get(ATTR_PRESET_MODE) == expected_preset + + +async def test_preset_mode_attributes_custom_names( + hass: HomeAssistant, + mock_saunum_client, +) -> None: + """Test preset mode attributes with custom names.""" + custom_options = { + OPT_PRESET_NAME_TYPE_1: "Finnish Sauna", + OPT_PRESET_NAME_TYPE_2: "Turkish Bath", + OPT_PRESET_NAME_TYPE_3: "Steam Room", + } + mock_config_entry = MockConfigEntry( + domain="saunum", + data={"host": "192.168.1.100"}, + options=custom_options, + title="Saunum", + ) + mock_saunum_client.async_get_data.return_value.sauna_type = 1 + + 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() + + state = hass.states.get("climate.saunum_leil") + assert state is not None + assert state.attributes.get(ATTR_PRESET_MODE) == "Turkish Bath" + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.saunum_leil", ATTR_PRESET_MODE: "Steam Room"}, + blocking=True, + ) + mock_saunum_client.async_set_sauna_type.assert_called_once_with(2) + + +async def test_preset_mode_options_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_saunum_client, +) -> None: + """Test that preset names update when options are changed.""" entity_id = "climate.saunum_leil" + 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() + + state = hass.states.get("climate.saunum_leil") + assert state is not None + assert "type_1" in state.attributes.get("preset_modes", []) + + custom_options = { + OPT_PRESET_NAME_TYPE_1: "Custom Type 1", + OPT_PRESET_NAME_TYPE_2: "Custom Type 2", + OPT_PRESET_NAME_TYPE_3: "Custom Type 3", + } + hass.config_entries.async_update_entry(mock_config_entry, options=custom_options) + await hass.async_block_till_done() + + state = hass.states.get("climate.saunum_leil") + assert state is not None + assert "Custom Type 1" in state.attributes.get("preset_modes", []) + assert "type_1" not in state.attributes.get("preset_modes", []) # Try to set fan mode and expect error with pytest.raises( ServiceValidationError, diff --git a/tests/components/saunum/test_config_flow.py b/tests/components/saunum/test_config_flow.py index 30ea1b2b0b1..7f720f445c3 100644 --- a/tests/components/saunum/test_config_flow.py +++ b/tests/components/saunum/test_config_flow.py @@ -7,7 +7,12 @@ from unittest.mock import AsyncMock from pysaunum import SaunumConnectionError, SaunumException import pytest -from homeassistant.components.saunum.const import DOMAIN +from homeassistant.components.saunum.const import ( + DOMAIN, + OPT_PRESET_NAME_TYPE_1, + OPT_PRESET_NAME_TYPE_2, + OPT_PRESET_NAME_TYPE_3, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -201,3 +206,82 @@ async def test_reconfigure_to_existing_host( # Verify the original entry was not changed assert mock_config_entry.data == TEST_USER_INPUT + + +@pytest.mark.usefixtures("mock_saunum_client") +async def test_options_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test options flow for configuring preset names.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # Configure custom preset names + custom_options = { + OPT_PRESET_NAME_TYPE_1: "Finnish Sauna", + OPT_PRESET_NAME_TYPE_2: "Turkish Bath", + OPT_PRESET_NAME_TYPE_3: "Steam Room", + } + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=custom_options, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == custom_options + assert mock_config_entry.options == custom_options + + +@pytest.mark.usefixtures("mock_saunum_client") +async def test_options_flow_with_existing_options( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test options flow with existing custom preset names.""" + existing_options = { + OPT_PRESET_NAME_TYPE_1: "My Custom Type 1", + OPT_PRESET_NAME_TYPE_2: "My Custom Type 2", + OPT_PRESET_NAME_TYPE_3: "My Custom Type 3", + } + + # Set up entry with existing options + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_USER_INPUT, + options=existing_options, + title="Saunum", + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # Update one option + updated_options = { + OPT_PRESET_NAME_TYPE_1: "Updated Type 1", + OPT_PRESET_NAME_TYPE_2: "My Custom Type 2", + OPT_PRESET_NAME_TYPE_3: "My Custom Type 3", + } + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=updated_options, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == updated_options + assert mock_config_entry.options == updated_options diff --git a/tests/components/saunum/test_number.py b/tests/components/saunum/test_number.py index dea753e0f40..80b5dcd68fa 100644 --- a/tests/components/saunum/test_number.py +++ b/tests/components/saunum/test_number.py @@ -136,7 +136,7 @@ async def test_set_value_while_session_active( # Attempt to set value should raise ServiceValidationError with pytest.raises( ServiceValidationError, - match="Cannot change sauna duration while session is active", + match="Cannot change sauna duration while sauna session is active", ): await hass.services.async_call( NUMBER_DOMAIN,