Add config flow to radiotherm (#72874)

This commit is contained in:
J. Nick Koston 2022-06-05 13:59:52 -10:00 committed by GitHub
parent f7626bd511
commit cac84e4160
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 900 additions and 211 deletions

View File

@ -963,7 +963,10 @@ omit =
homeassistant/components/radarr/sensor.py homeassistant/components/radarr/sensor.py
homeassistant/components/radio_browser/__init__.py homeassistant/components/radio_browser/__init__.py
homeassistant/components/radio_browser/media_source.py homeassistant/components/radio_browser/media_source.py
homeassistant/components/radiotherm/__init__.py
homeassistant/components/radiotherm/climate.py homeassistant/components/radiotherm/climate.py
homeassistant/components/radiotherm/coordinator.py
homeassistant/components/radiotherm/data.py
homeassistant/components/rainbird/* homeassistant/components/rainbird/*
homeassistant/components/raincloud/* homeassistant/components/raincloud/*
homeassistant/components/rainmachine/__init__.py homeassistant/components/rainmachine/__init__.py

View File

@ -825,7 +825,8 @@ build.json @home-assistant/supervisor
/tests/components/rachio/ @bdraco /tests/components/rachio/ @bdraco
/homeassistant/components/radio_browser/ @frenck /homeassistant/components/radio_browser/ @frenck
/tests/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/rainbird/ @konikvranik
/homeassistant/components/raincloud/ @vanstinator /homeassistant/components/raincloud/ @vanstinator
/homeassistant/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin /homeassistant/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin

View File

@ -1 +1,54 @@
"""The radiotherm component.""" """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

View File

@ -1,8 +1,9 @@
"""Support for Radio Thermostat wifi-enabled home thermostats.""" """Support for Radio Thermostat wifi-enabled home thermostats."""
from __future__ import annotations from __future__ import annotations
from functools import partial
import logging import logging
from socket import timeout from typing import Any
import radiotherm import radiotherm
import voluptuous as vol import voluptuous as vol
@ -18,24 +19,32 @@ from homeassistant.components.climate.const import (
HVACAction, HVACAction,
HVACMode, HVACMode,
) )
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
ATTR_TEMPERATURE, ATTR_TEMPERATURE,
CONF_HOST, CONF_HOST,
PRECISION_HALVES, PRECISION_HALVES,
TEMP_FAHRENHEIT, 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 import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util 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__) _LOGGER = logging.getLogger(__name__)
ATTR_FAN_ACTION = "fan_action" ATTR_FAN_ACTION = "fan_action"
CONF_HOLD_TEMP = "hold_temp"
PRESET_HOLIDAY = "holiday" PRESET_HOLIDAY = "holiday"
PRESET_ALTERNATE = "alternate" 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. # future this should probably made into a binary sensor for the fan.
CODE_TO_FAN_STATE = {0: FAN_OFF, 1: FAN_ON} 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} CODE_TO_HOLD_STATE = {0: False, 1: True}
PARALLEL_UPDATES = 1
def round_temp(temperature): def round_temp(temperature):
"""Round a temperature to the resolution of the thermostat. """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, hass: HomeAssistant,
config: ConfigType, config: ConfigType,
add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the Radio Thermostat.""" """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: if CONF_HOST in config:
hosts = config[CONF_HOST] hosts = config[CONF_HOST]
else: 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") _LOGGER.error("No Radiotherm Thermostats detected")
return False return
hold_temp = config.get(CONF_HOLD_TEMP)
tstats = []
hold_temp: bool = config[CONF_HOLD_TEMP]
for host in hosts: for host in hosts:
try: hass.async_create_task(
tstat = radiotherm.get_thermostat(host) hass.config_entries.flow.async_init(
tstats.append(RadioThermostat(tstat, hold_temp)) DOMAIN,
except OSError: context={"source": SOURCE_IMPORT},
_LOGGER.exception("Unable to connect to Radio Thermostat: %s", host) data={CONF_HOST: host, CONF_HOLD_TEMP: hold_temp},
)
add_entities(tstats, True) )
class RadioThermostat(ClimateEntity): class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEntity):
"""Representation of a Radio Thermostat.""" """Representation of a Radio Thermostat."""
_attr_hvac_modes = OPERATION_LIST _attr_hvac_modes = OPERATION_LIST
_attr_supported_features = ( _attr_temperature_unit = TEMP_FAHRENHEIT
ClimateEntityFeature.TARGET_TEMPERATURE _attr_precision = PRECISION_HALVES
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.PRESET_MODE
)
def __init__(self, device, hold_temp): def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None:
"""Initialize the thermostat.""" """Initialize the thermostat."""
self.device = device super().__init__(coordinator)
self._target_temperature = None self.device = coordinator.init_data.tstat
self._current_temperature = None self._attr_name = coordinator.init_data.name
self._current_humidity = None self._hold_temp = coordinator.hold_temp
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
self._hold_set = False self._hold_set = False
self._prev_temp = None self._attr_unique_id = coordinator.init_data.mac
self._preset_mode = None self._attr_device_info = DeviceInfo(
self._program_mode = None name=coordinator.init_data.name,
self._is_away = False model=coordinator.init_data.model,
manufacturer="Radio Thermostats",
# Fan circulate mode is only supported by the CT80 models. 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._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.""" """Register callbacks."""
# Set the time on the device. This shouldn't be in the # 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 # 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 # temperature in the thermostat. So add it as a future job
# for the event loop to run. # for the event loop to run.
self.hass.async_add_job(self.set_time) self.hass.async_add_job(self.set_time)
await super().async_added_to_hass()
@property async def async_set_fan_mode(self, fan_mode: str) -> None:
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):
"""Turn fan on/off.""" """Turn fan on/off."""
if (code := FAN_MODE_TO_CODE.get(fan_mode)) is not None: if (code := FAN_MODE_TO_CODE.get(fan_mode)) is None:
self.device.fmode = code 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 _set_fan_mode(self, code: int) -> None:
def current_temperature(self): """Turn fan on/off."""
"""Return the current temperature.""" self.device.fmode = code
return self._current_temperature
@property @callback
def current_humidity(self): def _handle_coordinator_update(self) -> None:
"""Return the current temperature.""" self._process_data()
return self._current_humidity return super()._handle_coordinator_update()
@property @callback
def hvac_mode(self) -> HVACMode: def _process_data(self) -> None:
"""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):
"""Update and validate the data from the thermostat.""" """Update and validate the data from the thermostat."""
# Radio thermostats are very slow, and sometimes don't respond data = self.data.tstat
# very quickly. So we need to keep the number of calls to them if self._is_model_ct80:
# to a bare minimum or we'll hit the Home Assistant 10 sec warning. We self._attr_current_humidity = self.data.humidity
# have to make one call to /tstat to get temps but we'll try and self._attr_preset_mode = CODE_TO_PRESET_MODE[data["program_mode"]]
# keep the other calls to a minimum. Even with this, these # Map thermostat values into various STATE_ flags.
# thermostats tend to time out sometimes when they're actively self._attr_current_temperature = data["temp"]
# heating or cooling. self._attr_fan_mode = CODE_TO_FAN_MODE[data["fmode"]]
self._attr_extra_state_attributes = {
try: ATTR_FAN_ACTION: CODE_TO_FAN_STATE[data["fstate"]]
# First time - get the name from the thermostat. This is }
# normally set in the radio thermostat web app. self._attr_hvac_mode = CODE_TO_TEMP_MODE[data["tmode"]]
if self._name is None: self._hold_set = CODE_TO_HOLD_STATE[data["hold"]]
self._name = self.device.name["raw"] if self.hvac_mode == HVACMode.OFF:
self._attr_hvac_action = None
# 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,
)
else: else:
if self._is_model_ct80: self._attr_hvac_action = CODE_TO_TEMP_STATE[data["tstate"]]
self._current_humidity = humiditydata if self.hvac_mode == HVACMode.COOL:
self._program_mode = data["program_mode"] self._attr_target_temperature = data["t_cool"]
self._preset_mode = CODE_TO_PRESET_MODE[data["program_mode"]] 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. async def async_set_temperature(self, **kwargs: Any) -> None:
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):
"""Set new target temperature.""" """Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return 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) temperature = round_temp(temperature)
if self.hvac_mode == HVACMode.COOL:
if self._current_operation == HVACMode.COOL:
self.device.t_cool = temperature self.device.t_cool = temperature
elif self._current_operation == HVACMode.HEAT: elif self.hvac_mode == HVACMode.HEAT:
self.device.t_heat = temperature self.device.t_heat = temperature
elif self._current_operation == HVACMode.AUTO: elif self.hvac_mode == HVACMode.AUTO:
if self._tstate == HVACAction.COOLING: if self.hvac_action == HVACAction.COOLING:
self.device.t_cool = temperature self.device.t_cool = temperature
elif self._tstate == HVACAction.HEATING: elif self.hvac_action == HVACAction.HEATING:
self.device.t_heat = temperature self.device.t_heat = temperature
# Only change the hold if requested or if hold mode was turned # Only change the hold if requested or if hold mode was turned
# on and we haven't set it yet. # 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: if self._hold_temp:
self.device.hold = 1 self.device.hold = 1
self._hold_set = True self._hold_set = True
else: else:
self.device.hold = 0 self.device.hold = 0
def set_time(self): def set_time(self) -> None:
"""Set device time.""" """Set device time."""
# Calling this clears any local temperature override and # Calling this clears any local temperature override and
# reverts to the scheduled temperature. # reverts to the scheduled temperature.
@ -353,23 +305,32 @@ class RadioThermostat(ClimateEntity):
"minute": now.minute, "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).""" """Set operation mode (auto, cool, heat, off)."""
if hvac_mode in (HVACMode.OFF, HVACMode.AUTO): if hvac_mode in (HVACMode.OFF, HVACMode.AUTO):
self.device.tmode = TEMP_MODE_TO_CODE[hvac_mode] self.device.tmode = TEMP_MODE_TO_CODE[hvac_mode]
# Setting t_cool or t_heat automatically changes tmode. # Setting t_cool or t_heat automatically changes tmode.
elif hvac_mode == HVACMode.COOL: elif hvac_mode == HVACMode.COOL:
self.device.t_cool = self._target_temperature self.device.t_cool = self.target_temperature
elif hvac_mode == HVACMode.HEAT: 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).""" """Set Preset mode (Home, Alternate, Away, Holiday)."""
if preset_mode in PRESET_MODES: if preset_mode not in PRESET_MODES:
self.device.program_mode = PRESET_MODE_TO_CODE[preset_mode] raise HomeAssistantError("{preset_mode} is not a valid preset_mode")
else: await self.hass.async_add_executor_job(self._set_preset_mode, preset_mode)
_LOGGER.error( self._attr_preset_mode = preset_mode
"Preset_mode %s not in PRESET_MODES", self.async_write_ha_state()
preset_mode, 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]

View File

@ -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
}
),
)

View File

@ -0,0 +1,7 @@
"""Constants for the Radio Thermostat integration."""
DOMAIN = "radiotherm"
CONF_HOLD_TEMP = "hold_temp"
TIMEOUT = 25

View File

@ -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

View File

@ -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)

View File

@ -3,7 +3,12 @@
"name": "Radio Thermostat", "name": "Radio Thermostat",
"documentation": "https://www.home-assistant.io/integrations/radiotherm", "documentation": "https://www.home-assistant.io/integrations/radiotherm",
"requirements": ["radiotherm==2.1.0"], "requirements": ["radiotherm==2.1.0"],
"codeowners": ["@vinnyfuria"], "codeowners": ["@bdraco", "@vinnyfuria"],
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["radiotherm"] "loggers": ["radiotherm"],
"dhcp": [
{ "hostname": "thermostat*", "macaddress": "5CDAD4*" },
{ "registered_devices": true }
],
"config_flow": true
} }

View File

@ -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."
}
}
}
}
}

View File

@ -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."
}
}
}
}
}

View File

@ -280,6 +280,7 @@ FLOWS = {
"qnap_qsw", "qnap_qsw",
"rachio", "rachio",
"radio_browser", "radio_browser",
"radiotherm",
"rainforest_eagle", "rainforest_eagle",
"rainmachine", "rainmachine",
"rdw", "rdw",

View File

@ -77,6 +77,8 @@ DHCP: list[dict[str, str | bool]] = [
{'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': '009D6B*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': '009D6B*'},
{'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': 'F0038C*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': 'F0038C*'},
{'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': '74C63B*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': '74C63B*'},
{'domain': 'radiotherm', 'hostname': 'thermostat*', 'macaddress': '5CDAD4*'},
{'domain': 'radiotherm', 'registered_devices': True},
{'domain': 'rainforest_eagle', 'macaddress': 'D8D5B9*'}, {'domain': 'rainforest_eagle', 'macaddress': 'D8D5B9*'},
{'domain': 'ring', 'hostname': 'ring*', 'macaddress': '0CAE7D*'}, {'domain': 'ring', 'hostname': 'ring*', 'macaddress': '0CAE7D*'},
{'domain': 'roomba', 'hostname': 'irobot-*', 'macaddress': '501479*'}, {'domain': 'roomba', 'hostname': 'irobot-*', 'macaddress': '501479*'},

View File

@ -1366,6 +1366,9 @@ rachiopy==1.0.3
# homeassistant.components.radio_browser # homeassistant.components.radio_browser
radios==0.1.1 radios==0.1.1
# homeassistant.components.radiotherm
radiotherm==2.1.0
# homeassistant.components.rainmachine # homeassistant.components.rainmachine
regenmaschine==2022.06.0 regenmaschine==2022.06.0

View File

@ -0,0 +1 @@
"""Tests for the Radio Thermostat integration."""

View File

@ -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}