Add honeywell config flow (#50731)

* Upgrade honeywell from platform to integration

* Add codeowner and run code formatter

* Add sensors for current indoor temp and humidity

* Fix tests and away temp

* Spring cleaning of honeywell tests

* Add config flow to honeywell integration

* Add config flow test

* Tie in honeywell service update

* Simplify config flow and add import

* Remove unnecessary platform schema

* Clean up based on PR comments

* Use new helper method

* Force single device and fix linter errors

* Address PR feedback

* Update translations

* Change string key and remove logger message

* Always add first device

* Fix test assertion

* Put PLATFORM_SCHEMA back

* Skip code coverage check on honeywell init

* add some tests for honeywell

* Make retry async

* Make device private

* Use _attr_ instead of properties

* Code cleanup from PR feedback

* Fix test and cleanup code

* Make description better

Co-authored-by: Matt Zimmerman <mdz@alcor.net>
This commit is contained in:
RDFurman 2021-07-19 13:44:02 -06:00 committed by GitHub
parent f5b3118d3c
commit 450fdc91e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 442 additions and 581 deletions

View file

@ -435,6 +435,7 @@ omit =
homeassistant/components/home_plus_control/api.py homeassistant/components/home_plus_control/api.py
homeassistant/components/home_plus_control/switch.py homeassistant/components/home_plus_control/switch.py
homeassistant/components/homeworks/* homeassistant/components/homeworks/*
homeassistant/components/honeywell/__init__.py
homeassistant/components/honeywell/climate.py homeassistant/components/honeywell/climate.py
homeassistant/components/horizon/media_player.py homeassistant/components/horizon/media_player.py
homeassistant/components/hp_ilo/sensor.py homeassistant/components/hp_ilo/sensor.py

View file

@ -214,6 +214,7 @@ homeassistant/components/homeassistant/* @home-assistant/core
homeassistant/components/homekit/* @bdraco homeassistant/components/homekit/* @bdraco
homeassistant/components/homekit_controller/* @Jc2k @bdraco homeassistant/components/homekit_controller/* @Jc2k @bdraco
homeassistant/components/homematic/* @pvizeli @danielperna84 homeassistant/components/homematic/* @pvizeli @danielperna84
homeassistant/components/honeywell/* @rdfurman
homeassistant/components/http/* @home-assistant/core homeassistant/components/http/* @home-assistant/core
homeassistant/components/huawei_lte/* @scop @fphammerle homeassistant/components/huawei_lte/* @scop @fphammerle
homeassistant/components/huawei_router/* @abmantis homeassistant/components/huawei_router/* @abmantis

View file

@ -1 +1,132 @@
"""Support for Honeywell (US) Total Connect Comfort climate systems.""" """Support for Honeywell (US) Total Connect Comfort climate systems."""
from datetime import timedelta
import somecomfort
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.util import Throttle
from .const import _LOGGER, CONF_DEV_ID, CONF_LOC_ID, DOMAIN
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180)
PLATFORMS = ["climate"]
async def async_setup_entry(hass, config):
"""Set up the Honeywell thermostat."""
username = config.data[CONF_USERNAME]
password = config.data[CONF_PASSWORD]
client = await hass.async_add_executor_job(
get_somecomfort_client, username, password
)
if client is None:
return False
loc_id = config.data.get(CONF_LOC_ID)
dev_id = config.data.get(CONF_DEV_ID)
devices = []
for location in client.locations_by_id.values():
for device in location.devices_by_id.values():
if (not loc_id or location.locationid == loc_id) and (
not dev_id or device.deviceid == dev_id
):
devices.append(device)
if len(devices) == 0:
_LOGGER.debug("No devices found")
return False
data = HoneywellService(hass, client, username, password, devices[0])
await data.update()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][config.entry_id] = data
hass.config_entries.async_setup_platforms(config, PLATFORMS)
return True
def get_somecomfort_client(username, password):
"""Initialize the somecomfort client."""
try:
return somecomfort.SomeComfort(username, password)
except somecomfort.AuthError:
_LOGGER.error("Failed to login to honeywell account %s", username)
return None
except somecomfort.SomeComfortError as ex:
raise ConfigEntryNotReady(
"Failed to initialize the Honeywell client: "
"Check your configuration (username, password), "
"or maybe you have exceeded the API rate limit?"
) from ex
class HoneywellService:
"""Get the latest data and update."""
def __init__(self, hass, client, username, password, device):
"""Initialize the data object."""
self._hass = hass
self._client = client
self._username = username
self._password = password
self.device = device
async def _retry(self) -> bool:
"""Recreate a new somecomfort client.
When we got an error, the best way to be sure that the next query
will succeed, is to recreate a new somecomfort client.
"""
self._client = await self._hass.async_add_executor_job(
get_somecomfort_client, self._username, self._password
)
if self._client is None:
return False
devices = [
device
for location in self._client.locations_by_id.values()
for device in location.devices_by_id.values()
if device.name == self.device.name
]
if len(devices) != 1:
_LOGGER.error("Failed to find device %s", self.device.name)
return False
self.device = devices[0]
return True
@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def update(self) -> None:
"""Update the state."""
retries = 3
while retries > 0:
try:
await self._hass.async_add_executor_job(self.device.refresh)
break
except (
somecomfort.client.APIRateLimited,
OSError,
somecomfort.client.ConnectionTimeout,
) as exp:
retries -= 1
if retries == 0:
raise exp
result = await self._hass.async_add_executor_job(self._retry())
if not result:
raise exp
_LOGGER.error("SomeComfort update failed, Retrying - Error: %s", exp)
_LOGGER.debug(
"latestData = %s ", self.device._data # pylint: disable=protected-access
)

View file

@ -2,10 +2,8 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import logging
from typing import Any from typing import Any
import requests
import somecomfort import somecomfort
import voluptuous as vol import voluptuous as vol
@ -33,6 +31,7 @@ from homeassistant.components.climate.const import (
SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_RANGE, SUPPORT_TARGET_TEMPERATURE_RANGE,
) )
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import ( from homeassistant.const import (
ATTR_TEMPERATURE, ATTR_TEMPERATURE,
CONF_PASSWORD, CONF_PASSWORD,
@ -42,19 +41,21 @@ from homeassistant.const import (
TEMP_FAHRENHEIT, TEMP_FAHRENHEIT,
) )
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.device_registry as dr
_LOGGER = logging.getLogger(__name__) from .const import (
_LOGGER,
CONF_COOL_AWAY_TEMPERATURE,
CONF_DEV_ID,
CONF_HEAT_AWAY_TEMPERATURE,
CONF_LOC_ID,
DEFAULT_COOL_AWAY_TEMPERATURE,
DEFAULT_HEAT_AWAY_TEMPERATURE,
DOMAIN,
)
ATTR_FAN_ACTION = "fan_action" ATTR_FAN_ACTION = "fan_action"
CONF_COOL_AWAY_TEMPERATURE = "away_cool_temperature"
CONF_HEAT_AWAY_TEMPERATURE = "away_heat_temperature"
CONF_DEV_ID = "thermostat"
CONF_LOC_ID = "location"
DEFAULT_COOL_AWAY_TEMPERATURE = 88
DEFAULT_HEAT_AWAY_TEMPERATURE = 61
ATTR_PERMANENT_HOLD = "permanent_hold" ATTR_PERMANENT_HOLD = "permanent_hold"
PLATFORM_SCHEMA = vol.All( PLATFORM_SCHEMA = vol.All(
@ -108,95 +109,88 @@ HW_FAN_MODE_TO_HA = {
} }
def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_entry(hass, config, async_add_entities, discovery_info=None):
"""Set up the Honeywell thermostat.""" """Set up the Honeywell thermostat."""
username = config.get(CONF_USERNAME) cool_away_temp = config.data.get(CONF_COOL_AWAY_TEMPERATURE)
password = config.get(CONF_PASSWORD) heat_away_temp = config.data.get(CONF_HEAT_AWAY_TEMPERATURE)
try: data = hass.data[DOMAIN][config.entry_id]
client = somecomfort.SomeComfort(username, password)
except somecomfort.AuthError: async_add_entities([HoneywellUSThermostat(data, cool_away_temp, heat_away_temp)])
_LOGGER.error("Failed to login to honeywell account %s", username)
return
except somecomfort.SomeComfortError: async def async_setup_platform(hass, config, add_entities, discovery_info=None):
_LOGGER.error( """Set up the Honeywell climate platform.
"Failed to initialize the Honeywell client: "
"Check your configuration (username, password), " Honeywell uses config flow for configuration now. If an entry exists in
"or maybe you have exceeded the API rate limit?" configuration.yaml, the import flow will attempt to import it and create
a config entry.
"""
if config["platform"] == "honeywell":
_LOGGER.warning(
"Loading honeywell via platform config is deprecated; The configuration"
" has been migrated to a config entry and can be safely removed"
) )
return # No config entry exists and configuration.yaml config exists, trigger the import flow.
if not hass.config_entries.async_entries(DOMAIN):
dev_id = config.get(CONF_DEV_ID) await hass.config_entries.flow.async_init(
loc_id = config.get(CONF_LOC_ID) DOMAIN, context={"source": SOURCE_IMPORT}, data=config
cool_away_temp = config.get(CONF_COOL_AWAY_TEMPERATURE)
heat_away_temp = config.get(CONF_HEAT_AWAY_TEMPERATURE)
add_entities(
[
HoneywellUSThermostat(
client,
device,
cool_away_temp,
heat_away_temp,
username,
password,
) )
for location in client.locations_by_id.values()
for device in location.devices_by_id.values()
if (
(not loc_id or location.locationid == loc_id)
and (not dev_id or device.deviceid == dev_id)
)
]
)
class HoneywellUSThermostat(ClimateEntity): class HoneywellUSThermostat(ClimateEntity):
"""Representation of a Honeywell US Thermostat.""" """Representation of a Honeywell US Thermostat."""
def __init__( def __init__(self, data, cool_away_temp, heat_away_temp):
self, client, device, cool_away_temp, heat_away_temp, username, password
):
"""Initialize the thermostat.""" """Initialize the thermostat."""
self._client = client self._data = data
self._device = device
self._cool_away_temp = cool_away_temp self._cool_away_temp = cool_away_temp
self._heat_away_temp = heat_away_temp self._heat_away_temp = heat_away_temp
self._away = False self._away = False
self._username = username
self._password = password
_LOGGER.debug("latestData = %s ", device._data) self._attr_unique_id = dr.format_mac(data.device.mac_address)
self._attr_name = data.device.name
self._attr_temperature_unit = (
TEMP_CELSIUS if data.device.temperature_unit == "C" else TEMP_FAHRENHEIT
)
self._attr_preset_modes = [PRESET_NONE, PRESET_AWAY]
self._attr_is_aux_heat = data.device.system_mode == "emheat"
# not all honeywell HVACs support all modes # not all honeywell HVACs support all modes
mappings = [v for k, v in HVAC_MODE_TO_HW_MODE.items() if device.raw_ui_data[k]] mappings = [
v for k, v in HVAC_MODE_TO_HW_MODE.items() if data.device.raw_ui_data[k]
]
self._hvac_mode_map = {k: v for d in mappings for k, v in d.items()} self._hvac_mode_map = {k: v for d in mappings for k, v in d.items()}
self._attr_hvac_modes = list(self._hvac_mode_map)
self._supported_features = ( self._attr_supported_features = (
SUPPORT_PRESET_MODE SUPPORT_PRESET_MODE
| SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE
| SUPPORT_TARGET_TEMPERATURE_RANGE | SUPPORT_TARGET_TEMPERATURE_RANGE
) )
if device._data["canControlHumidification"]: if data.device._data["canControlHumidification"]:
self._supported_features |= SUPPORT_TARGET_HUMIDITY self._attr_supported_features |= SUPPORT_TARGET_HUMIDITY
if device.raw_ui_data["SwitchEmergencyHeatAllowed"]: if data.device.raw_ui_data["SwitchEmergencyHeatAllowed"]:
self._supported_features |= SUPPORT_AUX_HEAT self._attr_supported_features |= SUPPORT_AUX_HEAT
if not device._data["hasFan"]: if not data.device._data["hasFan"]:
return return
# not all honeywell fans support all modes # not all honeywell fans support all modes
mappings = [v for k, v in FAN_MODE_TO_HW.items() if device.raw_fan_data[k]] mappings = [v for k, v in FAN_MODE_TO_HW.items() if data.device.raw_fan_data[k]]
self._fan_mode_map = {k: v for d in mappings for k, v in d.items()} self._fan_mode_map = {k: v for d in mappings for k, v in d.items()}
self._supported_features |= SUPPORT_FAN_MODE self._attr_fan_modes = list(self._fan_mode_map)
self._attr_supported_features |= SUPPORT_FAN_MODE
@property @property
def name(self) -> str | None: def _device(self):
"""Return the name of the honeywell, if any.""" """Shortcut to access the device."""
return self._device.name return self._data.device
@property @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
@ -208,11 +202,6 @@ class HoneywellUSThermostat(ClimateEntity):
data["dr_phase"] = self._device.raw_dr_data.get("Phase") data["dr_phase"] = self._device.raw_dr_data.get("Phase")
return data return data
@property
def supported_features(self) -> int:
"""Return the list of supported features."""
return self._supported_features
@property @property
def min_temp(self) -> float: def min_temp(self) -> float:
"""Return the minimum temperature.""" """Return the minimum temperature."""
@ -231,11 +220,6 @@ class HoneywellUSThermostat(ClimateEntity):
return self._device.raw_ui_data["HeatUpperSetptLimit"] return self._device.raw_ui_data["HeatUpperSetptLimit"]
return None return None
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement."""
return TEMP_CELSIUS if self._device.temperature_unit == "C" else TEMP_FAHRENHEIT
@property @property
def current_humidity(self) -> int | None: def current_humidity(self) -> int | None:
"""Return the current humidity.""" """Return the current humidity."""
@ -246,11 +230,6 @@ class HoneywellUSThermostat(ClimateEntity):
"""Return hvac operation ie. heat, cool mode.""" """Return hvac operation ie. heat, cool mode."""
return HW_MODE_TO_HVAC_MODE[self._device.system_mode] return HW_MODE_TO_HVAC_MODE[self._device.system_mode]
@property
def hvac_modes(self) -> list[str]:
"""Return the list of available hvac operation modes."""
return list(self._hvac_mode_map)
@property @property
def hvac_action(self) -> str | None: def hvac_action(self) -> str | None:
"""Return the current running hvac operation if supported.""" """Return the current running hvac operation if supported."""
@ -291,26 +270,11 @@ class HoneywellUSThermostat(ClimateEntity):
"""Return the current preset mode, e.g., home, away, temp.""" """Return the current preset mode, e.g., home, away, temp."""
return PRESET_AWAY if self._away else None return PRESET_AWAY if self._away else None
@property
def preset_modes(self) -> list[str] | None:
"""Return a list of available preset modes."""
return [PRESET_NONE, PRESET_AWAY]
@property
def is_aux_heat(self) -> str | None:
"""Return true if aux heater."""
return self._device.system_mode == "emheat"
@property @property
def fan_mode(self) -> str | None: def fan_mode(self) -> str | None:
"""Return the fan setting.""" """Return the fan setting."""
return HW_FAN_MODE_TO_HA[self._device.fan_mode] return HW_FAN_MODE_TO_HA[self._device.fan_mode]
@property
def fan_modes(self) -> list[str] | None:
"""Return the list of available fan modes."""
return list(self._fan_mode_map)
def _is_permanent_hold(self) -> bool: def _is_permanent_hold(self) -> bool:
heat_status = self._device.raw_ui_data.get("StatusHeat", 0) heat_status = self._device.raw_ui_data.get("StatusHeat", 0)
cool_status = self._device.raw_ui_data.get("StatusCool", 0) cool_status = self._device.raw_ui_data.get("StatusCool", 0)
@ -383,7 +347,9 @@ class HoneywellUSThermostat(ClimateEntity):
setattr(self._device, f"hold_{mode}", True) setattr(self._device, f"hold_{mode}", True)
# Set temperature # Set temperature
setattr( setattr(
self._device, f"setpoint_{mode}", getattr(self, f"_{mode}_away_temp") self._device,
f"setpoint_{mode}",
getattr(self, f"_{mode}_away_temp"),
) )
except somecomfort.SomeComfortError: except somecomfort.SomeComfortError:
_LOGGER.error( _LOGGER.error(
@ -418,54 +384,6 @@ class HoneywellUSThermostat(ClimateEntity):
else: else:
self.set_hvac_mode(HVAC_MODE_OFF) self.set_hvac_mode(HVAC_MODE_OFF)
def _retry(self) -> bool: async def async_update(self):
"""Recreate a new somecomfort client. """Get the latest state from the service."""
await self._data.update()
When we got an error, the best way to be sure that the next query
will succeed, is to recreate a new somecomfort client.
"""
try:
self._client = somecomfort.SomeComfort(self._username, self._password)
except somecomfort.AuthError:
_LOGGER.error("Failed to login to honeywell account %s", self._username)
return False
except somecomfort.SomeComfortError as ex:
_LOGGER.error("Failed to initialize honeywell client: %s", str(ex))
return False
devices = [
device
for location in self._client.locations_by_id.values()
for device in location.devices_by_id.values()
if device.name == self._device.name
]
if len(devices) != 1:
_LOGGER.error("Failed to find device %s", self._device.name)
return False
self._device = devices[0]
return True
def update(self) -> None:
"""Update the state."""
retries = 3
while retries > 0:
try:
self._device.refresh()
break
except (
somecomfort.client.APIRateLimited,
OSError,
requests.exceptions.ReadTimeout,
) as exp:
retries -= 1
if retries == 0:
raise exp
if not self._retry():
raise exp
_LOGGER.error("SomeComfort update failed, Retrying - Error: %s", exp)
_LOGGER.debug(
"latestData = %s ", self._device._data # pylint: disable=protected-access
)

View file

@ -0,0 +1,55 @@
"""Config flow to configure the honeywell integration."""
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.honeywell import get_somecomfort_client
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE, DOMAIN
class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a honeywell config flow."""
VERSION = 1
async def async_step_user(self, user_input=None):
"""Create config entry. Show the setup form to the user."""
errors = {}
if user_input is not None:
valid = await self.is_valid(**user_input)
if valid:
return self.async_create_entry(
title=DOMAIN,
data=user_input,
)
errors["base"] = "invalid_auth"
data_schema = {
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
return self.async_show_form(
step_id="user", data_schema=vol.Schema(data_schema), errors=errors
)
async def is_valid(self, **kwargs) -> bool:
"""Check if login credentials are valid."""
client = await self.hass.async_add_executor_job(
get_somecomfort_client, kwargs[CONF_USERNAME], kwargs[CONF_PASSWORD]
)
return client is not None
async def async_step_import(self, import_data):
"""Import entry from configuration.yaml."""
return await self.async_step_user(
{
CONF_USERNAME: import_data[CONF_USERNAME],
CONF_PASSWORD: import_data[CONF_PASSWORD],
CONF_COOL_AWAY_TEMPERATURE: import_data[CONF_COOL_AWAY_TEMPERATURE],
CONF_HEAT_AWAY_TEMPERATURE: import_data[CONF_HEAT_AWAY_TEMPERATURE],
}
)

View file

@ -0,0 +1,13 @@
"""Support for Honeywell (US) Total Connect Comfort climate systems."""
import logging
DOMAIN = "honeywell"
DEFAULT_COOL_AWAY_TEMPERATURE = 88
DEFAULT_HEAT_AWAY_TEMPERATURE = 61
CONF_COOL_AWAY_TEMPERATURE = "away_cool_temperature"
CONF_HEAT_AWAY_TEMPERATURE = "away_heat_temperature"
CONF_DEV_ID = "thermostat"
CONF_LOC_ID = "location"
_LOGGER = logging.getLogger(__name__)

View file

@ -1,8 +1,9 @@
{ {
"domain": "honeywell", "domain": "honeywell",
"name": "Honeywell Total Connect Comfort (US)", "name": "Honeywell Total Connect Comfort (US)",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/honeywell", "documentation": "https://www.home-assistant.io/integrations/honeywell",
"requirements": ["somecomfort==0.5.2"], "requirements": ["somecomfort==0.5.2"],
"codeowners": [], "codeowners": ["@rdfurman"],
"iot_class": "cloud_polling" "iot_class": "cloud_polling"
} }

View file

@ -0,0 +1,17 @@
{
"config": {
"step": {
"user": {
"title": "Honeywell Total Connect Comfort (US)",
"description": "Please enter the credentials used to log into mytotalconnectcomfort.com.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
}
}
}

View file

@ -0,0 +1,17 @@
{
"config": {
"error": {
"invalid_auth": "Invalid authentication"
},
"step": {
"user": {
"data": {
"password": "Password",
"username": "Username"
},
"description": "Please enter the credentials used to log into mytotalconnectcomfort.com.",
"title": "Honeywell Total Connect Comfort (US)"
}
}
}
}

View file

@ -113,6 +113,7 @@ FLOWS = [
"homekit", "homekit",
"homekit_controller", "homekit_controller",
"homematicip_cloud", "homematicip_cloud",
"honeywell",
"huawei_lte", "huawei_lte",
"hue", "hue",
"huisbaasje", "huisbaasje",

View file

@ -0,0 +1,65 @@
"""Fixtures for honeywell tests."""
from unittest.mock import create_autospec, patch
import pytest
import somecomfort
from homeassistant.components.honeywell.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from tests.common import MockConfigEntry
@pytest.fixture
def config_data():
"""Provide configuration data for tests."""
return {CONF_USERNAME: "fake", CONF_PASSWORD: "user"}
@pytest.fixture
def config_entry(config_data):
"""Create a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
data=config_data,
options={},
)
@pytest.fixture
def device():
"""Mock a somecomfort.Device."""
mock_device = create_autospec(somecomfort.Device, instance=True)
mock_device.deviceid.return_value = "device1"
mock_device._data = {
"canControlHumidification": False,
"hasFan": False,
}
mock_device.system_mode = "off"
mock_device.name = "device1"
mock_device.current_temperature = 20
mock_device.mac_address = "macaddress1"
return mock_device
@pytest.fixture
def location(device):
"""Mock a somecomfort.Location."""
mock_location = create_autospec(somecomfort.Location, instance=True)
mock_location.locationid.return_value = "location1"
mock_location.devices_by_id = {device.deviceid: device}
return mock_location
@pytest.fixture(autouse=True)
def client(location):
"""Mock a somecomfort.SomeComfort client."""
client_mock = create_autospec(somecomfort.SomeComfort, instance=True)
client_mock.locations_by_id = {location.locationid: location}
with patch(
"homeassistant.components.honeywell.somecomfort.SomeComfort"
) as sc_class_mock:
sc_class_mock.return_value = client_mock
yield client_mock

View file

@ -1,430 +0,0 @@
"""The test the Honeywell thermostat module."""
import unittest
from unittest import mock
import pytest
import requests.exceptions
import somecomfort
import voluptuous as vol
from homeassistant.components.climate.const import (
ATTR_FAN_MODE,
ATTR_FAN_MODES,
ATTR_HVAC_MODES,
)
import homeassistant.components.honeywell.climate as honeywell
from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
pytestmark = pytest.mark.skip("Need to be fixed!")
class TestHoneywell(unittest.TestCase):
"""A test class for Honeywell themostats."""
@mock.patch("somecomfort.SomeComfort")
@mock.patch("homeassistant.components.honeywell.climate.HoneywellUSThermostat")
def test_setup_us(self, mock_ht, mock_sc):
"""Test for the US setup."""
config = {
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
honeywell.CONF_REGION: "us",
}
bad_pass_config = {CONF_USERNAME: "user", honeywell.CONF_REGION: "us"}
bad_region_config = {
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
honeywell.CONF_REGION: "un",
}
with pytest.raises(vol.Invalid):
honeywell.PLATFORM_SCHEMA(None)
with pytest.raises(vol.Invalid):
honeywell.PLATFORM_SCHEMA({})
with pytest.raises(vol.Invalid):
honeywell.PLATFORM_SCHEMA(bad_pass_config)
with pytest.raises(vol.Invalid):
honeywell.PLATFORM_SCHEMA(bad_region_config)
hass = mock.MagicMock()
add_entities = mock.MagicMock()
locations = [mock.MagicMock(), mock.MagicMock()]
devices_1 = [mock.MagicMock()]
devices_2 = [mock.MagicMock(), mock.MagicMock]
mock_sc.return_value.locations_by_id.values.return_value = locations
locations[0].devices_by_id.values.return_value = devices_1
locations[1].devices_by_id.values.return_value = devices_2
result = honeywell.setup_platform(hass, config, add_entities)
assert result
assert mock_sc.call_count == 1
assert mock_sc.call_args == mock.call("user", "pass")
mock_ht.assert_has_calls(
[
mock.call(mock_sc.return_value, devices_1[0], 18, 28, "user", "pass"),
mock.call(mock_sc.return_value, devices_2[0], 18, 28, "user", "pass"),
mock.call(mock_sc.return_value, devices_2[1], 18, 28, "user", "pass"),
]
)
@mock.patch("somecomfort.SomeComfort")
def test_setup_us_failures(self, mock_sc):
"""Test the US setup."""
hass = mock.MagicMock()
add_entities = mock.MagicMock()
config = {
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
honeywell.CONF_REGION: "us",
}
mock_sc.side_effect = somecomfort.AuthError
result = honeywell.setup_platform(hass, config, add_entities)
assert not result
assert not add_entities.called
mock_sc.side_effect = somecomfort.SomeComfortError
result = honeywell.setup_platform(hass, config, add_entities)
assert not result
assert not add_entities.called
@mock.patch("somecomfort.SomeComfort")
@mock.patch("homeassistant.components.honeywell.climate.HoneywellUSThermostat")
def _test_us_filtered_devices(self, mock_ht, mock_sc, loc=None, dev=None):
"""Test for US filtered thermostats."""
config = {
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
honeywell.CONF_REGION: "us",
"location": loc,
"thermostat": dev,
}
locations = {
1: mock.MagicMock(
locationid=mock.sentinel.loc1,
devices_by_id={
11: mock.MagicMock(deviceid=mock.sentinel.loc1dev1),
12: mock.MagicMock(deviceid=mock.sentinel.loc1dev2),
},
),
2: mock.MagicMock(
locationid=mock.sentinel.loc2,
devices_by_id={21: mock.MagicMock(deviceid=mock.sentinel.loc2dev1)},
),
3: mock.MagicMock(
locationid=mock.sentinel.loc3,
devices_by_id={31: mock.MagicMock(deviceid=mock.sentinel.loc3dev1)},
),
}
mock_sc.return_value = mock.MagicMock(locations_by_id=locations)
hass = mock.MagicMock()
add_entities = mock.MagicMock()
assert honeywell.setup_platform(hass, config, add_entities) is True
return mock_ht.call_args_list, mock_sc
def test_us_filtered_thermostat_1(self):
"""Test for US filtered thermostats."""
result, client = self._test_us_filtered_devices(dev=mock.sentinel.loc1dev1)
devices = [x[0][1].deviceid for x in result]
assert [mock.sentinel.loc1dev1] == devices
def test_us_filtered_thermostat_2(self):
"""Test for US filtered location."""
result, client = self._test_us_filtered_devices(dev=mock.sentinel.loc2dev1)
devices = [x[0][1].deviceid for x in result]
assert [mock.sentinel.loc2dev1] == devices
def test_us_filtered_location_1(self):
"""Test for US filtered locations."""
result, client = self._test_us_filtered_devices(loc=mock.sentinel.loc1)
devices = [x[0][1].deviceid for x in result]
assert [mock.sentinel.loc1dev1, mock.sentinel.loc1dev2] == devices
def test_us_filtered_location_2(self):
"""Test for US filtered locations."""
result, client = self._test_us_filtered_devices(loc=mock.sentinel.loc2)
devices = [x[0][1].deviceid for x in result]
assert [mock.sentinel.loc2dev1] == devices
@mock.patch("evohomeclient.EvohomeClient")
@mock.patch("homeassistant.components.honeywell.climate.HoneywellUSThermostat")
def test_eu_setup_full_config(self, mock_round, mock_evo):
"""Test the EU setup with complete configuration."""
config = {
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
honeywell.CONF_REGION: "eu",
}
mock_evo.return_value.temperatures.return_value = [{"id": "foo"}, {"id": "bar"}]
hass = mock.MagicMock()
add_entities = mock.MagicMock()
assert honeywell.setup_platform(hass, config, add_entities)
assert mock_evo.call_count == 1
assert mock_evo.call_args == mock.call("user", "pass")
assert mock_evo.return_value.temperatures.call_count == 1
assert mock_evo.return_value.temperatures.call_args == mock.call(
force_refresh=True
)
mock_round.assert_has_calls(
[
mock.call(mock_evo.return_value, "foo", True, 20.0),
mock.call(mock_evo.return_value, "bar", False, 20.0),
]
)
assert add_entities.call_count == 2
@mock.patch("evohomeclient.EvohomeClient")
@mock.patch("homeassistant.components.honeywell.climate.HoneywellUSThermostat")
def test_eu_setup_partial_config(self, mock_round, mock_evo):
"""Test the EU setup with partial configuration."""
config = {
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
honeywell.CONF_REGION: "eu",
}
mock_evo.return_value.temperatures.return_value = [{"id": "foo"}, {"id": "bar"}]
hass = mock.MagicMock()
add_entities = mock.MagicMock()
assert honeywell.setup_platform(hass, config, add_entities)
mock_round.assert_has_calls(
[
mock.call(mock_evo.return_value, "foo", True, 16),
mock.call(mock_evo.return_value, "bar", False, 16),
]
)
@mock.patch("evohomeclient.EvohomeClient")
@mock.patch("homeassistant.components.honeywell.climate.HoneywellUSThermostat")
def test_eu_setup_bad_temp(self, mock_round, mock_evo):
"""Test the EU setup with invalid temperature."""
config = {
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
honeywell.CONF_REGION: "eu",
}
with pytest.raises(vol.Invalid):
honeywell.PLATFORM_SCHEMA(config)
@mock.patch("evohomeclient.EvohomeClient")
@mock.patch("homeassistant.components.honeywell.climate.HoneywellUSThermostat")
def test_eu_setup_error(self, mock_round, mock_evo):
"""Test the EU setup with errors."""
config = {
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
honeywell.CONF_REGION: "eu",
}
mock_evo.return_value.temperatures.side_effect = (
requests.exceptions.RequestException
)
add_entities = mock.MagicMock()
hass = mock.MagicMock()
assert not honeywell.setup_platform(hass, config, add_entities)
class TestHoneywellRound(unittest.TestCase):
"""A test class for Honeywell Round thermostats."""
def setup_method(self, method):
"""Test the setup method."""
def fake_temperatures(force_refresh=None):
"""Create fake temperatures."""
temps = [
{
"id": "1",
"temp": 20,
"setpoint": 21,
"thermostat": "main",
"name": "House",
},
{
"id": "2",
"temp": 21,
"setpoint": 22,
"thermostat": "DOMESTIC_HOT_WATER",
},
]
return temps
self.device = mock.MagicMock()
self.device.temperatures.side_effect = fake_temperatures
self.round1 = honeywell.RoundThermostat(self.device, "1", True, 16)
self.round1.update()
self.round2 = honeywell.RoundThermostat(self.device, "2", False, 17)
self.round2.update()
def test_attributes(self):
"""Test the attributes."""
assert self.round1.name == "House"
assert self.round1.temperature_unit == TEMP_CELSIUS
assert self.round1.current_temperature == 20
assert self.round1.target_temperature == 21
assert not self.round1.is_away_mode_on
assert self.round2.name == "Hot Water"
assert self.round2.temperature_unit == TEMP_CELSIUS
assert self.round2.current_temperature == 21
assert self.round2.target_temperature is None
assert not self.round2.is_away_mode_on
def test_away_mode(self):
"""Test setting the away mode."""
assert not self.round1.is_away_mode_on
self.round1.turn_away_mode_on()
assert self.round1.is_away_mode_on
assert self.device.set_temperature.call_count == 1
assert self.device.set_temperature.call_args == mock.call("House", 16)
self.device.set_temperature.reset_mock()
self.round1.turn_away_mode_off()
assert not self.round1.is_away_mode_on
assert self.device.cancel_temp_override.call_count == 1
assert self.device.cancel_temp_override.call_args == mock.call("House")
def test_set_temperature(self):
"""Test setting the temperature."""
self.round1.set_temperature(temperature=25)
assert self.device.set_temperature.call_count == 1
assert self.device.set_temperature.call_args == mock.call("House", 25)
def test_set_hvac_mode(self) -> None:
"""Test setting the system operation."""
self.round1.set_hvac_mode("cool")
assert self.round1.current_operation == "cool"
assert self.device.system_mode == "cool"
self.round1.set_hvac_mode("heat")
assert self.round1.current_operation == "heat"
assert self.device.system_mode == "heat"
class TestHoneywellUS(unittest.TestCase):
"""A test class for Honeywell US thermostats."""
def setup_method(self, method):
"""Test the setup method."""
self.client = mock.MagicMock()
self.device = mock.MagicMock()
self.cool_away_temp = 18
self.heat_away_temp = 28
self.honeywell = honeywell.HoneywellUSThermostat(
self.client,
self.device,
self.cool_away_temp,
self.heat_away_temp,
"user",
"password",
)
self.device.fan_running = True
self.device.name = "test"
self.device.temperature_unit = "F"
self.device.current_temperature = 72
self.device.setpoint_cool = 78
self.device.setpoint_heat = 65
self.device.system_mode = "heat"
self.device.fan_mode = "auto"
def test_properties(self):
"""Test the properties."""
assert self.honeywell.is_fan_on
assert self.honeywell.name == "test"
assert self.honeywell.current_temperature == 72
def test_unit_of_measurement(self):
"""Test the unit of measurement."""
assert self.honeywell.temperature_unit == TEMP_FAHRENHEIT
self.device.temperature_unit = "C"
assert self.honeywell.temperature_unit == TEMP_CELSIUS
def test_target_temp(self):
"""Test the target temperature."""
assert self.honeywell.target_temperature == 65
self.device.system_mode = "cool"
assert self.honeywell.target_temperature == 78
def test_set_temp(self):
"""Test setting the temperature."""
self.honeywell.set_temperature(temperature=70)
assert self.device.setpoint_heat == 70
assert self.honeywell.target_temperature == 70
self.device.system_mode = "cool"
assert self.honeywell.target_temperature == 78
self.honeywell.set_temperature(temperature=74)
assert self.device.setpoint_cool == 74
assert self.honeywell.target_temperature == 74
def test_set_hvac_mode(self) -> None:
"""Test setting the operation mode."""
self.honeywell.set_hvac_mode("cool")
assert self.device.system_mode == "cool"
self.honeywell.set_hvac_mode("heat")
assert self.device.system_mode == "heat"
def test_set_temp_fail(self):
"""Test if setting the temperature fails."""
self.device.setpoint_heat = mock.MagicMock(
side_effect=somecomfort.SomeComfortError
)
self.honeywell.set_temperature(temperature=123)
def test_attributes(self):
"""Test the attributes."""
expected = {
honeywell.ATTR_FAN: "running",
ATTR_FAN_MODE: "auto",
ATTR_FAN_MODES: somecomfort.FAN_MODES,
ATTR_HVAC_MODES: somecomfort.SYSTEM_MODES,
}
assert expected == self.honeywell.extra_state_attributes
expected["fan"] = "idle"
self.device.fan_running = False
assert self.honeywell.extra_state_attributes == expected
def test_with_no_fan(self):
"""Test if there is on fan."""
self.device.fan_running = False
self.device.fan_mode = None
expected = {
honeywell.ATTR_FAN: "idle",
ATTR_FAN_MODE: None,
ATTR_FAN_MODES: somecomfort.FAN_MODES,
ATTR_HVAC_MODES: somecomfort.SYSTEM_MODES,
}
assert self.honeywell.extra_state_attributes == expected
def test_heat_away_mode(self):
"""Test setting the heat away mode."""
self.honeywell.set_hvac_mode("heat")
assert not self.honeywell.is_away_mode_on
self.honeywell.turn_away_mode_on()
assert self.honeywell.is_away_mode_on
assert self.device.setpoint_heat == self.heat_away_temp
assert self.device.hold_heat is True
self.honeywell.turn_away_mode_off()
assert not self.honeywell.is_away_mode_on
assert self.device.hold_heat is False
@mock.patch("somecomfort.SomeComfort")
def test_retry(self, test_somecomfort):
"""Test retry connection."""
old_device = self.honeywell._device
self.honeywell._retry()
assert self.honeywell._device == old_device

View file

@ -0,0 +1,63 @@
"""Tests for honeywell config flow."""
from unittest.mock import patch
import somecomfort
from homeassistant import data_entry_flow
from homeassistant.components.honeywell.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.core import HomeAssistant
FAKE_CONFIG = {
"username": "fake",
"password": "user",
"away_cool_temperature": 88,
"away_heat_temperature": 61,
}
async def test_show_authenticate_form(hass: HomeAssistant) -> None:
"""Test that the config form is shown."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
async def test_connection_error(hass: HomeAssistant) -> None:
"""Test that an error message is shown on login fail."""
with patch(
"somecomfort.SomeComfort",
side_effect=somecomfort.AuthError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=FAKE_CONFIG
)
assert result["errors"] == {"base": "invalid_auth"}
async def test_create_entry(hass: HomeAssistant) -> None:
"""Test that the config entry is created."""
with patch(
"somecomfort.SomeComfort",
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=FAKE_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"] == FAKE_CONFIG
async def test_async_step_import(hass: HomeAssistant) -> None:
"""Test that the import step works."""
with patch(
"somecomfort.SomeComfort",
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=FAKE_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"] == FAKE_CONFIG

View file

@ -0,0 +1,8 @@
"""Test honeywell setup process."""
async def test_setup_entry(hass, config_entry):
"""Initialize the config entry."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()