From ed0bdf9e5fdf530e1e357636fb722585c6a44f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Fri, 25 Apr 2025 18:40:52 +0200 Subject: [PATCH] Add switch platform to miele integration (#142925) * Add switch platform * Add a type hint * Update after review --- homeassistant/components/miele/__init__.py | 1 + homeassistant/components/miele/icons.json | 15 ++ homeassistant/components/miele/strings.json | 11 + homeassistant/components/miele/switch.py | 225 ++++++++++++++++++ .../miele/snapshots/test_switch.ambr | 189 +++++++++++++++ tests/components/miele/test_switch.py | 95 ++++++++ 6 files changed, 536 insertions(+) create mode 100644 homeassistant/components/miele/icons.json create mode 100644 homeassistant/components/miele/switch.py create mode 100644 tests/components/miele/snapshots/test_switch.ambr create mode 100644 tests/components/miele/test_switch.py diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py index d6348d0eb7e..c366c29219f 100644 --- a/homeassistant/components/miele/__init__.py +++ b/homeassistant/components/miele/__init__.py @@ -20,6 +20,7 @@ from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.LIGHT, Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json new file mode 100644 index 00000000000..c9c7639b61a --- /dev/null +++ b/homeassistant/components/miele/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "switch": { + "power": { + "default": "mdi:power" + }, + "supercooling": { + "default": "mdi:snowflake-variant" + }, + "superfreezing": { + "default": "mdi:snowflake" + } + } + } +} diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index dcf2e270ffd..ae8c43b12db 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -146,6 +146,17 @@ "waiting_to_start": "Waiting to start" } } + }, + "switch": { + "power": { + "name": "Power" + }, + "supercooling": { + "name": "Supercooling" + }, + "superfreezing": { + "name": "Superfreezing" + } } }, "exceptions": { diff --git a/homeassistant/components/miele/switch.py b/homeassistant/components/miele/switch.py new file mode 100644 index 00000000000..26615f289a5 --- /dev/null +++ b/homeassistant/components/miele/switch.py @@ -0,0 +1,225 @@ +"""Switch platform for Miele switch integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any, Final, cast + +import aiohttp +from pymiele import MieleDevice + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import ( + DOMAIN, + POWER_OFF, + POWER_ON, + PROCESS_ACTION, + MieleActions, + MieleAppliance, + StateStatus, +) +from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator +from .entity import MieleEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class MieleSwitchDescription(SwitchEntityDescription): + """Class describing Miele switch entities.""" + + value_fn: Callable[[MieleDevice], StateType] + on_value: int = 0 + off_value: int = 0 + on_cmd_data: dict[str, str | int | bool] + off_cmd_data: dict[str, str | int | bool] + + +@dataclass +class MieleSwitchDefinition: + """Class for defining switch entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleSwitchDescription + + +SWITCH_TYPES: Final[tuple[MieleSwitchDefinition, ...]] = ( + MieleSwitchDefinition( + types=(MieleAppliance.FRIDGE, MieleAppliance.FRIDGE_FREEZER), + description=MieleSwitchDescription( + key="supercooling", + value_fn=lambda value: value.state_status, + on_value=StateStatus.SUPERCOOLING, + translation_key="supercooling", + on_cmd_data={PROCESS_ACTION: MieleActions.START_SUPERCOOL}, + off_cmd_data={PROCESS_ACTION: MieleActions.STOP_SUPERCOOL}, + ), + ), + MieleSwitchDefinition( + types=( + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.WINE_CABINET_FREEZER, + ), + description=MieleSwitchDescription( + key="superfreezing", + value_fn=lambda value: value.state_status, + on_value=StateStatus.SUPERFREEZING, + translation_key="superfreezing", + on_cmd_data={PROCESS_ACTION: MieleActions.START_SUPERFREEZE}, + off_cmd_data={PROCESS_ACTION: MieleActions.STOP_SUPERFREEZE}, + ), + ), + MieleSwitchDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSwitchDescription( + key="poweronoff", + value_fn=lambda value: value.state_status, + off_value=1, + translation_key="power", + on_cmd_data={POWER_ON: True}, + off_cmd_data={POWER_OFF: True}, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the switch platform.""" + coordinator = config_entry.runtime_data + + entities: list = [] + entity_class: type[MieleSwitch] + for device_id, device in coordinator.data.devices.items(): + for definition in SWITCH_TYPES: + if device.device_type in definition.types: + match definition.description.key: + case "poweronoff": + entity_class = MielePowerSwitch + case "supercooling" | "superfreezing": + entity_class = MieleSuperSwitch + + entities.append( + entity_class(coordinator, device_id, definition.description) + ) + async_add_entities(entities) + + +class MieleSwitch(MieleEntity, SwitchEntity): + """Representation of a Switch.""" + + entity_description: MieleSwitchDescription + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: MieleSwitchDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator, device_id, description) + self.api = coordinator.api + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the device.""" + await self.async_turn_switch(self.entity_description.on_cmd_data) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the device.""" + await self.async_turn_switch(self.entity_description.off_cmd_data) + + async def async_turn_switch(self, mode: dict[str, str | int | bool]) -> None: + """Set switch to mode.""" + try: + await self.api.send_action(self._device_id, mode) + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from err + + +class MielePowerSwitch(MieleSwitch): + """Representation of a power switch.""" + + entity_description: MieleSwitchDescription + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return self.coordinator.data.actions[self._device_id].power_off_enabled + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + + return ( + self.coordinator.data.actions[self._device_id].power_off_enabled + or self.coordinator.data.actions[self._device_id].power_on_enabled + ) and super().available + + async def async_turn_switch(self, mode: dict[str, str | int | bool]) -> None: + """Set switch to mode.""" + try: + await self.api.send_action(self._device_id, mode) + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from err + self.coordinator.data.actions[self._device_id].power_on_enabled = cast( + bool, mode + ) + self.coordinator.data.actions[self._device_id].power_off_enabled = not cast( + bool, mode + ) + self.async_write_ha_state() + + +class MieleSuperSwitch(MieleSwitch): + """Representation of a supercool/superfreeze switch.""" + + entity_description: MieleSwitchDescription + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return ( + self.entity_description.value_fn(self.device) + == self.entity_description.on_value + ) diff --git a/tests/components/miele/snapshots/test_switch.ambr b/tests/components/miele/snapshots/test_switch.ambr new file mode 100644 index 00000000000..b7f49f84eed --- /dev/null +++ b/tests/components/miele/snapshots/test_switch.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_switch_states[platforms0][switch.freezer_superfreezing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.freezer_superfreezing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Superfreezing', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'superfreezing', + 'unique_id': 'Dummy_Appliance_1-superfreezing', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.freezer_superfreezing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Freezer Superfreezing', + }), + 'context': , + 'entity_id': 'switch.freezer_superfreezing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.hood_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.hood_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'DummyAppliance_18-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.hood_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Power', + }), + 'context': , + 'entity_id': 'switch.hood_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.refrigerator_supercooling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.refrigerator_supercooling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Supercooling', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supercooling', + 'unique_id': 'Dummy_Appliance_2-supercooling', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.refrigerator_supercooling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Supercooling', + }), + 'context': , + 'entity_id': 'switch.refrigerator_supercooling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.washing_machine_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.washing_machine_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'Dummy_Appliance_3-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.washing_machine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Power', + }), + 'context': , + 'entity_id': 'switch.washing_machine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/miele/test_switch.py b/tests/components/miele/test_switch.py new file mode 100644 index 00000000000..fa5e9360da6 --- /dev/null +++ b/tests/components/miele/test_switch.py @@ -0,0 +1,95 @@ +"""Tests for miele switch module.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +TEST_PLATFORM = SWITCH_DOMAIN +pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) + +ENTITY_ID = "switch.freezer_superfreezing" + + +async def test_switch_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test switch entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity"), + [ + (ENTITY_ID), + ("switch.refrigerator_supercooling"), + ("switch.washing_machine_power"), + ], +) +@pytest.mark.parametrize( + ("service"), + [ + (SERVICE_TURN_ON), + (SERVICE_TURN_OFF), + ], +) +async def test_switching( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + service: str, + entity: str, +) -> None: + """Test the switch can be turned on/off.""" + + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: entity}, blocking=True + ) + mock_miele_client.send_action.assert_called_once() + + +@pytest.mark.parametrize( + ("entity"), + [ + (ENTITY_ID), + ("switch.refrigerator_supercooling"), + ("switch.washing_machine_power"), + ], +) +@pytest.mark.parametrize( + ("service"), + [ + (SERVICE_TURN_ON), + (SERVICE_TURN_OFF), + ], +) +async def test_api_failure( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + service: str, + entity: str, +) -> None: + """Test handling of exception from API.""" + mock_miele_client.send_action.side_effect = ClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: entity}, blocking=True + ) + mock_miele_client.send_action.assert_called_once()