1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Add Start session action to Saunum integration (#162177)

This commit is contained in:
mettolen
2026-02-06 13:47:50 +02:00
committed by GitHub
parent 286730165d
commit d02adabe5d
9 changed files with 277 additions and 30 deletions

View File

@@ -8,8 +8,12 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import LeilSaunaCoordinator
from .services import async_setup_services
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@@ -19,9 +23,17 @@ PLATFORMS: list[Platform] = [
Platform.SENSOR,
]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Saunum component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool:
"""Set up Saunum Leil Sauna from a config entry."""
host = entry.data[CONF_HOST]

View File

@@ -238,3 +238,33 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
) from err
await self.coordinator.async_request_refresh()
async def async_start_session(
self,
duration: int = 120,
target_temperature: int = 80,
fan_duration: int = 10,
) -> None:
"""Start a sauna session with custom parameters."""
if self.coordinator.data.door_open:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="door_open",
)
try:
# Set all parameters before starting the session
await self.coordinator.client.async_set_sauna_duration(duration)
await self.coordinator.client.async_set_target_temperature(
target_temperature
)
await self.coordinator.client.async_set_fan_duration(fan_duration)
await self.coordinator.client.async_start_session()
except SaunumException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="start_session_failed",
translation_placeholders={"error": str(err)},
) from err
await self.coordinator.async_request_refresh()

View File

@@ -27,5 +27,10 @@
"default": "mdi:radiator"
}
}
},
"services": {
"start_session": {
"service": "mdi:heat-wave"
}
}
}

View File

@@ -1,17 +1,13 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done

View File

@@ -0,0 +1,41 @@
"""Define services for the Saunum integration."""
from __future__ import annotations
from pysaunum import MAX_DURATION, MAX_FAN_DURATION, MAX_TEMPERATURE, MIN_TEMPERATURE
import voluptuous as vol
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
ATTR_DURATION = "duration"
ATTR_TARGET_TEMPERATURE = "target_temperature"
ATTR_FAN_DURATION = "fan_duration"
SERVICE_START_SESSION = "start_session"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services for the Saunum integration."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_START_SESSION,
entity_domain=CLIMATE_DOMAIN,
schema={
vol.Optional(ATTR_DURATION, default=120): vol.All(
cv.positive_int, vol.Range(min=1, max=MAX_DURATION)
),
vol.Optional(ATTR_TARGET_TEMPERATURE, default=80): vol.All(
cv.positive_int, vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
),
vol.Optional(ATTR_FAN_DURATION, default=10): vol.All(
cv.positive_int, vol.Range(min=1, max=MAX_FAN_DURATION)
),
},
func="async_start_session",
)

View File

@@ -0,0 +1,23 @@
start_session:
target:
entity:
domain: climate
integration: saunum
fields:
duration:
required: false
selector:
duration:
target_temperature:
required: false
example: 80
default: 80
selector:
number:
min: 40
max: 100
unit_of_measurement: "°C"
fan_duration:
required: false
selector:
duration:

View File

@@ -128,6 +128,9 @@
},
"set_temperature_failed": {
"message": "Failed to set temperature to {temperature}"
},
"start_session_failed": {
"message": "Failed to start sauna session: {error}"
}
},
"options": {
@@ -146,5 +149,25 @@
"description": "Customize the names of the three sauna type preset modes"
}
}
},
"services": {
"start_session": {
"description": "Starts a sauna session with custom duration, target temperature, and fan duration.",
"fields": {
"duration": {
"description": "Session duration in minutes.",
"name": "Duration"
},
"fan_duration": {
"description": "Fan duration in minutes.",
"name": "Fan duration"
},
"target_temperature": {
"description": "Target temperature in Celsius.",
"name": "Target temperature"
}
},
"name": "Start session"
}
}
}

View File

