From 84763c793d883bf195e8e8d0253f89657b3e99e6 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 20 Feb 2023 18:56:03 -0500 Subject: [PATCH] Support Ecobee climate Aux Heat on/off (#86100) Co-authored-by: J. Nick Koston --- homeassistant/components/ecobee/climate.py | 67 +++++++----- tests/components/ecobee/__init__.py | 118 +++++++++++++++++++++ tests/components/ecobee/common.py | 2 +- tests/components/ecobee/conftest.py | 16 +++ tests/components/ecobee/test_climate.py | 103 ++++++++++++++++-- 5 files changed, 272 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 2c2225ff9a8e..7925832953ba 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -35,6 +35,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter +from . import EcobeeData from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER from .util import ecobee_date, ecobee_time @@ -61,11 +62,14 @@ PRESET_HOLD_INDEFINITE = "indefinite" AWAY_MODE = "awayMode" PRESET_HOME = "home" PRESET_SLEEP = "sleep" +HAS_HEAT_PUMP = "hasHeatPump" DEFAULT_MIN_HUMIDITY = 15 DEFAULT_MAX_HUMIDITY = 50 HUMIDIFIER_MANUAL_MODE = "manual" +ECOBEE_AUX_HEAT_ONLY = "auxHeatOnly" + # Order matters, because for reverse mapping we don't want to map HEAT to AUX ECOBEE_HVAC_TO_HASS = collections.OrderedDict( @@ -161,7 +165,6 @@ SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema( SUPPORT_FLAGS = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.AUX_HEAT | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE ) @@ -308,7 +311,9 @@ class Thermostat(ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - def __init__(self, data, thermostat_index, thermostat): + def __init__( + self, data: EcobeeData, thermostat_index: int, thermostat: dict + ) -> None: """Initialize the thermostat.""" self.data = data self.thermostat_index = thermostat_index @@ -318,12 +323,9 @@ class Thermostat(ClimateEntity): self._last_active_hvac_mode = HVACMode.HEAT_COOL self._operation_list = [] - if ( - self.thermostat["settings"]["heatStages"] - or self.thermostat["settings"]["hasHeatPump"] - ): + if self.settings["heatStages"] or self.settings["hasHeatPump"]: self._operation_list.append(HVACMode.HEAT) - if self.thermostat["settings"]["coolStages"]: + if self.settings["coolStages"]: self._operation_list.append(HVACMode.COOL) if len(self._operation_list) == 2: self._operation_list.insert(0, HVACMode.HEAT_COOL) @@ -355,9 +357,12 @@ class Thermostat(ClimateEntity): @property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" + supported = SUPPORT_FLAGS if self.has_humidifier_control: - return SUPPORT_FLAGS | ClimateEntityFeature.TARGET_HUMIDITY - return SUPPORT_FLAGS + supported = supported | ClimateEntityFeature.TARGET_HUMIDITY + if self.has_aux_heat: + supported = supported | ClimateEntityFeature.AUX_HEAT + return supported @property def name(self): @@ -411,13 +416,23 @@ class Thermostat(ClimateEntity): return PRECISION_HALVES @property - def has_humidifier_control(self): + def settings(self) -> dict[str, Any]: + """Return the settings of the thermostat.""" + return self.thermostat["settings"] + + @property + def has_humidifier_control(self) -> bool: """Return true if humidifier connected to thermostat and set to manual/on mode.""" return ( - self.thermostat["settings"]["hasHumidifier"] - and self.thermostat["settings"]["humidifierMode"] == HUMIDIFIER_MANUAL_MODE + bool(self.settings.get("hasHumidifier")) + and self.settings.get("humidifierMode") == HUMIDIFIER_MANUAL_MODE ) + @property + def has_aux_heat(self) -> bool: + """Return true if the ecobee has a heat pump.""" + return bool(self.settings.get(HAS_HEAT_PUMP)) + @property def target_humidity(self) -> int | None: """Return the desired humidity set point.""" @@ -489,7 +504,7 @@ class Thermostat(ClimateEntity): @property def hvac_mode(self): """Return current operation.""" - return ECOBEE_HVAC_TO_HASS[self.thermostat["settings"]["hvacMode"]] + return ECOBEE_HVAC_TO_HASS[self.settings["hvacMode"]] @property def hvac_modes(self): @@ -541,23 +556,25 @@ class Thermostat(ClimateEntity): self.thermostat["program"]["currentClimateRef"] ], "equipment_running": status, - "fan_min_on_time": self.thermostat["settings"]["fanMinOnTime"], + "fan_min_on_time": self.settings["fanMinOnTime"], } @property - def is_aux_heat(self): + def is_aux_heat(self) -> bool: """Return true if aux heater.""" - return "auxHeat" in self.thermostat["equipmentStatus"] + return self.settings["hvacMode"] == ECOBEE_AUX_HEAT_ONLY - async def async_turn_aux_heat_on(self) -> None: + def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" - if not self.is_aux_heat: - _LOGGER.warning("# Changing aux heat is not supported") + _LOGGER.debug("Setting HVAC mode to auxHeatOnly to turn on aux heat") + self.data.ecobee.set_hvac_mode(self.thermostat_index, ECOBEE_AUX_HEAT_ONLY) + self.update_without_throttle = True - async def async_turn_aux_heat_off(self) -> None: + def turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" - if self.is_aux_heat: - _LOGGER.warning("# Changing aux heat is not supported") + _LOGGER.debug("Setting HVAC mode to last mode to disable aux heat") + self.set_hvac_mode(self._last_active_hvac_mode) + self.update_without_throttle = True def set_preset_mode(self, preset_mode: str) -> None: """Activate a preset.""" @@ -680,7 +697,7 @@ class Thermostat(ClimateEntity): heat_temp = temp cool_temp = temp else: - delta = self.thermostat["settings"]["heatCoolMinDelta"] / 10.0 + delta = self.settings["heatCoolMinDelta"] / 10.0 heat_temp = temp - delta cool_temp = temp + delta self.set_auto_temp_hold(heat_temp, cool_temp) @@ -739,7 +756,7 @@ class Thermostat(ClimateEntity): # "useEndTime2hour", "useEndTime4hour" # "nextPeriod", "askMe" # "indefinite" - device_preference = self.thermostat["settings"]["holdAction"] + device_preference = self.settings["holdAction"] # Currently supported pyecobee holdTypes: # dateTime, nextTransition, indefinite, holdHours hold_pref_map = { @@ -755,7 +772,7 @@ class Thermostat(ClimateEntity): # "useEndTime2hour", "useEndTime4hour" # "nextPeriod", "askMe" # "indefinite" - device_preference = self.thermostat["settings"]["holdAction"] + device_preference = self.settings["holdAction"] hold_hours_map = { "useEndTime2hour": 2, "useEndTime4hour": 4, diff --git a/tests/components/ecobee/__init__.py b/tests/components/ecobee/__init__.py index 389dc7101f93..3dba80090d48 100644 --- a/tests/components/ecobee/__init__.py +++ b/tests/components/ecobee/__init__.py @@ -1 +1,119 @@ """Tests for Ecobee integration.""" + +GENERIC_THERMOSTAT_INFO = { + "identifier": 8675309, + "name": "ecobee", + "modelNumber": "athenaSmart", + "program": { + "climates": [ + {"name": "Climate1", "climateRef": "c1"}, + {"name": "Climate2", "climateRef": "c2"}, + ], + "currentClimateRef": "c1", + }, + "runtime": { + "connected": True, + "actualTemperature": 300, + "actualHumidity": 15, + "desiredHeat": 400, + "desiredCool": 200, + "desiredFanMode": "on", + "desiredHumidity": 40, + }, + "settings": { + "hvacMode": "auto", + "heatStages": 1, + "coolStages": 1, + "fanMinOnTime": 10, + "heatCoolMinDelta": 50, + "holdAction": "nextTransition", + "hasHumidifier": False, + "humidifierMode": "manual", + "humidity": "30", + "ventilatorType": "none", + }, + "equipmentStatus": "fan", + "events": [ + { + "name": "Event1", + "running": True, + "type": "hold", + "holdClimateRef": "away", + "endDate": "2022-01-01 10:00:00", + "startDate": "2022-02-02 11:00:00", + } + ], + "remoteSensors": [ + { + "id": "rs:100", + "name": "Remote Sensor 1", + "type": "ecobee3_remote_sensor", + "code": "WKRP", + "inUse": False, + "capability": [ + {"id": "1", "type": "temperature", "value": "782"}, + {"id": "2", "type": "occupancy", "value": "false"}, + ], + } + ], +} + + +GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP = { + "identifier": 8675309, + "name": "ecobee", + "modelNumber": "athenaSmart", + "program": { + "climates": [ + {"name": "Climate1", "climateRef": "c1"}, + {"name": "Climate2", "climateRef": "c2"}, + ], + "currentClimateRef": "c1", + }, + "runtime": { + "connected": True, + "actualTemperature": 300, + "actualHumidity": 15, + "desiredHeat": 400, + "desiredCool": 200, + "desiredFanMode": "on", + "desiredHumidity": 40, + }, + "settings": { + "hvacMode": "auto", + "heatStages": 1, + "coolStages": 1, + "fanMinOnTime": 10, + "heatCoolMinDelta": 50, + "holdAction": "nextTransition", + "hasHumidifier": False, + "humidifierMode": "manual", + "humidity": "30", + "hasHeatPump": True, + "ventilatorType": "none", + }, + "equipmentStatus": "fan", + "events": [ + { + "name": "Event1", + "running": True, + "type": "hold", + "holdClimateRef": "away", + "endDate": "2022-01-01 10:00:00", + "startDate": "2022-02-02 11:00:00", + } + ], + "remoteSensors": [ + { + "id": "rs:100", + "name": "Remote Sensor 1", + "type": "ecobee3_remote_sensor", + "code": "WKRP", + "inUse": False, + "capability": [ + {"id": "1", "type": "temperature", "value": "782"}, + {"id": "2", "type": "occupancy", "value": "false"}, + ], + } + ], +} diff --git a/tests/components/ecobee/common.py b/tests/components/ecobee/common.py index 0422b35f787f..ff9bc1b61fd0 100644 --- a/tests/components/ecobee/common.py +++ b/tests/components/ecobee/common.py @@ -8,7 +8,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def setup_platform(hass, platform): +async def setup_platform(hass, platform) -> MockConfigEntry: """Set up the ecobee platform.""" mock_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/ecobee/conftest.py b/tests/components/ecobee/conftest.py index a7766af2ff92..05700fa2e20c 100644 --- a/tests/components/ecobee/conftest.py +++ b/tests/components/ecobee/conftest.py @@ -1,6 +1,10 @@ """Fixtures for tests.""" +from unittest.mock import MagicMock, patch + import pytest +from homeassistant.components.ecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN + from tests.common import load_fixture @@ -15,3 +19,15 @@ def requests_mock_fixture(requests_mock): "https://api.ecobee.com/token", text=load_fixture("ecobee/ecobee-token.json"), ) + + +@pytest.fixture +def mock_ecobee(): + """Mock an Ecobee object.""" + ecobee = MagicMock() + ecobee.request_pin.return_value = True + ecobee.refresh_tokens.return_value = True + + ecobee.config = {ECOBEE_API_KEY: "mocked_key", ECOBEE_REFRESH_TOKEN: "mocked_token"} + with patch("homeassistant.components.ecobee.Ecobee", return_value=ecobee): + yield ecobee diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index 1411a1f1084b..75722d68c0cb 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -1,12 +1,22 @@ """The test for the Ecobee thermostat module.""" +import copy from http import HTTPStatus from unittest import mock +from unittest.mock import MagicMock import pytest -from homeassistant.components.ecobee import climate as ecobee +from homeassistant.components import climate +from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.components.ecobee.climate import ECOBEE_AUX_HEAT_ONLY, Thermostat import homeassistant.const as const -from homeassistant.const import STATE_OFF +from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_OFF +from homeassistant.core import HomeAssistant + +from tests.components.ecobee import GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP +from tests.components.ecobee.common import setup_platform + +ENTITY_ID = "climate.ecobee" @pytest.fixture @@ -68,7 +78,7 @@ def data_fixture(ecobee_fixture): def thermostat_fixture(data): """Set up ecobee thermostat object.""" thermostat = data.ecobee.get_thermostat(1) - return ecobee.Thermostat(data, 1, thermostat) + return Thermostat(data, 1, thermostat) async def test_name(thermostat) -> None: @@ -76,6 +86,37 @@ async def test_name(thermostat) -> None: assert thermostat.name == "Ecobee" +async def test_aux_heat_not_supported_by_default(hass): + """Default setup should not support Aux heat.""" + await setup_platform(hass, const.Platform.CLIMATE) + state = hass.states.get(ENTITY_ID) + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + == ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TARGET_HUMIDITY + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_TEMPERATURE + ) + + +async def test_aux_heat_supported_with_heat_pump(hass): + """Aux Heat should be supported if thermostat has heatpump.""" + mock_get_thermostat = mock.Mock() + mock_get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP + with mock.patch("pyecobee.Ecobee.get_thermostat", mock_get_thermostat): + await setup_platform(hass, const.Platform.CLIMATE) + state = hass.states.get(ENTITY_ID) + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + == ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.AUX_HEAT + ) + + async def test_current_temperature(ecobee_fixture, thermostat) -> None: """Test current temperature.""" assert thermostat.current_temperature == 30 @@ -201,11 +242,27 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None: } == thermostat.extra_state_attributes -async def test_is_aux_heat_on(ecobee_fixture, thermostat) -> None: - """Test aux heat property.""" - assert not thermostat.is_aux_heat - ecobee_fixture["equipmentStatus"] = "fan, auxHeat" - assert thermostat.is_aux_heat +async def test_is_aux_heat_on(hass): + """Test aux heat property is only enabled for auxHeatOnly.""" + mock_get_thermostat = mock.Mock() + mock_get_thermostat.return_value = copy.deepcopy( + GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP + ) + mock_get_thermostat.return_value["settings"]["hvacMode"] = "auxHeatOnly" + with mock.patch("pyecobee.Ecobee.get_thermostat", mock_get_thermostat): + await setup_platform(hass, const.Platform.CLIMATE) + state = hass.states.get(ENTITY_ID) + assert state.attributes[climate.ATTR_AUX_HEAT] == "on" + + +async def test_is_aux_heat_off(hass): + """Test aux heat property is only enabled for auxHeatOnly.""" + mock_get_thermostat = mock.Mock() + mock_get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP + with mock.patch("pyecobee.Ecobee.get_thermostat", mock_get_thermostat): + await setup_platform(hass, const.Platform.CLIMATE) + state = hass.states.get(ENTITY_ID) + assert state.attributes[climate.ATTR_AUX_HEAT] == "off" async def test_set_temperature(ecobee_fixture, thermostat, data) -> None: @@ -335,3 +392,33 @@ async def test_set_fan_mode_auto(thermostat, data) -> None: data.ecobee.set_fan_mode.assert_has_calls( [mock.call(1, "auto", "nextTransition", holdHours=None)] ) + + +async def test_turn_aux_heat_on(hass: HomeAssistant, mock_ecobee: MagicMock) -> None: + """Test when aux heat is set on. This must change the HVAC mode.""" + mock_ecobee.get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP + mock_ecobee.thermostats = [GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP] + await setup_platform(hass, const.Platform.CLIMATE) + await hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: ENTITY_ID, climate.ATTR_AUX_HEAT: True}, + blocking=True, + ) + assert mock_ecobee.set_hvac_mode.call_count == 1 + assert mock_ecobee.set_hvac_mode.call_args == mock.call(0, ECOBEE_AUX_HEAT_ONLY) + + +async def test_turn_aux_heat_off(hass: HomeAssistant, mock_ecobee: MagicMock) -> None: + """Test when aux heat is tuned off. Must change HVAC mode back to last used.""" + mock_ecobee.get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP + mock_ecobee.thermostats = [GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP] + await setup_platform(hass, const.Platform.CLIMATE) + await hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: ENTITY_ID, climate.ATTR_AUX_HEAT: False}, + blocking=True, + ) + assert mock_ecobee.set_hvac_mode.call_count == 1 + assert mock_ecobee.set_hvac_mode.call_args == mock.call(0, "auto")