From 5302964eb65b7dc81fd4cef9e5c349aa029b488f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Fri, 25 Apr 2025 19:10:32 +0200 Subject: [PATCH] Add button platform to miele (#143508) * WIP Button platform * Add button platform * Disable by default, Address review , update tests * Follow review comments --- homeassistant/components/miele/__init__.py | 1 + homeassistant/components/miele/button.py | 163 +++++++++++++++ homeassistant/components/miele/icons.json | 11 + homeassistant/components/miele/strings.json | 11 + .../fixtures/action_washing_machine.json | 2 +- .../miele/snapshots/test_button.ambr | 189 ++++++++++++++++++ .../miele/snapshots/test_diagnostics.ambr | 15 ++ tests/components/miele/test_button.py | 66 ++++++ 8 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/miele/button.py create mode 100644 tests/components/miele/snapshots/test_button.ambr create mode 100644 tests/components/miele/test_button.py diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py index c366c29219f..0f538816657 100644 --- a/homeassistant/components/miele/__init__.py +++ b/homeassistant/components/miele/__init__.py @@ -18,6 +18,7 @@ from .const import DOMAIN from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator PLATFORMS: list[Platform] = [ + Platform.BUTTON, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/miele/button.py b/homeassistant/components/miele/button.py new file mode 100644 index 00000000000..f38b4de4b91 --- /dev/null +++ b/homeassistant/components/miele/button.py @@ -0,0 +1,163 @@ +"""Platform for Miele button integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Final + +import aiohttp + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN, PROCESS_ACTION, MieleActions, MieleAppliance +from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator +from .entity import MieleEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class MieleButtonDescription(ButtonEntityDescription): + """Class describing Miele button entities.""" + + press_data: MieleActions + + +@dataclass +class MieleButtonDefinition: + """Class for defining button entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleButtonDescription + + +BUTTON_TYPES: Final[tuple[MieleButtonDefinition, ...]] = ( + MieleButtonDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.DIALOG_OVEN, + ), + description=MieleButtonDescription( + key="start", + translation_key="start", + press_data=MieleActions.START, + entity_registry_enabled_default=False, + ), + ), + MieleButtonDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.HOOD, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.DIALOG_OVEN, + ), + description=MieleButtonDescription( + key="stop", + translation_key="stop", + press_data=MieleActions.STOP, + entity_registry_enabled_default=False, + ), + ), + MieleButtonDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.WASHER_DRYER, + ), + description=MieleButtonDescription( + key="pause", + translation_key="pause", + press_data=MieleActions.PAUSE, + entity_registry_enabled_default=False, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the button platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + MieleButton(coordinator, device_id, definition.description) + for device_id, device in coordinator.data.devices.items() + for definition in BUTTON_TYPES + if device.device_type in definition.types + ) + + +class MieleButton(MieleEntity, ButtonEntity): + """Representation of a Button.""" + + entity_description: MieleButtonDescription + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: MieleButtonDescription, + ) -> None: + """Initialize the button.""" + super().__init__(coordinator, device_id, description) + self.api = coordinator.api + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + + return ( + super().available + and self.entity_description.press_data + in self.coordinator.data.actions[self._device_id].process_actions + ) + + async def async_press(self) -> None: + """Press the button.""" + _LOGGER.debug("Press: %s", self.entity_description.key) + try: + await self.api.send_action( + self._device_id, + {PROCESS_ACTION: self.entity_description.press_data}, + ) + except aiohttp.ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from ex diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index c9c7639b61a..6ed7067c583 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -1,5 +1,16 @@ { "entity": { + "button": { + "start": { + "default": "mdi:play" + }, + "stop": { + "default": "mdi:stop" + }, + "pause": { + "default": "mdi:pause" + } + }, "switch": { "power": { "default": "mdi:power" diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index ae8c43b12db..5bf19933230 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -114,6 +114,17 @@ } }, "entity": { + "button": { + "start": { + "name": "[%key:common::action::start%]" + }, + "stop": { + "name": "[%key:common::action::stop%]" + }, + "pause": { + "name": "[%key:common::action::pause%]" + } + }, "light": { "ambient_light": { "name": "Ambient light" diff --git a/tests/components/miele/fixtures/action_washing_machine.json b/tests/components/miele/fixtures/action_washing_machine.json index 67e3a0666ff..5e8e00306f4 100644 --- a/tests/components/miele/fixtures/action_washing_machine.json +++ b/tests/components/miele/fixtures/action_washing_machine.json @@ -1,5 +1,5 @@ { - "processAction": [], + "processAction": [1, 2, 3], "light": [], "ambientLight": [], "startTime": [], diff --git a/tests/components/miele/snapshots/test_button.ambr b/tests/components/miele/snapshots/test_button.ambr new file mode 100644 index 00000000000..b4f5ea5685a --- /dev/null +++ b/tests/components/miele/snapshots/test_button.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_button_states[platforms0][button.hood_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.hood_stop', + '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': 'Stop', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'DummyAppliance_18-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.hood_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Stop', + }), + 'context': , + 'entity_id': 'button.hood_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_pause-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.washing_machine_pause', + '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': 'Pause', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pause', + 'unique_id': 'Dummy_Appliance_3-pause', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_pause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Pause', + }), + 'context': , + 'entity_id': 'button.washing_machine_pause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.washing_machine_start', + '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': 'Start', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'Dummy_Appliance_3-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Start', + }), + 'context': , + 'entity_id': 'button.washing_machine_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.washing_machine_stop', + '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': 'Stop', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'Dummy_Appliance_3-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Stop', + }), + 'context': , + 'entity_id': 'button.washing_machine_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/miele/snapshots/test_diagnostics.ambr b/tests/components/miele/snapshots/test_diagnostics.ambr index 2aac726cbad..20738295863 100644 --- a/tests/components/miele/snapshots/test_diagnostics.ambr +++ b/tests/components/miele/snapshots/test_diagnostics.ambr @@ -25,6 +25,9 @@ 'powerOff': False, 'powerOn': True, 'processAction': list([ + 1, + 2, + 3, ]), 'programId': list([ ]), @@ -50,6 +53,9 @@ 'powerOff': False, 'powerOn': True, 'processAction': list([ + 1, + 2, + 3, ]), 'programId': list([ ]), @@ -75,6 +81,9 @@ 'powerOff': False, 'powerOn': True, 'processAction': list([ + 1, + 2, + 3, ]), 'programId': list([ ]), @@ -100,6 +109,9 @@ 'powerOff': False, 'powerOn': True, 'processAction': list([ + 1, + 2, + 3, ]), 'programId': list([ ]), @@ -663,6 +675,9 @@ 'powerOff': False, 'powerOn': True, 'processAction': list([ + 1, + 2, + 3, ]), 'programId': list([ ]), diff --git a/tests/components/miele/test_button.py b/tests/components/miele/test_button.py new file mode 100644 index 00000000000..9bf5f2f3f54 --- /dev/null +++ b/tests/components/miele/test_button.py @@ -0,0 +1,66 @@ +"""Tests for Miele button module.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientResponseError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +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 = BUTTON_DOMAIN +pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) + +ENTITY_ID = "button.washing_machine_start" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test button entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_press( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, +) -> None: + """Test button press.""" + + await hass.services.async_call( + TEST_PLATFORM, SERVICE_PRESS, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once_with( + "Dummy_Appliance_3", {"processAction": 1} + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_api_failure( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, +) -> None: + """Test handling of exception from API.""" + mock_miele_client.send_action.side_effect = ClientResponseError("test", "Test") + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TEST_PLATFORM, SERVICE_PRESS, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once()