From c1952cddadeaf3369055ec76e648577c47463b27 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 24 Aug 2026 17:05:23 +0200 Subject: [PATCH] Add switch platform to Midea (#178802) Co-authored-by: Claude Sonnet 5 --- homeassistant/components/midea/__init__.py | 1 + homeassistant/components/midea/strings.json | 41 + homeassistant/components/midea/switch.py | 133 ++++ .../midea/snapshots/test_switch.ambr | 751 ++++++++++++++++++ tests/components/midea/test_switch.py | 346 ++++++++ 5 files changed, 1272 insertions(+) create mode 100644 homeassistant/components/midea/switch.py create mode 100644 tests/components/midea/snapshots/test_switch.ambr create mode 100644 tests/components/midea/test_switch.py diff --git a/homeassistant/components/midea/__init__.py b/homeassistant/components/midea/__init__.py index bbb36249c5e8..8463a7f02f62 100644 --- a/homeassistant/components/midea/__init__.py +++ b/homeassistant/components/midea/__init__.py @@ -26,6 +26,7 @@ _PLATFORMS: list[Platform] = [ Platform.HUMIDIFIER, Platform.NUMBER, Platform.SELECT, + Platform.SWITCH, ] diff --git a/homeassistant/components/midea/strings.json b/homeassistant/components/midea/strings.json index 9198298ba7d2..0005037db202 100644 --- a/homeassistant/components/midea/strings.json +++ b/homeassistant/components/midea/strings.json @@ -263,6 +263,47 @@ "up_mid": "Up middle" } } + }, + "switch": { + "anion": { + "name": "Anion" + }, + "aux_heating": { + "name": "Auxiliary heating" + }, + "disinfect": { + "name": "Disinfect" + }, + "night_light": { + "name": "Night light" + }, + "out_silent": { + "name": "Outdoor silent mode" + }, + "prompt_tone": { + "name": "Prompt tone" + }, + "screen_display": { + "name": "Screen display" + }, + "self_clean": { + "name": "Self clean" + }, + "smart_eye": { + "name": "Smart eye" + }, + "sound": { + "name": "Sound" + }, + "tbh": { + "name": "TBH" + }, + "zone1_curve": { + "name": "Zone 1 curve" + }, + "zone2_curve": { + "name": "Zone 2 curve" + } } }, "exceptions": { diff --git a/homeassistant/components/midea/switch.py b/homeassistant/components/midea/switch.py new file mode 100644 index 000000000000..b459aebd7004 --- /dev/null +++ b/homeassistant/components/midea/switch.py @@ -0,0 +1,133 @@ +"""Switch for Midea.""" + +from dataclasses import dataclass +from typing import Any, override + +from midealocal.const import DeviceType + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +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 MideaSwitchEntityDescription(SwitchEntityDescription): + """Description for a Midea switch entity.""" + + models: list[DeviceType] + + +SWITCHES: list[MideaSwitchEntityDescription] = [ + MideaSwitchEntityDescription( + key="aux_heating", + translation_key="aux_heating", + models=[DeviceType.AC, DeviceType.CC, DeviceType.CF], + ), + MideaSwitchEntityDescription( + key="prompt_tone", + translation_key="prompt_tone", + models=[DeviceType.AC], + ), + MideaSwitchEntityDescription( + key="screen_display", + translation_key="screen_display", + models=[DeviceType.AC], + ), + MideaSwitchEntityDescription( + key="out_silent", + translation_key="out_silent", + models=[DeviceType.AC], + ), + MideaSwitchEntityDescription( + key="smart_eye", + translation_key="smart_eye", + models=[DeviceType.AC], + ), + MideaSwitchEntityDescription( + key="anion", + translation_key="anion", + models=[DeviceType.AC], + ), + MideaSwitchEntityDescription( + key="sound", + translation_key="sound", + models=[DeviceType.AC], + ), + MideaSwitchEntityDescription( + key="self_clean", + translation_key="self_clean", + models=[DeviceType.AC], + ), + MideaSwitchEntityDescription( + key="disinfect", + translation_key="disinfect", + models=[DeviceType.C3], + ), + MideaSwitchEntityDescription( + key="tbh", + translation_key="tbh", + models=[DeviceType.C3], + ), + MideaSwitchEntityDescription( + key="zone1_curve", + translation_key="zone1_curve", + models=[DeviceType.C3], + ), + MideaSwitchEntityDescription( + key="zone2_curve", + translation_key="zone2_curve", + models=[DeviceType.C3], + ), + MideaSwitchEntityDescription( + key="night_light", + translation_key="night_light", + models=[DeviceType.CC], + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MideaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up switches for device.""" + device = config_entry.runtime_data + + async_add_entities( + MideaSwitch(device, description) + for description in SWITCHES + if device.device_type in description.models + and description.key in device.attributes + ) + + +class MideaSwitch(MideaEntity, SwitchEntity): + """Represent a Midea switch.""" + + entity_description: MideaSwitchEntityDescription + + @property + @override + def is_on(self) -> bool | None: + """Return true if switch is on.""" + value = self._device.get_attribute(self.entity_description.key) + if not isinstance(value, bool): + return None + return value + + @override + def turn_on(self, **kwargs: Any) -> None: + """Turn on switch.""" + with midea_api_call(): + self._device.set_attribute(attr=self.entity_description.key, value=True) + + @override + def turn_off(self, **kwargs: Any) -> None: + """Turn off switch.""" + with midea_api_call(): + self._device.set_attribute(attr=self.entity_description.key, value=False) diff --git a/tests/components/midea/snapshots/test_switch.ambr b/tests/components/midea/snapshots/test_switch.ambr new file mode 100644 index 000000000000..82637c041ce8 --- /dev/null +++ b/tests/components/midea/snapshots/test_switch.ambr @@ -0,0 +1,751 @@ +# serializer version: 1 +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_anion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_anion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Anion', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Anion', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'anion', + 'unique_id': '12345678_anion', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_anion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Anion', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_anion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_auxiliary_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_auxiliary_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auxiliary heating', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auxiliary heating', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'aux_heating', + 'unique_id': '12345678_aux_heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_auxiliary_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Auxiliary heating', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_auxiliary_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_outdoor_silent_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_outdoor_silent_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Outdoor silent mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Outdoor silent mode', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'out_silent', + 'unique_id': '12345678_out_silent', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_outdoor_silent_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Outdoor silent mode', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_outdoor_silent_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_prompt_tone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_prompt_tone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Prompt tone', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Prompt tone', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'prompt_tone', + 'unique_id': '12345678_prompt_tone', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_prompt_tone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Prompt tone', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_prompt_tone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_screen_display-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_screen_display', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Screen display', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Screen display', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'screen_display', + 'unique_id': '12345678_screen_display', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_screen_display-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Screen display', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_screen_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_self_clean-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_self_clean', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Self clean', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Self clean', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'self_clean', + 'unique_id': '12345678_self_clean', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_self_clean-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Self clean', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_self_clean', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_smart_eye-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_smart_eye', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Smart eye', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart eye', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smart_eye', + 'unique_id': '12345678_smart_eye', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_smart_eye-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Smart eye', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_smart_eye', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_sound-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_sound', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sound', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sound', + 'unique_id': '12345678_sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[ac][switch.bedroom_ac_sound-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Sound', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_sound', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_state_snapshot[c3][switch.bedroom_ac_disinfect-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_disinfect', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Disinfect', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disinfect', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'disinfect', + 'unique_id': '12345678_disinfect', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[c3][switch.bedroom_ac_disinfect-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Disinfect', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_disinfect', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state_snapshot[c3][switch.bedroom_ac_tbh-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_tbh', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'TBH', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TBH', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tbh', + 'unique_id': '12345678_tbh', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[c3][switch.bedroom_ac_tbh-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC TBH', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_tbh', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state_snapshot[c3][switch.bedroom_ac_zone_1_curve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_zone_1_curve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Zone 1 curve', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Zone 1 curve', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'zone1_curve', + 'unique_id': '12345678_zone1_curve', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[c3][switch.bedroom_ac_zone_1_curve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Zone 1 curve', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_zone_1_curve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state_snapshot[c3][switch.bedroom_ac_zone_2_curve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_zone_2_curve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Zone 2 curve', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Zone 2 curve', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'zone2_curve', + 'unique_id': '12345678_zone2_curve', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[c3][switch.bedroom_ac_zone_2_curve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Zone 2 curve', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_zone_2_curve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_state_snapshot[cc][switch.bedroom_ac_auxiliary_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_auxiliary_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auxiliary heating', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auxiliary heating', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'aux_heating', + 'unique_id': '12345678_aux_heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[cc][switch.bedroom_ac_auxiliary_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Auxiliary heating', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_auxiliary_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_state_snapshot[cc][switch.bedroom_ac_night_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_night_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Night light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Night light', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'night_light', + 'unique_id': '12345678_night_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[cc][switch.bedroom_ac_night_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Night light', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_night_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state_snapshot[cf][switch.bedroom_ac_auxiliary_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_ac_auxiliary_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auxiliary heating', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auxiliary heating', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'aux_heating', + 'unique_id': '12345678_aux_heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state_snapshot[cf][switch.bedroom_ac_auxiliary_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Auxiliary heating', + }), + 'context': , + 'entity_id': 'switch.bedroom_ac_auxiliary_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/midea/test_switch.py b/tests/components/midea/test_switch.py new file mode 100644 index 000000000000..2d36ef24286f --- /dev/null +++ b/tests/components/midea/test_switch.py @@ -0,0 +1,346 @@ +"""Tests for midea switch.py.""" + +from collections.abc import Callable +from unittest.mock import patch + +from midealocal.const import DeviceType +from midealocal.devices.ac import DeviceAttributes as ACAttributes +from midealocal.devices.c3 import DeviceAttributes as C3Attributes +from midealocal.devices.cc import DeviceAttributes as CCAttributes +from midealocal.devices.cf import DeviceAttributes as CFAttributes +from midealocal.exceptions import SocketException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, 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 + + +async def _assert_service_call( + hass: HomeAssistant, + entity_id: str, + service: str, + expected_calls: list[tuple], + device: DummyDevice, +) -> None: + """Call a switch service and assert the fake device recorded the right call.""" + device.calls.clear() + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert device.calls == expected_calls + + +@pytest.mark.parametrize( + "device", + [ + pytest.param( + DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.aux_heating: True, + ACAttributes.breezeless: False, + ACAttributes.dry: False, + ACAttributes.indirect_wind: False, + ACAttributes.natural_wind: False, + ACAttributes.prompt_tone: True, + ACAttributes.screen_display: True, + ACAttributes.screen_display_alternate: False, + ACAttributes.out_silent: False, + ACAttributes.smart_eye: False, + ACAttributes.anion: False, + ACAttributes.sound: True, + ACAttributes.self_clean: False, + ACAttributes.comfort_mode: False, + ACAttributes.eco_mode: False, + ACAttributes.boost_mode: False, + ACAttributes.sleep_mode: False, + ACAttributes.frost_protect: False, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + }, + ), + id="ac", + ), + pytest.param( + DummyDevice( + DeviceType.C3, + attributes={ + C3Attributes.zone1_power: True, + C3Attributes.zone2_power: False, + C3Attributes.mode: 1, + C3Attributes.target_temperature: [22, 23], + C3Attributes.disinfect: False, + C3Attributes.dhw_power: True, + C3Attributes.eco_mode: False, + C3Attributes.fast_dhw: False, + C3Attributes.silent_mode: True, + C3Attributes.tbh: False, + C3Attributes.zone1_curve: False, + C3Attributes.zone2_curve: True, + }, + ), + id="c3", + ), + pytest.param( + DummyDevice( + DeviceType.CC, + attributes={ + CCAttributes.power: True, + CCAttributes.mode: 1, + CCAttributes.eco_mode: False, + CCAttributes.sleep_mode: False, + CCAttributes.swing: False, + CCAttributes.aux_heating: True, + CCAttributes.night_light: False, + }, + ), + id="cc", + ), + pytest.param( + DummyDevice(DeviceType.CF, attributes={CFAttributes.aux_heating: True}), + id="cf", + ), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switch_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 switch entities per device type.""" + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry, device) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_ac_switch_services( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test AC switch service calls reach the device.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.aux_heating: False, + }, + ) + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_aux_heating"] + + assert (state := hass.states.get(entity_entry.entity_id)) is not None + assert state.state == "off" + + await _assert_service_call( + hass, + entity_entry.entity_id, + SERVICE_TURN_ON, + [("set_attribute", ACAttributes.aux_heating, True)], + device, + ) + await _assert_service_call( + hass, + entity_entry.entity_id, + SERVICE_TURN_OFF, + [("set_attribute", ACAttributes.aux_heating, False)], + device, + ) + + +async def test_switch_unknown_when_attribute_becomes_non_bool( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test is_on gracefully reports unknown if a later update clears it.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.aux_heating: False, + }, + ) + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_aux_heating"] + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.state == "off" + + device.attributes[ACAttributes.aux_heating] = True + device.notify_update({ACAttributes.aux_heating: True}) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.state == "on" + + device.attributes[ACAttributes.aux_heating] = None + device.notify_update({ACAttributes.aux_heating: None}) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.state == "unknown" + + +async def test_switch_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 = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.aux_heating: False, + }, + ) + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_aux_heating"] + + with ( + patch.object(device, "set_attribute", side_effect=SocketException("offline")), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_entry.entity_id}, + blocking=True, + ) + + +async def test_fb_has_no_switch_entities( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test FB devices, which only expose the climate-owned power attribute, get no switches.""" + device = DummyDevice(DeviceType.FB, attributes={"power": True}) + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry, device) + + assert entity_entries(hass, config_entry) == {} + + +async def test_switch_not_created_when_attribute_missing( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test a switch is not created when the device does not report the attribute.""" + 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.SWITCH]): + await setup_integration(hass, config_entry, device) + + assert f"{TEST_DEVICE_ID}_aux_heating" not in entity_entries(hass, config_entry) + + +async def test_switch_not_created_for_climate_owned_attribute( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test no switch is created for attributes already controlled by climate.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.comfort_mode: False, + ACAttributes.eco_mode: False, + ACAttributes.boost_mode: False, + ACAttributes.sleep_mode: False, + ACAttributes.frost_protect: False, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + }, + ) + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry, device) + + created = entity_entries(hass, config_entry) + for key in ( + "power", + "comfort_mode", + "eco_mode", + "boost_mode", + "sleep_mode", + "frost_protect", + "swing_vertical", + "swing_horizontal", + ): + assert f"{TEST_DEVICE_ID}_{key}" not in created + + +async def test_switch_not_created_for_other_device_type( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test a switch scoped to one device type is not created for another.""" + device = DummyDevice( + DeviceType.CC, + attributes={ + CCAttributes.power: True, + CCAttributes.mode: 1, + # C3-only attribute name reused here to prove the CC device type + # is excluded from the description's `models` even when the + # attribute happens to be present. + "disinfect": True, + }, + ) + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry, device) + + assert f"{TEST_DEVICE_ID}_disinfect" not in entity_entries(hass, config_entry)