Add configurable sauna types to Saunum integration (#159782)

This commit is contained in:
mettolen
2026-01-18 14:43:11 +02:00
committed by GitHub
parent 59776adeb3
commit 54fc963297
12 changed files with 547 additions and 149 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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,
}
),
)

View File

@@ -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"

View File

@@ -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,

View File

@@ -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"

View File

@@ -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"
}
}
}
}

View File

@@ -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': <ANY>,
@@ -43,8 +48,8 @@
'platform': 'saunum',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 9>,
'translation_key': None,
'supported_features': <ClimateEntityFeature: 25>,
'translation_key': 'saunum_climate',
'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA',
'unit_of_measurement': None,
})
@@ -68,7 +73,13 @@
]),
'max_temp': 100,
'min_temp': 40,
'supported_features': <ClimateEntityFeature: 9>,
'preset_mode': 'type_1',
'preset_modes': list([
'type_1',
'type_2',
'type_3',
]),
'supported_features': <ClimateEntityFeature: 25>,
'target_temp_step': 1.0,
'temperature': 80,
}),

View File

@@ -33,5 +33,7 @@
'last_update_success': True,
'update_interval': '0:01:00',
}),
'options': dict({
}),
})
# ---

View File

@@ -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,

View File

@@ -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

View File

@@ -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,