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 swing and horizontal swing to climate device
<!--
  You are awesome! Thanks for contributing to our project!
  Please, DO NOT DELETE ANY TEXT from this template!.
-->
## Description
Add swing and horizontal swing 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/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c704cb61..df83b218 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -8,7 +8,7 @@ repos:
           - tomli
         exclude_types: [csv, json]
   - repo: https://github.com/charliermarsh/ruff-pre-commit
-    rev: 'v0.8.3'
+    rev: 'v0.9.1'
     hooks:
       - id: ruff
         # in CI it is directly run by tox to allow dependency upgrade checks
diff --git a/docs/changelog.md b/docs/changelog.md
index 1995a8c8..adeed772 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -6,6 +6,10 @@ nav_order: 2
 
 # Changelog
 
+### Features
+
+- Added swing and horizontal swing support to climate device
+
 # 3.4.0 8 byte energy and 4 byte pressure 2024-11-20
 
 ### Devices
diff --git a/docs/climate.md b/docs/climate.md
index 6a4655bc..c88a72c6 100644
--- a/docs/climate.md
+++ b/docs/climate.md
@@ -22,6 +22,10 @@ Climate are representations of KNX HVAC/Climate controls.
 - `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*
+- `group_address_swing` KNX address for fan swing. If `group_address_horizontal_swing` is present this should refer to vertical swing *DPT 1*
+- `group_address_swing_state` KNX address for reading fan swing. If `group_address_horizontal_swing` is present this should refer to vertical swing *DPT 1*
+- `group_address_horizontal_swing` KNX address for horizontal fan swing. *DPT 1*
+- `group_address_horizontal_swing_state` KNX address for reading horizontal fan swing. *DPT 1*
 - `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.
@@ -84,6 +88,10 @@ climate = Climate(
     group_address_setpoint_shift_state='',
     group_address_fan_speed=None,
     group_address_fan_speed_state=None,
+    group_address_swing=None,
+    group_address_swing_sate=None,
+    group_address_horizontal_swing=None,
+    group_address_horizontal_swing_state=None,
     temperature_step=0.1,
     setpoint_shift_max=6,
     setpoint_shift_min=-6,
diff --git a/examples/ip_secure_calculations.py b/examples/ip_secure_calculations.py
index 8f974309..a4db5abb 100644
--- a/examples/ip_secure_calculations.py
+++ b/examples/ip_secure_calculations.py
@@ -268,7 +268,7 @@ def main() -> None:
     )
     session_authenticate = bytes.fromhex(
         "06 10 09 53 00 18 00 01"
-        "1f 1d 59 ea 9f 12 a1 52 e5 d9 72 7f 08 46 2c de"  # MAC
+        + "1f 1d 59 ea 9f 12 a1 52 e5 d9 72 7f 08 46 2c de"  # MAC
     )
     mac_cbc_authenticate = calculate_message_authentication_code_cbc(
         password_hash,
diff --git a/pyproject.toml b/pyproject.toml
index 73f8a269..986364cb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -165,3 +165,6 @@ combine-as-imports = true
   "RUF012",
   "SLF",    # private member access
 ] # Mutable class attributes should be annotated with `typing.ClassVar`
+
+[tool.ruff.lint.flake8-builtins]
+builtins-allowed-modules = ["datetime"]
diff --git a/requirements/production.txt b/requirements/production.txt
index 7868b9cf..ae23b3c6 100644
--- a/requirements/production.txt
+++ b/requirements/production.txt
@@ -1,4 +1,4 @@
-async-timeout==4.0.2;python_version<"3.11"
+async-timeout==5.0.1;python_version<"3.11"
 typing_extensions; python_version<"3.11"
 cryptography==44.0.0
 ifaddr==0.2.0
diff --git a/xknx/cemi/cemi_frame.py b/xknx/cemi/cemi_frame.py
index 0b38af6f..cb6ca888 100644
--- a/xknx/cemi/cemi_frame.py
+++ b/xknx/cemi/cemi_frame.py
@@ -478,11 +478,7 @@ class CEMIMPropWriteRequest(CEMIData):
 
     def __repr__(self) -> str:
         """Return object as readable string."""
