diff --git a/homeassistant/components/midea/__init__.py b/homeassistant/components/midea/__init__.py index 54525ab82162..f6dc85cb134d 100644 --- a/homeassistant/components/midea/__init__.py +++ b/homeassistant/components/midea/__init__.py @@ -29,6 +29,7 @@ _PLATFORMS: list[Platform] = [ Platform.CLIMATE, Platform.FAN, Platform.HUMIDIFIER, + Platform.LIGHT, Platform.NUMBER, Platform.SELECT, Platform.SWITCH, diff --git a/homeassistant/components/midea/device_catalog.py b/homeassistant/components/midea/device_catalog.py index ff5d8ec11907..379e8c2c3b69 100644 --- a/homeassistant/components/midea/device_catalog.py +++ b/homeassistant/components/midea/device_catalog.py @@ -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", diff --git a/homeassistant/components/midea/light.py b/homeassistant/components/midea/light.py new file mode 100644 index 000000000000..6d4571e4de67 --- /dev/null +++ b/homeassistant/components/midea/light.py @@ -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) diff --git a/homeassistant/components/midea/strings.json b/homeassistant/components/midea/strings.json index 0005037db202..56e22f12981f 100644 --- a/homeassistant/components/midea/strings.json +++ b/homeassistant/components/midea/strings.json @@ -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" diff --git a/tests/components/midea/snapshots/test_light.ambr b/tests/components/midea/snapshots/test_light.ambr new file mode 100644 index 000000000000..3880f200ea79 --- /dev/null +++ b/tests/components/midea/snapshots/test_light.ambr @@ -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({ + : list([ + 'off', + 'living', + 'reading', + 'mildly', + 'cinema', + 'night', + ]), + : 6500, + : 2700, + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.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': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'light', + 'unique_id': '12345678_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_state_snapshot[light.bedroom_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 128, + : , + : 4000, + : 'off', + : list([ + 'off', + 'living', + 'reading', + 'mildly', + 'cinema', + 'night', + ]), + : 'Bedroom AC', + : tuple( + 26.812, + 34.87, + ), + : 6500, + : 2700, + : tuple( + 255, + 206, + 166, + ), + : list([ + , + ]), + : , + : tuple( + 0.42, + 0.365, + ), + }), + 'context': , + 'entity_id': 'light.bedroom_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/midea/test_light.py b/tests/components/midea/test_light.py new file mode 100644 index 000000000000..4ad0b8443822 --- /dev/null +++ b/tests/components/midea/test_light.py @@ -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, + )