Create auxHeatOnly switch in Ecobee integration (#116323)

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
Brent Petit 2024-06-22 09:44:43 -05:00 committed by GitHub
parent 856aa38539
commit ed0e0eee71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 191 additions and 18 deletions

View File

@ -36,10 +36,17 @@ from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.util.unit_conversion import TemperatureConverter
from . import EcobeeData
from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER
from .const import (
_LOGGER,
DOMAIN,
ECOBEE_AUX_HEAT_ONLY,
ECOBEE_MODEL_TO_NAME,
MANUFACTURER,
)
from .util import ecobee_date, ecobee_time, is_indefinite_hold
ATTR_COOL_TEMP = "cool_temp"
@ -69,9 +76,6 @@ 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(
[
@ -79,9 +83,13 @@ ECOBEE_HVAC_TO_HASS = collections.OrderedDict(
("cool", HVACMode.COOL),
("auto", HVACMode.HEAT_COOL),
("off", HVACMode.OFF),
("auxHeatOnly", HVACMode.HEAT),
(ECOBEE_AUX_HEAT_ONLY, HVACMode.HEAT),
]
)
# Reverse key/value pair, drop auxHeatOnly as it doesn't map to specific HASS mode
HASS_TO_ECOBEE_HVAC = {
v: k for k, v in ECOBEE_HVAC_TO_HASS.items() if k != ECOBEE_AUX_HEAT_ONLY
}
ECOBEE_HVAC_ACTION_TO_HASS = {
# Map to None if we do not know how to represent.
@ -570,17 +578,39 @@ class Thermostat(ClimateEntity):
"""Return true if aux heater."""
return self.settings["hvacMode"] == ECOBEE_AUX_HEAT_ONLY
def turn_aux_heat_on(self) -> None:
async def async_turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
async_create_issue(
self.hass,
DOMAIN,
"migrate_aux_heat",
breaks_in_ha_version="2024.10.0",
is_fixable=True,
is_persistent=True,
translation_key="migrate_aux_heat",
severity=IssueSeverity.WARNING,
)
_LOGGER.debug("Setting HVAC mode to auxHeatOnly to turn on aux heat")
self._last_hvac_mode_before_aux_heat = self.hvac_mode
self.data.ecobee.set_hvac_mode(self.thermostat_index, ECOBEE_AUX_HEAT_ONLY)
await self.hass.async_add_executor_job(
self.data.ecobee.set_hvac_mode, self.thermostat_index, ECOBEE_AUX_HEAT_ONLY
)
self.update_without_throttle = True
def turn_aux_heat_off(self) -> None:
async def async_turn_aux_heat_off(self) -> None:
"""Turn auxiliary heater off."""
async_create_issue(
self.hass,
DOMAIN,
"migrate_aux_heat",
breaks_in_ha_version="2024.10.0",
is_fixable=True,
is_persistent=True,
translation_key="migrate_aux_heat",
severity=IssueSeverity.WARNING,
)
_LOGGER.debug("Setting HVAC mode to last mode to disable aux heat")
self.set_hvac_mode(self._last_hvac_mode_before_aux_heat)
await self.async_set_hvac_mode(self._last_hvac_mode_before_aux_heat)
self.update_without_throttle = True
def set_preset_mode(self, preset_mode: str) -> None:
@ -740,9 +770,7 @@ class Thermostat(ClimateEntity):
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set HVAC mode (auto, auxHeatOnly, cool, heat, off)."""
ecobee_value = next(
(k for k, v in ECOBEE_HVAC_TO_HASS.items() if v == hvac_mode), None
)
ecobee_value = HASS_TO_ECOBEE_HVAC.get(hvac_mode)
if ecobee_value is None:
_LOGGER.error("Invalid mode for set_hvac_mode: %s", hvac_mode)
return

View File

@ -55,6 +55,8 @@ PLATFORMS = [
MANUFACTURER = "ecobee"
ECOBEE_AUX_HEAT_ONLY = "auxHeatOnly"
# Translates ecobee API weatherSymbol to Home Assistant usable names
# https://www.ecobee.com/home/developer/api/documentation/v1/objects/WeatherForecast.shtml
ECOBEE_WEATHER_SYMBOL_TO_HASS = {

View File

@ -38,6 +38,11 @@
"ventilator_min_type_away": {
"name": "Ventilator min time away"
}
},
"switch": {
"aux_heat_only": {
"name": "Aux heat only"
}
}
},
"services": {
@ -163,5 +168,18 @@
}
}
}
},
"issues": {
"migrate_aux_heat": {
"title": "Migration of Ecobee set_aux_heat service",
"fix_flow": {
"step": {
"confirm": {
"description": "The Ecobee `set_aux_heat` service has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.",
"title": "Disable legacy Ecobee set_aux_heat service"
}
}
}
}
}
}

View File

@ -6,6 +6,7 @@ from datetime import tzinfo
import logging
from typing import Any
from homeassistant.components.climate import HVACMode
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@ -13,7 +14,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from . import EcobeeData
from .const import DOMAIN
from .climate import HASS_TO_ECOBEE_HVAC
from .const import DOMAIN, ECOBEE_AUX_HEAT_ONLY
from .entity import EcobeeBaseEntity
_LOGGER = logging.getLogger(__name__)
@ -43,6 +45,12 @@ async def async_setup_entry(
update_before_add=True,
)
async_add_entities(
EcobeeSwitchAuxHeatOnly(data, index)
for index, thermostat in enumerate(data.ecobee.thermostats)
if thermostat["settings"]["hasHeatPump"]
)
class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity):
"""A Switch class, representing 20 min timer for an ecobee thermostat with ventilator attached."""
@ -93,3 +101,39 @@ class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity):
self.data.ecobee.set_ventilator_timer, self.thermostat_index, False
)
self.update_without_throttle = True
class EcobeeSwitchAuxHeatOnly(EcobeeBaseEntity, SwitchEntity):
"""Representation of a aux_heat_only ecobee switch."""
_attr_has_entity_name = True
_attr_translation_key = "aux_heat_only"
def __init__(
self,
data: EcobeeData,
thermostat_index: int,
) -> None:
"""Initialize ecobee ventilator platform."""
super().__init__(data, thermostat_index)
self._attr_unique_id = f"{self.base_unique_id}_aux_heat_only"
self._last_hvac_mode_before_aux_heat = HASS_TO_ECOBEE_HVAC.get(
HVACMode.HEAT_COOL
)
def turn_on(self, **kwargs: Any) -> None:
"""Set the hvacMode to auxHeatOnly."""
self._last_hvac_mode_before_aux_heat = self.thermostat["settings"]["hvacMode"]
self.data.ecobee.set_hvac_mode(self.thermostat_index, ECOBEE_AUX_HEAT_ONLY)
def turn_off(self, **kwargs: Any) -> None:
"""Set the hvacMode back to the prior setting."""
self.data.ecobee.set_hvac_mode(
self.thermostat_index, self._last_hvac_mode_before_aux_heat
)
@property
def is_on(self) -> bool:
"""Return true if auxHeatOnly mode is active."""
return self.thermostat["settings"]["hvacMode"] == ECOBEE_AUX_HEAT_ONLY

View File

@ -11,8 +11,14 @@
},
"program": {
"climates": [
{ "name": "Climate1", "climateRef": "c1" },
{ "name": "Climate2", "climateRef": "c2" }
{
"name": "Climate1",
"climateRef": "c1"
},
{
"name": "Climate2",
"climateRef": "c2"
}
],
"currentClimateRef": "c1"
},
@ -39,6 +45,7 @@
"isVentilatorTimerOn": false,
"hasHumidifier": true,
"humidifierMode": "manual",
"hasHeatPump": false,
"humidity": "30"
},
"equipmentStatus": "fan",
@ -82,8 +89,14 @@
"modelNumber": "athenaSmart",
"program": {
"climates": [
{ "name": "Climate1", "climateRef": "c1" },
{ "name": "Climate2", "climateRef": "c2" }
{
"name": "Climate1",
"climateRef": "c1"
},
{
"name": "Climate2",
"climateRef": "c2"
}
],
"currentClimateRef": "c1"
},
@ -109,6 +122,7 @@
"isVentilatorTimerOn": false,
"hasHumidifier": true,
"humidifierMode": "manual",
"hasHeatPump": true,
"humidity": "30"
},
"equipmentStatus": "fan",
@ -184,6 +198,7 @@
"isVentilatorTimerOn": false,
"hasHumidifier": true,
"humidifierMode": "manual",
"hasHeatPump": false,
"humidity": "30"
},
"equipmentStatus": "fan",

View File

@ -3,6 +3,11 @@
from http import HTTPStatus
from unittest.mock import MagicMock
from homeassistant.components.climate import (
ATTR_AUX_HEAT,
DOMAIN as CLIMATE_DOMAIN,
SERVICE_SET_AUX_HEAT,
)
from homeassistant.components.ecobee import DOMAIN
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
from homeassistant.components.repairs.issue_handler import (
@ -12,6 +17,7 @@ from homeassistant.components.repairs.websocket_api import (
RepairsFlowIndexView,
RepairsFlowResourceView,
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@ -22,7 +28,7 @@ from tests.typing import ClientSessionGenerator
THERMOSTAT_ID = 0
async def test_ecobee_repair_flow(
async def test_ecobee_notify_repair_flow(
hass: HomeAssistant,
mock_ecobee: MagicMock,
hass_client: ClientSessionGenerator,
@ -77,3 +83,32 @@ async def test_ecobee_repair_flow(
issue_id=f"migrate_notify_{DOMAIN}_{DOMAIN}",
)
assert len(issue_registry.issues) == 0
async def test_ecobee_aux_heat_repair_flow(
hass: HomeAssistant,
mock_ecobee: MagicMock,
hass_client: ClientSessionGenerator,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test the ecobee aux_heat service repair flow is triggered."""
await setup_platform(hass, CLIMATE_DOMAIN)
await async_process_repairs_platforms(hass)
ENTITY_ID = "climate.ecobee2"
# Simulate legacy service being used
assert hass.services.has_service(CLIMATE_DOMAIN, SERVICE_SET_AUX_HEAT)
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_AUX_HEAT,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_AUX_HEAT: True},
blocking=True,
)
# Assert the issue is present
assert issue_registry.async_get_issue(
domain="ecobee",
issue_id="migrate_aux_heat",
)
assert len(issue_registry.issues) == 1

View File

@ -112,3 +112,34 @@ async def test_turn_off_20min_ventilator(hass: HomeAssistant) -> None:
)
await hass.async_block_till_done()
mock_set_20min_ventilator.assert_called_once_with(THERMOSTAT_ID, False)
DEVICE_ID = "switch.ecobee2_aux_heat_only"
async def test_aux_heat_only_turn_on(hass: HomeAssistant) -> None:
"""Test the switch can be turned on."""
with patch("pyecobee.Ecobee.set_hvac_mode") as mock_turn_on:
await setup_platform(hass, DOMAIN)
await hass.services.async_call(
DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: DEVICE_ID},
blocking=True,
)
mock_turn_on.assert_called_once_with(1, "auxHeatOnly")
async def test_aux_heat_only_turn_off(hass: HomeAssistant) -> None:
"""Test the switch can be turned off."""
with patch("pyecobee.Ecobee.set_hvac_mode") as mock_turn_off:
await setup_platform(hass, DOMAIN)
await hass.services.async_call(
DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: DEVICE_ID},
blocking=True,
)
mock_turn_off.assert_called_once_with(1, "auto")