[climate-1.0] Add RoundThermostat to evohome ()

* initial commit

* improve enumeration of zone(s)

* remove unused self._config

* remove unused self._config 2

* remove unused self._id

* clean up device_state_attributes

* remove some pylint: disable=protected-access

* remove LOGGER.warn(

* refactor for RoundThermostat

* ready for review

* small tweak

* small tweak 2

* fix regression, tweak

* tidy up docstring

* simplify code
This commit is contained in:
David Bonnes 2019-07-15 04:14:24 +01:00 committed by Paulus Schoutsen
parent bcf85a0df1
commit 3ddd482cc1
3 changed files with 162 additions and 110 deletions
homeassistant/components/evohome

View file

@ -143,7 +143,7 @@ class EvoBroker:
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
self._load_auth_tokens(), self.hass.loop).result() self._load_auth_tokens(), self.hass.loop).result()
# evohomeclient2 uses local datetimes # evohomeclient2 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 = _utc_to_local_dt(access_token_expires)
@ -212,7 +212,7 @@ class EvoBroker:
return (None, None, None) # account switched: so tokens wont be valid return (None, None, None) # account switched: so tokens wont be valid
async def _save_auth_tokens(self, *args) -> None: async def _save_auth_tokens(self, *args) -> None:
# evohomeclient2 uses local datetimes # evohomeclient2 uses naive/local datetimes
access_token_expires = _local_dt_to_utc( access_token_expires = _local_dt_to_utc(
self.client.access_token_expires) self.client.access_token_expires)

View file

