From 3ddd482cc1799e8438e955cb1de8c5f5bee09d26 Mon Sep 17 00:00:00 2001
From: David Bonnes <david@bonnes.me>
Date: Mon, 15 Jul 2019 04:14:24 +0100
Subject: [PATCH] [climate-1.0] Add RoundThermostat to evohome (#25141)

* 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
---
 homeassistant/components/evohome/__init__.py  |   4 +-
 homeassistant/components/evohome/climate.py   | 258 +++++++++++-------
 .../components/evohome/water_heater.py        |  10 +-
 3 files changed, 162 insertions(+), 110 deletions(-)

diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py
index d7892be6949..49ddbdde156 100644
--- a/homeassistant/components/evohome/__init__.py
+++ b/homeassistant/components/evohome/__init__.py
@@ -143,7 +143,7 @@ class EvoBroker:
             asyncio.run_coroutine_threadsafe(
                 self._load_auth_tokens(), self.hass.loop).result()
 
-        # evohomeclient2 uses local datetimes
+        # evohomeclient2 uses naive/local datetimes
         if access_token_expires is not None:
             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
 
     async def _save_auth_tokens(self, *args) -> None:
-        # evohomeclient2 uses local datetimes
+        # evohomeclient2 uses naive/local datetimes
         access_token_expires = _local_dt_to_utc(
             self.client.access_token_expires)
 
diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py
index c9391f16045..e31a71b19f2 100644
--- a/homeassistant/components/evohome/climate.py
+++ b/homeassistant/components/evohome/climate.py
@@ -1,7 +1,7 @@
 """Support for Climate devices of (EMEA/EU-based) Honeywell TCC systems."""
 from datetime import datetime
 import logging
-from typing import Optional, List
+from typing import Any, Dict, Optional, List
 
 import requests.exceptions
 import evohomeclient2
@@ -11,6 +11,7 @@ from homeassistant.components.climate.const import (
     HVAC_MODE_HEAT, HVAC_MODE_AUTO, HVAC_MODE_OFF,
     PRESET_AWAY, PRESET_ECO, PRESET_HOME,
     SUPPORT_TARGET_TEMPERATURE, SUPPORT_PRESET_MODE)
+from homeassistant.const import PRECISION_TENTHS
 from homeassistant.util.dt import parse_datetime
 
 from . import CONF_LOCATION_IDX, _handle_exception, EvoDevice
@@ -27,6 +28,7 @@ HA_HVAC_TO_TCS = {
     HVAC_MODE_OFF: EVO_HEATOFF,
     HVAC_MODE_HEAT: EVO_AUTO,
 }
+
 HA_PRESET_TO_TCS = {
     PRESET_AWAY: EVO_AWAY,
     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()}
 
-HA_PRESET_TO_EVO = {
-    'temporary': EVO_TEMPOVER,
-    'permanent': EVO_PERMOVER,
+EVO_PRESET_TO_HA = {
+    EVO_FOLLOW: None,
+    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,
@@ -50,24 +54,30 @@ def setup_platform(hass, hass_config, add_entities,
     loc_idx = broker.params[CONF_LOCATION_IDX]
 
     _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,
         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)
 
     zones = []
-    for zone_idx in broker.tcs.zones:
-        evo_zone = broker.tcs.zones[zone_idx]
+    for zone in broker.tcs.zones.values():
         _LOGGER.debug(
             "Found %s, id=%s [%s], name=%s",
-            evo_zone.zoneType, evo_zone.zoneId, evo_zone.modelType,
-            evo_zone.name)
-        zones.append(EvoZone(broker, evo_zone))
+            zone.zoneType, zone.zoneId, zone.modelType, zone.name)
+        zones.append(EvoZone(broker, zone))
 
-    entities = [controller] + zones
-
-    add_entities(entities, update_before_add=True)
+    add_entities([controller] + zones, update_before_add=True)
 
 
 class EvoClimateDevice(EvoDevice, ClimateDevice):
@@ -77,12 +87,67 @@ class EvoClimateDevice(EvoDevice, ClimateDevice):
         """Initialize the evohome Climate 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
     def hvac_modes(self) -> List[str]:
         """Return the list of available hvac operation modes."""
-        return self._hvac_modes
+        return [HVAC_MODE_OFF, HVAC_MODE_HEAT]
 
     @property
     def preset_modes(self) -> Optional[List[str]]:
@@ -97,39 +162,22 @@ class EvoZone(EvoClimateDevice):
         """Initialize the evohome Zone."""
         super().__init__(evo_broker, evo_device)
 
-        self._id = evo_device.zoneId
         self._name = evo_device.name
         self._icon = 'mdi:radiator'
 
         self._precision = \
             self._evo_device.setpointCapabilities['valueResolution']
         self._state_attributes = [
-            'activeFaults', 'setpointStatus', 'temperatureStatus', 'setpoints']
+            'zoneId', 'activeFaults', 'setpointStatus', 'temperatureStatus',
+            'setpoints']
 
         self._supported_features = SUPPORT_PRESET_MODE | \
             SUPPORT_TARGET_TEMPERATURE
-        self._hvac_modes = [HVAC_MODE_OFF, HVAC_MODE_HEAT]
         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
     def hvac_mode(self) -> str:
-        """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'.
-        """
+        """Return the current operating mode of the evohome Zone."""
         if self._evo_tcs.systemModeStatus['mode'] in [EVO_AWAY, EVO_HEATOFF]:
             return HVAC_MODE_AUTO
         is_off = self.target_temperature <= self.min_temp
@@ -152,7 +200,7 @@ class EvoZone(EvoClimateDevice):
     def preset_mode(self) -> Optional[str]:
         """Return the current preset mode, e.g., home, away, temp."""
         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(
             self._evo_device.setpointStatus['setpointMode'], 'follow')
 
@@ -172,18 +220,6 @@ class EvoZone(EvoClimateDevice):
         """
         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:
         """Set a new target temperature for an hour."""
         until = kwargs.get('until')
@@ -192,40 +228,20 @@ class EvoZone(EvoClimateDevice):
 
         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:
         """Set an operating mode for the Zone."""
         if hvac_mode == HVAC_MODE_OFF:
             self._set_temperature(self.min_temp, until=None)
 
         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:
         """Set a new preset mode.
 
         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):
@@ -239,27 +255,15 @@ class EvoController(EvoClimateDevice):
         """Initialize the evohome Controller (hub)."""
         super().__init__(evo_broker, evo_device)
 
-        self._id = evo_device.systemId
         self._name = evo_device.location.name
         self._icon = 'mdi:thermostat'
 
-        self._precision = None
-        self._state_attributes = ['activeFaults', 'systemModeStatus']
+        self._precision = PRECISION_TENTHS
+        self._state_attributes = [
+            'systemId', 'activeFaults', 'systemModeStatus']
 
         self._supported_features = SUPPORT_PRESET_MODE
-        self._hvac_modes = list(HA_HVAC_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'] = '...'
+        self._preset_modes = list(HA_PRESET_TO_TCS)
 
     @property
     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.
         """
-        temps = [z.temperatureStatus['temperature'] for z in
-                 self._evo_device._zones if z.temperatureStatus['isAvailable']]  # noqa: E501; pylint: disable=protected-access
+        temps = [z.temperatureStatus['temperature']
+                 for z in self._evo_device.zones.values()
+                 if z.temperatureStatus['isAvailable']]
         return round(sum(temps) / len(temps), 1) if temps else None
 
     @property
@@ -284,7 +289,7 @@ class EvoController(EvoClimateDevice):
         Controllers do not have a target temp, but one is expected by HA.
         """
         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
 
     @property
@@ -299,7 +304,7 @@ class EvoController(EvoClimateDevice):
         Controllers do not have a min target temp, but one is required by HA.
         """
         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
 
     @property
@@ -309,28 +314,77 @@ class EvoController(EvoClimateDevice):
         Controllers do not have a max target temp, but one is required by HA.
         """
         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
 
-    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:
         """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:
         """Set a new preset 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:
         """Get the latest state data."""
         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))
diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py
index 6e851741489..4706269e1cf 100644
--- a/homeassistant/components/evohome/water_heater.py
+++ b/homeassistant/components/evohome/water_heater.py
@@ -27,8 +27,8 @@ def setup_platform(hass, hass_config, add_entities,
     broker = hass.data[DOMAIN]['broker']
 
     _LOGGER.debug(
-        "Found DHW device, id: %s [%s]",
-        broker.tcs.hotwater.zoneId, broker.tcs.hotwater.zone_type)
+        "Found %s, id: %s",
+        broker.tcs.hotwater.zone_type, broker.tcs.hotwater.zoneId)
 
     evo_dhw = EvoDHW(broker, broker.tcs.hotwater)
 
@@ -42,19 +42,17 @@ class EvoDHW(EvoDevice, WaterHeaterDevice):
         """Initialize the evohome DHW controller."""
         super().__init__(evo_broker, evo_device)
 
-        self._id = evo_device.dhwId
         self._name = 'DHW controller'
         self._icon = 'mdi:thermometer-lines'
 
         self._precision = PRECISION_WHOLE
         self._state_attributes = [
-            'activeFaults', 'stateStatus', 'temperatureStatus', 'setpoints']
+            'dhwId', 'activeFaults', 'stateStatus', 'temperatureStatus',
+            'setpoints']
 
         self._supported_features = SUPPORT_OPERATION_MODE
         self._operation_list = list(HA_OPMODE_TO_DHW)
 
-        self._config = evo_broker.config['dhw']
-
     @property
     def current_operation(self) -> str:
         """Return the current operating mode (On, or Off)."""