Repository: XKNX/xknx (python)

Issue:
Feature request: Add Fan mode and Swing mode to climate KNX devices
Hi All,
Would it be possible to add Fan Mode and Swing Mode to KNX Climate devices? It is supported by Home Assistant in other Climate integrations (e.g. Sensibo Climate). If needed I can help with testing, I have a KNX modules for Mitsubishi A/C units which enable to control all features of the A/C unit including Fan modes and Swing modes. If needed I can also share which DPTs are used to control those modes.

Pull request description:
add support for fan_speed on climate device
<!--
  You are awesome! Thanks for contributing to our project!
  Please, DO NOT DELETE ANY TEXT from this template!.
-->
## Description
<!--
Please include a summary of the change and which issue is fixed.
Please also include relevant motivation and context.
List any dependencies that are required for this change.
-->

Add fan speed support to climate device. 

Fixes #466

## Type of change
<!--
Please tick the applicable options.
NOTE: Ticking multiple options most likely indicates
that your change is to big and it is suggested to split it into several smaller PRs.
-->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [X] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Code quality improvements to existing code or addition of tests

## Checklist

- [X] The documentation has been adjusted accordingly
- [X] Tests have been added that prove the fix is effective or that the feature works
- [X] The changes are documented in the changelog (docs/changelog.md)

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 4dcb3022..36f4c2a7 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 d43592ea..97cbd7d8 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/xknx/devices/climate.py b/xknx/devices/climate.py
index e9cac578..48ec3350 100644
--- a/xknx/devices/climate.py
+++ b/xknx/devices/climate.py
@@ -11,9 +11,11 @@ from collections.abc import Iterator
 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 @@ class Climate(Device):
         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 @@ class Climate(Device):
             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 @@ class Climate(Device):
         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 @@ class Climate(Device):
             )
             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 @@ class Climate(Device):
             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 @@ class Climate(Device):
             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()} "
             "/>"
         )


Test patch:
diff --git a/test/devices_tests/climate_test.py b/test/devices_tests/climate_test.py
index f4132c03..d6f5d8ca 100644
--- a/test/devices_tests/climate_test.py
+++ b/test/devices_tests/climate_test.py
@@ -1562,3 +1562,77 @@ class TestClimate:
         # 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 9128a173..53150e4a 100644
--- a/test/str_test.py
+++ b/test/str_test.py
@@ -131,6 +131,8 @@ class TestStringRepresentations:
             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) == '<Climate name="Wohnzimmer" '
@@ -139,7 +141,8 @@ class TestStringRepresentations:
             'temperature_step="0.1" '
             "setpoint_shift=<1/2/3, 1/2/4, [], None /> "
             '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):