1
mirror of https://github.com/home-assistant/core synced 2024-09-06 10:29:55 +02:00

Bugfix evohome (#26810)

* address issues #25984, #25985

* small tweak

* refactor - fix bugs, coding erros, consolidate

* some zones don't have schedules

* some zones don't have schedules 2

* some zones don't have schedules 3

* fix water_heater, add away mode

* readbility tweak

* bugfix: no refesh after state change

* bugfix: no refesh after state change 2

* temove dodgy wrappers (protected-access), fix until logic

* remove dodgy _set_zone_mode wrapper

* tweak

* tweak docstrings

* refactor as per PR review

* refactor as per PR review 3

* refactor to use dt_util

* small tweak

* tweak doc strings

* remove packet from _refresh

* set_temp() don't have until

* add unique_id

* add unique_id 2
This commit is contained in:
David Bonnes 2019-10-01 05:35:10 +01:00 committed by Paulus Schoutsen
parent e2d7a01d65
commit a1997ee891
4 changed files with 315 additions and 266 deletions

View File

@ -4,6 +4,7 @@ Such systems include evohome (multi-zone), and Round Thermostat (single zone).
"""
from datetime import datetime, timedelta
import logging
import re
from typing import Any, Dict, Optional, Tuple
import aiohttp.client_exceptions
@ -25,9 +26,9 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from homeassistant.util.dt import parse_datetime, utcnow
import homeassistant.util.dt as dt_util
from .const import DOMAIN, STORAGE_VERSION, STORAGE_KEY, GWS, TCS
from .const import DOMAIN, EVO_FOLLOW, STORAGE_VERSION, STORAGE_KEY, GWS, TCS
_LOGGER = logging.getLogger(__name__)
@ -55,20 +56,45 @@ CONFIG_SCHEMA = vol.Schema(
)
def _local_dt_to_utc(dt_naive: datetime) -> datetime:
dt_aware = utcnow() + (dt_naive - datetime.now())
def _local_dt_to_aware(dt_naive: datetime) -> datetime:
dt_aware = dt_util.now() + (dt_naive - datetime.now())
if dt_aware.microsecond >= 500000:
dt_aware += timedelta(seconds=1)
return dt_aware.replace(microsecond=0)
def _utc_to_local_dt(dt_aware: datetime) -> datetime:
dt_naive = datetime.now() + (dt_aware - utcnow())
def _dt_to_local_naive(dt_aware: datetime) -> datetime:
dt_naive = datetime.now() + (dt_aware - dt_util.now())
if dt_naive.microsecond >= 500000:
dt_naive += timedelta(seconds=1)
return dt_naive.replace(microsecond=0)
def convert_until(status_dict, until_key) -> str:
"""Convert datetime string from "%Y-%m-%dT%H:%M:%SZ" to local/aware/isoformat."""
if until_key in status_dict: # only present for certain modes
dt_utc_naive = dt_util.parse_datetime(status_dict[until_key])
status_dict[until_key] = dt_util.as_local(dt_utc_naive).isoformat()
def convert_dict(dictionary: Dict[str, Any]) -> Dict[str, Any]:
"""Recursively convert a dict's keys to snake_case."""
def convert_key(key: str) -> str:
"""Convert a string to snake_case."""
string = re.sub(r"[\-\.\s]", "_", str(key))
return (string[0]).lower() + re.sub(
r"[A-Z]", lambda matched: "_" + matched.group(0).lower(), string[1:]
)
return {
(convert_key(k) if isinstance(k, str) else k): (
convert_dict(v) if isinstance(v, dict) else v
)
for k, v in dictionary.items()
}
def _handle_exception(err) -> bool:
try:
raise err
@ -135,7 +161,7 @@ class EvoBroker:
"""Container for evohome client and data."""
def __init__(self, hass, params) -> None:
"""Initialize the evohome client and data structure."""
"""Initialize the evohome client and its data structure."""
self.hass = hass
self.params = params
self.config = {}
@ -157,7 +183,7 @@ class EvoBroker:
# evohomeasync2 uses naive/local datetimes
if access_token_expires is not None:
access_token_expires = _utc_to_local_dt(access_token_expires)
access_token_expires = _dt_to_local_naive(access_token_expires)
client = self.client = evohomeasync2.EvohomeClient(
self.params[CONF_USERNAME],
@ -220,7 +246,7 @@ class EvoBroker:
access_token = app_storage.get(CONF_ACCESS_TOKEN)
at_expires_str = app_storage.get(CONF_ACCESS_TOKEN_EXPIRES)
if at_expires_str:
at_expires_dt = parse_datetime(at_expires_str)
at_expires_dt = dt_util.parse_datetime(at_expires_str)
else:
at_expires_dt = None
@ -230,7 +256,7 @@ class EvoBroker:
async def _save_auth_tokens(self, *args) -> None:
# evohomeasync2 uses naive/local datetimes
access_token_expires = _local_dt_to_utc(self.client.access_token_expires)
access_token_expires = _local_dt_to_aware(self.client.access_token_expires)
self._app_storage[CONF_USERNAME] = self.params[CONF_USERNAME]
self._app_storage[CONF_REFRESH_TOKEN] = self.client.refresh_token
@ -246,11 +272,11 @@ class EvoBroker:
)
async def update(self, *args, **kwargs) -> None:
"""Get the latest state data of the entire evohome Location.
"""Get the latest state data of an entire evohome Location.
This includes state data for the Controller and all its child devices,
such as the operating mode of the Controller and the current temp of
its children (e.g. Zones, DHW controller).
This includes state data for a Controller and all its child devices, such as the
operating mode of the Controller and the current temp of its children (e.g.
Zones, DHW controller).
"""
loc_idx = self.params[CONF_LOCATION_IDX]
@ -260,9 +286,7 @@ class EvoBroker:
_handle_exception(err)
else:
# inform the evohome devices that state data has been updated
self.hass.helpers.dispatcher.async_dispatcher_send(
DOMAIN, {"signal": "refresh"}
)
self.hass.helpers.dispatcher.async_dispatcher_send(DOMAIN)
_LOGGER.debug("Status = %s", status[GWS][0][TCS][0])
@ -270,8 +294,8 @@ class EvoBroker:
class EvoDevice(Entity):
"""Base for any evohome device.
This includes the Controller, (up to 12) Heating Zones and
(optionally) a DHW controller.
This includes the Controller, (up to 12) Heating Zones and (optionally) a
DHW controller.
"""
def __init__(self, evo_broker, evo_device) -> None:
@ -280,72 +304,26 @@ class EvoDevice(Entity):
self._evo_broker = evo_broker
self._evo_tcs = evo_broker.tcs
self._name = self._icon = self._precision = None
self._state_attributes = []
self._unique_id = self._name = self._icon = self._precision = None
self._device_state_attrs = {}
self._state_attributes = []
self._supported_features = None
self._schedule = {}
@callback
def _refresh(self, packet):
if packet["signal"] == "refresh":
self.async_schedule_update_ha_state(force_refresh=True)
@property
def setpoints(self) -> Dict[str, Any]:
"""Return the current/next setpoints from the schedule.
Only Zones & DHW controllers (but not the TCS) can have schedules.
"""
if not self._schedule["DailySchedules"]:
return {}
switchpoints = {}
day_time = datetime.now()
day_of_week = int(day_time.strftime("%w")) # 0 is Sunday
# Iterate today's switchpoints until past the current time of day...
day = self._schedule["DailySchedules"][day_of_week]
sp_idx = -1 # last switchpoint of the day before
for i, tmp in enumerate(day["Switchpoints"]):
if day_time.strftime("%H:%M:%S") > tmp["TimeOfDay"]:
sp_idx = i # current setpoint
else:
break
# Did the current SP start yesterday? Does the next start SP tomorrow?
current_sp_day = -1 if sp_idx == -1 else 0
next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0
for key, offset, idx in [
("current", current_sp_day, sp_idx),
("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)),
]:
spt = switchpoints[key] = {}
sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d")
day = self._schedule["DailySchedules"][(day_of_week + offset) % 7]
switchpoint = day["Switchpoints"][idx]
dt_naive = datetime.strptime(
f"{sp_date}T{switchpoint['TimeOfDay']}", "%Y-%m-%dT%H:%M:%S"
)
spt["from"] = _local_dt_to_utc(dt_naive).isoformat()
try:
spt["temperature"] = switchpoint["heatSetpoint"]
except KeyError:
spt["state"] = switchpoint["DhwState"]
return switchpoints
def _refresh(self) -> None:
self.async_schedule_update_ha_state(force_refresh=True)
@property
def should_poll(self) -> bool:
"""Evohome entities should not be polled."""
return False
@property
def unique_id(self) -> Optional[str]:
"""Return a unique ID."""
return self._unique_id
@property
def name(self) -> str:
"""Return the name of the Evohome entity."""
@ -354,15 +332,15 @@ class EvoDevice(Entity):
@property
def device_state_attributes(self) -> Dict[str, Any]:
"""Return the Evohome-specific state attributes."""
status = {}
for attr in self._state_attributes:
if attr != "setpoints":
status[attr] = getattr(self._evo_device, attr)
status = self._device_state_attrs
if "systemModeStatus" in status:
convert_until(status["systemModeStatus"], "timeUntil")
if "setpointStatus" in status:
convert_until(status["setpointStatus"], "until")
if "stateStatus" in status:
convert_until(status["stateStatus"], "until")
if "setpoints" in self._state_attributes:
status["setpoints"] = self.setpoints
return {"status": status}
return {"status": convert_dict(status)}
@property
def icon(self) -> str:
@ -388,27 +366,98 @@ class EvoDevice(Entity):
"""Return the temperature unit to use in the frontend UI."""
return TEMP_CELSIUS
async def _call_client_api(self, api_function) -> None:
async def _call_client_api(self, api_function, refresh=True) -> Any:
try:
await api_function
result = await api_function
except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err:
_handle_exception(err)
if not _handle_exception(err):
return
self.hass.helpers.event.async_call_later(
2, self._evo_broker.update()
) # call update() in 2 seconds
if refresh is True:
self.hass.helpers.event.async_call_later(1, self._evo_broker.update())
return result
class EvoChild(EvoDevice):
"""Base for any evohome child.
This includes (up to 12) Heating Zones and (optionally) a DHW controller.
"""
def __init__(self, evo_broker, evo_device) -> None:
"""Initialize a evohome Controller (hub)."""
super().__init__(evo_broker, evo_device)
self._schedule = {}
self._setpoints = {}
@property
def current_temperature(self) -> Optional[float]:
"""Return the current temperature of a Zone."""
if self._evo_device.temperatureStatus["isAvailable"]:
return self._evo_device.temperatureStatus["temperature"]
return None
@property
def setpoints(self) -> Dict[str, Any]:
"""Return the current/next setpoints from the schedule.
Only Zones & DHW controllers (but not the TCS) can have schedules.
"""
if not self._schedule["DailySchedules"]:
return {} # no schedule {'DailySchedules': []}, so no scheduled setpoints
day_time = dt_util.now()
day_of_week = int(day_time.strftime("%w")) # 0 is Sunday
time_of_day = day_time.strftime("%H:%M:%S")
# Iterate today's switchpoints until past the current time of day...
day = self._schedule["DailySchedules"][day_of_week]
sp_idx = -1 # last switchpoint of the day before
for i, tmp in enumerate(day["Switchpoints"]):
if time_of_day > tmp["TimeOfDay"]:
sp_idx = i # current setpoint
else:
break
# Did the current SP start yesterday? Does the next start SP tomorrow?
this_sp_day = -1 if sp_idx == -1 else 0
next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0
for key, offset, idx in [
("this", this_sp_day, sp_idx),
("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)),
]:
sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d")
day = self._schedule["DailySchedules"][(day_of_week + offset) % 7]
switchpoint = day["Switchpoints"][idx]
dt_local_aware = _local_dt_to_aware(
dt_util.parse_datetime(f"{sp_date}T{switchpoint['TimeOfDay']}")
)
self._setpoints[f"{key}_sp_from"] = dt_local_aware.isoformat()
try:
self._setpoints[f"{key}_sp_temp"] = switchpoint["heatSetpoint"]
except KeyError:
self._setpoints[f"{key}_sp_state"] = switchpoint["DhwState"]
return self._setpoints
async def _update_schedule(self) -> None:
"""Get the latest state data."""
if (
not self._schedule.get("DailySchedules")
or parse_datetime(self.setpoints["next"]["from"]) < utcnow()
):
try:
self._schedule = await self._evo_device.schedule()
except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err:
_handle_exception(err)
"""Get the latest schedule."""
if "DailySchedules" in self._schedule and not self._schedule["DailySchedules"]:
if not self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW:
return # avoid unnecessary I/O - there's nothing to update
self._schedule = await self._call_client_api(
self._evo_device.schedule(), refresh=False
)
async def async_update(self) -> None:
"""Get the latest state data."""
await self._update_schedule()
next_sp_from = self._setpoints.get("next_sp_from", "2000-01-01T00:00:00+00:00")
if dt_util.now() >= dt_util.parse_datetime(next_sp_from):
await self._update_schedule() # no schedule, or it's out-of-date
self._device_state_attrs = {"setpoints": self.setpoints}

View File

@ -1,7 +1,6 @@
"""Support for Climate devices of (EMEA/EU-based) Honeywell TCC systems."""
from datetime import datetime
import logging
from typing import Any, Dict, Optional, List
from typing import Optional, List
from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import (
@ -22,7 +21,7 @@ from homeassistant.const import PRECISION_TENTHS
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from homeassistant.util.dt import parse_datetime
from . import CONF_LOCATION_IDX, EvoDevice
from . import CONF_LOCATION_IDX, EvoDevice, EvoChild
from .const import (
DOMAIN,
EVO_RESET,
@ -61,6 +60,9 @@ EVO_PRESET_TO_HA = {
}
HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()}
STATE_ATTRS_TCS = ["systemId", "activeFaults", "systemModeStatus"]
STATE_ATTRS_ZONES = ["zoneId", "activeFaults", "setpointStatus", "temperatureStatus"]
async def async_setup_platform(
hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None
@ -114,63 +116,20 @@ class EvoClimateDevice(EvoDevice, ClimateDevice):
"""Base for a Honeywell evohome Climate device."""
def __init__(self, evo_broker, evo_device) -> None:
"""Initialize the evohome Climate device."""
"""Initialize a Climate device."""
super().__init__(evo_broker, evo_device)
self._preset_modes = None
async def _set_temperature(
self, temperature: float, until: Optional[datetime] = None
) -> None:
"""Set a new target temperature for the Zone.
until == None means indefinitely (i.e. PermanentOverride)
"""
await self._call_client_api(
self._evo_device.set_temperature(temperature, until)
)
async def _set_zone_mode(self, op_mode: str) -> None:
"""Set a Zone to one of its native EVO_* operating modes.
Zones inherit their _effective_ operating mode from the Controller.
Usually, Zones are in 'FollowSchedule' mode, where their setpoints are
a function of their own schedule and the Controller's operating mode,
e.g. 'AutoWithEco' mode means their setpoint is (by default) 3C less
than scheduled.
However, Zones can _override_ these setpoints, either indefinitely,
'PermanentOverride' mode, or for a period of time, 'TemporaryOverride',
after which they will revert back to 'FollowSchedule'.
Finally, some of the Controller's operating modes are _forced_ upon the
Zones, regardless of any override mode, e.g. 'HeatingOff', Zones to
(by default) 5C, and 'Away', Zones to (by default) 12C.
"""
if op_mode == EVO_FOLLOW:
await self._call_client_api(self._evo_device.cancel_temp_override())
return
temperature = self._evo_device.setpointStatus["targetHeatTemperature"]
until = None # EVO_PERMOVER
if op_mode == EVO_TEMPOVER and self._schedule["DailySchedules"]:
await self._update_schedule()
if self._schedule["DailySchedules"]:
until = parse_datetime(self.setpoints["next"]["from"])
await self._set_temperature(temperature, until=until)
async def _set_tcs_mode(self, op_mode: str) -> None:
"""Set the Controller to any of its native EVO_* operating modes."""
"""Set a Controller to any of its native EVO_* operating modes."""
await self._call_client_api(
self._evo_tcs._set_status(op_mode) # pylint: disable=protected-access
)
@property
def hvac_modes(self) -> List[str]:
"""Return the list of available hvac operation modes."""
"""Return a list of available hvac operation modes."""
return list(HA_HVAC_TO_TCS)
@property
@ -179,36 +138,24 @@ class EvoClimateDevice(EvoDevice, ClimateDevice):
return self._preset_modes
class EvoZone(EvoClimateDevice):
class EvoZone(EvoChild, EvoClimateDevice):
"""Base for a Honeywell evohome Zone."""
def __init__(self, evo_broker, evo_device) -> None:
"""Initialize the evohome Zone."""
"""Initialize a Zone."""
super().__init__(evo_broker, evo_device)
self._unique_id = evo_device.zoneId
self._name = evo_device.name
self._icon = "mdi:radiator"
self._precision = self._evo_device.setpointCapabilities["valueResolution"]
self._state_attributes = [
"zoneId",
"activeFaults",
"setpointStatus",
"temperatureStatus",
"setpoints",
]
self._supported_features = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE
self._preset_modes = list(HA_PRESET_TO_EVO)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._evo_device.temperatureStatus["isAvailable"]
@property
def hvac_mode(self) -> str:
"""Return the current operating mode of the evohome Zone."""
"""Return the current operating mode of a Zone."""
if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]:
return HVAC_MODE_AUTO
is_off = self.target_temperature <= self.min_temp
@ -221,24 +168,15 @@ class EvoZone(EvoClimateDevice):
return CURRENT_HVAC_OFF
if self.target_temperature <= self.min_temp:
return CURRENT_HVAC_OFF
if self.target_temperature < self.current_temperature:
if not self._evo_device.temperatureStatus["isAvailable"]:
return None
if self.target_temperature <= self.current_temperature:
return CURRENT_HVAC_IDLE
return CURRENT_HVAC_HEAT
@property
def current_temperature(self) -> Optional[float]:
"""Return the current temperature of the evohome Zone."""
return (
self._evo_device.temperatureStatus["temperature"]
if self._evo_device.temperatureStatus["isAvailable"]
else None
)
@property
def target_temperature(self) -> float:
"""Return the target temperature of the evohome Zone."""
if self._evo_tcs.systemModeStatus["mode"] == EVO_HEATOFF:
return self._evo_device.setpointCapabilities["minHeatSetpoint"]
"""Return the target temperature of a Zone."""
return self._evo_device.setpointStatus["targetHeatTemperature"]
@property
@ -252,7 +190,7 @@ class EvoZone(EvoClimateDevice):
@property
def min_temp(self) -> float:
"""Return the minimum target temperature of a evohome Zone.
"""Return the minimum target temperature of a Zone.
The default is 5, but is user-configurable within 5-35 (in Celsius).
"""
@ -260,7 +198,7 @@ class EvoZone(EvoClimateDevice):
@property
def max_temp(self) -> float:
"""Return the maximum target temperature of a evohome Zone.
"""Return the maximum target temperature of a Zone.
The default is 35, but is user-configurable within 5-35 (in Celsius).
"""
@ -268,26 +206,70 @@ class EvoZone(EvoClimateDevice):
async def async_set_temperature(self, **kwargs) -> None:
"""Set a new target temperature."""
until = kwargs.get("until")
if until:
until = parse_datetime(until)
temperature = kwargs["temperature"]
await self._set_temperature(kwargs["temperature"], until)
if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW:
await self._update_schedule()
until = parse_datetime(str(self.setpoints.get("next_sp_from")))
elif self._evo_device.setpointStatus["setpointMode"] == EVO_TEMPOVER:
until = parse_datetime(self._evo_device.setpointStatus["until"])
else: # EVO_PERMOVER
until = None
await self._call_client_api(
self._evo_device.set_temperature(temperature, until)
)
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
"""Set an operating mode for the Zone."""
if hvac_mode == HVAC_MODE_OFF:
await self._set_temperature(self.min_temp, until=None)
"""Set a Zone to one of its native EVO_* operating modes.
Zones inherit their _effective_ operating mode from their Controller.
Usually, Zones are in 'FollowSchedule' mode, where their setpoints are a
function of their own schedule and the Controller's operating mode, e.g.
'AutoWithEco' mode means their setpoint is (by default) 3C less than scheduled.
However, Zones can _override_ these setpoints, either indefinitely,
'PermanentOverride' mode, or for a set period of time, 'TemporaryOverride' mode
(after which they will revert back to 'FollowSchedule' mode).
Finally, some of the Controller's operating modes are _forced_ upon the Zones,
regardless of any override mode, e.g. 'HeatingOff', Zones to (by default) 5C,
and 'Away', Zones to (by default) 12C.
"""
if hvac_mode == HVAC_MODE_OFF:
await self._call_client_api(
self._evo_device.set_temperature(self.min_temp, until=None)
)
else: # HVAC_MODE_HEAT
await self._set_zone_mode(EVO_FOLLOW)
await self._call_client_api(self._evo_device.cancel_temp_override())
async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None:
"""Set a new preset mode.
"""Set the preset mode; if None, then revert to following the schedule."""
evo_preset_mode = HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW)
If preset_mode is None, then revert to following the schedule.
"""
await self._set_zone_mode(HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW))
if evo_preset_mode == EVO_FOLLOW:
await self._call_client_api(self._evo_device.cancel_temp_override())
return
temperature = self._evo_device.setpointStatus["targetHeatTemperature"]
if evo_preset_mode == EVO_TEMPOVER:
await self._update_schedule()
until = parse_datetime(str(self.setpoints.get("next_sp_from")))
else: # EVO_PERMOVER
until = None
await self._call_client_api(
self._evo_device.set_temperature(temperature, until)
)
async def async_update(self) -> None:
"""Get the latest state data for a Zone."""
await super().async_update()
for attr in STATE_ATTRS_ZONES:
self._device_state_attrs[attr] = getattr(self._evo_device, attr)
class EvoController(EvoClimateDevice):
@ -298,21 +280,20 @@ class EvoController(EvoClimateDevice):
"""
def __init__(self, evo_broker, evo_device) -> None:
"""Initialize the evohome Controller (hub)."""
"""Initialize a evohome Controller (hub)."""
super().__init__(evo_broker, evo_device)
self._unique_id = evo_device.systemId
self._name = evo_device.location.name
self._icon = "mdi:thermostat"
self._precision = PRECISION_TENTHS
self._state_attributes = ["systemId", "activeFaults", "systemModeStatus"]
self._supported_features = SUPPORT_PRESET_MODE
self._preset_modes = list(HA_PRESET_TO_TCS)
@property
def hvac_mode(self) -> str:
"""Return the current operating mode of the evohome Controller."""
"""Return the current operating mode of a Controller."""
tcs_mode = self._evo_tcs.systemModeStatus["mode"]
return HVAC_MODE_OFF if tcs_mode == EVO_HEATOFF else HVAC_MODE_HEAT
@ -334,52 +315,53 @@ class EvoController(EvoClimateDevice):
"""Return the current preset mode, e.g., home, away, temp."""
return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"])
async def async_set_temperature(self, **kwargs) -> None:
"""Do nothing.
@property
def min_temp(self) -> float:
"""Return None as Controllers don't have a target temperature."""
return None
The evohome Controller doesn't have a target temperature.
"""
return
@property
def max_temp(self) -> float:
"""Return None as Controllers don't have a target temperature."""
return None
async def async_set_temperature(self, **kwargs) -> None:
"""Raise exception as Controllers don't have a target temperature."""
raise NotImplementedError("Evohome Controllers don't have target temperatures.")
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
"""Set an operating mode for the Controller."""
"""Set an operating mode for a Controller."""
await self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode))
async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None:
"""Set a new preset mode.
If preset_mode is None, then revert to 'Auto' mode.
"""
"""Set the preset mode; if None, then revert to 'Auto' mode."""
await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EVO_AUTO))
async def async_update(self) -> None:
"""Get the latest state data."""
return
"""Get the latest state data for a Controller."""
self._device_state_attrs = {}
attrs = self._device_state_attrs
for attr in STATE_ATTRS_TCS:
if attr == "activeFaults":
attrs["activeSystemFaults"] = getattr(self._evo_tcs, attr)
else:
attrs[attr] = getattr(self._evo_tcs, attr)
class EvoThermostat(EvoZone):
"""Base for a Honeywell Round Thermostat.
Implemented as a combined Controller/Zone.
These are implemented as a combined Controller/Zone.
"""
def __init__(self, evo_broker, evo_device) -> None:
"""Initialize the Round Thermostat."""
"""Initialize the Thermostat."""
super().__init__(evo_broker, evo_device)
self._name = evo_broker.tcs.location.name
self._preset_modes = [PRESET_AWAY, PRESET_ECO]
@property
def device_state_attributes(self) -> Dict[str, Any]:
"""Return the device-specific state attributes."""
status = super().device_state_attributes["status"]
status["systemModeStatus"] = self._evo_tcs.systemModeStatus
status["activeFaults"] += self._evo_tcs.activeFaults
return {"status": status}
@property
def hvac_mode(self) -> str:
"""Return the current operating mode."""
@ -404,11 +386,19 @@ class EvoThermostat(EvoZone):
await self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode))
async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None:
"""Set a new preset mode.
If preset_mode is None, then revert to following the schedule.
"""
"""Set the preset mode; if None, then revert to following the schedule."""
if preset_mode in list(HA_PRESET_TO_TCS):
await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode))
else:
await self._set_zone_mode(HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW))
await super().async_set_hvac_mode(preset_mode)
async def async_update(self) -> None:
"""Get the latest state data for the Thermostat."""
await super().async_update()
attrs = self._device_state_attrs
for attr in STATE_ATTRS_TCS:
if attr == "activeFaults": # self._evo_device also has "activeFaults"
attrs["activeSystemFaults"] = getattr(self._evo_tcs, attr)
else:
attrs[attr] = getattr(self._evo_tcs, attr)

