273 lines
9.0 KiB
Python
273 lines
9.0 KiB
Python
"""Support for (EMEA/EU-based) Honeywell evohome systems."""
|
|
# Glossary:
|
|
# TCS - temperature control system (a.k.a. Controller, Parent), which can
|
|
# have up to 13 Children:
|
|
# 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 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,
|
|
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_connect, async_dispatcher_send)
|
|
from homeassistant.helpers.entity import Entity
|
|
|
|
from .const import (
|
|
DOMAIN, DATA_EVOHOME, DISPATCHER_EVOHOME, GWS, TCS)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
CONF_LOCATION_IDX = 'location_idx'
|
|
SCAN_INTERVAL_DEFAULT = timedelta(seconds=300)
|
|
SCAN_INTERVAL_MINIMUM = timedelta(seconds=180)
|
|
|
|
CONFIG_SCHEMA = vol.Schema({
|
|
DOMAIN: vol.Schema({
|
|
vol.Required(CONF_USERNAME): cv.string,
|
|
vol.Required(CONF_PASSWORD): cv.string,
|
|
vol.Optional(CONF_LOCATION_IDX, default=0): cv.positive_int,
|
|
vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL_DEFAULT):
|
|
vol.All(cv.time_period, vol.Range(min=SCAN_INTERVAL_MINIMUM)),
|
|
}),
|
|
}, extra=vol.ALLOW_EXTRA)
|
|
|
|
CONF_SECRETS = [
|
|
CONF_USERNAME, CONF_PASSWORD,
|
|
]
|
|
|
|
# bit masks for dispatcher packets
|
|
EVO_PARENT = 0x01
|
|
EVO_CHILD = 0x02
|
|
|
|
|
|
def setup(hass, hass_config):
|
|
"""Create a (EMEA/EU-based) Honeywell evohome system.
|
|
|
|
Currently, only the Controller and the Zones are implemented here.
|
|
"""
|
|
evo_data = hass.data[DATA_EVOHOME] = {}
|
|
evo_data['timers'] = {}
|
|
|
|
# use a copy, since scan_interval is rounded up to nearest 60s
|
|
evo_data['params'] = dict(hass_config[DOMAIN])
|
|
scan_interval = evo_data['params'][CONF_SCAN_INTERVAL]
|
|
scan_interval = timedelta(
|
|
minutes=(scan_interval.total_seconds() + 59) // 60)
|
|
|
|
try:
|
|
client = evo_data['client'] = evohomeclient2.EvohomeClient(
|
|
evo_data['params'][CONF_USERNAME],
|
|
evo_data['params'][CONF_PASSWORD],
|
|
debug=False
|
|
)
|
|
|
|
except evohomeclient2.AuthenticationError as err:
|
|
_LOGGER.error(
|
|
"setup(): Failed to authenticate with the vendor's server. "
|
|
"Check your username and password are correct. "
|
|
"Resolve any errors and restart HA. Message is: %s",
|
|
err
|
|
)
|
|
return False
|
|
|
|
except requests.exceptions.ConnectionError:
|
|
_LOGGER.error(
|
|
"setup(): Unable to connect with the vendor's server. "
|
|
"Check your network and the vendor's status page. "
|
|
"Resolve any errors and restart HA."
|
|
)
|
|
return False
|
|
|
|
finally: # Redact any config data that's no longer needed
|
|
for parameter in CONF_SECRETS:
|
|
evo_data['params'][parameter] = 'REDACTED' \
|
|
if evo_data['params'][parameter] else None
|
|
|
|
evo_data['status'] = {}
|
|
|
|
# Redact any installation data that's no longer needed
|
|
for loc in client.installation_info:
|
|
loc['locationInfo']['locationId'] = 'REDACTED'
|
|
loc['locationInfo']['locationOwner'] = 'REDACTED'
|
|
loc['locationInfo']['streetAddress'] = 'REDACTED'
|
|
loc['locationInfo']['city'] = 'REDACTED'
|
|
loc[GWS][0]['gatewayInfo'] = 'REDACTED'
|
|
|
|
# Pull down the installation configuration
|
|
loc_idx = evo_data['params'][CONF_LOCATION_IDX]
|
|
try:
|
|
evo_data['config'] = client.installation_info[loc_idx]
|
|
|
|
except IndexError:
|
|
_LOGGER.error(
|
|
"setup(): config error, '%s' = %s, but its valid range is 0-%s. "
|
|
"Unable to continue. Fix any configuration errors and restart HA.",
|
|
CONF_LOCATION_IDX, loc_idx, len(client.installation_info) - 1
|
|
)
|
|
return False
|
|
|
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
tmp_loc = dict(evo_data['config'])
|
|
tmp_loc['locationInfo']['postcode'] = 'REDACTED'
|
|
|
|
if 'dhw' in tmp_loc[GWS][0][TCS][0]: # if this location has DHW...
|
|
tmp_loc[GWS][0][TCS][0]['dhw'] = '...'
|
|
|
|
_LOGGER.debug("setup(): evo_data['config']=%s", tmp_loc)
|
|
|
|
load_platform(hass, 'climate', DOMAIN, {}, hass_config)
|
|
|
|
if 'dhw' in evo_data['config'][GWS][0][TCS][0]:
|
|
_LOGGER.warning(
|
|
"setup(): DHW found, but this component doesn't support DHW."
|
|
)
|
|
|
|
@callback
|
|
def _first_update(event):
|
|
"""When HA has started, the hub knows to retrieve it's first update."""
|
|
pkt = {'sender': 'setup()', 'signal': 'refresh', 'to': EVO_PARENT}
|
|
async_dispatcher_send(hass, DISPATCHER_EVOHOME, pkt)
|
|
|
|
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
|