mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 05:06:13 +01:00
Add configurable sauna types to Saunum integration (#159782)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -33,5 +33,7 @@
|
||||
'last_update_success': True,
|
||||
'update_interval': '0:01:00',
|
||||
}),
|
||||
'options': dict({
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user