Repository: XKNX/xknx
Pull request #167 (2019-01-11 21:14:08)

Added DPT-3s and corresponding sensor types (second try)
Added all missing DPT-3 datatypes, DPT 3.007 and 3.008, each in their stepwise and startstop mode, see [ABB Applications Manual](https://library.e.abb.com/public/78c74aa86d4648b7b9d918485cd4621a/2CDC500051M0203_ApplicationHB_Lighting_EN.pdf#page=34) for details.
I'm using them to control my Hues with my Busch Jäger push button-coupling 6108/02.

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

Coverage remained the same at ?% when pulling **d8645cee53f705bccde86e934856802f8039b1eb on buergi:master** into **af25730672dd1ba7336ed5720e1c5de401f4495d on XKNX:master**.

nodefeet: Thank you very much @buergi , that's what was missing in xknx for me.
I think the relativ dimming should additionally be implemented in the device class Light to cover the basic functionality someone would expect from a KNX Light.
farmio: @nodefeet could you please explain your usecase for DPT-3 in the Light module? My only usage of xknx is through home-assistant - which has no  concept of holding down buttons / switches. Are you using xknx outside of HA?
nodefeet: Yes and it's really just that for that: dimming directly with KNX switches outside of the HA interface. A lot of visualizations for smart homes offer the relative dimming, but I would agree that as soon as you have a virtual slider or knob it’s not really necessary. But with KNX compatible switches it is very useful to dim the light directly from your physical switch (the fastest app is still no app).

From my experience the stepwise dimming is rarely used in practice, partly due to that it is swiftly flooding the bus with excess telegrams, but, the start stop dimming is imho part of any basic KNX installation with lights. After the brightness is modified via relative dimming (i. e. after receiving the stop telegram or reaching 0 or 100 % brightness ) KNX dimming actuators typically return the actual brightness to the bus (so that interfaces like HA can update their status). 

Let me know if I can provide any additional input.
nodefeet: @farmio  I see now that you were specifically asking about the use case of DPT-3 in the **class Light** and not just about the use case of DPT-3 in general.

So let me add that since I think DPT-3 or relative dimming is essential for an abstraction of a KNX Light I would expect to not just find the `group_address_brightness(_absolut)`, but also a `group_address_brightness_relative` and the associated functionality in the device class Light.

farmio: Adding a stepwise dimming GA to the **Light class** would be no big deal. As far as im concerned it is a stateless action so we wouldn't even need a state GA or a `current_* property`. It would just forward the given value to the GA. 
Functionally this would be no different to adding a *Sensor* with DPT-3 type. I see that for the sake of completeness it could be added to the Light class.
I'd say its @Julius2342 call, since he doesn't want the light module to get too bloated.
buergi: Which bitshifting do you mean? The few lines inside the to_knx method? After some pondering I couldn't make up any better representation that shows the origin of the values better of course I could encapsulate the bitshifting like this, if you prefer this, but I wouldn't consider this to increase the readability

    MakeDPT3 = lambda sign, stepcode: DPTBinary(sign << 3 | stepcode)
buergi: Integrated your most if not all of your remarks and ported the remote value to a new dpt class. What do you think?
farmio: Thank you for updating it. This is very much appreciated. 

I think remote_value_sensor should be used for numeric datatypes only. I would add a new class (maybe "remote_value_control") for this - and maybe DPT2 in the future. This would also enable us to pass tuples holding control and step values so we can use them too.
I'd also create a new device subclass for this. This would be a cleaner imho.
buergi: I see your point, having a sensor for a control state is a bit weird, especially as you cannot set the value for controlling it.
I have a remote_value_dpt3 implementation in my desk drawer, however, it currently does not use the DPT3 class. However, it would be no problem adapting it.
However, I have no idea, how I'd integrate this remote value class into a usable device in home assistant.
So, what do you suggest, how to go on? I remove the sensor integration, add a remote value class and we then probably need a new device type, e.g. control.

As shortly explained in the docstring, there are two control types stepwise dimming and start/stop dimming. The frontend for the start/stop could probably look similar to the cover control. For the stepwise dimming it would be similar, plus evtl. another slider to allow setting the step size.

For easy use of the control without any further automatisation the control's state would probably include an internally accumulated absolute value of the dimming/cover state.
farmio: You're right, the remote_value class is really not a big deal - its mostly boilerplate.

I like xknx classes to be explicit. HA is another issue - we have to adapt to the available platforms. We could 
- integrate the Control device in the HA integration 
- or maybe the RemoteValueControl in the xknx Sensor class.

In ExposeSensor we have something similar: https://github.com/XKNX/xknx/blob/213c6fc5c88648ca5565299d02c8693a2989e0a7/xknx/devices/expose_sensor.py#L33

For stepwise dimming it would also be an option to make the step size configurable via yaml. Just like KNX switches where the stepsize is set once via ETS. It could always be bypassed by eg. HA-automations with an optional argument.
recMartin: I made similar work and only now saw that there exists this still unmerged PR.
Perhaps someone which has a better view of the complete xknx than me can look at both?
See #262 .
buergi: Thanks @recMartin for your implementation. I looked over the code and like the idea of having DPTControl implemented as "control + stepcode". It is closer to the KNX standard as my "increment" implementation and especially as it is bijective (invertable).

However, from a user perspective I'd expect to work with increment values, e.g. make the light 25% brigher. I'm currently consider moving the increment conversion to a corresponding RemoteValue type and stripping the DPT implementation down to the control+stepcode approach.

As @recMartin said he won't do any changes on his PR at the moment I'd suggest I keep working in this PR and try to merge both PRs.
recMartin: I had a look in the code, its a different approach than mine (#262), but also good work!
Some comments and suggestions:
- I like that this solution does not need a list (as mine does) but only one value
- Maybe we should make increment_to_stepcode(value) inverse of stepcode_to_increment(stepcode, sign) this means:
  - stepcode_to_increment(stepcode, sign) -> value 
  - increment_to_stepcode(value)          -> (value, sign) , calculate 'sign' in this function
- DPT3 looks like only a base-class, but it is also used for both, stepwise-dimming and controlling blinds
- the derived classes 'DPTControlDimming' and 'DPTControlBlinds' can only be used for start/stop dimming. 
  All step dimmings +/-1 to +/-100% are mapped to +/-100% and output as DECREASE or INCREASE or STOP, so some information is lost in from_knx (not the correct step is output).
- Could we allow/handle both types of values in the DPT classes or handle this only in remote_control?
- Since DPT 3.00x is not for sensors, but for actuators/controls, it should be incorporated in e.g. "remote_value_control" (as farmio suggested) and not in remote_sensor. 

buergi: Thanks @recMartin for your input. I've been working on the code yesterday and a lot has changed. I've not pushed it yet as I have some open points I still have to ponder about. Especially as I already have a future control device implementation in mind when developing.

I currently implemented a base DPTControl class based mostly on your code with a few esthetical changes. From this I derived a DPTControlStepwise which is mostly my code. I renamed the DPTControlDimming/Blinds to DPTControlStartStopDimming/Blinds (which I actually already had in the original code of this PR). I still like to add an intermediate DPTControlStartStop class to reduce the amount of duplicated code, but I'm not done with it yet
The implementation of the RemoteValueControl is very similar to your implementation (with a DPTMap as in RemoteValueSensor).

I already added unit tests for all important cases I could think of.
I guess this should tackle all your valid points of critisism you mentioned, but I'm open to new comments once I pushed the code.

I currently don't plan to add a new Control class in this PR, but I have some ideas and will make another PR when I'm ready. In short I plan to make a Control with an integrated absolute counter which can be configured to stepwise or startstop mode. The first case is simple and the absolute value is changed directly when a step-message is received. For the second case I have an implementation in mind which is similar to the one of the cover device, which uses a method which is added as a job to home assistant (see [cover.py)](https://github.com/XKNX/xknx/blob/b097e1978f7c86084d68c415a646c19a27b7abe7/home-assistant-plugin/custom_components/xknx/cover.py#L211)
buergi: I again rebased the PR and updated the DPT mapping in the RemoteValueControl to match that of RemoteValueSensor.

There is a slight problem with the `dpt_main_number` and `dpt_sub_number` convention. It is meant to be unique, however, in this case it isn't. There are two different interpretations for DPT3 values, stepwise and start/stop. A +100% message e.g. in the context of light dimming can either mean "turn the brightness to maximum" or "start increasing the brighness (until a 0% message is received)". Therefore both are handled via separate classes so only one can carry the `dpt_*_number` flags. I decided for the stepwise class as it carries more information than the start/stop class.

Currently, this value class cannot be used from within HA. I still plan to add a new kind of device which has an internal value that can be controlled with this type of messages. However, in the meantime, and also for flexibility reasons I would suggest extending the RemoteValueSensor type to allow DPTBinary payloads just by adding `or isinstance(payload, DPTBinary)` to the return statement of its `payload_valid` method and a slight adaption in its `to_knx` method. This makes no sense for stepwise controls but would allow using start/stop switches in custom automations. However, as I thought you might not like this idea I did not include it in the PR.
farmio: Hi! 
Thanks for your contribution. We have some bugs currently that keep me form reviewing this PR. 

All I can say right now is that I'd not be in favour of adding `or isinstance(payload, DPTBinary)` to RemoteValueSensor.payload_valid() . I'd rather use a new RemoteValue subclass and use it form Sensor() - just as done in ExposeSensor for binary type.
farmio: Hey! I think this can be merged now, but for some reason this is showing 99 files edited now. There are some commits in the changeset that are already merged a while ago. Can you please clean this up (hopefully) a last time?
buergi: Upps, what did I do there, git keeps on surprising me. I now cherry-picked all my commits again upon the current head. I'm not sure why the coverage test fails. I don't understand the output of this unit test.

Patch:
diff --git a/changelog.md b/changelog.md
index dd67d200..ebaf212b 100644
--- a/changelog.md
+++ b/changelog.md
@@ -2,6 +2,10 @@
 
 ## Unreleased changes
 
+### Devices
+
+- Sensor: add DPT-3 datatypes "stepwise_dimming", "stepwise_blinds", "startstop_dimming", "startstop_blinds"
+
 ### HA integration
 
 - knx_event: renamed `address` to `destination` and added `source`, `telegramtype`, `direction` attributes.
diff --git a/xknx/devices/sensor.py b/xknx/devices/sensor.py
index de62ec95..22cdfa39 100644
--- a/xknx/devices/sensor.py
+++ b/xknx/devices/sensor.py
@@ -6,7 +6,7 @@ It provides functionality for
 * reading the current state from KNX bus.
 * watching for state updates from KNX bus.
 """
-from xknx.remote_value import RemoteValueSensor
+from xknx.remote_value import RemoteValueControl, RemoteValueSensor
 
 from .device import Device
 
@@ -28,14 +28,29 @@ class Sensor(Device):
         # pylint: disable=too-many-arguments
         super().__init__(xknx, name, device_updated_cb)
         self.always_callback = always_callback
-        self.sensor_value = RemoteValueSensor(
-            xknx,
-            group_address_state=group_address_state,
-            sync_state=sync_state,
-            value_type=value_type,
-            device_name=self.name,
-            after_update_cb=self.after_update,
-        )
+        if value_type in [
+            "stepwise_dimming",
+            "stepwise_blinds",
+            "startstop_dimming",
+            "startstop_blinds",
+        ]:
+            self.sensor_value = RemoteValueControl(
+                xknx,
+                group_address_state=group_address_state,
+                sync_state=sync_state,
+                value_type=value_type,
+                device_name=self.name,
+                after_update_cb=self.after_update,
+            )
+        else:
+            self.sensor_value = RemoteValueSensor(
+                xknx,
+                group_address_state=group_address_state,
+                sync_state=sync_state,
+                value_type=value_type,
+                device_name=self.name,
+                after_update_cb=self.after_update,
+            )
 
     def _iter_remote_values(self):
         """Iterate the devices RemoteValue classes."""
diff --git a/xknx/dpt/__init__.py b/xknx/dpt/__init__.py
index 0141d90e..bd8cb703 100644
--- a/xknx/dpt/__init__.py
+++ b/xknx/dpt/__init__.py
@@ -62,6 +62,15 @@ from .dpt_2byte_uint import (
     DPTTimePeriodSec,
     DPTUElCurrentmA,
 )
+from .dpt_4bit_control import (
+    DPTControlStartStop,
+    DPTControlStartStopBlinds,
+    DPTControlStartStopDimming,
+    DPTControlStepCode,
+    DPTControlStepwise,
+    DPTControlStepwiseBlinds,
+    DPTControlStepwiseDimming,
+)
 from .dpt_4byte_float import (
     DPT4ByteFloat,
     DPTAbsoluteTemperature,
diff --git a/xknx/dpt/dpt_4bit_control.py b/xknx/dpt/dpt_4bit_control.py
new file mode 100644
index 00000000..51233688
--- /dev/null
+++ b/xknx/dpt/dpt_4bit_control.py
@@ -0,0 +1,257 @@
+"""Implementation of Basic KNX DPT B1U3 Values (DPT 3.007/3.008).
+
+There are two separate dimming modes sharing the same DPT class:
+
+ * Stepwise dimming
+   The full brightness range is divided into 2^(stepcode-1) intervals.
+   The value is always rounded to full interval boundary, i.e. 30% +25% = 50%, 50% +25% = 75%, 30% -25% = 25%
+
+ * Start-stop dimming
+   Dimming is started with -/+100% (0x1/0x9) and keeps dimming until a STOP diagram (0x0/0x8) is received.
+
+As the same payload in these cases in interpreted completely different it is reasonable to make separate DPT classes.
+"""
+from enum import Enum
+from typing import Tuple
+
+from xknx.exceptions import ConversionError
+
+from .dpt import DPTBase
+
+
+class DPTControlStepCode(DPTBase):
+    """Abstraction for KNX B1U3 values (DPT 3.007/3.008)."""
+
+    # APCI (application layer control information)
+    APCI_CONTROLMASK = 0x08
+    APCI_STEPCODEMASK = 0x07
+    APCI_MAX_VALUE = APCI_CONTROLMASK | APCI_STEPCODEMASK
+
+    value_type = "control_stepcode"
+    unit = ""
+    payload_length = 1
+
+    @classmethod
+    def _encode(cls, control: bool, step_code: int):
+        """Encode control-bit with step-code."""
+        value = 1 if control > 0 else 0
+        value = (value << 3) | (step_code & cls.APCI_STEPCODEMASK)
+        return value
+
+    @classmethod
+    def _decode(cls, value) -> Tuple[bool, int]:
+        """Decode value into control-bit and step-code."""
+        control = 1 if (value & cls.APCI_CONTROLMASK) != 0 else 0
+        step_code = value & cls.APCI_STEPCODEMASK
+        return control, step_code
+
+    @classmethod
+    def _test_boundaries(cls, raw):
+        """Test if raw KNX data is within defined range for this object."""
+        if isinstance(raw, int):
+            return 0 <= raw <= cls.APCI_MAX_VALUE
+        return False
+
+    @classmethod
+    def _test_values(cls, control: bool, step_code: int):
+        """Test if input values are valid."""
+        if isinstance(control, int) and isinstance(step_code, int):
+            if control in (0, 1) and 0 <= step_code <= cls.APCI_STEPCODEMASK:
+                return True
+        return False
+
+    @classmethod
+    def to_knx(cls, value, invert: bool = False):
+        """Serialize to KNX/IP raw data."""
+        if not isinstance(value, dict):
+            raise ConversionError(
+                "Cant serialize %s; invalid value type" % cls.__name__, value=value
+            )
+
+        try:
+            control = value["control"]
+            step_code = value["step_code"]
+        except KeyError:
+            raise ConversionError(
+                "Cant serialize %s; invalid keys" % cls.__name__, value=value
+            )
+
+        if not cls._test_values(control, step_code):
+            raise ConversionError(
+                "Cant serialize %s; invalid values" % cls.__name__, value=value
+            )
+
+        if invert:
+            control = 0 if control > 0 else 1
+
+        return cls._encode(control, step_code)
+
+    @classmethod
+    def from_knx(cls, raw, invert: bool = False):
+        """Parse/deserialize from KNX/IP raw data."""
+        if not cls._test_boundaries(raw):
+            raise ConversionError("Cant parse %s" % cls.__name__, raw=raw)
+
+        control, step_code = cls._decode(raw)
+
+        if invert:
+            control = 0 if control > 0 else 1
+
+        return {"control": control, "step_code": step_code}
+
+
+class DPTControlStepwise(DPTControlStepCode):
+    """Abstraction for KNX DPT 3.xxx in stepwise mode with conversion to an incement value."""
+
+    dpt_main_number = 3
+    dpt_sub_number = None
+    value_type = "stepwise"
+    unit = "%"
+
+    @staticmethod
+    def _from_increment(value):
+        """Calculate control bit and stepcode as defined in the KNX standard section 3.3.1 from an increment value."""
+        # control bit in KNX standard
+        #   0: - = decrease/move up
+        #   1: + = increase/move down
+        control = 0 if value <= 0 else 1
+
+        stepcode = (
+            0  # special case = break indication (e.g. stop dimming/moving blinds)
+        )
+        if abs(value) >= 100:
+            stepcode = 1
+        elif abs(value) >= 50:
+            stepcode = 2
+        elif abs(value) >= 25:
+            stepcode = 3
+        elif abs(value) >= 12:
+            stepcode = 4
+        elif abs(value) >= 6:
+            stepcode = 5
+        elif abs(value) >= 3:
+            stepcode = 6
+        elif abs(value) >= 1:
+            stepcode = 7
+
+        return {"control": control, "step_code": stepcode}
+
+    @staticmethod
+    def _to_increment(value):
+        """Calculate the increment value from the stepcode and control bit as defined in the KNX standard section 3.3.1."""
+        # calculated using floor(100/2^((value&0x07)-1))
+        inc = [0, 100, 50, 25, 12, 6, 3, 1][value["step_code"] & 0x07]
+        return inc if value["control"] == 1 else -inc
+
+    @classmethod
+    def to_knx(cls, value, invert: bool = False):
+        """Serialize to KNX/IP raw data."""
+        if not isinstance(value, int):
+            raise ConversionError("Cant serialize %s" % cls.__name__, value=value)
+
+        return super().to_knx(cls._from_increment(value), invert)
+
+    @classmethod
+    def from_knx(cls, raw, invert: bool = False):
+        """Parse/deserialize from KNX/IP raw data."""
+        return cls._to_increment(super().from_knx(raw, invert))
+
+
+class DPTControlStepwiseDimming(DPTControlStepwise):
+    """Abstraction for KNX DPT 3.007 / DPT_Control_Dimming in stepwise mode."""
+
+    dpt_main_number = 3
+    dpt_sub_number = 7
+    value_type = "stepwise_dimming"
+
+
+class DPTControlStepwiseBlinds(DPTControlStepwise):
+    """Abstraction for KNX DPT 3.008 / DPT_Control_Blinds in stepwise mode."""
+
+    dpt_main_number = 3
+    dpt_sub_number = 8
+    value_type = "stepwise_blinds"
+
+
+class TitleEnum(Enum):
+    """Enum with a descriptive string representation.
+
+    Ensures values are rendered nicely, e.g. in home assistant.
+    """
+
+    def __str__(self):
+        """Return string representation."""
+        # pylint: disable=no-member
+        return self.name.title()
+
+
+class DPTControlStartStop(DPTControlStepCode):
+    """Abstraction for KNX DPT 3.xxx in start/stop mode."""
+
+    value_type = "startstop"
+    unit = ""
+
+    class Direction(TitleEnum):
+        """Enum for indicating the direction."""
+
+        DECREASE = 0
+        INCREASE = 1
+        STOP = 2
+
+    @classmethod
+    def to_knx(cls, value, invert: bool = False):
+        """Convert value to payload."""
+        control = 0
+        step_code = 0
+        if value == cls.Direction(1):  # INCREASE/DOWN
+            control = 1
+            step_code = 1
+        elif value == cls.Direction(0):  # DECREASE/UP
+            control = 0
+            step_code = 1
+        elif value == cls.Direction(2):  # STOP
+            control = 0
+            step_code = 0
+        else:
+            raise ConversionError("Cant serialize %s" % cls.__name__, value=value)
+
+        values = {"control": control, "step_code": step_code}
+        return super().to_knx(values, invert)
+
+    @classmethod
+    def from_knx(cls, raw, invert: bool = False):
+        """Convert current payload to value."""
+        values = super().from_knx(raw, invert)
+        if values["step_code"] == 0:
+            return cls.Direction(2)  # STOP
+        if values["control"] == 0:
+            return cls.Direction(0)  # DECREASE/UP
+        return cls.Direction(1)  # INCREASE/DOWN
+
+
+class DPTControlStartStopDimming(DPTControlStartStop):
+    """Abstraction for KNX DPT 3.007 / DPT_Control_Dimming in start/stop mode."""
+
+    value_type = "startstop_dimming"
+
+    # redefining Direction enum ensures proper typing, e.g.
+    # DPTControlStartStop.Direction.INCREASE != DPTControlStartStopDimming.Direction.INCREASE
+    class Direction(TitleEnum):
+        """Enum for indicating the direction."""
+
+        DECREASE = 0
+        INCREASE = 1
+        STOP = 2
+
+
+class DPTControlStartStopBlinds(DPTControlStartStop):
+    """Abstraction for KNX DPT 3.008 / DPT_Control_Blinds in start/stop mode."""
+
+    value_type = "startstop_blinds"
+
+    class Direction(TitleEnum):
+        """Enum for indicating the direction."""
+
+        UP = 0
+        DOWN = 1
+        STOP = 2
diff --git a/xknx/remote_value/__init__.py b/xknx/remote_value/__init__.py
index 9ea8a0d6..a7d72b8f 100644
--- a/xknx/remote_value/__init__.py
+++ b/xknx/remote_value/__init__.py
@@ -9,6 +9,7 @@ from .remote_value_climate_mode import (
 )
 from .remote_value_color_rgb import RemoteValueColorRGB
 from .remote_value_color_rgbw import RemoteValueColorRGBW
+from .remote_value_control import RemoteValueControl
 from .remote_value_datetime import RemoteValueDateTime
 from .remote_value_dpt_2_byte_unsigned import RemoteValueDpt2ByteUnsigned
 from .remote_value_dpt_value_1_ucount import RemoteValueDptValue1Ucount
diff --git a/xknx/remote_value/remote_value_control.py b/xknx/remote_value/remote_value_control.py
new file mode 100644
index 00000000..cf5c031c
--- /dev/null
+++ b/xknx/remote_value/remote_value_control.py
@@ -0,0 +1,71 @@
+"""
+Module for managing a control remote value.
+
+Examples are switching commands with priority control, relative dimming or blinds control commands.
+DPT 2.yyy and DPT 3.yyy
+"""
+from typing import List
+
+from xknx.dpt import DPTBase, DPTBinary
+from xknx.exceptions import ConversionError
+
+from .remote_value import RemoteValue
+
+
+class RemoteValueControl(RemoteValue):
+    """Abstraction for remote value used for controling."""
+
+    def __init__(
+        self,
+        xknx,
+        group_address=None,
+        group_address_state=None,
+        sync_state=True,
+        value_type=None,
+        device_name=None,
+        feature_name="Control",
+        after_update_cb=None,
+        invert=False,
+        passive_group_addresses: List[str] = None,
+    ):
+        """Initialize control remote value."""
+        # pylint: disable=too-many-arguments
+        self.invert = invert
+        _dpt_class = DPTBase.parse_transcoder(value_type)
+        if _dpt_class is None:
+            raise ConversionError(
+                "invalid value type", value_type=value_type, device_name=device_name
+            )
+        self.dpt_class = _dpt_class
+        super().__init__(
+            xknx,
+            group_address,
+            group_address_state,
+            sync_state=sync_state,
+            device_name=device_name,
+            feature_name=feature_name,
+            after_update_cb=after_update_cb,
+            passive_group_addresses=passive_group_addresses,
+        )
+
+    def payload_valid(self, payload):
+        """Test if telegram payload may be parsed."""
+        return isinstance(payload, DPTBinary)
+
+    def to_knx(self, value):
+        """Convert value to payload."""
+        return DPTBinary(self.dpt_class.to_knx(value, invert=self.invert))
+
+    def from_knx(self, payload):
+        """Convert current payload to value."""
+        return self.dpt_class.from_knx(payload.value, invert=self.invert)
+
+    @property
+    def unit_of_measurement(self):
+        """Return the unit of measurement."""
+        return self.dpt_class.unit
+
+    @property
+    def ha_device_class(self):
+        """Return a string representing the home assistant device class."""
+        return getattr(self.dpt_class, "ha_device_class", None)


Test patch:
diff --git a/test/dpt_tests/dpt_4bit_control_test.py b/test/dpt_tests/dpt_4bit_control_test.py
new file mode 100644
index 00000000..06cd3096
--- /dev/null
+++ b/test/dpt_tests/dpt_4bit_control_test.py
@@ -0,0 +1,249 @@
+"""Unit test for DPTControlStepCode objects."""
+import unittest
+
+from xknx.dpt import (
+    DPTControlStartStop,
+    DPTControlStartStopBlinds,
+    DPTControlStartStopDimming,
+    DPTControlStepCode,
+    DPTControlStepwise,
+)
+from xknx.exceptions import ConversionError
+
+
+class TestDPTControlStepCode(unittest.TestCase):
+    """Test class for DPTControlStepCode objects."""
+
+    def test_to_knx(self):
+        """Test serializing values to DPTControlStepCode."""
+        for rawref in range(16):
+            control = 1 if rawref >> 3 else 0
+            raw = DPTControlStepCode.to_knx(
+                {"control": control, "step_code": rawref & 0x07}
+            )
+            self.assertEqual(raw, rawref)
+
+    def test_to_knx_inverted(self):
+        """Test serializing values to DPTControlStepCode in inverted mode."""
+        for rawref in range(16):
+            control = 0 if rawref >> 3 else 1
+            raw = DPTControlStepCode.to_knx(
+                {"control": control, "step_code": rawref & 0x07}, invert=True
+            )
+            self.assertEqual(raw, rawref)
+
+    def test_to_knx_wrong_type(self):
+        """Test serializing wrong type to DPTControlStepCode."""
+        with self.assertRaises(ConversionError):
+            DPTControlStepCode.to_knx("")
+        with self.assertRaises(ConversionError):
+            DPTControlStepCode.to_knx(0)
+
+    def test_to_knx_wrong_keys(self):
+        """Test serializing map with missing keys to DPTControlStepCode."""
+        with self.assertRaises(ConversionError):
+            DPTControlStepCode.to_knx({"control": 0})
+        with self.assertRaises(ConversionError):
+            DPTControlStepCode.to_knx({"step_code": 0})
+
+    def test_to_knx_wrong_value_types(self):
+        """Test serializing map with keys of invalid type to DPTControlStepCode."""
+        with self.assertRaises(ConversionError):
+            DPTControlStepCode.to_knx({"control": ""})
+        with self.assertRaises(ConversionError):
+            DPTControlStepCode.to_knx({"step_code": ""})
+
+    def test_to_knx_wrong_values(self):
+        """Test serializing map with keys of invalid values to DPTControlStepCode."""
+        with self.assertRaises(ConversionError):
+            DPTControlStepCode.to_knx({"control": -1, "step_code": 0})
+        with self.assertRaises(ConversionError):
+            DPTControlStepCode.to_knx({"control": 2, "step_code": 0})
+        with self.assertRaises(ConversionError):
+            DPTControlStepCode.to_knx({"control": 0, "step_code": -1})
+        with self.assertRaises(ConversionError):
+            DPTControlStepCode.to_knx({"control": 0, "step_code": 0x8})
+
+    def test_from_knx(self):
+        """Test parsing DPTControlStepCode types from KNX."""
+        for raw in range(16):
+            control = 1 if raw >> 3 else 0
+            valueref = {"control": control, "step_code": raw & 0x07}
+            value = DPTControlStepCode.from_knx(raw)
+            self.assertEqual(value, valueref)
+
+    def test_from_knx_inverted(self):
+        """Test parsing DPTControlStepCode types from KNX."""
+        for raw in range(16):
+            control = 0 if raw >> 3 else 1
+            valueref = {"control": control, "step_code": raw & 0x07}
+            value = DPTControlStepCode.from_knx(raw, invert=True)
+            self.assertEqual(value, valueref)
+
+    def test_from_knx_wrong_value(self):
+        """Test parsing invalid DPTControlStepCode type from KNX."""
+        with self.assertRaises(ConversionError):
+            DPTControlStepCode.from_knx(0x1F)
+
+    def test_unit(self):
+        """Test unit_of_measurement function."""
+        self.assertEqual(DPTControlStepCode.unit, "")
+
+
+class TestDPTControlStepwise(unittest.TestCase):
+    """Test class for DPTControlStepwise objects."""
+
+    def test_to_knx(self):
+        """Test serializing values to DPTControlStepwise."""
+        self.assertEqual(DPTControlStepwise.to_knx(1), 0xF)
+        self.assertEqual(DPTControlStepwise.to_knx(3), 0xE)
+        self.assertEqual(DPTControlStepwise.to_knx(6), 0xD)
+        self.assertEqual(DPTControlStepwise.to_knx(12), 0xC)
+        self.assertEqual(DPTControlStepwise.to_knx(25), 0xB)
+        self.assertEqual(DPTControlStepwise.to_knx(50), 0xA)
+        self.assertEqual(DPTControlStepwise.to_knx(100), 0x9)
+        self.assertEqual(DPTControlStepwise.to_knx(-1), 0x7)
+        self.assertEqual(DPTControlStepwise.to_knx(-3), 0x6)
+        self.assertEqual(DPTControlStepwise.to_knx(-6), 0x5)
+        self.assertEqual(DPTControlStepwise.to_knx(-12), 0x4)
+        self.assertEqual(DPTControlStepwise.to_knx(-25), 0x3)
+        self.assertEqual(DPTControlStepwise.to_knx(-50), 0x2)
+        self.assertEqual(DPTControlStepwise.to_knx(-100), 0x1)
+        self.assertEqual(DPTControlStepwise.to_knx(0), 0x0)
+
+    def test_to_knx_wrong_type(self):
+        """Test serializing wrong type to DPTControlStepwise."""
+        with self.assertRaises(ConversionError):
+            DPTControlStepwise.to_knx("")
+
+    def test_from_knx(self):
+        """Test parsing DPTControlStepwise types from KNX."""
+        self.assertEqual(DPTControlStepwise.from_knx(0xF), 1)
+        self.assertEqual(DPTControlStepwise.from_knx(0xE), 3)
+        self.assertEqual(DPTControlStepwise.from_knx(0xD), 6)
+        self.assertEqual(DPTControlStepwise.from_knx(0xC), 12)
+        self.assertEqual(DPTControlStepwise.from_knx(0xB), 25)
+        self.assertEqual(DPTControlStepwise.from_knx(0xA), 50)
+        self.assertEqual(DPTControlStepwise.from_knx(0x9), 100)
+        self.assertEqual(DPTControlStepwise.from_knx(0x8), 0)
+        self.assertEqual(DPTControlStepwise.from_knx(0x7), -1)
+        self.assertEqual(DPTControlStepwise.from_knx(0x6), -3)
+        self.assertEqual(DPTControlStepwise.from_knx(0x5), -6)
+        self.assertEqual(DPTControlStepwise.from_knx(0x4), -12)
+        self.assertEqual(DPTControlStepwise.from_knx(0x3), -25)
+        self.assertEqual(DPTControlStepwise.from_knx(0x2), -50)
+        self.assertEqual(DPTControlStepwise.from_knx(0x1), -100)
+        self.assertEqual(DPTControlStepwise.from_knx(0x0), 0)
+
+    def test_from_knx_wrong_value(self):
+        """Test parsing invalid DPTControlStepwise type from KNX."""
+        with self.assertRaises(ConversionError):
+            DPTControlStepwise.from_knx(0x1F)
+
+    def test_unit(self):
+        """Test unit_of_measurement function."""
+        self.assertEqual(DPTControlStepwise.unit, "%")
+
+
+class TestDPTControlStartStop(unittest.TestCase):
+    """Test class for DPTControlStartStop objects."""
+
+    def test_mode_to_knx(self):
+        """Test serializing dimming commands to KNX."""
+        self.assertEqual(
+            DPTControlStartStopDimming.to_knx(
+                DPTControlStartStopDimming.Direction.INCREASE
+            ),
+            9,
+        )
+        self.assertEqual(
+            DPTControlStartStopDimming.to_knx(
+                DPTControlStartStopDimming.Direction.DECREASE
+            ),
+            1,
+        )
+        self.assertEqual(
+            DPTControlStartStopDimming.to_knx(
+                DPTControlStartStopDimming.Direction.STOP
+            ),
+            0,
+        )
+
+    def test_mode_to_knx_wrong_value(self):
+        """Test serializing invalid data type to KNX."""
+        with self.assertRaises(ConversionError):
+            DPTControlStartStopDimming.to_knx(1)
+
+    def test_mode_from_knx(self):
+        """Test parsing dimming commands from KNX."""
+        for i in range(16):
+            if i > 8:
+                expected_direction = DPTControlStartStopDimming.Direction.INCREASE
+            elif i in (0, 8):
+                expected_direction = DPTControlStartStopDimming.Direction.STOP
+            elif i < 8:
+                expected_direction = DPTControlStartStopDimming.Direction.DECREASE
+            self.assertEqual(DPTControlStartStopDimming.from_knx(i), expected_direction)
+
+    def test_mode_from_knx_wrong_value(self):
+        """Test serializing invalid data type to KNX."""
+        with self.assertRaises(ConversionError):
+            DPTControlStartStopDimming.from_knx((1, 2))
+
+    def test_direction_names(self):
+        """Test names of Direction Enum."""
+        self.assertEqual(str(DPTControlStartStop.Direction.INCREASE), "Increase")
+        self.assertEqual(str(DPTControlStartStop.Direction.DECREASE), "Decrease")
+        self.assertEqual(str(DPTControlStartStop.Direction.STOP), "Stop")
+
+
+class TestDPTControlStartStopDimming(TestDPTControlStartStop):
+    """Test class for DPTControlStartStopDimming objects."""
+
+    def test_direction_names(self):
+        """Test names of Direction Enum."""
+        self.assertEqual(str(DPTControlStartStopDimming.Direction.INCREASE), "Increase")
+        self.assertEqual(str(DPTControlStartStopDimming.Direction.DECREASE), "Decrease")
+        self.assertEqual(str(DPTControlStartStopDimming.Direction.STOP), "Stop")
+
+    def test_direction_values(self):
+        """Test values of Direction Enum."""
+        # pylint: disable=no-member
+        self.assertEqual(
+            DPTControlStartStopDimming.Direction.DECREASE.value,
+            DPTControlStartStop.Direction.DECREASE.value,
+        )
+        self.assertEqual(
+            DPTControlStartStopDimming.Direction.INCREASE.value,
+            DPTControlStartStop.Direction.INCREASE.value,
+        )
+        self.assertEqual(
+            DPTControlStartStopDimming.Direction.STOP.value,
+            DPTControlStartStop.Direction.STOP.value,
+        )
+
+
+class TestDPTControlStartStopBlinds(unittest.TestCase):
+    """Test class for DPTControlStartStopBlinds objects."""
+
+    def test_direction_names(self):
+        """Test names of Direction Enum."""
+        self.assertEqual(str(DPTControlStartStopBlinds.Direction.DOWN), "Down")
+        self.assertEqual(str(DPTControlStartStopBlinds.Direction.UP), "Up")
+        self.assertEqual(str(DPTControlStartStopBlinds.Direction.STOP), "Stop")
+
+    def test_direction_values(self):
+        """Test values of Direction Enum."""
+        # pylint: disable=no-member
+        self.assertEqual(
+            DPTControlStartStopBlinds.Direction.UP.value,
+            DPTControlStartStop.Direction.DECREASE.value,
+        )
+        self.assertEqual(
+            DPTControlStartStopBlinds.Direction.DOWN.value,
+            DPTControlStartStop.Direction.INCREASE.value,
+        )
+        self.assertEqual(
+            DPTControlStartStopBlinds.Direction.STOP.value,
+            DPTControlStartStop.Direction.STOP.value,
+        )
diff --git a/test/remote_value_tests/remote_value_control_test.py b/test/remote_value_tests/remote_value_control_test.py
new file mode 100644
index 00000000..88059c64
--- /dev/null
+++ b/test/remote_value_tests/remote_value_control_test.py
@@ -0,0 +1,86 @@
+"""Unit test for RemoteValueControl objects."""
+import asyncio
+import unittest
+
+from xknx import XKNX
+from xknx.dpt import DPTArray, DPTBinary
+from xknx.exceptions import ConversionError, CouldNotParseTelegram
+from xknx.remote_value import RemoteValueControl
+from xknx.telegram import GroupAddress, Telegram
+from xknx.telegram.apci import GroupValueWrite
+
+
+class TestRemoteValueControl(unittest.TestCase):
+    """Test class for RemoteValueControl objects."""
+
+    def setUp(self):
+        """Set up test class."""
+        self.loop = asyncio.new_event_loop()
+        asyncio.set_event_loop(self.loop)
+
+    def tearDown(self):
+        """Tear down test class."""
+        self.loop.close()
+
+    def test_wrong_value_type(self):
+        """Test initializing with wrong value_type."""
+        xknx = XKNX()
+        with self.assertRaises(ConversionError):
+            RemoteValueControl(xknx, value_type="wrong_value_type")
+
+    def test_valid_payload(self):
+        """Test valid_payload method."""
+        self.assertTrue(DPTBinary(0))
+        self.assertTrue(DPTArray([0]))
+
+    def test_set(self):
+        """Test setting value."""
+        xknx = XKNX()
+        remote_value = RemoteValueControl(
+            xknx, group_address=GroupAddress("1/2/3"), value_type="stepwise"
+        )
+        self.loop.run_until_complete(asyncio.Task(remote_value.set(25)))
+        self.assertEqual(xknx.telegrams.qsize(), 1)
+        telegram = xknx.telegrams.get_nowait()
+        self.assertEqual(
+            telegram,
+            Telegram(
+                destination_address=GroupAddress("1/2/3"),
+                payload=GroupValueWrite(DPTBinary(0xB)),
+            ),
+        )
+
+    def test_process(self):
+        """Test process telegram."""
+        xknx = XKNX()
+        remote_value = RemoteValueControl(
+            xknx, group_address=GroupAddress("1/2/3"), value_type="stepwise"
+        )
+        telegram = Telegram(
+            destination_address=GroupAddress("1/2/3"),
+            payload=GroupValueWrite(DPTBinary(0xB)),
+        )
+        self.assertEqual(remote_value.value, None)
+        self.loop.run_until_complete(remote_value.process(telegram))
+        self.assertEqual(remote_value.value, 25)
+
+    def test_to_process_error(self):
+        """Test process errornous telegram."""
+        xknx = XKNX()
+        remote_value = RemoteValueControl(
+            xknx, group_address=GroupAddress("1/2/3"), value_type="stepwise"
+        )
+        with self.assertRaises(CouldNotParseTelegram):
+            telegram = Telegram(
+                destination_address=GroupAddress("1/2/3"),
+                payload=GroupValueWrite(DPTArray(0x01)),
+            )
+            self.loop.run_until_complete(remote_value.process(telegram))
+        with self.assertRaises(ConversionError):
+            telegram = Telegram(
+                destination_address=GroupAddress("1/2/3"),
+                payload=GroupValueWrite(DPTBinary(0x10)),
+            )
+            self.loop.run_until_complete(remote_value.process(telegram))
+            # pylint: disable=pointless-statement
+            remote_value.value