@@ -42,7 +42,31 @@ def mock_config_entry() -> MockConfigEntry:
@pytest.fixture
def mock_saunum_client_class() -> Generator[MagicMock]:
def mock_saunum_data() -> SaunumData:
"""Return default mock Saunum data."""
return SaunumData(
session_active=False,
sauna_type=0,
sauna_duration=120,
fan_duration=10,
target_temperature=80,
fan_speed=2,
light_on=False,
current_temperature=75.0,
on_time=3600,
heater_elements_active=0,
door_open=False,
alarm_door_open=False,
alarm_door_sensor=False,
alarm_thermal_cutoff=False,
alarm_internal_temp=False,
alarm_temp_sensor_short=False,
alarm_temp_sensor_open=False,
)
@pytest.fixture
def mock_saunum_client_class(mock_saunum_data: SaunumData) -> Generator[MagicMock]:
"""Return a mocked Saunum client class for config flow and integration tests."""
with (
patch(
@@ -54,29 +78,7 @@ def mock_saunum_client_class() -> Generator[MagicMock]:
mock_client.is_connected = True
mock_client_class.create = AsyncMock(return_value=mock_client)
# Create mock data for async_get_data
mock_data = SaunumData(
session_active=False,
sauna_type=0,
sauna_duration=120,
fan_duration=10,
target_temperature=80,
fan_speed=2,
light_on=False,
current_temperature=75.0,
on_time=3600,
heater_elements_active=0,
door_open=False,
alarm_door_open=False,
alarm_door_sensor=False,
alarm_thermal_cutoff=False,
alarm_internal_temp=False,
alarm_temp_sensor_short=False,
alarm_temp_sensor_open=False,
)
mock_client.async_get_data.return_value = mock_data
mock_client.async_get_data.return_value = mock_saunum_data
yield mock_client_class

View File

@@ -0,0 +1,115 @@
"""Tests for Saunum services."""
from unittest.mock import MagicMock
from pysaunum import SaunumData, SaunumException
import pytest
from homeassistant.components.saunum.const import DOMAIN
from homeassistant.components.saunum.services import (
ATTR_DURATION,
ATTR_FAN_DURATION,
ATTR_TARGET_TEMPERATURE,
SERVICE_START_SESSION,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from tests.common import MockConfigEntry
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.CLIMATE]
async def test_start_session_success(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_saunum_client: MagicMock,
) -> None:
"""Test start_session service success."""
await hass.services.async_call(
DOMAIN,
SERVICE_START_SESSION,
{
ATTR_ENTITY_ID: "climate.saunum_leil",
ATTR_DURATION: 120,
ATTR_TARGET_TEMPERATURE: 80,
ATTR_FAN_DURATION: 10,
},
blocking=True,
)
mock_saunum_client.async_set_sauna_duration.assert_called_once_with(120)
mock_saunum_client.async_set_target_temperature.assert_called_once_with(80)
mock_saunum_client.async_set_fan_duration.assert_called_once_with(10)
mock_saunum_client.async_start_session.assert_called_once()
async def test_start_session_with_defaults(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_saunum_client: MagicMock,
) -> None:
"""Test start_session service uses defaults when optional fields omitted."""
await hass.services.async_call(
DOMAIN,
SERVICE_START_SESSION,
{ATTR_ENTITY_ID: "climate.saunum_leil"},
blocking=True,
)
# Defaults: duration=120, target_temperature=80, fan_duration=10
mock_saunum_client.async_set_sauna_duration.assert_called_once_with(120)
mock_saunum_client.async_set_target_temperature.assert_called_once_with(80)
mock_saunum_client.async_set_fan_duration.assert_called_once_with(10)
mock_saunum_client.async_start_session.assert_called_once()
async def test_start_session_door_open(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_saunum_client: MagicMock,
mock_saunum_data: SaunumData,
) -> None:
"""Test start_session service fails when door is open."""
mock_saunum_client.async_get_data.return_value = SaunumData(
**{**mock_saunum_data.__dict__, "door_open": True}
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
SERVICE_START_SESSION,
{ATTR_ENTITY_ID: "climate.saunum_leil"},
blocking=True,
)
assert exc_info.value.translation_key == "door_open"
async def test_start_session_communication_error(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_saunum_client: MagicMock,
) -> None:
"""Test start_session service handles communication error."""
mock_saunum_client.async_set_sauna_duration.side_effect = SaunumException(
"Connection lost"
)
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
SERVICE_START_SESSION,
{ATTR_ENTITY_ID: "climate.saunum_leil"},
blocking=True,
)
assert exc_info.value.translation_key == "start_session_failed"