From c2c3ceb3a91c19c3cd7a976d6514c11841656efc Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Tue, 29 Sep 2026 20:00:46 +0200 Subject: [PATCH] Add a fan platform for the HomematicIP ventilation actuator (#183091) --- .../components/homematicip_cloud/const.py | 1 + .../components/homematicip_cloud/fan.py | 100 ++++++++++++ .../fixtures/homematicip_cloud.json | 128 +++++++++++++++ .../homematicip_cloud/test_device.py | 2 +- .../components/homematicip_cloud/test_fan.py | 154 ++++++++++++++++++ 5 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/homematicip_cloud/fan.py create mode 100644 tests/components/homematicip_cloud/test_fan.py diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index d9046de8de58..1afdd714fdc9 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -11,6 +11,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.COVER, Platform.EVENT, + Platform.FAN, Platform.LIGHT, Platform.LOCK, Platform.SENSOR, diff --git a/homeassistant/components/homematicip_cloud/fan.py b/homeassistant/components/homematicip_cloud/fan.py new file mode 100644 index 000000000000..4db884e98f03 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/fan.py @@ -0,0 +1,100 @@ +"""Support for HomematicIP Cloud ventilation actuators.""" + +from typing import Any, override + +from homematicip.base.functionalChannels import FunctionalChannelType +from homematicip.device import Device + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import HomematicipGenericEntity +from .hap import HomematicIPConfigEntry, HomematicipHAP + +VENTILATION_ACTUATOR_ROLE = "VENTILATION_ACTUATOR" + +STATE_VENTILATION = "VENTILATION" +STATE_NO_VENTILATION = "NO_VENTILATION" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomematicIPConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the HomematicIP fans from a config entry.""" + hap = config_entry.runtime_data + async_add_entities( + HomematicipVentilationFan(hap, device, ch.index) + for device in hap.home.devices + for ch in device.functionalChannels + # The channel type also carries dimming actuators, so the role decides. + if ch.functionalChannelType == FunctionalChannelType.UNIVERSAL_ACTUATOR_CHANNEL + and ch.channelRole == VENTILATION_ACTUATOR_ROLE + ) + + +class HomematicipVentilationFan(HomematicipGenericEntity, FanEntity): + """Representation of a HomematicIP ventilation actuator.""" + + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + ) + + def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None: + """Initialize the ventilation fan.""" + super().__init__( + hap, + device=device, + channel=channel, + channel_real_index=channel, + post="ventilation", + is_multi_channel=True, + feature_id="ventilation", + ) + + @property + @override + def is_on(self) -> bool: + """Return whether the actuator is ventilating.""" + channel = self.get_channel_or_raise() + return channel.ventilationState == STATE_VENTILATION + + @property + @override + def percentage(self) -> int | None: + """Return the current ventilation level in percent.""" + channel = self.get_channel_or_raise() + return round(channel.ventilationLevel * 100) + + @override + async def async_set_percentage(self, percentage: int) -> None: + """Set the ventilation level.""" + channel = self.get_channel_or_raise() + if percentage == 0: + await channel.async_set_ventilation_state(STATE_NO_VENTILATION) + return + await channel.async_set_ventilation_level(percentage / 100) + + @override + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Start ventilating, optionally at a given level.""" + if percentage is not None: + await self.async_set_percentage(percentage) + return + channel = self.get_channel_or_raise() + await channel.async_set_ventilation_state(STATE_VENTILATION) + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Stop ventilating.""" + channel = self.get_channel_or_raise() + await channel.async_set_ventilation_state(STATE_NO_VENTILATION) diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index e3a10f5551db..826c52692556 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -9469,6 +9469,134 @@ "serializedGlobalTradeItemNumber": "3014F711000000000000WRC6", "type": "WIRED_PUSH_BUTTON_6", "updateState": "UP_TO_DATE" + }, + "3014F7110000000000000WUA": { + "availableFirmwareVersion": "1.0.16", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.16", + "firmwareVersionInteger": 65552, + "functionalChannels": { + "0": { + "altitude": null, + "bootedRecently": false, + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "daliBusState": null, + "dataDecodingFailedError": null, + "defaultLinkedGroup": [], + "deviceAliveSignalEnabled": null, + "deviceCanBusError": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F7110000000000000WUA", + "deviceOperationMode": null, + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "displayMode": null, + "displayMountingOrientation": null, + "dutyCycle": false, + "fanControlMode": null, + "frostProtectionError": null, + "frostProtectionErrorAcknowledged": null, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": [], + "index": 0, + "input1CoproEnabled": null, + "input2CoproEnabled": null, + "input3CoproEnabled": null, + "input4CoproEnabled": null, + "inputLayoutMode": null, + "invertedDisplayColors": null, + "label": "", + "lastBootTimestamp": 1783290601087, + "lockJammed": null, + "lowBat": null, + "mountingModuleError": null, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "noDataFromLinkyError": null, + "notRechargeableBattery": null, + "operationDays": null, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -71, + "rssiPeerValue": -75, + "sensorCommunicationError": null, + "sensorError": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureProfilePeriodLimit": true, + "IFeatureRssiValue": true, + "IOptionalFeatureDutyCycle": true + }, + "switchChannelMode": null, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "ticVersionError": null, + "unreach": false, + "valveFlowError": null, + "valveWaterError": null + }, + "1": { + "channelRole": "VENTILATION_ACTUATOR", + "deviceId": "3014F7110000000000000WUA", + "dimLevel": 0.01, + "functionalChannelType": "UNIVERSAL_ACTUATOR_CHANNEL", + "groupIndex": 1, + "groups": [], + "index": 1, + "label": "", + "on": true, + "powerUpDimLevel": 1.0, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "relayMode": "RELAY_OFFDELAY_0S", + "supportedOptionalFeatures": { + "IFeatureVentilationGroupActuatorChannel": true, + "IFeatureVentilationProfileActuatorChannel": true, + "IOptionalFeaturePowerUpDimmerState": true, + "IOptionalFeaturePowerUpSwitchState": true + }, + "switchVisualization": "VENTILATION", + "userDesiredProfileMode": "AUTOMATIC", + "ventilationLevel": 0.01, + "ventilationState": "VENTILATION" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F7110000000000000WUA", + "label": "Universalaktor", + "lastStatusUpdate": 1790082161197, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manuallyUpdateForced": false, + "manufacturerCode": 1, + "measuredAttributes": { + "1": { + "ventilationLevel": true + } + }, + "modelId": 442, + "modelType": "HmIP-WUA", + "oem": "eQ-3", + "permanentlyReachable": true, + "serializedGlobalTradeItemNumber": "3014F7110000000000000WUA", + "type": "WALL_MOUNTED_UNIVERSAL_ACTUATOR", + "updateState": "UP_TO_DATE" } }, "groups": { diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 5b677bd95e7a..3d160a19bcfd 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -24,7 +24,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 390 + assert len(mock_hap.hmip_device_by_entity_id) == 391 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_fan.py b/tests/components/homematicip_cloud/test_fan.py new file mode 100644 index 000000000000..4c69f8d551a4 --- /dev/null +++ b/tests/components/homematicip_cloud/test_fan.py @@ -0,0 +1,154 @@ +"""Tests for the HomematicIP Cloud fan.""" + +from copy import deepcopy +import json + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant + +from .helper import ( + FIXTURE_DATA, + HomeFactory, + async_manipulate_test_data, + get_and_check_entity_basics, +) + + +async def test_ventilation_fan( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicIP ventilation actuator.""" + entity_id = "fan.universalaktor_ventilation" + entity_name = "Universalaktor Ventilation" + device_model = "HmIP-WUA" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Universalaktor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == STATE_ON + assert ha_state.attributes[ATTR_PERCENTAGE] == 1 + + channel = hmip_device.functionalChannels[1] + + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert channel.mock_calls[-1][0] == "async_set_ventilation_state" + assert channel.mock_calls[-1][1] == ("NO_VENTILATION",) + + await async_manipulate_test_data( + hass, hmip_device, "ventilationState", "NO_VENTILATION", channel=1 + ) + assert hass.states.get(entity_id).state == STATE_OFF + + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert channel.mock_calls[-1][0] == "async_set_ventilation_state" + assert channel.mock_calls[-1][1] == ("VENTILATION",) + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + assert channel.mock_calls[-1][0] == "async_set_ventilation_level" + assert channel.mock_calls[-1][1] == (0.5,) + + await async_manipulate_test_data( + hass, hmip_device, "ventilationLevel", 0.5, channel=1 + ) + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 50 + + # 0.07 * 100 is 7.000000000000001 in binary floating point. + await async_manipulate_test_data( + hass, hmip_device, "ventilationLevel", 0.07, channel=1 + ) + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 7 + + +async def test_ventilation_fan_percentage_zero_stops_ventilation( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test that a percentage of zero stops ventilating instead of setting level 0.""" + entity_id = "fan.universalaktor_ventilation" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Universalaktor"] + ) + hmip_device = mock_hap.hmip_device_by_entity_id.get(entity_id) + channel = hmip_device.functionalChannels[1] + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 0}, + blocking=True, + ) + + assert channel.mock_calls[-1][0] == "async_set_ventilation_state" + assert channel.mock_calls[-1][1] == ("NO_VENTILATION",) + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 0}, + blocking=True, + ) + + assert channel.mock_calls[-1][0] == "async_set_ventilation_state" + assert channel.mock_calls[-1][1] == ("NO_VENTILATION",) + + +async def test_ventilation_fan_turn_on_with_percentage( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test that turning on with a percentage sets the level in one call.""" + entity_id = "fan.universalaktor_ventilation" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Universalaktor"] + ) + hmip_device = mock_hap.hmip_device_by_entity_id.get(entity_id) + channel = hmip_device.functionalChannels[1] + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 40}, + blocking=True, + ) + + assert channel.mock_calls[-1][0] == "async_set_ventilation_level" + assert channel.mock_calls[-1][1] == (0.4,) + + +async def test_no_fan_for_other_channel_role( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test that a universal actuator that does not ventilate gets no fan.""" + device = deepcopy(json.loads(FIXTURE_DATA)["devices"]["3014F7110000000000000WUA"]) + device["id"] = "3014F7110000000000000DIM" + device["label"] = "Dimmaktor" + device["functionalChannels"]["1"]["channelRole"] = "DIMMER" + + await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Universalaktor", "Dimmaktor"], extra_devices=[device] + ) + + assert hass.states.get("fan.universalaktor_ventilation") is not None + assert hass.states.get("fan.dimmaktor_ventilation") is None