1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-20 02:48:57 +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 from __future__ import annotations
import math import math
from typing import Any, Final from typing import Any
from propcache.api import cached_property from propcache.api import cached_property
from xknx.devices import Fan as XknxFan from xknx.devices import Fan as XknxFan
@@ -32,12 +32,11 @@ from .storage.const import (
CONF_GA_OSCILLATION, CONF_GA_OSCILLATION,
CONF_GA_SPEED, CONF_GA_SPEED,
CONF_GA_STEP, CONF_GA_STEP,
CONF_GA_SWITCH,
CONF_SPEED, CONF_SPEED,
) )
from .storage.util import ConfigExtractor from .storage.util import ConfigExtractor
DEFAULT_PERCENTAGE: Final = 50
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@@ -77,26 +76,24 @@ class _KnxFan(FanEntity):
_device: XknxFan _device: XknxFan
_step_range: tuple[int, int] | None _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: async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage.""" """Set the speed of the fan, as a percentage."""
if self._step_range: await self._device.set_speed(self._get_knx_speed(percentage))
step = math.ceil(percentage_to_ranged_value(self._step_range, percentage))
await self._device.set_speed(step)
else:
await self._device.set_speed(percentage)
@cached_property @cached_property
def supported_features(self) -> FanEntityFeature: def supported_features(self) -> FanEntityFeature:
"""Flag supported features.""" """Flag supported features."""
flags = ( flags = FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
FanEntityFeature.SET_SPEED if self._device.speed.initialized:
| FanEntityFeature.TURN_ON flags |= FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_OFF
)
if self._device.supports_oscillation: if self._device.supports_oscillation:
flags |= FanEntityFeature.OSCILLATE flags |= FanEntityFeature.OSCILLATE
return flags return flags
@property @property
@@ -118,6 +115,11 @@ class _KnxFan(FanEntity):
return super().speed_count return super().speed_count
return int_states_in_range(self._step_range) 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( async def async_turn_on(
self, self,
percentage: int | None = None, percentage: int | None = None,
@@ -125,14 +127,12 @@ class _KnxFan(FanEntity):
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
"""Turn on the fan.""" """Turn on the fan."""
if percentage is None: speed = self._get_knx_speed(percentage) if percentage is not None else None
await self.async_set_percentage(DEFAULT_PERCENTAGE) await self._device.turn_on(speed)
else:
await self.async_set_percentage(percentage)
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off.""" """Turn the fan off."""
await self.async_set_percentage(0) await self._device.turn_off()
async def async_oscillate(self, oscillating: bool) -> None: async def async_oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan.""" """Oscillate the fan."""
@@ -165,7 +165,12 @@ class KnxYamlFan(_KnxFan, KnxYamlEntity):
group_address_oscillation_state=config.get( group_address_oscillation_state=config.get(
FanSchema.CONF_OSCILLATION_STATE_ADDRESS 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, max_step=max_step,
sync_state=config.get(CONF_SYNC_STATE, True),
), ),
) )
# FanSpeedMode.STEP if max_step is set # 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( group_address_oscillation_state=knx_conf.get_state_and_passive(
CONF_GA_OSCILLATION 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, max_step=max_step,
sync_state=knx_conf.get(CONF_SYNC_STATE), sync_state=knx_conf.get(CONF_SYNC_STATE),
) )

View File

@@ -576,19 +576,40 @@ class FanSchema(KNXPlatformSchema):
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
CONF_OSCILLATION_ADDRESS = "oscillation_address" CONF_OSCILLATION_ADDRESS = "oscillation_address"
CONF_OSCILLATION_STATE_ADDRESS = "oscillation_state_address" CONF_OSCILLATION_STATE_ADDRESS = "oscillation_state_address"
CONF_SWITCH_ADDRESS = "switch_address"
CONF_SWITCH_STATE_ADDRESS = "switch_state_address"
DEFAULT_NAME = "KNX Fan" DEFAULT_NAME = "KNX Fan"
ENTITY_SCHEMA = vol.Schema( ENTITY_SCHEMA = vol.All(
{ vol.Schema(
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, {
vol.Required(KNX_ADDRESS): ga_list_validator, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator, vol.Optional(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator, vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator, vol.Optional(CONF_SWITCH_ADDRESS): ga_list_validator,
vol.Optional(FanConf.MAX_STEP): cv.byte, vol.Optional(CONF_SWITCH_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, 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( FAN_KNX_SCHEMA = AllSerializeFirst(
{ vol.Schema(
vol.Required(CONF_SPEED): GroupSelect( {
GroupSelectOption( vol.Optional(CONF_GA_SWITCH): GASelector(
translation_key="percentage_mode", write_required=True, valid_dpt="1"
schema={
vol.Required(CONF_GA_SPEED): GASelector(
write_required=True, valid_dpt="5.001"
),
},
), ),
GroupSelectOption( vol.Optional(CONF_SPEED): GroupSelect(
translation_key="step_mode", GroupSelectOption(
schema={ translation_key="percentage_mode",
vol.Required(CONF_GA_STEP): GASelector( schema={
write_required=True, valid_dpt="5.010" vol.Required(CONF_GA_SPEED): GASelector(
), write_required=True, valid_dpt="5.001"
vol.Required(FanConf.MAX_STEP, default=3): selector.NumberSelector( ),
selector.NumberSelectorConfig( },
min=1, ),
max=100, GroupSelectOption(
step=1, translation_key="step_mode",
mode=selector.NumberSelectorMode.BOX, 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( vol.Schema(
write_required=True, valid_dpt="1" {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.", "description": "Toggle oscillation of the fan.",
"label": "Oscillation" "label": "Oscillation"
}, },
"ga_switch": {
"description": "Group address to turn the fan on/off.",
"label": "Switch"
},
"speed": { "speed": {
"description": "Control the speed of the fan.", "description": "Control the speed of the fan.",
"ga_speed": { "ga_speed": {

View File

@@ -1037,10 +1037,32 @@
dict({ dict({
'id': 1, 'id': 1,
'result': list([ '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({ dict({
'collapsible': False, 'collapsible': False,
'name': 'speed', 'name': 'speed',
'required': True, 'optional': True,
'required': False,
'schema': list([ 'schema': list([
dict({ dict({
'schema': list([ 'schema': list([

View File

@@ -109,6 +109,72 @@ async def test_fan_step(hass: HomeAssistant, knx: KNXTestKit) -> None:
await knx.assert_telegram_count(0) 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: async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None:
"""Test KNX fan oscillation.""" """Test KNX fan oscillation."""
await knx.setup_integration( await knx.setup_integration(
@@ -153,7 +219,7 @@ async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("knx_data", "expected_read_response", "expected_state"), ("knx_data", "expected_read_response", "expected_state"),
[ [
( ( # percent mode fan with oscillation
{ {
"speed": { "speed": {
"ga_speed": {"write": "1/1/0", "state": "1/1/1"}, "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)], [("1/1/1", (0x55,)), ("2/2/2", True)],
{"state": STATE_ON, "percentage": 33, "oscillating": True}, {"state": STATE_ON, "percentage": 33, "oscillating": True},
), ),
( ( # step only fan
{ {
"speed": { "speed": {
"ga_step": {"write": "1/1/0", "state": "1/1/1"}, "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,))], [("1/1/1", (2,))],
{"state": STATE_ON, "percentage": 66}, {"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( async def test_fan_ui_create(