Repository: XKNX/xknx

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.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/XKNX/xknx/pull/1572?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 96.92%. Comparing base [(`fe974e3`)](https://app.codecov.io/gh/XKNX/xknx/commit/fe974e3f2060c4bc248780ed8b4e1ff8c2375508?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) to head [(`6cf8244`)](https://app.codecov.io/gh/XKNX/xknx/commit/6cf82440525f5cd815a3d55d6b02291331da13a1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX).

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/XKNX/xknx/pull/1572/graphs/tree.svg?width=650&height=150&src=pr&token=irWbIygS84&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)](https://app.codecov.io/gh/XKNX/xknx/pull/1572?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)

```diff
@@           Coverage Diff           @@
##             main    #1572   +/-   ##
=======================================
  Coverage   96.92%   96.92%           
=======================================
  Files         157      157           
  Lines       10557    10570   +13     
=======================================
+ Hits        10232    10245   +13     
  Misses        325      325           
```

| [Files with missing lines](https://app.codecov.io/gh/XKNX/xknx/pull/1572?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/devices/climate.py](https://app.codecov.io/gh/XKNX/xknx/pull/1572?src=pr&el=tree&filepath=xknx%2Fdevices%2Fclimate.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kZXZpY2VzL2NsaW1hdGUucHk=) | `100.00% <100.00%> (ø)` | |

</details>
farmio: Thank you very much! 👍

Do you plan to add this to the HA integration too?

I wonder if it would make sense to create a new device (from HA or in xknx.devices.Climate - like done for ClimateMode) to keep the climate module scope more clean -> 
Climate -> temperatures (and on/off 😬)
ClimateMode -> modes and presets
ClimateFan -> fan speed, fan modes, swing modes and whatever else a climate fan does... (maybe even xknx.devices.Fan can be reused for this) https://developers.home-assistant.io/docs/core/entity/climate
What do you think?
somdoron: I do plan to add this to Home Assistant. I don't have a strong opinion on how to integrate it, but from Home Assistant's perspective, fan speed is part of the climate device, so it makes sense to handle it the same way here. Additionally, the "switch" for the fan speed is actually the climate switch, so I wouldn't use the fan device for controlling the climate fan speed.

As for using a different class for ClimateFan, I think it would be relatively thin compared to ClimateMode, but I'm fine with moving it to its own class.
farmio: > I do plan to add this to Home Assistant.

Nice 👍 let me know if any questions arise.

>  Additionally, the "switch" for the fan speed is actually the climate switch

Yes sure, but afaik the "switch" of the xknx Fan device is optional. I don't have a strong opinion about using this vs. a new class vs. putting it in xknx Climate (and I personally don't own any fans 🙃 whatsoever)

> I think it would be relatively thin compared to ClimateMode

... which is probably a good thing 😛 Was just looking at #466 and thought there could be more to come regarding vane stuff. But could of course always be refactored later on if needed.

Looking at HA climate entity documentation, it seems they don't support percentage fan speeds, but only modes like `[LOW, MEDIUM, HIGH]` (or custom). Do you already have plans on how to integrate that?

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):