mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 14:29:21 -04:00
Add a fan platform for the HomematicIP ventilation actuator (#183091)
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user