From 49807c9fbe504b121f1254bb30fab7e62447e379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 22 Jul 2025 13:33:03 +0200 Subject: [PATCH] Add set_program service to Miele (#143442) --- homeassistant/components/miele/__init__.py | 13 ++- homeassistant/components/miele/icons.json | 5 + homeassistant/components/miele/services.py | 92 ++++++++++++++++ homeassistant/components/miele/services.yaml | 17 +++ homeassistant/components/miele/strings.json | 22 ++++ tests/components/miele/conftest.py | 1 + tests/components/miele/test_services.py | 110 +++++++++++++++++++ 7 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/miele/services.py create mode 100644 homeassistant/components/miele/services.yaml create mode 100644 tests/components/miele/test_services.py diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py index 9b9ec81bea9..1cb2fc0fab1 100644 --- a/homeassistant/components/miele/__init__.py +++ b/homeassistant/components/miele/__init__.py @@ -7,16 +7,18 @@ from aiohttp import ClientError, ClientResponseError from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) +from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth from .const import DOMAIN from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator +from .services import async_setup_services PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -29,6 +31,15 @@ PLATFORMS: list[Platform] = [ Platform.VACUUM, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up service actions.""" + await async_setup_services(hass) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool: """Set up Miele from a config entry.""" diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index 44b51a67c24..1b757a9e113 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -103,5 +103,10 @@ "default": "mdi:snowflake" } } + }, + "services": { + "set_program": { + "service": "mdi:arrow-right-circle-outline" + } } } diff --git a/homeassistant/components/miele/services.py b/homeassistant/components/miele/services.py new file mode 100644 index 00000000000..70ea20ccc4a --- /dev/null +++ b/homeassistant/components/miele/services.py @@ -0,0 +1,92 @@ +"""Services for Miele integration.""" + +import logging +from typing import cast + +import aiohttp +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.service import async_extract_config_entry_ids + +from .const import DOMAIN +from .coordinator import MieleConfigEntry + +ATTR_PROGRAM_ID = "program_id" +ATTR_DURATION = "duration" + + +SERVICE_SET_PROGRAM = "set_program" +SERVICE_SET_PROGRAM_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM_ID): cv.positive_int, + }, +) + +_LOGGER = logging.getLogger(__name__) + + +async def _extract_config_entry(service_call: ServiceCall) -> MieleConfigEntry: + """Extract config entry from the service call.""" + hass = service_call.hass + target_entry_ids = await async_extract_config_entry_ids(hass, service_call) + target_entries: list[MieleConfigEntry] = [ + loaded_entry + for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN) + if loaded_entry.entry_id in target_entry_ids + ] + if not target_entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + return target_entries[0] + + +async def set_program(call: ServiceCall) -> None: + """Set a program on a Miele appliance.""" + + _LOGGER.debug("Set program call: %s", call) + config_entry = await _extract_config_entry(call) + device_reg = dr.async_get(call.hass) + api = config_entry.runtime_data.api + device = call.data[ATTR_DEVICE_ID] + device_entry = device_reg.async_get(device) + + data = {"programId": call.data[ATTR_PROGRAM_ID]} + serial_number = next( + ( + identifier[1] + for identifier in cast(dr.DeviceEntry, device_entry).identifiers + if identifier[0] == DOMAIN + ), + None, + ) + if serial_number is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + try: + await api.set_program(serial_number, data) + except aiohttp.ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_program_error", + translation_placeholders={ + "status": str(ex.status), + "message": ex.message, + }, + ) from ex + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services.""" + + hass.services.async_register( + DOMAIN, SERVICE_SET_PROGRAM, set_program, SERVICE_SET_PROGRAM_SCHEMA + ) diff --git a/homeassistant/components/miele/services.yaml b/homeassistant/components/miele/services.yaml new file mode 100644 index 00000000000..486fdf7307b --- /dev/null +++ b/homeassistant/components/miele/services.yaml @@ -0,0 +1,17 @@ +# Services descriptions for Miele integration + +set_program: + fields: + device_id: + selector: + device: + integration: miele + required: true + program_id: + required: true + selector: + number: + min: 0 + max: 99999 + mode: box + example: 24 diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 97035da6d5f..865f3313ad5 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -1059,8 +1059,30 @@ "config_entry_not_ready": { "message": "Error while loading the integration." }, + "invalid_target": { + "message": "Invalid device targeted." + }, + "set_program_error": { + "message": "'Set program' action failed {status} / {message}." + }, "set_state_error": { "message": "Failed to set state for {entity}." } + }, + "services": { + "set_program": { + "name": "Set program", + "description": "Sets and starts a program on the appliance.", + "fields": { + "device_id": { + "description": "The device to set the program on.", + "name": "Device" + }, + "program_id": { + "description": "The ID of the program to set.", + "name": "Program ID" + } + } + } } } diff --git a/tests/components/miele/conftest.py b/tests/components/miele/conftest.py index 94112e29143..7b3c3f35f7e 100644 --- a/tests/components/miele/conftest.py +++ b/tests/components/miele/conftest.py @@ -125,6 +125,7 @@ def mock_miele_client( client.get_devices.return_value = device_fixture client.get_actions.return_value = action_fixture client.get_programs.return_value = programs_fixture + client.set_program.return_value = None yield client diff --git a/tests/components/miele/test_services.py b/tests/components/miele/test_services.py new file mode 100644 index 00000000000..8b33c17d69f --- /dev/null +++ b/tests/components/miele/test_services.py @@ -0,0 +1,110 @@ +"""Tests the services provided by the miele integration.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientResponseError +import pytest +from voluptuous import MultipleInvalid + +from homeassistant.components.miele.const import DOMAIN +from homeassistant.components.miele.services import ATTR_PROGRAM_ID, SERVICE_SET_PROGRAM +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.device_registry import DeviceRegistry + +from . import setup_integration + +from tests.common import MockConfigEntry + +TEST_APPLIANCE = "Dummy_Appliance_1" + + +async def test_services( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Tests that the custom services are correct.""" + + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + { + ATTR_DEVICE_ID: device.id, + ATTR_PROGRAM_ID: 24, + }, + blocking=True, + ) + mock_miele_client.set_program.assert_called_once_with( + TEST_APPLIANCE, {"programId": 24} + ) + + +async def test_service_api_errors( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service api errors.""" + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + + # Test http error + mock_miele_client.set_program.side_effect = ClientResponseError("TestInfo", "test") + with pytest.raises(HomeAssistantError, match="'Set program' action failed"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": device.id, ATTR_PROGRAM_ID: 1}, + blocking=True, + ) + mock_miele_client.set_program.assert_called_once_with( + TEST_APPLIANCE, {"programId": 1} + ) + + +async def test_service_validation_errors( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Tests that the custom services handle bad data.""" + + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + + # Test missing program_id + with pytest.raises(MultipleInvalid, match="required key not provided"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": device.id}, + blocking=True, + ) + mock_miele_client.set_program.assert_not_called() + + # Test invalid program_id + with pytest.raises(MultipleInvalid, match="expected int for dictionary value"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": device.id, ATTR_PROGRAM_ID: "invalid"}, + blocking=True, + ) + mock_miele_client.set_program.assert_not_called() + + # Test invalid device + with pytest.raises(ServiceValidationError, match="Invalid device targeted"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": "invalid_device", ATTR_PROGRAM_ID: 1}, + blocking=True, + ) + mock_miele_client.set_program.assert_not_called()