diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py index 0f538816657..97dd1cde457 100644 --- a/homeassistant/components/miele/__init__.py +++ b/homeassistant/components/miele/__init__.py @@ -19,6 +19,7 @@ from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BUTTON, + Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/miele/climate.py b/homeassistant/components/miele/climate.py new file mode 100644 index 00000000000..2808220cb35 --- /dev/null +++ b/homeassistant/components/miele/climate.py @@ -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() diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index bd9cd1e6100..25d1ada415d 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -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 diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 5bf19933230..968fb12d5f0 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -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", diff --git a/tests/components/miele/fixtures/action_freezer.json b/tests/components/miele/fixtures/action_freezer.json index 9bfc7810a41..1d6e8832bae 100644 --- a/tests/components/miele/fixtures/action_freezer.json +++ b/tests/components/miele/fixtures/action_freezer.json @@ -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, diff --git a/tests/components/miele/fixtures/action_fridge.json b/tests/components/miele/fixtures/action_fridge.json index 1d6e8832bae..9bfc7810a41 100644 --- a/tests/components/miele/fixtures/action_fridge.json +++ b/tests/components/miele/fixtures/action_fridge.json @@ -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, diff --git a/tests/components/miele/snapshots/test_climate.ambr b/tests/components/miele/snapshots/test_climate.ambr new file mode 100644 index 00000000000..15490047d36 --- /dev/null +++ b/tests/components/miele/snapshots/test_climate.ambr @@ -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([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.freezer_freezer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Freezer', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': , + '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([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': -18, + }), + 'context': , + 'entity_id': 'climate.freezer_freezer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_states[platforms0-freezer][climate.refrigerator_refrigerator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.refrigerator_refrigerator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Refrigerator', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': , + '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([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.refrigerator_refrigerator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/miele/test_climate.py b/tests/components/miele/test_climate.py new file mode 100644 index 00000000000..73e530eb87c --- /dev/null +++ b/tests/components/miele/test_climate.py @@ -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()