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 from datetime import datetime, timedelta
import logging import logging
import re
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
import aiohttp.client_exceptions 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.discovery import async_load_platform
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType, HomeAssistantType 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__) _LOGGER = logging.getLogger(__name__)
@ -55,20 +56,45 @@ CONFIG_SCHEMA = vol.Schema(
) )
def _local_dt_to_utc(dt_naive: datetime) -> datetime: def _local_dt_to_aware(dt_naive: datetime) -> datetime:
dt_aware = utcnow() + (dt_naive - datetime.now()) dt_aware = dt_util.now() + (dt_naive - datetime.now())
if dt_aware.microsecond >= 500000: if dt_aware.microsecond >= 500000:
dt_aware += timedelta(seconds=1) dt_aware += timedelta(seconds=1)
return dt_aware.replace(microsecond=0) return dt_aware.replace(microsecond=0)
def _utc_to_local_dt(dt_aware: datetime) -> datetime: def _dt_to_local_naive(dt_aware: datetime) -> datetime:
dt_naive = datetime.now() + (dt_aware - utcnow()) dt_naive = datetime.now() + (dt_aware - dt_util.now())
if dt_naive.microsecond >= 500000: if dt_naive.microsecond >= 500000:
dt_naive += timedelta(seconds=1) dt_naive += timedelta(seconds=1)
return dt_naive.replace(microsecond=0) 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: def _handle_exception(err) -> bool:
try: try:
raise err raise err
@ -135,7 +161,7 @@ class EvoBroker:
"""Container for evohome client and data.""" """Container for evohome client and data."""
def __init__(self, hass, params) -> None: 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.hass = hass
self.params = params self.params = params
self.config = {} self.config = {}
@ -157,7 +183,7 @@ class EvoBroker:
# evohomeasync2 uses naive/local datetimes # evohomeasync2 uses naive/local datetimes
if access_token_expires is not None: 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( client = self.client = evohomeasync2.EvohomeClient(
self.params[CONF_USERNAME], self.params[CONF_USERNAME],
@ -220,7 +246,7 @@ class EvoBroker:
access_token = app_storage.get(CONF_ACCESS_TOKEN) access_token = app_storage.get(CONF_ACCESS_TOKEN)
at_expires_str = app_storage.get(CONF_ACCESS_TOKEN_EXPIRES) at_expires_str = app_storage.get(CONF_ACCESS_TOKEN_EXPIRES)
if at_expires_str: if at_expires_str:
at_expires_dt = parse_datetime(at_expires_str) at_expires_dt = dt_util.parse_datetime(at_expires_str)
else: else:
at_expires_dt = None at_expires_dt = None
@ -230,7 +256,7 @@ class EvoBroker:
async def _save_auth_tokens(self, *args) -> None: async def _save_auth_tokens(self, *args) -> None:
# evohomeasync2 uses naive/local datetimes # 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_USERNAME] = self.params[CONF_USERNAME]
self._app_storage[CONF_REFRESH_TOKEN] = self.client.refresh_token self._app_storage[CONF_REFRESH_TOKEN] = self.client.refresh_token
@ -246,11 +272,11 @@ class EvoBroker:
) )
async def update(self, *args, **kwargs) -> None: 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, This includes state data for a Controller and all its child devices, such as the
such as the operating mode of the Controller and the current temp of operating mode of the Controller and the current temp of its children (e.g.
its children (e.g. Zones, DHW controller). Zones, DHW controller).
""" """
loc_idx = self.params[CONF_LOCATION_IDX] loc_idx = self.params[CONF_LOCATION_IDX]
@ -260,9 +286,7 @@ class EvoBroker:
_handle_exception(err) _handle_exception(err)
else: else:
# inform the evohome devices that state data has been updated # inform the evohome devices that state data has been updated
self.hass.helpers.dispatcher.async_dispatcher_send( self.hass.helpers.dispatcher.async_dispatcher_send(DOMAIN)
DOMAIN, {"signal": "refresh"}
)
_LOGGER.debug("Status = %s", status[GWS][0][TCS][0]) _LOGGER.debug("Status = %s", status[GWS][0][TCS][0])
@ -270,8 +294,8 @@ class EvoBroker:
class EvoDevice(Entity): class EvoDevice(Entity):
"""Base for any evohome device. """Base for any evohome device.
This includes the Controller, (up to 12) Heating Zones and This includes the Controller, (up to 12) Heating Zones and (optionally) a
(optionally) a DHW controller. DHW controller.
""" """
def __init__(self, evo_broker, evo_device) -> None: def __init__(self, evo_broker, evo_device) -> None:
@ -280,72 +304,26 @@ class EvoDevice(Entity):
self._evo_broker = evo_broker self._evo_broker = evo_broker
self._evo_tcs = evo_broker.tcs self._evo_tcs = evo_broker.tcs
self._name = self._icon = self._precision = None self._unique_id = self._name = self._icon = self._precision = None
self._state_attributes = []
self._device_state_attrs = {}
self._state_attributes = []
self._supported_features = None self._supported_features = None
self._schedule = {}
@callback @callback
def _refresh(self, packet): def _refresh(self) -> None:
if packet["signal"] == "refresh":
self.async_schedule_update_ha_state(force_refresh=True) 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
@property @property
def should_poll(self) -> bool: def should_poll(self) -> bool:
"""Evohome entities should not be polled.""" """Evohome entities should not be polled."""
return False return False
@property
def unique_id(self) -> Optional[str]:
"""Return a unique ID."""
return self._unique_id
@property @property
def name(self) -> str: def name(self) -> str:
"""Return the name of the Evohome entity.""" """Return the name of the Evohome entity."""
@ -354,15 +332,15 @@ class EvoDevice(Entity):
@property @property
def device_state_attributes(self) -> Dict[str, Any]: def device_state_attributes(self) -> Dict[str, Any]:
"""Return the Evohome-specific state attributes.""" """Return the Evohome-specific state attributes."""
status = {} status = self._device_state_attrs
for attr in self._state_attributes: if "systemModeStatus" in status:
if attr != "setpoints": convert_until(status["systemModeStatus"], "timeUntil")
status[attr] = getattr(self._evo_device, attr) if "setpointStatus" in status:
convert_until(status["setpointStatus"], "until")
if "stateStatus" in status:
convert_until(status["stateStatus"], "until")
if "setpoints" in self._state_attributes: return {"status": convert_dict(status)}
status["setpoints"] = self.setpoints
return {"status": status}
@property @property
def icon(self) -> str: def icon(self) -> str:
@ -388,27 +366,98 @@ class EvoDevice(Entity):
"""Return the temperature unit to use in the frontend UI.""" """Return the temperature unit to use in the frontend UI."""
return TEMP_CELSIUS return TEMP_CELSIUS
async def _call_client_api(self, api_function) -> None: async def _call_client_api(self, api_function, refresh=True) -> Any:
try: try:
await api_function result = await api_function
except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err:
_handle_exception(err) if not _handle_exception(err):
return
self.hass.helpers.event.async_call_later( if refresh is True:
2, self._evo_broker.update() self.hass.helpers.event.async_call_later(1, self._evo_broker.update())
) # call update() in 2 seconds
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: async def _update_schedule(self) -> None:
"""Get the latest state data.""" """Get the latest schedule."""
if ( if "DailySchedules" in self._schedule and not self._schedule["DailySchedules"]:
not self._schedule.get("DailySchedules") if not self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW:
or parse_datetime(self.setpoints["next"]["from"]) < utcnow() return # avoid unnecessary I/O - there's nothing to update
):
try: self._schedule = await self._call_client_api(
self._schedule = await self._evo_device.schedule() self._evo_device.schedule(), refresh=False
except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: )
_handle_exception(err)
async def async_update(self) -> None: async def async_update(self) -> None:
"""Get the latest state data.""" """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.""" """Support for Climate devices of (EMEA/EU-based) Honeywell TCC systems."""
from datetime import datetime
import logging import logging
from typing import Any, Dict, Optional, List from typing import Optional, List
from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import ( 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.helpers.typing import ConfigType, HomeAssistantType
from homeassistant.util.dt import parse_datetime from homeassistant.util.dt import parse_datetime
from . import CONF_LOCATION_IDX, EvoDevice from . import CONF_LOCATION_IDX, EvoDevice, EvoChild
from .const import ( from .const import (
DOMAIN, DOMAIN,
EVO_RESET, 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()} 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( async def async_setup_platform(
hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None 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.""" """Base for a Honeywell evohome Climate device."""
def __init__(self, evo_broker, evo_device) -> None: def __init__(self, evo_broker, evo_device) -> None:
"""Initialize the evohome Climate device.""" """Initialize a Climate device."""
super().__init__(evo_broker, evo_device) super().__init__(evo_broker, evo_device)
self._preset_modes = None 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: 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( await self._call_client_api(
self._evo_tcs._set_status(op_mode) # pylint: disable=protected-access self._evo_tcs._set_status(op_mode) # pylint: disable=protected-access
) )
@property @property
def hvac_modes(self) -> List[str]: 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) return list(HA_HVAC_TO_TCS)
@property @property
@ -179,36 +138,24 @@ class EvoClimateDevice(EvoDevice, ClimateDevice):
return self._preset_modes return self._preset_modes
class EvoZone(EvoClimateDevice): class EvoZone(EvoChild, EvoClimateDevice):
"""Base for a Honeywell evohome Zone.""" """Base for a Honeywell evohome Zone."""
def __init__(self, evo_broker, evo_device) -> None: def __init__(self, evo_broker, evo_device) -> None:
"""Initialize the evohome Zone.""" """Initialize a Zone."""
super().__init__(evo_broker, evo_device) super().__init__(evo_broker, evo_device)
self._unique_id = evo_device.zoneId
self._name = evo_device.name self._name = evo_device.name
self._icon = "mdi:radiator" self._icon = "mdi:radiator"
self._precision = self._evo_device.setpointCapabilities["valueResolution"] 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._supported_features = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE
self._preset_modes = list(HA_PRESET_TO_EVO) 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 @property
def hvac_mode(self) -> str: 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]: if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]:
return HVAC_MODE_AUTO return HVAC_MODE_AUTO
is_off = self.target_temperature <= self.min_temp is_off = self.target_temperature <= self.min_temp
@ -221,24 +168,15 @@ class EvoZone(EvoClimateDevice):
return CURRENT_HVAC_OFF return CURRENT_HVAC_OFF
if self.target_temperature <= self.min_temp: if self.target_temperature <= self.min_temp:
return CURRENT_HVAC_OFF 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_IDLE
return CURRENT_HVAC_HEAT 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 @property
def target_temperature(self) -> float: def target_temperature(self) -> float:
"""Return the target temperature of the evohome Zone.""" """Return the target temperature of a Zone."""
if self._evo_tcs.systemModeStatus["mode"] == EVO_HEATOFF:
return self._evo_device.setpointCapabilities["minHeatSetpoint"]
return self._evo_device.setpointStatus["targetHeatTemperature"] return self._evo_device.setpointStatus["targetHeatTemperature"]
@property @property
@ -252,7 +190,7 @@ class EvoZone(EvoClimateDevice):
@property @property
def min_temp(self) -> float: 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). The default is 5, but is user-configurable within 5-35 (in Celsius).
""" """
@ -260,7 +198,7 @@ class EvoZone(EvoClimateDevice):
@property @property
def max_temp(self) -> float: 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). 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: async def async_set_temperature(self, **kwargs) -> None:
"""Set a new target temperature.""" """Set a new target temperature."""
until = kwargs.get("until") temperature = kwargs["temperature"]
if until:
until = parse_datetime(until)
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: async def async_set_hvac_mode(self, hvac_mode: str) -> None:
"""Set an operating mode for the Zone.""" """Set a Zone to one of its native EVO_* operating modes.
if hvac_mode == HVAC_MODE_OFF:
await self._set_temperature(self.min_temp, until=None)
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 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: 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. if evo_preset_mode == EVO_FOLLOW:
""" await self._call_client_api(self._evo_device.cancel_temp_override())
await self._set_zone_mode(HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW)) 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): class EvoController(EvoClimateDevice):
@ -298,21 +280,20 @@ class EvoController(EvoClimateDevice):
""" """
def __init__(self, evo_broker, evo_device) -> None: def __init__(self, evo_broker, evo_device) -> None:
"""Initialize the evohome Controller (hub).""" """Initialize a evohome Controller (hub)."""
super().__init__(evo_broker, evo_device) super().__init__(evo_broker, evo_device)
self._unique_id = evo_device.systemId
self._name = evo_device.location.name self._name = evo_device.location.name
self._icon = "mdi:thermostat" self._icon = "mdi:thermostat"
self._precision = PRECISION_TENTHS self._precision = PRECISION_TENTHS
self._state_attributes = ["systemId", "activeFaults", "systemModeStatus"]
self._supported_features = SUPPORT_PRESET_MODE self._supported_features = SUPPORT_PRESET_MODE
self._preset_modes = list(HA_PRESET_TO_TCS) self._preset_modes = list(HA_PRESET_TO_TCS)
@property @property
def hvac_mode(self) -> str: 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"] tcs_mode = self._evo_tcs.systemModeStatus["mode"]
return HVAC_MODE_OFF if tcs_mode == EVO_HEATOFF else HVAC_MODE_HEAT 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 the current preset mode, e.g., home, away, temp."""
return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"])
async def async_set_temperature(self, **kwargs) -> None: @property
"""Do nothing. 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. @property
""" def max_temp(self) -> float:
return """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: 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)) await self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode))
async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: 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 'Auto' mode."""
If preset_mode is None, then revert to 'Auto' mode.
"""
await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EVO_AUTO)) await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EVO_AUTO))
async def async_update(self) -> None: async def async_update(self) -> None:
"""Get the latest state data.""" """Get the latest state data for a Controller."""
return 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): class EvoThermostat(EvoZone):
"""Base for a Honeywell Round Thermostat. """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: def __init__(self, evo_broker, evo_device) -> None:
"""Initialize the Round Thermostat.""" """Initialize the Thermostat."""
super().__init__(evo_broker, evo_device) super().__init__(evo_broker, evo_device)
self._name = evo_broker.tcs.location.name self._name = evo_broker.tcs.location.name
self._preset_modes = [PRESET_AWAY, PRESET_ECO] 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 @property
def hvac_mode(self) -> str: def hvac_mode(self) -> str:
"""Return the current operating mode.""" """Return the current operating mode."""
@ -404,11 +386,19 @@ class EvoThermostat(EvoZone):
await self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode)) await self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode))
async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: 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."""
If preset_mode is None, then revert to following the schedule.
"""
if preset_mode in list(HA_PRESET_TO_TCS): if preset_mode in list(HA_PRESET_TO_TCS):
await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode)) await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode))
else: 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 # These are used only to help prevent E501 (line too long) violations
GWS = "gateways" GWS = "gateways"
TCS = "temperatureControlSystems" TCS = "temperatureControlSystems"
EVO_STRFTIME = "%Y-%m-%dT%H:%M:%SZ"

View File

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