From b09a9fc81a3ba9c1f2204899a38dc9c7dfb39935 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Mar 2020 00:29:45 -0500 Subject: [PATCH] Add config flow for Nuheat (#32885) * Modernize nuheat for new climate platform * Home Assistant state now mirrors the state displayed at mynewheat.com * Remove off mode as the device does not implement and setting was not implemented anyways * Implement missing set_hvac_mode for nuheat * Now shows as unavailable when offline * Add a unique id (serial number) * Fix hvac_mode as it was really implementing hvac_action * Presets now map to the open api spec published at https://api.mynuheat.com/swagger/ * ThermostatModel: scheduleMode * Revert test cleanup as it leaves files behind. Its going to be more invasive to modernize the tests so it will have to come in a new pr * Config flow for nuheat * codeowners * Add an import test as well * remove debug --- CODEOWNERS | 1 + .../components/nuheat/.translations/en.json | 25 ++ homeassistant/components/nuheat/__init__.py | 94 +++++- homeassistant/components/nuheat/climate.py | 67 ++--- .../components/nuheat/config_flow.py | 104 +++++++ homeassistant/components/nuheat/const.py | 9 + homeassistant/components/nuheat/manifest.json | 13 +- homeassistant/components/nuheat/services.yaml | 6 - homeassistant/components/nuheat/strings.json | 25 ++ homeassistant/generated/config_flows.py | 1 + tests/components/nuheat/mocks.py | 121 ++++++++ tests/components/nuheat/test_climate.py | 283 +++++++----------- tests/components/nuheat/test_config_flow.py | 163 ++++++++++ tests/components/nuheat/test_init.py | 44 +-- 14 files changed, 683 insertions(+), 273 deletions(-) create mode 100644 homeassistant/components/nuheat/.translations/en.json create mode 100644 homeassistant/components/nuheat/config_flow.py create mode 100644 homeassistant/components/nuheat/const.py delete mode 100644 homeassistant/components/nuheat/services.yaml create mode 100644 homeassistant/components/nuheat/strings.json create mode 100644 tests/components/nuheat/mocks.py create mode 100644 tests/components/nuheat/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index f185059c999..556ce10b18e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -253,6 +253,7 @@ homeassistant/components/notify/* @home-assistant/core homeassistant/components/notion/* @bachya homeassistant/components/nsw_fuel_station/* @nickw444 homeassistant/components/nsw_rural_fire_service_feed/* @exxamalte +homeassistant/components/nuheat/* @bdraco homeassistant/components/nuki/* @pvizeli homeassistant/components/nws/* @MatthewFlamm homeassistant/components/nzbget/* @chriscla diff --git a/homeassistant/components/nuheat/.translations/en.json b/homeassistant/components/nuheat/.translations/en.json new file mode 100644 index 00000000000..4bfbb8ef62a --- /dev/null +++ b/homeassistant/components/nuheat/.translations/en.json @@ -0,0 +1,25 @@ +{ + "config" : { + "error" : { + "unknown" : "Unexpected error", + "cannot_connect" : "Failed to connect, please try again", + "invalid_auth" : "Invalid authentication", + "invalid_thermostat" : "The thermostat serial number is invalid." + }, + "title" : "NuHeat", + "abort" : { + "already_configured" : "The thermostat is already configured" + }, + "step" : { + "user" : { + "title" : "Connect to the NuHeat", + "description": "You will need to obtain your thermostat’s numeric serial number or ID by logging into https://MyNuHeat.com and selecting your thermostat(s).", + "data" : { + "username" : "Username", + "password" : "Password", + "serial_number" : "Serial number of the thermostat." + } + } + } + } +} diff --git a/homeassistant/components/nuheat/__init__.py b/homeassistant/components/nuheat/__init__.py index 88e10270d18..ff90bb26530 100644 --- a/homeassistant/components/nuheat/__init__.py +++ b/homeassistant/components/nuheat/__init__.py @@ -1,16 +1,21 @@ """Support for NuHeat thermostats.""" +import asyncio import logging import nuheat +import requests import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv + +from .const import CONF_SERIAL_NUMBER, DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) -DOMAIN = "nuheat" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -27,16 +32,81 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass, config): - """Set up the NuHeat thermostat component.""" - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - devices = conf.get(CONF_DEVICES) +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the NuHeat component.""" + hass.data.setdefault(DOMAIN, {}) + conf = config.get(DOMAIN) + if not conf: + return True + + for serial_number in conf[CONF_DEVICES]: + # Since the api currently doesn't permit fetching the serial numbers + # and they have to be specified we create a separate config entry for + # each serial number. This won't increase the number of http + # requests as each thermostat has to be updated anyways. + # This also allows us to validate that the entered valid serial + # numbers and do not end up with a config entry where half of the + # devices work. + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_USERNAME: conf[CONF_USERNAME], + CONF_PASSWORD: conf[CONF_PASSWORD], + CONF_SERIAL_NUMBER: serial_number, + }, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up NuHeat from a config entry.""" + + conf = entry.data + + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + serial_number = conf[CONF_SERIAL_NUMBER] api = nuheat.NuHeat(username, password) - api.authenticate() - hass.data[DOMAIN] = (api, devices) - discovery.load_platform(hass, "climate", DOMAIN, {}, config) + try: + await hass.async_add_executor_job(api.authenticate) + except requests.exceptions.Timeout: + raise ConfigEntryNotReady + except requests.exceptions.HTTPError as ex: + if ex.request.status_code > 400 and ex.request.status_code < 500: + _LOGGER.error("Failed to login to nuheat: %s", ex) + return False + raise ConfigEntryNotReady + except Exception as ex: # pylint: disable=broad-except + _LOGGER.error("Failed to login to nuheat: %s", ex) + return False + + hass.data[DOMAIN][entry.entry_id] = (api, serial_number) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index c13e2ab257f..dbd4eed8efb 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -3,7 +3,6 @@ from datetime import timedelta import logging from nuheat.config import SCHEDULE_HOLD, SCHEDULE_RUN, SCHEDULE_TEMPORARY_HOLD -import voluptuous as vol from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( @@ -14,16 +13,10 @@ from homeassistant.components.climate.const import ( SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_TEMPERATURE, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -import homeassistant.helpers.config_validation as cv +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.util import Throttle -from . import DOMAIN +from .const import DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) @@ -49,55 +42,29 @@ SCHEDULE_MODE_TO_PRESET_MODE_MAP = { value: key for key, value in PRESET_MODE_TO_SCHEDULE_MODE_MAP.items() } -SERVICE_RESUME_PROGRAM = "resume_program" - -RESUME_PROGRAM_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) - SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the NuHeat thermostat(s).""" - if discovery_info is None: - return + api, serial_number = hass.data[DOMAIN][config_entry.entry_id] temperature_unit = hass.config.units.temperature_unit - api, serial_numbers = hass.data[DOMAIN] - thermostats = [ - NuHeatThermostat(api, serial_number, temperature_unit) - for serial_number in serial_numbers - ] - add_entities(thermostats, True) + thermostat = await hass.async_add_executor_job(api.get_thermostat, serial_number) + entity = NuHeatThermostat(thermostat, temperature_unit) - 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 + # No longer need a service as set_hvac_mode to auto does this + # since climate 1.0 has been implemented - for thermostat in target_thermostats: - thermostat.resume_program() - - thermostat.schedule_update_ha_state(True) - - hass.services.register( - DOMAIN, - SERVICE_RESUME_PROGRAM, - resume_program_set_service, - schema=RESUME_PROGRAM_SCHEMA, - ) + async_add_entities([entity], True) class NuHeatThermostat(ClimateDevice): """Representation of a NuHeat Thermostat.""" - def __init__(self, api, serial_number, temperature_unit): + def __init__(self, thermostat, temperature_unit): """Initialize the thermostat.""" - self._thermostat = api.get_thermostat(serial_number) + self._thermostat = thermostat self._temperature_unit = temperature_unit self._force_update = False @@ -140,8 +107,9 @@ class NuHeatThermostat(ClimateDevice): def set_hvac_mode(self, hvac_mode): """Set the system mode.""" + # This is the same as what res if hvac_mode == HVAC_MODE_AUTO: - self._thermostat.schedule_mode = SCHEDULE_RUN + self._thermostat.resume_schedule() elif hvac_mode == HVAC_MODE_HEAT: self._thermostat.schedule_mode = SCHEDULE_HOLD @@ -251,3 +219,12 @@ class NuHeatThermostat(ClimateDevice): def _throttled_update(self, **kwargs): """Get the latest state from the thermostat with a throttle.""" self._thermostat.get_data() + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._thermostat.serial_number)}, + "name": self._thermostat.room, + "manufacturer": MANUFACTURER, + } diff --git a/homeassistant/components/nuheat/config_flow.py b/homeassistant/components/nuheat/config_flow.py new file mode 100644 index 00000000000..082cb899ec5 --- /dev/null +++ b/homeassistant/components/nuheat/config_flow.py @@ -0,0 +1,104 @@ +"""Config flow for NuHeat integration.""" +import logging + +import nuheat +import requests.exceptions +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import CONF_SERIAL_NUMBER +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_SERIAL_NUMBER): str, + } +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + api = nuheat.NuHeat(data[CONF_USERNAME], data[CONF_PASSWORD]) + + try: + await hass.async_add_executor_job(api.authenticate) + except requests.exceptions.Timeout: + raise CannotConnect + except requests.exceptions.HTTPError as ex: + if ex.request.status_code > 400 and ex.request.status_code < 500: + raise InvalidAuth + raise CannotConnect + # + # The underlying module throws a generic exception on login failure + # + except Exception: # pylint: disable=broad-except + raise InvalidAuth + + try: + thermostat = await hass.async_add_executor_job( + api.get_thermostat, data[CONF_SERIAL_NUMBER] + ) + except requests.exceptions.HTTPError: + raise InvalidThermostat + + return {"title": thermostat.room, "serial_number": thermostat.serial_number} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for NuHeat.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except InvalidThermostat: + errors["base"] = "invalid_thermostat" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if "base" not in errors: + await self.async_set_unique_id(info["serial_number"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input): + """Handle import.""" + await self.async_set_unique_id(user_input[CONF_SERIAL_NUMBER]) + self._abort_if_unique_id_configured() + + return await self.async_step_user(user_input) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class InvalidThermostat(exceptions.HomeAssistantError): + """Error to indicate there is invalid thermostat.""" diff --git a/homeassistant/components/nuheat/const.py b/homeassistant/components/nuheat/const.py new file mode 100644 index 00000000000..e9465d69275 --- /dev/null +++ b/homeassistant/components/nuheat/const.py @@ -0,0 +1,9 @@ +"""Constants for NuHeat thermostats.""" + +DOMAIN = "nuheat" + +PLATFORMS = ["climate"] + +CONF_SERIAL_NUMBER = "serial_number" + +MANUFACTURER = "NuHeat" diff --git a/homeassistant/components/nuheat/manifest.json b/homeassistant/components/nuheat/manifest.json index fa011443245..ef78870854c 100644 --- a/homeassistant/components/nuheat/manifest.json +++ b/homeassistant/components/nuheat/manifest.json @@ -1,8 +1,9 @@ { - "domain": "nuheat", - "name": "NuHeat", - "documentation": "https://www.home-assistant.io/integrations/nuheat", - "requirements": ["nuheat==0.3.0"], - "dependencies": [], - "codeowners": [] + "domain": "nuheat", + "name": "NuHeat", + "documentation": "https://www.home-assistant.io/integrations/nuheat", + "requirements": ["nuheat==0.3.0"], + "dependencies": [], + "codeowners": ["@bdraco"], + "config_flow": true } diff --git a/homeassistant/components/nuheat/services.yaml b/homeassistant/components/nuheat/services.yaml deleted file mode 100644 index 6639fcd9898..00000000000 --- a/homeassistant/components/nuheat/services.yaml +++ /dev/null @@ -1,6 +0,0 @@ -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/strings.json b/homeassistant/components/nuheat/strings.json new file mode 100644 index 00000000000..4bfbb8ef62a --- /dev/null +++ b/homeassistant/components/nuheat/strings.json @@ -0,0 +1,25 @@ +{ + "config" : { + "error" : { + "unknown" : "Unexpected error", + "cannot_connect" : "Failed to connect, please try again", + "invalid_auth" : "Invalid authentication", + "invalid_thermostat" : "The thermostat serial number is invalid." + }, + "title" : "NuHeat", + "abort" : { + "already_configured" : "The thermostat is already configured" + }, + "step" : { + "user" : { + "title" : "Connect to the NuHeat", + "description": "You will need to obtain your thermostat’s numeric serial number or ID by logging into https://MyNuHeat.com and selecting your thermostat(s).", + "data" : { + "username" : "Username", + "password" : "Password", + "serial_number" : "Serial number of the thermostat." + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9e9084a9349..cc36af05da4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -77,6 +77,7 @@ FLOWS = [ "netatmo", "nexia", "notion", + "nuheat", "opentherm_gw", "openuv", "owntracks", diff --git a/tests/components/nuheat/mocks.py b/tests/components/nuheat/mocks.py new file mode 100644 index 00000000000..7b7c9d1ac06 --- /dev/null +++ b/tests/components/nuheat/mocks.py @@ -0,0 +1,121 @@ +"""The test for the NuHeat thermostat module.""" +from asynctest.mock import MagicMock, Mock +from nuheat.config import SCHEDULE_HOLD, SCHEDULE_RUN, SCHEDULE_TEMPORARY_HOLD + +from homeassistant.components.nuheat.const import DOMAIN +from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME + + +def _get_mock_thermostat_run(): + serial_number = "12345" + 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() + thermostat.schedule_mode = Mock() + return thermostat + + +def _get_mock_thermostat_schedule_hold_unavailable(): + serial_number = "876" + thermostat = Mock( + serial_number=serial_number, + room="Guest bathroom", + online=False, + heating=False, + temperature=12, + celsius=12, + fahrenheit=102, + max_celsius=99, + max_fahrenheit=357, + min_celsius=9, + min_fahrenheit=21, + schedule_mode=SCHEDULE_HOLD, + target_celsius=23, + target_fahrenheit=79, + ) + + thermostat.get_data = Mock() + thermostat.resume_schedule = Mock() + thermostat.schedule_mode = Mock() + return thermostat + + +def _get_mock_thermostat_schedule_hold_available(): + serial_number = "876" + thermostat = Mock( + serial_number=serial_number, + room="Available bathroom", + online=True, + heating=False, + temperature=12, + celsius=12, + fahrenheit=102, + max_celsius=99, + max_fahrenheit=357, + min_celsius=9, + min_fahrenheit=21, + schedule_mode=SCHEDULE_HOLD, + target_celsius=23, + target_fahrenheit=79, + ) + + thermostat.get_data = Mock() + thermostat.resume_schedule = Mock() + thermostat.schedule_mode = Mock() + return thermostat + + +def _get_mock_thermostat_schedule_temporary_hold(): + serial_number = "999" + thermostat = Mock( + serial_number=serial_number, + room="Temp bathroom", + online=True, + heating=False, + temperature=14, + celsius=13, + fahrenheit=202, + max_celsius=39, + max_fahrenheit=357, + min_celsius=3, + min_fahrenheit=31, + schedule_mode=SCHEDULE_TEMPORARY_HOLD, + target_celsius=43, + target_fahrenheit=99, + ) + + thermostat.get_data = Mock() + thermostat.resume_schedule = Mock() + thermostat.schedule_mode = Mock() + return thermostat + + +def _get_mock_nuheat(authenticate=None, get_thermostat=None): + nuheat_mock = MagicMock() + type(nuheat_mock).authenticate = MagicMock() + type(nuheat_mock).get_thermostat = MagicMock(return_value=get_thermostat) + + return nuheat_mock + + +def _mock_get_config(): + """Return a default nuheat config.""" + return { + DOMAIN: {CONF_USERNAME: "me", CONF_PASSWORD: "secret", CONF_DEVICES: [12345]} + } diff --git a/tests/components/nuheat/test_climate.py b/tests/components/nuheat/test_climate.py index af16e3ffc26..7bf52026ef9 100644 --- a/tests/components/nuheat/test_climate.py +++ b/tests/components/nuheat/test_climate.py @@ -1,194 +1,133 @@ """The test for the NuHeat thermostat module.""" -import unittest -from unittest.mock import Mock, patch +from asynctest.mock import patch -from homeassistant.components.climate.const import ( - HVAC_MODE_AUTO, - HVAC_MODE_HEAT, - SUPPORT_PRESET_MODE, - SUPPORT_TARGET_TEMPERATURE, +from homeassistant.components.nuheat.const import DOMAIN +from homeassistant.setup import async_setup_component + +from .mocks import ( + _get_mock_nuheat, + _get_mock_thermostat_run, + _get_mock_thermostat_schedule_hold_available, + _get_mock_thermostat_schedule_hold_unavailable, + _get_mock_thermostat_schedule_temporary_hold, + _mock_get_config, ) -import homeassistant.components.nuheat.climate as nuheat -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT - -from tests.common import get_test_home_assistant - -SCHEDULE_HOLD = 3 -SCHEDULE_RUN = 1 -SCHEDULE_TEMPORARY_HOLD = 2 -class TestNuHeat(unittest.TestCase): - """Tests for NuHeat climate.""" +async def test_climate_thermostat_run(hass): + """Test a thermostat with the schedule running.""" + mock_thermostat = _get_mock_thermostat_run() + mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat) - # pylint: disable=protected-access, no-self-use + with patch( + "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat, + ): + assert await async_setup_component(hass, DOMAIN, _mock_get_config()) + await hass.async_block_till_done() - def setUp(self): # pylint: disable=invalid-name - """Set up test variables.""" - serial_number = "12345" - temperature_unit = "F" + state = hass.states.get("climate.master_bathroom") + assert state.state == "auto" + expected_attributes = { + "current_temperature": 22.2, + "friendly_name": "Master bathroom", + "hvac_action": "heating", + "hvac_modes": ["auto", "heat"], + "max_temp": 69.4, + "min_temp": 5.0, + "preset_mode": "Run Schedule", + "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"], + "supported_features": 17, + "temperature": 22.2, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all(item in state.attributes.items() for item in expected_attributes.items()) - 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() +async def test_climate_thermostat_schedule_hold_unavailable(hass): + """Test a thermostat with the schedule hold that is offline.""" + mock_thermostat = _get_mock_thermostat_schedule_hold_unavailable() + mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat) - self.api = Mock() - self.api.get_thermostat.return_value = thermostat + with patch( + "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat, + ): + assert await async_setup_component(hass, DOMAIN, _mock_get_config()) + await hass.async_block_till_done() - self.hass = get_test_home_assistant() - self.thermostat = nuheat.NuHeatThermostat( - self.api, serial_number, temperature_unit - ) + state = hass.states.get("climate.guest_bathroom") - def tearDown(self): # pylint: disable=invalid-name - """Stop hass.""" - self.hass.stop() + assert state.state == "unavailable" + expected_attributes = { + "friendly_name": "Guest bathroom", + "hvac_modes": ["auto", "heat"], + "max_temp": 180.6, + "min_temp": -6.1, + "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"], + "supported_features": 17, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all(item in state.attributes.items() for item in expected_attributes.items()) - @patch("homeassistant.components.nuheat.climate.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.DOMAIN] = (self.api, ["12345"]) +async def test_climate_thermostat_schedule_hold_available(hass): + """Test a thermostat with the schedule hold that is online.""" + mock_thermostat = _get_mock_thermostat_schedule_hold_available() + mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat) - config = {} - add_entities = Mock() - discovery_info = {} + with patch( + "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat, + ): + assert await async_setup_component(hass, DOMAIN, _mock_get_config()) + await hass.async_block_till_done() - nuheat.setup_platform(self.hass, config, add_entities, discovery_info) - add_entities.assert_called_once_with(thermostats, True) + state = hass.states.get("climate.available_bathroom") - @patch("homeassistant.components.nuheat.climate.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" + assert state.state == "auto" + expected_attributes = { + "current_temperature": 38.9, + "friendly_name": "Available bathroom", + "hvac_action": "idle", + "hvac_modes": ["auto", "heat"], + "max_temp": 180.6, + "min_temp": -6.1, + "preset_mode": "Run Schedule", + "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"], + "supported_features": 17, + "temperature": 26.1, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all(item in state.attributes.items() for item in expected_attributes.items()) - self.hass.data[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, - ) +async def test_climate_thermostat_schedule_temporary_hold(hass): + """Test a thermostat with the temporary schedule hold that is online.""" + mock_thermostat = _get_mock_thermostat_schedule_temporary_hold() + mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat) - thermostat.resume_program.assert_called_with() - thermostat.schedule_update_ha_state.assert_called_with(True) + with patch( + "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat, + ): + assert await async_setup_component(hass, DOMAIN, _mock_get_config()) + await hass.async_block_till_done() - thermostat.resume_program.reset_mock() - thermostat.schedule_update_ha_state.reset_mock() + state = hass.states.get("climate.temp_bathroom") - # 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.""" - assert self.thermostat.name == "Master bathroom" - - def test_supported_features(self): - """Test name property.""" - features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE - assert self.thermostat.supported_features == features - - def test_temperature_unit(self): - """Test temperature unit.""" - assert self.thermostat.temperature_unit == TEMP_FAHRENHEIT - self.thermostat._temperature_unit = "C" - assert self.thermostat.temperature_unit == TEMP_CELSIUS - - def test_current_temperature(self): - """Test current temperature.""" - assert self.thermostat.current_temperature == 72 - self.thermostat._temperature_unit = "C" - assert self.thermostat.current_temperature == 22 - - def test_current_operation(self): - """Test requested mode.""" - assert self.thermostat.hvac_mode == HVAC_MODE_AUTO - - def test_min_temp(self): - """Test min temp.""" - assert self.thermostat.min_temp == 41 - self.thermostat._temperature_unit = "C" - assert self.thermostat.min_temp == 5 - - def test_max_temp(self): - """Test max temp.""" - assert self.thermostat.max_temp == 157 - self.thermostat._temperature_unit = "C" - assert self.thermostat.max_temp == 69 - - def test_target_temperature(self): - """Test target temperature.""" - assert self.thermostat.target_temperature == 72 - self.thermostat._temperature_unit = "C" - assert self.thermostat.target_temperature == 22 - - def test_operation_list(self): - """Test the operation list.""" - assert self.thermostat.hvac_modes == [HVAC_MODE_AUTO, HVAC_MODE_HEAT] - - def test_resume_program(self): - """Test resume schedule.""" - self.thermostat.resume_program() - self.thermostat._thermostat.resume_schedule.assert_called_once_with() - assert self.thermostat._force_update - - def test_set_temperature(self): - """Test set temperature.""" - self.thermostat.set_temperature(temperature=85) - assert self.thermostat._thermostat.target_fahrenheit == 85 - assert self.thermostat._force_update - - self.thermostat._temperature_unit = "C" - self.thermostat.set_temperature(temperature=23) - assert self.thermostat._thermostat.target_celsius == 23 - assert 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) - assert not 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() - assert not 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() + assert state.state == "auto" + expected_attributes = { + "current_temperature": 94.4, + "friendly_name": "Temp bathroom", + "hvac_action": "idle", + "hvac_modes": ["auto", "heat"], + "max_temp": 180.6, + "min_temp": -0.6, + "preset_mode": "Run Schedule", + "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"], + "supported_features": 17, + "temperature": 37.2, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all(item in state.attributes.items() for item in expected_attributes.items()) diff --git a/tests/components/nuheat/test_config_flow.py b/tests/components/nuheat/test_config_flow.py new file mode 100644 index 00000000000..95987404e44 --- /dev/null +++ b/tests/components/nuheat/test_config_flow.py @@ -0,0 +1,163 @@ +"""Test the NuHeat config flow.""" +from asynctest import patch +import requests + +from homeassistant import config_entries, setup +from homeassistant.components.nuheat.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .mocks import _get_mock_thermostat_run + + +async def test_form_user(hass): + """Test we get the form with user source.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + mock_thermostat = _get_mock_thermostat_run() + + with patch( + "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate", + return_value=True, + ), patch( + "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.get_thermostat", + return_value=mock_thermostat, + ), patch( + "homeassistant.components.nuheat.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.nuheat.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL_NUMBER: "12345", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Master bathroom" + assert result2["data"] == { + CONF_SERIAL_NUMBER: "12345", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_import(hass): + """Test we get the form with import source.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + mock_thermostat = _get_mock_thermostat_run() + + with patch( + "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate", + return_value=True, + ), patch( + "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.get_thermostat", + return_value=mock_thermostat, + ), patch( + "homeassistant.components.nuheat.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.nuheat.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_SERIAL_NUMBER: "12345", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "Master bathroom" + assert result["data"] == { + CONF_SERIAL_NUMBER: "12345", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL_NUMBER: "12345", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_invalid_thermostat(hass): + """Test we handle invalid thermostats.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate", + return_value=True, + ), patch( + "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.get_thermostat", + side_effect=requests.exceptions.HTTPError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL_NUMBER: "12345", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_thermostat"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate", + side_effect=requests.exceptions.Timeout, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL_NUMBER: "12345", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/nuheat/test_init.py b/tests/components/nuheat/test_init.py index 90a209fd897..01128610462 100644 --- a/tests/components/nuheat/test_init.py +++ b/tests/components/nuheat/test_init.py @@ -1,43 +1,23 @@ """NuHeat component tests.""" -import unittest from unittest.mock import patch -from homeassistant.components import nuheat +from homeassistant.components.nuheat.const import DOMAIN +from homeassistant.setup import async_setup_component -from tests.common import MockDependency, get_test_home_assistant +from .mocks import _get_mock_nuheat VALID_CONFIG = { "nuheat": {"username": "warm", "password": "feet", "devices": "thermostat123"} } +INVALID_CONFIG = {"nuheat": {"username": "warm", "password": "feet"}} -class TestNuHeat(unittest.TestCase): - """Test the NuHeat component.""" +async def test_init_success(hass): + """Test that we can setup with valid config.""" + mock_nuheat = _get_mock_nuheat() - 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.""" - with patch.object(nuheat, "nuheat", mocked_nuheat): - nuheat.setup(self.hass, self.config) - - mocked_nuheat.NuHeat.assert_called_with("warm", "feet") - assert nuheat.DOMAIN in self.hass.data - assert len(self.hass.data[nuheat.DOMAIN]) == 2 - assert isinstance( - self.hass.data[nuheat.DOMAIN][0], type(mocked_nuheat.NuHeat()) - ) - assert self.hass.data[nuheat.DOMAIN][1] == "thermostat123" - - mocked_load.assert_called_with( - self.hass, "climate", nuheat.DOMAIN, {}, self.config - ) + with patch( + "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat, + ): + assert await async_setup_component(hass, DOMAIN, VALID_CONFIG) + await hass.async_block_till_done()