diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py new file mode 100644 index 00000000000..a62a684299d --- /dev/null +++ b/homeassistant/components/climate/nuheat.py @@ -0,0 +1,233 @@ +""" +Support for NuHeat thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.nuheat/ +""" +import logging +from datetime import timedelta +from os import path + +import voluptuous as vol + +from homeassistant.components.climate import ( + ClimateDevice, + DOMAIN, + SUPPORT_HOLD_MODE, + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + STATE_AUTO, + STATE_HEAT, + STATE_IDLE) +from homeassistant.components.nuheat import DOMAIN as NUHEAT_DOMAIN +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +DEPENDENCIES = ["nuheat"] + +_LOGGER = logging.getLogger(__name__) + +ICON = "mdi:thermometer" + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + +# Hold modes +MODE_AUTO = STATE_AUTO # Run device schedule +MODE_HOLD_TEMPERATURE = "temperature" +MODE_TEMPORARY_HOLD = "temporary_temperature" + +OPERATION_LIST = [STATE_HEAT, STATE_IDLE] + +SCHEDULE_HOLD = 3 +SCHEDULE_RUN = 1 +SCHEDULE_TEMPORARY_HOLD = 2 + +SERVICE_RESUME_PROGRAM = "nuheat_resume_program" + +RESUME_PROGRAM_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids +}) + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_HOLD_MODE | + SUPPORT_OPERATION_MODE) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the NuHeat thermostat(s).""" + if discovery_info is None: + return + + temperature_unit = hass.config.units.temperature_unit + api, serial_numbers = hass.data[NUHEAT_DOMAIN] + thermostats = [ + NuHeatThermostat(api, serial_number, temperature_unit) + for serial_number in serial_numbers + ] + add_devices(thermostats, True) + + def resume_program_set_service(service): + """Resume the program on the target thermostats.""" + entity_id = service.data.get(ATTR_ENTITY_ID) + if entity_id: + target_thermostats = [device for device in thermostats + if device.entity_id in entity_id] + else: + target_thermostats = thermostats + + for thermostat in target_thermostats: + thermostat.resume_program() + + thermostat.schedule_update_ha_state(True) + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), "services.yaml")) + + hass.services.register( + DOMAIN, SERVICE_RESUME_PROGRAM, resume_program_set_service, + descriptions.get(SERVICE_RESUME_PROGRAM), + schema=RESUME_PROGRAM_SCHEMA) + + +class NuHeatThermostat(ClimateDevice): + """Representation of a NuHeat Thermostat.""" + + def __init__(self, api, serial_number, temperature_unit): + """Initialize the thermostat.""" + 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 icon(self): + """Return the icon to use in the frontend.""" + return ICON + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @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 current_hold_mode(self): + """Return current hold mode.""" + 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 + + def resume_program(self): + """Resume the thermostat's programmed schedule.""" + self._thermostat.resume_schedule() + self._force_update = True + + def set_hold_mode(self, hold_mode, **kwargs): + """Update the hold mode of the thermostat.""" + if hold_mode == MODE_AUTO: + schedule_mode = SCHEDULE_RUN + + if hold_mode == MODE_HOLD_TEMPERATURE: + schedule_mode = SCHEDULE_HOLD + + if hold_mode == MODE_TEMPORARY_HOLD: + schedule_mode = SCHEDULE_TEMPORARY_HOLD + + self._thermostat.schedule_mode = schedule_mode + 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.debug( + "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, **kwargs): + """Get the latest state from the thermostat with a throttle.""" + self._thermostat.get_data() diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 193c5107575..5d7f30d252d 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -100,3 +100,10 @@ ecobee_resume_program: resume_all: description: Resume all events and return to the scheduled program. This default to false which removes only the top event. example: true + +nuheat_resume_program: + description: Resume the programmed schedule. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' diff --git a/homeassistant/components/nuheat.py b/homeassistant/components/nuheat.py new file mode 100644 index 00000000000..fb14f119dbd --- /dev/null +++ b/homeassistant/components/nuheat.py @@ -0,0 +1,45 @@ +""" +Support for NuHeat thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/nuheat/ +""" +import logging + +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.3.0"] + +_LOGGER = logging.getLogger(__name__) + +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, default=[]): + 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[DOMAIN] = (api, devices) + + discovery.load_platform(hass, "climate", DOMAIN, {}, config) + return True diff --git a/requirements_all.txt b/requirements_all.txt index 86bb34a5ce1..1f8a0cb9de4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -500,6 +500,9 @@ neurio==0.3.1 # homeassistant.components.sensor.nederlandse_spoorwegen nsapi==2.7.4 +# homeassistant.components.nuheat +nuheat==0.3.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..6ec63646bec --- /dev/null +++ b/tests/components/climate/test_nuheat.py @@ -0,0 +1,232 @@ +"""The test for the NuHeat thermostat module.""" +import unittest +from unittest.mock import Mock, patch +from tests.common import get_test_home_assistant + +from homeassistant.components.climate import ( + SUPPORT_HOLD_MODE, + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + 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.""" + + # pylint: disable=protected-access, no-self-use + + def setUp(self): # pylint: disable=invalid-name + """Set up test variables.""" + 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) + + thermostat.get_data = Mock() + thermostat.resume_schedule = Mock() + + self.api = Mock() + self.api.get_thermostat.return_value = thermostat + + self.hass = get_test_home_assistant() + self.thermostat = nuheat.NuHeatThermostat( + self.api, serial_number, temperature_unit) + + def tearDown(self): # pylint: disable=invalid-name + """Stop hass.""" + self.hass.stop() + + @patch("homeassistant.components.climate.nuheat.NuHeatThermostat") + def test_setup_platform(self, mocked_thermostat): + """Test setup_platform.""" + mocked_thermostat.return_value = self.thermostat + thermostat = mocked_thermostat(self.api, "12345", "F") + thermostats = [thermostat] + + self.hass.data[nuheat.NUHEAT_DOMAIN] = (self.api, ["12345"]) + + config = {} + add_devices = Mock() + discovery_info = {} + + nuheat.setup_platform(self.hass, config, add_devices, discovery_info) + add_devices.assert_called_once_with(thermostats, True) + + @patch("homeassistant.components.climate.nuheat.NuHeatThermostat") + def test_resume_program_service(self, mocked_thermostat): + """Test resume program service.""" + mocked_thermostat.return_value = self.thermostat + thermostat = mocked_thermostat(self.api, "12345", "F") + thermostat.resume_program = Mock() + thermostat.schedule_update_ha_state = Mock() + thermostat.entity_id = "climate.master_bathroom" + + self.hass.data[nuheat.NUHEAT_DOMAIN] = (self.api, ["12345"]) + nuheat.setup_platform(self.hass, {}, Mock(), {}) + + # Explicit entity + self.hass.services.call(nuheat.DOMAIN, nuheat.SERVICE_RESUME_PROGRAM, + {"entity_id": "climate.master_bathroom"}, True) + + thermostat.resume_program.assert_called_with() + thermostat.schedule_update_ha_state.assert_called_with(True) + + thermostat.resume_program.reset_mock() + thermostat.schedule_update_ha_state.reset_mock() + + # All entities + self.hass.services.call( + nuheat.DOMAIN, nuheat.SERVICE_RESUME_PROGRAM, {}, True) + + thermostat.resume_program.assert_called_with() + thermostat.schedule_update_ha_state.assert_called_with(True) + + def test_name(self): + """Test name property.""" + self.assertEqual(self.thermostat.name, "Master bathroom") + + def test_icon(self): + """Test name property.""" + self.assertEqual(self.thermostat.icon, "mdi:thermometer") + + def test_supported_features(self): + """Test name property.""" + features = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_HOLD_MODE | + SUPPORT_OPERATION_MODE) + self.assertEqual(self.thermostat.supported_features, features) + + 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_current_hold_mode(self): + """Test current hold mode.""" + 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) + + self.thermostat._thermostat.schedule_mode = None + self.assertEqual( + self.thermostat.current_hold_mode, nuheat.MODE_AUTO) + + def test_operation_list(self): + """Test the operation list.""" + self.assertEqual( + self.thermostat.operation_list, + [STATE_HEAT, STATE_IDLE] + ) + + def test_resume_program(self): + """Test resume schedule.""" + self.thermostat.resume_program() + self.thermostat._thermostat.resume_schedule.assert_called_once_with() + self.assertTrue(self.thermostat._force_update) + + def test_set_hold_mode(self): + """Test set hold mode.""" + self.thermostat.set_hold_mode("temperature") + self.assertEqual( + self.thermostat._thermostat.schedule_mode, SCHEDULE_HOLD) + self.assertTrue(self.thermostat._force_update) + + self.thermostat.set_hold_mode("temporary_temperature") + self.assertEqual( + self.thermostat._thermostat.schedule_mode, SCHEDULE_TEMPORARY_HOLD) + self.assertTrue(self.thermostat._force_update) + + self.thermostat.set_hold_mode("auto") + self.assertEqual( + self.thermostat._thermostat.schedule_mode, SCHEDULE_RUN) + self.assertTrue(self.thermostat._force_update) + + 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_update_without_throttle(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_update_with_throttle(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) + + def test_throttled_update(self): + """Test update with throttle.""" + self.thermostat._throttled_update() + self.thermostat._thermostat.get_data.assert_called_once_with() diff --git a/tests/components/test_nuheat.py b/tests/components/test_nuheat.py new file mode 100644 index 00000000000..91a8b326bf9 --- /dev/null +++ b/tests/components/test_nuheat.py @@ -0,0 +1,46 @@ +"""NuHeat component tests.""" +import unittest + +from unittest.mock import patch +from tests.common import get_test_home_assistant, MockDependency + +from homeassistant.components import nuheat + +VALID_CONFIG = { + "nuheat": { + "username": "warm", + "password": "feet", + "devices": "thermostat123" + } +} + + +class TestNuHeat(unittest.TestCase): + """Test the NuHeat component.""" + + def setUp(self): # pylint: disable=invalid-name + """Initialize the values for this test class.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG + + def tearDown(self): # pylint: disable=invalid-name + """Teardown this test class. Stop hass.""" + self.hass.stop() + + @MockDependency("nuheat") + @patch("homeassistant.helpers.discovery.load_platform") + def test_setup(self, mocked_nuheat, mocked_load): + """Test setting up the NuHeat component.""" + nuheat.setup(self.hass, self.config) + + mocked_nuheat.NuHeat.assert_called_with("warm", "feet") + self.assertIn(nuheat.DOMAIN, self.hass.data) + self.assertEquals(2, len(self.hass.data[nuheat.DOMAIN])) + self.assertIsInstance( + self.hass.data[nuheat.DOMAIN][0], type(mocked_nuheat.NuHeat()) + ) + self.assertEquals(self.hass.data[nuheat.DOMAIN][1], "thermostat123") + + mocked_load.assert_called_with( + self.hass, "climate", nuheat.DOMAIN, {}, self.config + )