Add climate platform to miele integration (#143333)

* Add climate platform

* Merge

* Address review and improve test

* Address review comments

* Streamline entity naming

* Update tests/components/miele/test_climate.py

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Åke Strandberg 2025-04-25 19:18:39 +02:00 committed by GitHub
parent 5302964eb6
commit a783b6a0ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 473 additions and 6 deletions

View File

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

View File

@ -0,0 +1,236 @@
"""Platform for Miele 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.climate import (
ClimateEntity,
ClimateEntityDescription,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
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 DEVICE_TYPE_TAGS, DISABLED_TEMP_ENTITIES, DOMAIN, MieleAppliance
from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator
from .entity import MieleEntity
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class MieleClimateDescription(ClimateEntityDescription):
"""Class describing Miele climate entities."""
value_fn: Callable[[MieleDevice], StateType]
target_fn: Callable[[MieleDevice], StateType]
zone: int = 1
@dataclass
class MieleClimateDefinition:
"""Class for defining climate entities."""
types: tuple[MieleAppliance, ...]
description: MieleClimateDescription
CLIMATE_TYPES: Final[tuple[MieleClimateDefinition, ...]] = (
MieleClimateDefinition(
types=(
MieleAppliance.FRIDGE,
MieleAppliance.FREEZER,
MieleAppliance.FRIDGE_FREEZER,
MieleAppliance.WINE_CABINET,
MieleAppliance.WINE_CONDITIONING_UNIT,
MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT,
MieleAppliance.WINE_CABINET_FREEZER,
),
description=MieleClimateDescription(
key="thermostat",
value_fn=(
lambda value: cast(int, value.state_temperatures[0].temperature) / 100.0
),
target_fn=(
lambda value: cast(int, value.state_target_temperature[0].temperature)
/ 100.0
),
zone=1,
),
),
MieleClimateDefinition(
types=(
MieleAppliance.FRIDGE,
MieleAppliance.FREEZER,
MieleAppliance.FRIDGE_FREEZER,
MieleAppliance.WINE_CABINET,
MieleAppliance.WINE_CONDITIONING_UNIT,
MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT,
MieleAppliance.WINE_CABINET_FREEZER,
),
description=MieleClimateDescription(
key="thermostat2",
value_fn=(
lambda value: cast(int, value.state_temperatures[1].temperature) / 100.0
),
target_fn=(
lambda value: cast(int, value.state_target_temperature[1].temperature)
/ 100.0
),
translation_key="zone_2",
zone=2,
),
),
MieleClimateDefinition(
types=(
MieleAppliance.FRIDGE,
MieleAppliance.FREEZER,
MieleAppliance.FRIDGE_FREEZER,
MieleAppliance.WINE_CABINET,
MieleAppliance.WINE_CONDITIONING_UNIT,
MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT,
MieleAppliance.WINE_CABINET_FREEZER,
),
description=MieleClimateDescription(
key="thermostat3",
value_fn=(
lambda value: cast(int, value.state_temperatures[2].temperature) / 100.0
),
target_fn=(
lambda value: cast(int, value.state_target_temperature[2].temperature)
/ 100.0
),
translation_key="zone_3",
zone=3,
),
),
)
ZONE1_DEVICES = {
MieleAppliance.FRIDGE: DEVICE_TYPE_TAGS[MieleAppliance.FRIDGE],
MieleAppliance.FRIDGE_FREEZER: DEVICE_TYPE_TAGS[MieleAppliance.FRIDGE],
MieleAppliance.FREEZER: DEVICE_TYPE_TAGS[MieleAppliance.FREEZER],
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MieleConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the climate platform."""
coordinator = config_entry.runtime_data
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)
)
)
class MieleClimate(MieleEntity, ClimateEntity):
"""Representation of a climate entity."""
entity_description: MieleClimateDescription
_attr_precision = PRECISION_WHOLE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_target_temperature_step = 1.0
_attr_hvac_modes = [HVACMode.COOL]
_attr_hvac_mode = HVACMode.COOL
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return cast(float, self.entity_description.value_fn(self.device))
def __init__(
self,
coordinator: MieleDataUpdateCoordinator,
device_id: str,
description: MieleClimateDescription,
) -> None:
"""Initialize the climate entity."""
super().__init__(coordinator, device_id, description)
self.api = coordinator.api
t_key = self.entity_description.translation_key
if description.zone == 1:
t_key = ZONE1_DEVICES.get(
cast(MieleAppliance, self.device.device_type), "zone_1"
)
if description.zone == 2:
if self.device.device_type in (
MieleAppliance.FRIDGE_FREEZER,
MieleAppliance.WINE_CABINET_FREEZER,
):
t_key = DEVICE_TYPE_TAGS[MieleAppliance.FREEZER]
else:
t_key = "zone_2"
elif description.zone == 3:
t_key = "zone_3"
self._attr_translation_key = t_key
self._attr_unique_id = f"{device_id}-{description.key}-{description.zone}"
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
if self.entity_description.target_fn(self.device) is None:
return None
return cast(float | None, self.entity_description.target_fn(self.device))
@property
def max_temp(self) -> float:
"""Return the maximum target temperature."""
return cast(
float,
self.coordinator.data.actions[self._device_id]
.target_temperature[self.entity_description.zone - 1]
.max,
)
@property
def min_temp(self) -> float:
"""Return the minimum target temperature."""
return cast(
float,
self.coordinator.data.actions[self._device_id]
.target_temperature[self.entity_description.zone - 1]
.min,
)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
try:
await self.api.set_target_temperature(
self._device_id, temperature, self.entity_description.zone
)
except aiohttp.ClientError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_state_error",
translation_placeholders={
"entity": self.entity_id,
},
) from err
await self.coordinator.async_request_refresh()

