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
This commit is contained in:
J. Nick Koston 2020-03-23 00:29:45 -05:00 committed by GitHub
parent fa60e9b03b
commit b09a9fc81a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 683 additions and 273 deletions

View file

@ -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

View file

@ -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 thermostats 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."
}
}
}
}
}

View file

@ -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

View file

@ -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,
}

View file

@ -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."""

View file

@ -0,0 +1,9 @@
"""Constants for NuHeat thermostats."""
DOMAIN = "nuheat"
PLATFORMS = ["climate"]
CONF_SERIAL_NUMBER = "serial_number"
MANUFACTURER = "NuHeat"

View file

@ -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
}

View file

@ -1,6 +0,0 @@
resume_program:
description: Resume the programmed schedule.
fields:
entity_id:
description: Name(s) of entities to change.
example: 'climate.kitchen'

View file

@ -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 thermostats 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."
}
}
}
}
}

View file

@ -77,6 +77,7 @@ FLOWS = [
"netatmo",
"nexia",
"notion",
"nuheat",
"opentherm_gw",
"openuv",
"owntracks",

View file

@ -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]}
}

View file

@ -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())

View file

@ -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"}

View file

@ -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()