From cac84e4160841aa931d826c1f1acf55033042917 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Jun 2022 13:59:52 -1000 Subject: [PATCH] Add config flow to radiotherm (#72874) --- .coveragerc | 3 + CODEOWNERS | 3 +- .../components/radiotherm/__init__.py | 53 +++ .../components/radiotherm/climate.py | 377 ++++++++---------- .../components/radiotherm/config_flow.py | 166 ++++++++ homeassistant/components/radiotherm/const.py | 7 + .../components/radiotherm/coordinator.py | 48 +++ homeassistant/components/radiotherm/data.py | 74 ++++ .../components/radiotherm/manifest.json | 9 +- .../components/radiotherm/strings.json | 31 ++ .../radiotherm/translations/en.json | 31 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/dhcp.py | 2 + requirements_test_all.txt | 3 + tests/components/radiotherm/__init__.py | 1 + .../components/radiotherm/test_config_flow.py | 302 ++++++++++++++ 16 files changed, 900 insertions(+), 211 deletions(-) create mode 100644 homeassistant/components/radiotherm/config_flow.py create mode 100644 homeassistant/components/radiotherm/const.py create mode 100644 homeassistant/components/radiotherm/coordinator.py create mode 100644 homeassistant/components/radiotherm/data.py create mode 100644 homeassistant/components/radiotherm/strings.json create mode 100644 homeassistant/components/radiotherm/translations/en.json create mode 100644 tests/components/radiotherm/__init__.py create mode 100644 tests/components/radiotherm/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index f4cdfa1c460..0f9c3e4998a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -963,7 +963,10 @@ omit = homeassistant/components/radarr/sensor.py homeassistant/components/radio_browser/__init__.py homeassistant/components/radio_browser/media_source.py + homeassistant/components/radiotherm/__init__.py homeassistant/components/radiotherm/climate.py + homeassistant/components/radiotherm/coordinator.py + homeassistant/components/radiotherm/data.py homeassistant/components/rainbird/* homeassistant/components/raincloud/* homeassistant/components/rainmachine/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 077f4ca9063..6db9d92ebb9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -825,7 +825,8 @@ build.json @home-assistant/supervisor /tests/components/rachio/ @bdraco /homeassistant/components/radio_browser/ @frenck /tests/components/radio_browser/ @frenck -/homeassistant/components/radiotherm/ @vinnyfuria +/homeassistant/components/radiotherm/ @bdraco @vinnyfuria +/tests/components/radiotherm/ @bdraco @vinnyfuria /homeassistant/components/rainbird/ @konikvranik /homeassistant/components/raincloud/ @vanstinator /homeassistant/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin diff --git a/homeassistant/components/radiotherm/__init__.py b/homeassistant/components/radiotherm/__init__.py index adc8cdbd6ee..9e389af6719 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -1 +1,54 @@ """The radiotherm component.""" +from __future__ import annotations + +from socket import timeout + +from radiotherm.validate import RadiothermTstatError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_HOLD_TEMP, DOMAIN +from .coordinator import RadioThermUpdateCoordinator +from .data import async_get_init_data + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Radio Thermostat from a config entry.""" + host = entry.data[CONF_HOST] + try: + init_data = await async_get_init_data(hass, host) + except RadiothermTstatError as ex: + raise ConfigEntryNotReady( + f"{host} was busy (invalid value returned): {ex}" + ) from ex + except timeout as ex: + raise ConfigEntryNotReady( + f"{host} timed out waiting for a response: {ex}" + ) from ex + + hold_temp = entry.options[CONF_HOLD_TEMP] + coordinator = RadioThermUpdateCoordinator(hass, init_data, hold_temp) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index dbf013ffa9a..63ee93c9c84 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -1,8 +1,9 @@ """Support for Radio Thermostat wifi-enabled home thermostats.""" from __future__ import annotations +from functools import partial import logging -from socket import timeout +from typing import Any import radiotherm import voluptuous as vol @@ -18,24 +19,32 @@ from homeassistant.components.climate.const import ( HVACAction, HVACMode, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, CONF_HOST, PRECISION_HALVES, TEMP_FAHRENHEIT, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util +from . import DOMAIN +from .const import CONF_HOLD_TEMP +from .coordinator import RadioThermUpdateCoordinator +from .data import RadioThermUpdate + _LOGGER = logging.getLogger(__name__) ATTR_FAN_ACTION = "fan_action" -CONF_HOLD_TEMP = "hold_temp" - PRESET_HOLIDAY = "holiday" PRESET_ALTERNATE = "alternate" @@ -74,12 +83,19 @@ CODE_TO_TEMP_STATE = {0: HVACAction.IDLE, 1: HVACAction.HEATING, 2: HVACAction.C # future this should probably made into a binary sensor for the fan. CODE_TO_FAN_STATE = {0: FAN_OFF, 1: FAN_ON} -PRESET_MODE_TO_CODE = {"home": 0, "alternate": 1, "away": 2, "holiday": 3} +PRESET_MODE_TO_CODE = { + PRESET_HOME: 0, + PRESET_ALTERNATE: 1, + PRESET_AWAY: 2, + PRESET_HOLIDAY: 3, +} -CODE_TO_PRESET_MODE = {0: "home", 1: "alternate", 2: "away", 3: "holiday"} +CODE_TO_PRESET_MODE = {v: k for k, v in PRESET_MODE_TO_CODE.items()} CODE_TO_HOLD_STATE = {0: False, 1: True} +PARALLEL_UPDATES = 1 + def round_temp(temperature): """Round a temperature to the resolution of the thermostat. @@ -98,69 +114,93 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up climate for a radiotherm device.""" + coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([RadioThermostat(coordinator)]) + + +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Radio Thermostat.""" - hosts = [] + _LOGGER.warning( + # config flow added in 2022.7 and should be removed in 2022.9 + "Configuration of the Radio Thermostat climate platform in YAML is deprecated and " + "will be removed in Home Assistant 2022.9; Your existing configuration " + "has been imported into the UI automatically and can be safely removed " + "from your configuration.yaml file" + ) + hosts: list[str] = [] if CONF_HOST in config: hosts = config[CONF_HOST] else: - hosts.append(radiotherm.discover.discover_address()) + hosts.append( + await hass.async_add_executor_job(radiotherm.discover.discover_address) + ) - if hosts is None: + if not hosts: _LOGGER.error("No Radiotherm Thermostats detected") - return False - - hold_temp = config.get(CONF_HOLD_TEMP) - tstats = [] + return + hold_temp: bool = config[CONF_HOLD_TEMP] for host in hosts: - try: - tstat = radiotherm.get_thermostat(host) - tstats.append(RadioThermostat(tstat, hold_temp)) - except OSError: - _LOGGER.exception("Unable to connect to Radio Thermostat: %s", host) - - add_entities(tstats, True) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: host, CONF_HOLD_TEMP: hold_temp}, + ) + ) -class RadioThermostat(ClimateEntity): +class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEntity): """Representation of a Radio Thermostat.""" _attr_hvac_modes = OPERATION_LIST - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.PRESET_MODE - ) + _attr_temperature_unit = TEMP_FAHRENHEIT + _attr_precision = PRECISION_HALVES - def __init__(self, device, hold_temp): + def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: """Initialize the thermostat.""" - self.device = device - self._target_temperature = None - self._current_temperature = None - self._current_humidity = None - self._current_operation = HVACMode.OFF - self._name = None - self._fmode = None - self._fstate = None - self._tmode = None - self._tstate: HVACAction | None = None - self._hold_temp = hold_temp + super().__init__(coordinator) + self.device = coordinator.init_data.tstat + self._attr_name = coordinator.init_data.name + self._hold_temp = coordinator.hold_temp self._hold_set = False - self._prev_temp = None - self._preset_mode = None - self._program_mode = None - self._is_away = False - - # Fan circulate mode is only supported by the CT80 models. + self._attr_unique_id = coordinator.init_data.mac + self._attr_device_info = DeviceInfo( + name=coordinator.init_data.name, + model=coordinator.init_data.model, + manufacturer="Radio Thermostats", + sw_version=coordinator.init_data.fw_version, + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.init_data.mac)}, + ) self._is_model_ct80 = isinstance(self.device, radiotherm.thermostat.CT80) + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + self._process_data() + if not self._is_model_ct80: + self._attr_fan_modes = CT30_FAN_OPERATION_LIST + return + self._attr_fan_modes = CT80_FAN_OPERATION_LIST + self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + self._attr_preset_modes = PRESET_MODES - async def async_added_to_hass(self): + @property + def data(self) -> RadioThermUpdate: + """Returnt the last update.""" + return self.coordinator.data + + async def async_added_to_hass(self) -> None: """Register callbacks.""" # Set the time on the device. This shouldn't be in the # constructor because it's a network call. We can't put it in @@ -168,181 +208,93 @@ class RadioThermostat(ClimateEntity): # temperature in the thermostat. So add it as a future job # for the event loop to run. self.hass.async_add_job(self.set_time) + await super().async_added_to_hass() - @property - def name(self): - """Return the name of the Radio Thermostat.""" - return self._name - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT - - @property - def precision(self): - """Return the precision of the system.""" - return PRECISION_HALVES - - @property - def extra_state_attributes(self): - """Return the device specific state attributes.""" - return {ATTR_FAN_ACTION: self._fstate} - - @property - def fan_modes(self): - """List of available fan modes.""" - if self._is_model_ct80: - return CT80_FAN_OPERATION_LIST - return CT30_FAN_OPERATION_LIST - - @property - def fan_mode(self): - """Return whether the fan is on.""" - return self._fmode - - def set_fan_mode(self, fan_mode): + async def async_set_fan_mode(self, fan_mode: str) -> None: """Turn fan on/off.""" - if (code := FAN_MODE_TO_CODE.get(fan_mode)) is not None: - self.device.fmode = code + if (code := FAN_MODE_TO_CODE.get(fan_mode)) is None: + raise HomeAssistantError(f"{fan_mode} is not a valid fan mode") + await self.hass.async_add_executor_job(self._set_fan_mode, code) + self._attr_fan_mode = fan_mode + self.async_write_ha_state() + await self.coordinator.async_request_refresh() - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature + def _set_fan_mode(self, code: int) -> None: + """Turn fan on/off.""" + self.device.fmode = code - @property - def current_humidity(self): - """Return the current temperature.""" - return self._current_humidity + @callback + def _handle_coordinator_update(self) -> None: + self._process_data() + return super()._handle_coordinator_update() - @property - def hvac_mode(self) -> HVACMode: - """Return the current operation. head, cool idle.""" - return self._current_operation - - @property - def hvac_action(self) -> HVACAction | None: - """Return the current running hvac operation if supported.""" - if self.hvac_mode == HVACMode.OFF: - return None - return self._tstate - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - - @property - def preset_mode(self): - """Return the current preset mode, e.g., home, away, temp.""" - if self._program_mode == 0: - return PRESET_HOME - if self._program_mode == 1: - return PRESET_ALTERNATE - if self._program_mode == 2: - return PRESET_AWAY - if self._program_mode == 3: - return PRESET_HOLIDAY - - @property - def preset_modes(self): - """Return a list of available preset modes.""" - return PRESET_MODES - - def update(self): + @callback + def _process_data(self) -> None: """Update and validate the data from the thermostat.""" - # Radio thermostats are very slow, and sometimes don't respond - # very quickly. So we need to keep the number of calls to them - # to a bare minimum or we'll hit the Home Assistant 10 sec warning. We - # have to make one call to /tstat to get temps but we'll try and - # keep the other calls to a minimum. Even with this, these - # thermostats tend to time out sometimes when they're actively - # heating or cooling. - - try: - # First time - get the name from the thermostat. This is - # normally set in the radio thermostat web app. - if self._name is None: - self._name = self.device.name["raw"] - - # Request the current state from the thermostat. - data = self.device.tstat["raw"] - - if self._is_model_ct80: - humiditydata = self.device.humidity["raw"] - - except radiotherm.validate.RadiothermTstatError: - _LOGGER.warning( - "%s (%s) was busy (invalid value returned)", - self._name, - self.device.host, - ) - - except timeout: - _LOGGER.warning( - "Timeout waiting for response from %s (%s)", - self._name, - self.device.host, - ) - + data = self.data.tstat + if self._is_model_ct80: + self._attr_current_humidity = self.data.humidity + self._attr_preset_mode = CODE_TO_PRESET_MODE[data["program_mode"]] + # Map thermostat values into various STATE_ flags. + self._attr_current_temperature = data["temp"] + self._attr_fan_mode = CODE_TO_FAN_MODE[data["fmode"]] + self._attr_extra_state_attributes = { + ATTR_FAN_ACTION: CODE_TO_FAN_STATE[data["fstate"]] + } + self._attr_hvac_mode = CODE_TO_TEMP_MODE[data["tmode"]] + self._hold_set = CODE_TO_HOLD_STATE[data["hold"]] + if self.hvac_mode == HVACMode.OFF: + self._attr_hvac_action = None else: - if self._is_model_ct80: - self._current_humidity = humiditydata - self._program_mode = data["program_mode"] - self._preset_mode = CODE_TO_PRESET_MODE[data["program_mode"]] + self._attr_hvac_action = CODE_TO_TEMP_STATE[data["tstate"]] + if self.hvac_mode == HVACMode.COOL: + self._attr_target_temperature = data["t_cool"] + elif self.hvac_mode == HVACMode.HEAT: + self._attr_target_temperature = data["t_heat"] + elif self.hvac_mode == HVACMode.AUTO: + # This doesn't really work - tstate is only set if the HVAC is + # active. If it's idle, we don't know what to do with the target + # temperature. + if self.hvac_action == HVACAction.COOLING: + self._attr_target_temperature = data["t_cool"] + elif self.hvac_action == HVACAction.HEATING: + self._attr_target_temperature = data["t_heat"] - # Map thermostat values into various STATE_ flags. - self._current_temperature = data["temp"] - self._fmode = CODE_TO_FAN_MODE[data["fmode"]] - self._fstate = CODE_TO_FAN_STATE[data["fstate"]] - self._tmode = CODE_TO_TEMP_MODE[data["tmode"]] - self._tstate = CODE_TO_TEMP_STATE[data["tstate"]] - self._hold_set = CODE_TO_HOLD_STATE[data["hold"]] - - self._current_operation = self._tmode - if self._tmode == HVACMode.COOL: - self._target_temperature = data["t_cool"] - elif self._tmode == HVACMode.HEAT: - self._target_temperature = data["t_heat"] - elif self._tmode == HVACMode.AUTO: - # This doesn't really work - tstate is only set if the HVAC is - # active. If it's idle, we don't know what to do with the target - # temperature. - if self._tstate == HVACAction.COOLING: - self._target_temperature = data["t_cool"] - elif self._tstate == HVACAction.HEATING: - self._target_temperature = data["t_heat"] - else: - self._current_operation = HVACMode.OFF - - def set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return + hold_changed = kwargs.get("hold_changed", False) + await self.hass.async_add_executor_job( + partial(self._set_temperature, temperature, hold_changed) + ) + self._attr_target_temperature = temperature + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + def _set_temperature(self, temperature: int, hold_changed: bool) -> None: + """Set new target temperature.""" temperature = round_temp(temperature) - - if self._current_operation == HVACMode.COOL: + if self.hvac_mode == HVACMode.COOL: self.device.t_cool = temperature - elif self._current_operation == HVACMode.HEAT: + elif self.hvac_mode == HVACMode.HEAT: self.device.t_heat = temperature - elif self._current_operation == HVACMode.AUTO: - if self._tstate == HVACAction.COOLING: + elif self.hvac_mode == HVACMode.AUTO: + if self.hvac_action == HVACAction.COOLING: self.device.t_cool = temperature - elif self._tstate == HVACAction.HEATING: + elif self.hvac_action == HVACAction.HEATING: self.device.t_heat = temperature # Only change the hold if requested or if hold mode was turned # on and we haven't set it yet. - if kwargs.get("hold_changed", False) or not self._hold_set: + if hold_changed or not self._hold_set: if self._hold_temp: self.device.hold = 1 self._hold_set = True else: self.device.hold = 0 - def set_time(self): + def set_time(self) -> None: """Set device time.""" # Calling this clears any local temperature override and # reverts to the scheduled temperature. @@ -353,23 +305,32 @@ class RadioThermostat(ClimateEntity): "minute": now.minute, } - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set operation mode (auto, cool, heat, off).""" + await self.hass.async_add_executor_job(self._set_hvac_mode, hvac_mode) + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + def _set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set operation mode (auto, cool, heat, off).""" if hvac_mode in (HVACMode.OFF, HVACMode.AUTO): self.device.tmode = TEMP_MODE_TO_CODE[hvac_mode] - # Setting t_cool or t_heat automatically changes tmode. elif hvac_mode == HVACMode.COOL: - self.device.t_cool = self._target_temperature + self.device.t_cool = self.target_temperature elif hvac_mode == HVACMode.HEAT: - self.device.t_heat = self._target_temperature + self.device.t_heat = self.target_temperature - def set_preset_mode(self, preset_mode): + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set Preset mode (Home, Alternate, Away, Holiday).""" - if preset_mode in PRESET_MODES: - self.device.program_mode = PRESET_MODE_TO_CODE[preset_mode] - else: - _LOGGER.error( - "Preset_mode %s not in PRESET_MODES", - preset_mode, - ) + if preset_mode not in PRESET_MODES: + raise HomeAssistantError("{preset_mode} is not a valid preset_mode") + await self.hass.async_add_executor_job(self._set_preset_mode, preset_mode) + self._attr_preset_mode = preset_mode + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + def _set_preset_mode(self, preset_mode: str) -> None: + """Set Preset mode (Home, Alternate, Away, Holiday).""" + self.device.program_mode = PRESET_MODE_TO_CODE[preset_mode] diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py new file mode 100644 index 00000000000..45fee0f7fd9 --- /dev/null +++ b/homeassistant/components/radiotherm/config_flow.py @@ -0,0 +1,166 @@ +"""Config flow for Radio Thermostat integration.""" +from __future__ import annotations + +import logging +from socket import timeout +from typing import Any + +from radiotherm.validate import RadiothermTstatError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import dhcp +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import CONF_HOLD_TEMP, DOMAIN +from .data import RadioThermInitData, async_get_init_data + +_LOGGER = logging.getLogger(__name__) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +async def validate_connection(hass: HomeAssistant, host: str) -> RadioThermInitData: + """Validate the connection.""" + try: + return await async_get_init_data(hass, host) + except (timeout, RadiothermTstatError) as ex: + raise CannotConnect(f"Failed to connect to {host}: {ex}") from ex + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Radio Thermostat.""" + + VERSION = 1 + + def __init__(self): + """Initialize ConfigFlow.""" + self.discovered_ip: str | None = None + self.discovered_init_data: RadioThermInitData | None = None + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Discover via DHCP.""" + self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) + try: + init_data = await validate_connection(self.hass, discovery_info.ip) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(init_data.mac) + self._abort_if_unique_id_configured( + updates={CONF_HOST: discovery_info.ip}, reload_on_update=False + ) + self.discovered_init_data = init_data + self.discovered_ip = discovery_info.ip + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input=None): + """Attempt to confirm.""" + ip_address = self.discovered_ip + init_data = self.discovered_init_data + assert ip_address is not None + assert init_data is not None + if user_input is not None: + return self.async_create_entry( + title=init_data.name, + data={CONF_HOST: ip_address}, + options={CONF_HOLD_TEMP: False}, + ) + + self._set_confirm_only() + placeholders = { + "name": init_data.name, + "host": self.discovered_ip, + "model": init_data.model or "Unknown", + } + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="confirm", + description_placeholders=placeholders, + ) + + async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: + """Import from yaml.""" + host = import_info[CONF_HOST] + self._async_abort_entries_match({CONF_HOST: host}) + _LOGGER.debug("Importing entry for host: %s", host) + try: + init_data = await validate_connection(self.hass, host) + except CannotConnect as ex: + _LOGGER.debug("Importing failed for %s", host, exc_info=ex) + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(init_data.mac, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host}, reload_on_update=False + ) + return self.async_create_entry( + title=init_data.name, + data={CONF_HOST: import_info[CONF_HOST]}, + options={CONF_HOLD_TEMP: import_info[CONF_HOLD_TEMP]}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + init_data = await validate_connection(self.hass, user_input[CONF_HOST]) + except CannotConnect: + errors[CONF_HOST] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(init_data.mac, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]}, + reload_on_update=False, + ) + return self.async_create_entry( + title=init_data.name, + data=user_input, + options={CONF_HOLD_TEMP: False}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for radiotherm.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_HOLD_TEMP, + default=self.config_entry.options[CONF_HOLD_TEMP], + ): bool + } + ), + ) diff --git a/homeassistant/components/radiotherm/const.py b/homeassistant/components/radiotherm/const.py new file mode 100644 index 00000000000..398747c6571 --- /dev/null +++ b/homeassistant/components/radiotherm/const.py @@ -0,0 +1,7 @@ +"""Constants for the Radio Thermostat integration.""" + +DOMAIN = "radiotherm" + +CONF_HOLD_TEMP = "hold_temp" + +TIMEOUT = 25 diff --git a/homeassistant/components/radiotherm/coordinator.py b/homeassistant/components/radiotherm/coordinator.py new file mode 100644 index 00000000000..264a4a8d1fd --- /dev/null +++ b/homeassistant/components/radiotherm/coordinator.py @@ -0,0 +1,48 @@ +"""Coordinator for radiotherm.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from socket import timeout + +from radiotherm.validate import RadiothermTstatError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .data import RadioThermInitData, RadioThermUpdate, async_get_data + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(seconds=15) + + +class RadioThermUpdateCoordinator(DataUpdateCoordinator[RadioThermUpdate]): + """DataUpdateCoordinator to gather data for radio thermostats.""" + + def __init__( + self, hass: HomeAssistant, init_data: RadioThermInitData, hold_temp: bool + ) -> None: + """Initialize DataUpdateCoordinator.""" + self.init_data = init_data + self.hold_temp = hold_temp + self._description = f"{init_data.name} ({init_data.host})" + super().__init__( + hass, + _LOGGER, + name=f"radiotherm {self.init_data.name}", + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> RadioThermUpdate: + """Update data from the thermostat.""" + try: + return await async_get_data(self.hass, self.init_data.tstat) + except RadiothermTstatError as ex: + raise UpdateFailed( + f"{self._description} was busy (invalid value returned): {ex}" + ) from ex + except timeout as ex: + raise UpdateFailed( + f"{self._description}) timed out waiting for a response: {ex}" + ) from ex diff --git a/homeassistant/components/radiotherm/data.py b/homeassistant/components/radiotherm/data.py new file mode 100644 index 00000000000..3aa4e6b7631 --- /dev/null +++ b/homeassistant/components/radiotherm/data.py @@ -0,0 +1,74 @@ +"""The radiotherm component data.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import radiotherm +from radiotherm.thermostat import CommonThermostat + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import TIMEOUT + + +@dataclass +class RadioThermUpdate: + """An update from a radiotherm device.""" + + tstat: dict[str, Any] + humidity: int | None + + +@dataclass +class RadioThermInitData: + """An data needed to init the integration.""" + + tstat: CommonThermostat + host: str + name: str + mac: str + model: str | None + fw_version: str | None + api_version: int | None + + +def _get_init_data(host: str) -> RadioThermInitData: + tstat = radiotherm.get_thermostat(host) + tstat.timeout = TIMEOUT + name: str = tstat.name["raw"] + sys: dict[str, Any] = tstat.sys["raw"] + mac: str = dr.format_mac(sys["uuid"]) + model: str = tstat.model.get("raw") + return RadioThermInitData( + tstat, host, name, mac, model, sys.get("fw_version"), sys.get("api_version") + ) + + +async def async_get_init_data(hass: HomeAssistant, host: str) -> RadioThermInitData: + """Get the RadioInitData.""" + return await hass.async_add_executor_job(_get_init_data, host) + + +def _get_data(device: CommonThermostat) -> RadioThermUpdate: + # Request the current state from the thermostat. + # Radio thermostats are very slow, and sometimes don't respond + # very quickly. So we need to keep the number of calls to them + # to a bare minimum or we'll hit the Home Assistant 10 sec warning. We + # have to make one call to /tstat to get temps but we'll try and + # keep the other calls to a minimum. Even with this, these + # thermostats tend to time out sometimes when they're actively + # heating or cooling. + tstat: dict[str, Any] = device.tstat["raw"] + humidity: int | None = None + if isinstance(device, radiotherm.thermostat.CT80): + humidity = device.humidity["raw"] + return RadioThermUpdate(tstat, humidity) + + +async def async_get_data( + hass: HomeAssistant, device: CommonThermostat +) -> RadioThermUpdate: + """Fetch the data from the thermostat.""" + return await hass.async_add_executor_job(_get_data, device) diff --git a/homeassistant/components/radiotherm/manifest.json b/homeassistant/components/radiotherm/manifest.json index 72c2c8eb300..c6ae4e5bb06 100644 --- a/homeassistant/components/radiotherm/manifest.json +++ b/homeassistant/components/radiotherm/manifest.json @@ -3,7 +3,12 @@ "name": "Radio Thermostat", "documentation": "https://www.home-assistant.io/integrations/radiotherm", "requirements": ["radiotherm==2.1.0"], - "codeowners": ["@vinnyfuria"], + "codeowners": ["@bdraco", "@vinnyfuria"], "iot_class": "local_polling", - "loggers": ["radiotherm"] + "loggers": ["radiotherm"], + "dhcp": [ + { "hostname": "thermostat*", "macaddress": "5CDAD4*" }, + { "registered_devices": true } + ], + "config_flow": true } diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json new file mode 100644 index 00000000000..22f17224285 --- /dev/null +++ b/homeassistant/components/radiotherm/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "flow_title": "{name} {model} ({host})", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "confirm": { + "description": "Do you want to setup {name} {model} ({host})?" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Set a permanent hold when adjusting the temperature." + } + } + } + } +} diff --git a/homeassistant/components/radiotherm/translations/en.json b/homeassistant/components/radiotherm/translations/en.json new file mode 100644 index 00000000000..b524f188e59 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Do you want to setup {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Set a permanent hold when adjusting the temperature." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 06da1eae90c..843e3f7f7a9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -280,6 +280,7 @@ FLOWS = { "qnap_qsw", "rachio", "radio_browser", + "radiotherm", "rainforest_eagle", "rainmachine", "rdw", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 015d70e2939..43bf7ca2715 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -77,6 +77,8 @@ DHCP: list[dict[str, str | bool]] = [ {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': '009D6B*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': 'F0038C*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': '74C63B*'}, + {'domain': 'radiotherm', 'hostname': 'thermostat*', 'macaddress': '5CDAD4*'}, + {'domain': 'radiotherm', 'registered_devices': True}, {'domain': 'rainforest_eagle', 'macaddress': 'D8D5B9*'}, {'domain': 'ring', 'hostname': 'ring*', 'macaddress': '0CAE7D*'}, {'domain': 'roomba', 'hostname': 'irobot-*', 'macaddress': '501479*'}, diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4331cc6dc36..c3178a0822f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1366,6 +1366,9 @@ rachiopy==1.0.3 # homeassistant.components.radio_browser radios==0.1.1 +# homeassistant.components.radiotherm +radiotherm==2.1.0 + # homeassistant.components.rainmachine regenmaschine==2022.06.0 diff --git a/tests/components/radiotherm/__init__.py b/tests/components/radiotherm/__init__.py new file mode 100644 index 00000000000..cf8bc0c7cc5 --- /dev/null +++ b/tests/components/radiotherm/__init__.py @@ -0,0 +1 @@ +"""Tests for the Radio Thermostat integration.""" diff --git a/tests/components/radiotherm/test_config_flow.py b/tests/components/radiotherm/test_config_flow.py new file mode 100644 index 00000000000..bc729a27e1c --- /dev/null +++ b/tests/components/radiotherm/test_config_flow.py @@ -0,0 +1,302 @@ +"""Test the Radio Thermostat config flow.""" +import socket +from unittest.mock import MagicMock, patch + +from radiotherm import CommonThermostat +from radiotherm.validate import RadiothermTstatError + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import dhcp +from homeassistant.components.radiotherm.const import CONF_HOLD_TEMP, DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + + +def _mock_radiotherm(): + tstat = MagicMock(autospec=CommonThermostat) + tstat.name = {"raw": "My Name"} + tstat.sys = { + "raw": {"uuid": "aabbccddeeff", "fw_version": "1.2.3", "api_version": "4.5.6"} + } + tstat.model = {"raw": "Model"} + return tstat + + +async def test_form(hass): + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ), patch( + "homeassistant.components.radiotherm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "My Name" + assert result2["data"] == { + "host": "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_unknown_error(hass): + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + side_effect=RadiothermTstatError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {CONF_HOST: "cannot_connect"} + + +async def test_import(hass): + """Test we get can import from yaml.""" + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ), patch( + "homeassistant.components.radiotherm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4", CONF_HOLD_TEMP: True}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "My Name" + assert result["data"] == { + CONF_HOST: "1.2.3.4", + } + assert result["options"] == { + CONF_HOLD_TEMP: True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_cannot_connect(hass): + """Test we abort if we cannot connect on import from yaml.""" + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + side_effect=socket.timeout, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4", CONF_HOLD_TEMP: True}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_dhcp_can_confirm(hass): + """Test DHCP discovery flow can confirm right away.""" + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="radiotherm", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + assert result["description_placeholders"] == { + "host": "1.2.3.4", + "name": "My Name", + "model": "Model", + } + + with patch( + "homeassistant.components.radiotherm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "My Name" + assert result2["data"] == { + "host": "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_fails_to_connect(hass): + """Test DHCP discovery flow that fails to connect.""" + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + side_effect=RadiothermTstatError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="radiotherm", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_dhcp_already_exists(hass): + """Test DHCP discovery flow that fails to connect.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="radiotherm", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_user_unique_id_already_exists(hass): + """Test creating an entry where the unique_id already exists.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ), patch( + "homeassistant.components.radiotherm.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + +async def test_options_flow(hass): + """Test config flow options.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + unique_id="aa:bb:cc:dd:ee:ff", + options={CONF_HOLD_TEMP: False}, + ) + + entry.add_to_hass(hass) + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.async_block_till_done() + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_HOLD_TEMP: True} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert entry.options == {CONF_HOLD_TEMP: True}