hass-core/homeassistant/components/climate/honeywell.py
zxdavb 8d65230a36 Add (EU-based) Honeywell evohome CH/DHW controller (#16427)
* Add support for Honeywell evohome CH/DHW systems

More flake8 corrections

Passes Flake8 tests

Almost passed flake8.pylint!

Passed all tox tests

Now it needs serious testing!

Almost ready to submit

BUGFIX: DHW state now functional

More improvements to available()

Solved the DHW temp units problem!

Last minute bug squash

to improve dicts merge

Trying to rebase

fixing more rbase errors

revert to creating HTTP_error_code internally for now

ready to submit PR

Added support for Honeywell evohome CH/DHW systems

* Updated requirements_test_all.txt

* Fix: D401 First line should be in imperative mood

* Remove _LOGGER.info (replace with _LOGGER.debug)

* raise PlatformNotReady when RequestException during setup()

* Revert some LOGGER.debug to LOGGER.warning

* Improved logging levels, and removed some unwanted comments

* Improvments to logging - additional self._status info

* BUGFIX: DHW wrongly showing available = False (and some other tweaks)

* Fix trailing whitespace

* Remove state_attributes override and API_VER code

* Removed heating zones, DHW and heuristics to reduce # lines of code

* Removed some more lines of code

* Removed unused configuration parameters

* Remove some more un-needed lines

* Removed more (uneeded) lines of code & fixed two minor typos

* Improvements to debug logging of available() = False

* Improvements to code, and code clean-up

* Corrected a minor typo

* A small tidy up

* reduces precision of emulated temps floats to 0.1

* Some code improvements as suggested by JeardM

* Rewrite of exception handler

* Removed another unwanted logging in properties

* Remove async_ version of various methods

* moved available heuristics to update()

* Cleanup of code, and re-work linter hints

* fixed a minor documentation typo

* scan_interval is now no longer a configurable option

* Change from Master/Slave to Parent/Child

* Removed the last of the slaves

* Removed the last of the masters

* Move -PARALLEL_UPDATES to .\climate\evohome.py'

* main code moved to climate/evohome.py

* merge EvoEntity into EvoController class

* remove should_poll (for now)

* woops! left a hint in

* removed icon

* only log a WARNING the first time available = False

* cleanup dodgy exception handling

* Tidy up exception handling

* Many changes as suggested by @MartinHjelmare, thanks

* remove hass from init, part 1

* use async_added_to_hass instead of dispatcher_connect

* remove hass from init, part 2 (done)

* add 1-2 arrays, and tidied up some comments

* from dispatcher to async_added_to_hass

* cleaned up some logging, and removed others

* Many changes as request by @MartinHjelmare

* Homage to the lint

* Changed to the HA of doing operating_mode

* Now using update_before_add=True

* reduced logging further still...

* fixed minor lint

* fix a small logic error

* Add device_state_attributes to track actual operating mode

* Clean up doc quotes caused by  previous changes

* Woops! removed some debug lines that shoudln't have stayed in

* Add a complete set of device_state_attributes

* Cleanup some constants

* Remove more legacy code

* domain_data to evo_data & this else should be a finally

* minor change for readability

* Minor change for readability #2

* removed unused code

* small tidy up - no code changes

* fix minor lint

* Correct URLs & descriptions in docstring

* whoops - fixed a typo in docstrings

* remove an unused line of cide & a small tidy-up
2018-09-27 13:29:44 +02:00

428 lines
14 KiB
Python

"""
Support for Honeywell Round Connected and Honeywell Evohome thermostats.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/climate.honeywell/
"""
import logging
import socket
import datetime
import requests
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.components.climate import (
ClimateDevice, PLATFORM_SCHEMA, ATTR_FAN_MODE, ATTR_FAN_LIST,
ATTR_OPERATION_MODE, ATTR_OPERATION_LIST, SUPPORT_TARGET_TEMPERATURE,
SUPPORT_AWAY_MODE, SUPPORT_OPERATION_MODE)
from homeassistant.const import (
CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT,
ATTR_TEMPERATURE, CONF_REGION)
REQUIREMENTS = ['evohomeclient==0.2.7', 'somecomfort==0.5.2']
_LOGGER = logging.getLogger(__name__)
ATTR_FAN = 'fan'
ATTR_SYSTEM_MODE = 'system_mode'
ATTR_CURRENT_OPERATION = 'equipment_output_status'
CONF_AWAY_TEMPERATURE = 'away_temperature'
CONF_COOL_AWAY_TEMPERATURE = 'away_cool_temperature'
CONF_HEAT_AWAY_TEMPERATURE = 'away_heat_temperature'
DEFAULT_AWAY_TEMPERATURE = 16
DEFAULT_COOL_AWAY_TEMPERATURE = 30
DEFAULT_HEAT_AWAY_TEMPERATURE = 16
DEFAULT_REGION = 'eu'
REGIONS = ['eu', 'us']
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_AWAY_TEMPERATURE,
default=DEFAULT_AWAY_TEMPERATURE): vol.Coerce(float),
vol.Optional(CONF_COOL_AWAY_TEMPERATURE,
default=DEFAULT_COOL_AWAY_TEMPERATURE): vol.Coerce(float),
vol.Optional(CONF_HEAT_AWAY_TEMPERATURE,
default=DEFAULT_HEAT_AWAY_TEMPERATURE): vol.Coerce(float),
vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In(REGIONS),
})
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Honeywell thermostat."""
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
region = config.get(CONF_REGION)
if region == 'us':
return _setup_us(username, password, config, add_entities)
return _setup_round(username, password, config, add_entities)
def _setup_round(username, password, config, add_entities):
"""Set up the rounding function."""
from evohomeclient import EvohomeClient
away_temp = config.get(CONF_AWAY_TEMPERATURE)
evo_api = EvohomeClient(username, password)
try:
zones = evo_api.temperatures(force_refresh=True)
for i, zone in enumerate(zones):
add_entities(
[RoundThermostat(evo_api, zone['id'], i == 0, away_temp)],
True
)
except socket.error:
_LOGGER.error(
"Connection error logging into the honeywell evohome web service")
return False
return True
# config will be used later
def _setup_us(username, password, config, add_entities):
"""Set up the user."""
import somecomfort
try:
client = somecomfort.SomeComfort(username, password)
except somecomfort.AuthError:
_LOGGER.error("Failed to login to honeywell account %s", username)
return False
except somecomfort.SomeComfortError as ex:
_LOGGER.error("Failed to initialize honeywell client: %s", str(ex))
return False
dev_id = config.get('thermostat')
loc_id = config.get('location')
cool_away_temp = config.get(CONF_COOL_AWAY_TEMPERATURE)
heat_away_temp = config.get(CONF_HEAT_AWAY_TEMPERATURE)
add_entities([HoneywellUSThermostat(client, device, cool_away_temp,
heat_away_temp, username, password)
for location in client.locations_by_id.values()
for device in location.devices_by_id.values()
if ((not loc_id or location.locationid == loc_id) and
(not dev_id or device.deviceid == dev_id))])
return True
class RoundThermostat(ClimateDevice):
"""Representation of a Honeywell Round Connected thermostat."""
def __init__(self, client, zone_id, master, away_temp):
"""Initialize the thermostat."""
self.client = client
self._current_temperature = None
self._target_temperature = None
self._name = 'round connected'
self._id = zone_id
self._master = master
self._is_dhw = False
self._away_temp = away_temp
self._away = False
@property
def supported_features(self):
"""Return the list of supported features."""
supported = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE)
if hasattr(self.client, ATTR_SYSTEM_MODE):
supported |= SUPPORT_OPERATION_MODE
return supported
@property
def name(self):
"""Return the name of the honeywell, if any."""
return self._name
@property
def temperature_unit(self):
"""Return the unit of measurement."""
return TEMP_CELSIUS
@property
def current_temperature(self):
"""Return the current temperature."""
return self._current_temperature
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
if self._is_dhw:
return None
return self._target_temperature
def set_temperature(self, **kwargs):
"""Set new target temperature."""
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None:
return
self.client.set_temperature(self._name, temperature)
@property
def current_operation(self) -> str:
"""Get the current operation of the system."""
return getattr(self.client, ATTR_SYSTEM_MODE, None)
@property
def is_away_mode_on(self):
"""Return true if away mode is on."""
return self._away
def set_operation_mode(self, operation_mode: str) -> None:
"""Set the HVAC mode for the thermostat."""
if hasattr(self.client, ATTR_SYSTEM_MODE):
self.client.system_mode = operation_mode
def turn_away_mode_on(self):
"""Turn away on.
Honeywell does have a proprietary away mode, but it doesn't really work
the way it should. For example: If you set a temperature manually
it doesn't get overwritten when away mode is switched on.
"""
self._away = True
self.client.set_temperature(self._name, self._away_temp)
def turn_away_mode_off(self):
"""Turn away off."""
self._away = False
self.client.cancel_temp_override(self._name)
def update(self):
"""Get the latest date."""
try:
# Only refresh if this is the "master" device,
# others will pick up the cache
for val in self.client.temperatures(force_refresh=self._master):
if val['id'] == self._id:
data = val
except KeyError:
_LOGGER.error("Update failed from Honeywell server")
self.client.user_data = None
return
except StopIteration:
_LOGGER.error("Did not receive any temperature data from the "
"evohomeclient API")
return
self._current_temperature = data['temp']
self._target_temperature = data['setpoint']
if data['thermostat'] == 'DOMESTIC_HOT_WATER':
self._name = 'Hot Water'
self._is_dhw = True
else:
self._name = data['name']
self._is_dhw = False
# The underlying library doesn't expose the thermostat's mode
# but we can pull it out of the big dictionary of information.
device = self.client.devices[self._id]
self.client.system_mode = device[
'thermostat']['changeableValues']['mode']
class HoneywellUSThermostat(ClimateDevice):
"""Representation of a Honeywell US Thermostat."""
def __init__(self, client, device, cool_away_temp,
heat_away_temp, username, password):
"""Initialize the thermostat."""
self._client = client
self._device = device
self._cool_away_temp = cool_away_temp
self._heat_away_temp = heat_away_temp
self._away = False
self._username = username
self._password = password
@property
def supported_features(self):
"""Return the list of supported features."""
supported = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE)
if hasattr(self._device, ATTR_SYSTEM_MODE):
supported |= SUPPORT_OPERATION_MODE
return supported
@property
def is_fan_on(self):
"""Return true if fan is on."""
return self._device.fan_running
@property
def name(self):
"""Return the name of the honeywell, if any."""
return self._device.name
@property
def temperature_unit(self):
"""Return the unit of measurement."""
return (TEMP_CELSIUS if self._device.temperature_unit == 'C'
else TEMP_FAHRENHEIT)
@property
def current_temperature(self):
"""Return the current temperature."""
return self._device.current_temperature
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
if self._device.system_mode == 'cool':
return self._device.setpoint_cool
return self._device.setpoint_heat
@property
def current_operation(self) -> str:
"""Return current operation ie. heat, cool, idle."""
oper = getattr(self._device, ATTR_CURRENT_OPERATION, None)
if oper == "off":
oper = "idle"
return oper
def set_temperature(self, **kwargs):
"""Set target temperature."""
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None:
return
import somecomfort
try:
# Get current mode
mode = self._device.system_mode
# Set hold if this is not the case
if getattr(self._device, "hold_{}".format(mode)) is False:
# Get next period key
next_period_key = '{}NextPeriod'.format(mode.capitalize())
# Get next period raw value
next_period = self._device.raw_ui_data.get(next_period_key)
# Get next period time
hour, minute = divmod(next_period * 15, 60)
# Set hold time
setattr(self._device,
"hold_{}".format(mode),
datetime.time(hour, minute))
# Set temperature
setattr(self._device,
"setpoint_{}".format(mode),
temperature)
except somecomfort.SomeComfortError:
_LOGGER.error("Temperature %.1f out of range", temperature)
@property
def device_state_attributes(self):
"""Return the device specific state attributes."""
import somecomfort
data = {
ATTR_FAN: (self.is_fan_on and 'running' or 'idle'),
ATTR_FAN_MODE: self._device.fan_mode,
ATTR_OPERATION_MODE: self._device.system_mode,
}
data[ATTR_FAN_LIST] = somecomfort.FAN_MODES
data[ATTR_OPERATION_LIST] = somecomfort.SYSTEM_MODES
return data
@property
def is_away_mode_on(self):
"""Return true if away mode is on."""
return self._away
def turn_away_mode_on(self):
"""Turn away on.
Somecomfort does have a proprietary away mode, but it doesn't really
work the way it should. For example: If you set a temperature manually
it doesn't get overwritten when away mode is switched on.
"""
self._away = True
import somecomfort
try:
# Get current mode
mode = self._device.system_mode
except somecomfort.SomeComfortError:
_LOGGER.error('Can not get system mode')
return
try:
# Set permanent hold
setattr(self._device,
"hold_{}".format(mode),
True)
# Set temperature
setattr(self._device,
"setpoint_{}".format(mode),
getattr(self, "_{}_away_temp".format(mode)))
except somecomfort.SomeComfortError:
_LOGGER.error('Temperature %.1f out of range',
getattr(self, "_{}_away_temp".format(mode)))
def turn_away_mode_off(self):
"""Turn away off."""
self._away = False
import somecomfort
try:
# Disabling all hold modes
self._device.hold_cool = False
self._device.hold_heat = False
except somecomfort.SomeComfortError:
_LOGGER.error('Can not stop hold mode')
def set_operation_mode(self, operation_mode: str) -> None:
"""Set the system mode (Cool, Heat, etc)."""
if hasattr(self._device, ATTR_SYSTEM_MODE):
self._device.system_mode = operation_mode
def update(self):
"""Update the state."""
import somecomfort
retries = 3
while retries > 0:
try:
self._device.refresh()
break
except (somecomfort.client.APIRateLimited, OSError,
requests.exceptions.ReadTimeout) as exp:
retries -= 1
if retries == 0:
raise exp
if not self._retry():
raise exp
_LOGGER.error(
"SomeComfort update failed, Retrying - Error: %s", exp)
def _retry(self):
"""Recreate a new somecomfort client.
When we got an error, the best way to be sure that the next query
will succeed, is to recreate a new somecomfort client.
"""
import somecomfort
try:
self._client = somecomfort.SomeComfort(
self._username, self._password)
except somecomfort.AuthError:
_LOGGER.error("Failed to login to honeywell account %s",
self._username)
return False
except somecomfort.SomeComfortError as ex:
_LOGGER.error("Failed to initialize honeywell client: %s",
str(ex))
return False
devices = [device
for location in self._client.locations_by_id.values()
for device in location.devices_by_id.values()
if device.name == self._device.name]
if len(devices) != 1:
_LOGGER.error("Failed to find device %s", self._device.name)
return False
self._device = devices[0]
return True