diff --git a/docs/changelog.md b/docs/changelog.md index 4dcb3022f..36f4c2a78 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,10 @@ nav_order: 2 # Changelog +### Features + +- Added fan speed support to climate + # 3.1.1 Fix Eberle status 2024-08-19 ### Bugfixes diff --git a/docs/climate.md b/docs/climate.md index d43592ea9..97cbd7d8b 100644 --- a/docs/climate.md +++ b/docs/climate.md @@ -20,6 +20,8 @@ Climate are representations of KNX HVAC/Climate controls. - `group_address_target_temperature_state` KNX address for reading the target temperature from the KNX bus. Used in for setpoint_shift calculations as base temperature. *DPT 9.001* - `group_address_setpoint_shift` KNX address to set setpoint_shift (base temperature deviation). *DPT 6.010* or *DPT 9.002* - `group_address_setpoint_shift_state` KNX address to read current setpoint_shift. *DPT 6.010* or *DPT 9.002* +- `group_address_fan_speed` KNX address for the fan speed. *DPT 5.001 / 5.010* +- `group_address_fan_speed_state` KNX address for reading fan speed. *DPT 5.001 / 5.010* - `setpoint_shift_mode` SetpointShiftMode Enum for setpoint_shift payload encoding. When `None` it is inferred from first incoming payload. Default: `None` - `setpoint_shift_max` Maximum value for setpoint_shift. - `setpoint_shift_min` Minimum value for setpoint_shift. @@ -32,6 +34,7 @@ Climate are representations of KNX HVAC/Climate controls. - `sync_state` defines if and how often the value should be actively read from the bus. If `False` no GroupValueRead telegrams will be sent to its group address. Defaults to `True` - `max_temp` Maximum value for target temperature. - `min_temp` Minimum value for target temperature. +- `fan_max_step` Maximum step amount for fans which are controlled with steps and not percentage. If this attribute is set, the fan is controlled by sending the step value in the range `0` and `max_step`. In that case, the group address DPT changes from *DPT 5.001* to *DPT 5.010*. Default: None. - `mode` ClimateMode instance for this climate device - `group_address_operation_mode` KNX address for operation mode. *DPT 20.102* - `group_address_operation_mode_state` KNX address for operation mode status. *DPT 20.102* @@ -78,6 +81,8 @@ climate = Climate( group_address_target_temperature_state='', group_address_setpoint_shift='', group_address_setpoint_shift_state='', + group_address_fan_speed=None, + group_address_fan_speed_state=None, temperature_step=0.1, setpoint_shift_max=6, setpoint_shift_min=-6, @@ -88,6 +93,7 @@ climate = Climate( max_temp=26, mode=climate_mode, device_updated_cb=None, + fan_max_step=None, ) xknx.devices.async_add(climate) xknx.devices.async_add(climate_mode) diff --git a/test/devices_tests/climate_test.py b/test/devices_tests/climate_test.py index f4132c034..d6f5d8cab 100644 --- a/test/devices_tests/climate_test.py +++ b/test/devices_tests/climate_test.py @@ -1562,3 +1562,77 @@ async def test_is_active(self): # only command initialized climate_active_command.active.value = None assert climate_active_command.is_active is False + + async def test_fan_speed(self): + """Test fan speed functionality.""" + xknx = XKNX() + climate_step = Climate( + xknx, + name="TestClimate", + group_address_fan_speed="1/2/3", + group_address_fan_speed_state="1/2/4", + fan_max_step=3, + ) + xknx.devices.async_add(climate_step) + climate_percent = Climate( + xknx, + name="TestClimate", + group_address_fan_speed="1/2/5", + group_address_fan_speed_state="1/2/6", + ) + xknx.devices.async_add(climate_percent) + + # Test initial state + assert climate_step.current_fan_speed is None + assert climate_percent.current_fan_speed is None + + xknx.devices.process( + Telegram( + destination_address=GroupAddress("1/2/3"), + payload=GroupValueWrite(DPTArray(2)), + ) + ) + assert climate_step.current_fan_speed == 2 + + # 140 is 55% as byte (0...255) + xknx.devices.process( + Telegram( + destination_address=GroupAddress("1/2/5"), + payload=GroupValueWrite(DPTArray(140)), + ) + ) + assert climate_percent.current_fan_speed == 55 + + await climate_step.set_fan_speed(3) + assert xknx.telegrams.qsize() == 1 + telegram = xknx.telegrams.get_nowait() + assert telegram == Telegram( + destination_address=GroupAddress("1/2/3"), + payload=GroupValueWrite(DPTArray(3)), + ) + + await climate_percent.set_fan_speed(45) + assert xknx.telegrams.qsize() == 1 + telegram = xknx.telegrams.get_nowait() + # 115 is 45% as byte (0...255) + assert telegram == Telegram( + destination_address=GroupAddress("1/2/5"), + payload=GroupValueWrite(DPTArray(115)), + ) + + xknx.devices.process( + Telegram( + destination_address=GroupAddress("1/2/4"), + payload=GroupValueWrite(DPTArray(2)), + ) + ) + assert climate_step.current_fan_speed == 2 + + # 140 is 55% as byte (0...255) + xknx.devices.process( + Telegram( + destination_address=GroupAddress("1/2/6"), + payload=GroupValueWrite(DPTArray(140)), + ) + ) + assert climate_percent.current_fan_speed == 55 diff --git a/test/str_test.py b/test/str_test.py index 9128a1734..53150e4a9 100644 --- a/test/str_test.py +++ b/test/str_test.py @@ -131,6 +131,8 @@ def test_climate(self): setpoint_shift_min=-20, group_address_on_off="1/2/14", group_address_on_off_state="1/2/15", + group_address_fan_speed="1/2/16", + group_address_fan_speed_state="1/2/17", ) assert ( str(climate) == ' " 'setpoint_shift_max="20" setpoint_shift_min="-20" ' - "group_address_on_off=<1/2/14, 1/2/15, [], None /> />" + "group_address_on_off=<1/2/14, 1/2/15, [], None /> " + "group_address_fan_speed=<1/2/16, 1/2/17, [], None /> />" ) def test_climate_mode(self): diff --git a/xknx/devices/climate.py b/xknx/devices/climate.py index e9cac578e..48ec3350a 100644 --- a/xknx/devices/climate.py +++ b/xknx/devices/climate.py @@ -11,9 +11,11 @@ import logging from typing import TYPE_CHECKING, Any +from xknx.devices.fan import FanSpeedMode from xknx.remote_value import ( GroupAddressesType, RemoteValue, + RemoteValueDptValue1Ucount, RemoteValueScaling, RemoteValueSetpointShift, RemoteValueSwitch, @@ -63,6 +65,9 @@ def __init__( max_temp: float | None = None, mode: ClimateMode | None = None, device_updated_cb: DeviceCallbackType[Climate] | None = None, + group_address_fan_speed: GroupAddressesType = None, + group_address_fan_speed_state: GroupAddressesType = None, + fan_max_step: int | None = None, ): """Initialize Climate class.""" super().__init__(xknx, name, device_updated_cb) @@ -135,6 +140,33 @@ def __init__( after_update_cb=self.after_update, ) + self.fan_speed: RemoteValueDptValue1Ucount | RemoteValueScaling + self.fan_mode = FanSpeedMode.STEP if fan_max_step else FanSpeedMode.PERCENT + self.fan_max_step = fan_max_step + + if self.fan_mode == FanSpeedMode.STEP: + self.fan_speed = RemoteValueDptValue1Ucount( + xknx, + group_address_fan_speed, + group_address_fan_speed_state, + sync_state=sync_state, + device_name=self.name, + feature_name="Fan Speed", + after_update_cb=self.after_update, + ) + else: + self.fan_speed = RemoteValueScaling( + xknx, + group_address_fan_speed, + group_address_fan_speed_state, + sync_state=sync_state, + device_name=self.name, + feature_name="Fan Speed", + after_update_cb=self.after_update, + range_from=0, + range_to=100, + ) + self.mode = mode def _iter_remote_values(self) -> Iterator[RemoteValue[Any]]: @@ -145,6 +177,7 @@ def _iter_remote_values(self) -> Iterator[RemoteValue[Any]]: yield self.on yield self.active yield self.command_value + yield self.fan_speed def has_group_address(self, group_address: DeviceGroupAddress) -> bool: """Test if device has given group address.""" @@ -197,6 +230,10 @@ async def set_target_temperature(self, target_temperature: float) -> None: ) self.target_temperature.set(validated_temp) + async def set_fan_speed(self, speed: int) -> None: + """Set the fan to a designated speed.""" + self.fan_speed.set(speed) + @property def base_temperature(self) -> float | None: """ @@ -262,6 +299,11 @@ def target_temperature_min(self) -> float | None: return self.base_temperature + self.setpoint_shift_min return None + @property + def current_fan_speed(self) -> int | None: + """Return current speed of fan.""" + return self.fan_speed.value + def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" for remote_value in self._iter_remote_values(): @@ -287,5 +329,6 @@ def __str__(self) -> str: f'setpoint_shift_max="{self.setpoint_shift_max}" ' f'setpoint_shift_min="{self.setpoint_shift_min}" ' f"group_address_on_off={self.on.group_addr_str()} " + f"group_address_fan_speed={self.fan_speed.group_addr_str()} " "/>" )