1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-19 18:38:58 +00:00

KNX Fan: Add support for switch addresses (#159367)

This commit is contained in:
Matthias Alphart
2025-12-19 15:37:50 +01:00
committed by GitHub
parent e0cb56a38c
commit 1c3492b4c2
6 changed files with 209 additions and 63 deletions

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import math
from typing import Any, Final
from typing import Any
from propcache.api import cached_property
from xknx.devices import Fan as XknxFan
@@ -32,12 +32,11 @@ from .storage.const import (
CONF_GA_OSCILLATION,
CONF_GA_SPEED,
CONF_GA_STEP,
CONF_GA_SWITCH,
CONF_SPEED,
)
from .storage.util import ConfigExtractor
DEFAULT_PERCENTAGE: Final = 50
async def async_setup_entry(
hass: HomeAssistant,
@@ -77,26 +76,24 @@ class _KnxFan(FanEntity):
_device: XknxFan
_step_range: tuple[int, int] | None
def _get_knx_speed(self, percentage: int) -> int:
"""Convert percentage to KNX speed value."""
if self._step_range is not None:
return math.ceil(percentage_to_ranged_value(self._step_range, percentage))
return percentage
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
if self._step_range:
step = math.ceil(percentage_to_ranged_value(self._step_range, percentage))
await self._device.set_speed(step)
else:
await self._device.set_speed(percentage)
await self._device.set_speed(self._get_knx_speed(percentage))
@cached_property
def supported_features(self) -> FanEntityFeature:
"""Flag supported features."""
flags = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
flags = FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
if self._device.speed.initialized:
flags |= FanEntityFeature.SET_SPEED
if self._device.supports_oscillation:
flags |= FanEntityFeature.OSCILLATE
return flags
@property
@@ -118,6 +115,11 @@ class _KnxFan(FanEntity):
return super().speed_count
return int_states_in_range(self._step_range)
@property
def is_on(self) -> bool:
"""Return the current fan state of the device."""
return self._device.is_on
async def async_turn_on(
self,
percentage: int | None = None,
@@ -125,14 +127,12 @@ class _KnxFan(FanEntity):
**kwargs: Any,
) -> None:
"""Turn on the fan."""
if percentage is None:
await self.async_set_percentage(DEFAULT_PERCENTAGE)
else:
await self.async_set_percentage(percentage)
speed = self._get_knx_speed(percentage) if percentage is not None else None
await self._device.turn_on(speed)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
await self.async_set_percentage(0)
await self._device.turn_off()
async def async_oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan."""
@@ -165,7 +165,12 @@ class KnxYamlFan(_KnxFan, KnxYamlEntity):
group_address_oscillation_state=config.get(
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
),
group_address_switch=config.get(FanSchema.CONF_SWITCH_ADDRESS),
group_address_switch_state=config.get(
FanSchema.CONF_SWITCH_STATE_ADDRESS
),
max_step=max_step,
sync_state=config.get(CONF_SYNC_STATE, True),
),
)
# FanSpeedMode.STEP if max_step is set
@@ -210,6 +215,8 @@ class KnxUiFan(_KnxFan, KnxUiEntity):
group_address_oscillation_state=knx_conf.get_state_and_passive(
CONF_GA_OSCILLATION
),
group_address_switch=knx_conf.get_write(CONF_GA_SWITCH),
group_address_switch_state=knx_conf.get_state_and_passive(CONF_GA_SWITCH),
max_step=max_step,
sync_state=knx_conf.get(CONF_SYNC_STATE),
)

View File

@@ -576,19 +576,40 @@ class FanSchema(KNXPlatformSchema):
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
CONF_OSCILLATION_ADDRESS = "oscillation_address"
CONF_OSCILLATION_STATE_ADDRESS = "oscillation_state_address"
CONF_SWITCH_ADDRESS = "switch_address"
CONF_SWITCH_STATE_ADDRESS = "switch_state_address"
DEFAULT_NAME = "KNX Fan"
ENTITY_SCHEMA = vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
vol.Optional(FanConf.MAX_STEP): cv.byte,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
ENTITY_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_SWITCH_ADDRESS): ga_list_validator,
vol.Optional(CONF_SWITCH_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
vol.Optional(FanConf.MAX_STEP): cv.byte,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
}
),
vol.Any(
vol.Schema(
{vol.Required(KNX_ADDRESS): object},
extra=vol.ALLOW_EXTRA,
),
vol.Schema(
{vol.Required(CONF_SWITCH_ADDRESS): object},
extra=vol.ALLOW_EXTRA,
),
msg=(
f"At least one of '{KNX_ADDRESS}' or"
f" '{CONF_SWITCH_ADDRESS}' is required."
),
),
)

View File

@@ -224,40 +224,58 @@ DATETIME_KNX_SCHEMA = vol.Schema(
}
)
FAN_KNX_SCHEMA = vol.Schema(
{
vol.Required(CONF_SPEED): GroupSelect(
GroupSelectOption(
translation_key="percentage_mode",
schema={
vol.Required(CONF_GA_SPEED): GASelector(
write_required=True, valid_dpt="5.001"
),
},
FAN_KNX_SCHEMA = AllSerializeFirst(
vol.Schema(
{
vol.Optional(CONF_GA_SWITCH): GASelector(
write_required=True, valid_dpt="1"
),
GroupSelectOption(
translation_key="step_mode",
schema={
vol.Required(CONF_GA_STEP): GASelector(
write_required=True, valid_dpt="5.010"
),
vol.Required(FanConf.MAX_STEP, default=3): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=100,
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
},
vol.Optional(CONF_SPEED): GroupSelect(
GroupSelectOption(
translation_key="percentage_mode",
schema={
vol.Required(CONF_GA_SPEED): GASelector(
write_required=True, valid_dpt="5.001"
),
},
),
GroupSelectOption(
translation_key="step_mode",
schema={
vol.Required(CONF_GA_STEP): GASelector(
write_required=True, valid_dpt="5.010"
),
vol.Required(
FanConf.MAX_STEP, default=3
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=100,
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
},
),
collapsible=False,
),
collapsible=False,
vol.Optional(CONF_GA_OSCILLATION): GASelector(
write_required=True, valid_dpt="1"
),
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
}
),
vol.Any(
vol.Schema(
{vol.Required(CONF_GA_SWITCH): object},
extra=vol.ALLOW_EXTRA,
),
vol.Optional(CONF_GA_OSCILLATION): GASelector(
write_required=True, valid_dpt="1"
vol.Schema(
{vol.Required(CONF_SPEED): object},
extra=vol.ALLOW_EXTRA,
),
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
}
msg=("At least one of 'Switch' or 'Fan speed' is required."),
),
)

View File

@@ -467,6 +467,10 @@
"description": "Toggle oscillation of the fan.",
"label": "Oscillation"
},
"ga_switch": {
"description": "Group address to turn the fan on/off.",
"label": "Switch"
},
"speed": {
"description": "Control the speed of the fan.",
"ga_speed": {

View File

@@ -1037,10 +1037,32 @@
dict({
'id': 1,
'result': list([
dict({
'name': 'ga_switch',
'optional': True,
'options': dict({
'passive': True,
'state': dict({
'required': False,
}),
'validDPTs': list([
dict({
'main': 1,
'sub': None,
}),
]),
'write': dict({
'required': True,
}),
}),
'required': False,
'type': 'knx_group_address',
}),
dict({
'collapsible': False,
'name': 'speed',
'required': True,
'optional': True,
'required': False,
'schema': list([
dict({
'schema': list([

View File

@@ -109,6 +109,72 @@ async def test_fan_step(hass: HomeAssistant, knx: KNXTestKit) -> None:
await knx.assert_telegram_count(0)
async def test_fan_switch(hass: HomeAssistant, knx: KNXTestKit) -> None:
"""Test KNX fan with switch only."""
await knx.setup_integration(
{
FanSchema.PLATFORM: {
CONF_NAME: "test",
FanSchema.CONF_SWITCH_ADDRESS: "1/2/3",
}
}
)
# turn on fan
await hass.services.async_call(
"fan", "turn_on", {"entity_id": "fan.test"}, blocking=True
)
await knx.assert_write("1/2/3", True)
# turn off fan
await hass.services.async_call(
"fan", "turn_off", {"entity_id": "fan.test"}, blocking=True
)
await knx.assert_write("1/2/3", False)
async def test_fan_switch_step(hass: HomeAssistant, knx: KNXTestKit) -> None:
"""Test KNX fan with speed steps and switch address."""
await knx.setup_integration(
{
FanSchema.PLATFORM: {
CONF_NAME: "test",
KNX_ADDRESS: "1/1/1",
FanSchema.CONF_SWITCH_ADDRESS: "2/2/2",
FanConf.MAX_STEP: 4,
}
}
)
# turn on fan without percentage - actuator sets default speed
await hass.services.async_call(
"fan", "turn_on", {"entity_id": "fan.test"}, blocking=True
)
await knx.assert_write("2/2/2", True)
# turn on with speed 75% - step 3 - turn_on sends switch ON again
await hass.services.async_call(
"fan", "turn_on", {"entity_id": "fan.test", "percentage": 75}, blocking=True
)
await knx.assert_write("2/2/2", True)
await knx.assert_write("1/1/1", (3,))
# set speed to 25% - step 1 - set_percentage doesn't send switch ON
await hass.services.async_call(
"fan",
"set_percentage",
{"entity_id": "fan.test", "percentage": 25},
blocking=True,
)
await knx.assert_write("1/1/1", (1,))
# turn off fan - no percentage change sent
await hass.services.async_call(
"fan", "turn_off", {"entity_id": "fan.test"}, blocking=True
)
await knx.assert_write("2/2/2", False)
async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None:
"""Test KNX fan oscillation."""
await knx.setup_integration(
@@ -153,7 +219,7 @@ async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None:
@pytest.mark.parametrize(
("knx_data", "expected_read_response", "expected_state"),
[
(
( # percent mode fan with oscillation
{
"speed": {
"ga_speed": {"write": "1/1/0", "state": "1/1/1"},
@@ -164,7 +230,7 @@ async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None:
[("1/1/1", (0x55,)), ("2/2/2", True)],
{"state": STATE_ON, "percentage": 33, "oscillating": True},
),
(
( # step only fan
{
"speed": {
"ga_step": {"write": "1/1/0", "state": "1/1/1"},
@@ -175,6 +241,14 @@ async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None:
[("1/1/1", (2,))],
{"state": STATE_ON, "percentage": 66},
),
( # switch only fan
{
"ga_switch": {"write": "1/1/0", "state": "1/1/1"},
"sync_state": True,
},
[("1/1/1", True)],
{"state": STATE_ON, "percentage": None},
),
],
)
async def test_fan_ui_create(