Add miele devices dynamically (#144216)

* Use device class transation

* WIP Cleanup tests

* First 3

* First 3

* Button

* Climate

* Light

* Switch

* New and cleaner variant

* Update homeassistant/components/miele/entity.py

---------

Co-authored-by: Josef Zweck <josef@zweck.dev>
This commit is contained in:
Åke Strandberg 2025-05-08 21:20:02 +02:00 committed by GitHub
parent 34dbd1fb10
commit 337c64d69d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 389 additions and 109 deletions

View File

@ -263,13 +263,23 @@ async def async_setup_entry(
) -> None:
"""Set up the binary sensor platform."""
coordinator = config_entry.runtime_data
added_devices: set[str] = set()
async_add_entities(
MieleBinarySensor(coordinator, device_id, definition.description)
for device_id, device in coordinator.data.devices.items()
for definition in BINARY_SENSOR_TYPES
if device.device_type in definition.types
)
def _async_add_new_devices() -> None:
nonlocal added_devices
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
added_devices = current_devices
async_add_entities(
MieleBinarySensor(coordinator, device_id, definition.description)
for device_id, device in coordinator.data.devices.items()
for definition in BINARY_SENSOR_TYPES
if device_id in new_devices_set and device.device_type in definition.types
)
config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
_async_add_new_devices()
class MieleBinarySensor(MieleEntity, BinarySensorEntity):

View File

@ -111,13 +111,22 @@ async def async_setup_entry(
) -> None:
"""Set up the button platform."""
coordinator = config_entry.runtime_data
added_devices: set[str] = set()
async_add_entities(
MieleButton(coordinator, device_id, definition.description)
for device_id, device in coordinator.data.devices.items()
for definition in BUTTON_TYPES
if device.device_type in definition.types
)
def _async_add_new_devices() -> None:
nonlocal added_devices
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
added_devices = current_devices
async_add_entities(
MieleButton(coordinator, device_id, definition.description)
for device_id, device in coordinator.data.devices.items()
for definition in BUTTON_TYPES
if device_id in new_devices_set and device.device_type in definition.types
)
config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
_async_add_new_devices()
class MieleButton(MieleEntity, ButtonEntity):

View File

@ -131,16 +131,30 @@ async def async_setup_entry(
) -> None:
"""Set up the climate platform."""
coordinator = config_entry.runtime_data
added_devices: set[str] = set()
async_add_entities(
MieleClimate(coordinator, device_id, definition.description)
for device_id, device in coordinator.data.devices.items()
for definition in CLIMATE_TYPES
if (
device.device_type in definition.types
and (definition.description.value_fn(device) not in DISABLED_TEMP_ENTITIES)
def _async_add_new_devices() -> None:
nonlocal added_devices
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
added_devices = current_devices
async_add_entities(
MieleClimate(coordinator, device_id, definition.description)
for device_id, device in coordinator.data.devices.items()
for definition in CLIMATE_TYPES
if (
device_id in new_devices_set
and device.device_type in definition.types
and (
definition.description.value_fn(device)
not in DISABLED_TEMP_ENTITIES
)
)
)
)
config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
_async_add_new_devices()
class MieleClimate(MieleEntity, ClimateEntity):

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio.timeouts
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
@ -33,6 +34,11 @@ class MieleCoordinatorData:
class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]):
"""Coordinator for Miele data."""
config_entry: MieleConfigEntry
new_device_callbacks: list[Callable[[dict[str, MieleDevice]], None]] = []
known_devices: set[str] = set()
devices: dict[str, MieleDevice] = {}
def __init__(
self,
hass: HomeAssistant,
@ -56,12 +62,20 @@ class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]):
device_id: MieleDevice(device)
for device_id, device in devices_json.items()
}
self.devices = devices
actions = {}
for device_id in devices:
actions_json = await self.api.get_actions(device_id)
actions[device_id] = MieleAction(actions_json)
return MieleCoordinatorData(devices=devices, actions=actions)
def async_add_devices(self, added_devices: set[str]) -> tuple[set[str], set[str]]:
"""Add devices."""
current_devices = set(self.devices)
new_devices: set[str] = current_devices - added_devices
return (new_devices, current_devices)
async def callback_update_data(self, devices_json: dict[str, dict]) -> None:
"""Handle data update from the API."""
devices = {

View File

@ -65,13 +65,22 @@ async def async_setup_entry(
) -> None:
"""Set up the fan platform."""
coordinator = config_entry.runtime_data
added_devices: set[str] = set()
async_add_entities(
MieleFan(coordinator, device_id, definition.description)
for device_id, device in coordinator.data.devices.items()
for definition in FAN_TYPES
if device.device_type in definition.types
)
def _async_add_new_devices() -> None:
nonlocal added_devices
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
added_devices = current_devices
async_add_entities(
MieleFan(coordinator, device_id, definition.description)
for device_id, device in coordinator.data.devices.items()
for definition in FAN_TYPES
if device_id in new_devices_set and device.device_type in definition.types
)
config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
_async_add_new_devices()
class MieleFan(MieleEntity, FanEntity):

View File

@ -85,13 +85,22 @@ async def async_setup_entry(
) -> None:
"""Set up the light platform."""
coordinator = config_entry.runtime_data
added_devices: set[str] = set()
async_add_entities(
MieleLight(coordinator, device_id, definition.description)
for device_id, device in coordinator.data.devices.items()
for definition in LIGHT_TYPES
if device.device_type in definition.types
)
def _async_add_new_devices() -> None:
nonlocal added_devices
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
added_devices = current_devices
async_add_entities(
MieleLight(coordinator, device_id, definition.description)
for device_id, device in coordinator.data.devices.items()
for definition in LIGHT_TYPES
if device_id in new_devices_set and device.device_type in definition.types
)
config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
_async_add_new_devices()
class MieleLight(MieleEntity, LightEntity):

View File

@ -426,33 +426,43 @@ async def async_setup_entry(
) -> None:
"""Set up the sensor platform."""
coordinator = config_entry.runtime_data
added_devices: set[str] = set()
entities: list = []
entity_class: type[MieleSensor]
for device_id, device in coordinator.data.devices.items():
for definition in SENSOR_TYPES:
if device.device_type in definition.types:
match definition.description.key:
case "state_status":
entity_class = MieleStatusSensor
case "state_program_id":
entity_class = MieleProgramIdSensor
case "state_program_phase":
entity_class = MielePhaseSensor
case _:
entity_class = MieleSensor
if (
definition.description.device_class == SensorDeviceClass.TEMPERATURE
and definition.description.value_fn(device)
== DISABLED_TEMPERATURE / 100
):
# Don't create entity if API signals that datapoint is disabled
continue
entities.append(
entity_class(coordinator, device_id, definition.description)
)
def _async_add_new_devices() -> None:
nonlocal added_devices
entities: list = []
entity_class: type[MieleSensor]
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
added_devices = current_devices
async_add_entities(entities)
for device_id, device in coordinator.data.devices.items():
for definition in SENSOR_TYPES:
if device.device_type in definition.types:
match definition.description.key:
case "state_status":
entity_class = MieleStatusSensor
case "state_program_id":
entity_class = MieleProgramIdSensor
case "state_program_phase":
entity_class = MielePhaseSensor
case _:
entity_class = MieleSensor
if (
device_id in new_devices_set
and definition.description.device_class
== SensorDeviceClass.TEMPERATURE
and definition.description.value_fn(device)
== DISABLED_TEMPERATURE / 100
):
# Don't create entity if API signals that datapoint is disabled
continue
entities.append(
entity_class(coordinator, device_id, definition.description)
)
async_add_entities(entities)
config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
_async_add_new_devices()
APPLIANCE_ICONS = {

View File

@ -116,22 +116,34 @@ async def async_setup_entry(
) -> None:
"""Set up the switch platform."""
coordinator = config_entry.runtime_data
added_devices: set[str] = set()
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
def _async_add_new_devices() -> None:
nonlocal added_devices
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
added_devices = current_devices
entities.append(
entity_class(coordinator, device_id, definition.description)
)
async_add_entities(entities)
entities = []
for device_id, device in coordinator.data.devices.items():
for definition in SWITCH_TYPES:
if (
device_id in new_devices_set
and device.device_type in definition.types
):
entity_class: type[MieleSwitch] = MieleSwitch
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)
config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
_async_add_new_devices()
class MieleSwitch(MieleEntity, SwitchEntity):

View File

@ -141,7 +141,7 @@ async def setup_platform(
with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
yield
yield mock_config_entry
@pytest.fixture

View File

@ -356,6 +356,106 @@
"batteryLevel": null
}
},
"DummyAppliance_18": {
"ident": {
"type": {
"key_localized": "Device type",
"value_raw": 18,
"value_localized": "Cooker Hood"
},
"deviceName": "",
"protocolVersion": 2,
"deviceIdentLabel": {
"fabNumber": "<fabNumber3>",
"fabIndex": "64",
"techType": "Fläkt",
"matNumber": "<matNumber3>",
"swids": ["<swid1>", "<swid2>", "<swid3>", "<...>"]
},
"xkmIdentLabel": {
"techType": "EK039W",
"releaseVersion": "02.72"
}
},
"state": {
"ProgramID": {
"value_raw": 1,
"value_localized": "Off",
"key_localized": "Program name"
},
"status": {
"value_raw": 1,
"value_localized": "Off",
"key_localized": "status"
},
"programType": {
"value_raw": 0,
"value_localized": "Program",
"key_localized": "Program type"
},
"programPhase": {
"value_raw": 4608,
"value_localized": "",
"key_localized": "Program phase"
},
"remainingTime": [0, 0],
"startTime": [0, 0],
"targetTemperature": [
{
"value_raw": -32768,
"value_localized": null,
"unit": "Celsius"
}
],
"temperature": [
{
"value_raw": -32768,
"value_localized": null,
"unit": "Celsius"
},
{
"value_raw": -32768,
"value_localized": null,
"unit": "Celsius"
},
{
"value_raw": -32768,
"value_localized": null,
"unit": "Celsius"
}
],
"signalInfo": false,
"signalFailure": false,
"signalDoor": false,
"remoteEnable": {
"fullRemoteControl": true,
"smartGrid": false,
"mobileStart": false
},
"ambientLight": 2,
"light": 1,
"elapsedTime": {},
"spinningSpeed": {
"unit": "rpm",
"value_raw": null,
"value_localized": null,
"key_localized": "Spin speed"
},
"dryingStep": {
"value_raw": null,
"value_localized": "",
"key_localized": "Drying level"
},
"ventilationStep": {
"value_raw": 0,
"value_localized": "0",
"key_localized": "Fan level"
},
"plateStep": [],
"ecoFeedback": null,
"batteryLevel": null
}
},
"Dummy_Appliance_4": {
"ident": {
"type": {
@ -402,10 +502,28 @@
{ "value_raw": -32768, "value_localized": null, "unit": "Celsius" },
{ "value_raw": -32768, "value_localized": null, "unit": "Celsius" }
],
"coreTargetTemperature": [
{ "value_raw": 7500, "value_localized": "75.0", "unit": "Celsius" }
],
"coreTemperature": [
{ "value_raw": 5200, "value_localized": "52.0", "unit": "Celsius" }
],
"temperature": [
{ "value_raw": -32768, "value_localized": null, "unit": "Celsius" },
{ "value_raw": -32768, "value_localized": null, "unit": "Celsius" },
{ "value_raw": -32768, "value_localized": null, "unit": "Celsius" }
{
"value_raw": 17500,
"value_localized": "175.0",
"unit": "Celsius"
},
{
"value_raw": -32768,
"value_localized": null,
"unit": "Celsius"
},
{
"value_raw": -32768,
"value_localized": null,
"unit": "Celsius"
}
],
"signalInfo": false,
"signalFailure": false,

View File

@ -5,7 +5,13 @@
"startTime": [],
"ventilationStep": [],
"programId": [],
"targetTemperature": [],
"targetTemperature": [
{
"zone": 1,
"min": -28,
"max": -14
}
],
"deviceName": true,
"powerOn": true,
"powerOff": false,

View File

@ -36,6 +36,11 @@
'startTime': list([
]),
'targetTemperature': list([
dict({
'max': -14,
'min': -28,
'zone': 1,
}),
]),
'ventilationStep': list([
]),
@ -64,6 +69,11 @@
'startTime': list([
]),
'targetTemperature': list([
dict({
'max': -14,
'min': -28,
'zone': 1,
}),
]),
'ventilationStep': list([
]),
@ -92,6 +102,11 @@
'startTime': list([
]),
'targetTemperature': list([
dict({
'max': -14,
'min': -28,
'zone': 1,
}),
]),
'ventilationStep': list([
]),
@ -120,6 +135,11 @@
'startTime': list([
]),
'targetTemperature': list([
dict({
'max': -14,
'min': -28,
'zone': 1,
}),
]),
'ventilationStep': list([
]),
@ -689,6 +709,11 @@
'startTime': list([
]),
'targetTemperature': list([
dict({
'max': -14,
'min': -28,
'zone': 1,
}),
]),
'ventilationStep': list([
]),

View File

@ -17,11 +17,10 @@ from tests.common import MockConfigEntry, snapshot_platform
async def test_binary_sensor_states(
hass: HomeAssistant,
mock_miele_client: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: None,
setup_platform: MockConfigEntry,
) -> None:
"""Test binary sensor state."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)

View File

@ -24,21 +24,20 @@ ENTITY_ID = "button.washing_machine_start"
async def test_button_states(
hass: HomeAssistant,
mock_miele_client: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: None,
setup_platform: MockConfigEntry,
) -> None:
"""Test button entity state."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_button_press(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
setup_platform: MockConfigEntry,
) -> None:
"""Test button press."""
@ -54,7 +53,7 @@ async def test_button_press(
async def test_api_failure(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
setup_platform: MockConfigEntry,
) -> None:
"""Test handling of exception from API."""
mock_miele_client.send_action.side_effect = ClientResponseError("test", "Test")

View File

@ -33,20 +33,19 @@ SERVICE_SET_TEMPERATURE = "set_temperature"
async def test_climate_states(
hass: HomeAssistant,
mock_miele_client: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: None,
setup_platform: MockConfigEntry,
) -> None:
"""Test climate entity state."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)
async def test_set_target(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
setup_platform: MockConfigEntry,
) -> None:
"""Test the climate can be turned on/off."""
@ -64,7 +63,7 @@ async def test_set_target(
async def test_api_failure(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
setup_platform: MockConfigEntry,
) -> None:
"""Test handling of exception from API."""
mock_miele_client.set_target_temperature.side_effect = ClientError

View File

@ -25,14 +25,13 @@ ENTITY_ID = "fan.hood_fan"
async def test_fan_states(
hass: HomeAssistant,
mock_miele_client: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: None,
setup_platform: MockConfigEntry,
) -> None:
"""Test fan entity state."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)
@pytest.mark.parametrize("load_device_file", ["fan_devices.json"])
@ -46,7 +45,7 @@ async def test_fan_states(
async def test_fan_control(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
setup_platform: MockConfigEntry,
service: str,
expected_argument: dict[str, Any],
) -> None:
@ -74,7 +73,7 @@ async def test_fan_control(
async def test_fan_set_speed(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
setup_platform: MockConfigEntry,
service: str,
percentage: int,
expected_argument: dict[str, Any],
@ -102,7 +101,7 @@ async def test_fan_set_speed(
async def test_api_failure(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
setup_platform: MockConfigEntry,
service: str,
) -> None:
"""Test handling of exception from API."""

View File

@ -1,10 +1,12 @@
"""Tests for init module."""
from datetime import timedelta
import http
import time
from unittest.mock import MagicMock
from aiohttp import ClientConnectionError
from freezegun.api import FrozenDateTimeFactory
from pymiele import OAUTH2_TOKEN
import pytest
from syrupy import SnapshotAssertion
@ -17,7 +19,11 @@ from homeassistant.setup import async_setup_component
from . import setup_integration
from tests.common import MockConfigEntry
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
load_json_object_fixture,
)
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import WebSocketGenerator
@ -157,3 +163,48 @@ async def test_device_remove_devices(
old_device_entry.id, mock_config_entry.entry_id
)
assert response["success"]
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_setup_all_platforms(
hass: HomeAssistant,
mock_miele_client: MagicMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
load_device_file: str,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that all platforms can be set up."""
await setup_integration(hass, mock_config_entry)
assert hass.states.get("binary_sensor.freezer_door").state == "off"
assert hass.states.get("binary_sensor.hood_problem").state == "off"
assert (
hass.states.get("button.washing_machine_start").object_id
== "washing_machine_start"
)
assert hass.states.get("climate.freezer").state == "cool"
assert hass.states.get("light.hood_light").state == "on"
assert hass.states.get("sensor.freezer_temperature").state == "-18.0"
assert hass.states.get("sensor.washing_machine").state == "off"
assert hass.states.get("switch.washing_machine_power").state == "off"
# Add two devices and let the clock tick for 130 seconds
freezer.tick(timedelta(seconds=130))
mock_miele_client.get_devices.return_value = load_json_object_fixture(
"5_devices.json", DOMAIN
)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(device_registry.devices) == 6
# Check a sample sensor for each new device
assert hass.states.get("sensor.dishwasher").state == "in_use"
assert hass.states.get("sensor.oven_temperature").state == "175.0"

View File

@ -23,14 +23,13 @@ ENTITY_ID = "light.hood_light"
async def test_light_states(
hass: HomeAssistant,
mock_miele_client: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: None,
setup_platform: MockConfigEntry,
) -> None:
"""Test light entity state."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)
@pytest.mark.parametrize(
@ -43,7 +42,7 @@ async def test_light_states(
async def test_light_toggle(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
setup_platform: MockConfigEntry,
service: str,
light_state: int,
) -> None:
@ -67,7 +66,7 @@ async def test_light_toggle(
async def test_api_failure(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
setup_platform: MockConfigEntry,
service: str,
) -> None:
"""Test handling of exception from API."""

View File

@ -17,11 +17,10 @@ from tests.common import MockConfigEntry, snapshot_platform
async def test_sensor_states(
hass: HomeAssistant,
mock_miele_client: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: None,
setup_platform: MockConfigEntry,
) -> None:
"""Test sensor state."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)

View File

@ -23,14 +23,13 @@ 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,
setup_platform: MockConfigEntry,
) -> None:
"""Test switch entity state."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)
@pytest.mark.parametrize(
@ -51,7 +50,7 @@ async def test_switch_states(
async def test_switching(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
setup_platform: MockConfigEntry,
service: str,
entity: str,
) -> None:
@ -81,7 +80,7 @@ async def test_switching(
async def test_api_failure(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
setup_platform: MockConfigEntry,
service: str,
entity: str,
) -> None: