Repository: XKNX/xknx
Pull request #594 (2021-02-04 20:48:59)

Add oscillation to fan
<!--
  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.
-->
HA supports turning the oscillation of the fan on or off. https://developers.home-assistant.io/docs/core/entity/fan/
Furthermore in #466 there was the request to control the oscillation via switch DPT 1.001

Fixes # (issue)

## 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)
- [x] This change requires a documentation update

## Checklist:

- [x] I have performed a self-review of my own code
- [x] The documentation has been adjusted accordingly
- [x] The changes generate no new warnings
- [x] Tests have been added that prove the fix is effective or that the feature works
- [x] The changes are documented in the changelog
- [ ] The Homeassistant plugin has been adjusted in case of new config options


coveralls: 
[![Coverage Status](https://coveralls.io/builds/36879024/badge)](https://coveralls.io/builds/36879024)

Coverage increased (+0.01%) to 94.871% when pulling **b0986efb88ce66f9ae163a894614f0c738601da0 on crazyfx1:feat/fan_oscillate** into **aa093838dea3db033a29137b5be59131d11eb3bb on XKNX:master**.


Patch:
diff --git a/changelog.md b/changelog.md
index 7aff5797..e9eed605 100644
--- a/changelog.md
+++ b/changelog.md
@@ -5,6 +5,7 @@
 ### Devices
 
 - Fan: Add `max_step` attribute which defines the maximum amount of steps. If set, the fan is controlled by steps instead of percentage.
+- Fan: Add `group_address_oscillation` and `group_address_oscillation_state` attributes to control the oscillation of a fan.
 
 ## 0.16.2 Bugfix for yaml loader 2021-01-24
 
diff --git a/docs/fan.md b/docs/fan.md
index 402ffe2f..aa4f216a 100644
--- a/docs/fan.md
+++ b/docs/fan.md
@@ -9,16 +9,18 @@ nav_order: 4
 
 ## [](#header-2)Overview
 
-Fans are simple representations of KNX controlled fans. They support setting the speed.
+Fans are simple representations of KNX controlled fans. They support setting the speed and the oscillation.
 
 ## [](#header-2)Interface
 
 - `xknx` XKNX object.
 - `name` name of the device.
-- `group_address` is the KNX group address of the fan device. Used for sending.
-- `group_address_state` is the KNX group address of the fan state. Used for updating and reading state.
+- `group_address` is the KNX group address of the fan speed. Used for sending. *DPT 5.001 / 5.010*
+- `group_address_state` is the KNX group address of the fan speed state. Used for updating and reading state. *DPT 5.001 / 5.010*
+- `group_address_oscillation` is the KNX group address of the oscillation. Used for sending. *DPT 1.001*
+- `group_address_oscillation_state` is the KNX group address of the fan oscillation state. Used for updating and reading state. *DPT 1.001*
 - `device_updated_cb` awaitable callback for each update.
-- `max_step` Maximum step amount for fans which are controlled with steps and not percentage.
+- `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
 
 ## [](#header-2)Example
 
@@ -26,7 +28,9 @@ Fans are simple representations of KNX controlled fans. They support setting the
 fan = Fan(xknx,
               'TestFan',
               group_address='1/2/1',
-              group_address_state='1/2/2')
+              group_address_state='1/2/2',
+              group_address_oscillation='1/2/3',
+              group_address_oscillation_state='1/2/4')
 
 # Set the fan speed
 await fan.set_speed(50)
@@ -34,6 +38,12 @@ await fan.set_speed(50)
 # Accessing speed
 print(fan.current_speed)
 
+# Set the oscillation
+await fan.set_oscillation(True)
+
+# Accessing speed
+print(fan.current_oscillation)
+
 # Requesting state via KNX GroupValueRead
 await fan.sync()
 ```
@@ -45,5 +55,5 @@ Fans are usually configured via [`xknx.yaml`](/configuration):
 ```yaml
 groups:
     fan:
-        Livingroom.Fan_1: {group_address: '1/4/1', group_address_state: '1/4/2' }
+        Livingroom.Fan_1: { group_address: '1/4/1', group_address_state: '1/4/2', group_address_oscillation: '1/4/3', group_address_oscillation_state: '1/4/4' }
 ```
diff --git a/xknx/devices/fan.py b/xknx/devices/fan.py
index 6655865c..8d7e171b 100644
--- a/xknx/devices/fan.py
+++ b/xknx/devices/fan.py
@@ -10,7 +10,11 @@ from enum import Enum
 import logging
 from typing import TYPE_CHECKING, Any, Iterator, Optional, Union
 
-from xknx.remote_value import RemoteValueDptValue1Ucount, RemoteValueScaling
+from xknx.remote_value import (
+    RemoteValueDptValue1Ucount,
+    RemoteValueScaling,
+    RemoteValueSwitch,
+)
 
 from .device import Device, DeviceCallbackType
 
@@ -42,6 +46,8 @@ class Fan(Device):
         name: str,
         group_address_speed: Optional["GroupAddressableType"] = None,
         group_address_speed_state: Optional["GroupAddressableType"] = None,
+        group_address_oscillation: Optional["GroupAddressableType"] = None,
+        group_address_oscillation_state: Optional["GroupAddressableType"] = None,
         device_updated_cb: Optional[DeviceCallbackType] = None,
         max_step: Optional[int] = None,
     ):
@@ -74,15 +80,31 @@ class Fan(Device):
                 range_to=100,
             )
 