@ -1,7 +1,7 @@
"""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 from datetime import datetime
import logging import logging
from typing import Optional, List from typing import Any, Dict, Optional, List
import requests.exceptions import requests.exceptions
import evohomeclient2 import evohomeclient2
@ -11,6 +11,7 @@ from homeassistant.components.climate.const import (
HVAC_MODE_HEAT, HVAC_MODE_AUTO, HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_AUTO, HVAC_MODE_OFF,
PRESET_AWAY, PRESET_ECO, PRESET_HOME, PRESET_AWAY, PRESET_ECO, PRESET_HOME,
SUPPORT_TARGET_TEMPERATURE, SUPPORT_PRESET_MODE) SUPPORT_TARGET_TEMPERATURE, SUPPORT_PRESET_MODE)
from homeassistant.const import PRECISION_TENTHS
from homeassistant.util.dt import parse_datetime from homeassistant.util.dt import parse_datetime
from . import CONF_LOCATION_IDX, _handle_exception, EvoDevice from . import CONF_LOCATION_IDX, _handle_exception, EvoDevice
@ -27,6 +28,7 @@ HA_HVAC_TO_TCS = {
HVAC_MODE_OFF: EVO_HEATOFF, HVAC_MODE_OFF: EVO_HEATOFF,
HVAC_MODE_HEAT: EVO_AUTO, HVAC_MODE_HEAT: EVO_AUTO,
} }
HA_PRESET_TO_TCS = { HA_PRESET_TO_TCS = {
PRESET_AWAY: EVO_AWAY, PRESET_AWAY: EVO_AWAY,
PRESET_CUSTOM: EVO_CUSTOM, PRESET_CUSTOM: EVO_CUSTOM,
@ -36,11 +38,13 @@ HA_PRESET_TO_TCS = {
} }
TCS_PRESET_TO_HA = {v: k for k, v in HA_PRESET_TO_TCS.items()} TCS_PRESET_TO_HA = {v: k for k, v in HA_PRESET_TO_TCS.items()}
HA_PRESET_TO_EVO = { EVO_PRESET_TO_HA = {
'temporary': EVO_TEMPOVER, EVO_FOLLOW: None,
'permanent': EVO_PERMOVER, EVO_TEMPOVER: 'temporary',
EVO_PERMOVER: 'permanent',
} }
EVO_PRESET_TO_HA = {v: k for k, v in HA_PRESET_TO_EVO.items()} HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()
if v is not None}
def setup_platform(hass, hass_config, add_entities, def setup_platform(hass, hass_config, add_entities,
@ -50,24 +54,30 @@ def setup_platform(hass, hass_config, add_entities,
loc_idx = broker.params[CONF_LOCATION_IDX] loc_idx = broker.params[CONF_LOCATION_IDX]
_LOGGER.debug( _LOGGER.debug(
"Found Controller, id=%s [%s], name=%s (location_idx=%s)", "Found Location/Controller, id=%s [%s], name=%s (location_idx=%s)",
broker.tcs.systemId, broker.tcs.modelType, broker.tcs.location.name, broker.tcs.systemId, broker.tcs.modelType, broker.tcs.location.name,
loc_idx) loc_idx)
# special case of RoundThermostat (is single zone)
if broker.config['zones'][0]['modelType'] == 'RoundModulation':
zone = list(broker.tcs.zones.values())[0]
_LOGGER.debug(
"Found %s, id=%s [%s], name=%s",
zone.zoneType, zone.zoneId, zone.modelType, zone.name)
add_entities([EvoThermostat(broker, zone)], update_before_add=True)
return
controller = EvoController(broker, broker.tcs) controller = EvoController(broker, broker.tcs)
zones = [] zones = []
for zone_idx in broker.tcs.zones: for zone in broker.tcs.zones.values():
evo_zone = broker.tcs.zones[zone_idx]
_LOGGER.debug( _LOGGER.debug(
"Found %s, id=%s [%s], name=%s", "Found %s, id=%s [%s], name=%s",
evo_zone.zoneType, evo_zone.zoneId, evo_zone.modelType, zone.zoneType, zone.zoneId, zone.modelType, zone.name)
evo_zone.name) zones.append(EvoZone(broker, zone))
zones.append(EvoZone(broker, evo_zone))
entities = [controller] + zones add_entities([controller] + zones, update_before_add=True)
add_entities(entities, update_before_add=True)
class EvoClimateDevice(EvoDevice, ClimateDevice): class EvoClimateDevice(EvoDevice, ClimateDevice):
@ -77,12 +87,67 @@ class EvoClimateDevice(EvoDevice, ClimateDevice):
"""Initialize the evohome Climate device.""" """Initialize the evohome Climate device."""
super().__init__(evo_broker, evo_device) super().__init__(evo_broker, evo_device)
self._hvac_modes = self._preset_modes = None self._preset_modes = None
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)
"""
try:
self._evo_device.set_temperature(temperature, until)
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
_handle_exception(err)
def _set_zone_mode(self, op_mode: str) -> None:
"""Set the Zone to one of its native EVO_* operating modes.
NB: evohome Zones 'inherit' their operating mode from the Controller.
Usually, Zones are in 'FollowSchedule' mode, where their setpoints are
a function of their schedule, and the Controller's operating_mode, e.g.
Economy mode is their scheduled setpoint less (usually) 3C.
However, Zones can override these setpoints, either for a specified
period of time, 'TemporaryOverride', after which they will revert back
to 'FollowSchedule' mode, or indefinitely, 'PermanentOverride'.
Some of the Controller's operating_mode are 'forced' upon the Zone,
regardless of its override state, e.g. 'HeatingOff' (Zones to min_temp)
and 'Away' (Zones to 12C).
"""
if op_mode == EVO_FOLLOW:
try:
self._evo_device.cancel_temp_override()
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
_handle_exception(err)
return
temperature = self._evo_device.setpointStatus['targetHeatTemperature']
until = None # EVO_PERMOVER
if op_mode == EVO_TEMPOVER:
self._setpoints = self.get_setpoints()
if self._setpoints:
until = parse_datetime(self._setpoints['next']['from'])
self._set_temperature(temperature, until=until)
def _set_tcs_mode(self, op_mode: str) -> None:
"""Set the Controller to any of its native EVO_* operating modes."""
try:
self._evo_tcs._set_status(op_mode) # noqa: E501; pylint: disable=protected-access
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
_handle_exception(err)
@property @property
def hvac_modes(self) -> List[str]: def hvac_modes(self) -> List[str]:
"""Return the list of available hvac operation modes.""" """Return the list of available hvac operation modes."""
return self._hvac_modes return [HVAC_MODE_OFF, HVAC_MODE_HEAT]
@property @property
def preset_modes(self) -> Optional[List[str]]: def preset_modes(self) -> Optional[List[str]]:
@ -97,39 +162,22 @@ class EvoZone(EvoClimateDevice):
"""Initialize the evohome Zone.""" """Initialize the evohome Zone."""
super().__init__(evo_broker, evo_device) super().__init__(evo_broker, evo_device)
self._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._precision = \
self._evo_device.setpointCapabilities['valueResolution'] self._evo_device.setpointCapabilities['valueResolution']
self._state_attributes = [ self._state_attributes = [
'activeFaults', 'setpointStatus', 'temperatureStatus', 'setpoints'] 'zoneId', 'activeFaults', 'setpointStatus', 'temperatureStatus',
'setpoints']
self._supported_features = SUPPORT_PRESET_MODE | \ self._supported_features = SUPPORT_PRESET_MODE | \
SUPPORT_TARGET_TEMPERATURE SUPPORT_TARGET_TEMPERATURE
self._hvac_modes = [HVAC_MODE_OFF, HVAC_MODE_HEAT]
self._preset_modes = list(HA_PRESET_TO_EVO) self._preset_modes = list(HA_PRESET_TO_EVO)
for _zone in evo_broker.config['zones']:
if _zone['zoneId'] == self._id:
self._config = _zone
break
@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 the evohome Zone."""
NB: evohome Zones 'inherit' their operating mode from the controller.
Usually, Zones are in 'FollowSchedule' mode, where their setpoints are
a function of their schedule, and the Controller's operating_mode, e.g.
Economy mode is their scheduled setpoint less (usually) 3C.
However, Zones can override these setpoints, either for a specified
period of time, 'TemporaryOverride', after which they will revert back
to 'FollowSchedule' mode, or indefinitely, 'PermanentOverride'.
"""
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
@ -152,7 +200,7 @@ class EvoZone(EvoClimateDevice):
def preset_mode(self) -> Optional[str]: def preset_mode(self) -> Optional[str]:
"""Return the current preset mode, e.g., home, away, temp.""" """Return the current preset mode, e.g., home, away, temp."""
if self._evo_tcs.systemModeStatus['mode'] in [EVO_AWAY, EVO_HEATOFF]: if self._evo_tcs.systemModeStatus['mode'] in [EVO_AWAY, EVO_HEATOFF]:
return None return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus['mode'])
return EVO_PRESET_TO_HA.get( return EVO_PRESET_TO_HA.get(
self._evo_device.setpointStatus['setpointMode'], 'follow') self._evo_device.setpointStatus['setpointMode'], 'follow')
@ -172,18 +220,6 @@ class EvoZone(EvoClimateDevice):
""" """
return self._evo_device.setpointCapabilities['maxHeatSetpoint'] return self._evo_device.setpointCapabilities['maxHeatSetpoint']
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)
"""
try:
self._evo_device.set_temperature(temperature, until)
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
_handle_exception(err)
def set_temperature(self, **kwargs) -> None: def set_temperature(self, **kwargs) -> None:
"""Set a new target temperature for an hour.""" """Set a new target temperature for an hour."""
until = kwargs.get('until') until = kwargs.get('until')
@ -192,40 +228,20 @@ class EvoZone(EvoClimateDevice):
self._set_temperature(kwargs['temperature'], until) self._set_temperature(kwargs['temperature'], until)
def _set_operation_mode(self, op_mode: str) -> None:
"""Set the Zone to one of its native EVO_* operating modes."""
if op_mode == EVO_FOLLOW:
try:
self._evo_device.cancel_temp_override()
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
_handle_exception(err)
return
temperature = self._evo_device.setpointStatus['targetHeatTemperature']
until = None # EVO_PERMOVER
if op_mode == EVO_TEMPOVER:
self._setpoints = self.get_setpoints()
if self._setpoints:
until = parse_datetime(self._setpoints['next']['from'])
self._set_temperature(temperature, until=until)
def set_hvac_mode(self, hvac_mode: str) -> None: def set_hvac_mode(self, hvac_mode: str) -> None:
"""Set an operating mode for the Zone.""" """Set an operating mode for the Zone."""
if hvac_mode == HVAC_MODE_OFF: if hvac_mode == HVAC_MODE_OFF:
self._set_temperature(self.min_temp, until=None) self._set_temperature(self.min_temp, until=None)
else: # HVAC_MODE_HEAT else: # HVAC_MODE_HEAT
self._set_operation_mode(EVO_FOLLOW) self._set_zone_mode(EVO_FOLLOW)
def set_preset_mode(self, preset_mode: Optional[str]) -> None: def set_preset_mode(self, preset_mode: Optional[str]) -> None:
"""Set a new preset mode. """Set a new preset mode.
If preset_mode is None, then revert to following the schedule. If preset_mode is None, then revert to following the schedule.
""" """
self._set_operation_mode(HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW)) self._set_zone_mode(HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW))
class EvoController(EvoClimateDevice): class EvoController(EvoClimateDevice):
@ -239,27 +255,15 @@ class EvoController(EvoClimateDevice):
"""Initialize the evohome Controller (hub).""" """Initialize the evohome Controller (hub)."""
super().__init__(evo_broker, evo_device) super().__init__(evo_broker, evo_device)
self._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 = None self._precision = PRECISION_TENTHS
self._state_attributes = ['activeFaults', 'systemModeStatus'] self._state_attributes = [
'systemId', 'activeFaults', 'systemModeStatus']
self._supported_features = SUPPORT_PRESET_MODE self._supported_features = SUPPORT_PRESET_MODE
self._hvac_modes = list(HA_HVAC_TO_TCS) self._preset_modes = list(HA_PRESET_TO_TCS)
self._config = dict(evo_broker.config)
# special case of RoundThermostat
if self._config['zones'][0]['modelType'] == 'RoundModulation':
self._preset_modes = [PRESET_AWAY, PRESET_ECO]
else:
self._preset_modes = list(HA_PRESET_TO_TCS)
self._config['zones'] = '...'
if 'dhw' in self._config:
self._config['dhw'] = '...'
@property @property
def hvac_mode(self) -> str: def hvac_mode(self) -> str:
@ -273,8 +277,9 @@ class EvoController(EvoClimateDevice):
Controllers do not have a current temp, but one is expected by HA. Controllers do not have a current temp, but one is expected by HA.
""" """
temps = [z.temperatureStatus['temperature'] for z in temps = [z.temperatureStatus['temperature']
self._evo_device._zones if z.temperatureStatus['isAvailable']] # noqa: E501; pylint: disable=protected-access for z in self._evo_device.zones.values()
if z.temperatureStatus['isAvailable']]
return round(sum(temps) / len(temps), 1) if temps else None return round(sum(temps) / len(temps), 1) if temps else None
@property @property
@ -284,7 +289,7 @@ class EvoController(EvoClimateDevice):
Controllers do not have a target temp, but one is expected by HA. Controllers do not have a target temp, but one is expected by HA.
""" """
temps = [z.setpointStatus['targetHeatTemperature'] temps = [z.setpointStatus['targetHeatTemperature']
for z in self._evo_device._zones] # noqa: E501; pylint: disable=protected-access for z in self._evo_device.zones.values()]
return round(sum(temps) / len(temps), 1) if temps else None return round(sum(temps) / len(temps), 1) if temps else None
@property @property
@ -299,7 +304,7 @@ class EvoController(EvoClimateDevice):
Controllers do not have a min target temp, but one is required by HA. Controllers do not have a min target temp, but one is required by HA.
""" """
temps = [z.setpointCapabilities['minHeatSetpoint'] temps = [z.setpointCapabilities['minHeatSetpoint']
for z in self._evo_device._zones] # noqa: E501; pylint: disable=protected-access for z in self._evo_device.zones.values()]
return min(temps) if temps else 5 return min(temps) if temps else 5
@property @property
@ -309,28 +314,77 @@ class EvoController(EvoClimateDevice):
Controllers do not have a max target temp, but one is required by HA. Controllers do not have a max target temp, but one is required by HA.
""" """
temps = [z.setpointCapabilities['maxHeatSetpoint'] temps = [z.setpointCapabilities['maxHeatSetpoint']
for z in self._evo_device._zones] # noqa: E501; pylint: disable=protected-access for z in self._evo_device.zones.values()]
return max(temps) if temps else 35 return max(temps) if temps else 35
def _set_operation_mode(self, op_mode: str) -> None:
"""Set the Controller to any of its native EVO_* operating modes."""
try:
self._evo_device._set_status(op_mode) # noqa: E501; pylint: disable=protected-access
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
_handle_exception(err)
def set_hvac_mode(self, hvac_mode: str) -> None: def set_hvac_mode(self, hvac_mode: str) -> None:
"""Set an operating mode for the Controller.""" """Set an operating mode for the Controller."""
self._set_operation_mode(HA_HVAC_TO_TCS.get(hvac_mode)) self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode))
def set_preset_mode(self, preset_mode: Optional[str]) -> None: def set_preset_mode(self, preset_mode: Optional[str]) -> None:
"""Set a new preset mode. """Set a new preset mode.
If preset_mode is None, then revert to 'Auto' mode. If preset_mode is None, then revert to 'Auto' mode.
""" """
self._set_operation_mode(HA_PRESET_TO_TCS.get(preset_mode, EVO_AUTO)) self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EVO_AUTO))
def update(self) -> None: def update(self) -> None:
"""Get the latest state data.""" """Get the latest state data."""
pass pass
class EvoThermostat(EvoZone):
"""Base for a Honeywell Round Thermostat.
Implemented as a combined Controller/Zone.
"""
def __init__(self, evo_broker, evo_device) -> None:
"""Initialize the Round Thermostat."""
super().__init__(evo_broker, evo_device)
self._name = evo_broker.tcs.location.name
self._icon = 'mdi:radiator'
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'] = getattr(self._evo_tcs, 'systemModeStatus')
status['activeFaults'] += getattr(self._evo_tcs, 'activeFaults')
return {'status': status}
@property
def hvac_mode(self) -> str:
"""Return the current operating mode."""
if self._evo_tcs.systemModeStatus['mode'] == EVO_HEATOFF:
return HVAC_MODE_OFF
return super().hvac_mode
@property
def preset_mode(self) -> Optional[str]:
"""Return the current preset mode, e.g., home, away, temp."""
if self._evo_tcs.systemModeStatus['mode'] == EVO_AUTOECO:
if self._evo_device.setpointStatus['setpointMode'] == EVO_FOLLOW:
return PRESET_ECO
return super().preset_mode
def set_hvac_mode(self, hvac_mode: str) -> None:
"""Set an operating mode."""
self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode))
def 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.
"""
if preset_mode in list(HA_PRESET_TO_TCS):
self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode))
else:
self._set_zone_mode(HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW))

