Add light platform to Midea (#179243)

This commit is contained in:
Simone Chemelli
2026-08-24 20:12:27 +02:00
committed by GitHub
parent b916fae390
commit 975e134c71
6 changed files with 581 additions and 0 deletions
@@ -29,6 +29,7 @@ _PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.FAN,
Platform.HUMIDIFIER,
Platform.LIGHT,
Platform.NUMBER,
Platform.SELECT,
Platform.SWITCH,
@@ -8,6 +8,7 @@ MIDEA_DEVICE_NAMES: dict[DeviceType, str] = {
DeviceType.CC: "MDV Wi-Fi Controller",
DeviceType.CF: "Heat Pump",
DeviceType.FB: "Electric Heater",
DeviceType.X13: "Light",
DeviceType.C2: "Toilet",
DeviceType.CD: "Heat Pump Water Heater",
DeviceType.ED: "Water Drinking Appliance",
+167
View File
@@ -0,0 +1,167 @@
"""Light for Midea."""
from dataclasses import dataclass
from typing import Any, cast, override
from midealocal.const import DeviceType
from midealocal.devices.x13 import Midea13Device
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
EFFECT_OFF,
ColorMode,
LightEntity,
LightEntityDescription,
LightEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MideaConfigEntry, MideaEntity, midea_api_call
PARALLEL_UPDATES = 0
@dataclass(kw_only=True, frozen=True)
class MideaLightEntityDescription(LightEntityDescription):
"""Description for a Midea light entity."""
models: list[DeviceType]
LIGHTS: list[MideaLightEntityDescription] = [
MideaLightEntityDescription(
key="light",
models=[DeviceType.X13],
translation_key="light",
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MideaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up lights for device."""
device = config_entry.runtime_data
async_add_entities(
MideaLight(cast(Midea13Device, device), description)
for description in LIGHTS
if device.device_type in description.models
)
class MideaLight(MideaEntity, LightEntity):
"""Represent a Midea light."""
_device: Midea13Device
@property
@override
def supported_features(self) -> LightEntityFeature:
"""Midea light supported features."""
if self._device.get_attribute("effect") is not None:
return LightEntityFeature.EFFECT
return LightEntityFeature(0)
@property
@override
def supported_color_modes(self) -> set[ColorMode]:
"""Midea light supported color modes."""
if self._device.get_attribute("color_temperature") is not None:
return {ColorMode.COLOR_TEMP}
if self._device.get_attribute("brightness") is not None:
return {ColorMode.BRIGHTNESS}
return {ColorMode.ONOFF}
@property
@override
def color_mode(self) -> ColorMode:
"""Midea light current color mode."""
supported = self.supported_color_modes
if ColorMode.COLOR_TEMP in supported and self.color_temp_kelvin is not None:
return ColorMode.COLOR_TEMP
if ColorMode.BRIGHTNESS in supported and self.brightness is not None:
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
@property
@override
def is_on(self) -> bool | None:
"""Midea light is on."""
power = self._device.get_attribute("power")
if not isinstance(power, bool):
return None
return power
@property
@override
def brightness(self) -> int | None:
"""Midea light brightness."""
value = self._device.get_attribute("brightness")
return value if isinstance(value, int) else None
@property
@override
def color_temp_kelvin(self) -> int | None:
"""Midea light color temperature."""
value = self._device.get_attribute("color_temperature")
return value if isinstance(value, int) else None
@property
@override
def min_color_temp_kelvin(self) -> int:
"""Midea light min color temperature."""
return self._device.color_temp_range[0]
@property
@override
def max_color_temp_kelvin(self) -> int:
"""Midea light max color temperature."""
return self._device.color_temp_range[1]
@property
@override
def effect_list(self) -> list[str]:
"""Midea light effect list."""
return [EFFECT_OFF if e == "none" else e for e in self._device.effects]
@property
@override
def effect(self) -> str | None:
"""Midea light current effect."""
value = self._device.get_attribute("effect")
if not isinstance(value, str):
return None
return EFFECT_OFF if value == "none" else value
@override
def turn_on(self, **kwargs: Any) -> None:
"""Midea light turn on."""
with midea_api_call():
if not self.is_on:
self._device.set_attribute(attr="power", value=True)
if ATTR_BRIGHTNESS in kwargs:
self._device.set_attribute(
attr="brightness", value=kwargs[ATTR_BRIGHTNESS]
)
if ATTR_COLOR_TEMP_KELVIN in kwargs:
self._device.set_attribute(
attr="color_temperature", value=kwargs[ATTR_COLOR_TEMP_KELVIN]
)
if ATTR_EFFECT in kwargs:
effect = kwargs[ATTR_EFFECT]
self._device.set_attribute(
attr="effect",
value="none" if effect == EFFECT_OFF else effect,
)
@override
def turn_off(self, **kwargs: Any) -> None:
"""Midea light turn off."""
with midea_api_call():
self._device.set_attribute(attr="power", value=False)
@@ -129,6 +129,21 @@
}
}
},
"light": {
"light": {
"state_attributes": {
"effect": {
"state": {
"cinema": "Cinema",
"living": "Living",
"mildly": "Mildly",
"night": "Night",
"reading": "Reading"
}
}
}
}
},
"number": {
"dry_level": {
"name": "Dry level"
@@ -0,0 +1,96 @@
# serializer version: 1
# name: test_light_state_snapshot[light.bedroom_ac-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<LightEntityCapabilityAttribute.EFFECT_LIST: 'effect_list'>: list([
'off',
'living',
'reading',
'mildly',
'cinema',
'night',
]),
<LightEntityCapabilityAttribute.MAX_COLOR_TEMP_KELVIN: 'max_color_temp_kelvin'>: 6500,
<LightEntityCapabilityAttribute.MIN_COLOR_TEMP_KELVIN: 'min_color_temp_kelvin'>: 2700,
<LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES: 'supported_color_modes'>: list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.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': None,
'original_icon': None,
'original_name': None,
'platform': 'midea',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <LightEntityFeature: 4>,
'translation_key': 'light',
'unique_id': '12345678_light',
'unit_of_measurement': None,
})
# ---
# name: test_light_state_snapshot[light.bedroom_ac-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<LightEntityStateAttribute.BRIGHTNESS: 'brightness'>: 128,
<LightEntityStateAttribute.COLOR_MODE: 'color_mode'>: <ColorMode.COLOR_TEMP: 'color_temp'>,
<LightEntityStateAttribute.COLOR_TEMP_KELVIN: 'color_temp_kelvin'>: 4000,
<LightEntityStateAttribute.EFFECT: 'effect'>: 'off',
<LightEntityCapabilityAttribute.EFFECT_LIST: 'effect_list'>: list([
'off',
'living',
'reading',
'mildly',
'cinema',
'night',
]),
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Bedroom AC',
<LightEntityStateAttribute.HS_COLOR: 'hs_color'>: tuple(
26.812,
34.87,
),
<LightEntityCapabilityAttribute.MAX_COLOR_TEMP_KELVIN: 'max_color_temp_kelvin'>: 6500,
<LightEntityCapabilityAttribute.MIN_COLOR_TEMP_KELVIN: 'min_color_temp_kelvin'>: 2700,
<LightEntityStateAttribute.RGB_COLOR: 'rgb_color'>: tuple(
255,
206,
166,
),
<LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES: 'supported_color_modes'>: list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
]),
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <LightEntityFeature: 4>,
<LightEntityStateAttribute.XY_COLOR: 'xy_color'>: tuple(
0.42,
0.365,
),
}),
'context': <ANY>,
'entity_id': 'light.bedroom_ac',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
+301
View File
@@ -0,0 +1,301 @@
"""Tests for midea light.py."""
from collections.abc import Callable
from typing import Any
from unittest.mock import patch
from midealocal.const import DeviceType
from midealocal.devices.ac import DeviceAttributes as ACAttributes
from midealocal.devices.x13 import DeviceAttributes as X13Attributes, Midea13Device
from midealocal.exceptions import SocketException
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_EFFECT_LIST,
ATTR_MAX_COLOR_TEMP_KELVIN,
ATTR_MIN_COLOR_TEMP_KELVIN,
ATTR_SUPPORTED_COLOR_MODES,
DOMAIN as LIGHT_DOMAIN,
EFFECT_OFF,
ColorMode,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import 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
X13_EFFECTS = list(Midea13Device._effects)
def _x13_device() -> DummyDevice:
device = DummyDevice(
DeviceType.X13,
attributes={
X13Attributes.power: True,
X13Attributes.brightness: 128,
X13Attributes.color_temperature: 4000,
X13Attributes.effect: "none",
X13Attributes.rgb_color: None,
},
)
device.color_temp_range = [2700, 6500]
device.effects = X13_EFFECTS
return device
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_light_state_snapshot(
hass: HomeAssistant,
mock_config_entry: Callable[[DummyDevice], MockConfigEntry],
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test async_setup_entry creates the light entity for an X13 device."""
device = _x13_device()
config_entry = mock_config_entry(device)
with patch("homeassistant.components.midea._PLATFORMS", [Platform.LIGHT]):
await setup_integration(hass, config_entry, device)
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
async def test_light_state_and_services(
hass: HomeAssistant,
mock_config_entry: Callable[[DummyDevice], MockConfigEntry],
) -> None:
"""Test light state attributes and service calls reach the device."""
device = _x13_device()
config_entry = mock_config_entry(device)
with patch("homeassistant.components.midea._PLATFORMS", [Platform.LIGHT]):
await setup_integration(hass, config_entry, device)
entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_light"]
assert (state := hass.states.get(entity_entry.entity_id)) is not None
assert state.state == "on"
assert state.attributes[ATTR_BRIGHTNESS] == 128
assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 4000
assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700
assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500
assert state.attributes[ATTR_EFFECT] == EFFECT_OFF
assert state.attributes[ATTR_EFFECT_LIST] == [
EFFECT_OFF,
*(effect for effect in X13_EFFECTS if effect != "none"),
]
assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP
assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.COLOR_TEMP]
device.calls.clear()
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: entity_entry.entity_id,
ATTR_BRIGHTNESS: 200,
ATTR_COLOR_TEMP_KELVIN: 5000,
ATTR_EFFECT: "cinema",
},
blocking=True,
)
assert device.calls == [
("set_attribute", "brightness", 200),
("set_attribute", "color_temperature", 5000),
("set_attribute", "effect", "cinema"),
]
device.calls.clear()
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_entry.entity_id, ATTR_EFFECT: EFFECT_OFF},
blocking=True,
)
assert device.calls == [("set_attribute", "effect", "none")]
device.calls.clear()
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_entry.entity_id},
blocking=True,
)
assert device.calls == [("set_attribute", "power", False)]
@pytest.mark.parametrize(
("initial_power", "service_data", "expected_calls"),
[
pytest.param(
True,
{ATTR_BRIGHTNESS: 50},
[("set_attribute", "brightness", 50)],
id="already_on_skips_repower",
),
pytest.param(
False,
{},
[("set_attribute", "power", True)],
id="off_powers_on",
),
],
)
async def test_light_turn_on_power_handling(
hass: HomeAssistant,
mock_config_entry: Callable[[DummyDevice], MockConfigEntry],
initial_power: bool,
service_data: dict[str, Any],
expected_calls: list[tuple],
) -> None:
"""Test turn_on only resends power when the device is currently off."""
device = _x13_device()
device.attributes[X13Attributes.power] = initial_power
config_entry = mock_config_entry(device)
with patch("homeassistant.components.midea._PLATFORMS", [Platform.LIGHT]):
await setup_integration(hass, config_entry, device)
entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_light"]
device.calls.clear()
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_entry.entity_id, **service_data},
blocking=True,
)
assert device.calls == expected_calls
@pytest.mark.parametrize(
(
"attributes",
"expected_state",
"expected_color_mode",
"expected_supported_color_modes",
"expected_has_effect",
),
[
pytest.param(
{},
"unknown",
# HA only reports a color_mode while is_on is True.
None,
[ColorMode.ONOFF],
False,
id="before_first_status",
),
pytest.param(
{
X13Attributes.power: True,
X13Attributes.brightness: None,
X13Attributes.color_temperature: None,
X13Attributes.effect: None,
X13Attributes.rgb_color: None,
},
"on",
ColorMode.ONOFF,
[ColorMode.ONOFF],
False,
id="onoff_only",
),
pytest.param(
{
X13Attributes.power: True,
X13Attributes.brightness: 50,
X13Attributes.color_temperature: None,
X13Attributes.effect: None,
X13Attributes.rgb_color: None,
},
"on",
ColorMode.BRIGHTNESS,
[ColorMode.BRIGHTNESS],
False,
id="brightness_only",
),
],
)
async def test_light_color_mode_fallbacks(
hass: HomeAssistant,
mock_config_entry: Callable[[DummyDevice], MockConfigEntry],
attributes: dict[X13Attributes, Any],
expected_state: str,
expected_color_mode: ColorMode | None,
expected_supported_color_modes: list[ColorMode],
expected_has_effect: bool,
) -> None:
"""Test the light falls back to the correct color mode based on reported capability."""
device = DummyDevice(DeviceType.X13, attributes=attributes)
device.color_temp_range = [2700, 6500]
device.effects = X13_EFFECTS
config_entry = mock_config_entry(device)
with patch("homeassistant.components.midea._PLATFORMS", [Platform.LIGHT]):
await setup_integration(hass, config_entry, device)
entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_light"]
assert (state := hass.states.get(entity_entry.entity_id)) is not None
assert state.state == expected_state
assert state.attributes[ATTR_COLOR_MODE] == expected_color_mode
assert (
state.attributes[ATTR_SUPPORTED_COLOR_MODES] == expected_supported_color_modes
)
assert (ATTR_EFFECT in state.attributes) == expected_has_effect
async def test_light_not_created_for_other_device_type(
hass: HomeAssistant,
mock_config_entry: Callable[[DummyDevice], MockConfigEntry],
) -> None:
"""Test no light 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.LIGHT]):
await setup_integration(hass, config_entry, device)
assert entity_entries(hass, config_entry) == {}
async def test_light_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 = _x13_device()
config_entry = mock_config_entry(device)
with patch("homeassistant.components.midea._PLATFORMS", [Platform.LIGHT]):
await setup_integration(hass, config_entry, device)
entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_light"]
with (
patch.object(device, "set_attribute", side_effect=SocketException("offline")),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_entry.entity_id},
blocking=True,
)