View File

@ -21,5 +21,3 @@ EVO_PERMOVER = "PermanentOverride"
# These are used only to help prevent E501 (line too long) violations
GWS = "gateways"
TCS = "temperatureControlSystems"
EVO_STRFTIME = "%Y-%m-%dT%H:%M:%SZ"

View File

@ -3,27 +3,31 @@ import logging
from typing import List
from homeassistant.components.water_heater import (
SUPPORT_AWAY_MODE,
SUPPORT_OPERATION_MODE,
WaterHeaterDevice,
)
from homeassistant.const import PRECISION_WHOLE, STATE_OFF, STATE_ON
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from homeassistant.util.dt import parse_datetime
from . import EvoDevice
from .const import DOMAIN, EVO_STRFTIME, EVO_FOLLOW, EVO_TEMPOVER, EVO_PERMOVER
from . import EvoChild
from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER
_LOGGER = logging.getLogger(__name__)
HA_STATE_TO_EVO = {STATE_ON: "On", STATE_OFF: "Off"}
EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items()}
STATE_AUTO = "auto"
HA_OPMODE_TO_DHW = {STATE_ON: EVO_FOLLOW, STATE_OFF: EVO_PERMOVER}
HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: "On", STATE_OFF: "Off"}
EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items() if k != ""}
STATE_ATTRS_DHW = ["dhwId", "activeFaults", "stateStatus", "temperatureStatus"]
async def async_setup_platform(
hass, config, async_add_entities, discovery_info=None
hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None
) -> None:
"""Create the DHW controller."""
"""Create a DHW controller."""
if discovery_info is None:
return
@ -38,63 +42,71 @@ async def async_setup_platform(
async_add_entities([evo_dhw], update_before_add=True)
class EvoDHW(EvoDevice, WaterHeaterDevice):
class EvoDHW(EvoChild, WaterHeaterDevice):
"""Base for a Honeywell evohome DHW controller (aka boiler)."""
def __init__(self, evo_broker, evo_device) -> None:
"""Initialize the evohome DHW controller."""
"""Initialize a evohome DHW controller."""
super().__init__(evo_broker, evo_device)
self._unique_id = evo_device.dhwId
self._name = "DHW controller"
self._icon = "mdi:thermometer-lines"
self._precision = PRECISION_WHOLE
self._state_attributes = [
"dhwId",
"activeFaults",
"stateStatus",
"temperatureStatus",
"setpoints",
]
self._supported_features = SUPPORT_OPERATION_MODE
self._operation_list = list(HA_OPMODE_TO_DHW)
self._supported_features = SUPPORT_AWAY_MODE | SUPPORT_OPERATION_MODE
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._evo_device.temperatureStatus.get("isAvailable", False)
def state(self):
"""Return the current state."""
return EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]]
@property
def current_operation(self) -> str:
"""Return the current operating mode (On, or Off)."""
"""Return the current operating mode (Auto, On, or Off)."""
if self._evo_device.stateStatus["mode"] == EVO_FOLLOW:
return STATE_AUTO
return EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]]
@property
def operation_list(self) -> List[str]:
"""Return the list of available operations."""
return self._operation_list
return list(HA_STATE_TO_EVO)
@property
def current_temperature(self) -> float:
"""Return the current temperature."""
return self._evo_device.temperatureStatus["temperature"]
def is_away_mode_on(self):
"""Return True if away mode is on."""
is_off = EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] == STATE_OFF
is_permanent = self._evo_device.stateStatus["mode"] == EVO_PERMOVER
return is_off and is_permanent
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new operation mode for a DHW controller."""
op_mode = HA_OPMODE_TO_DHW[operation_mode]
"""Set new operation mode for a DHW controller.
state = "" if op_mode == EVO_FOLLOW else HA_STATE_TO_EVO[STATE_OFF]
until = None # EVO_FOLLOW, EVO_PERMOVER
if op_mode == EVO_TEMPOVER and self._schedule["DailySchedules"]:
Except for Auto, the mode is only until the next SetPoint.
"""
if operation_mode == STATE_AUTO:
await self._call_client_api(self._evo_device.set_dhw_auto())
else:
await self._update_schedule()
if self._schedule["DailySchedules"]:
until = parse_datetime(self.setpoints["next"]["from"])
until = until.strftime(EVO_STRFTIME)
until = parse_datetime(str(self.setpoints.get("next_sp_from")))
data = {"Mode": op_mode, "State": state, "UntilTime": until}
if operation_mode == STATE_ON:
await self._call_client_api(self._evo_device.set_dhw_on(until))
else: # STATE_OFF
await self._call_client_api(self._evo_device.set_dhw_off(until))
await self._call_client_api(
self._evo_device._set_dhw(data) # pylint: disable=protected-access
)
async def async_turn_away_mode_on(self):
"""Turn away mode on."""
await self._call_client_api(self._evo_device.set_dhw_off())
async def async_turn_away_mode_off(self):
"""Turn away mode off."""
await self._call_client_api(self._evo_device.set_dhw_auto())
async def async_update(self) -> None:
"""Get the latest state data for a DHW controller."""
await super().async_update()
for attr in STATE_ATTRS_DHW:
self._device_state_attrs[attr] = getattr(self._evo_device, attr)