Support Ecobee climate Aux Heat on/off (#86100)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Luke 2023-02-20 18:56:03 -05:00 committed by GitHub
parent f0af0e2b42
commit 84763c793d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 272 additions and 34 deletions

View file

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

View file

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

View file

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

View file

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

View file

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