diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 8a751ebfe0c98..14460ef09215c 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -163,6 +163,7 @@ class FanZeroMode(StrEnum): Platform.CLIMATE, Platform.COVER, Platform.DATE, + Platform.FAN, Platform.DATETIME, Platform.LIGHT, Platform.SWITCH, @@ -217,3 +218,9 @@ class ClimateConf: FAN_MAX_STEP: Final = "fan_max_step" FAN_SPEED_MODE: Final = "fan_speed_mode" FAN_ZERO_MODE: Final = "fan_zero_mode" + + +class FanConf: + """Common config keys for fan.""" + + MAX_STEP: Final = "max_step" diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 23f25dc846932..275f72ca50f0e 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -5,13 +5,17 @@ import math from typing import Any, Final +from propcache.api import cached_property from xknx.devices import Fan as XknxFan from homeassistant import config_entries from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -19,10 +23,18 @@ ) from homeassistant.util.scaling import int_states_in_range -from .const import KNX_ADDRESS, KNX_MODULE_KEY -from .entity import KnxYamlEntity +from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, FanConf +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .knx_module import KNXModule from .schema import FanSchema +from .storage.const import ( + CONF_ENTITY, + CONF_GA_OSCILLATION, + CONF_GA_SPEED, + CONF_GA_STEP, + CONF_SPEED, +) +from .storage.util import ConfigExtractor DEFAULT_PERCENTAGE: Final = 50 @@ -34,40 +46,36 @@ async def async_setup_entry( ) -> None: """Set up fan(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.FAN] - - async_add_entities(KNXFan(knx_module, entity_config) for entity_config in config) + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.FAN, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiFan, + ), + ) + + entities: list[_KnxFan] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.FAN): + entities.extend( + KnxYamlFan(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get(Platform.FAN): + entities.extend( + KnxUiFan(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) -class KNXFan(KnxYamlEntity, FanEntity): +class _KnxFan(FanEntity): """Representation of a KNX fan.""" _device: XknxFan - - def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: - """Initialize of KNX fan.""" - max_step = config.get(FanSchema.CONF_MAX_STEP) - super().__init__( - knx_module=knx_module, - device=XknxFan( - xknx=knx_module.xknx, - name=config[CONF_NAME], - group_address_speed=config.get(KNX_ADDRESS), - group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS), - group_address_oscillation=config.get( - FanSchema.CONF_OSCILLATION_ADDRESS - ), - group_address_oscillation_state=config.get( - FanSchema.CONF_OSCILLATION_STATE_ADDRESS - ), - max_step=max_step, - ), - ) - # FanSpeedMode.STEP if max_step is set - self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - - self._attr_unique_id = str(self._device.speed.group_address) + _step_range: tuple[int, int] | None async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" @@ -77,7 +85,7 @@ async def async_set_percentage(self, percentage: int) -> None: else: await self._device.set_speed(percentage) - @property + @cached_property def supported_features(self) -> FanEntityFeature: """Flag supported features.""" flags = ( @@ -103,7 +111,7 @@ def percentage(self) -> int | None: ) return self._device.current_speed - @property + @cached_property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" if self._step_range is None: @@ -134,3 +142,76 @@ async def async_oscillate(self, oscillating: bool) -> None: def oscillating(self) -> bool | None: """Return whether or not the fan is currently oscillating.""" return self._device.current_oscillation + + +class KnxYamlFan(_KnxFan, KnxYamlEntity): + """Representation of a KNX fan configured from YAML.""" + + _device: XknxFan + + def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: + """Initialize of KNX fan.""" + max_step = config.get(FanConf.MAX_STEP) + super().__init__( + knx_module=knx_module, + device=XknxFan( + xknx=knx_module.xknx, + name=config[CONF_NAME], + group_address_speed=config.get(KNX_ADDRESS), + group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS), + group_address_oscillation=config.get( + FanSchema.CONF_OSCILLATION_ADDRESS + ), + group_address_oscillation_state=config.get( + FanSchema.CONF_OSCILLATION_STATE_ADDRESS + ), + max_step=max_step, + ), + ) + # FanSpeedMode.STEP if max_step is set + self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + + self._attr_unique_id = str(self._device.speed.group_address) + + +class KnxUiFan(_KnxFan, KnxUiEntity): + """Representation of a KNX fan configured from UI.""" + + _device: XknxFan + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + ) -> None: + """Initialize of KNX fan.""" + knx_conf = ConfigExtractor(config[DOMAIN]) + # max_step is required for step mode, thus can be used to differentiate modes + max_step: int | None = knx_conf.get(CONF_SPEED, FanConf.MAX_STEP) + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) + if max_step: + # step control + speed_write = knx_conf.get_write(CONF_SPEED, CONF_GA_STEP) + speed_state = knx_conf.get_state_and_passive(CONF_SPEED, CONF_GA_STEP) + else: + # percentage control + speed_write = knx_conf.get_write(CONF_SPEED, CONF_GA_SPEED) + speed_state = knx_conf.get_state_and_passive(CONF_SPEED, CONF_GA_SPEED) + + self._device = XknxFan( + xknx=knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + group_address_speed=speed_write, + group_address_speed_state=speed_state, + group_address_oscillation=knx_conf.get_write(CONF_GA_OSCILLATION), + group_address_oscillation_state=knx_conf.get_state_and_passive( + CONF_GA_OSCILLATION + ), + max_step=max_step, + sync_state=knx_conf.get(CONF_SYNC_STATE), + ) + # FanSpeedMode.STEP if max_step is set + self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index faf53162dfe42..2adb3dec2c7c1 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -59,6 +59,7 @@ ClimateConf, ColorTempModes, CoverConf, + FanConf, FanZeroMode, ) from .validation import ( @@ -575,7 +576,6 @@ class FanSchema(KNXPlatformSchema): CONF_STATE_ADDRESS = CONF_STATE_ADDRESS CONF_OSCILLATION_ADDRESS = "oscillation_address" CONF_OSCILLATION_STATE_ADDRESS = "oscillation_state_address" - CONF_MAX_STEP = "max_step" DEFAULT_NAME = "KNX Fan" @@ -586,7 +586,7 @@ class FanSchema(KNXPlatformSchema): 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(CONF_MAX_STEP): cv.byte, + vol.Optional(FanConf.MAX_STEP): cv.byte, vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, } ) diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index eac1dde1f10b4..76ffc6e0c7c68 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -17,6 +17,8 @@ CONF_GA_DATETIME: Final = "ga_datetime" CONF_GA_TIME: Final = "ga_time" +CONF_GA_STEP: Final = "ga_step" + # Climate CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current" CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current" @@ -42,11 +44,15 @@ # Cover CONF_GA_UP_DOWN: Final = "ga_up_down" CONF_GA_STOP: Final = "ga_stop" -CONF_GA_STEP: Final = "ga_step" CONF_GA_POSITION_SET: Final = "ga_position_set" CONF_GA_POSITION_STATE: Final = "ga_position_state" CONF_GA_ANGLE: Final = "ga_angle" +# Fan +CONF_SPEED: Final = "speed" +CONF_GA_SPEED: Final = "ga_speed" +CONF_GA_OSCILLATION: Final = "ga_oscillation" + # Light CONF_COLOR_TEMP_MIN: Final = "color_temp_min" CONF_COLOR_TEMP_MAX: Final = "color_temp_max" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 7b742b63b598a..24ae93b488b12 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -28,6 +28,7 @@ ClimateConf, ColorTempModes, CoverConf, + FanConf, FanZeroMode, ) from .const import ( @@ -62,6 +63,7 @@ CONF_GA_OP_MODE_PROTECTION, CONF_GA_OP_MODE_STANDBY, CONF_GA_OPERATION_MODE, + CONF_GA_OSCILLATION, CONF_GA_POSITION_SET, CONF_GA_POSITION_STATE, CONF_GA_RED_BRIGHTNESS, @@ -69,6 +71,7 @@ CONF_GA_SATURATION, CONF_GA_SENSOR, CONF_GA_SETPOINT_SHIFT, + CONF_GA_SPEED, CONF_GA_STEP, CONF_GA_STOP, CONF_GA_SWITCH, @@ -80,6 +83,7 @@ CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, CONF_IGNORE_AUTO_MODE, + CONF_SPEED, CONF_TARGET_TEMPERATURE, ) from .knx_selector import ( @@ -220,6 +224,42 @@ } ) +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" + ), + }, + ), + 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, + ), + vol.Optional(CONF_GA_OSCILLATION): GASelector( + write_required=True, valid_dpt="1" + ), + vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), + } +) + @unique class LightColorMode(StrEnum): @@ -513,6 +553,7 @@ class ConfClimateFanSpeedMode(StrEnum): Platform.COVER: COVER_KNX_SCHEMA, Platform.DATE: DATE_KNX_SCHEMA, Platform.DATETIME: DATETIME_KNX_SCHEMA, + Platform.FAN: FAN_KNX_SCHEMA, Platform.LIGHT: LIGHT_KNX_SCHEMA, Platform.SWITCH: SWITCH_KNX_SCHEMA, Platform.TIME: TIME_KNX_SCHEMA, diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 3d5e88420a7cb..ad910d0659a24 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -460,6 +460,41 @@ } } }, + "fan": { + "description": "The KNX fan platform is used as an interface to fan actuators.", + "knx": { + "ga_oscillation": { + "description": "Toggle oscillation of the fan.", + "label": "Oscillation" + }, + "speed": { + "description": "Control the speed of the fan.", + "ga_speed": { + "description": "Group address to control the current speed of the fan as a percentage value.", + "label": "Speed" + }, + "ga_step": { + "description": "Group address to control the current speed step.", + "label": "Step" + }, + "max_step": { + "description": "Number of discrete fan speed steps (Off excluded).", + "label": "Fan steps" + }, + "options": { + "percentage_mode": { + "description": "Set the fan speed as a percentage value (0-100%).", + "label": "Percentage" + }, + "step_mode": { + "description": "Set the fan speed in discrete steps.", + "label": "Steps" + } + }, + "title": "Fan speed" + } + } + }, "header": "Create new entity", "light": { "description": "The KNX light platform is used as an interface to dimming actuators, LED controllers, DALI gateways and similar.", diff --git a/tests/components/knx/fixtures/config_store_fan.json b/tests/components/knx/fixtures/config_store_fan.json new file mode 100644 index 0000000000000..2110ec7f9816f --- /dev/null +++ b/tests/components/knx/fixtures/config_store_fan.json @@ -0,0 +1,51 @@ +{ + "version": 2, + "minor_version": 2, + "key": "knx/config_store.json", + "data": { + "entities": { + "fan": { + "knx_es_01KCK9VB3YE1DZ7X4GDHB8BS05": { + "entity": { + "name": "test_step_oscillate", + "device_info": null, + "entity_category": null + }, + "knx": { + "speed": { + "ga_step": { + "write": "1/1/1", + "state": "1/1/0", + "passive": [] + }, + "max_step": 4.0 + }, + "sync_state": true, + "ga_oscillation": { + "write": "1/2/1", + "state": "1/2/0", + "passive": [] + } + } + }, + "knx_es_01KCK9XHXYBG6AP3CNXV4QX2FW": { + "entity": { + "name": "test_percent", + "device_info": null, + "entity_category": null + }, + "knx": { + "speed": { + "ga_speed": { + "write": "2/2/2", + "state": "2/2/0", + "passive": [] + } + }, + "sync_state": true + } + } + } + } + } +} diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index 1896c958877d1..debcdfffb2015 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -1033,6 +1033,118 @@ 'type': 'result', }) # --- +# name: test_knx_get_schema[fan] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'collapsible': False, + 'name': 'speed', + 'required': True, + 'schema': list([ + dict({ + 'schema': list([ + dict({ + 'name': 'ga_speed', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + ]), + 'translation_key': 'percentage_mode', + 'type': 'knx_group_select_option', + }), + dict({ + 'schema': list([ + dict({ + 'name': 'ga_step', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 10, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'default': 3, + 'name': 'max_step', + 'required': True, + 'selector': dict({ + 'number': dict({ + 'max': 100.0, + 'min': 1.0, + 'mode': 'box', + 'step': 1.0, + }), + }), + 'type': 'ha_selector', + }), + ]), + 'translation_key': 'step_mode', + 'type': 'knx_group_select_option', + }), + ]), + 'type': 'knx_group_select', + }), + dict({ + 'name': 'ga_oscillation', + '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({ + 'allow_false': False, + 'default': True, + 'name': 'sync_state', + 'optional': True, + 'required': False, + 'type': 'knx_sync_state', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- # name: test_knx_get_schema[light] dict({ 'id': 1, diff --git a/tests/components/knx/test_fan.py b/tests/components/knx/test_fan.py index 39cb851af51b8..a97214d55cf18 100644 --- a/tests/components/knx/test_fan.py +++ b/tests/components/knx/test_fan.py @@ -1,10 +1,15 @@ """Test KNX fan.""" -from homeassistant.components.knx.const import KNX_ADDRESS +from typing import Any + +import pytest + +from homeassistant.components.knx.const import KNX_ADDRESS, FanConf from homeassistant.components.knx.schema import FanSchema -from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant +from . import KnxEntityGenerator from .conftest import KNXTestKit @@ -59,7 +64,7 @@ async def test_fan_step(hass: HomeAssistant, knx: KNXTestKit) -> None: FanSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: "1/2/3", - FanSchema.CONF_MAX_STEP: 4, + FanConf.MAX_STEP: 4, } } ) @@ -143,3 +148,70 @@ async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.receive_write("2/2/2", False) state = hass.states.get("fan.test") assert state.attributes.get("oscillating") is False + + +@pytest.mark.parametrize( + ("knx_data", "expected_read_response", "expected_state"), + [ + ( + { + "speed": { + "ga_speed": {"write": "1/1/0", "state": "1/1/1"}, + }, + "ga_oscillation": {"write": "2/2/0", "state": "2/2/2"}, + "sync_state": True, + }, + [("1/1/1", (0x55,)), ("2/2/2", True)], + {"state": STATE_ON, "percentage": 33, "oscillating": True}, + ), + ( + { + "speed": { + "ga_step": {"write": "1/1/0", "state": "1/1/1"}, + "max_step": 3, + }, + "sync_state": True, + }, + [("1/1/1", (2,))], + {"state": STATE_ON, "percentage": 66}, + ), + ], +) +async def test_fan_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + knx_data: dict[str, Any], + expected_read_response: list[tuple[str, int | tuple[int, ...]]], + expected_state: dict[str, Any], +) -> None: + """Test creating a fan.""" + await knx.setup_integration() + await create_ui_entity( + platform=Platform.FAN, + entity_data={"name": "test"}, + knx_data=knx_data, + ) + for address, response in expected_read_response: + await knx.assert_read(address, response=response) + knx.assert_state("fan.test", **expected_state) + + +async def test_fan_ui_load(knx: KNXTestKit) -> None: + """Test loading a fan from storage.""" + await knx.setup_integration(config_store_fixture="config_store_fan.json") + + await knx.assert_read("1/1/0", response=(2,), ignore_order=True) # speed step + await knx.assert_read("1/2/0", response=True, ignore_order=True) # oscillation + await knx.assert_read("2/2/0", response=(0xFF,), ignore_order=True) # speed percent + knx.assert_state( + "fan.test_step_oscillate", + STATE_ON, + percentage=50, + oscillating=True, + ) + knx.assert_state( + "fan.test_percent", + STATE_ON, + percentage=100, + )