Make radiotherm hold mode a switch (#73104)

This commit is contained in:
J. Nick Koston 2022-06-07 14:26:06 -10:00 committed by GitHub
parent 1331c75ec2
commit 329595bf73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 195 additions and 181 deletions

View File

@ -963,9 +963,12 @@ omit =
homeassistant/components/radio_browser/__init__.py
homeassistant/components/radio_browser/media_source.py
homeassistant/components/radiotherm/__init__.py
homeassistant/components/radiotherm/entity.py
homeassistant/components/radiotherm/climate.py
homeassistant/components/radiotherm/coordinator.py
homeassistant/components/radiotherm/data.py
homeassistant/components/radiotherm/switch.py
homeassistant/components/radiotherm/util.py
homeassistant/components/rainbird/*
homeassistant/components/raincloud/*
homeassistant/components/rainmachine/__init__.py

View File

@ -1,7 +1,9 @@
"""The radiotherm component."""
from __future__ import annotations
from collections.abc import Coroutine
from socket import timeout
from typing import Any, TypeVar
from radiotherm.validate import RadiothermTstatError
@ -10,30 +12,46 @@ 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 .const import DOMAIN
from .coordinator import RadioThermUpdateCoordinator
from .data import async_get_init_data
from .util import async_set_time
PLATFORMS: list[Platform] = [Platform.CLIMATE]
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH]
_T = TypeVar("_T")
async def _async_call_or_raise_not_ready(
coro: Coroutine[Any, Any, _T], host: str
) -> _T:
"""Call a coro or raise ConfigEntryNotReady."""
try:
return await coro
except RadiothermTstatError as ex:
msg = f"{host} was busy (invalid value returned): {ex}"
raise ConfigEntryNotReady(msg) from ex
except timeout as ex:
msg = f"{host} timed out waiting for a response: {ex}"
raise ConfigEntryNotReady(msg) from ex
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)
init_coro = async_get_init_data(hass, host)
init_data = await _async_call_or_raise_not_ready(init_coro, host)
coordinator = RadioThermUpdateCoordinator(hass, init_data)
await coordinator.async_config_entry_first_refresh()
# Only set the time if the thermostat is
# not in hold mode since setting the time
# clears the hold for some strange design
# choice
if not coordinator.data.tstat["hold"]:
time_coro = async_set_time(hass, init_data.tstat)
await _async_call_or_raise_not_ready(time_coro, host)
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))

View File

@ -1,7 +1,6 @@
"""Support for Radio Thermostat wifi-enabled home thermostats."""
from __future__ import annotations
from functools import partial
import logging
from typing import Any
@ -27,19 +26,13 @@ from homeassistant.const import (
TEMP_FAHRENHEIT,
)
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
from .entity import RadioThermostatEntity
_LOGGER = logging.getLogger(__name__)
@ -92,10 +85,11 @@ PRESET_MODE_TO_CODE = {
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
CONF_HOLD_TEMP = "hold_temp"
def round_temp(temperature):
"""Round a temperature to the resolution of the thermostat.
@ -150,18 +144,17 @@ async def async_setup_platform(
_LOGGER.error("No Radiotherm Thermostats detected")
return
hold_temp: bool = config[CONF_HOLD_TEMP]
for host in hosts:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_HOST: host, CONF_HOLD_TEMP: hold_temp},
data={CONF_HOST: host},
)
)
class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEntity):
class RadioThermostat(RadioThermostatEntity, ClimateEntity):
"""Representation of a Radio Thermostat."""
_attr_hvac_modes = OPERATION_LIST
@ -171,49 +164,22 @@ class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEnt
def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None:
"""Initialize the thermostat."""
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._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_name = self.init_data.name
self._attr_unique_id = self.init_data.mac
self._attr_fan_modes = CT30_FAN_OPERATION_LIST
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
if not isinstance(self.device, radiotherm.thermostat.CT80):
return
self._attr_fan_modes = CT80_FAN_OPERATION_LIST
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
self._attr_preset_modes = PRESET_MODES
@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
# update() because calling it will clear any temporary mode or
# 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()
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 None:
raise HomeAssistantError(f"{fan_mode} is not a valid fan mode")
raise ValueError(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()
@ -223,16 +189,11 @@ class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEnt
"""Turn fan on/off."""
self.device.fmode = code
@callback
def _handle_coordinator_update(self) -> None:
self._process_data()
return super()._handle_coordinator_update()
@callback
def _process_data(self) -> None:
"""Update and validate the data from the thermostat."""
data = self.data.tstat
if self._is_model_ct80:
if isinstance(self.device, radiotherm.thermostat.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.
@ -242,7 +203,6 @@ class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEnt
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:
@ -264,15 +224,12 @@ class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEnt
"""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)
)
await self.hass.async_add_executor_job(self._set_temperature, temperature)
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:
def _set_temperature(self, temperature: int) -> None:
"""Set new target temperature."""
temperature = round_temp(temperature)
if self.hvac_mode == HVACMode.COOL:
@ -285,26 +242,6 @@ class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEnt
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 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) -> None:
"""Set device time."""
# Calling this clears any local temperature override and
# reverts to the scheduled temperature.
now = dt_util.now()
self.device.time = {
"day": now.weekday(),
"hour": now.hour,
"minute": now.minute,
}
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)
@ -325,7 +262,7 @@ class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEnt
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set Preset mode (Home, Alternate, Away, Holiday)."""
if preset_mode not in PRESET_MODES:
raise HomeAssistantError("{preset_mode} is not a valid preset_mode")
raise ValueError(f"{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()
@ -333,4 +270,5 @@ class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEnt
def _set_preset_mode(self, preset_mode: str) -> None:
"""Set Preset mode (Home, Alternate, Away, Holiday)."""
assert isinstance(self.device, radiotherm.thermostat.CT80)
self.device.program_mode = PRESET_MODE_TO_CODE[preset_mode]

View File

@ -11,11 +11,11 @@ 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.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError
from .const import CONF_HOLD_TEMP, DOMAIN
from .const import DOMAIN
from .data import RadioThermInitData, async_get_init_data
_LOGGER = logging.getLogger(__name__)
@ -68,7 +68,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title=init_data.name,
data={CONF_HOST: ip_address},
options={CONF_HOLD_TEMP: False},
)
self._set_confirm_only()
@ -100,7 +99,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
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(
@ -125,7 +123,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title=init_data.name,
data=user_input,
options={CONF_HOLD_TEMP: False},
)
return self.async_show_form(
@ -133,34 +130,3 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
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

@ -2,6 +2,4 @@
DOMAIN = "radiotherm"
CONF_HOLD_TEMP = "hold_temp"
TIMEOUT = 25

View File

@ -20,12 +20,9 @@ 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:
def __init__(self, hass: HomeAssistant, init_data: RadioThermInitData) -> 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,
@ -39,10 +36,8 @@ class RadioThermUpdateCoordinator(DataUpdateCoordinator[RadioThermUpdate]):
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
msg = f"{self._description} was busy (invalid value returned): {ex}"
raise UpdateFailed(msg) from ex
except timeout as ex:
raise UpdateFailed(
f"{self._description}) timed out waiting for a response: {ex}"
) from ex
msg = f"{self._description}) timed out waiting for a response: {ex}"
raise UpdateFailed(msg) from ex

View File

@ -0,0 +1,44 @@
"""The radiotherm integration base entity."""
from abc import abstractmethod
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import RadioThermUpdateCoordinator
from .data import RadioThermUpdate
class RadioThermostatEntity(CoordinatorEntity[RadioThermUpdateCoordinator]):
"""Base class for radiotherm entities."""
def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.init_data = coordinator.init_data
self.device = coordinator.init_data.tstat
self._attr_device_info = DeviceInfo(
name=self.init_data.name,
model=self.init_data.model,
manufacturer="Radio Thermostats",
sw_version=self.init_data.fw_version,
connections={(dr.CONNECTION_NETWORK_MAC, self.init_data.mac)},
)
self._process_data()
@property
def data(self) -> RadioThermUpdate:
"""Returnt the last update."""
return self.coordinator.data
@callback
@abstractmethod
def _process_data(self) -> None:
"""Update and validate the data from the thermostat."""
@callback
def _handle_coordinator_update(self) -> None:
self._process_data()
return super()._handle_coordinator_update()

View File

@ -0,0 +1,65 @@
"""Support for radiotherm switches."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import RadioThermUpdateCoordinator
from .entity import RadioThermostatEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switches for a radiotherm device."""
coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([RadioThermHoldSwitch(coordinator)])
class RadioThermHoldSwitch(RadioThermostatEntity, SwitchEntity):
"""Provides radiotherm hold switch support."""
def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None:
"""Initialize the hold mode switch."""
super().__init__(coordinator)
self._attr_name = f"{coordinator.init_data.name} Hold"
self._attr_unique_id = f"{coordinator.init_data.mac}_hold"
@property
def icon(self) -> str:
"""Return the icon for the switch."""
return "mdi:timer-off" if self.is_on else "mdi:timer"
@callback
def _process_data(self) -> None:
"""Update and validate the data from the thermostat."""
data = self.data.tstat
self._attr_is_on = bool(data["hold"])
def _set_hold(self, hold: bool) -> None:
"""Set hold mode."""
self.device.hold = int(hold)
async def _async_set_hold(self, hold: bool) -> None:
"""Set hold mode."""
await self.hass.async_add_executor_job(self._set_hold, hold)
self._attr_is_on = hold
self.async_write_ha_state()
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable permanent hold."""
await self._async_set_hold(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable permanent hold."""
await self._async_set_hold(False)

View File

@ -0,0 +1,24 @@
"""Utils for radiotherm."""
from __future__ import annotations
from radiotherm.thermostat import CommonThermostat
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
async def async_set_time(hass: HomeAssistant, device: CommonThermostat) -> None:
"""Sync time to the thermostat."""
await hass.async_add_executor_job(_set_time, device)
def _set_time(device: CommonThermostat) -> None:
"""Set device time."""
# Calling this clears any local temperature override and
# reverts to the scheduled temperature.
now = dt_util.now()
device.time = {
"day": now.weekday(),
"hour": now.hour,
"minute": now.minute,
}

View File

@ -7,7 +7,7 @@ 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.components.radiotherm.const import DOMAIN
from homeassistant.const import CONF_HOST
from tests.common import MockConfigEntry
@ -110,17 +110,12 @@ async def test_import(hass):
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},
data={CONF_HOST: "1.2.3.4"},
)
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 result["data"] == {CONF_HOST: "1.2.3.4"}
assert len(mock_setup_entry.mock_calls) == 1
@ -133,7 +128,7 @@ async def test_import_cannot_connect(hass):
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},
data={CONF_HOST: "1.2.3.4"},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
@ -268,35 +263,3 @@ async def test_user_unique_id_already_exists(hass):
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}