diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 11675c0bf61..8dcc7285590 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -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 diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py index 8adc7f9638b..85a332f3c87 100644 --- a/homeassistant/components/ecobee/const.py +++ b/homeassistant/components/ecobee/const.py @@ -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 = { diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index b1d1df65417..56cf6e9ebf0 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -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" + } + } + } + } } } diff --git a/homeassistant/components/ecobee/switch.py b/homeassistant/components/ecobee/switch.py index 607585887f0..67be78fb21d 100644 --- a/homeassistant/components/ecobee/switch.py +++ b/homeassistant/components/ecobee/switch.py @@ -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 diff --git a/tests/components/ecobee/fixtures/ecobee-data.json b/tests/components/ecobee/fixtures/ecobee-data.json index c86782d9c0b..b2f336e064d 100644 --- a/tests/components/ecobee/fixtures/ecobee-data.json +++ b/tests/components/ecobee/fixtures/ecobee-data.json @@ -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", diff --git a/tests/components/ecobee/test_repairs.py b/tests/components/ecobee/test_repairs.py index 9821d31ac64..1473f8eb3a1 100644 --- a/tests/components/ecobee/test_repairs.py +++ b/tests/components/ecobee/test_repairs.py @@ -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 diff --git a/tests/components/ecobee/test_switch.py b/tests/components/ecobee/test_switch.py index 94b7296dcf5..05cea5a5e9d 100644 --- a/tests/components/ecobee/test_switch.py +++ b/tests/components/ecobee/test_switch.py @@ -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")