View File

@ -9,6 +9,10 @@ ACTIONS = "actions"
POWER_ON = "powerOn"
POWER_OFF = "powerOff"
PROCESS_ACTION = "processAction"
DISABLED_TEMP_ENTITIES = (
-32768 / 100,
-32766 / 100,
)
AMBIENT_LIGHT = "ambientLight"
LIGHT = "light"
LIGHT_ON = 1

View File

@ -133,6 +133,26 @@
"name": "[%key:component::light::title%]"
}
},
"climate": {
"freezer": {
"name": "[%key:component::miele::device::freezer::name%]"
},
"refrigerator": {
"name": "[%key:component::miele::device::refrigerator::name%]"
},
"wine_cabinet": {
"name": "[%key:component::miele::device::wine_cabinet::name%]"
},
"zone_1": {
"name": "Zone 1"
},
"zone_2": {
"name": "Zone 2"
},
"zone_3": {
"name": "Zone 3"
}
},
"sensor": {
"status": {
"name": "Status",

View File

@ -1,5 +1,5 @@
{
"processAction": [6],
"processAction": [4],
"light": [],
"ambientLight": [],
"startTime": [],
@ -8,8 +8,8 @@
"targetTemperature": [
{
"zone": 1,
"min": 1,
"max": 9
"min": -28,
"max": -14
}
],
"deviceName": true,

View File

@ -1,5 +1,5 @@
{
"processAction": [4],
"processAction": [6],
"light": [],
"ambientLight": [],
"startTime": [],
@ -8,8 +8,8 @@
"targetTemperature": [
{
"zone": 1,
"min": -28,
"max": -14
"min": 1,
"max": 9
}
],
"deviceName": true,

View File

@ -0,0 +1,127 @@
# serializer version: 1
# name: test_climate_states[platforms0-freezer][climate.freezer_freezer-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.COOL: 'cool'>,
]),
'max_temp': -14,
'min_temp': -28,
'target_temp_step': 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.freezer_freezer',
'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': 'Freezer',
'platform': 'miele',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 1>,
'translation_key': 'freezer',
'unique_id': 'Dummy_Appliance_1-thermostat-1',
'unit_of_measurement': None,
})
# ---
# name: test_climate_states[platforms0-freezer][climate.freezer_freezer-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': -18,
'friendly_name': 'Freezer Freezer',
'hvac_modes': list([
<HVACMode.COOL: 'cool'>,
]),
'max_temp': -14,
'min_temp': -28,
'supported_features': <ClimateEntityFeature: 1>,
'target_temp_step': 1.0,
'temperature': -18,
}),
'context': <ANY>,
'entity_id': 'climate.freezer_freezer',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'cool',
})
# ---
# name: test_climate_states[platforms0-freezer][climate.refrigerator_refrigerator-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.COOL: 'cool'>,
]),
'max_temp': -14,
'min_temp': -28,
'target_temp_step': 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.refrigerator_refrigerator',
'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': 'Refrigerator',
'platform': 'miele',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 1>,
'translation_key': 'refrigerator',
'unique_id': 'Dummy_Appliance_2-thermostat-1',
'unit_of_measurement': None,
})
# ---
# name: test_climate_states[platforms0-freezer][climate.refrigerator_refrigerator-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 4,
'friendly_name': 'Refrigerator Refrigerator',
'hvac_modes': list([
<HVACMode.COOL: 'cool'>,
]),
'max_temp': -14,
'min_temp': -28,
'supported_features': <ClimateEntityFeature: 1>,
'target_temp_step': 1.0,
'temperature': 4,
}),
'context': <ANY>,
'entity_id': 'climate.refrigerator_refrigerator',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'cool',
})
# ---

View File

@ -0,0 +1,79 @@
"""Tests for miele climate module."""
from unittest.mock import MagicMock
from aiohttp import ClientError
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE
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 = CLIMATE_DOMAIN
pytestmark = [
pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]),
pytest.mark.parametrize(
"load_action_file",
["action_freezer.json"],
ids=[
"freezer",
],
),
]
ENTITY_ID = "climate.freezer_freezer"
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,
) -> None:
"""Test climate entity state."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_set_target(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
) -> None:
"""Test the climate can be turned on/off."""
await hass.services.async_call(
TEST_PLATFORM,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: -17},
blocking=True,
)
mock_miele_client.set_target_temperature.assert_called_once_with(
"Dummy_Appliance_1", -17.0, 1
)
async def test_api_failure(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: None,
) -> None:
"""Test handling of exception from API."""
mock_miele_client.set_target_temperature.side_effect = ClientError
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
TEST_PLATFORM,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: -17},
blocking=True,
)
mock_miele_client.set_target_temperature.assert_called_once()