Refactor evohome to prepare for water_heater (#23489)

* refactor - add const.py, change order of propertys, methods

* import client at top of file

* remove debug line

* de-lint

* delint

* add me as CODEOWNER

* remove lint hint

* delint
This commit is contained in:
David Bonnes 2019-05-03 00:43:19 +01:00 committed by Martin Hjelmare
parent 3338f5c9b4
commit 6130831a43
5 changed files with 257 additions and 241 deletions

View File

@ -73,6 +73,7 @@ homeassistant/components/epsonworkforce/* @ThaStealth
homeassistant/components/eq3btsmart/* @rytilahti
homeassistant/components/esphome/* @OttoWinter
homeassistant/components/essent/* @TheLastProject
homeassistant/components/evohome/* @zxdavb
homeassistant/components/file/* @fabaff
homeassistant/components/filter/* @dgomes
homeassistant/components/fitbit/* @robbiet480

View File

@ -5,26 +5,31 @@
# 0-12 Heating zones (a.k.a. Zone), and
# 0-1 DHW controller, (a.k.a. Boiler)
# The TCS & Zones are implemented as Climate devices, Boiler as a WaterHeater
from datetime import timedelta
from datetime import datetime, timedelta
import logging
import requests.exceptions
import voluptuous as vol
import evohomeclient2
from homeassistant.const import (
CONF_SCAN_INTERVAL, CONF_USERNAME, CONF_PASSWORD,
EVENT_HOMEASSISTANT_START)
EVENT_HOMEASSISTANT_START,
HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS,
PRECISION_HALVES, TEMP_CELSIUS)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect, async_dispatcher_send)
from homeassistant.helpers.entity import Entity
from .const import (
DOMAIN, DATA_EVOHOME, DISPATCHER_EVOHOME, GWS, TCS)
_LOGGER = logging.getLogger(__name__)
DOMAIN = 'evohome'
DATA_EVOHOME = 'data_' + DOMAIN
DISPATCHER_EVOHOME = 'dispatcher_' + DOMAIN
CONF_LOCATION_IDX = 'location_idx'
SCAN_INTERVAL_DEFAULT = timedelta(seconds=300)
SCAN_INTERVAL_MINIMUM = timedelta(seconds=180)
@ -43,10 +48,6 @@ CONF_SECRETS = [
CONF_USERNAME, CONF_PASSWORD,
]
# These are used to help prevent E501 (line too long) violations.
GWS = 'gateways'
TCS = 'temperatureControlSystems'
# bit masks for dispatcher packets
EVO_PARENT = 0x01
EVO_CHILD = 0x02
@ -66,8 +67,6 @@ def setup(hass, hass_config):
scan_interval = timedelta(
minutes=(scan_interval.total_seconds() + 59) // 60)
import evohomeclient2
try:
client = evo_data['client'] = evohomeclient2.EvohomeClient(
evo_data['params'][CONF_USERNAME],
@ -145,3 +144,129 @@ def setup(hass, hass_config):
hass.bus.listen(EVENT_HOMEASSISTANT_START, _first_update)
return True
class EvoDevice(Entity):
"""Base for any Honeywell evohome device.
Such devices include the Controller, (up to 12) Heating Zones and
(optionally) a DHW controller.
"""
def __init__(self, evo_data, client, obj_ref):
"""Initialize the evohome entity."""
self._client = client
self._obj = obj_ref
self._name = None
self._icon = None
self._type = None
self._supported_features = None
self._operation_list = None
self._params = evo_data['params']
self._timers = evo_data['timers']
self._status = {}
self._available = False # should become True after first update()
@callback
def _connect(self, packet):
if packet['to'] & self._type and packet['signal'] == 'refresh':
self.async_schedule_update_ha_state(force_refresh=True)
def _handle_exception(self, err):
try:
raise err
except evohomeclient2.AuthenticationError:
_LOGGER.error(
"Failed to (re)authenticate with the vendor's server. "
"This may be a temporary error. Message is: %s",
err
)
except requests.exceptions.ConnectionError:
# this appears to be common with Honeywell's servers
_LOGGER.warning(
"Unable to connect with the vendor's server. "
"Check your network and the vendor's status page."
)
except requests.exceptions.HTTPError:
if err.response.status_code == HTTP_SERVICE_UNAVAILABLE:
_LOGGER.warning(
"Vendor says their server is currently unavailable. "
"This may be temporary; check the vendor's status page."
)
elif err.response.status_code == HTTP_TOO_MANY_REQUESTS:
_LOGGER.warning(
"The vendor's API rate limit has been exceeded. "
"So will cease polling, and will resume after %s seconds.",
(self._params[CONF_SCAN_INTERVAL] * 3).total_seconds()
)
self._timers['statusUpdated'] = datetime.now() + \
self._params[CONF_SCAN_INTERVAL] * 3
else:
raise # we don't expect/handle any other HTTPErrors
# These properties, methods are from the Entity class
async def async_added_to_hass(self):
"""Run when entity about to be added."""
async_dispatcher_connect(self.hass, DISPATCHER_EVOHOME, self._connect)
@property
def should_poll(self) -> bool:
"""Most evohome devices push their state to HA.
Only the Controller should be polled.
"""
return False
@property
def name(self) -> str:
"""Return the name to use in the frontend UI."""
return self._name
@property
def device_state_attributes(self):
"""Return the device state attributes of the evohome device.
This is state data that is not available otherwise, due to the
restrictions placed upon ClimateDevice properties, etc. by HA.
"""
return {'status': self._status}
@property
def icon(self):
"""Return the icon to use in the frontend UI."""
return self._icon
@property
def available(self) -> bool:
"""Return True if the device is currently available."""
return self._available
@property
def supported_features(self):
"""Get the list of supported features of the device."""
return self._supported_features
# These properties are common to ClimateDevice, WaterHeaterDevice classes
@property
def precision(self):
"""Return the temperature precision to use in the frontend UI."""
return PRECISION_HALVES
@property
def temperature_unit(self):
"""Return the temperature unit to use in the frontend UI."""
return TEMP_CELSIUS
@property
def operation_list(self):
"""Return the list of available operations."""
return self._operation_list

View File

@ -4,20 +4,21 @@ import logging
import requests.exceptions
import evohomeclient2
from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import (
STATE_AUTO, STATE_ECO, STATE_MANUAL, SUPPORT_AWAY_MODE, SUPPORT_ON_OFF,
SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
from homeassistant.const import (
CONF_SCAN_INTERVAL, HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS,
PRECISION_HALVES, STATE_OFF, TEMP_CELSIUS)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect, dispatcher_send)
CONF_SCAN_INTERVAL, STATE_OFF,)
from homeassistant.helpers.dispatcher import dispatcher_send
from . import (
CONF_LOCATION_IDX, DATA_EVOHOME, DISPATCHER_EVOHOME, EVO_CHILD, EVO_PARENT,
GWS, TCS)
EvoDevice,
CONF_LOCATION_IDX, EVO_CHILD, EVO_PARENT)
from .const import (
DATA_EVOHOME, DISPATCHER_EVOHOME, GWS, TCS)
_LOGGER = logging.getLogger(__name__)
@ -103,115 +104,7 @@ async def async_setup_platform(hass, hass_config, async_add_entities,
async_add_entities(entities, update_before_add=False)
class EvoClimateDevice(ClimateDevice):
"""Base for a Honeywell evohome Climate device."""
# pylint: disable=no-member
def __init__(self, evo_data, client, obj_ref):
"""Initialize the evohome entity."""
self._client = client
self._obj = obj_ref
self._params = evo_data['params']
self._timers = evo_data['timers']
self._status = {}
self._available = False # should become True after first update()
async def async_added_to_hass(self):
"""Run when entity about to be added."""
async_dispatcher_connect(self.hass, DISPATCHER_EVOHOME, self._connect)
@callback
def _connect(self, packet):
if packet['to'] & self._type and packet['signal'] == 'refresh':
self.async_schedule_update_ha_state(force_refresh=True)
def _handle_exception(self, err):
try:
import evohomeclient2
raise err
except evohomeclient2.AuthenticationError:
_LOGGER.error(
"Failed to (re)authenticate with the vendor's server. "
"This may be a temporary error. Message is: %s",
err
)
except requests.exceptions.ConnectionError:
# this appears to be common with Honeywell's servers
_LOGGER.warning(
"Unable to connect with the vendor's server. "
"Check your network and the vendor's status page."
)
except requests.exceptions.HTTPError:
if err.response.status_code == HTTP_SERVICE_UNAVAILABLE:
_LOGGER.warning(
"Vendor says their server is currently unavailable. "
"This may be temporary; check the vendor's status page."
)
elif err.response.status_code == HTTP_TOO_MANY_REQUESTS:
_LOGGER.warning(
"The vendor's API rate limit has been exceeded. "
"So will cease polling, and will resume after %s seconds.",
(self._params[CONF_SCAN_INTERVAL] * 3).total_seconds()
)
self._timers['statusUpdated'] = datetime.now() + \
self._params[CONF_SCAN_INTERVAL] * 3
else:
raise # we don't expect/handle any other HTTPErrors
@property
def name(self) -> str:
"""Return the name to use in the frontend UI."""
return self._name
@property
def icon(self):
"""Return the icon to use in the frontend UI."""
return self._icon
@property
def device_state_attributes(self):
"""Return the device state attributes of the evohome Climate device.
This is state data that is not available otherwise, due to the
restrictions placed upon ClimateDevice properties, etc. by HA.
"""
return {'status': self._status}
@property
def available(self) -> bool:
"""Return True if the device is currently available."""
return self._available
@property
def supported_features(self):
"""Get the list of supported features of the device."""
return self._supported_features
@property
def operation_list(self):
"""Return the list of available operations."""
return self._operation_list
@property
def temperature_unit(self):
"""Return the temperature unit to use in the frontend UI."""
return TEMP_CELSIUS
@property
def precision(self):
"""Return the temperature precision to use in the frontend UI."""
return PRECISION_HALVES
class EvoZone(EvoClimateDevice):
class EvoZone(EvoDevice, ClimateDevice):
"""Base for a Honeywell evohome Zone device."""
def __init__(self, evo_data, client, obj_ref):
@ -235,33 +128,6 @@ class EvoZone(EvoClimateDevice):
SUPPORT_TARGET_TEMPERATURE | \
SUPPORT_ON_OFF
@property
def min_temp(self):
"""Return the minimum target temperature of a evohome Zone.
The default is 5 (in Celsius), but it is configurable within 5-35.
"""
return self._config['setpointCapabilities']['minHeatSetpoint']
@property
def max_temp(self):
"""Return the minimum target temperature of a evohome Zone.
The default is 35 (in Celsius), but it is configurable within 5-35.
"""
return self._config['setpointCapabilities']['maxHeatSetpoint']
@property
def target_temperature(self):
"""Return the target temperature of the evohome Zone."""
return self._status['setpointStatus']['targetHeatTemperature']
@property
def current_temperature(self):
"""Return the current temperature of the evohome Zone."""
return (self._status['temperatureStatus']['temperature']
if self._status['temperatureStatus']['isAvailable'] else None)
@property
def current_operation(self):
"""Return the current operating mode of the evohome Zone.
@ -285,6 +151,17 @@ class EvoZone(EvoClimateDevice):
return current_operation
@property
def current_temperature(self):
"""Return the current temperature of the evohome Zone."""
return (self._status['temperatureStatus']['temperature']
if self._status['temperatureStatus']['isAvailable'] else None)
@property
def target_temperature(self):
"""Return the target temperature of the evohome Zone."""
return self._status['setpointStatus']['targetHeatTemperature']
@property
def is_on(self) -> bool:
"""Return True if the evohome Zone is off.
@ -297,6 +174,22 @@ class EvoZone(EvoClimateDevice):
self._status['setpointStatus']['setpointMode'] == EVO_PERMOVER
return not is_off
@property
def min_temp(self):
"""Return the minimum target temperature of a evohome Zone.
The default is 5 (in Celsius), but it is configurable within 5-35.
"""
return self._config['setpointCapabilities']['minHeatSetpoint']
@property
def max_temp(self):
"""Return the maximum target temperature of a evohome Zone.
The default is 35 (in Celsius), but it is configurable within 5-35.
"""
return self._config['setpointCapabilities']['maxHeatSetpoint']
def _set_temperature(self, temperature, until=None):
"""Set the new target temperature of a Zone.
@ -305,7 +198,6 @@ class EvoZone(EvoClimateDevice):
- None for PermanentOverride (i.e. indefinitely)
"""
try:
import evohomeclient2
self._obj.set_temperature(temperature, until)
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
@ -330,6 +222,29 @@ class EvoZone(EvoClimateDevice):
"""
self._set_temperature(self.min_temp, until=None)
def _set_operation_mode(self, operation_mode):
if operation_mode == EVO_FOLLOW:
try:
self._obj.cancel_temp_override()
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
self._handle_exception(err)
elif operation_mode == EVO_TEMPOVER:
_LOGGER.error(
"_set_operation_mode(op_mode=%s): mode not yet implemented",
operation_mode
)
elif operation_mode == EVO_PERMOVER:
self._set_temperature(self.target_temperature, until=None)
else:
_LOGGER.error(
"_set_operation_mode(op_mode=%s): mode not valid",
operation_mode
)
def set_operation_mode(self, operation_mode):
"""Set an operating mode for a Zone.
@ -354,38 +269,6 @@ class EvoZone(EvoClimateDevice):
"""
self._set_operation_mode(HA_STATE_TO_ZONE.get(operation_mode))
def _set_operation_mode(self, operation_mode):
if operation_mode == EVO_FOLLOW:
try:
import evohomeclient2
self._obj.cancel_temp_override()
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
self._handle_exception(err)
elif operation_mode == EVO_TEMPOVER:
_LOGGER.error(
"_set_operation_mode(op_mode=%s): mode not yet implemented",
operation_mode
)
elif operation_mode == EVO_PERMOVER:
self._set_temperature(self.target_temperature, until=None)
else:
_LOGGER.error(
"_set_operation_mode(op_mode=%s): mode not valid",
operation_mode
)
@property
def should_poll(self) -> bool:
"""Return False as evohome child devices should never be polled.
The evohome Controller will inform its children when to update().
"""
return False
def update(self):
"""Process the evohome Zone's state data."""
evo_data = self.hass.data[DATA_EVOHOME]
@ -398,7 +281,7 @@ class EvoZone(EvoClimateDevice):
self._available = True
class EvoController(EvoClimateDevice):
class EvoController(EvoDevice, ClimateDevice):
"""Base for a Honeywell evohome hub/Controller device.
The Controller (aka TCS, temperature control system) is the parent of all
@ -445,22 +328,18 @@ class EvoController(EvoClimateDevice):
return TCS_STATE_TO_HA.get(self._status['systemModeStatus']['mode'])
@property
def min_temp(self):
"""Return the minimum target temperature of a evohome Controller.
def current_temperature(self):
"""Return the average current temperature of the Heating/DHW zones.
Although evohome Controllers do not have a minimum target temp, one is
expected by the HA schema; the default for an evohome HR92 is used.
Although evohome Controllers do not have a target temp, one is
expected by the HA schema.
"""
return 5
tmp_list = [x for x in self._status['zones']
if x['temperatureStatus']['isAvailable']]
temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list]
@property
def max_temp(self):
"""Return the minimum target temperature of a evohome Controller.
Although evohome Controllers do not have a maximum target temp, one is
expected by the HA schema; the default for an evohome HR92 is used.
"""
return 35
avg_temp = round(sum(temps) / len(temps), 1) if temps else None
return avg_temp
@property
def target_temperature(self):
@ -476,18 +355,9 @@ class EvoController(EvoClimateDevice):
return avg_temp
@property
def current_temperature(self):
"""Return the average current temperature of the Heating/DHW zones.
Although evohome Controllers do not have a target temp, one is
expected by the HA schema.
"""
tmp_list = [x for x in self._status['zones']
if x['temperatureStatus']['isAvailable'] is True]
temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list]
avg_temp = round(sum(temps) / len(temps), 1) if temps else None
return avg_temp
def is_away_mode_on(self) -> bool:
"""Return True if away mode is on."""
return self._status['systemModeStatus']['mode'] == EVO_AWAY
@property
def is_on(self) -> bool:
@ -499,9 +369,42 @@ class EvoController(EvoClimateDevice):
return True
@property
def is_away_mode_on(self) -> bool:
"""Return True if away mode is on."""
return self._status['systemModeStatus']['mode'] == EVO_AWAY
def min_temp(self):
"""Return the minimum target temperature of a evohome Controller.
Although evohome Controllers do not have a minimum target temp, one is
expected by the HA schema; the default for an evohome HR92 is used.
"""
return 5
@property
def max_temp(self):
"""Return the maximum target temperature of a evohome Controller.
Although evohome Controllers do not have a maximum target temp, one is
expected by the HA schema; the default for an evohome HR92 is used.
"""
return 35
@property
def should_poll(self) -> bool:
"""Return True as the evohome Controller should always be polled."""
return True
def _set_operation_mode(self, operation_mode):
try:
self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
self._handle_exception(err)
def set_operation_mode(self, operation_mode):
"""Set new target operation mode for the TCS.
Currently limited to 'Auto', 'AutoWithEco' & 'HeatingOff'. If 'Away'
mode is needed, it can be enabled via turn_away_mode_on method.
"""
self._set_operation_mode(HA_STATE_TO_TCS.get(operation_mode))
def turn_away_mode_on(self):
"""Turn away mode on.
@ -519,27 +422,6 @@ class EvoController(EvoClimateDevice):
"""
self._set_operation_mode(EVO_AUTO)
def _set_operation_mode(self, operation_mode):
try:
import evohomeclient2
self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
self._handle_exception(err)
def set_operation_mode(self, operation_mode):
"""Set new target operation mode for the TCS.
Currently limited to 'Auto', 'AutoWithEco' & 'HeatingOff'. If 'Away'
mode is needed, it can be enabled via turn_away_mode_on method.
"""
self._set_operation_mode(HA_STATE_TO_TCS.get(operation_mode))
@property
def should_poll(self) -> bool:
"""Return True as the evohome Controller should always be polled."""
return True
def update(self):
"""Get the latest state data of the entire evohome Location.
@ -559,7 +441,6 @@ class EvoController(EvoClimateDevice):
loc_idx = self._params[CONF_LOCATION_IDX]
try:
import evohomeclient2
self._status.update(
self._client.locations[loc_idx].status()[GWS][0][TCS][0])
except (requests.exceptions.RequestException,

View File

@ -0,0 +1,9 @@
"""Provides the constants needed for evohome."""
DOMAIN = 'evohome'
DATA_EVOHOME = 'data_' + DOMAIN
DISPATCHER_EVOHOME = 'dispatcher_' + DOMAIN
# These are used only to help prevent E501 (line too long) violations.
GWS = 'gateways'
TCS = 'temperatureControlSystems'

View File

@ -6,5 +6,5 @@
"evohomeclient==0.3.2"
],
"dependencies": [],
"codeowners": []
"codeowners": ["@zxdavb"]
}