Add a fan platform for the HomematicIP ventilation actuator (#183091)

This commit is contained in:
Christian Lackas
2026-09-29 20:00:46 +02:00
committed by GitHub
parent 0aa7f15ddf
commit c2c3ceb3a9
5 changed files with 384 additions and 1 deletions
@@ -11,6 +11,7 @@ PLATFORMS = [
Platform.CLIMATE,
Platform.COVER,
Platform.EVENT,
Platform.FAN,
Platform.LIGHT,
Platform.LOCK,
Platform.SENSOR,
@@ -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)
@@ -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": {
@@ -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(
@@ -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