From c91d52a5875818a794f6710f394ab8bd80203bdf Mon Sep 17 00:00:00 2001 From: Derek Brooks <derek@broox.com> Date: Sat, 11 Nov 2017 00:22:37 -0600 Subject: [PATCH] first stab at the nuheat components --- homeassistant/components/climate/nuheat.py | 208 +++++++++++++++++++++ homeassistant/components/nuheat.py | 47 +++++ requirements_all.txt | 3 + tests/components/climate/test_nuheat.py | 187 ++++++++++++++++++ 4 files changed, 445 insertions(+) create mode 100644 homeassistant/components/climate/nuheat.py create mode 100644 homeassistant/components/nuheat.py create mode 100644 tests/components/climate/test_nuheat.py diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py new file mode 100644 index 00000000000..60a253e9b7c --- /dev/null +++ b/homeassistant/components/climate/nuheat.py @@ -0,0 +1,208 @@ +""" +Support for NuHeat thermostats. + +For more details about this platform, please refer to the documentation at +""" +import logging +from datetime import timedelta + +from homeassistant.components.climate import ( + ClimateDevice, + STATE_HEAT, + STATE_IDLE) +from homeassistant.const import ( + ATTR_TEMPERATURE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT) +from homeassistant.util import Throttle + +DEPENDENCIES = ["nuheat"] + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + +MODE_AUTO = "auto" # Run device schedule +MODE_AWAY = "away" +MODE_HOLD_TEMPERATURE = "temperature" +MODE_TEMPORARY_HOLD = "temporary_temperature" +# TODO: offline? + +OPERATION_LIST = [STATE_HEAT, STATE_IDLE] + +SCHEDULE_HOLD = 3 +SCHEDULE_RUN = 1 +SCHEDULE_TEMPORARY_HOLD = 2 + + +def setup_platform(hass, config, add_devices, discovery_info=None): + if discovery_info is None: + return + + _LOGGER.info("Loading NuHeat thermostat component") + + temperature_unit = hass.config.units.temperature_unit + _LOGGER.debug("temp_unit is %s", temperature_unit) + api, serial_numbers = hass.data[DATA_NUHEAT] + + thermostats = [ + NuHeatThermostat(api, serial_number, temperature_unit) + for serial_number in serial_numbers + ] + add_devices(thermostats, True) + + +class NuHeatThermostat(ClimateDevice): + """Representation of a NuHeat Thermostat.""" + def __init__(self, api, serial_number, temperature_unit): + self._thermostat = api.get_thermostat(serial_number) + self._temperature_unit = temperature_unit + self._force_update = False + + @property + def name(self): + """Return the name of the thermostat.""" + return self._thermostat.room + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + if self._temperature_unit == "C": + return TEMP_CELSIUS + + return TEMP_FAHRENHEIT + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._temperature_unit == "C": + return self._thermostat.celsius + + return self._thermostat.fahrenheit + + @property + def current_operation(self): + """Return current operation. ie. heat, idle.""" + if self._thermostat.heating: + return STATE_HEAT + + return STATE_IDLE + + @property + def min_temp(self): + """Return the minimum supported temperature for the thermostat.""" + if self._temperature_unit == "C": + return self._thermostat.min_celsius + + return self._thermostat.min_fahrenheit + + @property + def max_temp(self): + """Return the maximum supported temperature for the thermostat.""" + if self._temperature_unit == "C": + return self._thermostat.max_celsius + + return self._thermostat.max_fahrenheit + + @property + def target_temperature(self): + """Return the currently programmed temperature.""" + if self._temperature_unit == "C": + return self._thermostat.target_celsius + + return self._thermostat.target_fahrenheit + + @property + def target_temperature_low(self): + """Return the lower bound temperature we try to reach.""" + return self.target_temperature + + @property + def target_temperature_high(self): + """Return the upper bound temperature we try to reach.""" + return self.target_temperature + + @property + def current_hold_mode(self): + """Return current hold mode.""" + if self.is_away_mode_on: + return MODE_AWAY + + schedule_mode = self._thermostat.schedule_mode + if schedule_mode == SCHEDULE_RUN: + return MODE_AUTO + + if schedule_mode == SCHEDULE_HOLD: + return MODE_HOLD_TEMPERATURE + + if schedule_mode == SCHEDULE_TEMPORARY_HOLD: + return MODE_TEMPORARY_HOLD + + return MODE_AUTO + + @property + def operation_list(self): + """Return list of possible operation modes.""" + return OPERATION_LIST + + @property + def is_away_mode_on(self): + """ + Return true if away mode is on. + + Away mode is determined by setting and HOLDing the target temperature + to the minimum temperature supported. + """ + if self._thermostat.target_celsius > self._thermostat.min_celsius: + return False + + if self._thermostat.schedule_mode != SCHEDULE_HOLD: + return False + + return True + + def turn_away_mode_on(self): + """Turn away mode on.""" + if self.is_away_mode_on: + return + + kwargs = {} + kwargs[ATTR_TEMPERATURE] = self.min_temp + + self.set_temperature(**kwargs) + self._force_update = True + + def turn_away_mode_off(self): + """Turn away mode off.""" + if not self.is_away_mode_on: + return + + self._thermostat.resume_schedule() + self._force_update = True + + def set_temperature(self, **kwargs): + """Set a new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if self._temperature_unit == "C": + self._thermostat.target_celsius = temperature + else: + self._thermostat.target_fahrenheit = temperature + + _LOGGER.info( + "Setting NuHeat thermostat temperature to %s %s", + temperature, self.temperature_unit) + + self._force_update = True + + def update(self): + """Get the latest state from the thermostat.""" + if self._force_update: + self._throttled_update(no_throttle=True) + self._force_update = False + else: + self._throttled_update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _throttled_update(self): + """Get the latest state from the thermostat... but throttled!""" + self._thermostat.get_data() diff --git a/homeassistant/components/nuheat.py b/homeassistant/components/nuheat.py new file mode 100644 index 00000000000..969afe1ee48 --- /dev/null +++ b/homeassistant/components/nuheat.py @@ -0,0 +1,47 @@ +""" +Support for NuHeat thermostats. + +For more details about this platform, please refer to the documentation at +""" +import logging + +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_DEVICES +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery + +REQUIREMENTS = ["nuheat==0.2.0"] + +_LOGGER = logging.getLogger(__name__) + +DATA_NUHEAT = "nuheat" + +DOMAIN = "nuheat" + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, cv.string) + }), +}, extra=vol.ALLOW_EXTRA) + +def setup(hass, config): + """Set up the NuHeat thermostat component.""" + import nuheat + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + devices = conf.get(CONF_DEVICES) + + api = nuheat.NuHeat(username, password) + api.authenticate() + hass.data[DATA_NUHEAT] = (api, devices) + + discovery.load_platform(hass, "climate", DOMAIN, {}, config) + _LOGGER.debug("NuHeat initialized") + return True diff --git a/requirements_all.txt b/requirements_all.txt index dec8f96f39a..ef8c952f485 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -484,6 +484,9 @@ neurio==0.3.1 # homeassistant.components.sensor.nederlandse_spoorwegen nsapi==2.7.4 +# homeassistant.components.nuheat +nuheat==0.2.0 + # homeassistant.components.binary_sensor.trend # homeassistant.components.image_processing.opencv numpy==1.13.3 diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py new file mode 100644 index 00000000000..33a1c3e02f7 --- /dev/null +++ b/tests/components/climate/test_nuheat.py @@ -0,0 +1,187 @@ +"""The test for the NuHeat thermostat module.""" +import unittest +from unittest.mock import PropertyMock, Mock, patch + +from homeassistant.components.climate import STATE_HEAT, STATE_IDLE +import homeassistant.components.climate.nuheat as nuheat +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT + +SCHEDULE_HOLD = 3 +SCHEDULE_RUN = 1 +SCHEDULE_TEMPORARY_HOLD = 2 + + +class TestNuHeat(unittest.TestCase): + """Tests for NuHeat climate.""" + + def setUp(self): + + serial_number = "12345" + temperature_unit = "F" + + thermostat = Mock( + serial_number=serial_number, + room="Master bathroom", + online=True, + heating=True, + temperature=2222, + celsius=22, + fahrenheit=72, + max_celsius=69, + max_fahrenheit=157, + min_celsius=5, + min_fahrenheit=41, + schedule_mode=SCHEDULE_RUN, + target_celsius=22, + target_fahrenheit=72) + + api = Mock() + api.get_thermostat.return_value = thermostat + + self.thermostat = nuheat.NuHeatThermostat( + api, serial_number, temperature_unit) + + def test_name(self): + """Test name property.""" + self.assertEqual(self.thermostat.name, "Master bathroom") + + def test_temperature_unit(self): + """Test temperature unit.""" + self.assertEqual(self.thermostat.temperature_unit, TEMP_FAHRENHEIT) + + self.thermostat._temperature_unit = "C" + self.assertEqual(self.thermostat.temperature_unit, TEMP_CELSIUS) + + def test_current_temperature(self): + """Test current temperature.""" + self.assertEqual(self.thermostat.current_temperature, 72) + + self.thermostat._temperature_unit = "C" + self.assertEqual(self.thermostat.current_temperature, 22) + + def test_current_operation(self): + """Test current operation.""" + self.assertEqual(self.thermostat.current_operation, STATE_HEAT) + + self.thermostat._thermostat.heating = False + self.assertEqual(self.thermostat.current_operation, STATE_IDLE) + + def test_min_temp(self): + """Test min temp.""" + self.assertEqual(self.thermostat.min_temp, 41) + + self.thermostat._temperature_unit = "C" + self.assertEqual(self.thermostat.min_temp, 5) + + def test_max_temp(self): + """Test max temp.""" + self.assertEqual(self.thermostat.max_temp, 157) + + self.thermostat._temperature_unit = "C" + self.assertEqual(self.thermostat.max_temp, 69) + + def test_target_temperature(self): + """Test target temperature.""" + self.assertEqual(self.thermostat.target_temperature, 72) + + self.thermostat._temperature_unit = "C" + self.assertEqual(self.thermostat.target_temperature, 22) + + + def test_target_temperature_low(self): + """Test low target temperature.""" + self.assertEqual(self.thermostat.target_temperature_low, 72) + + self.thermostat._temperature_unit = "C" + self.assertEqual(self.thermostat.target_temperature_low, 22) + + def test_target_temperature_high(self): + """Test high target temperature.""" + self.assertEqual(self.thermostat.target_temperature_high, 72) + + self.thermostat._temperature_unit = "C" + self.assertEqual(self.thermostat.target_temperature_high, 22) + + @patch.object( + nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) + def test_current_hold_mode_away(self, is_away_mode_on): + """Test current hold mode while away.""" + is_away_mode_on.return_value = True + self.assertEqual(self.thermostat.current_hold_mode, nuheat.MODE_AWAY) + + @patch.object( + nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) + def test_current_hold_mode(self, is_away_mode_on): + """Test current hold mode.""" + is_away_mode_on.return_value = False + + self.thermostat._thermostat.schedule_mode = SCHEDULE_RUN + self.assertEqual(self.thermostat.current_hold_mode, nuheat.MODE_AUTO) + + self.thermostat._thermostat.schedule_mode = SCHEDULE_HOLD + self.assertEqual( + self.thermostat.current_hold_mode, nuheat.MODE_HOLD_TEMPERATURE) + + self.thermostat._thermostat.schedule_mode = SCHEDULE_TEMPORARY_HOLD + self.assertEqual( + self.thermostat.current_hold_mode, nuheat.MODE_TEMPORARY_HOLD) + + def test_is_away_mode_on(self): + """Test is away mode on.""" + _thermostat = self.thermostat._thermostat + _thermostat.target_celsius = _thermostat.min_celsius + _thermostat.schedule_mode = SCHEDULE_HOLD + self.assertTrue(self.thermostat.is_away_mode_on) + + _thermostat.target_celsius = _thermostat.min_celsius + 1 + self.assertFalse(self.thermostat.is_away_mode_on) + + _thermostat.target_celsius = _thermostat.min_celsius + _thermostat.schedule_mode = SCHEDULE_RUN + self.assertFalse(self.thermostat.is_away_mode_on) + + @patch.object( + nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) + @patch.object(nuheat.NuHeatThermostat, "set_temperature") + def test_turn_away_mode_on_while_home(self, set_temp, is_away_mode_on): + """Test turn away mode on when not away.""" + is_away_mode_on.return_value = False + self.thermostat.turn_away_mode_on() + set_temp.assert_called_once_with(temperature=self.thermostat.min_temp) + self.assertTrue(self.thermostat._force_update) + + @patch.object( + nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) + @patch.object(nuheat.NuHeatThermostat, "set_temperature") + def test_turn_away_mode_on_while_away(self, set_temp, is_away_mode_on): + """Test turn away mode on when away.""" + is_away_mode_on.return_value = True + self.thermostat.turn_away_mode_on() + set_temp.assert_not_called() + + def test_set_temperature(self): + """Test set temperature.""" + self.thermostat.set_temperature(temperature=85) + self.assertEqual(self.thermostat._thermostat.target_fahrenheit, 85) + self.assertTrue(self.thermostat._force_update) + + self.thermostat._temperature_unit = "C" + self.thermostat.set_temperature(temperature=23) + self.assertEqual(self.thermostat._thermostat.target_celsius, 23) + self.assertTrue(self.thermostat._force_update) + + @patch.object(nuheat.NuHeatThermostat, "_throttled_update") + def test_forced_update(self, throttled_update): + """Test update without throttle.""" + self.thermostat._force_update = True + self.thermostat.update() + throttled_update.assert_called_once_with(no_throttle=True) + self.assertFalse(self.thermostat._force_update) + + @patch.object(nuheat.NuHeatThermostat, "_throttled_update") + def test_throttled_update(self, throttled_update): + """Test update with throttle.""" + self.thermostat._force_update = False + self.thermostat.update() + throttled_update.assert_called_once_with() + self.assertFalse(self.thermostat._force_update) -- GitLab