From fe9f6823c3c1bac51ce35b7956e9babf3c1c528c Mon Sep 17 00:00:00 2001 From: Marc-Olivier Arsenault Date: Tue, 7 Feb 2023 08:54:23 -0500 Subject: [PATCH] Add ecobee ventilator (#83645) Co-authored-by: G Johansson Co-authored-by: J. Nick Koston --- CODEOWNERS | 4 +- homeassistant/components/ecobee/__init__.py | 4 +- homeassistant/components/ecobee/const.py | 1 + homeassistant/components/ecobee/entity.py | 39 +++++++ homeassistant/components/ecobee/manifest.json | 2 +- homeassistant/components/ecobee/number.py | 107 ++++++++++++++++++ .../ecobee/fixtures/ecobee-data.json | 72 ++++++++++++ tests/components/ecobee/test_number.py | 74 ++++++++++++ 8 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/ecobee/entity.py create mode 100644 homeassistant/components/ecobee/number.py create mode 100644 tests/components/ecobee/test_number.py diff --git a/CODEOWNERS b/CODEOWNERS index 1cb4a6ba362..48407f3aaa7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -288,8 +288,8 @@ build.json @home-assistant/supervisor /tests/components/eafm/ @Jc2k /homeassistant/components/easyenergy/ @klaasnicolaas /tests/components/easyenergy/ @klaasnicolaas -/homeassistant/components/ecobee/ @marthoc -/tests/components/ecobee/ @marthoc +/homeassistant/components/ecobee/ @marthoc @marcolivierarsenault +/tests/components/ecobee/ @marthoc @marcolivierarsenault /homeassistant/components/econet/ @vangorra @w1ll1am23 /tests/components/econet/ @vangorra @w1ll1am23 /homeassistant/components/ecovacs/ @OverloadUT @mib1185 diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index 65be0475313..962eebc2a33 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -91,7 +91,9 @@ class EcobeeData: Also handle refreshing tokens and updating config entry with refreshed tokens. """ - def __init__(self, hass, entry, api_key, refresh_token): + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, api_key: str, refresh_token: str + ) -> None: """Initialize the Ecobee data object.""" self._hass = hass self._entry = entry diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py index 3f629fa48f2..23fe544d3c9 100644 --- a/homeassistant/components/ecobee/const.py +++ b/homeassistant/components/ecobee/const.py @@ -45,6 +45,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.HUMIDIFIER, + Platform.NUMBER, Platform.SENSOR, Platform.WEATHER, ] diff --git a/homeassistant/components/ecobee/entity.py b/homeassistant/components/ecobee/entity.py new file mode 100644 index 00000000000..4bb2036bb4b --- /dev/null +++ b/homeassistant/components/ecobee/entity.py @@ -0,0 +1,39 @@ +"""Base classes shared among Ecobee entities.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.helpers.entity import DeviceInfo, Entity + +from . import EcobeeData +from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +class EcobeeBaseEntity(Entity): + """Base methods for Ecobee entities.""" + + def __init__(self, data: EcobeeData, thermostat_index: int) -> None: + """Initiate base methods for Ecobee entities.""" + self.data = data + self.thermostat_index = thermostat_index + thermostat = self.thermostat + self.base_unique_id = thermostat["identifier"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, thermostat["identifier"])}, + manufacturer=MANUFACTURER, + model=ECOBEE_MODEL_TO_NAME.get(thermostat["modelNumber"]), + name=thermostat["name"], + ) + + @property + def thermostat(self) -> dict[str, Any]: + """Return the thermostat data for the entity.""" + return self.data.ecobee.get_thermostat(self.thermostat_index) + + @property + def available(self) -> bool: + """Return if device is available.""" + return self.thermostat["runtime"]["connected"] diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 47a6e607e3b..500c3ec2218 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", "requirements": ["python-ecobee-api==0.2.14"], - "codeowners": ["@marthoc"], + "codeowners": ["@marthoc", "@marcolivierarsenault"], "homekit": { "models": ["EB-*", "ecobee*"] }, diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py new file mode 100644 index 00000000000..15ad17b0e39 --- /dev/null +++ b/homeassistant/components/ecobee/number.py @@ -0,0 +1,107 @@ +"""Support for using number with ecobee thermostats.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import logging + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EcobeeData +from .const import DOMAIN +from .entity import EcobeeBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class EcobeeNumberEntityDescriptionBase: + """Required values when describing Ecobee number entities.""" + + ecobee_setting_key: str + set_fn: Callable[[EcobeeData, int, int], Awaitable] + + +@dataclass +class EcobeeNumberEntityDescription( + NumberEntityDescription, EcobeeNumberEntityDescriptionBase +): + """Class describing Ecobee number entities.""" + + +VENTILATOR_NUMBERS = ( + EcobeeNumberEntityDescription( + key="home", + name="home", + ecobee_setting_key="ventilatorMinOnTimeHome", + set_fn=lambda data, id, min_time: data.ecobee.set_ventilator_min_on_time_home( + id, min_time + ), + ), + EcobeeNumberEntityDescription( + key="away", + name="away", + ecobee_setting_key="ventilatorMinOnTimeAway", + set_fn=lambda data, id, min_time: data.ecobee.set_ventilator_min_on_time_away( + id, min_time + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the ecobee thermostat number entity.""" + data: EcobeeData = hass.data[DOMAIN] + entities = [] + _LOGGER.debug("Adding min time ventilators numbers (if present)") + for index, thermostat in enumerate(data.ecobee.thermostats): + if thermostat["settings"]["ventilatorType"] == "none": + continue + _LOGGER.debug("Adding %s's ventilator min times number", thermostat["name"]) + for numbers in VENTILATOR_NUMBERS: + entities.append(EcobeeVentilatorMinTime(data, index, numbers)) + + async_add_entities(entities, True) + + +class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity): + """A number class, representing min time for an ecobee thermostat with ventilator attached.""" + + entity_description: EcobeeNumberEntityDescription + + _attr_native_min_value = 0 + _attr_native_max_value = 60 + _attr_native_step = 5 + _attr_native_unit_of_measurement = UnitOfTime.MINUTES + _attr_has_entity_name = True + + def __init__( + self, + data: EcobeeData, + thermostat_index: int, + description: EcobeeNumberEntityDescription, + ) -> None: + """Initialize ecobee ventilator platform.""" + super().__init__(data, thermostat_index) + self.entity_description = description + self._attr_name = f"Ventilator min time {description.name}" + self._attr_unique_id = f"{self.base_unique_id}_ventilator_{description.key}" + + async def async_update(self) -> None: + """Get the latest state from the thermostat.""" + await self.data.update() + self._attr_native_value = self.thermostat["settings"][ + self.entity_description.ecobee_setting_key + ] + + def set_native_value(self, value: float) -> None: + """Set new ventilator Min On Time value.""" + self.entity_description.set_fn(self.data, self.thermostat_index, int(value)) diff --git a/tests/components/ecobee/fixtures/ecobee-data.json b/tests/components/ecobee/fixtures/ecobee-data.json index 5dd4dd0d4bd..9fe19af35c6 100644 --- a/tests/components/ecobee/fixtures/ecobee-data.json +++ b/tests/components/ecobee/fixtures/ecobee-data.json @@ -27,6 +27,78 @@ "fanMinOnTime": 10, "heatCoolMinDelta": 50, "holdAction": "nextTransition", + "ventilatorType": "hrv", + "ventilatorMinOnTimeHome": 20, + "ventilatorMinOnTimeAway": 10, + "isVentilatorTimerOn": false, + "hasHumidifier": true, + "humidifierMode": "manual", + "humidity": "30" + }, + "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" + } + ] + } + ] + }, + { + "identifier": 8675308, + "name": "ecobee2", + "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", + "ventilatorType": "none", + "ventilatorMinOnTimeHome": 20, + "ventilatorMinOnTimeAway": 10, + "isVentilatorTimerOn": false, "hasHumidifier": true, "humidifierMode": "manual", "humidity": "30" diff --git a/tests/components/ecobee/test_number.py b/tests/components/ecobee/test_number.py new file mode 100644 index 00000000000..803a6fb5091 --- /dev/null +++ b/tests/components/ecobee/test_number.py @@ -0,0 +1,74 @@ +"""The test for the ecobee thermostat number module.""" +from unittest.mock import patch + +from homeassistant.components.number import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID, UnitOfTime +from homeassistant.core import HomeAssistant + +from .common import setup_platform + +VENTILATOR_MIN_HOME_ID = "number.ecobee_ventilator_min_time_home" +VENTILATOR_MIN_AWAY_ID = "number.ecobee_ventilator_min_time_away" +THERMOSTAT_ID = 0 + + +async def test_ventilator_min_on_home_attributes(hass): + """Test the ventilator number on home attributes are correct.""" + await setup_platform(hass, DOMAIN) + + state = hass.states.get(VENTILATOR_MIN_HOME_ID) + assert state.state == "20" + assert state.attributes.get("min") == 0 + assert state.attributes.get("max") == 60 + assert state.attributes.get("step") == 5 + assert state.attributes.get("friendly_name") == "ecobee Ventilator min time home" + assert state.attributes.get("unit_of_measurement") == UnitOfTime.MINUTES + + +async def test_ventilator_min_on_away_attributes(hass): + """Test the ventilator number on away attributes are correct.""" + await setup_platform(hass, DOMAIN) + + state = hass.states.get(VENTILATOR_MIN_AWAY_ID) + assert state.state == "10" + assert state.attributes.get("min") == 0 + assert state.attributes.get("max") == 60 + assert state.attributes.get("step") == 5 + assert state.attributes.get("friendly_name") == "ecobee Ventilator min time away" + assert state.attributes.get("unit_of_measurement") == UnitOfTime.MINUTES + + +async def test_set_min_time_home(hass: HomeAssistant): + """Test the number can set min time home.""" + target_value = 40 + with patch( + "homeassistant.components.ecobee.Ecobee.set_ventilator_min_on_time_home" + ) as mock_set_min_home_time: + await setup_platform(hass, DOMAIN) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: VENTILATOR_MIN_HOME_ID, ATTR_VALUE: target_value}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_min_home_time.assert_called_once_with(THERMOSTAT_ID, target_value) + + +async def test_set_min_time_away(hass: HomeAssistant) -> None: + """Test the number can set min time away.""" + target_value = 0 + with patch( + "homeassistant.components.ecobee.Ecobee.set_ventilator_min_on_time_away" + ) as mock_set_min_away_time: + await setup_platform(hass, DOMAIN) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: VENTILATOR_MIN_AWAY_ID, ATTR_VALUE: target_value}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_min_away_time.assert_called_once_with(THERMOSTAT_ID, target_value)