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