diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py index 44abafe8380..caf25690a9d 100644 --- a/homeassistant/components/ecobee/const.py +++ b/homeassistant/components/ecobee/const.py @@ -37,7 +37,7 @@ ECOBEE_MODEL_TO_NAME = { "vulcanSmart": "ecobee4 Smart", } -PLATFORMS = ["binary_sensor", "climate", "sensor", "weather"] +PLATFORMS = ["binary_sensor", "climate", "humidifier", "sensor", "weather"] MANUFACTURER = "ecobee" diff --git a/homeassistant/components/ecobee/humidifier.py b/homeassistant/components/ecobee/humidifier.py new file mode 100644 index 00000000000..5067d5080cb --- /dev/null +++ b/homeassistant/components/ecobee/humidifier.py @@ -0,0 +1,123 @@ +"""Support for using humidifier with ecobee thermostats.""" +from datetime import timedelta + +from homeassistant.components.humidifier import HumidifierEntity +from homeassistant.components.humidifier.const import ( + DEFAULT_MAX_HUMIDITY, + DEFAULT_MIN_HUMIDITY, + DEVICE_CLASS_HUMIDIFIER, + MODE_AUTO, + SUPPORT_MODES, +) + +from .const import DOMAIN + +SCAN_INTERVAL = timedelta(minutes=3) + +MODE_MANUAL = "manual" +MODE_OFF = "off" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the ecobee thermostat humidifier entity.""" + data = hass.data[DOMAIN] + entities = [] + for index in range(len(data.ecobee.thermostats)): + thermostat = data.ecobee.get_thermostat(index) + if thermostat["settings"]["hasHumidifier"]: + entities.append(EcobeeHumidifier(data, index)) + + async_add_entities(entities, True) + + +class EcobeeHumidifier(HumidifierEntity): + """A humidifier class for an ecobee thermostat with humidifer attached.""" + + def __init__(self, data, thermostat_index): + """Initialize ecobee humidifier platform.""" + self.data = data + self.thermostat_index = thermostat_index + self.thermostat = self.data.ecobee.get_thermostat(self.thermostat_index) + self._name = self.thermostat["name"] + self._last_humidifier_on_mode = MODE_MANUAL + + self.update_without_throttle = False + + async def async_update(self): + """Get the latest state from the thermostat.""" + if self.update_without_throttle: + await self.data.update(no_throttle=True) + self.update_without_throttle = False + else: + await self.data.update() + self.thermostat = self.data.ecobee.get_thermostat(self.thermostat_index) + if self.mode != MODE_OFF: + self._last_humidifier_on_mode = self.mode + + @property + def available_modes(self): + """Return the list of available modes.""" + return [MODE_OFF, MODE_AUTO, MODE_MANUAL] + + @property + def device_class(self): + """Return the device class type.""" + return DEVICE_CLASS_HUMIDIFIER + + @property + def is_on(self): + """Return True if the humidifier is on.""" + return self.mode != MODE_OFF + + @property + def max_humidity(self): + """Return the maximum humidity.""" + return DEFAULT_MAX_HUMIDITY + + @property + def min_humidity(self): + """Return the minimum humidity.""" + return DEFAULT_MIN_HUMIDITY + + @property + def mode(self): + """Return the current mode, e.g., off, auto, manual.""" + return self.thermostat["settings"]["humidifierMode"] + + @property + def name(self): + """Return the name of the ecobee thermostat.""" + return self._name + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_MODES + + @property + def target_humidity(self) -> int: + """Return the desired humidity set point.""" + return int(self.thermostat["runtime"]["desiredHumidity"]) + + def set_mode(self, mode): + """Set humidifier mode (auto, off, manual).""" + if mode.lower() not in (self.available_modes): + raise ValueError( + f"Invalid mode value: {mode} Valid values are {', '.join(self.available_modes)}." + ) + + self.data.ecobee.set_humidifier_mode(self.thermostat_index, mode) + self.update_without_throttle = True + + def set_humidity(self, humidity): + """Set the humidity level.""" + self.data.ecobee.set_humidity(self.thermostat_index, humidity) + self.update_without_throttle = True + + def turn_off(self, **kwargs): + """Set humidifier to off mode.""" + self.set_mode(MODE_OFF) + + def turn_on(self, **kwargs): + """Set humidifier to on mode.""" + self.set_mode(self._last_humidifier_on_mode) diff --git a/tests/components/ecobee/common.py b/tests/components/ecobee/common.py new file mode 100644 index 00000000000..0422b35f787 --- /dev/null +++ b/tests/components/ecobee/common.py @@ -0,0 +1,27 @@ +"""Common methods used across tests for Ecobee.""" +from unittest.mock import patch + +from homeassistant.components.ecobee.const import CONF_REFRESH_TOKEN, DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def setup_platform(hass, platform): + """Set up the ecobee platform.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "ABC123", + CONF_REFRESH_TOKEN: "EFG456", + }, + ) + mock_entry.add_to_hass(hass) + + with patch("homeassistant.components.ecobee.const.PLATFORMS", [platform]): + assert await async_setup_component(hass, DOMAIN, {}) + + await hass.async_block_till_done() + + return mock_entry diff --git a/tests/components/ecobee/conftest.py b/tests/components/ecobee/conftest.py new file mode 100644 index 00000000000..a7766af2ff9 --- /dev/null +++ b/tests/components/ecobee/conftest.py @@ -0,0 +1,17 @@ +"""Fixtures for tests.""" +import pytest + +from tests.common import load_fixture + + +@pytest.fixture(autouse=True) +def requests_mock_fixture(requests_mock): + """Fixture to provide a requests mocker.""" + requests_mock.get( + "https://api.ecobee.com/1/thermostat", + text=load_fixture("ecobee/ecobee-data.json"), + ) + requests_mock.post( + "https://api.ecobee.com/token", + text=load_fixture("ecobee/ecobee-token.json"), + ) diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index 95b4b290b70..270c6cfec15 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -164,6 +164,7 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat): "fan_min_on_time": 10, "equipment_running": "auxHeat2", } == thermostat.extra_state_attributes + ecobee_fixture["equipmentStatus"] = "compCool1" assert { "fan": "off", diff --git a/tests/components/ecobee/test_humidifier.py b/tests/components/ecobee/test_humidifier.py new file mode 100644 index 00000000000..dd58decfb32 --- /dev/null +++ b/tests/components/ecobee/test_humidifier.py @@ -0,0 +1,130 @@ +"""The test for the ecobee thermostat humidifier module.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.ecobee.humidifier import MODE_MANUAL, MODE_OFF +from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN +from homeassistant.components.humidifier.const import ( + ATTR_AVAILABLE_MODES, + ATTR_HUMIDITY, + ATTR_MAX_HUMIDITY, + ATTR_MIN_HUMIDITY, + DEFAULT_MAX_HUMIDITY, + DEFAULT_MIN_HUMIDITY, + DEVICE_CLASS_HUMIDIFIER, + MODE_AUTO, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + SUPPORT_MODES, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_MODE, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, +) + +from .common import setup_platform + +DEVICE_ID = "humidifier.ecobee" + + +async def test_attributes(hass): + """Test the humidifier attributes are correct.""" + await setup_platform(hass, HUMIDIFIER_DOMAIN) + + state = hass.states.get(DEVICE_ID) + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_MIN_HUMIDITY) == DEFAULT_MIN_HUMIDITY + assert state.attributes.get(ATTR_MAX_HUMIDITY) == DEFAULT_MAX_HUMIDITY + assert state.attributes.get(ATTR_HUMIDITY) == 40 + assert state.attributes.get(ATTR_AVAILABLE_MODES) == [ + MODE_OFF, + MODE_AUTO, + MODE_MANUAL, + ] + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "ecobee" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDIFIER + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == SUPPORT_MODES + + +async def test_turn_on(hass): + """Test the humidifer can be turned on.""" + with patch("pyecobee.Ecobee.set_humidifier_mode") as mock_turn_on: + await setup_platform(hass, HUMIDIFIER_DOMAIN) + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: DEVICE_ID}, + blocking=True, + ) + await hass.async_block_till_done() + mock_turn_on.assert_called_once_with(0, "manual") + + +async def test_turn_off(hass): + """Test the humidifer can be turned off.""" + with patch("pyecobee.Ecobee.set_humidifier_mode") as mock_turn_off: + await setup_platform(hass, HUMIDIFIER_DOMAIN) + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: DEVICE_ID}, + blocking=True, + ) + await hass.async_block_till_done() + mock_turn_off.assert_called_once_with(0, STATE_OFF) + + +async def test_set_mode(hass): + """Test the humidifer can change modes.""" + with patch("pyecobee.Ecobee.set_humidifier_mode") as mock_set_mode: + await setup_platform(hass, HUMIDIFIER_DOMAIN) + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: DEVICE_ID, ATTR_MODE: MODE_AUTO}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_mode.assert_called_once_with(0, MODE_AUTO) + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: DEVICE_ID, ATTR_MODE: MODE_MANUAL}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_mode.assert_called_with(0, MODE_MANUAL) + + with pytest.raises(ValueError): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: DEVICE_ID, ATTR_MODE: "ModeThatDoesntExist"}, + blocking=True, + ) + + +async def test_set_humidity(hass): + """Test the humidifer can set humidity level.""" + with patch("pyecobee.Ecobee.set_humidity") as mock_set_humidity: + await setup_platform(hass, HUMIDIFIER_DOMAIN) + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: DEVICE_ID, ATTR_HUMIDITY: 60}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_humidity.assert_called_once_with(0, 60) diff --git a/tests/fixtures/ecobee/ecobee-data.json b/tests/fixtures/ecobee/ecobee-data.json new file mode 100644 index 00000000000..2727103c9b1 --- /dev/null +++ b/tests/fixtures/ecobee/ecobee-data.json @@ -0,0 +1,43 @@ +{ + "thermostatList": [ + {"name": "ecobee", + "program": { + "climates": [ + {"name": "Climate1", "climateRef": "c1"}, + {"name": "Climate2", "climateRef": "c2"} + ], + "currentClimateRef": "c1" + }, + "runtime": { + "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": true, + "humidifierMode": "off", + "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" + } + ]} + ] + +} \ No newline at end of file diff --git a/tests/fixtures/ecobee/ecobee-token.json b/tests/fixtures/ecobee/ecobee-token.json new file mode 100644 index 00000000000..6ee8305a592 --- /dev/null +++ b/tests/fixtures/ecobee/ecobee-token.json @@ -0,0 +1,7 @@ +{ + "access_token": "Rc7JE8P7XUgSCPogLOx2VLMfITqQQrjg", + "token_type": "Bearer", + "expires_in": 3599, + "refresh_token": "og2Obost3ucRo1ofo0EDoslGltmFMe2g", + "scope": "smartWrite" +} \ No newline at end of file