Add TOLO Sauna (tolo) integration (#55619)

This commit is contained in:
Matthias Lohr 2021-11-24 20:45:13 +01:00 committed by GitHub
parent 2439f6b562
commit a399037a46
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 560 additions and 0 deletions

View file

@ -1091,6 +1091,8 @@ omit =
homeassistant/components/todoist/calendar.py
homeassistant/components/todoist/const.py
homeassistant/components/tof/sensor.py
homeassistant/components/tolo/__init__.py
homeassistant/components/tolo/climate.py
homeassistant/components/tomato/device_tracker.py
homeassistant/components/toon/__init__.py
homeassistant/components/toon/binary_sensor.py

View file

@ -131,6 +131,7 @@ homeassistant.components.tautulli.*
homeassistant.components.tcp.*
homeassistant.components.tile.*
homeassistant.components.tplink.*
homeassistant.components.tolo.*
homeassistant.components.tractive.*
homeassistant.components.tradfri.*
homeassistant.components.tts.*

View file

@ -541,6 +541,7 @@ homeassistant/components/tile/* @bachya
homeassistant/components/time_date/* @fabaff
homeassistant/components/tmb/* @alemuro
homeassistant/components/todoist/* @boralyl
homeassistant/components/tolo/* @MatthiasLohr
homeassistant/components/totalconnect/* @austinmroczek
homeassistant/components/tplink/* @rytilahti @thegardenmonkey
homeassistant/components/traccar/* @ludeeus

View file

@ -0,0 +1,101 @@
"""Component to control TOLO Sauna/Steam Bath."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import NamedTuple
from tololib import ToloClient
from tololib.errors import ResponseTimedOutError
from tololib.message_info import SettingsInfo, StatusInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import DEFAULT_RETRY_COUNT, DEFAULT_RETRY_TIMEOUT, DOMAIN
PLATFORMS = ["climate"]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up tolo from a config entry."""
coordinator = ToloSaunaUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class ToloSaunaData(NamedTuple):
"""Compound class for reflecting full state (status and info) of a TOLO Sauna."""
status: StatusInfo
settings: SettingsInfo
class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]):
"""DataUpdateCoordinator for TOLO Sauna."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize ToloSaunaUpdateCoordinator."""
self.client = ToloClient(entry.data[CONF_HOST])
super().__init__(
hass=hass,
logger=_LOGGER,
name=f"{entry.title} ({entry.data[CONF_HOST]}) Data Update Coordinator",
update_interval=timedelta(seconds=3),
)
async def _async_update_data(self) -> ToloSaunaData:
return await self.hass.async_add_executor_job(self._get_tolo_sauna_data)
def _get_tolo_sauna_data(self) -> ToloSaunaData:
try:
status = self.client.get_status_info(
resend_timeout=DEFAULT_RETRY_TIMEOUT, retries=DEFAULT_RETRY_COUNT
)
settings = self.client.get_settings_info(
resend_timeout=DEFAULT_RETRY_TIMEOUT, retries=DEFAULT_RETRY_COUNT
)
return ToloSaunaData(status, settings)
except ResponseTimedOutError as error:
raise UpdateFailed("communication timeout") from error
class ToloSaunaCoordinatorEntity(CoordinatorEntity):
"""CoordinatorEntity for TOLO Sauna."""
coordinator: ToloSaunaUpdateCoordinator
def __init__(
self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry
) -> None:
"""Initialize ToloSaunaCoordinatorEntity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
name="TOLO Sauna",
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="SteamTec",
model=self.coordinator.data.status.model.name.capitalize(),
)

View file

@ -0,0 +1,156 @@
"""TOLO Sauna climate controls (main sauna control)."""
from __future__ import annotations
from typing import Any
from tololib.const import Calefaction
from homeassistant.components.climate import (
HVAC_MODE_HEAT,
HVAC_MODE_OFF,
ClimateEntity,
)
from homeassistant.components.climate.const import (
CURRENT_HVAC_DRY,
CURRENT_HVAC_HEAT,
CURRENT_HVAC_IDLE,
CURRENT_HVAC_OFF,
FAN_OFF,
FAN_ON,
HVAC_MODE_DRY,
SUPPORT_FAN_MODE,
SUPPORT_TARGET_HUMIDITY,
SUPPORT_TARGET_TEMPERATURE,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, TEMP_CELSIUS
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator
from .const import (
DEFAULT_MAX_HUMIDITY,
DEFAULT_MAX_TEMP,
DEFAULT_MIN_HUMIDITY,
DEFAULT_MIN_TEMP,
DOMAIN,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up climate controls for TOLO Sauna."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([SaunaClimate(coordinator, entry)])
class SaunaClimate(ToloSaunaCoordinatorEntity, ClimateEntity):
"""Sauna climate control."""
_attr_fan_modes = [FAN_ON, FAN_OFF]
_attr_hvac_modes = [HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_DRY]
_attr_max_humidity = DEFAULT_MAX_HUMIDITY
_attr_max_temp = DEFAULT_MAX_TEMP
_attr_min_humidity = DEFAULT_MIN_HUMIDITY
_attr_min_temp = DEFAULT_MIN_TEMP
_attr_name = "Sauna Climate"
_attr_precision = PRECISION_WHOLE
_attr_supported_features = (
SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_HUMIDITY | SUPPORT_FAN_MODE
)
_attr_target_temperature_step = 1
_attr_temperature_unit = TEMP_CELSIUS
def __init__(
self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry
) -> None:
"""Initialize TOLO Sauna Climate entity."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_climate"
@property
def current_temperature(self) -> int:
"""Return current temperature."""
return self.coordinator.data.status.current_temperature
@property
def current_humidity(self) -> int:
"""Return current humidity."""
return self.coordinator.data.status.current_humidity
@property
def target_temperature(self) -> int:
"""Return target temperature."""
return self.coordinator.data.settings.target_temperature
@property
def target_humidity(self) -> int:
"""Return target humidity."""
return self.coordinator.data.settings.target_humidity
@property
def hvac_mode(self) -> str:
"""Get current HVAC mode."""
if self.coordinator.data.status.power_on:
return HVAC_MODE_HEAT
if (
not self.coordinator.data.status.power_on
and self.coordinator.data.status.fan_on
):
return HVAC_MODE_DRY
return HVAC_MODE_OFF
@property
def hvac_action(self) -> str | None:
"""Execute HVAC action."""
if self.coordinator.data.status.calefaction == Calefaction.HEAT:
return CURRENT_HVAC_HEAT
if self.coordinator.data.status.calefaction == Calefaction.KEEP:
return CURRENT_HVAC_IDLE
if self.coordinator.data.status.calefaction == Calefaction.INACTIVE:
if self.coordinator.data.status.fan_on:
return CURRENT_HVAC_DRY
return CURRENT_HVAC_OFF
return None
@property
def fan_mode(self) -> str:
"""Return current fan mode."""
if self.coordinator.data.status.fan_on:
return FAN_ON
return FAN_OFF
def set_hvac_mode(self, hvac_mode: str) -> None:
"""Set HVAC mode."""
if hvac_mode == HVAC_MODE_OFF:
self._set_power_and_fan(False, False)
if hvac_mode == HVAC_MODE_HEAT:
self._set_power_and_fan(True, False)
if hvac_mode == HVAC_MODE_DRY:
self._set_power_and_fan(False, True)
def set_fan_mode(self, fan_mode: str) -> None:
"""Set fan mode."""
self.coordinator.client.set_fan_on(fan_mode == FAN_ON)
def set_humidity(self, humidity: float) -> None:
"""Set desired target humidity."""
self.coordinator.client.set_target_humidity(round(humidity))
def set_temperature(self, **kwargs: Any) -> None:
"""Set desired target temperature."""
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None:
return
self.coordinator.client.set_target_temperature(round(temperature))
def _set_power_and_fan(self, power_on: bool, fan_on: bool) -> None:
"""Shortcut for setting power and fan of TOLO device on one method."""
self.coordinator.client.set_power_on(power_on)
self.coordinator.client.set_fan_on(fan_on)

View file

@ -0,0 +1,96 @@
"""Config flow for tolo."""
from __future__ import annotations
import logging
from typing import Any
from tololib import ToloClient
from tololib.errors import ResponseTimedOutError
import voluptuous as vol
from homeassistant.components import dhcp
from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_HOST
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.device_registry import format_mac
from .const import DEFAULT_NAME, DEFAULT_RETRY_COUNT, DEFAULT_RETRY_TIMEOUT, DOMAIN
_LOGGER = logging.getLogger(__name__)
class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
"""ConfigFlow for TOLO Sauna."""
VERSION = 1
_discovered_host: str | None = None
@staticmethod
def _check_device_availability(host: str) -> bool:
client = ToloClient(host)
try:
result = client.get_status_info(
resend_timeout=DEFAULT_RETRY_TIMEOUT, retries=DEFAULT_RETRY_COUNT
)
return result is not None
except ResponseTimedOutError:
return False
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
device_available = await self.hass.async_add_executor_job(
self._check_device_availability, user_input[CONF_HOST]
)
if not device_available:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title=DEFAULT_NAME, data={CONF_HOST: user_input[CONF_HOST]}
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors,
)
async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult:
"""Handle a flow initialized by discovery."""
await self.async_set_unique_id(format_mac(discovery_info[MAC_ADDRESS]))
self._abort_if_unique_id_configured({CONF_HOST: discovery_info[IP_ADDRESS]})
self._async_abort_entries_match({CONF_HOST: discovery_info[IP_ADDRESS]})
device_available = await self.hass.async_add_executor_job(
self._check_device_availability, discovery_info[IP_ADDRESS]
)
if device_available:
self._discovered_host = discovery_info[IP_ADDRESS]
return await self.async_step_confirm()
return self.async_abort(reason="not_tolo_device")
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle user-confirmation of discovered node."""
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: self._discovered_host})
return self.async_create_entry(
title=DEFAULT_NAME, data={CONF_HOST: self._discovered_host}
)
return self.async_show_form(
step_id="confirm",
description_placeholders={CONF_HOST: self._discovered_host},
)

View file

@ -0,0 +1,13 @@
"""Constants for the tolo integration."""
DOMAIN = "tolo"
DEFAULT_NAME = "TOLO Sauna"
DEFAULT_RETRY_TIMEOUT = 1
DEFAULT_RETRY_COUNT = 3
DEFAULT_MAX_TEMP = 60
DEFAULT_MIN_TEMP = 20
DEFAULT_MAX_HUMIDITY = 99
DEFAULT_MIN_HUMIDITY = 60

View file

@ -0,0 +1,14 @@
{
"domain": "tolo",
"name": "TOLO Sauna",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tolo",
"requirements": [
"tololib==0.1.0b2"
],
"codeowners": [
"@MatthiasLohr"
],
"iot_class": "local_polling",
"dhcp": [{"hostname": "usr-tcp232-ed2"}]
}

View file

@ -0,0 +1,23 @@
{
"config": {
"flow_title": "{name}",
"step": {
"user": {
"description": "Enter the hostname or IP address of your TOLO Sauna device.",
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
},
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}

View file

@ -0,0 +1,23 @@
{
"config": {
"abort": {
"no_devices_found": "No devices found on the network",
"already_configured": "Device is already configured"
},
"error": {
"cannot_connect": "Failed to connect"
},
"flow_title": "{name}",
"step": {
"confirm": {
"description": "Do you want to start set up?"
},
"user": {
"data": {
"host": "Host"
},
"description": "Enter the hostname or IP address of your TOLO Sauna device."
}
}
}
}

View file

@ -298,6 +298,7 @@ FLOWS = [
"tellduslive",
"tibber",
"tile",
"tolo",
"toon",
"totalconnect",
"tplink",

View file

@ -361,6 +361,10 @@ DHCP = [
"domain": "tado",
"hostname": "tado*"
},
{
"domain": "tolo",
"hostname": "usr-tcp232-ed2"
},
{
"domain": "toon",
"hostname": "eneco-*",

View file

@ -1452,6 +1452,17 @@ no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.tolo.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.tractive.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View file

@ -2316,6 +2316,9 @@ tmb==0.0.4
# homeassistant.components.todoist
todoist-python==8.0.0
# homeassistant.components.tolo
tololib==0.1.0b2
# homeassistant.components.toon
toonapi==0.2.1

View file

@ -1350,6 +1350,9 @@ tellduslive==0.10.11
# homeassistant.components.powerwall
tesla-powerwall==0.3.12
# homeassistant.components.tolo
tololib==0.1.0b2
# homeassistant.components.toon
toonapi==0.2.1

View file

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

View file

@ -0,0 +1,107 @@
"""Tests for the TOLO Sauna config flow."""
from unittest.mock import Mock, patch
import pytest
from tololib.errors import ResponseTimedOutError
from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS
from homeassistant.components.tolo.const import DOMAIN
from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY,
RESULT_TYPE_FORM,
)
MOCK_DHCP_DATA = {IP_ADDRESS: "127.0.0.2", MAC_ADDRESS: "00:11:22:33:44:55"}
@pytest.fixture(name="toloclient")
def toloclient_fixture() -> Mock:
"""Patch libraries."""
with patch("homeassistant.components.tolo.config_flow.ToloClient") as toloclient:
yield toloclient
async def test_user_with_timed_out_host(hass: HomeAssistant, toloclient: Mock):
"""Test a user initiated config flow with provided host which times out."""
toloclient().get_status_info.side_effect = lambda *args, **kwargs: (
_ for _ in ()
).throw(ResponseTimedOutError())
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={CONF_HOST: "127.0.0.1"},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == SOURCE_USER
assert result["errors"] == {"base": "cannot_connect"}
async def test_user_walkthrough(hass: HomeAssistant, toloclient: Mock):
"""Test complete user flow with first wrong and then correct host."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == SOURCE_USER
assert "flow_id" in result
toloclient().get_status_info.side_effect = lambda *args, **kwargs: None
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "127.0.0.2"},
)
assert result2["type"] == RESULT_TYPE_FORM
assert result2["step_id"] == SOURCE_USER
assert result2["errors"] == {"base": "cannot_connect"}
assert "flow_id" in result2
toloclient().get_status_info.side_effect = lambda *args, **kwargs: object()
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "127.0.0.1"},
)
assert result3["type"] == RESULT_TYPE_CREATE_ENTRY
assert result3["title"] == "TOLO Sauna"
assert result3["data"][CONF_HOST] == "127.0.0.1"
async def test_dhcp(hass: HomeAssistant, toloclient: Mock):
"""Test starting a flow from discovery."""
toloclient().get_status_info.side_effect = lambda *args, **kwargs: object()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_DHCP}, data=MOCK_DHCP_DATA
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={},
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "TOLO Sauna"
assert result["data"][CONF_HOST] == "127.0.0.2"
assert result["result"].unique_id == "00:11:22:33:44:55"
async def test_dhcp_invalid_device(hass: HomeAssistant, toloclient: Mock):
"""Test starting a flow from discovery."""
toloclient().get_status_info.side_effect = lambda *args, **kwargs: None
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_DHCP}, data=MOCK_DHCP_DATA
)
assert result["type"] == RESULT_TYPE_ABORT