-        return (
-            f"CEMIMPropWriteRequest("
-            f"{self.property_info}"
-            f'data="{self.data.hex()}" )'
-        )
+        return f'CEMIMPropWriteRequest({self.property_info}data="{self.data.hex()}" )'
 
 
 class CEMIMPropWriteResponse(CEMIData):
diff --git a/xknx/devices/climate.py b/xknx/devices/climate.py
index aa57eb0c..bca25e0a 100644
--- a/xknx/devices/climate.py
+++ b/xknx/devices/climate.py
@@ -70,6 +70,10 @@ class Climate(Device):
         group_address_fan_speed_state: GroupAddressesType = None,
         fan_speed_mode: FanSpeedMode = FanSpeedMode.PERCENT,
         group_address_humidity_state: GroupAddressesType = None,
+        group_address_swing: GroupAddressesType = None,
+        group_address_swing_state: GroupAddressesType = None,
+        group_address_horizontal_swing: GroupAddressesType = None,
+        group_address_horizontal_swing_state: GroupAddressesType = None,
     ):
         """Initialize Climate class."""
         super().__init__(xknx, name, device_updated_cb)
@@ -168,6 +172,24 @@ class Climate(Device):
                 range_to=100,
             )
 
+        self.swing = RemoteValueSwitch(
+            xknx,
+            group_address_swing,
+            group_address_swing_state,
+            sync_state=sync_state,
+            device_name=self.name,
+            after_update_cb=self.after_update,
+        )
+
+        self.horizontal_swing = RemoteValueSwitch(
+            xknx,
+            group_address_horizontal_swing,
+            group_address_horizontal_swing_state,
+            sync_state=sync_state,
+            device_name=self.name,
+            after_update_cb=self.after_update,
+        )
+
         self.mode = mode
 
         self.humidity = RemoteValueNumeric(
@@ -189,6 +211,8 @@ class Climate(Device):
         yield self.active
         yield self.command_value
         yield self.fan_speed
+        yield self.swing
+        yield self.horizontal_swing
         yield self.humidity
 
     def has_group_address(self, group_address: DeviceGroupAddress) -> bool:
@@ -246,6 +270,14 @@ class Climate(Device):
         """Set the fan to a designated speed."""
         self.fan_speed.set(speed)
 
+    async def set_swing(self, swing: bool) -> None:
+        """Set swing to the designated state."""
+        self.swing.set(swing)
+
+    async def set_horizontal_swing(self, horizontal_swing: bool) -> None:
+        """Set swing to the designated state."""
+        self.horizontal_swing.set(horizontal_swing)
+
     @property
     def base_temperature(self) -> float | None:
         """
@@ -316,6 +348,16 @@ class Climate(Device):
         """Return current speed of fan."""
         return self.fan_speed.value
 
+    @property
+    def current_swing(self) -> bool | None:
+        """Return current swing state."""
+        return self.swing.value
+
+    @property
+    def current_horizontal_swing(self) -> bool | None:
+        """Return current horizontal swing state."""
+        return self.horizontal_swing.value
+
     def process_group_write(self, telegram: Telegram) -> None:
         """Process incoming and outgoing GROUP WRITE telegram."""
         for remote_value in self._iter_remote_values():
@@ -342,5 +384,7 @@ class Climate(Device):
             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()} "
+            f"group_address_swing={self.swing.group_addr_str()} "
+            f"group_address_horizontal_swing={self.horizontal_swing.group_addr_str()} "
             "/>"
         )
diff --git a/xknx/knxip/connect_response.py b/xknx/knxip/connect_response.py
index 37f660bf..340eaaf4 100644
--- a/xknx/knxip/connect_response.py
+++ b/xknx/knxip/connect_response.py
@@ -141,8 +141,4 @@ class ConnectResponseData:
             if self._is_tunnel_crd() and self.individual_address
             else ""
         )
-        return (
-            "<ConnectResponseData "
-            f'request_type="{self.request_type}" '
-            f"{_address}/>"
-        )
+        return f'<ConnectResponseData request_type="{self.request_type}" {_address}/>'
diff --git a/xknx/knxip/description_response.py b/xknx/knxip/description_response.py
index 43feba86..a3d5c937 100644
--- a/xknx/knxip/description_response.py
+++ b/xknx/knxip/description_response.py
@@ -52,4 +52,4 @@ class DescriptionResponse(KNXIPBody):
     def __repr__(self) -> str:
         """Return object as readable string."""
         _dibs_str = ",\n".join(dib.__repr__() for dib in self.dibs)
-        return "<DescriptionResponse " f'dibs="[\n{_dibs_str}\n]" />'
+        return f'<DescriptionResponse dibs="[\n{_dibs_str}\n]" />'


Test patch:
diff --git a/requirements/testing.txt b/requirements/testing.txt
index 71d8adca..9c109460 100644
--- a/requirements/testing.txt
+++ b/requirements/testing.txt
@@ -7,7 +7,7 @@ pytest==8.3.4
 pytest-asyncio==0.25.2
 pytest-cov==6.0.0
 pytest-icdiff==0.9
-ruff==0.8.6
+ruff==0.9.2
 setuptools==75.8.0
 tox==4.23.2
 tox-gh-actions==3.2.0
diff --git a/test/devices_tests/climate_test.py b/test/devices_tests/climate_test.py
index 8a2dddee..6669b010 100644
--- a/test/devices_tests/climate_test.py
+++ b/test/devices_tests/climate_test.py
@@ -1665,3 +1665,63 @@ class TestClimate:
             )
         )
         assert climate_percent.current_fan_speed == 55
+
+    async def test_swing(self):
+        """Test fan speed functionality."""
+        xknx = XKNX()
+        climate_swing = Climate(
+            xknx,
+            name="TestClimate",
+            group_address_swing="1/2/3",
+            group_address_swing_state="1/2/4",
+        )
+        xknx.devices.async_add(climate_swing)
+
+        # Test initial state
+        assert climate_swing.current_swing is None
+
+        xknx.devices.process(
+            Telegram(
+                destination_address=GroupAddress("1/2/3"),
+                payload=GroupValueWrite(DPTBinary(True)),
+            )
+        )
+        assert climate_swing.current_swing is True
+
+        await climate_swing.set_swing(False)
+        assert xknx.telegrams.qsize() == 1
+        telegram = xknx.telegrams.get_nowait()
+        assert telegram == Telegram(
+            destination_address=GroupAddress("1/2/3"),
+            payload=GroupValueWrite(DPTBinary(False)),
+        )
+
+    async def test_horizontal_swing(self):
+        """Test fan speed functionality."""
+        xknx = XKNX()
+        climate_horizontal_swing = Climate(
+            xknx,
+            name="TestClimate",
+            group_address_horizontal_swing="1/2/3",
+            group_address_horizontal_swing_state="1/2/4",
+        )
+        xknx.devices.async_add(climate_horizontal_swing)
+
+        # Test initial state
+        assert climate_horizontal_swing.current_horizontal_swing is None
+
+        xknx.devices.process(
+            Telegram(
+                destination_address=GroupAddress("1/2/3"),
+                payload=GroupValueWrite(DPTBinary(True)),
+            )
+        )
+        assert climate_horizontal_swing.current_horizontal_swing is True
+
+        await climate_horizontal_swing.set_horizontal_swing(False)
+        assert xknx.telegrams.qsize() == 1
+        telegram = xknx.telegrams.get_nowait()
+        assert telegram == Telegram(
+            destination_address=GroupAddress("1/2/3"),
+            payload=GroupValueWrite(DPTBinary(False)),
+        )
diff --git a/test/knxip_tests/connect_request_test.py b/test/knxip_tests/connect_request_test.py
index 201d4a76..536e97b1 100644
--- a/test/knxip_tests/connect_request_test.py
+++ b/test/knxip_tests/connect_request_test.py
@@ -126,7 +126,8 @@ class TestKNXIPConnectRequest:
     def test_from_knx_wrong_length_of_cri2(self):
         """Test parsing and streaming wrong ConnectRequest."""
         raw = bytes.fromhex(
-            "06 10 02 05 00 18 08 01 C0 A8 2A 01 84 95 08 01 C0 A8 2A 01 CC A9 03 03"
+            "06 10 02 05 00 18 08 01 C0 A8 2A 01 84 95 08 01"
+            + "C0 A8 2A 01 CC A9 03 03"
         )
         with pytest.raises(CouldNotParseKNXIP):
             KNXIPFrame.from_knx(raw)
@@ -134,7 +135,8 @@ class TestKNXIPConnectRequest:
     def test_from_knx_wrong_length_of_cri3(self):
         """Test parsing and streaming wrong ConnectRequest."""
         raw = bytes.fromhex(
-            "06 10 02 05 00 18 08 01 C0 A8 2A 01 84 95 08 01 C0 A8 2A 01 CC A9 01 03"
+            "06 10 02 05 00 18 08 01 C0 A8 2A 01 84 95 08 01"
+            + "C0 A8 2A 01 CC A9 01 03"
         )
         with pytest.raises(CouldNotParseKNXIP):
             KNXIPFrame.from_knx(raw)
@@ -142,7 +144,8 @@ class TestKNXIPConnectRequest:
     def test_from_knx_wrong_length_of_cri4(self):
         """Test parsing and streaming wrong ConnectRequest."""
         raw = bytes.fromhex(
-            "06 10 02 05 00 19 08 01 C0 A8 2A 01 84 95 08 01 C0 A8 2A 01 CC A9 03 03 01"
+            "06 10 02 05 00 19 08 01 C0 A8 2A 01 84 95 08 01"
+            + "C0 A8 2A 01 CC A9 03 03 01"
         )
         with pytest.raises(CouldNotParseKNXIP):
             KNXIPFrame.from_knx(raw)
@@ -151,7 +154,7 @@ class TestKNXIPConnectRequest:
         """Test parsing and streaming wrong ConnectRequest."""
         raw = bytes.fromhex(
             "06 10 02 05 00 1A 08 01 C0 A8 2A 01 84 95 08 01"
-            "C0 A8 2A 01 CC A9 04 04 02"
+            + "C0 A8 2A 01 CC A9 04 04 02"
         )
         with pytest.raises(CouldNotParseKNXIP):
             KNXIPFrame.from_knx(raw)
diff --git a/test/knxip_tests/session_authenticate_test.py b/test/knxip_tests/session_authenticate_test.py
index 302664f2..03b82012 100644
--- a/test/knxip_tests/session_authenticate_test.py
+++ b/test/knxip_tests/session_authenticate_test.py
@@ -13,7 +13,7 @@ class TestKNXIPSessionAuthenticate:
             "e5 d9 72 7f 08 46 2c de"
         )
         raw = (
-            bytes.fromhex("06 10 09 53 00 18" "00 01")  # KNXnet/IP header  # User ID
+            bytes.fromhex("06 10 09 53 00 18 00 01")  # KNXnet/IP header  # User ID
             + message_authentication_code
         )
         knxipframe, _ = KNXIPFrame.from_knx(raw)
diff --git a/test/knxip_tests/timer_notify_test.py b/test/knxip_tests/timer_notify_test.py
index 355e4add..bf2da13a 100644
--- a/test/knxip_tests/timer_notify_test.py
+++ b/test/knxip_tests/timer_notify_test.py
@@ -9,7 +9,7 @@ class TestKNXIPTimerNotify:
     def test_timer_notify(self):
         """Test parsing and streaming TimerNotify KNX/IP packet."""
         message_authentication_code = bytes.fromhex(
-            "72 12 a0 3a aa e4 9d a8" "56 89 77 4c 1d 2b 4d a4"
+            "72 12 a0 3a aa e4 9d a8 56 89 77 4c 1d 2b 4d a4"
         )
         raw = (
             bytes.fromhex(
diff --git a/test/str_test.py b/test/str_test.py
index 2bb0fb29..739b7175 100644
--- a/test/str_test.py
+++ b/test/str_test.py
@@ -133,6 +133,10 @@ class TestStringRepresentations:
             group_address_on_off_state="1/2/15",
             group_address_fan_speed="1/2/16",
             group_address_fan_speed_state="1/2/17",
+            group_address_swing="1/2/18",
+            group_address_swing_state="1/2/19",
+            group_address_horizontal_swing="1/2/20",
+            group_address_horizontal_swing_state="1/2/21",
         )
         assert (
             str(climate) == '<Climate name="Wohnzimmer" '
@@ -142,7 +146,9 @@ class TestStringRepresentations:
             "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_fan_speed=<1/2/16, 1/2/17, [], None /> />"
+            "group_address_fan_speed=<1/2/16, 1/2/17, [], None /> "
+            "group_address_swing=<1/2/18, 1/2/19, [], None /> "
+            "group_address_horizontal_swing=<1/2/20, 1/2/21, [], None /> />"
         )
 
     def test_climate_mode(self):