Skip to content

Commit d1dea85

Browse files
mettolenjoostlek
andauthored
Add Saunum integration (home-assistant#155099)
Co-authored-by: Joostlek <[email protected]>
1 parent 84b0d39 commit d1dea85

21 files changed

+1008
-0
lines changed

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""The Saunum Leil Sauna Control Unit integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
7+
from pysaunum import SaunumClient, SaunumConnectionError
8+
9+
from homeassistant.config_entries import ConfigEntry
10+
from homeassistant.const import CONF_HOST
11+
from homeassistant.core import HomeAssistant
12+
from homeassistant.exceptions import ConfigEntryNotReady
13+
14+
from .const import PLATFORMS
15+
from .coordinator import LeilSaunaCoordinator
16+
17+
_LOGGER = logging.getLogger(__name__)
18+
19+
type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator]
20+
21+
22+
async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool:
23+
"""Set up Saunum Leil Sauna from a config entry."""
24+
host = entry.data[CONF_HOST]
25+
26+
client = SaunumClient(host=host)
27+
28+
# Test connection
29+
try:
30+
await client.connect()
31+
except SaunumConnectionError as exc:
32+
raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc
33+
34+
coordinator = LeilSaunaCoordinator(hass, client, entry)
35+
await coordinator.async_config_entry_first_refresh()
36+
37+
entry.runtime_data = coordinator
38+
39+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
40+
41+
return True
42+
43+
44+
async def async_unload_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool:
45+
"""Unload a config entry."""
46+
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
47+
coordinator = entry.runtime_data
48+
coordinator.client.close()
49+
50+
return unload_ok
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Climate platform for Saunum Leil Sauna Control Unit."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import logging
7+
from typing import Any
8+
9+
from pysaunum import MAX_TEMPERATURE, MIN_TEMPERATURE, SaunumException
10+
11+
from homeassistant.components.climate import (
12+
ClimateEntity,
13+
ClimateEntityFeature,
14+
HVACAction,
15+
HVACMode,
16+
)
17+
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
18+
from homeassistant.core import HomeAssistant
19+
from homeassistant.exceptions import HomeAssistantError
20+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
21+
22+
from . import LeilSaunaConfigEntry
23+
from .const import DELAYED_REFRESH_SECONDS
24+
from .entity import LeilSaunaEntity
25+
26+
_LOGGER = logging.getLogger(__name__)
27+
28+
PARALLEL_UPDATES = 1
29+
30+
31+
async def async_setup_entry(
32+
hass: HomeAssistant,
33+
entry: LeilSaunaConfigEntry,
34+
async_add_entities: AddConfigEntryEntitiesCallback,
35+
) -> None:
36+
"""Set up Saunum Leil Sauna climate entity."""
37+
coordinator = entry.runtime_data
38+
async_add_entities([LeilSaunaClimate(coordinator)])
39+
40+
41+
class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
42+
"""Representation of a Saunum Leil Sauna climate entity."""
43+
44+
_attr_name = None
45+
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
46+
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
47+
_attr_temperature_unit = UnitOfTemperature.CELSIUS
48+
_attr_min_temp = MIN_TEMPERATURE
49+
_attr_max_temp = MAX_TEMPERATURE
50+
51+
@property
52+
def current_temperature(self) -> float | None:
53+
"""Return the current temperature in Celsius."""
54+
return self.coordinator.data.current_temperature
55+
56+
@property
57+
def target_temperature(self) -> float | None:
58+
"""Return the target temperature in Celsius."""
59+
return self.coordinator.data.target_temperature
60+
61+
@property
62+
def hvac_mode(self) -> HVACMode:
63+
"""Return current HVAC mode."""
64+
session_active = self.coordinator.data.session_active
65+
return HVACMode.HEAT if session_active else HVACMode.OFF
66+
67+
@property
68+
def hvac_action(self) -> HVACAction | None:
69+
"""Return current HVAC action."""
70+
if not self.coordinator.data.session_active:
71+
return HVACAction.OFF
72+
73+
heater_elements_active = self.coordinator.data.heater_elements_active
74+
return (
75+
HVACAction.HEATING
76+
if heater_elements_active and heater_elements_active > 0
77+
else HVACAction.IDLE
78+
)
79+
80+
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
81+
"""Set new HVAC mode."""
82+
try:
83+
if hvac_mode == HVACMode.HEAT:
84+
await self.coordinator.client.async_start_session()
85+
else:
86+
await self.coordinator.client.async_stop_session()
87+
except SaunumException as err:
88+
raise HomeAssistantError(f"Failed to set HVAC mode to {hvac_mode}") from err
89+
90+
# The device takes 1-2 seconds to turn heater elements on/off and
91+
# update heater_elements_active. Wait and refresh again to ensure
92+
# the HVAC action state reflects the actual heater status.
93+
await asyncio.sleep(DELAYED_REFRESH_SECONDS.total_seconds())
94+
await self.coordinator.async_request_refresh()
95+
96+
async def async_set_temperature(self, **kwargs: Any) -> None:
97+
"""Set new target temperature."""
98+
try:
99+
await self.coordinator.client.async_set_target_temperature(
100+
int(kwargs[ATTR_TEMPERATURE])
101+
)
102+
except SaunumException as err:
103+
raise HomeAssistantError(
104+
f"Failed to set temperature to {kwargs[ATTR_TEMPERATURE]}"
105+
) from err
106+
107+
await self.coordinator.async_request_refresh()
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Config flow for Saunum Leil Sauna Control Unit integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from pysaunum import SaunumClient, SaunumException
9+
import voluptuous as vol
10+
11+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
12+
from homeassistant.const import CONF_HOST
13+
from homeassistant.helpers import config_validation as cv
14+
15+
from .const import DOMAIN
16+
17+
_LOGGER = logging.getLogger(__name__)
18+
19+
STEP_USER_DATA_SCHEMA = vol.Schema(
20+
{
21+
vol.Required(CONF_HOST): cv.string,
22+
}
23+
)
24+
25+
26+
async def validate_input(data: dict[str, Any]) -> None:
27+
"""Validate the user input allows us to connect.
28+
29+
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
30+
"""
31+
host = data[CONF_HOST]
32+
33+
client = SaunumClient(host=host)
34+
35+
try:
36+
await client.connect()
37+
# Try to read data to verify communication
38+
await client.async_get_data()
39+
finally:
40+
client.close()
41+
42+
43+
class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
44+
"""Handle a config flow for Saunum Leil Sauna Control Unit."""
45+
46+
VERSION = 1
47+
MINOR_VERSION = 1
48+
49+
async def async_step_user(
50+
self, user_input: dict[str, Any] | None = None
51+
) -> ConfigFlowResult:
52+
"""Handle the initial step."""
53+
errors: dict[str, str] = {}
54+
55+
if user_input is not None:
56+
# Check for duplicate configuration
57+
self._async_abort_entries_match(user_input)
58+
59+
try:
60+
await validate_input(user_input)
61+
except SaunumException:
62+
errors["base"] = "cannot_connect"
63+
except Exception:
64+
_LOGGER.exception("Unexpected exception")
65+
errors["base"] = "unknown"
66+
else:
67+
return self.async_create_entry(
68+
title="Saunum Leil Sauna",
69+
data=user_input,
70+
)
71+
72+
return self.async_show_form(
73+
step_id="user",
74+
data_schema=STEP_USER_DATA_SCHEMA,
75+
errors=errors,
76+
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Constants for the Saunum Leil Sauna Control Unit integration."""
2+
3+
from datetime import timedelta
4+
from typing import Final
5+
6+
from homeassistant.const import Platform
7+
8+
DOMAIN: Final = "saunum"
9+
10+
# Platforms
11+
PLATFORMS: list[Platform] = [
12+
Platform.CLIMATE,
13+
]
14+
15+
DEFAULT_SCAN_INTERVAL: Final = timedelta(seconds=60)
16+
DELAYED_REFRESH_SECONDS: Final = timedelta(seconds=3)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Coordinator for Saunum Leil Sauna Control Unit integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import TYPE_CHECKING
7+
8+
from pysaunum import SaunumClient, SaunumData, SaunumException
9+
10+
from homeassistant.core import HomeAssistant
11+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
12+
13+
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
14+
15+
if TYPE_CHECKING:
16+
from . import LeilSaunaConfigEntry
17+
18+
_LOGGER = logging.getLogger(__name__)
19+
20+
21+
class LeilSaunaCoordinator(DataUpdateCoordinator[SaunumData]):
22+
"""Coordinator for fetching Saunum Leil Sauna data."""
23+
24+
config_entry: LeilSaunaConfigEntry
25+
26+
def __init__(
27+
self,
28+
hass: HomeAssistant,
29+
client: SaunumClient,
30+
config_entry: LeilSaunaConfigEntry,
31+
) -> None:
32+
"""Initialize the coordinator."""
33+
super().__init__(
34+
hass,
35+
_LOGGER,
36+
name=DOMAIN,
37+
update_interval=DEFAULT_SCAN_INTERVAL,
38+
config_entry=config_entry,
39+
)
40+
self.client = client
41+
42+
async def _async_update_data(self) -> SaunumData:
43+
"""Fetch data from the sauna controller."""
44+
try:
45+
return await self.client.async_get_data()
46+
except SaunumException as err:
47+
raise UpdateFailed(f"Communication error: {err}") from err
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Base entity for Saunum Leil Sauna Control Unit integration."""
2+
3+
from __future__ import annotations
4+
5+
from homeassistant.helpers.device_registry import DeviceInfo
6+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
7+
8+
from .const import DOMAIN
9+
from .coordinator import LeilSaunaCoordinator
10+
11+
12+
class LeilSaunaEntity(CoordinatorEntity[LeilSaunaCoordinator]):
13+
"""Base entity for Saunum Leil Sauna."""
14+
15+
_attr_has_entity_name = True
16+
17+
def __init__(self, coordinator: LeilSaunaCoordinator) -> None:
18+
"""Initialize the entity."""
19+
super().__init__(coordinator)
20+
entry_id = coordinator.config_entry.entry_id
21+
self._attr_unique_id = entry_id
22+
self._attr_device_info = DeviceInfo(
23+
identifiers={(DOMAIN, entry_id)},
24+
name="Saunum Leil",
25+
manufacturer="Saunum",
26+
model="Leil Touch Panel",
27+
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"domain": "saunum",
3+
"name": "Saunum Leil",
4+
"codeowners": ["@mettolen"],
5+
"config_flow": true,
6+
"documentation": "https://www.home-assistant.io/integrations/saunum",
7+
"integration_type": "device",
8+
"iot_class": "local_polling",
9+
"loggers": ["pysaunum"],
10+
"quality_scale": "silver",
11+
"requirements": ["pysaunum==0.1.0"]
12+
}

0 commit comments

Comments
 (0)