diff --git a/CODEOWNERS b/CODEOWNERS index 695537acacf..b868e9eee5d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py new file mode 100644 index 00000000000..e9bd9fb4020 --- /dev/null +++ b/homeassistant/components/saunum/__init__.py @@ -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 diff --git a/homeassistant/components/saunum/climate.py b/homeassistant/components/saunum/climate.py new file mode 100644 index 00000000000..d4b48423e5f --- /dev/null +++ b/homeassistant/components/saunum/climate.py @@ -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() diff --git a/homeassistant/components/saunum/config_flow.py b/homeassistant/components/saunum/config_flow.py new file mode 100644 index 00000000000..d1e60dc0ede --- /dev/null +++ b/homeassistant/components/saunum/config_flow.py @@ -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, + ) diff --git a/homeassistant/components/saunum/const.py b/homeassistant/components/saunum/const.py new file mode 100644 index 00000000000..70ab3a988fd --- /dev/null +++ b/homeassistant/components/saunum/const.py @@ -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) diff --git a/homeassistant/components/saunum/coordinator.py b/homeassistant/components/saunum/coordinator.py new file mode 100644 index 00000000000..867e9715c97 --- /dev/null +++ b/homeassistant/components/saunum/coordinator.py @@ -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 diff --git a/homeassistant/components/saunum/entity.py b/homeassistant/components/saunum/entity.py new file mode 100644 index 00000000000..c0ed7bad517 --- /dev/null +++ b/homeassistant/components/saunum/entity.py @@ -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", + ) diff --git a/homeassistant/components/saunum/manifest.json b/homeassistant/components/saunum/manifest.json new file mode 100644 index 00000000000..7a0c682547b --- /dev/null +++ b/homeassistant/components/saunum/manifest.json @@ -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"] +} diff --git a/homeassistant/components/saunum/quality_scale.yaml b/homeassistant/components/saunum/quality_scale.yaml new file mode 100644 index 00000000000..c7a52ef2f85 --- /dev/null +++ b/homeassistant/components/saunum/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/saunum/strings.json b/homeassistant/components/saunum/strings.json new file mode 100644 index 00000000000..94d9727d68a --- /dev/null +++ b/homeassistant/components/saunum/strings.json @@ -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" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2e27f344ef1..fa45a9defb1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -575,6 +575,7 @@ FLOWS = { "samsungtv", "sanix", "satel_integra", + "saunum", "schlage", "scrape", "screenlogic", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6ad3ae487bf..ddff8ad4232 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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", diff --git a/requirements_all.txt b/requirements_all.txt index 1bd9f9fb7d5..a39c25c8a66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f40743a25b..79ac88818b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/saunum/__init__.py b/tests/components/saunum/__init__.py new file mode 100644 index 00000000000..007297a4e81 --- /dev/null +++ b/tests/components/saunum/__init__.py @@ -0,0 +1 @@ +"""Tests for the Saunum integration.""" diff --git a/tests/components/saunum/conftest.py b/tests/components/saunum/conftest.py new file mode 100644 index 00000000000..fa97c41d123 --- /dev/null +++ b/tests/components/saunum/conftest.py @@ -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 diff --git a/tests/components/saunum/snapshots/test_climate.ambr b/tests/components/saunum/snapshots/test_climate.ambr new file mode 100644 index 00000000000..573ab535455 --- /dev/null +++ b/tests/components/saunum/snapshots/test_climate.ambr @@ -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([ + , + , + ]), + 'max_temp': 100, + 'min_temp': 40, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.saunum_leil', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + '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': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 100, + 'min_temp': 40, + 'supported_features': , + 'temperature': 80, + }), + 'context': , + 'entity_id': 'climate.saunum_leil', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/saunum/snapshots/test_init.ambr b/tests/components/saunum/snapshots/test_init.ambr new file mode 100644 index 00000000000..473bfe6ce13 --- /dev/null +++ b/tests/components/saunum/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_device_entry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + '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': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/saunum/test_climate.py b/tests/components/saunum/test_climate.py new file mode 100644 index 00000000000..d636d9136e8 --- /dev/null +++ b/tests/components/saunum/test_climate.py @@ -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, + ) diff --git a/tests/components/saunum/test_config_flow.py b/tests/components/saunum/test_config_flow.py new file mode 100644 index 00000000000..f5b3f6e6b39 --- /dev/null +++ b/tests/components/saunum/test_config_flow.py @@ -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" diff --git a/tests/components/saunum/test_init.py b/tests/components/saunum/test_init.py new file mode 100644 index 00000000000..fe50e182f84 --- /dev/null +++ b/tests/components/saunum/test_init.py @@ -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