From 35b21e6b9cef1c8087af547c41d73f7036cf817e Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 18 Aug 2026 17:13:11 +0200 Subject: [PATCH] Add humidifier platform to Midea (#178873) --- homeassistant/components/midea/__init__.py | 2 +- .../components/midea/device_catalog.py | 2 + homeassistant/components/midea/entity.py | 17 ++ homeassistant/components/midea/humidifier.py | 137 ++++++++++ homeassistant/components/midea/strings.json | 3 + .../midea/snapshots/test_humidifier.ambr | 153 +++++++++++ tests/components/midea/test_entity.py | 13 + tests/components/midea/test_humidifier.py | 256 ++++++++++++++++++ 8 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/midea/humidifier.py create mode 100644 tests/components/midea/snapshots/test_humidifier.ambr create mode 100644 tests/components/midea/test_humidifier.py diff --git a/homeassistant/components/midea/__init__.py b/homeassistant/components/midea/__init__.py index d283668f05ee..51605275c330 100644 --- a/homeassistant/components/midea/__init__.py +++ b/homeassistant/components/midea/__init__.py @@ -20,7 +20,7 @@ from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from .const import CONF_KEY, CONF_SUBTYPE from .entity import MideaConfigEntry -_PLATFORMS: list[Platform] = [Platform.CLIMATE] +_PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.HUMIDIFIER] async def async_setup_entry(hass: HomeAssistant, entry: MideaConfigEntry) -> bool: diff --git a/homeassistant/components/midea/device_catalog.py b/homeassistant/components/midea/device_catalog.py index ab1446a516d2..cef8589404aa 100644 --- a/homeassistant/components/midea/device_catalog.py +++ b/homeassistant/components/midea/device_catalog.py @@ -8,4 +8,6 @@ MIDEA_DEVICE_NAMES: dict[DeviceType, str] = { DeviceType.CC: "MDV Wi-Fi Controller", DeviceType.CF: "Heat Pump", DeviceType.FB: "Electric Heater", + DeviceType.A1: "Dehumidifier", + DeviceType.FD: "Humidifier", } diff --git a/homeassistant/components/midea/entity.py b/homeassistant/components/midea/entity.py index 02a612592425..98a18289cde5 100644 --- a/homeassistant/components/midea/entity.py +++ b/homeassistant/components/midea/entity.py @@ -1,10 +1,14 @@ """Base entity for Midea.""" +from collections.abc import Generator +from contextlib import contextmanager from typing import Any, override from midealocal.device import MideaDevice +from midealocal.exceptions import MideaLocalError from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription @@ -14,6 +18,19 @@ from .device_catalog import MIDEA_DEVICE_NAMES type MideaConfigEntry = ConfigEntry[MideaDevice] +@contextmanager +def midea_api_call() -> Generator[None]: + """Translate midealocal device-communication errors into HomeAssistantError.""" + try: + yield + except MideaLocalError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"error": str(err)}, + ) from err + + class MideaEntity(Entity): """Base Midea entity.""" diff --git a/homeassistant/components/midea/humidifier.py b/homeassistant/components/midea/humidifier.py new file mode 100644 index 000000000000..7c1e57cdadc5 --- /dev/null +++ b/homeassistant/components/midea/humidifier.py @@ -0,0 +1,137 @@ +"""Humidifier for Midea.""" + +from dataclasses import dataclass +from typing import Any, override + +from midealocal.const import DeviceType +from midealocal.devices.a1 import MideaA1Device +from midealocal.devices.fd import MideaFDDevice + +from homeassistant.components.humidifier import ( + HumidifierDeviceClass, + HumidifierEntity, + HumidifierEntityDescription, + HumidifierEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import MideaConfigEntry, MideaEntity, midea_api_call + +PARALLEL_UPDATES = 0 + +type MideaHumidifierDevice = MideaA1Device | MideaFDDevice + + +@dataclass(kw_only=True, frozen=True) +class MideaHumidifierEntityDescription(HumidifierEntityDescription): + """Description for a Midea humidifier entity.""" + + models: list[DeviceType] + + +HUMIDIFIERS: list[MideaHumidifierEntityDescription] = [ + MideaHumidifierEntityDescription( + key="humidifier", + models=[DeviceType.A1], + device_class=HumidifierDeviceClass.DEHUMIDIFIER, + ), + MideaHumidifierEntityDescription( + key="humidifier", + models=[DeviceType.FD], + device_class=HumidifierDeviceClass.HUMIDIFIER, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MideaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up humidifiers for device.""" + device = config_entry.runtime_data + + async_add_entities( + MideaHumidifier(device, description) + for description in HUMIDIFIERS + if device.device_type in description.models + ) + + +class MideaHumidifier(MideaEntity, HumidifierEntity): + """Represent a Midea humidifier.""" + + _device: MideaHumidifierDevice + entity_description: MideaHumidifierEntityDescription + + _attr_min_humidity: float = 35 + _attr_max_humidity: float = 85 + _attr_supported_features = HumidifierEntityFeature.MODES + + def _float_attribute(self, attr: str) -> float | None: + """Return a device attribute as float, if convertible.""" + value = self._device.get_attribute(attr) + if not isinstance(value, (int, float, str)): + return None + return float(value) + + @property + @override + def current_humidity(self) -> float | None: + """Midea Humidifier current humidity.""" + return self._float_attribute("current_humidity") + + @property + @override + def target_humidity(self) -> float | None: + """Midea Humidifier target humidity.""" + return self._float_attribute("target_humidity") + + @property + @override + def mode(self) -> str | None: + """Midea Humidifier mode.""" + mode = self._device.get_attribute("mode") + if not isinstance(mode, str): + return None + return mode + + @property + @override + def available_modes(self) -> list[str]: + """Midea Humidifier available modes.""" + return self._device.modes + + @property + @override + def is_on(self) -> bool | None: + """Midea Humidifier is on.""" + power = self._device.get_attribute("power") + if not isinstance(power, bool): + return None + return power + + @override + def set_humidity(self, humidity: int) -> None: + """Midea Humidifier set humidity.""" + with midea_api_call(): + self._device.set_attribute(attr="target_humidity", value=humidity) + + @override + def set_mode(self, mode: str) -> None: + """Midea Humidifier set mode.""" + with midea_api_call(): + self._device.set_attribute(attr="mode", value=mode) + + @override + def turn_on(self, **kwargs: Any) -> None: + """Midea Humidifier turn on.""" + with midea_api_call(): + self._device.set_attribute(attr="power", value=True) + + @override + def turn_off(self, **kwargs: Any) -> None: + """Midea Humidifier turn off.""" + with midea_api_call(): + self._device.set_attribute(attr="power", value=False) diff --git a/homeassistant/components/midea/strings.json b/homeassistant/components/midea/strings.json index d8fe2947570c..3ab55b4f6349 100644 --- a/homeassistant/components/midea/strings.json +++ b/homeassistant/components/midea/strings.json @@ -108,6 +108,9 @@ } }, "exceptions": { + "device_communication_error": { + "message": "Error communicating with the device: {error}" + }, "unsupported_hvac_mode": { "message": "HVAC mode {hvac_mode} is not supported by this device." } diff --git a/tests/components/midea/snapshots/test_humidifier.ambr b/tests/components/midea/snapshots/test_humidifier.ambr new file mode 100644 index 000000000000..348853f23038 --- /dev/null +++ b/tests/components/midea/snapshots/test_humidifier.ambr @@ -0,0 +1,153 @@ +# serializer version: 1 +# name: test_humidifier_state_snapshot[a1][humidifier.bedroom_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'manual', + 'continuous', + 'auto', + 'clothes_dry', + 'shoes_dry', + ]), + : 85, + : 35, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.bedroom_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345678_humidifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_humidifier_state_snapshot[a1][humidifier.bedroom_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : list([ + 'manual', + 'continuous', + 'auto', + 'clothes_dry', + 'shoes_dry', + ]), + : 60.0, + : 'dehumidifier', + : 'Bedroom AC', + : 55.0, + : 85, + : 35, + : 'auto', + : , + }), + 'context': , + 'entity_id': 'humidifier.bedroom_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_humidifier_state_snapshot[fd][humidifier.bedroom_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'manual', + 'auto', + 'continuous', + 'living_room', + 'bed_room', + 'kitchen', + 'sleep', + ]), + : 85, + : 35, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.bedroom_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345678_humidifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_humidifier_state_snapshot[fd][humidifier.bedroom_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : list([ + 'manual', + 'auto', + 'continuous', + 'living_room', + 'bed_room', + 'kitchen', + 'sleep', + ]), + : 40.0, + : 'humidifier', + : 'Bedroom AC', + : 45.0, + : 85, + : 35, + : 'continuous', + : , + }), + 'context': , + 'entity_id': 'humidifier.bedroom_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/midea/test_entity.py b/tests/components/midea/test_entity.py index 9df02fab6a59..6ff19da7bdbd 100644 --- a/tests/components/midea/test_entity.py +++ b/tests/components/midea/test_entity.py @@ -3,10 +3,13 @@ from collections.abc import Callable from midealocal.devices.ac import DeviceAttributes as ACAttributes +from midealocal.exceptions import SocketException import pytest from homeassistant.components.midea.const import DOMAIN +from homeassistant.components.midea.entity import midea_api_call from homeassistant.core import CoreState, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from . import setup_integration @@ -16,6 +19,16 @@ from .const import TEST_DEVICE_ID, TEST_MAC_ADDRESS, TEST_MODEL, TEST_SERIAL_NUM from tests.common import MockConfigEntry +def test_midea_api_call_translates_midea_local_error() -> None: + """Test midea_api_call turns a midealocal error into a HomeAssistantError.""" + with pytest.raises(HomeAssistantError) as exc_info, midea_api_call(): + raise SocketException("offline") + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "device_communication_error" + assert exc_info.value.translation_placeholders == {"error": "offline"} + + @pytest.mark.parametrize( ( "update", diff --git a/tests/components/midea/test_humidifier.py b/tests/components/midea/test_humidifier.py new file mode 100644 index 000000000000..faa02c845806 --- /dev/null +++ b/tests/components/midea/test_humidifier.py @@ -0,0 +1,256 @@ +"""Tests for midea humidifier.py.""" + +from collections.abc import Callable +from unittest.mock import patch + +from midealocal.const import DeviceType +from midealocal.devices.a1 import DeviceAttributes as A1Attributes, MideaA1Device +from midealocal.devices.ac import DeviceAttributes as ACAttributes +from midealocal.devices.fd import DeviceAttributes as FDAttributes, MideaFDDevice +from midealocal.exceptions import SocketException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.humidifier import ( + ATTR_AVAILABLE_MODES, + ATTR_CURRENT_HUMIDITY, + ATTR_HUMIDITY, + DOMAIN as HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.midea.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration +from .conftest import DummyDevice, entity_entries +from .const import TEST_DEVICE_ID + +from tests.common import MockConfigEntry, snapshot_platform + + +async def _assert_service_call( + hass: HomeAssistant, + entity_id: str, + service: str, + service_data: dict, + expected_calls: list[tuple], + device: DummyDevice, +) -> None: + """Call a humidifier service and assert the fake device recorded the right call.""" + device.calls.clear() + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + assert device.calls == expected_calls + + +def _a1_device() -> DummyDevice: + device = DummyDevice( + DeviceType.A1, + attributes={ + A1Attributes.power: True, + A1Attributes.mode: "auto", + A1Attributes.target_humidity: 55, + A1Attributes.current_humidity: 60, + }, + ) + device.modes = list(MideaA1Device._default_modes.values()) + return device + + +def _fd_device() -> DummyDevice: + device = DummyDevice( + DeviceType.FD, + attributes={ + FDAttributes.power: True, + FDAttributes.mode: "continuous", + FDAttributes.target_humidity: 45, + FDAttributes.current_humidity: 40, + }, + ) + device.modes = list(MideaFDDevice._modes) + return device + + +@pytest.mark.parametrize( + "device", + [ + pytest.param(_a1_device(), id="a1"), + pytest.param(_fd_device(), id="fd"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_humidifier_state_snapshot( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device: DummyDevice, +) -> None: + """Test async_setup_entry creates the right humidifier entity per device type.""" + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.HUMIDIFIER]): + await setup_integration(hass, config_entry, device) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("device", "expected_model"), + [ + pytest.param(_a1_device(), "Dehumidifier", id="a1"), + pytest.param(_fd_device(), "Humidifier", id="fd"), + ], +) +async def test_humidifier_device_info_model( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + device: DummyDevice, + expected_model: str, +) -> None: + """Test the device registry entry uses the right model name for A1 and FD.""" + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.HUMIDIFIER]): + await setup_integration(hass, config_entry, device) + + assert ( + device_entry := device_registry.async_get_device_by_identifier( + (DOMAIN, str(TEST_DEVICE_ID)), config_entry.entry_id + ) + ) is not None + assert device_entry.model == expected_model + + +async def test_a1_humidifier_services( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test A1 humidifier service calls reach the device.""" + device = _a1_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.HUMIDIFIER]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_humidifier"] + + assert (state := hass.states.get(entity_entry.entity_id)) is not None + assert state.state == "on" + assert state.attributes[ATTR_HUMIDITY] == 55 + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 60 + assert state.attributes[ATTR_MODE] == "auto" + assert state.attributes[ATTR_AVAILABLE_MODES] == device.modes + + await _assert_service_call( + hass, + entity_entry.entity_id, + SERVICE_SET_HUMIDITY, + {ATTR_HUMIDITY: 65}, + [("set_attribute", "target_humidity", 65)], + device, + ) + await _assert_service_call( + hass, + entity_entry.entity_id, + SERVICE_SET_MODE, + {ATTR_MODE: "continuous"}, + [("set_attribute", "mode", "continuous")], + device, + ) + await _assert_service_call( + hass, + entity_entry.entity_id, + SERVICE_TURN_OFF, + {}, + [("set_attribute", "power", False)], + device, + ) + await _assert_service_call( + hass, + entity_entry.entity_id, + SERVICE_TURN_ON, + {}, + [("set_attribute", "power", True)], + device, + ) + + +async def test_humidifier_not_created_for_other_device_type( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test no humidifier entity is created for a device type without one.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + }, + ) + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.HUMIDIFIER]): + await setup_integration(hass, config_entry, device) + + assert entity_entries(hass, config_entry) == {} + + +async def test_humidifier_unknown_mode_and_power_return_none( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test mode and is_on gracefully report unknown when attributes are unset.""" + device = DummyDevice( + DeviceType.A1, + attributes={ + A1Attributes.power: None, + A1Attributes.mode: None, + A1Attributes.target_humidity: None, + A1Attributes.current_humidity: None, + }, + ) + device.modes = ["Manual", "Continuous", "Auto"] + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.HUMIDIFIER]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_humidifier"] + assert (state := hass.states.get(entity_entry.entity_id)) is not None + assert state.state == "unknown" + assert state.attributes.get(ATTR_HUMIDITY) is None + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) is None + assert state.attributes.get(ATTR_MODE) is None + + +async def test_humidifier_turn_on_raises_on_device_communication_error( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test a device communication failure surfaces as a HomeAssistantError.""" + device = _fd_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.HUMIDIFIER]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_humidifier"] + + with ( + patch.object(device, "set_attribute", side_effect=SocketException("offline")), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_entry.entity_id}, + blocking=True, + )