Add Saunum integration (#155099)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
mettolen
2025-11-14 20:55:55 +02:00
committed by GitHub
parent 84b0d39763
commit d1dea85cf5
21 changed files with 1008 additions and 0 deletions

2
CODEOWNERS generated
View File

@@ -1376,6 +1376,8 @@ build.json @home-assistant/supervisor
/tests/components/sanix/ @tomaszsluszniak
/homeassistant/components/satel_integra/ @Tommatheussen
/tests/components/satel_integra/ @Tommatheussen
/homeassistant/components/saunum/ @mettolen
/tests/components/saunum/ @mettolen
/homeassistant/components/scene/ @home-assistant/core
/tests/components/scene/ @home-assistant/core
/homeassistant/components/schedule/ @home-assistant/core

View File

@@ -0,0 +1,50 @@
"""The Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
import logging
from pysaunum import SaunumClient, SaunumConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import PLATFORMS
from .coordinator import LeilSaunaCoordinator
_LOGGER = logging.getLogger(__name__)
type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool:
"""Set up Saunum Leil Sauna from a config entry."""
host = entry.data[CONF_HOST]
client = SaunumClient(host=host)
# Test connection
try:
await client.connect()
except SaunumConnectionError as exc:
raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc
coordinator = LeilSaunaCoordinator(hass, client, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
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()
return unload_ok

View File

@@ -0,0 +1,107 @@
"""Climate platform for Saunum Leil Sauna Control Unit."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from pysaunum import MAX_TEMPERATURE, MIN_TEMPERATURE, SaunumException
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LeilSaunaConfigEntry
from .const import DELAYED_REFRESH_SECONDS
from .entity import LeilSaunaEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: LeilSaunaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Saunum Leil Sauna climate entity."""
coordinator = entry.runtime_data
async_add_entities([LeilSaunaClimate(coordinator)])
class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
"""Representation of a Saunum Leil Sauna climate entity."""
_attr_name = None
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temp = MIN_TEMPERATURE
_attr_max_temp = MAX_TEMPERATURE
@property
def current_temperature(self) -> float | None:
"""Return the current temperature in Celsius."""
return self.coordinator.data.current_temperature
@property
def target_temperature(self) -> float | None:
"""Return the target temperature in Celsius."""
return self.coordinator.data.target_temperature
@property
def hvac_mode(self) -> HVACMode:
"""Return current HVAC mode."""
session_active = self.coordinator.data.session_active
return HVACMode.HEAT if session_active else HVACMode.OFF
@property
def hvac_action(self) -> HVACAction | None:
"""Return current HVAC action."""
if not self.coordinator.data.session_active:
return HVACAction.OFF
heater_elements_active = self.coordinator.data.heater_elements_active
return (
HVACAction.HEATING
if heater_elements_active and heater_elements_active > 0
else HVACAction.IDLE
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new HVAC mode."""
try:
if hvac_mode == HVACMode.HEAT:
await self.coordinator.client.async_start_session()
else:
await self.coordinator.client.async_stop_session()
except SaunumException as err:
raise HomeAssistantError(f"Failed to set HVAC mode to {hvac_mode}") from err
# The device takes 1-2 seconds to turn heater elements on/off and
# update heater_elements_active. Wait and refresh again to ensure
# the HVAC action state reflects the actual heater status.
await asyncio.sleep(DELAYED_REFRESH_SECONDS.total_seconds())
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
try:
await self.coordinator.client.async_set_target_temperature(
int(kwargs[ATTR_TEMPERATURE])
)
except SaunumException as err:
raise HomeAssistantError(
f"Failed to set temperature to {kwargs[ATTR_TEMPERATURE]}"
) from err
await self.coordinator.async_request_refresh()

View File

@@ -0,0 +1,76 @@
"""Config flow for Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
import logging
from typing import Any
from pysaunum import SaunumClient, SaunumException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
}
)
async def validate_input(data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
host = data[CONF_HOST]
client = SaunumClient(host=host)
try:
await client.connect()
# Try to read data to verify communication
await client.async_get_data()
finally:
client.close()
class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Saunum Leil Sauna Control Unit."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
# Check for duplicate configuration
self._async_abort_entries_match(user_input)
try:
await validate_input(user_input)
except SaunumException:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="Saunum Leil Sauna",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)

View File

@@ -0,0 +1,16 @@
"""Constants for the Saunum Leil Sauna Control Unit integration."""
from datetime import timedelta
from typing import Final
from homeassistant.const import Platform
DOMAIN: Final = "saunum"
# Platforms
PLATFORMS: list[Platform] = [
Platform.CLIMATE,
]
DEFAULT_SCAN_INTERVAL: Final = timedelta(seconds=60)
DELAYED_REFRESH_SECONDS: Final = timedelta(seconds=3)

View File

@@ -0,0 +1,47 @@
"""Coordinator for Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from pysaunum import SaunumClient, SaunumData, SaunumException
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
if TYPE_CHECKING:
from . import LeilSaunaConfigEntry
_LOGGER = logging.getLogger(__name__)
class LeilSaunaCoordinator(DataUpdateCoordinator[SaunumData]):
"""Coordinator for fetching Saunum Leil Sauna data."""
config_entry: LeilSaunaConfigEntry
def __init__(
self,
hass: HomeAssistant,
client: SaunumClient,
config_entry: LeilSaunaConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=DEFAULT_SCAN_INTERVAL,
config_entry=config_entry,
)
self.client = client
async def _async_update_data(self) -> SaunumData:
"""Fetch data from the sauna controller."""
try:
return await self.client.async_get_data()
except SaunumException as err:
raise UpdateFailed(f"Communication error: {err}") from err

View File

@@ -0,0 +1,27 @@
"""Base entity for Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LeilSaunaCoordinator
class LeilSaunaEntity(CoordinatorEntity[LeilSaunaCoordinator]):
"""Base entity for Saunum Leil Sauna."""
_attr_has_entity_name = True
def __init__(self, coordinator: LeilSaunaCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
entry_id = coordinator.config_entry.entry_id
self._attr_unique_id = entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry_id)},
name="Saunum Leil",
manufacturer="Saunum",
model="Leil Touch Panel",
)

View File

@@ -0,0 +1,12 @@
{
"domain": "saunum",
"name": "Saunum Leil",
"codeowners": ["@mettolen"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/saunum",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pysaunum"],
"quality_scale": "silver",
"requirements": ["pysaunum==0.1.0"]
}

View File

@@ -0,0 +1,74 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
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-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver tier
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: Modbus TCP does not require authentication.
test-coverage: done
# Gold tier
devices: done
diagnostics: todo
discovery:
status: exempt
comment: Device uses generic Espressif hardware with no unique identifying information (MAC OUI or hostname) that would distinguish it from other Espressif-based devices on the network.
discovery-update-info: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Integration controls a single device; no dynamic device discovery needed.
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: exempt
comment: Integration controls a single device; no dynamic device discovery needed.
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -0,0 +1,22 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::ip%]"
},
"data_description": {
"host": "IP address of your Saunum Leil sauna control unit"
},
"description": "To find the IP address, navigate to Settings → Modbus Settings on your Leil touch panel"
}
}
}
}

View File

@@ -575,6 +575,7 @@ FLOWS = {
"samsungtv",
"sanix",
"satel_integra",
"saunum",
"schlage",
"scrape",
"screenlogic",

View File

@@ -5757,6 +5757,12 @@
"config_flow": true,
"iot_class": "local_push"
},
"saunum": {
"name": "Saunum Leil",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"schlage": {
"name": "Schlage",
"integration_type": "hub",

3
requirements_all.txt generated
View File

@@ -2339,6 +2339,9 @@ pysabnzbd==1.1.1
# homeassistant.components.saj
pysaj==0.0.16
# homeassistant.components.saunum
pysaunum==0.1.0
# homeassistant.components.schlage
pyschlage==2025.9.0

View File

@@ -1950,6 +1950,9 @@ pyrympro==0.0.9
# homeassistant.components.sabnzbd
pysabnzbd==1.1.1
# homeassistant.components.saunum
pysaunum==0.1.0
# homeassistant.components.schlage
pyschlage==2025.9.0

View File

@@ -0,0 +1 @@
"""Tests for the Saunum integration."""

View File

@@ -0,0 +1,77 @@
"""Configuration for Saunum Leil integration tests."""
from collections.abc import Generator
from unittest.mock import MagicMock, patch
from pysaunum import SaunumData
import pytest
from homeassistant.components.saunum.const import DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
entry_id="01K98T2T85R5GN0ZHYV25VFMMA",
title="Saunum Leil Sauna",
domain=DOMAIN,
data={CONF_HOST: "192.168.1.100"},
)
@pytest.fixture
def mock_saunum_client() -> Generator[MagicMock]:
"""Return a mocked Saunum client for config flow and integration tests."""
with (
patch(
"homeassistant.components.saunum.config_flow.SaunumClient", autospec=True
) as mock_client_class,
patch("homeassistant.components.saunum.SaunumClient", new=mock_client_class),
):
mock_client = mock_client_class.return_value
mock_client.is_connected = True
# 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
yield mock_client
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_saunum_client: MagicMock,
) -> MockConfigEntry:
"""Set up the integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View File

@@ -0,0 +1,66 @@
# serializer version: 1
# name: test_entities[climate.saunum_leil-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 100,
'min_temp': 40,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.saunum_leil',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'saunum',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 1>,
'translation_key': None,
'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA',
'unit_of_measurement': None,
})
# ---
# name: test_entities[climate.saunum_leil-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 75.0,
'friendly_name': 'Saunum Leil',
'hvac_action': <HVACAction.OFF: 'off'>,
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 100,
'min_temp': 40,
'supported_features': <ClimateEntityFeature: 1>,
'temperature': 80,
}),
'context': <ANY>,
'entity_id': 'climate.saunum_leil',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -0,0 +1,32 @@
# serializer version: 1
# name: test_device_entry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'saunum',
'01K98T2T85R5GN0ZHYV25VFMMA',
),
}),
'labels': set({
}),
'manufacturer': 'Saunum',
'model': 'Leil Touch Panel',
'model_id': None,
'name': 'Saunum Leil',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---

