diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 275f72ca50f..666ffad836f 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -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), ) diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 2adb3dec2c7..3ded33494cc 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -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." + ), + ), ) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 24ae93b488b..952ec1f238e 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -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."), + ), ) diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 6a1f689d44f..541a2e889f8 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -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": { diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index debcdfffb20..7ab2fab9ad8 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -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([ diff --git a/tests/components/knx/test_fan.py b/tests/components/knx/test_fan.py index a97214d55cf..7e04f3f5192 100644 --- a/tests/components/knx/test_fan.py +++ b/tests/components/knx/test_fan.py @@ -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(