Repository: XKNX/xknx
Pull request #575 (2021-01-25 17:01:57)

Add fan mode for steps
<!--
  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.
-->
Our controlled domestic ventilation can be control by KNX. 
The ventilation fan cannot be controlled directly with percentage but with 4 predefined levels 0-3. The communication object has DPT5.010.

To enable xknx (and furthermore HA) integration, I've added a fan mode parameter which allows the switch between percentage controlled fans and step controlled ones.

If this is merged, I'm going to add the KNX Fan entity to HA. The code is already finished, I just need to add the new mode parameter :)

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)
- [ ] 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/36578614/badge)](https://coveralls.io/builds/36578614)

Coverage increased (+0.007%) to 94.858% when pulling **94fa07be9aebb093b51d4b321b8b934467c8cfb4 on crazyfx1:feat/fan_speed_step** into **21b63f5915c6f815f3c0281c8f5cfa8c6057dc5a on XKNX:master**.

crazyfx1: Thanks, I've included the feedback.
I don't think any ventilation would use a value of 255 steps but I personally wouldn't add a max value :)

The control is very basic, you can just set the preset from 0-3 where 0 means off. There is no extra on/off switch.
![image](https://user-images.githubusercontent.com/9930385/105756338-c27c2080-5f4c-11eb-8a52-40e7001c0081.png)



Patch:
diff --git a/changelog.md b/changelog.md
index 4656935f..e68058d9 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,11 @@
 # Changelog
 
+## 0.xx
+
+### Devices
+
+- Fan: Add "mode" parameter to control fans by steps instead of percentage.
+
 ## 0.16.2 Bugfix for yaml loader 2021-01-24
 
 ### Internals
diff --git a/docs/fan.md b/docs/fan.md
new file mode 100644
index 00000000..9a0591b0
--- /dev/null
+++ b/docs/fan.md
@@ -0,0 +1,49 @@
+---
+layout: default
+title: Fan
+parent: Devices
+nav_order: 4
+---
+
+# [](#header-1)Fans
+
+## [](#header-2)Overview
+
+Fans are simple representations of KNX controlled fans. They support setting the speed.
+
+## [](#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.
+- `device_updated_cb` awaitable callback for each update.
+- `mode` Enum for the type of the fan control. Can be *Percent* or *Step*. Default: FanSpeedMode.Percent
+
+## [](#header-2)Example
+
+```python
+fan = Fan(xknx,
+              'TestFan',
+              group_address='1/2/1',
+              group_address_state='1/2/2')
+
+# Set the fan speed
+await fan.set_speed(50)
+
+# Accessing speed
+print(fan.current_speed)
+
+# Requesting state via KNX GroupValueRead
+await fan.sync()
+```
+
+## [](#header-2)Configuration via **xknx.yaml**
+
+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' }
+```
diff --git a/docs/light.md b/docs/light.md
index 549d4495..d4ed63e0 100644
--- a/docs/light.md
+++ b/docs/light.md
@@ -2,7 +2,7 @@
 layout: default
 title: Lights / Dimmer
 parent: Devices
-nav_order: 4
+nav_order: 5
 ---
 
 # [](#header-1)Light & Dimmer
diff --git a/docs/sensor.md b/docs/sensor.md
index 4fa91ba4..01454ffd 100644
--- a/docs/sensor.md
+++ b/docs/sensor.md
@@ -2,7 +2,7 @@
 layout: default
 title: Sensor
 parent: Devices
-nav_order: 5
+nav_order: 6
 ---
 
 # [](#header-1)Sensor - Monitor values of KNX
diff --git a/docs/switch.md b/docs/switch.md
index 46275311..40d212ad 100644
--- a/docs/switch.md
+++ b/docs/switch.md
@@ -2,7 +2,7 @@
 layout: default
 title: Switches
 parent: Devices
-nav_order: 6
+nav_order: 7
 ---
 
 # [](#header-1)Switches
diff --git a/docs/time.md b/docs/time.md
index b47605a5..b81ff4aa 100644
--- a/docs/time.md
+++ b/docs/time.md
@@ -2,7 +2,7 @@
 layout: default
 title: Time
 parent: Devices
-nav_order: 7
+nav_order: 8
 ---
 
 # [](#header-1)Time
diff --git a/docs/weather.md b/docs/weather.md
index b81f8be4..11eec415 100644
--- a/docs/weather.md
+++ b/docs/weather.md
@@ -2,7 +2,7 @@
 layout: default
 title: Weather
 parent: Devices
-nav_order: 8
+nav_order: 9
 ---
 
 # [](#header-1)Weather device
diff --git a/xknx/devices/fan.py b/xknx/devices/fan.py
index e986c53b..a00ca055 100644
--- a/xknx/devices/fan.py
+++ b/xknx/devices/fan.py
@@ -3,17 +3,19 @@ Module for managing a fan via KNX.
 
 It provides functionality for
 
-* setting fan to specific speed
+* setting fan to specific speed / step
 * reading the current speed from KNX bus.
 """
+from enum import Enum
 import logging
-from typing import TYPE_CHECKING, Any, Iterator, Optional
+from typing import TYPE_CHECKING, Any, Iterator, Optional, Union
 
-from xknx.remote_value import RemoteValueScaling
+from xknx.remote_value import RemoteValueDptValue1Ucount, RemoteValueScaling
 
 from .device import Device, DeviceCallbackType
 
 if TYPE_CHECKING:
+    from xknx.remote_value import RemoteValue
     from xknx.telegram import Telegram
     from xknx.telegram.address import GroupAddressableType
     from xknx.xknx import XKNX
@@ -21,6 +23,16 @@ if TYPE_CHECKING:
 logger = logging.getLogger("xknx.log")
 
 
+class FanSpeedMode(Enum):
+    """Enum for setting the fan speed mode."""
+
+    Percent = 1
+    Step = 2
+
+
+DEFAULT_MODE = FanSpeedMode.Percent
+
+
 class Fan(Device):
     """Class for managing a fan."""
 
@@ -34,23 +46,35 @@ class Fan(Device):
         group_address_speed: Optional["GroupAddressableType"] = None,
         group_address_speed_state: Optional["GroupAddressableType"] = None,
         device_updated_cb: Optional[DeviceCallbackType] = None,
+        mode: FanSpeedMode = DEFAULT_MODE,
     ):
         """Initialize fan class."""
         # pylint: disable=too-many-arguments
         super().__init__(xknx, name, device_updated_cb)
 
-        self.speed = RemoteValueScaling(
-            xknx,
-            group_address_speed,
-            group_address_speed_state,
-            device_name=self.name,
-            feature_name="Speed",
-            after_update_cb=self.after_update,
-            range_from=0,
-            range_to=100,
-        )
+        self.speed: Union[RemoteValueDptValue1Ucount, RemoteValueScaling]
+        if mode == FanSpeedMode.Step:
+            self.speed = RemoteValueDptValue1Ucount(
+                xknx,
+                group_address_speed,
+                group_address_speed_state,
+                device_name=self.name,
+                feature_name="Speed",
+                after_update_cb=self.after_update,
+            )
+        else:
+            self.speed = RemoteValueScaling(
+                xknx,
+                group_address_speed,
+                group_address_speed_state,
+                device_name=self.name,
+                feature_name="Speed",
+                after_update_cb=self.after_update,
+                range_from=0,
+                range_to=100,
+            )
 
-    def _iter_remote_values(self) -> Iterator[RemoteValueScaling]:
+    def _iter_remote_values(self) -> Iterator["RemoteValue"]:
         """Iterate the devices RemoteValue classes."""
         yield self.speed
 
@@ -59,12 +83,14 @@ class Fan(Device):
         """Initialize object from configuration structure."""
         group_address_speed = config.get("group_address_speed")
         group_address_speed_state = config.get("group_address_speed_state")
+        mode = config.get("mode", DEFAULT_MODE)
 
         return cls(
             xknx,
             name,
             group_address_speed=group_address_speed,
             group_address_speed_state=group_address_speed_state,
+            mode=mode,
         )
 
     def __str__(self) -> str:


Test patch:
diff --git a/test/devices_tests/fan_test.py b/test/devices_tests/fan_test.py
index 0e7a7767..0038af2f 100644
--- a/test/devices_tests/fan_test.py
+++ b/test/devices_tests/fan_test.py
@@ -5,6 +5,7 @@ from unittest.mock import patch
 
 from xknx import XKNX
 from xknx.devices import Fan
+from xknx.devices.fan import FanSpeedMode
 from xknx.dpt import DPTArray, DPTBinary
 from xknx.exceptions import CouldNotParseTelegram
 from xknx.telegram import GroupAddress, Telegram
@@ -44,6 +45,27 @@ class TestFan(unittest.TestCase):
             ),
         )
 
+    def test_sync_step(self):
+        """Test sync function / sending group reads to KNX bus."""
+        xknx = XKNX()
+        fan = Fan(
+            xknx,
+            name="TestFan",
+            group_address_speed_state="1/2/3",
+            mode=FanSpeedMode.Step,
+        )
+        self.loop.run_until_complete(fan.sync())
+
+        self.assertEqual(xknx.telegrams.qsize(), 1)
+
+        telegram1 = xknx.telegrams.get_nowait()
+        self.assertEqual(
+            telegram1,
+            Telegram(
+                destination_address=GroupAddress("1/2/3"), payload=GroupValueRead()
+            ),
+        )
+
     #
     # SYNC WITH STATE ADDRESS
     #
@@ -88,6 +110,27 @@ class TestFan(unittest.TestCase):
             ),
         )
 
+    #
+    #
+    # TEST SET SPEED STEP
+    #
+    def test_set_speed_step(self):
+        """Test setting the speed of a Fan."""
+        xknx = XKNX()
+        fan = Fan(
+            xknx, name="TestFan", group_address_speed="1/2/3", mode=FanSpeedMode.Step
+        )
+        self.loop.run_until_complete(fan.set_speed(2))
+        self.assertEqual(xknx.telegrams.qsize(), 1)
+        telegram = xknx.telegrams.get_nowait()
+        self.assertEqual(
+            telegram,
+            Telegram(
+                destination_address=GroupAddress("1/2/3"),
+                payload=GroupValueWrite(DPTArray(2)),
+            ),
+        )
+
     #
     # TEST PROCESS
     #
@@ -128,6 +171,24 @@ class TestFan(unittest.TestCase):
         with self.assertRaises(CouldNotParseTelegram):
             self.loop.run_until_complete(fan.process(telegram))
 
+    #
+    # TEST PROCESS STEP MODE
+    #
+    def test_process_speed_step(self):
+        """Test process / reading telegrams from telegram queue. Test if speed is processed."""
+        xknx = XKNX()
+        fan = Fan(
+            xknx, name="TestFan", group_address_speed="1/2/3", mode=FanSpeedMode.Step
+        )
+        self.assertEqual(fan.current_speed, None)
+
+        telegram = Telegram(
+            destination_address=GroupAddress("1/2/3"),
+            payload=GroupValueWrite(DPTArray(2)),
+        )
+        self.loop.run_until_complete(fan.process(telegram))
+        self.assertEqual(fan.current_speed, 2)
+
     #
     # TEST DO
     #