Add switch platform to miele integration (#142925)

* Add switch platform

* Add a type hint

* Update after review
This commit is contained in:
Åke Strandberg 2025-04-25 18:40:52 +02:00 committed by GitHub
parent 735e2e4192
commit ed0bdf9e5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 536 additions and 0 deletions

View File

@ -20,6 +20,7 @@ from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
]

View File

@ -0,0 +1,15 @@
{
"entity": {
"switch": {
"power": {
"default": "mdi:power"
},
"supercooling": {
"default": "mdi:snowflake-variant"
},
"superfreezing": {
"default": "mdi:snowflake"
}
}
}
}

View File

@ -146,6 +146,17 @@
"waiting_to_start": "Waiting to start"
}
}
},
"switch": {
"power": {
"name": "Power"
},
"supercooling": {
"name": "Supercooling"
},
"superfreezing": {
"name": "Superfreezing"
}
}
},
"exceptions": {

View File

@ -0,0 +1,225 @@
"""Switch platform for Miele switch integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any, Final, cast
import aiohttp
from pymiele import MieleDevice
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
DOMAIN,
POWER_OFF,
POWER_ON,
PROCESS_ACTION,
MieleActions,
MieleAppliance,
StateStatus,
)
from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator
from .entity import MieleEntity
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class MieleSwitchDescription(SwitchEntityDescription):
"""Class describing Miele switch entities."""
value_fn: Callable[[MieleDevice], StateType]
on_value: int = 0
off_value: int = 0
on_cmd_data: dict[str, str | int | bool]
off_cmd_data: dict[str, str | int | bool]
@dataclass
class MieleSwitchDefinition:
"""Class for defining switch entities."""
types: tuple[MieleAppliance, ...]
description: MieleSwitchDescription
SWITCH_TYPES: Final[tuple[MieleSwitchDefinition, ...]] = (
MieleSwitchDefinition(
types=(MieleAppliance.FRIDGE, MieleAppliance.FRIDGE_FREEZER),
description=MieleSwitchDescription(
key="supercooling",
value_fn=lambda value: value.state_status,
on_value=StateStatus.SUPERCOOLING,
translation_key="supercooling",
on_cmd_data={PROCESS_ACTION: MieleActions.START_SUPERCOOL},
off_cmd_data={PROCESS_ACTION: MieleActions.STOP_SUPERCOOL},
),
),
MieleSwitchDefinition(
types=(
MieleAppliance.FREEZER,
MieleAppliance.FRIDGE_FREEZER,
MieleAppliance.WINE_CABINET_FREEZER,
),
description=MieleSwitchDescription(
key="superfreezing",
value_fn=lambda value: value.state_status,
on_value=StateStatus.SUPERFREEZING,
translation_key="superfreezing",
on_cmd_data={PROCESS_ACTION: MieleActions.START_SUPERFREEZE},
off_cmd_data={PROCESS_ACTION: MieleActions.STOP_SUPERFREEZE},
),
),
MieleSwitchDefinition(
types=(
MieleAppliance.WASHING_MACHINE,
MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL,
MieleAppliance.TUMBLE_DRYER,
MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL,
MieleAppliance.DISHWASHER,
MieleAppliance.DISH_WARMER,
MieleAppliance.OVEN,
MieleAppliance.OVEN_MICROWAVE,
MieleAppliance.STEAM_OVEN,
MieleAppliance.MICROWAVE,
MieleAppliance.COFFEE_SYSTEM,
MieleAppliance.HOOD,
MieleAppliance.WASHER_DRYER,
MieleAppliance.STEAM_OVEN_COMBI,
MieleAppliance.STEAM_OVEN_MICRO,
MieleAppliance.DIALOG_OVEN,
MieleAppliance.STEAM_OVEN_MK2,
),
description=MieleSwitchDescription(
key="poweronoff",
value_fn=lambda value: value.state_status,
off_value=1,
translation_key="power",
on_cmd_data={POWER_ON: True},
off_cmd_data={POWER_OFF: True},
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MieleConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the switch platform."""
coordinator = config_entry.runtime_data
entities: list = []
entity_class: type[MieleSwitch]
for device_id, device in coordinator.data.devices.items():
for definition in SWITCH_TYPES:
if device.device_type in definition.types:
match definition.description.key:
case "poweronoff":
entity_class = MielePowerSwitch
case "supercooling" | "superfreezing":
entity_class = MieleSuperSwitch
entities.append(
entity_class(coordinator, device_id, definition.description)
)
async_add_entities(entities)
class MieleSwitch(MieleEntity, SwitchEntity):
"""Representation of a Switch."""
entity_description: MieleSwitchDescription
def __init__(
self,
coordinator: MieleDataUpdateCoordinator,
device_id: str,
description: MieleSwitchDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator, device_id, description)
self.api = coordinator.api
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the device."""
await self.async_turn_switch(self.entity_description.on_cmd_data)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the device."""
await self.async_turn_switch(self.entity_description.off_cmd_data)
async def async_turn_switch(self, mode: dict[str, str | int | bool]) -> None:
"""Set switch to mode."""
try:
await self.api.send_action(self._device_id, mode)
except aiohttp.ClientError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_state_error",
translation_placeholders={
"entity": self.entity_id,
},
) from err
class MielePowerSwitch(MieleSwitch):
"""Representation of a power switch."""
entity_description: MieleSwitchDescription
@property
def is_on(self) -> bool | None:
"""Return the state of the switch."""
return self.coordinator.data.actions[self._device_id].power_off_enabled
@property
def available(self) -> bool:
"""Return the availability of the entity."""
return (
self.coordinator.data.actions[self._device_id].power_off_enabled
or self.coordinator.data.actions[self._device_id].power_on_enabled
) and super().available
async def async_turn_switch(self, mode: dict[str, str | int | bool]) -> None:
"""Set switch to mode."""
try:
await self.api.send_action(self._device_id, mode)
except aiohttp.ClientError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_state_error",
translation_placeholders={
"entity": self.entity_id,
},
) from err
self.coordinator.data.actions[self._device_id].power_on_enabled = cast(
bool, mode
)
self.coordinator.data.actions[self._device_id].power_off_enabled = not cast(
bool, mode
)
self.async_write_ha_state()
class MieleSuperSwitch(MieleSwitch):
"""Representation of a supercool/superfreeze switch."""
entity_description: MieleSwitchDescription
@property
def is_on(self) -> bool | None:
"""Return the state of the switch."""
return (
self.entity_description.value_fn(self.device)
== self.entity_description.on_value
)

