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": {
"get_programs": {
"service": "mdi:stack-overflow"
},
"set_program": {
"service": "mdi:arrow-right-circle-outline"
}

View File

@ -7,7 +7,12 @@ import aiohttp
import voluptuous as vol
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.helpers import config_validation as cv, device_registry as dr
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__)
@ -47,17 +59,12 @@ async def _extract_config_entry(service_call: ServiceCall) -> MieleConfigEntry:
return target_entries[0]
async def set_program(call: ServiceCall) -> None:
"""Set a program on a Miele appliance."""
async def _get_serial_number(call: ServiceCall) -> str:
"""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)
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]
@ -71,6 +78,18 @@ async def set_program(call: ServiceCall) -> None:
translation_domain=DOMAIN,
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:
await api.set_program(serial_number, data)
except aiohttp.ClientResponseError as ex:
@ -84,9 +103,82 @@ async def set_program(call: ServiceCall) -> None:
) 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:
"""Set up services."""
hass.services.async_register(
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
get_programs:
fields:
device_id:
selector:
device:
integration: miele
required: true
set_program:
fields:
device_id:

View File

@ -1062,6 +1062,9 @@
"invalid_target": {
"message": "Invalid device targeted."
},
"get_programs_error": {
"message": "'Get programs' action failed {status} / {message}."
},
"set_program_error": {
"message": "'Set program' action failed {status} / {message}."
},
@ -1070,12 +1073,22 @@
}
},
"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": {
"name": "Set program",
"description": "Sets and starts a program on the appliance.",
"fields": {
"device_id": {
"description": "The device to set the program on.",
"description": "The target device for this action.",
"name": "Device"
},
"program_id": {

View File

@ -20,8 +20,8 @@ from .const import CLIENT_ID, CLIENT_SECRET
from tests.common import (
MockConfigEntry,
async_load_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")
def load_programs_file() -> str:
"""Fixture for loading programs file."""
return "programs_washing_machine.json"
return "programs.json"
@pytest.fixture
async def programs_fixture(hass: HomeAssistant, load_programs_file: str) -> list[dict]:
"""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

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
import pytest
from syrupy.assertion import SnapshotAssertion
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.components.miele.services import (
ATTR_PROGRAM_ID,
SERVICE_GET_PROGRAMS,
SERVICE_SET_PROGRAM,
)
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant
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(
hass: HomeAssistant,
device_registry: DeviceRegistry,
@ -60,7 +87,7 @@ async def test_service_api_errors(
await hass.services.async_call(
DOMAIN,
SERVICE_SET_PROGRAM,
{"device_id": device.id, ATTR_PROGRAM_ID: 1},
{ATTR_DEVICE_ID: device.id, ATTR_PROGRAM_ID: 1},
blocking=True,
)
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(
hass: HomeAssistant,
device_registry: DeviceRegistry,