From c9f1829c0bf0b0cad5427614b40f1bc4aadd2c4f Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 18 Dec 2024 09:27:40 -0500 Subject: [PATCH] Add (de)humidifier platform to Honeywell (#132287) Co-authored-by: Joost Lekkerkerker --- .../components/honeywell/__init__.py | 2 +- .../components/honeywell/humidifier.py | 136 ++++++++++++++++++ .../components/honeywell/strings.json | 8 ++ tests/components/honeywell/__init__.py | 2 +- tests/components/honeywell/conftest.py | 23 ++- .../honeywell/snapshots/test_humidity.ambr | 39 +++++ tests/components/honeywell/test_climate.py | 2 +- tests/components/honeywell/test_humidity.py | 110 ++++++++++++++ 8 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/honeywell/humidifier.py create mode 100644 tests/components/honeywell/snapshots/test_humidity.ambr create mode 100644 tests/components/honeywell/test_humidity.py diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index a8ee5975914..eb89ba2a681 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -22,7 +22,7 @@ from .const import ( ) UPDATE_LOOP_SLEEP_TIME = 5 -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.CLIMATE, Platform.HUMIDIFIER, Platform.SENSOR, Platform.SWITCH] MIGRATE_OPTIONS_KEYS = {CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE} diff --git a/homeassistant/components/honeywell/humidifier.py b/homeassistant/components/honeywell/humidifier.py new file mode 100644 index 00000000000..e94ba465c30 --- /dev/null +++ b/homeassistant/components/honeywell/humidifier.py @@ -0,0 +1,136 @@ +"""Support for Honeywell (de)humidifiers.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from aiosomecomfort.device import Device + +from homeassistant.components.humidifier import ( + HumidifierDeviceClass, + HumidifierEntity, + HumidifierEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HoneywellConfigEntry +from .const import DOMAIN + +HUMIDIFIER_KEY = "humidifier" +DEHUMIDIFIER_KEY = "dehumidifier" + + +@dataclass(frozen=True, kw_only=True) +class HoneywellHumidifierEntityDescription(HumidifierEntityDescription): + """Describes a Honeywell humidifier entity.""" + + current_humidity: Callable[[Device], Any] + current_set_humidity: Callable[[Device], Any] + max_humidity: Callable[[Device], Any] + min_humidity: Callable[[Device], Any] + set_humidity: Callable[[Device, Any], Any] + mode: Callable[[Device], Any] + off: Callable[[Device], Any] + on: Callable[[Device], Any] + + +HUMIDIFIERS: dict[str, HoneywellHumidifierEntityDescription] = { + "Humidifier": HoneywellHumidifierEntityDescription( + key=HUMIDIFIER_KEY, + translation_key=HUMIDIFIER_KEY, + current_humidity=lambda device: device.current_humidity, + set_humidity=lambda device, humidity: device.set_humidifier_setpoint(humidity), + min_humidity=lambda device: device.humidifier_lower_limit, + max_humidity=lambda device: device.humidifier_upper_limit, + current_set_humidity=lambda device: device.humidifier_setpoint, + mode=lambda device: device.humidifier_mode, + off=lambda device: device.set_humidifier_off(), + on=lambda device: device.set_humidifier_auto(), + device_class=HumidifierDeviceClass.HUMIDIFIER, + ), + "Dehumidifier": HoneywellHumidifierEntityDescription( + key=DEHUMIDIFIER_KEY, + translation_key=DEHUMIDIFIER_KEY, + current_humidity=lambda device: device.current_humidity, + set_humidity=lambda device, humidity: device.set_dehumidifier_setpoint( + humidity + ), + min_humidity=lambda device: device.dehumidifier_lower_limit, + max_humidity=lambda device: device.dehumidifier_upper_limit, + current_set_humidity=lambda device: device.dehumidifier_setpoint, + mode=lambda device: device.dehumidifier_mode, + off=lambda device: device.set_dehumidifier_off(), + on=lambda device: device.set_dehumidifier_auto(), + device_class=HumidifierDeviceClass.DEHUMIDIFIER, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HoneywellConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Honeywell (de)humidifier dynamically.""" + data = config_entry.runtime_data + entities: list = [] + for device in data.devices.values(): + if device.has_humidifier: + entities.append(HoneywellHumidifier(device, HUMIDIFIERS["Humidifier"])) + if device.has_dehumidifier: + entities.append(HoneywellHumidifier(device, HUMIDIFIERS["Dehumidifier"])) + + async_add_entities(entities) + + +class HoneywellHumidifier(HumidifierEntity): + """Representation of a Honeywell US (De)Humidifier.""" + + entity_description: HoneywellHumidifierEntityDescription + _attr_has_entity_name = True + + def __init__( + self, device: Device, description: HoneywellHumidifierEntityDescription + ) -> None: + """Initialize the (De)Humidifier.""" + self._device = device + self.entity_description = description + self._attr_unique_id = f"{device.deviceid}_{description.key}" + self._attr_min_humidity = description.min_humidity(device) + self._attr_max_humidity = description.max_humidity(device) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.deviceid)}, + name=device.name, + manufacturer="Honeywell", + ) + + @property + def is_on(self) -> bool: + """Return the device is on or off.""" + return self.entity_description.mode(self._device) != 0 + + @property + def target_humidity(self) -> int | None: + """Return the humidity we try to reach.""" + return self.entity_description.current_set_humidity(self._device) + + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + return self.entity_description.current_humidity(self._device) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.entity_description.on(self._device) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.entity_description.off(self._device) + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + await self.entity_description.set_humidity(self._device, humidity) diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index a64f1a6fce0..2538e7101a1 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -61,6 +61,14 @@ } } } + }, + "humidifier": { + "humidifier": { + "name": "[%key:component::humidifier::title%]" + }, + "dehumidifier": { + "name": "[%key:component::humidifier::entity_component::dehumidifier::name%]" + } } }, "exceptions": { diff --git a/tests/components/honeywell/__init__.py b/tests/components/honeywell/__init__.py index 98fcaa551bf..94022667e0e 100644 --- a/tests/components/honeywell/__init__.py +++ b/tests/components/honeywell/__init__.py @@ -1,4 +1,4 @@ -"""Tests for honeywell component.""" +"""Tests for Honeywell component.""" from unittest.mock import MagicMock diff --git a/tests/components/honeywell/conftest.py b/tests/components/honeywell/conftest.py index e48664db9ae..dd3341aa75c 100644 --- a/tests/components/honeywell/conftest.py +++ b/tests/components/honeywell/conftest.py @@ -127,7 +127,16 @@ def device(): mock_device.refresh = AsyncMock() mock_device.heat_away_temp = HEATAWAY mock_device.cool_away_temp = COOLAWAY - + mock_device.has_humidifier = False + mock_device.has_dehumidifier = False + mock_device.humidifier_upper_limit = 60 + mock_device.humidifier_lower_limit = 10 + mock_device.humidifier_setpoint = 20 + mock_device.dehumidifier_mode = 1 + mock_device.dehumidifier_upper_limit = 55 + mock_device.dehumidifier_lower_limit = 15 + mock_device.dehumidifier_setpoint = 30 + mock_device.dehumidifier_mode = 1 mock_device.raw_dr_data = {"CoolSetpLimit": None, "HeatSetpLimit": None} return mock_device @@ -149,6 +158,8 @@ def device_with_outdoor_sensor(): mock_device.temperature_unit = "C" mock_device.outdoor_temperature = OUTDOORTEMP mock_device.outdoor_humidity = OUTDOORHUMIDITY + mock_device.has_humidifier = False + mock_device.has_dehumidifier = False mock_device.raw_ui_data = { "SwitchOffAllowed": True, "SwitchAutoAllowed": True, @@ -188,6 +199,16 @@ def another_device(): mock_device.mac_address = "macaddress1" mock_device.outdoor_temperature = None mock_device.outdoor_humidity = None + mock_device.has_humidifier = False + mock_device.has_dehumidifier = False + mock_device.humidifier_upper_limit = 60 + mock_device.humidifier_lower_limit = 10 + mock_device.humidifier_setpoint = 20 + mock_device.dehumidifier_mode = 1 + mock_device.dehumidifier_upper_limit = 55 + mock_device.dehumidifier_lower_limit = 15 + mock_device.dehumidifier_setpoint = 30 + mock_device.dehumidifier_mode = 1 mock_device.raw_ui_data = { "SwitchOffAllowed": True, "SwitchAutoAllowed": True, diff --git a/tests/components/honeywell/snapshots/test_humidity.ambr b/tests/components/honeywell/snapshots/test_humidity.ambr new file mode 100644 index 00000000000..369167b8c1e --- /dev/null +++ b/tests/components/honeywell/snapshots/test_humidity.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_static_attributes[dehumidifier] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 50, + 'device_class': 'dehumidifier', + 'friendly_name': 'device1 Dehumidifier', + 'humidity': 30, + 'max_humidity': 55, + 'min_humidity': 15, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.device1_dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_static_attributes[humidifier] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 50, + 'device_class': 'humidifier', + 'friendly_name': 'device1 Humidifier', + 'humidity': 20, + 'max_humidity': 60, + 'min_humidity': 10, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.device1_humidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 73c5ff33dbc..57cdfaa9a23 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -1,4 +1,4 @@ -"""Test the Whirlpool Sixth Sense climate domain.""" +"""Test the Honeywell climate domain.""" import datetime from unittest.mock import MagicMock diff --git a/tests/components/honeywell/test_humidity.py b/tests/components/honeywell/test_humidity.py new file mode 100644 index 00000000000..2e1f8cec6aa --- /dev/null +++ b/tests/components/honeywell/test_humidity.py @@ -0,0 +1,110 @@ +"""Test the Honeywell humidity domain.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + DOMAIN as HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + + +async def test_humidifier_service_calls( + hass: HomeAssistant, device: MagicMock, config_entry: MagicMock +) -> None: + """Test the setup of the climate entities when there are no additional options available.""" + device.has_humidifier = True + await init_integration(hass, config_entry) + entity_id = f"humidifier.{device.name}_humidifier" + assert hass.states.get(f"humidifier.{device.name}_dehumidifier") is None + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device.set_humidifier_auto.assert_called_once() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device.set_humidifier_off.assert_called_once() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: entity_id, ATTR_HUMIDITY: 40}, + blocking=True, + ) + device.set_humidifier_setpoint.assert_called_once_with(40) + + +async def test_dehumidifier_service_calls( + hass: HomeAssistant, device: MagicMock, config_entry: MagicMock +) -> None: + """Test the setup of the climate entities when there are no additional options available.""" + device.has_dehumidifier = True + await init_integration(hass, config_entry) + entity_id = f"humidifier.{device.name}_dehumidifier" + assert hass.states.get(f"humidifier.{device.name}_humidifier") is None + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device.set_dehumidifier_auto.assert_called_once() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device.set_dehumidifier_off.assert_called_once() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: entity_id, ATTR_HUMIDITY: 40}, + blocking=True, + ) + device.set_dehumidifier_setpoint.assert_called_once_with(40) + + +async def test_static_attributes( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device: MagicMock, + config_entry: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test static humidifier attributes.""" + device.has_dehumidifier = True + device.has_humidifier = True + await init_integration(hass, config_entry) + + entity_id_dehumidifier = f"humidifier.{device.name}_dehumidifier" + entity_id_humidifier = f"humidifier.{device.name}_humidifier" + entry = entity_registry.async_get(entity_id_dehumidifier) + assert entry + + state = hass.states.get(entity_id_dehumidifier) + + assert state == snapshot(name="dehumidifier") + + state = hass.states.get(entity_id_humidifier) + + assert state == snapshot(name="humidifier")