View File

@@ -0,0 +1,232 @@
"""Test the Saunum climate platform."""
from __future__ import annotations
from dataclasses import replace
from freezegun.api import FrozenDateTimeFactory
from pysaunum import SaunumException
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.climate import (
ATTR_CURRENT_TEMPERATURE,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
DOMAIN as CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_TEMPERATURE,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("service", "service_data", "client_method", "expected_args"),
[
(
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.HEAT},
"async_start_session",
(),
),
(
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.OFF},
"async_stop_session",
(),
),
(
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: 85},
"async_set_target_temperature",
(85,),
),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_climate_service_calls(
hass: HomeAssistant,
mock_saunum_client,
service: str,
service_data: dict,
client_method: str,
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},
blocking=True,
)
getattr(mock_saunum_client, client_method).assert_called_once_with(*expected_args)
@pytest.mark.parametrize(
("heater_elements_active", "expected_hvac_action"),
[
(3, HVACAction.HEATING),
(0, HVACAction.IDLE),
],
)
async def test_climate_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
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)
assert state is not None
assert state.state == HVACMode.HEAT
assert state.attributes.get(ATTR_HVAC_ACTION) == expected_hvac_action
@pytest.mark.parametrize(
(
"current_temperature",
"target_temperature",
"expected_current",
"expected_target",
),
[
(None, 80, None, 80),
(35.0, 30, 35, 30),
],
)
async def test_climate_temperature_edge_cases(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_saunum_client,
current_temperature: float | None,
target_temperature: int,
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
base_data = mock_saunum_client.async_get_data.return_value
mock_saunum_client.async_get_data.return_value = replace(
base_data,
current_temperature=current_temperature,
target_temperature=target_temperature,
)
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)
assert state is not None
assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == expected_current
assert state.attributes.get(ATTR_TEMPERATURE) == expected_target
@pytest.mark.usefixtures("init_integration")
async def test_entity_unavailable_on_update_failure(
hass: HomeAssistant,
mock_saunum_client,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that entity becomes unavailable when coordinator update fails."""
entity_id = "climate.saunum_leil"
# Verify entity is initially available
state = hass.states.get(entity_id)
assert state is not None
assert state.state != STATE_UNAVAILABLE
# Make the next update fail
mock_saunum_client.async_get_data.side_effect = SaunumException("Read error")
# Move time forward to trigger a coordinator update (60 seconds)
freezer.tick(60)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Entity should now be unavailable
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
("service", "service_data", "client_method", "error_match"),
[
(
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.HEAT},
"async_start_session",
"Failed to set HVAC mode",
),
(
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: 85},
"async_set_target_temperature",
"Failed to set temperature",
),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_action_error_handling(
hass: HomeAssistant,
mock_saunum_client,
service: str,
service_data: dict,
client_method: str,
error_match: str,
) -> None:
"""Test error handling when climate actions fail."""
entity_id = "climate.saunum_leil"
# Make the client method raise an exception
getattr(mock_saunum_client, client_method).side_effect = SaunumException(
"Communication error"
)
# Attempt to call service should raise HomeAssistantError
with pytest.raises(HomeAssistantError, match=error_match):
await hass.services.async_call(
CLIMATE_DOMAIN,
service,
{ATTR_ENTITY_ID: entity_id, **service_data},
blocking=True,
)

View File

@@ -0,0 +1,98 @@
"""Test the Saunum config flow."""
from __future__ import annotations
from pysaunum import SaunumConnectionError, SaunumException
import pytest
from homeassistant.components.saunum.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
TEST_USER_INPUT = {CONF_HOST: "192.168.1.100"}
@pytest.mark.usefixtures("mock_saunum_client")
async def test_full_flow(hass: HomeAssistant) -> None:
"""Test full flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert not result["errors"]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_USER_INPUT,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Saunum Leil Sauna"
assert result["data"] == TEST_USER_INPUT
@pytest.mark.parametrize(
("side_effect", "error_base"),
[
(SaunumConnectionError("Connection failed"), "cannot_connect"),
(SaunumException("Read error"), "cannot_connect"),
(Exception("Unexpected error"), "unknown"),
],
)
async def test_form_errors(
hass: HomeAssistant,
mock_saunum_client,
side_effect: Exception,
error_base: str,
) -> None:
"""Test error handling and recovery."""
mock_saunum_client.connect.side_effect = side_effect
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_USER_INPUT,
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error_base}
# Test recovery - clear the error and try again
mock_saunum_client.connect.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_USER_INPUT,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Saunum Leil Sauna"
assert result["data"] == TEST_USER_INPUT
@pytest.mark.usefixtures("mock_saunum_client")
async def test_form_duplicate(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test duplicate entry handling."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_USER_INPUT,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@@ -0,0 +1,56 @@
"""Test Saunum Leil integration setup and teardown."""
from pysaunum import SaunumConnectionError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.saunum.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
async def test_setup_and_unload(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_saunum_client,
) -> None:
"""Test integration setup and unload."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_async_setup_entry_connection_failed(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_saunum_client,
) -> None:
"""Test integration setup fails when connection cannot be established."""
mock_config_entry.add_to_hass(hass)
mock_saunum_client.connect.side_effect = SaunumConnectionError("Connection failed")
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("init_integration")
async def test_device_entry(
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test device registry entry."""
assert (
device_entry := device_registry.async_get_device(
identifiers={(DOMAIN, "01K98T2T85R5GN0ZHYV25VFMMA")}
)
)
assert device_entry == snapshot