View File

@ -0,0 +1,189 @@
# serializer version: 1
# name: test_switch_states[platforms0][switch.freezer_superfreezing-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.freezer_superfreezing',
'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': 'Superfreezing',
'platform': 'miele',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'superfreezing',
'unique_id': 'Dummy_Appliance_1-superfreezing',
'unit_of_measurement': None,
})
# ---
# name: test_switch_states[platforms0][switch.freezer_superfreezing-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Freezer Superfreezing',
}),
'context': <ANY>,
'entity_id': 'switch.freezer_superfreezing',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_switch_states[platforms0][switch.hood_power-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.hood_power',
'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': 'Power',
'platform': 'miele',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'power',
'unique_id': 'DummyAppliance_18-poweronoff',
'unit_of_measurement': None,
})
# ---
# name: test_switch_states[platforms0][switch.hood_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Hood Power',
}),
'context': <ANY>,
'entity_id': 'switch.hood_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_switch_states[platforms0][switch.refrigerator_supercooling-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.refrigerator_supercooling',
'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': 'Supercooling',
'platform': 'miele',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'supercooling',
'unique_id': 'Dummy_Appliance_2-supercooling',
'unit_of_measurement': None,
})
# ---
# name: test_switch_states[platforms0][switch.refrigerator_supercooling-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Refrigerator Supercooling',
}),
'context': <ANY>,
'entity_id': 'switch.refrigerator_supercooling',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_switch_states[platforms0][switch.washing_machine_power-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.washing_machine_power',
'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': 'Power',
'platform': 'miele',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'power',
'unique_id': 'Dummy_Appliance_3-poweronoff',
'unit_of_measurement': None,
})
# ---
# name: test_switch_states[platforms0][switch.washing_machine_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Washing machine Power',
}),
'context': <ANY>,
'entity_id': 'switch.washing_machine_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@ -0,0 +1,95 @@
"""Tests for miele switch module."""
from unittest.mock import MagicMock
from aiohttp import ClientError
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON
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 = SWITCH_DOMAIN
pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)])
ENTITY_ID = "switch.freezer_superfreezing"
async def test_switch_states(
hass: HomeAssistant,
mock_miele_client: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: None,
) -> None:
"""Test switch entity state."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("entity"),
[
(ENTITY_ID),
("switch.refrigerator_supercooling"),
("switch.washing_machine_power"),
],
)
@pytest.mark.parametrize(
("service"),
[
(SERVICE_TURN_ON),
(SERVICE_TURN_OFF),
],
)
async def test_switching(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
service: str,
entity: str,
) -> None:
"""Test the switch can be turned on/off."""
await hass.services.async_call(
TEST_PLATFORM, service, {ATTR_ENTITY_ID: entity}, blocking=True
)
mock_miele_client.send_action.assert_called_once()
@pytest.mark.parametrize(
("entity"),
[
(ENTITY_ID),
("switch.refrigerator_supercooling"),
("switch.washing_machine_power"),
],
)
@pytest.mark.parametrize(
("service"),
[
(SERVICE_TURN_ON),
(SERVICE_TURN_OFF),
],
)
async def test_api_failure(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
service: str,
entity: str,
) -> None:
"""Test handling of exception from API."""
mock_miele_client.send_action.side_effect = ClientError
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
TEST_PLATFORM, service, {ATTR_ENTITY_ID: entity}, blocking=True
)
mock_miele_client.send_action.assert_called_once()