From a399037a46921d749358bf6d6ad7b9549e20f1e1 Mon Sep 17 00:00:00 2001 From: Matthias Lohr Date: Wed, 24 Nov 2021 20:45:13 +0100 Subject: [PATCH] Add TOLO Sauna (tolo) integration (#55619) --- .coveragerc | 2 + .strict-typing | 1 + CODEOWNERS | 1 + homeassistant/components/tolo/__init__.py | 101 ++++++++++++ homeassistant/components/tolo/climate.py | 156 ++++++++++++++++++ homeassistant/components/tolo/config_flow.py | 96 +++++++++++ homeassistant/components/tolo/const.py | 13 ++ homeassistant/components/tolo/manifest.json | 14 ++ homeassistant/components/tolo/strings.json | 23 +++ .../components/tolo/translations/en.json | 23 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/dhcp.py | 4 + mypy.ini | 11 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/tolo/__init__.py | 1 + tests/components/tolo/test_config_flow.py | 107 ++++++++++++ 17 files changed, 560 insertions(+) create mode 100644 homeassistant/components/tolo/__init__.py create mode 100644 homeassistant/components/tolo/climate.py create mode 100644 homeassistant/components/tolo/config_flow.py create mode 100644 homeassistant/components/tolo/const.py create mode 100644 homeassistant/components/tolo/manifest.json create mode 100644 homeassistant/components/tolo/strings.json create mode 100644 homeassistant/components/tolo/translations/en.json create mode 100644 tests/components/tolo/__init__.py create mode 100644 tests/components/tolo/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index f03ad280be6..ab8e540636b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/.strict-typing b/.strict-typing index e8941c307e6..ce04c74702b 100644 --- a/.strict-typing +++ b/.strict-typing @@ -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.* diff --git a/CODEOWNERS b/CODEOWNERS index 242ffa1ea03..48a3b5ed02b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/tolo/__init__.py b/homeassistant/components/tolo/__init__.py new file mode 100644 index 00000000000..d4b182061c3 --- /dev/null +++ b/homeassistant/components/tolo/__init__.py @@ -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(), + ) diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py new file mode 100644 index 00000000000..659dfcbda16 --- /dev/null +++ b/homeassistant/components/tolo/climate.py @@ -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) diff --git a/homeassistant/components/tolo/config_flow.py b/homeassistant/components/tolo/config_flow.py new file mode 100644 index 00000000000..4503fd511ba --- /dev/null +++ b/homeassistant/components/tolo/config_flow.py @@ -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}, + ) diff --git a/homeassistant/components/tolo/const.py b/homeassistant/components/tolo/const.py new file mode 100644 index 00000000000..bfd700bb955 --- /dev/null +++ b/homeassistant/components/tolo/const.py @@ -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 diff --git a/homeassistant/components/tolo/manifest.json b/homeassistant/components/tolo/manifest.json new file mode 100644 index 00000000000..4aa84f3f2d2 --- /dev/null +++ b/homeassistant/components/tolo/manifest.json @@ -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"}] +} \ No newline at end of file diff --git a/homeassistant/components/tolo/strings.json b/homeassistant/components/tolo/strings.json new file mode 100644 index 00000000000..f9316f4d72b --- /dev/null +++ b/homeassistant/components/tolo/strings.json @@ -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%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/en.json b/homeassistant/components/tolo/translations/en.json new file mode 100644 index 00000000000..c304f583b61 --- /dev/null +++ b/homeassistant/components/tolo/translations/en.json @@ -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." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d54e515681e..6c199b47e32 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -298,6 +298,7 @@ FLOWS = [ "tellduslive", "tibber", "tile", + "tolo", "toon", "totalconnect", "tplink", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 31481df3495..59f346e0be0 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -361,6 +361,10 @@ DHCP = [ "domain": "tado", "hostname": "tado*" }, + { + "domain": "tolo", + "hostname": "usr-tcp232-ed2" + }, { "domain": "toon", "hostname": "eneco-*", diff --git a/mypy.ini b/mypy.ini index da1fb4f08d4..cc1337bfc3c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index e5a962b12cd..e113992ce2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 68451703626..1ae165ba050 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/tolo/__init__.py b/tests/components/tolo/__init__.py new file mode 100644 index 00000000000..d8874d9ceec --- /dev/null +++ b/tests/components/tolo/__init__.py @@ -0,0 +1 @@ +"""Tests for the TOLO Sauna integration.""" diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py new file mode 100644 index 00000000000..6634d444bce --- /dev/null +++ b/tests/components/tolo/test_config_flow.py @@ -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