Add set_program service to Miele (#143442)

This commit is contained in:
Åke Strandberg 2025-07-22 13:33:03 +02:00 committed by GitHub
parent e79d42ecfc
commit 49807c9fbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 259 additions and 1 deletions

View File

@ -7,16 +7,18 @@ from aiohttp import ClientError, ClientResponseError
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady 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.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import ( from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session, OAuth2Session,
async_get_config_entry_implementation, async_get_config_entry_implementation,
) )
from homeassistant.helpers.typing import ConfigType
from .api import AsyncConfigEntryAuth from .api import AsyncConfigEntryAuth
from .const import DOMAIN from .const import DOMAIN
from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator
from .services import async_setup_services
PLATFORMS: list[Platform] = [ PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
@ -29,6 +31,15 @@ PLATFORMS: list[Platform] = [
Platform.VACUUM, 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: async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool:
"""Set up Miele from a config entry.""" """Set up Miele from a config entry."""

View File

@ -103,5 +103,10 @@
"default": "mdi:snowflake" "default": "mdi:snowflake"
} }
} }
},
"services": {
"set_program": {
"service": "mdi:arrow-right-circle-outline"
}
} }
} }

View File

@ -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
)

View File

@ -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

View File

@ -1059,8 +1059,30 @@
"config_entry_not_ready": { "config_entry_not_ready": {
"message": "Error while loading the integration." "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": { "set_state_error": {
"message": "Failed to set state for {entity}." "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"
}
}
}
} }
} }

View File

@ -125,6 +125,7 @@ def mock_miele_client(
client.get_devices.return_value = device_fixture client.get_devices.return_value = device_fixture
client.get_actions.return_value = action_fixture client.get_actions.return_value = action_fixture
client.get_programs.return_value = programs_fixture client.get_programs.return_value = programs_fixture
client.set_program.return_value = None
yield client yield client

View File

@ -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()