+        self.oscillation = RemoteValueSwitch(
+            xknx,
+            group_address_oscillation,
+            group_address_oscillation_state,
+            device_name=self.name,
+            feature_name="Oscillation",
+            after_update_cb=self.after_update,
+        )
+
     def _iter_remote_values(self) -> Iterator["RemoteValue"]:
         """Iterate the devices RemoteValue classes."""
-        yield self.speed
+        yield from (self.speed, self.oscillation)
+
+    @property
+    def supports_oscillation(self) -> bool:
+        """Return if fan supports oscillation."""
+        return self.oscillation.initialized
 
     @classmethod
     def from_config(cls, xknx: "XKNX", name: str, config: Any) -> "Fan":
         """Initialize object from configuration structure."""
         group_address_speed = config.get("group_address_speed")
         group_address_speed_state = config.get("group_address_speed_state")
+        group_address_oscillation = config.get("group_address_oscillation")
+        group_address_oscillation_state = config.get("group_address_oscillation_state")
         max_step = config.get("max_step")
 
         return cls(
@@ -90,23 +112,40 @@ class Fan(Device):
             name,
             group_address_speed=group_address_speed,
             group_address_speed_state=group_address_speed_state,
+            group_address_oscillation=group_address_oscillation,
+            group_address_oscillation_state=group_address_oscillation_state,
             max_step=max_step,
         )
 
     def __str__(self) -> str:
         """Return object as readable string."""
-        return '<Fan name="{}" ' 'speed="{}" />'.format(
-            self.name, self.speed.group_addr_str()
+
+        str_oscillation = (
+            ""
+            if not self.supports_oscillation
+            else f' oscillation="{self.oscillation.group_addr_str()}"'
+        )
+
+        return '<Fan name="{}" ' 'speed="{}"{} />'.format(
+            self.name, self.speed.group_addr_str(), str_oscillation
         )
 
     async def set_speed(self, speed: int) -> None:
         """Set the fan to a desginated speed."""
         await self.speed.set(speed)
 
+    async def set_oscillation(self, oscillation: bool) -> None:
+        """Set the fan oscillation mode on or off."""
+        await self.oscillation.set(oscillation)
+
     async def do(self, action: str) -> None:
         """Execute 'do' commands."""
         if action.startswith("speed:"):
             await self.set_speed(int(action[6:]))
+        elif action == "oscillation:True":
+            await self.set_oscillation(True)
+        elif action == "oscillation:False":
+            await self.set_oscillation(False)
         else:
             logger.warning(
                 "Could not understand action %s for device %s", action, self.get_name()
@@ -115,8 +154,14 @@ class Fan(Device):
     async def process_group_write(self, telegram: "Telegram") -> None:
         """Process incoming and outgoing GROUP WRITE telegram."""
         await self.speed.process(telegram)
+        await self.oscillation.process(telegram)
 
     @property
     def current_speed(self) -> Optional[int]:
         """Return current speed of fan."""
         return self.speed.value  # type: ignore
+
+    @property
+    def current_oscillation(self) -> Optional[bool]:
+        """Return true if the fan is oscillating."""
+        return self.oscillation.value  # type: ignore


Test patch:
diff --git a/test/devices_tests/fan_test.py b/test/devices_tests/fan_test.py
index 5bfef7de..3e6e52d7 100644
--- a/test/devices_tests/fan_test.py
+++ b/test/devices_tests/fan_test.py
@@ -133,6 +133,30 @@ class TestFan(unittest.TestCase):
             ),
         )
 
