Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create auxHeatOnly switch in Ecobee integration #116323

Open
wants to merge 3 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
52 changes: 40 additions & 12 deletions homeassistant/components/ecobee/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,17 @@
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"
Expand Down Expand Up @@ -69,19 +76,20 @@
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(
[
("heat", HVACMode.HEAT),
("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.
Expand Down Expand Up @@ -570,17 +578,39 @@ def is_aux_heat(self) -> bool:
"""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:
Expand Down Expand Up @@ -740,9 +770,7 @@ def set_humidity(self, humidity: int) -> None:

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
Expand Down
2 changes: 2 additions & 0 deletions homeassistant/components/ecobee/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@

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 = {
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/ecobee/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"name": "ecobee",
"codeowners": [],
"config_flow": true,
"dependencies": ["http", "repairs"],
"documentation": "https://www.home-assistant.io/integrations/ecobee",
"homekit": {
"models": ["EB", "ecobee*"]
Expand Down
13 changes: 13 additions & 0 deletions homeassistant/components/ecobee/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,5 +163,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"
}
}
}
}
}
}
48 changes: 47 additions & 1 deletion homeassistant/components/ecobee/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
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
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__)
Expand All @@ -37,6 +39,12 @@ async def async_setup_entry(
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."""
Expand Down Expand Up @@ -88,3 +96,41 @@ async def async_turn_off(self, **kwargs: Any) -> None:
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_name = "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."""
_LOGGER.debug("Setting HVAC mode to auxHeatOnly to turn on aux heat")
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."""
_LOGGER.debug("Setting HVAC mode to last mode to disable aux heat")
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
23 changes: 19 additions & 4 deletions tests/components/ecobee/fixtures/ecobee-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@
},
"program": {
"climates": [
{ "name": "Climate1", "climateRef": "c1" },
{ "name": "Climate2", "climateRef": "c2" }
{
"name": "Climate1",
"climateRef": "c1"
},
{
"name": "Climate2",
"climateRef": "c2"
}
],
"currentClimateRef": "c1"
},
Expand All @@ -39,6 +45,7 @@
"isVentilatorTimerOn": false,
"hasHumidifier": true,
"humidifierMode": "manual",
"hasHeatPump": false,
"humidity": "30"
},
"equipmentStatus": "fan",
Expand Down Expand Up @@ -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"
},
Expand All @@ -109,6 +122,7 @@
"isVentilatorTimerOn": false,
"hasHumidifier": true,
"humidifierMode": "manual",
"hasHeatPump": true,
"humidity": "30"
},
"equipmentStatus": "fan",
Expand Down Expand Up @@ -184,6 +198,7 @@
"isVentilatorTimerOn": false,
"hasHumidifier": true,
"humidifierMode": "manual",
"hasHeatPump": false,
"humidity": "30"
},
"equipmentStatus": "fan",
Expand Down
65 changes: 64 additions & 1 deletion tests/components/ecobee/test_repairs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -12,6 +17,7 @@
RepairsFlowIndexView,
RepairsFlowResourceView,
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir

Expand All @@ -22,7 +28,7 @@
THERMOSTAT_ID = 0


async def test_ecobee_repair_flow(
async def test_ecobee_notify_repair_flow(
hass: HomeAssistant,
mock_ecobee: MagicMock,
hass_client: ClientSessionGenerator,
Expand Down Expand Up @@ -77,3 +83,60 @@ async def test_ecobee_repair_flow(
issue_id="migrate_notify",
)
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)

http_client = await hass_client()

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,
)
await hass.async_block_till_done()

# Assert the issue is present
assert issue_registry.async_get_issue(
domain="ecobee",
issue_id="migrate_aux_heat",
)
assert len(issue_registry.issues) == 1

url = RepairsFlowIndexView.url
resp = await http_client.post(
url, json={"handler": "ecobee", "issue_id": "migrate_aux_heat"}
)
assert resp.status == HTTPStatus.OK
data = await resp.json()

flow_id = data["flow_id"]
assert data["step_id"] == "confirm"

url = RepairsFlowResourceView.url.format(flow_id=flow_id)
resp = await http_client.post(url)
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data["type"] == "create_entry"
# Test confirm step in repair flow
await hass.async_block_till_done()

# Assert the issue is no longer present
assert not issue_registry.async_get_issue(
domain="ecobee",
issue_id="migrate_aux_heat",
)
assert len(issue_registry.issues) == 0
33 changes: 33 additions & 0 deletions tests/components/ecobee/test_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,36 @@ 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,
)
await hass.async_block_till_done()
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,
)
await hass.async_block_till_done()
mock_turn_off.assert_called_once_with(1, "auto")