Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 144 additions & 20 deletions homeassistant/components/climate/honeywell.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@
"""
import logging
import socket
import datetime

import voluptuous as vol
import requests

from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA)
from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA,
ATTR_FAN_MODE, ATTR_FAN_LIST,
ATTR_OPERATION_MODE,
ATTR_OPERATION_LIST)
from homeassistant.const import (
CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT,
ATTR_TEMPERATURE)
Expand All @@ -21,27 +26,35 @@
_LOGGER = logging.getLogger(__name__)

ATTR_FAN = 'fan'
ATTR_FANMODE = 'fanmode'
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'
CONF_REGION = 'region'

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_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_devices, discovery_info=None):
"""Setup the HoneywelL thermostat."""
"""Setup the Honeywell thermostat."""
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
region = config.get(CONF_REGION)
Expand Down Expand Up @@ -88,8 +101,11 @@ def _setup_us(username, password, config, add_devices):

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_devices([HoneywellUSThermostat(client, device)
add_devices([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
Expand Down Expand Up @@ -160,7 +176,7 @@ def set_operation_mode(self: ClimateDevice, operation_mode: str) -> None:
def turn_away_mode_on(self):
"""Turn away on.

Evohome does have a proprietary away mode, but it doesn't really work
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.
"""
Expand Down Expand Up @@ -199,10 +215,16 @@ def update(self):
class HoneywellUSThermostat(ClimateDevice):
"""Representation of a Honeywell US Thermostat."""

def __init__(self, client, device):
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 is_fan_on(self):
Expand Down Expand Up @@ -236,7 +258,10 @@ def target_temperature(self):
@property
def current_operation(self: ClimateDevice) -> str:
"""Return current operation ie. heat, cool, idle."""
return getattr(self._device, ATTR_SYSTEM_MODE, None)
oper = getattr(self._device, ATTR_CURRENT_OPERATION, None)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this do I/O inside the event loop? (which is not allowed)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we need to run self._device.refresh() to update this value

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And I'm confirming it, using tcpdump

if oper == "off":
oper = "idle"
return oper

def set_temperature(self, **kwargs):
"""Set target temperature."""
Expand All @@ -245,29 +270,84 @@ def set_temperature(self, **kwargs):
return
import somecomfort
try:
if self._device.system_mode == 'cool':
self._device.setpoint_cool = temperature
else:
self._device.setpoint_heat = temperature
# 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."""
return {
import somecomfort
data = {
ATTR_FAN: (self.is_fan_on and 'running' or 'idle'),
ATTR_FANMODE: self._device.fan_mode,
ATTR_SYSTEM_MODE: self._device.system_mode,
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."""
pass
"""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."""
pass
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: ClimateDevice, operation_mode: str) -> None:
"""Set the system mode (Cool, Heat, etc)."""
Expand All @@ -276,4 +356,48 @@ def set_operation_mode(self: ClimateDevice, operation_mode: str) -> None:

def update(self):
"""Update the state."""
self._device.refresh()
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]
57 changes: 47 additions & 10 deletions tests/components/climate/test_honeywell.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

from homeassistant.const import (
CONF_USERNAME, CONF_PASSWORD, TEMP_CELSIUS, TEMP_FAHRENHEIT)
from homeassistant.components.climate import (
ATTR_FAN_MODE, ATTR_OPERATION_MODE, ATTR_FAN_LIST, ATTR_OPERATION_LIST)

import homeassistant.components.climate.honeywell as honeywell


Expand All @@ -22,15 +25,21 @@ def test_setup_us(self, mock_ht, mock_sc):
config = {
CONF_USERNAME: 'user',
CONF_PASSWORD: 'pass',
honeywell.CONF_COOL_AWAY_TEMPERATURE: 18,
honeywell.CONF_HEAT_AWAY_TEMPERATURE: 28,
honeywell.CONF_REGION: 'us',
}
bad_pass_config = {
CONF_USERNAME: 'user',
honeywell.CONF_COOL_AWAY_TEMPERATURE: 18,
honeywell.CONF_HEAT_AWAY_TEMPERATURE: 28,
honeywell.CONF_REGION: 'us',
}
bad_region_config = {
CONF_USERNAME: 'user',
CONF_PASSWORD: 'pass',
honeywell.CONF_COOL_AWAY_TEMPERATURE: 18,
honeywell.CONF_HEAT_AWAY_TEMPERATURE: 28,
honeywell.CONF_REGION: 'un',
}

Expand Down Expand Up @@ -65,9 +74,12 @@ def test_setup_us(self, mock_ht, mock_sc):
self.assertEqual(mock_sc.call_count, 1)
self.assertEqual(mock_sc.call_args, mock.call('user', 'pass'))
mock_ht.assert_has_calls([
mock.call(mock_sc.return_value, devices_1[0]),
mock.call(mock_sc.return_value, devices_2[0]),
mock.call(mock_sc.return_value, devices_2[1]),
mock.call(mock_sc.return_value, devices_1[0], 18, 28,
'user', 'pass'),
mock.call(mock_sc.return_value, devices_2[0], 18, 28,
'user', 'pass'),
mock.call(mock_sc.return_value, devices_2[1], 18, 28,
'user', 'pass'),
])

@mock.patch('somecomfort.SomeComfort')
Expand Down Expand Up @@ -324,8 +336,12 @@ def setup_method(self, method):
"""Test the setup method."""
self.client = mock.MagicMock()
self.device = mock.MagicMock()
self.cool_away_temp = 18
self.heat_away_temp = 28
self.honeywell = honeywell.HoneywellUSThermostat(
self.client, self.device)
self.client, self.device,
self.cool_away_temp, self.heat_away_temp,
'user', 'password')

self.device.fan_running = True
self.device.name = 'test'
Expand Down Expand Up @@ -369,11 +385,9 @@ def test_set_temp(self):
def test_set_operation_mode(self: unittest.TestCase) -> None:
"""Test setting the operation mode."""
self.honeywell.set_operation_mode('cool')
self.assertEqual('cool', self.honeywell.current_operation)
self.assertEqual('cool', self.device.system_mode)

self.honeywell.set_operation_mode('heat')
self.assertEqual('heat', self.honeywell.current_operation)
self.assertEqual('heat', self.device.system_mode)

def test_set_temp_fail(self):
Expand All @@ -386,8 +400,10 @@ def test_attributes(self):
"""Test the attributes."""
expected = {
honeywell.ATTR_FAN: 'running',
honeywell.ATTR_FANMODE: 'auto',
honeywell.ATTR_SYSTEM_MODE: 'heat',
ATTR_FAN_MODE: 'auto',
ATTR_OPERATION_MODE: 'heat',
ATTR_FAN_LIST: somecomfort.FAN_MODES,
ATTR_OPERATION_LIST: somecomfort.SYSTEM_MODES,
}
self.assertEqual(expected, self.honeywell.device_state_attributes)
expected['fan'] = 'idle'
Expand All @@ -400,7 +416,28 @@ def test_with_no_fan(self):
self.device.fan_mode = None
expected = {
honeywell.ATTR_FAN: 'idle',
honeywell.ATTR_FANMODE: None,
honeywell.ATTR_SYSTEM_MODE: 'heat',
ATTR_FAN_MODE: None,
ATTR_OPERATION_MODE: 'heat',
ATTR_FAN_LIST: somecomfort.FAN_MODES,
ATTR_OPERATION_LIST: somecomfort.SYSTEM_MODES,
}
self.assertEqual(expected, self.honeywell.device_state_attributes)

def test_heat_away_mode(self):
"""Test setting the heat away mode."""
self.honeywell.set_operation_mode('heat')
self.assertFalse(self.honeywell.is_away_mode_on)
self.honeywell.turn_away_mode_on()
self.assertTrue(self.honeywell.is_away_mode_on)
self.assertEqual(self.device.setpoint_heat, self.heat_away_temp)
self.assertEqual(self.device.hold_heat, True)

self.honeywell.turn_away_mode_off()
self.assertFalse(self.honeywell.is_away_mode_on)
self.assertEqual(self.device.hold_heat, False)

def test_retry(self):
"""Test retry connection."""
old_device = self.honeywell._device
self.honeywell._retry()
self.assertEqual(self.honeywell._device, old_device)