+    #
+    #
+    # TEST SET OSCILLATION
+    #
+    def test_set_oscillation(self):
+        """Test setting the oscillation of a Fan."""
+        xknx = XKNX()
+        fan = Fan(
+            xknx,
+            name="TestFan",
+            group_address_speed="1/2/3",
+            group_address_oscillation="1/2/5",
+        )
+        self.loop.run_until_complete(fan.set_oscillation(False))
+        self.assertEqual(xknx.telegrams.qsize(), 1)
+        telegram = xknx.telegrams.get_nowait()
+        self.assertEqual(
+            telegram,
+            Telegram(
+                destination_address=GroupAddress("1/2/5"),
+                payload=GroupValueWrite(DPTBinary(0)),
+            ),
+        )
+
     #
     # TEST PROCESS
     #
@@ -161,6 +185,27 @@ class TestFan(unittest.TestCase):
         with self.assertRaises(CouldNotParseTelegram):
             self.loop.run_until_complete(fan.process(telegram))
 
+    #
+    # TEST PROCESS OSCILLATION
+    #
+    def test_process_oscillation(self):
+        """Test process / reading telegrams from telegram queue. Test if oscillation is processed."""
+        xknx = XKNX()
+        fan = Fan(
+            xknx,
+            name="TestFan",
+            group_address_speed="1/2/3",
+            group_address_oscillation="1/2/5",
+        )
+        self.assertEqual(fan.current_oscillation, None)
+
+        telegram = Telegram(
+            destination_address=GroupAddress("1/2/5"),
+            payload=GroupValueWrite(DPTBinary(1)),
+        )
+        self.loop.run_until_complete(fan.process(telegram))
+        self.assertTrue(fan.current_oscillation)
+
     def test_process_fan_payload_invalid_length(self):
         """Test process wrong telegrams. (wrong payload length)."""
         # pylint: disable=invalid-name
@@ -196,9 +241,9 @@ class TestFan(unittest.TestCase):
         self.assertEqual(fan.current_speed, 2)
 
     #
-    # TEST DO
+    # TEST DO SPEED
     #
-    def test_do(self):
+    def test_do_speed(self):
         """Test 'do' functionality."""
         xknx = XKNX()
         fan = Fan(xknx, name="TestFan", group_address_speed="1/2/3")
@@ -209,6 +254,25 @@ class TestFan(unittest.TestCase):
         self.loop.run_until_complete(xknx.devices.process(xknx.telegrams.get_nowait()))
         self.assertEqual(fan.current_speed, 25)
 
+    #
+    # TEST DO OSCILLATION
+    #
+    def test_do_oscillation(self):
+        """Test 'do' functionality."""
+        xknx = XKNX()
+        fan = Fan(
+            xknx,
+            name="TestFan",
+            group_address_speed="1/2/3",
+            group_address_oscillation="1/2/4",
+        )
+        self.loop.run_until_complete(fan.do("oscillation:True"))
+        self.loop.run_until_complete(xknx.devices.process(xknx.telegrams.get_nowait()))
+        self.assertTrue(fan.current_oscillation)
+        self.loop.run_until_complete(fan.do("oscillation:False"))
+        self.loop.run_until_complete(xknx.devices.process(xknx.telegrams.get_nowait()))
+        self.assertFalse(fan.current_oscillation)
+
     def test_wrong_do(self):
         """Test wrong do command."""
         xknx = XKNX()
@@ -228,7 +292,11 @@ class TestFan(unittest.TestCase):
             "TestFan",
             group_address_speed="1/7/1",
             group_address_speed_state="1/7/2",
+            group_address_oscillation="1/6/1",
+            group_address_oscillation_state="1/6/2",
         )
         self.assertTrue(fan.has_group_address(GroupAddress("1/7/1")))
         self.assertTrue(fan.has_group_address(GroupAddress("1/7/2")))
         self.assertFalse(fan.has_group_address(GroupAddress("1/7/3")))
+        self.assertTrue(fan.has_group_address(GroupAddress("1/6/1")))
+        self.assertTrue(fan.has_group_address(GroupAddress("1/6/2")))
diff --git a/test/str_test.py b/test/str_test.py
index 3e4c4444..0833f5e2 100644
--- a/test/str_test.py
+++ b/test/str_test.py
@@ -188,10 +188,12 @@ class TestStringRepresentations(unittest.TestCase):
             name="Dunstabzug",
             group_address_speed="1/2/3",
             group_address_speed_state="1/2/4",
+            group_address_oscillation="1/2/5",
+            group_address_oscillation_state="1/2/6",
         )
         self.assertEqual(
             str(fan),
-            '<Fan name="Dunstabzug" speed="GroupAddress("1/2/3")/GroupAddress("1/2/4")/None/None" />',
+            '<Fan name="Dunstabzug" speed="GroupAddress("1/2/3")/GroupAddress("1/2/4")/None/None" oscillation="GroupAddress("1/2/5")/GroupAddress("1/2/6")/None/None" />',
         )
 
     def test_light(self):