View file

@ -27,8 +27,8 @@ def setup_platform(hass, hass_config, add_entities,
broker = hass.data[DOMAIN]['broker'] broker = hass.data[DOMAIN]['broker']
_LOGGER.debug( _LOGGER.debug(
"Found DHW device, id: %s [%s]", "Found %s, id: %s",
broker.tcs.hotwater.zoneId, broker.tcs.hotwater.zone_type) broker.tcs.hotwater.zone_type, broker.tcs.hotwater.zoneId)
evo_dhw = EvoDHW(broker, broker.tcs.hotwater) evo_dhw = EvoDHW(broker, broker.tcs.hotwater)
@ -42,19 +42,17 @@ class EvoDHW(EvoDevice, WaterHeaterDevice):
"""Initialize the evohome DHW controller.""" """Initialize the evohome DHW controller."""
super().__init__(evo_broker, evo_device) super().__init__(evo_broker, evo_device)
self._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._state_attributes = [
'activeFaults', 'stateStatus', 'temperatureStatus', 'setpoints'] 'dhwId', 'activeFaults', 'stateStatus', 'temperatureStatus',
'setpoints']
self._supported_features = SUPPORT_OPERATION_MODE self._supported_features = SUPPORT_OPERATION_MODE
self._operation_list = list(HA_OPMODE_TO_DHW) self._operation_list = list(HA_OPMODE_TO_DHW)
self._config = evo_broker.config['dhw']
@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 (On, or Off)."""