From a71edcf1a181a00bf24abb1f8453ecdd2a962432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 29 Apr 2025 10:48:56 +0200 Subject: [PATCH] Add fan platform to miele integration (#143772) * Add fan platform * Fix after review comment * Address review comments * Remove commented code * Update tests * Use constant --- homeassistant/components/miele/__init__.py | 1 + homeassistant/components/miele/const.py | 1 + homeassistant/components/miele/fan.py | 182 +++++++++++++++ homeassistant/components/miele/strings.json | 5 + .../miele/fixtures/fan_devices.json | 214 ++++++++++++++++++ .../components/miele/snapshots/test_fan.ambr | 104 +++++++++ tests/components/miele/test_fan.py | 115 ++++++++++ 7 files changed, 622 insertions(+) create mode 100644 homeassistant/components/miele/fan.py create mode 100644 tests/components/miele/fixtures/fan_devices.json create mode 100644 tests/components/miele/snapshots/test_fan.ambr create mode 100644 tests/components/miele/test_fan.py diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py index 3f1d4e7fd54..98a6919980a 100644 --- a/homeassistant/components/miele/__init__.py +++ b/homeassistant/components/miele/__init__.py @@ -22,6 +22,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.FAN, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 25d1ada415d..d129bdcbbd4 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -9,6 +9,7 @@ ACTIONS = "actions" POWER_ON = "powerOn" POWER_OFF = "powerOff" PROCESS_ACTION = "processAction" +VENTILATION_STEP = "ventilationStep" DISABLED_TEMP_ENTITIES = ( -32768 / 100, -32766 / 100, diff --git a/homeassistant/components/miele/fan.py b/homeassistant/components/miele/fan.py new file mode 100644 index 00000000000..4781d27901f --- /dev/null +++ b/homeassistant/components/miele/fan.py @@ -0,0 +1,182 @@ +"""Platform for Miele fan entity.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +import math +from typing import Any, Final + +from aiohttp import ClientResponseError + +from homeassistant.components.fan import ( + FanEntity, + FanEntityDescription, + FanEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) +from homeassistant.util.scaling import int_states_in_range + +from .const import DOMAIN, POWER_OFF, POWER_ON, VENTILATION_STEP, MieleAppliance +from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator +from .entity import MieleEntity + +_LOGGER = logging.getLogger(__name__) + +SPEED_RANGE = (1, 4) + + +@dataclass(frozen=True, kw_only=True) +class MieleFanDefinition: + """Class for defining fan entities.""" + + types: tuple[MieleAppliance, ...] + description: FanEntityDescription + + +FAN_TYPES: Final[tuple[MieleFanDefinition, ...]] = ( + MieleFanDefinition( + types=(MieleAppliance.HOOD,), + description=FanEntityDescription( + key="fan", + translation_key="fan", + ), + ), + MieleFanDefinition( + types=(MieleAppliance.HOB_INDUCT_EXTR,), + description=FanEntityDescription( + key="fan_readonly", + translation_key="fan", + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the fan platform.""" + coordinator = config_entry.runtime_data + + 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 + ) + + +class MieleFan(MieleEntity, FanEntity): + """Representation of a Fan.""" + + entity_description: FanEntityDescription + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: FanEntityDescription, + ) -> None: + """Initialize the fan.""" + + self._attr_supported_features: FanEntityFeature = ( + FanEntityFeature(0) + if description.key == "fan_readonly" + else FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + super().__init__(coordinator, device_id, description) + + @property + def is_on(self) -> bool: + """Return current on/off state.""" + assert self.device.state_ventilation_step is not None + return self.device.state_ventilation_step > 0 + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) + + @property + def percentage(self) -> int | None: + """Return the current speed percentage.""" + return ranged_value_to_percentage( + SPEED_RANGE, + (self.device.state_ventilation_step or 0), + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + _LOGGER.debug("Set_percentage: %s", percentage) + ventilation_step = math.ceil( + percentage_to_ranged_value(SPEED_RANGE, percentage) + ) + _LOGGER.debug("Calc ventilation_step: %s", ventilation_step) + if ventilation_step == 0: + await self.async_turn_off() + else: + try: + await self.api.send_action( + self._device_id, {VENTILATION_STEP: ventilation_step} + ) + except ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from ex + self.device.state_ventilation_step = ventilation_step + self.async_write_ha_state() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + _LOGGER.debug( + "Turn_on -> percentage: %s, preset_mode: %s", percentage, preset_mode + ) + try: + await self.api.send_action(self._device_id, {POWER_ON: True}) + except ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from ex + + if percentage is not None: + await self.async_set_percentage(percentage) + return + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + try: + await self.api.send_action(self._device_id, {POWER_OFF: True}) + except ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from ex + + self.device.state_ventilation_step = 0 + self.async_write_ha_state() diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 5436877a3eb..f1a79bd62f7 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -139,6 +139,11 @@ "name": "[%key:common::action::pause%]" } }, + "fan": { + "fan": { + "name": "[%key:component::fan::title%]" + } + }, "light": { "ambient_light": { "name": "Ambient light" diff --git a/tests/components/miele/fixtures/fan_devices.json b/tests/components/miele/fixtures/fan_devices.json new file mode 100644 index 00000000000..d3403c0f7bc --- /dev/null +++ b/tests/components/miele/fixtures/fan_devices.json @@ -0,0 +1,214 @@ +{ + "DummyAppliance_18": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 18, + "value_localized": "Cooker Hood" + }, + "deviceName": "", + "protocolVersion": 2, + "deviceIdentLabel": { + "fabNumber": "", + "fabIndex": "64", + "techType": "Fläkt", + "matNumber": "", + "swids": ["", "", "", "<...>"] + }, + "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 + } + }, + "DummyAppliance_74": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 74, + "value_localized": "Hob w extraction" + }, + "deviceName": "", + "protocolVersion": 203, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "00", + "techType": "KMDA7634", + "matNumber": "", + "swids": ["000"] + }, + "xkmIdentLabel": { + "techType": "EK039W", + "releaseVersion": "02.72" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "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": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": "", + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": 1, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [ + { + "value_raw": 0, + "value_localized": 0, + "key_localized": "Power level" + }, + { + "value_raw": 3, + "value_localized": 2, + "key_localized": "Power level" + }, + { + "value_raw": 7, + "value_localized": 4, + "key_localized": "Power level" + }, + { + "value_raw": 15, + "value_localized": 8, + "key_localized": "Power level" + }, + { + "value_raw": 117, + "value_localized": 10, + "key_localized": "Power level" + } + ], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/snapshots/test_fan.ambr b/tests/components/miele/snapshots/test_fan.ambr new file mode 100644 index 00000000000..ffd6c90a388 --- /dev/null +++ b/tests/components/miele/snapshots/test_fan.ambr @@ -0,0 +1,104 @@ +# serializer version: 1 +# name: test_fan_states[fan_devices.json-platforms0][fan.hob_with_extraction_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.hob_with_extraction_fan', + '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': 'Fan', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan', + 'unique_id': 'DummyAppliance_74-fan_readonly', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_states[fan_devices.json-platforms0][fan.hob_with_extraction_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hob with extraction Fan', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.hob_with_extraction_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_fan_states[fan_devices.json-platforms0][fan.hood_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.hood_fan', + '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': 'Fan', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'fan', + 'unique_id': 'DummyAppliance_18-fan', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_states[fan_devices.json-platforms0][fan.hood_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Fan', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.hood_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/miele/test_fan.py b/tests/components/miele/test_fan.py new file mode 100644 index 00000000000..87f80614551 --- /dev/null +++ b/tests/components/miele/test_fan.py @@ -0,0 +1,115 @@ +"""Tests for miele fan module.""" + +from typing import Any +from unittest.mock import MagicMock + +from aiohttp import ClientResponseError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.fan import ATTR_PERCENTAGE, DOMAIN as FAN_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 = FAN_DOMAIN +pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) + +ENTITY_ID = "fan.hood_fan" + + +@pytest.mark.parametrize("load_device_file", ["fan_devices.json"]) +async def test_fan_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test fan entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["fan_devices.json"]) +@pytest.mark.parametrize( + ("service", "expected_argument"), + [ + (SERVICE_TURN_ON, {"powerOn": True}), + (SERVICE_TURN_OFF, {"powerOff": True}), + ], +) +async def test_fan_control( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + service: str, + expected_argument: dict[str, Any], +) -> None: + """Test the fan can be turned on/off.""" + + await hass.services.async_call( + TEST_PLATFORM, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_miele_client.send_action.assert_called_once_with( + "DummyAppliance_18", expected_argument + ) + + +@pytest.mark.parametrize( + ("service", "percentage", "expected_argument"), + [ + ("set_percentage", 0, {"powerOff": True}), + ("set_percentage", 20, {"ventilationStep": 1}), + ("set_percentage", 100, {"ventilationStep": 4}), + ], +) +async def test_fan_set_speed( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + service: str, + percentage: int, + expected_argument: dict[str, Any], +) -> None: + """Test the fan can be turned on/off.""" + + await hass.services.async_call( + TEST_PLATFORM, + service, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: percentage}, + blocking=True, + ) + mock_miele_client.send_action.assert_called_once_with( + "DummyAppliance_18", expected_argument + ) + + +@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, +) -> 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()