Add vacuum platform to miele (#143757)

* Add vacuum platform

* Add comments

* Update snapshot

* Use class defined in pymiele

* Use device class transation

* Fix typo

* Cleanup consts

* Clean up activity property

* Address review comments

* Address review comments
This commit is contained in:
Åke Strandberg 2025-05-09 13:17:37 +02:00 committed by GitHub
parent 7bad07ac10
commit a93bf3c150
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 489 additions and 0 deletions

View File

@ -26,6 +26,7 @@ PLATFORMS: list[Platform] = [
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
Platform.VACUUM,
]

View File

@ -11,6 +11,7 @@ ACTIONS = "actions"
POWER_ON = "powerOn"
POWER_OFF = "powerOff"
PROCESS_ACTION = "processAction"
PROGRAM_ID = "programId"
VENTILATION_STEP = "ventilationStep"
TARGET_TEMPERATURE = "targetTemperature"
AMBIENT_LIGHT = "ambientLight"

View File

@ -0,0 +1,224 @@
"""Platform for Miele vacuum integration."""
from __future__ import annotations
from dataclasses import dataclass
from enum import IntEnum
import logging
from typing import Any, Final
from aiohttp import ClientResponseError
from pymiele import MieleEnum
from homeassistant.components.vacuum import (
StateVacuumEntity,
StateVacuumEntityDescription,
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, PROCESS_ACTION, PROGRAM_ID, MieleActions, MieleAppliance
from .coordinator import MieleConfigEntry
from .entity import MieleEntity
_LOGGER = logging.getLogger(__name__)
# The following const classes define program speeds and programs for the vacuum cleaner.
# Miele have used the same and overlapping names for fan_speeds and programs even
# if the contexts are different. This is an attempt to make it clearer in the integration.
class FanSpeed(IntEnum):
"""Define fan speeds."""
normal = 0
turbo = 1
silent = 2
class FanProgram(IntEnum):
"""Define fan programs."""
auto = 1
spot = 2
turbo = 3
silent = 4
PROGRAM_MAP = {
"normal": FanProgram.auto,
"turbo": FanProgram.turbo,
"silent": FanProgram.silent,
}
PROGRAM_TO_SPEED: dict[int, str] = {
FanProgram.auto: "normal",
FanProgram.turbo: "turbo",
FanProgram.silent: "silent",
FanProgram.spot: "normal",
}
class MieleVacuumStateCode(MieleEnum):
"""Define vacuum state codes."""
idle = 0
cleaning = 5889
returning = 5890
paused = 5891
going_to_target_area = 5892
wheel_lifted = 5893
dirty_sensors = 5894
dust_box_missing = 5895
blocked_drive_wheels = 5896
blocked_brushes = 5897
check_dust_box_and_filter = 5898
internal_fault_reboot = 5899
blocked_front_wheel = 5900
docked = 5903, 5904
remote_controlled = 5910
unknown = -9999
SUPPORTED_FEATURES = (
VacuumEntityFeature.STATE
| VacuumEntityFeature.BATTERY
| VacuumEntityFeature.FAN_SPEED
| VacuumEntityFeature.START
| VacuumEntityFeature.STOP
| VacuumEntityFeature.PAUSE
| VacuumEntityFeature.CLEAN_SPOT
)
@dataclass(frozen=True, kw_only=True)
class MieleVacuumDescription(StateVacuumEntityDescription):
"""Class describing Miele vacuum entities."""
on_value: int
@dataclass
class MieleVacuumDefinition:
"""Class for defining vacuum entities."""
types: tuple[MieleAppliance, ...]
description: MieleVacuumDescription
VACUUM_TYPES: Final[tuple[MieleVacuumDefinition, ...]] = (
MieleVacuumDefinition(
types=(MieleAppliance.ROBOT_VACUUM_CLEANER,),
description=MieleVacuumDescription(
key="vacuum",
on_value=14,
name=None,
translation_key="vacuum",
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MieleConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the vacuum platform."""
coordinator = config_entry.runtime_data
async_add_entities(
MieleVacuum(coordinator, device_id, definition.description)
for device_id, device in coordinator.data.devices.items()
for definition in VACUUM_TYPES
if device.device_type in definition.types
)
VACUUM_PHASE_TO_ACTIVITY = {
MieleVacuumStateCode.idle: VacuumActivity.IDLE,
MieleVacuumStateCode.docked: VacuumActivity.DOCKED,
MieleVacuumStateCode.cleaning: VacuumActivity.CLEANING,
MieleVacuumStateCode.going_to_target_area: VacuumActivity.CLEANING,
MieleVacuumStateCode.returning: VacuumActivity.RETURNING,
MieleVacuumStateCode.wheel_lifted: VacuumActivity.ERROR,
MieleVacuumStateCode.dirty_sensors: VacuumActivity.ERROR,
MieleVacuumStateCode.dust_box_missing: VacuumActivity.ERROR,
MieleVacuumStateCode.blocked_drive_wheels: VacuumActivity.ERROR,
MieleVacuumStateCode.blocked_brushes: VacuumActivity.ERROR,
MieleVacuumStateCode.check_dust_box_and_filter: VacuumActivity.ERROR,
MieleVacuumStateCode.internal_fault_reboot: VacuumActivity.ERROR,
MieleVacuumStateCode.blocked_front_wheel: VacuumActivity.ERROR,
MieleVacuumStateCode.paused: VacuumActivity.PAUSED,
MieleVacuumStateCode.remote_controlled: VacuumActivity.PAUSED,
}
class MieleVacuum(MieleEntity, StateVacuumEntity):
"""Representation of a Vacuum entity."""
entity_description: MieleVacuumDescription
_attr_supported_features = SUPPORTED_FEATURES
_attr_fan_speed_list = [fan_speed.name for fan_speed in FanSpeed]
_attr_name = None
@property
def activity(self) -> VacuumActivity | None:
"""Return activity."""
return VACUUM_PHASE_TO_ACTIVITY.get(
MieleVacuumStateCode(self.device.state_program_phase)
)
@property
def battery_level(self) -> int | None:
"""Return the battery level."""
return self.device.state_battery_level
@property
def fan_speed(self) -> str | None:
"""Return the fan speed."""
return PROGRAM_TO_SPEED.get(self.device.state_program_id)
@property
def available(self) -> bool:
"""Return the availability of the entity."""
return (
self.action.power_off_enabled or self.action.power_on_enabled
) and super().available
async def send(self, device_id: str, action: dict[str, Any]) -> None:
"""Send action to the device."""
try:
await self.api.send_action(device_id, action)
except ClientResponseError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_state_error",
translation_placeholders={
"entity": self.entity_id,
},
) from ex
async def async_clean_spot(self, **kwargs: Any) -> None:
"""Clean spot."""
await self.send(self._device_id, {PROGRAM_ID: FanProgram.spot})
async def async_start(self, **kwargs: Any) -> None:
"""Start cleaning."""
await self.send(self._device_id, {PROCESS_ACTION: MieleActions.START})
async def async_stop(self, **kwargs: Any) -> None:
"""Stop cleaning."""
await self.send(self._device_id, {PROCESS_ACTION: MieleActions.STOP})
async def async_pause(self, **kwargs: Any) -> None:
"""Pause cleaning."""
await self.send(self._device_id, {PROCESS_ACTION: MieleActions.PAUSE})
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set fan speed."""
await self.send(self._device_id, {PROGRAM_ID: PROGRAM_MAP[fan_speed]})

View File

@ -0,0 +1,81 @@
{
"Dummy_Vacuum_1": {
"ident": {
"type": {
"key_localized": "Device type",
"value_raw": 23,
"value_localized": "Robot vacuum cleaner"
},
"deviceName": "",
"protocolVersion": 0,
"deviceIdentLabel": {
"fabNumber": "161173909",
"fabIndex": "32",
"techType": "RX3",
"matNumber": "11686510",
"swids": ["<swid1>", "<swid2>", "<swid3>", "<...>"]
},
"xkmIdentLabel": { "techType": "", "releaseVersion": "" }
},
"state": {
"ProgramID": {
"value_raw": 1,
"value_localized": "Auto",
"key_localized": "Program name"
},
"status": {
"value_raw": 2,
"value_localized": "On",
"key_localized": "status"
},
"programType": {
"value_raw": 0,
"value_localized": "Program",
"key_localized": "Program type"
},
"programPhase": {
"xvalue_raw": 5889,
"zvalue_raw": 5904,
"value_raw": 5893,
"value_localized": "in the base station",
"key_localized": "Program phase"
},
"remainingTime": [0, 0],
"startTime": [0, 0],
"targetTemperature": [],
"temperature": [],
"coreTargetTemperature": [],
"coreTemperature": [],
"signalInfo": false,
"signalFailure": false,
"signalDoor": false,
"remoteEnable": {
"fullRemoteControl": true,
"smartGrid": false,
"mobileStart": false
},
"ambientLight": null,
"light": null,
"elapsedTime": [0, 0],
"spinningSpeed": {
"unit": "rpm",
"value_raw": null,
"value_localized": null,
"key_localized": "Spin speed"
},
"dryingStep": {
"value_raw": 0,
"value_localized": "",
"key_localized": "Drying level"
},
"ventilationStep": {
"value_raw": null,
"value_localized": "",
"key_localized": "Fan level"
},
"plateStep": [],
"ecoFeedback": null,
"batteryLevel": 65
}
}
}

View File

@ -0,0 +1,63 @@
# serializer version: 1
# name: test_sensor_states[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'fan_speed_list': list([
'normal',
'turbo',
'silent',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'vacuum',
'entity_category': None,
'entity_id': 'vacuum.robot_vacuum_cleaner',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'miele',
'previous_unique_id': None,
'supported_features': <VacuumEntityFeature: 13420>,
'translation_key': 'vacuum',
'unique_id': 'Dummy_Vacuum_1-vacuum',
'unit_of_measurement': None,
})
# ---
# name: test_sensor_states[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'battery_icon': 'mdi:battery-60',
'battery_level': 65,
'fan_speed': 'normal',
'fan_speed_list': list([
'normal',
'turbo',
'silent',
]),
'friendly_name': 'robot_vacuum_cleaner',
'supported_features': <VacuumEntityFeature: 13420>,
}),
'context': <ANY>,
'entity_id': 'vacuum.robot_vacuum_cleaner',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'paused',
})
# ---

