Add action to retrieve list of programs on miele appliance (#149307)

This commit is contained in:
Åke Strandberg 2025-07-28 22:19:51 +02:00 committed by GitHub
parent 7f9be420d2
commit cf05f1046d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 262 additions and 131 deletions

View File

@ -105,6 +105,9 @@
} }
}, },
"services": { "services": {
"get_programs": {
"service": "mdi:stack-overflow"
},
"set_program": { "set_program": {
"service": "mdi:arrow-right-circle-outline" "service": "mdi:arrow-right-circle-outline"
} }

View File

@ -7,7 +7,12 @@ import aiohttp
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.service import async_extract_config_entry_ids from homeassistant.helpers.service import async_extract_config_entry_ids
@ -27,6 +32,13 @@ SERVICE_SET_PROGRAM_SCHEMA = vol.Schema(
}, },
) )
SERVICE_GET_PROGRAMS = "get_programs"
SERVICE_GET_PROGRAMS_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
},
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -47,17 +59,12 @@ async def _extract_config_entry(service_call: ServiceCall) -> MieleConfigEntry:
return target_entries[0] return target_entries[0]
async def set_program(call: ServiceCall) -> None: async def _get_serial_number(call: ServiceCall) -> str:
"""Set a program on a Miele appliance.""" """Extract the serial number from the device identifier."""
_LOGGER.debug("Set program call: %s", call)
config_entry = await _extract_config_entry(call)
device_reg = dr.async_get(call.hass) device_reg = dr.async_get(call.hass)
api = config_entry.runtime_data.api
device = call.data[ATTR_DEVICE_ID] device = call.data[ATTR_DEVICE_ID]
device_entry = device_reg.async_get(device) device_entry = device_reg.async_get(device)
data = {"programId": call.data[ATTR_PROGRAM_ID]}
serial_number = next( serial_number = next(
( (
identifier[1] identifier[1]
@ -71,6 +78,18 @@ async def set_program(call: ServiceCall) -> None:
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="invalid_target", translation_key="invalid_target",
) )
return serial_number
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)
api = config_entry.runtime_data.api
serial_number = await _get_serial_number(call)
data = {"programId": call.data[ATTR_PROGRAM_ID]}
try: try:
await api.set_program(serial_number, data) await api.set_program(serial_number, data)
except aiohttp.ClientResponseError as ex: except aiohttp.ClientResponseError as ex:
@ -84,9 +103,82 @@ async def set_program(call: ServiceCall) -> None:
) from ex ) from ex
async def get_programs(call: ServiceCall) -> ServiceResponse:
"""Get available programs from appliance."""
config_entry = await _extract_config_entry(call)
api = config_entry.runtime_data.api
serial_number = await _get_serial_number(call)
try:
programs = await api.get_programs(serial_number)
except aiohttp.ClientResponseError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="get_programs_error",
translation_placeholders={
"status": str(ex.status),
"message": ex.message,
},
) from ex
return {
"programs": [
{
"program_id": item["programId"],
"program": item["program"],
"parameters": (
{
"temperature": (
{
"min": item["parameters"]["temperature"]["min"],
"max": item["parameters"]["temperature"]["max"],
"step": item["parameters"]["temperature"]["step"],
"mandatory": item["parameters"]["temperature"][
"mandatory"
],
}
if "temperature" in item["parameters"]
else {}
),
"duration": (
{
"min": {
"hours": item["parameters"]["duration"]["min"][0],
"minutes": item["parameters"]["duration"]["min"][1],
},
"max": {
"hours": item["parameters"]["duration"]["max"][0],
"minutes": item["parameters"]["duration"]["max"][1],
},
"mandatory": item["parameters"]["duration"][
"mandatory"
],
}
if "duration" in item["parameters"]
else {}
),
}
if item["parameters"]
else {}
),
}
for item in programs
],
}
async def async_setup_services(hass: HomeAssistant) -> None: async def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services.""" """Set up services."""
hass.services.async_register( hass.services.async_register(
DOMAIN, SERVICE_SET_PROGRAM, set_program, SERVICE_SET_PROGRAM_SCHEMA DOMAIN, SERVICE_SET_PROGRAM, set_program, SERVICE_SET_PROGRAM_SCHEMA
) )
hass.services.async_register(
DOMAIN,
SERVICE_GET_PROGRAMS,
get_programs,
SERVICE_GET_PROGRAMS_SCHEMA,
supports_response=SupportsResponse.ONLY,
)

View File

@ -1,5 +1,13 @@
# Services descriptions for Miele integration # Services descriptions for Miele integration
get_programs:
fields:
device_id:
selector:
device:
integration: miele
required: true
set_program: set_program:
fields: fields:
device_id: device_id:

View File

@ -1062,6 +1062,9 @@
"invalid_target": { "invalid_target": {
"message": "Invalid device targeted." "message": "Invalid device targeted."
}, },
"get_programs_error": {
"message": "'Get programs' action failed {status} / {message}."
},
"set_program_error": { "set_program_error": {
"message": "'Set program' action failed {status} / {message}." "message": "'Set program' action failed {status} / {message}."
}, },
@ -1070,12 +1073,22 @@
} }
}, },
"services": { "services": {
"get_programs": {
"name": "Get programs",
"description": "Returns a list of available programs.",
"fields": {
"device_id": {
"description": "[%key:component::miele::services::set_program::fields::device_id::description%]",
"name": "[%key:component::miele::services::set_program::fields::device_id::name%]"
}
}
},
"set_program": { "set_program": {
"name": "Set program", "name": "Set program",
"description": "Sets and starts a program on the appliance.", "description": "Sets and starts a program on the appliance.",
"fields": { "fields": {
"device_id": { "device_id": {
"description": "The device to set the program on.", "description": "The target device for this action.",
"name": "Device" "name": "Device"
}, },
"program_id": { "program_id": {

View File

@ -20,8 +20,8 @@ from .const import CLIENT_ID, CLIENT_SECRET
from tests.common import ( from tests.common import (
MockConfigEntry, MockConfigEntry,
async_load_fixture,
async_load_json_object_fixture, async_load_json_object_fixture,
load_json_value_fixture,
) )
@ -99,13 +99,13 @@ async def action_fixture(hass: HomeAssistant, load_action_file: str) -> MieleAct
@pytest.fixture(scope="package") @pytest.fixture(scope="package")
def load_programs_file() -> str: def load_programs_file() -> str:
"""Fixture for loading programs file.""" """Fixture for loading programs file."""
return "programs_washing_machine.json" return "programs.json"
@pytest.fixture @pytest.fixture
async def programs_fixture(hass: HomeAssistant, load_programs_file: str) -> list[dict]: async def programs_fixture(hass: HomeAssistant, load_programs_file: str) -> list[dict]:
"""Fixture for available programs.""" """Fixture for available programs."""
return await async_load_fixture(hass, load_programs_file, DOMAIN) return load_json_value_fixture(load_programs_file, DOMAIN)
@pytest.fixture @pytest.fixture

View File

@ -0,0 +1,34 @@
[
{
"programId": 1,
"program": "Cottons",
"parameters": {}
},
{
"programId": 146,
"program": "QuickPowerWash",
"parameters": {}
},
{
"programId": 123,
"program": "Dark garments / Denim",
"parameters": {}
},
{
"programId": 13,
"program": "Fan plus",
"parameters": {
"temperature": {
"min": 30,
"max": 250,
"step": 5,
"mandatory": false
},
"duration": {
"min": [0, 1],
"max": [12, 0],
"mandatory": true
}
}
}
]

View File

@ -1,117 +0,0 @@
[
{
"programId": 146,
"program": "QuickPowerWash",
"parameters": {}
},
{
"programId": 123,
"program": "Dark garments / Denim",
"parameters": {}
},
{
"programId": 190,
"program": "ECO 40-60 ",
"parameters": {}
},
{
"programId": 27,
"program": "Proofing",
"parameters": {}
},
{
"programId": 23,
"program": "Shirts",
"parameters": {}
},
{
"programId": 9,
"program": "Silks ",
"parameters": {}
},
{
"programId": 8,
"program": "Woollens ",
"parameters": {}
},
{
"programId": 4,
"program": "Delicates",
"parameters": {}
},
{
"programId": 3,
"program": "Minimum iron",
"parameters": {}
},
{
"programId": 1,
"program": "Cottons",
"parameters": {}
},
{
"programId": 69,
"program": "Cottons hygiene",
"parameters": {}
},
{
"programId": 37,
"program": "Outerwear",
"parameters": {}
},
{
"programId": 122,
"program": "Express 20",
"parameters": {}
},
{
"programId": 29,
"program": "Sportswear",
"parameters": {}
},
{
"programId": 31,
"program": "Automatic plus",
"parameters": {}
},
{
"programId": 39,
"program": "Pillows",
"parameters": {}
},
{
"programId": 22,
"program": "Curtains",
"parameters": {}
},
{
"programId": 129,
"program": "Down filled items",
"parameters": {}
},
{
"programId": 53,
"program": "First wash",
"parameters": {}
},
{
"programId": 95,
"program": "Down duvets",
"parameters": {}
},
{
"programId": 52,
"program": "Separate rinse / Starch",
"parameters": {}
},
{
"programId": 21,
"program": "Drain / Spin",
"parameters": {}
},
{
"programId": 91,
"program": "Clean machine",
"parameters": {}
}
]

View File

@ -0,0 +1,48 @@
# serializer version: 1
# name: test_services_with_response
dict({
'programs': list([
dict({
'parameters': dict({
}),
'program': 'Cottons',
'program_id': 1,
}),
dict({
'parameters': dict({
}),
'program': 'QuickPowerWash',
'program_id': 146,
}),
dict({
'parameters': dict({
}),
'program': 'Dark garments / Denim',
'program_id': 123,
}),
dict({
'parameters': dict({
'duration': dict({
'mandatory': True,
'max': dict({
'hours': 12,
'minutes': 0,
}),
'min': dict({
'hours': 0,
'minutes': 1,
}),
}),
'temperature': dict({
'mandatory': False,
'max': 250,
'min': 30,
'step': 5,
}),
}),
'program': 'Fan plus',
'program_id': 13,
}),
]),
})
# ---

View File

@ -4,10 +4,15 @@ from unittest.mock import MagicMock
from aiohttp import ClientResponseError from aiohttp import ClientResponseError
import pytest import pytest
from syrupy.assertion import SnapshotAssertion
from voluptuous import MultipleInvalid from voluptuous import MultipleInvalid
from homeassistant.components.miele.const import DOMAIN from homeassistant.components.miele.const import DOMAIN
from homeassistant.components.miele.services import ATTR_PROGRAM_ID, SERVICE_SET_PROGRAM from homeassistant.components.miele.services import (
ATTR_PROGRAM_ID,
SERVICE_GET_PROGRAMS,
SERVICE_SET_PROGRAM,
)
from homeassistant.const import ATTR_DEVICE_ID from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
@ -44,6 +49,28 @@ async def test_services(
) )
async def test_services_with_response(
hass: HomeAssistant,
device_registry: DeviceRegistry,
mock_miele_client: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Tests that the custom services that returns a response are correct."""
await setup_integration(hass, mock_config_entry)
device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)})
assert snapshot == await hass.services.async_call(
DOMAIN,
SERVICE_GET_PROGRAMS,
{
ATTR_DEVICE_ID: device.id,
},
blocking=True,
return_response=True,
)
async def test_service_api_errors( async def test_service_api_errors(
hass: HomeAssistant, hass: HomeAssistant,
device_registry: DeviceRegistry, device_registry: DeviceRegistry,
@ -60,7 +87,7 @@ async def test_service_api_errors(
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
SERVICE_SET_PROGRAM, SERVICE_SET_PROGRAM,
{"device_id": device.id, ATTR_PROGRAM_ID: 1}, {ATTR_DEVICE_ID: device.id, ATTR_PROGRAM_ID: 1},
blocking=True, blocking=True,
) )
mock_miele_client.set_program.assert_called_once_with( mock_miele_client.set_program.assert_called_once_with(
@ -68,6 +95,29 @@ async def test_service_api_errors(
) )
async def test_get_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.get_programs.side_effect = ClientResponseError("TestInfo", "test")
with pytest.raises(HomeAssistantError, match="'Get programs' action failed"):
await hass.services.async_call(
DOMAIN,
SERVICE_GET_PROGRAMS,
{ATTR_DEVICE_ID: device.id},
blocking=True,
return_response=True,
)
mock_miele_client.get_programs.assert_called_once()
async def test_service_validation_errors( async def test_service_validation_errors(
hass: HomeAssistant, hass: HomeAssistant,
device_registry: DeviceRegistry, device_registry: DeviceRegistry,