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:
parent
e2d7a01d65
commit
a1997ee891
@ -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}
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user