View File

@ -0,0 +1,119 @@
"""Tests for miele vacuum module."""
from unittest.mock import MagicMock
from aiohttp import ClientResponseError
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.miele.const import PROCESS_ACTION, PROGRAM_ID
from homeassistant.components.vacuum import (
ATTR_FAN_SPEED,
DOMAIN as VACUUM_DOMAIN,
SERVICE_CLEAN_SPOT,
SERVICE_PAUSE,
SERVICE_SET_FAN_SPEED,
SERVICE_START,
SERVICE_STOP,
)
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 = VACUUM_DOMAIN
ENTITY_ID = "vacuum.robot_vacuum_cleaner"
pytestmark = [
pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]),
pytest.mark.parametrize("load_device_file", ["vacuum_device.json"]),
]
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_states(
hass: HomeAssistant,
mock_miele_client: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: None,
) -> None:
"""Test vacuum entity setup."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("service", "action_command", "vacuum_power"),
[
(SERVICE_START, PROCESS_ACTION, 1),
(SERVICE_STOP, PROCESS_ACTION, 2),
(SERVICE_PAUSE, PROCESS_ACTION, 3),
(SERVICE_CLEAN_SPOT, PROGRAM_ID, 2),
],
)
async def test_vacuum_program(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
service: str,
vacuum_power: int | str,
action_command: str,
) -> None:
"""Test the vacuum can be controlled."""
await hass.services.async_call(
TEST_PLATFORM, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True
)
mock_miele_client.send_action.assert_called_once_with(
"Dummy_Vacuum_1", {action_command: vacuum_power}
)
@pytest.mark.parametrize(
("fan_speed", "expected"), [("normal", 1), ("turbo", 3), ("silent", 4)]
)
async def test_vacuum_fan_speed(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
fan_speed: str,
expected: int,
) -> None:
"""Test the vacuum can be controlled."""
await hass.services.async_call(
TEST_PLATFORM,
SERVICE_SET_FAN_SPEED,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_SPEED: fan_speed},
blocking=True,
)
mock_miele_client.send_action.assert_called_once_with(
"Dummy_Vacuum_1", {"programId": expected}
)
@pytest.mark.parametrize(
("service"),
[
(SERVICE_START),
(SERVICE_STOP),
],
)
async def test_api_failure(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
service: str,
) -> 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, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True
)
mock_miele_client.send_action.assert_called_once()