diff --git a/.coveragerc b/.coveragerc index 6d7363c3d04..2f60e74480f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -435,6 +435,7 @@ omit = homeassistant/components/home_plus_control/api.py homeassistant/components/home_plus_control/switch.py homeassistant/components/homeworks/* + homeassistant/components/honeywell/__init__.py homeassistant/components/honeywell/climate.py homeassistant/components/horizon/media_player.py homeassistant/components/hp_ilo/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index c269ad64888..acd2b6d44c3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -214,6 +214,7 @@ homeassistant/components/homeassistant/* @home-assistant/core homeassistant/components/homekit/* @bdraco homeassistant/components/homekit_controller/* @Jc2k @bdraco homeassistant/components/homematic/* @pvizeli @danielperna84 +homeassistant/components/honeywell/* @rdfurman homeassistant/components/http/* @home-assistant/core homeassistant/components/huawei_lte/* @scop @fphammerle homeassistant/components/huawei_router/* @abmantis diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 57176c9acf8..48f2802e89f 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -1 +1,132 @@ """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 + ) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 8053ad85502..36fe16aeaa2 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -2,10 +2,8 @@ from __future__ import annotations import datetime -import logging from typing import Any -import requests import somecomfort import voluptuous as vol @@ -33,6 +31,7 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_TEMPERATURE, CONF_PASSWORD, @@ -42,19 +41,21 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) 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" -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" 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.""" - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) + cool_away_temp = config.data.get(CONF_COOL_AWAY_TEMPERATURE) + heat_away_temp = config.data.get(CONF_HEAT_AWAY_TEMPERATURE) - try: - client = somecomfort.SomeComfort(username, password) - except somecomfort.AuthError: - _LOGGER.error("Failed to login to honeywell account %s", username) - return - except somecomfort.SomeComfortError: - _LOGGER.error( - "Failed to initialize the Honeywell client: " - "Check your configuration (username, password), " - "or maybe you have exceeded the API rate limit?" + data = hass.data[DOMAIN][config.entry_id] + + async_add_entities([HoneywellUSThermostat(data, cool_away_temp, heat_away_temp)]) + + +async def async_setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Honeywell climate platform. + + Honeywell uses config flow for configuration now. If an entry exists in + 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 - - dev_id = config.get(CONF_DEV_ID) - loc_id = config.get(CONF_LOC_ID) - 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, + # No config entry exists and configuration.yaml config exists, trigger the import flow. + if not hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config ) - 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): """Representation of a Honeywell US Thermostat.""" - def __init__( - self, client, device, cool_away_temp, heat_away_temp, username, password - ): + def __init__(self, data, cool_away_temp, heat_away_temp): """Initialize the thermostat.""" - self._client = client - self._device = device + self._data = data self._cool_away_temp = cool_away_temp self._heat_away_temp = heat_away_temp 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 - 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._attr_hvac_modes = list(self._hvac_mode_map) - self._supported_features = ( + self._attr_supported_features = ( SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE ) - if device._data["canControlHumidification"]: - self._supported_features |= SUPPORT_TARGET_HUMIDITY + if data.device._data["canControlHumidification"]: + self._attr_supported_features |= SUPPORT_TARGET_HUMIDITY - if device.raw_ui_data["SwitchEmergencyHeatAllowed"]: - self._supported_features |= SUPPORT_AUX_HEAT + if data.device.raw_ui_data["SwitchEmergencyHeatAllowed"]: + self._attr_supported_features |= SUPPORT_AUX_HEAT - if not device._data["hasFan"]: + if not data.device._data["hasFan"]: return # 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._supported_features |= SUPPORT_FAN_MODE + self._attr_fan_modes = list(self._fan_mode_map) + + self._attr_supported_features |= SUPPORT_FAN_MODE @property - def name(self) -> str | None: - """Return the name of the honeywell, if any.""" - return self._device.name + def _device(self): + """Shortcut to access the device.""" + return self._data.device @property 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") return data - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return self._supported_features - @property def min_temp(self) -> float: """Return the minimum temperature.""" @@ -231,11 +220,6 @@ class HoneywellUSThermostat(ClimateEntity): return self._device.raw_ui_data["HeatUpperSetptLimit"] 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 def current_humidity(self) -> int | None: """Return the current humidity.""" @@ -246,11 +230,6 @@ class HoneywellUSThermostat(ClimateEntity): """Return hvac operation ie. heat, cool 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 def hvac_action(self) -> str | None: """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 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 def fan_mode(self) -> str | None: """Return the fan setting.""" 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: heat_status = self._device.raw_ui_data.get("StatusHeat", 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) # Set temperature 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: _LOGGER.error( @@ -418,54 +384,6 @@ class HoneywellUSThermostat(ClimateEntity): else: self.set_hvac_mode(HVAC_MODE_OFF) - 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. - """ - 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 - ) + async def async_update(self): + """Get the latest state from the service.""" + await self._data.update() diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py new file mode 100644 index 00000000000..318809aaa03 --- /dev/null +++ b/homeassistant/components/honeywell/config_flow.py @@ -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], + } + ) diff --git a/homeassistant/components/honeywell/const.py b/homeassistant/components/honeywell/const.py new file mode 100644 index 00000000000..6102f30d3de --- /dev/null +++ b/homeassistant/components/honeywell/const.py @@ -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__) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index bd0c5dfca6d..5ba4947e046 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -1,8 +1,9 @@ { "domain": "honeywell", "name": "Honeywell Total Connect Comfort (US)", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/honeywell", "requirements": ["somecomfort==0.5.2"], - "codeowners": [], + "codeowners": ["@rdfurman"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json new file mode 100644 index 00000000000..ce76b571996 --- /dev/null +++ b/homeassistant/components/honeywell/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/components/honeywell/translations/en.json b/homeassistant/components/honeywell/translations/en.json new file mode 100644 index 00000000000..454093c5b3e --- /dev/null +++ b/homeassistant/components/honeywell/translations/en.json @@ -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)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b04d4f50dd8..40b5f1360ed 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -113,6 +113,7 @@ FLOWS = [ "homekit", "homekit_controller", "homematicip_cloud", + "honeywell", "huawei_lte", "hue", "huisbaasje", diff --git a/tests/components/honeywell/conftest.py b/tests/components/honeywell/conftest.py new file mode 100644 index 00000000000..05e3631e08d --- /dev/null +++ b/tests/components/honeywell/conftest.py @@ -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 diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py deleted file mode 100644 index d97bbc86ed6..00000000000 --- a/tests/components/honeywell/test_climate.py +++ /dev/null @@ -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 diff --git a/tests/components/honeywell/test_config_flow.py b/tests/components/honeywell/test_config_flow.py new file mode 100644 index 00000000000..65f47ddf35f --- /dev/null +++ b/tests/components/honeywell/test_config_flow.py @@ -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 diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py new file mode 100644 index 00000000000..d0bdb5ccf2d --- /dev/null +++ b/tests/components/honeywell/test_init.py @@ -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()