Add humidifier platform to Midea (#178873)

This commit is contained in:
Simone Chemelli
2026-08-18 17:13:11 +02:00
committed by GitHub
parent 36435f5c82
commit 35b21e6b9c
8 changed files with 582 additions and 1 deletions
+1 -1
View File
@@ -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:
@@ -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",
}
+17
View File
@@ -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."""
@@ -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)
@@ -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."
}
@@ -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({
<HumidifierEntityCapabilityAttribute.AVAILABLE_MODES: 'available_modes'>: list([
'manual',
'continuous',
'auto',
'clothes_dry',
'shoes_dry',
]),
<HumidifierEntityCapabilityAttribute.MAX_HUMIDITY: 'max_humidity'>: 85,
<HumidifierEntityCapabilityAttribute.MIN_HUMIDITY: 'min_humidity'>: 35,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'humidifier',
'entity_category': None,
'entity_id': 'humidifier.bedroom_ac',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <HumidifierDeviceClass.DEHUMIDIFIER: 'dehumidifier'>,
'original_icon': None,
'original_name': None,
'platform': 'midea',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <HumidifierEntityFeature: 1>,
'translation_key': None,
'unique_id': '12345678_humidifier',
'unit_of_measurement': None,
})
# ---
# name: test_humidifier_state_snapshot[a1][humidifier.bedroom_ac-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<HumidifierEntityCapabilityAttribute.AVAILABLE_MODES: 'available_modes'>: list([
'manual',
'continuous',
'auto',
'clothes_dry',
'shoes_dry',
]),
<HumidifierEntityStateAttribute.CURRENT_HUMIDITY: 'current_humidity'>: 60.0,
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'dehumidifier',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Bedroom AC',
<HumidifierEntityStateAttribute.HUMIDITY: 'humidity'>: 55.0,
<HumidifierEntityCapabilityAttribute.MAX_HUMIDITY: 'max_humidity'>: 85,
<HumidifierEntityCapabilityAttribute.MIN_HUMIDITY: 'min_humidity'>: 35,
<HumidifierEntityStateAttribute.MODE: 'mode'>: 'auto',
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <HumidifierEntityFeature: 1>,
}),
'context': <ANY>,
'entity_id': 'humidifier.bedroom_ac',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_humidifier_state_snapshot[fd][humidifier.bedroom_ac-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<HumidifierEntityCapabilityAttribute.AVAILABLE_MODES: 'available_modes'>: list([
'manual',
'auto',
'continuous',
'living_room',
'bed_room',
'kitchen',
'sleep',
]),
<HumidifierEntityCapabilityAttribute.MAX_HUMIDITY: 'max_humidity'>: 85,
<HumidifierEntityCapabilityAttribute.MIN_HUMIDITY: 'min_humidity'>: 35,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'humidifier',
'entity_category': None,
'entity_id': 'humidifier.bedroom_ac',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <HumidifierDeviceClass.HUMIDIFIER: 'humidifier'>,
'original_icon': None,
'original_name': None,
'platform': 'midea',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <HumidifierEntityFeature: 1>,
'translation_key': None,
'unique_id': '12345678_humidifier',
'unit_of_measurement': None,
})
# ---
# name: test_humidifier_state_snapshot[fd][humidifier.bedroom_ac-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<HumidifierEntityCapabilityAttribute.AVAILABLE_MODES: 'available_modes'>: list([
'manual',
'auto',
'continuous',
'living_room',
'bed_room',
'kitchen',
'sleep',
]),
<HumidifierEntityStateAttribute.CURRENT_HUMIDITY: 'current_humidity'>: 40.0,
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'humidifier',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Bedroom AC',
<HumidifierEntityStateAttribute.HUMIDITY: 'humidity'>: 45.0,
<HumidifierEntityCapabilityAttribute.MAX_HUMIDITY: 'max_humidity'>: 85,
<HumidifierEntityCapabilityAttribute.MIN_HUMIDITY: 'min_humidity'>: 35,
<HumidifierEntityStateAttribute.MODE: 'mode'>: 'continuous',
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <HumidifierEntityFeature: 1>,
}),
'context': <ANY>,
'entity_id': 'humidifier.bedroom_ac',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
+13
View File
@@ -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",
+256
View File
@@ -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,
)