Repository: XKNX/xknx
Pull request #531 (2020-12-14 22:55:12)

parse ConnectResponse Telegrams with Error codes
from @uweswrtz in #272

<!--
  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.
-->
I've taken some changes form #272 to parse ConnectionResponse Telegrams with Error codes "correctly". 
From my local tests nothing changes to current xknx behaviour with these changes, but the parsed error code can be used later in the RequestResponse() or Tunnel() class. I plan to do that in #443 

Also added tests from #272 (rebasing this was too much trouble at this time) Thanks @uweswrtz !

closes #272 

## 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)
- [ ] 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
- [ ] The documentation has been adjusted accordingly
- [ ] The changes generate no new warnings
- [ ] Tests have been added that prove the fix is effective or that the feature works
- [ ] 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/35710956/badge)](https://coveralls.io/builds/35710956)

Coverage decreased (-0.002%) to 94.771% when pulling **c25b3e4a8c16e1d747b99bbe8b38e858f6400bd0 on farmio:error-connect-response** into **ec278a8f2da31abfd33049c1c9769677c33c45a6 on XKNX:master**.


Patch:
diff --git a/changelog.md b/changelog.md
index 8d91c307..64e46e0b 100644
--- a/changelog.md
+++ b/changelog.md
@@ -5,6 +5,7 @@
 ### Devices
 
 - Sensor: add DPT-3 datatypes "stepwise_dimming", "stepwise_blinds", "startstop_dimming", "startstop_blinds"
+- Light: It is now possible to control lights using individual group addresses for red, green, blue and white
 
 ### HA integration
 
@@ -12,6 +13,10 @@
 
 ### Internals
 
+- Tunnel connections process DisconnectRequest now and closes/reconnects the tunnel when the other side closes gracefully
+- XKNX.connected Event can be used in future to await for a working connection or stop/relaunch tasks if the connection is lost
+- Lower heartbeat rate from every 15sec to every 70 sec and raise ConnectionstateRequest timeout from 1 to 10sec (3/8/1 KNXip Overview §5.8 Timeout Constants)
+- clean up Tunnel class
 - refactored timeout handling in GatewayScanner, RequestResponse and ValueReader.
 - renamed "PhysicalAddress" to "IndividualAddress"
 - Telegram: `group_address` renamed to `destination_address`, to prepare support for other APCI services and add `source_address`
diff --git a/docs/light.md b/docs/light.md
index 4ba00f2b..549d4495 100644
--- a/docs/light.md
+++ b/docs/light.md
@@ -27,6 +27,24 @@ The Light object is either a representation of a binary or dimm actor, LED-contr
 - `group_address_tunable_white_state` KNX group address for the current relative color temperature. *DPT 5.001*
 - `group_address_color_temperature` KNX group address to set absolute color temperature. *DPT 7.600*
 - `group_address_color_temperature_state` KNX group address for the current absolute color temperature. *DPT 7.600*
+
+- `group_address_switch_red` KNX group address to switch the red component. *DPT 1.001*
+- `group_address_switch_red_state` KNX group address for the state of the red component. *DPT 1.001*
+- `group_address_brightness_red` KNX group address to set the brightness of the red component. *DPT 5.001*
+- `group_address_brightness_red_state` KNX group address for the current brightness of the red component. *DPT 5.001*
+- `group_address_switch_green` KNX group address to switch the green component. *DPT 1.001*
+- `group_address_switch_green_state` KNX group address for the state of the green component. *DPT 1.001*
+- `group_address_brightness_green` KNX group address to set the brightness of the green component. *DPT 5.001*
+- `group_address_brightness_green_state` KNX group address for the current brightness of the green component. *DPT 5.001*
+- `group_address_switch_blue` KNX group address to switch the blue component. *DPT 1.001*
+- `group_address_switch_blue_state` KNX group address for the state of the blue component. *DPT 1.001*
+- `group_address_brightness_blue` KNX group address to set the brightness of the blue component. *DPT 5.001*
+- `group_address_brightness_blue_state` KNX group address for the current brightness of the blue component. *DPT 5.001*
+- `group_address_switch_white` KNX group address to switch the white component. *DPT 1.001*
+- `group_address_switch_white_state` KNX group address for the state of the white component. *DPT 1.001*
+- `group_address_brightness_white` KNX group address to set the brightness of the white component. *DPT 5.001*
+- `group_address_brightness_white_state` KNX group address for the current brightness of the white component. *DPT 5.001*
+
 - `min_kelvin` lowest possible color temperature in Kelvin. Default: 2700
 - `max_kelvin` hightest possible color temperature in Kelvin. Default: 6000
 - `device_updated_cb` awaitable callback for each update.
@@ -93,6 +111,60 @@ print(light.current_color_temperature)
 await light.sync()
 ```
 
+## [](#header-2)Example: RGBW light with individual group addresses for red, green, blue and white
+
+```python
+light = Light(xknx,
+              name='TestRGBWLight',
+              group_address_switch_red="1/1/1",
+              group_address_switch_red_state="1/1/2",
+              group_address_brightness_red="1/1/3",
+              group_address_brightness_red_state="1/1/4",
+              group_address_switch_green="1/1/5",
+              group_address_switch_green_state="1/1/6",
+              group_address_brightness_green="1/1/7",
+              group_address_brightness_green_state="1/1/8",
+              group_address_switch_blue="1/1/9",
+              group_address_switch_blue_state="1/1/10",
+              group_address_brightness_blue="1/1/11",
+              group_address_brightness_blue_state="1/1/12",
+              group_address_switch_white="1/1/13",
+              group_address_switch_white_state="1/1/14",
+              group_address_brightness_white="1/1/15",
+              group_address_brightness_white_state="1/1/16")
+
+# Switching light on
+await light.set_on()
+
+# Switching light off
+await light.set_off()
+
+# Set color
+await light.set_color((20, 70,200))
+
+# Set rgbw color
+await light.set_color((20,70,200), 30)
+
+
+# Accessing light via 'do'
+await light.do('on')
+await light.do('off')
+await light.do('brightness:80')
+
+# Accessing state
+print(light.state)
+print(light.supports_brightness)
+print(light.supports_color)
+print(light.supports_rgbw)
+print(light.current_color)
+print(light.supports_tunable_white)
+print(light.supports_color_temperature)
+
+# Requesting current state via KNX GroupValueRead for all _state addresses
+await light.sync()
+
+## [](#header-2)Configuration via **xknx.yaml**
+```
 ## [](#header-2)Configuration via **xknx.yaml**
 
 Lights are usually configured via [`xknx.yaml`](/configuration):
@@ -117,5 +189,28 @@ groups:
         # Light with color temperature in percent
         Living-Room.Light_TW:  {group_address_switch: '1/6/21', group_address_switch_state: '1/6/20', group_address_brightness: '1/6/22', group_address_brightness_state: '1/6/23', group_address_tunable_white: '1/6/24',  group_address_tunable_white_state: '1/6/25'}
 
+        # Light with RGBW color
+        Kitchen.Light_rgbw:
+            {
+                individual_colors:
+                    {
+                        white: {group_address_switch: "1/6/4", group_address_switch_state: "1/6/5", group_address_brightness: "1/6/6", group_address_brightness_state: "1/6/7"},
+                        red: {group_address_switch: "1/6/14", group_address_switch_state: "1/6/15", group_address_brightness: "1/6/16", group_address_brightness_state: "1/6/17"},
+                        green: {group_address_switch: "1/6/24", group_address_switch_state: "1/6/25", group_address_brightness: "1/6/26", group_address_brightness_state: "1/6/27"},
+                        blue: {group_address_switch: "1/6/34", group_address_switch_state: "1/6/35", group_address_brightness: "1/6/36", group_address_brightness_state: "1/6/37"}
+                    }
+            }
+
+        # Light with RGB color and no white
+        Kitchen.Light_rgb:
+            {
+                individual_colors:
+                    {
+                        red: {group_address_switch: "1/6/14", group_address_switch_state: "1/6/15", group_address_brightness: "1/6/16", group_address_brightness_state: "1/6/17"},
+                        green: {group_address_switch: "1/6/24", group_address_switch_state: "1/6/25", group_address_brightness: "1/6/26", group_address_brightness_state: "1/6/27"},
+                        blue: {group_address_switch: "1/6/34", group_address_switch_state: "1/6/35", group_address_brightness: "1/6/36", group_address_brightness_state: "1/6/37"}
+                    }
+                r
+            }
 ```
 
diff --git a/examples/example_tunnel.py b/examples/example_tunnel.py
index 979adde3..86a83c00 100644
--- a/examples/example_tunnel.py
+++ b/examples/example_tunnel.py
@@ -35,7 +35,6 @@ async def main():
         gateway_port=gateway.port,
     )
 
-    await tunnel.connect_udp()
     await tunnel.connect()
 
     await tunnel.send_telegram(
@@ -53,7 +52,6 @@ async def main():
     )
     await asyncio.sleep(2)
 
-    await tunnel.connectionstate()
     await tunnel.disconnect()
 
 
diff --git a/home-assistant-plugin/custom_components/xknx/cover.py b/home-assistant-plugin/custom_components/xknx/cover.py
index f1d046a8..b88b1cfe 100644
--- a/home-assistant-plugin/custom_components/xknx/cover.py
+++ b/home-assistant-plugin/custom_components/xknx/cover.py
@@ -91,11 +91,6 @@ class KNXCover(KnxEntity, CoverEntity):
         """Return if the cover is closing or not."""
         return self._device.is_closing()
 
-    @property
-    def assumed_state(self) -> bool:
-        """Return True if unable to access real state of the entity."""
-        return self._device.current_position() is None
-
     async def async_close_cover(self, **kwargs):
         """Close the cover."""
         await self._device.set_down()
diff --git a/home-assistant-plugin/custom_components/xknx/factory.py b/home-assistant-plugin/custom_components/xknx/factory.py
index 9fe70bf3..cee6b67a 100644
--- a/home-assistant-plugin/custom_components/xknx/factory.py
+++ b/home-assistant-plugin/custom_components/xknx/factory.py
@@ -1,4 +1,6 @@
 """Factory function to initialize KNX devices from config."""
+from typing import Optional, Tuple
+
 from xknx import XKNX
 from xknx.devices import (
     BinarySensor as XknxBinarySensor,
@@ -86,8 +88,30 @@ def _create_cover(knx_module: XKNX, config: ConfigType) -> XknxCover:
     )
 
 
+def _create_light_color(
+    color: str, config: ConfigType
+) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
+    """Load color configuration from configuration structure."""
+    if "individual_colors" in config and color in config["individual_colors"]:
+        sub_config = config["individual_colors"][color]
+        group_address_switch = sub_config.get(CONF_ADDRESS)
+        group_address_switch_state = sub_config.get(LightSchema.CONF_STATE_ADDRESS)
+        group_address_brightness = sub_config.get(LightSchema.CONF_BRIGHTNESS_ADDRESS)
+        group_address_brightness_state = sub_config.get(
+            LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS
+        )
+        return (
+            group_address_switch,
+            group_address_switch_state,
+            group_address_brightness,
+            group_address_brightness_state,
+        )
+    return None, None, None, None
+
+
 def _create_light(knx_module: XKNX, config: ConfigType) -> XknxLight:
     """Return a KNX Light device to be used within XKNX."""
+
     group_address_tunable_white = None
     group_address_tunable_white_state = None
     group_address_color_temp = None
@@ -103,10 +127,35 @@ def _create_light(knx_module: XKNX, config: ConfigType) -> XknxLight:
             LightSchema.CONF_COLOR_TEMP_STATE_ADDRESS
         )
 
+    (
+        red_switch,
+        red_switch_state,
+        red_brightness,
+        red_brightness_state,
+    ) = _create_light_color(LightSchema.CONF_RED, config)
+    (
+        green_switch,
+        green_switch_state,
+        green_brightness,
+        green_brightness_state,
+    ) = _create_light_color(LightSchema.CONF_GREEN, config)
+    (
+        blue_switch,
+        blue_switch_state,
+        blue_brightness,
+        blue_brightness_state,
+    ) = _create_light_color(LightSchema.CONF_BLUE, config)
+    (
+        white_switch,
+        white_switch_state,
+        white_brightness,
+        white_brightness_state,
+    ) = _create_light_color(LightSchema.CONF_WHITE, config)
+
     return XknxLight(
         knx_module,
         name=config[CONF_NAME],
-        group_address_switch=config[CONF_ADDRESS],
+        group_address_switch=config.get(CONF_ADDRESS),
         group_address_switch_state=config.get(LightSchema.CONF_STATE_ADDRESS),
         group_address_brightness=config.get(LightSchema.CONF_BRIGHTNESS_ADDRESS),
         group_address_brightness_state=config.get(
@@ -120,6 +169,22 @@ def _create_light(knx_module: XKNX, config: ConfigType) -> XknxLight:
         group_address_tunable_white_state=group_address_tunable_white_state,
         group_address_color_temperature=group_address_color_temp,
         group_address_color_temperature_state=group_address_color_temp_state,
+        group_address_switch_red=red_switch,
+        group_address_switch_red_state=red_switch_state,
+        group_address_brightness_red=red_brightness,
+        group_address_brightness_red_state=red_brightness_state,
+        group_address_switch_green=green_switch,
+        group_address_switch_green_state=green_switch_state,
+        group_address_brightness_green=green_brightness,
+        group_address_brightness_green_state=green_brightness_state,
+        group_address_switch_blue=blue_switch,
+        group_address_switch_blue_state=blue_switch_state,
+        group_address_brightness_blue=blue_brightness,
+        group_address_brightness_blue_state=blue_brightness_state,
+        group_address_switch_white=white_switch,
+        group_address_switch_white_state=white_switch_state,
+        group_address_brightness_white=white_brightness,
+        group_address_brightness_white_state=white_brightness_state,
         min_kelvin=config[LightSchema.CONF_MIN_KELVIN],
         max_kelvin=config[LightSchema.CONF_MAX_KELVIN],
     )
diff --git a/home-assistant-plugin/custom_components/xknx/light.py b/home-assistant-plugin/custom_components/xknx/light.py
index cb65d147..d9f0f9c0 100644
--- a/home-assistant-plugin/custom_components/xknx/light.py
+++ b/home-assistant-plugin/custom_components/xknx/light.py
@@ -202,8 +202,3 @@ class KNXLight(KnxEntity, LightEntity):
     async def async_turn_off(self, **kwargs):
         """Turn the light off."""
         await self._device.set_off()
-
-    @property
-    def assumed_state(self) -> bool:
-        """Return True if unable to access real state of the entity."""
-        return self._device.state is None
diff --git a/home-assistant-plugin/custom_components/xknx/schema.py b/home-assistant-plugin/custom_components/xknx/schema.py
index 54fd3f1c..230f6aca 100644
--- a/home-assistant-plugin/custom_components/xknx/schema.py
+++ b/home-assistant-plugin/custom_components/xknx/schema.py
@@ -139,29 +139,70 @@ class LightSchema:
     DEFAULT_MIN_KELVIN = 2700  # 370 mireds
     DEFAULT_MAX_KELVIN = 6000  # 166 mireds
 
-    SCHEMA = vol.Schema(
+    CONF_INDIVIDUAL_COLORS = "individual_colors"
+    CONF_RED = "red"
+    CONF_GREEN = "green"
+    CONF_BLUE = "blue"
+    CONF_WHITE = "white"
+
+    COLOR_SCHEMA = vol.Schema(
         {
-            vol.Required(CONF_ADDRESS): cv.string,
-            vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+            vol.Optional(CONF_ADDRESS): cv.string,
             vol.Optional(CONF_STATE_ADDRESS): cv.string,
-            vol.Optional(CONF_BRIGHTNESS_ADDRESS): cv.string,
+            vol.Required(CONF_BRIGHTNESS_ADDRESS): cv.string,
             vol.Optional(CONF_BRIGHTNESS_STATE_ADDRESS): cv.string,
-            vol.Optional(CONF_COLOR_ADDRESS): cv.string,
-            vol.Optional(CONF_COLOR_STATE_ADDRESS): cv.string,
-            vol.Optional(CONF_COLOR_TEMP_ADDRESS): cv.string,
-            vol.Optional(CONF_COLOR_TEMP_STATE_ADDRESS): cv.string,
-            vol.Optional(
-                CONF_COLOR_TEMP_MODE, default=DEFAULT_COLOR_TEMP_MODE
-            ): cv.enum(ColorTempModes),
-            vol.Optional(CONF_RGBW_ADDRESS): cv.string,
-            vol.Optional(CONF_RGBW_STATE_ADDRESS): cv.string,
-            vol.Optional(CONF_MIN_KELVIN, default=DEFAULT_MIN_KELVIN): vol.All(
-                vol.Coerce(int), vol.Range(min=1)
+        }
+    )
+
+    SCHEMA = vol.All(
+        vol.Schema(
+            {
+                vol.Optional(CONF_ADDRESS): cv.string,
+                vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+                vol.Optional(CONF_STATE_ADDRESS): cv.string,
+                vol.Optional(CONF_BRIGHTNESS_ADDRESS): cv.string,
+                vol.Optional(CONF_BRIGHTNESS_STATE_ADDRESS): cv.string,
+                vol.Exclusive(CONF_INDIVIDUAL_COLORS, "color"): {
+                    vol.Inclusive(CONF_RED, "colors"): COLOR_SCHEMA,
+                    vol.Inclusive(CONF_GREEN, "colors"): COLOR_SCHEMA,
+                    vol.Inclusive(CONF_BLUE, "colors"): COLOR_SCHEMA,
+                    vol.Optional(CONF_WHITE): COLOR_SCHEMA,
+                },
+                vol.Exclusive(CONF_COLOR_ADDRESS, "color"): cv.string,
+                vol.Optional(CONF_COLOR_STATE_ADDRESS): cv.string,
+                vol.Optional(CONF_COLOR_TEMP_ADDRESS): cv.string,
+                vol.Optional(CONF_COLOR_TEMP_STATE_ADDRESS): cv.string,
+                vol.Optional(
+                    CONF_COLOR_TEMP_MODE, default=DEFAULT_COLOR_TEMP_MODE
+                ): cv.enum(ColorTempModes),
+                vol.Exclusive(CONF_RGBW_ADDRESS, "color"): cv.string,
+                vol.Optional(CONF_RGBW_STATE_ADDRESS): cv.string,
+                vol.Optional(CONF_MIN_KELVIN, default=DEFAULT_MIN_KELVIN): vol.All(
+                    vol.Coerce(int), vol.Range(min=1)
+                ),
+                vol.Optional(CONF_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All(
+                    vol.Coerce(int), vol.Range(min=1)
+                ),
+            }
+        ),
+        vol.Any(
+            vol.Schema(
+                {
+                    vol.Required(CONF_INDIVIDUAL_COLORS): {
+                        vol.Required(CONF_RED): {vol.Required(CONF_ADDRESS): object},
+                        vol.Required(CONF_GREEN): {vol.Required(CONF_ADDRESS): object},
+                        vol.Required(CONF_BLUE): {vol.Required(CONF_ADDRESS): object},
+                    },
+                },
+                extra=vol.ALLOW_EXTRA,
             ),
-            vol.Optional(CONF_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All(
-                vol.Coerce(int), vol.Range(min=1)
+            vol.Schema(
+                {
+                    vol.Required(CONF_ADDRESS): object,
+                },
+                extra=vol.ALLOW_EXTRA,
             ),
-        }
+        ),
     )
 
 
diff --git a/home-assistant-plugin/custom_components/xknx/switch.py b/home-assistant-plugin/custom_components/xknx/switch.py
index 048ec443..ae3048e2 100644
--- a/home-assistant-plugin/custom_components/xknx/switch.py
+++ b/home-assistant-plugin/custom_components/xknx/switch.py
@@ -35,8 +35,3 @@ class KNXSwitch(KnxEntity, SwitchEntity):
     async def async_turn_off(self, **kwargs):
         """Turn the device off."""
         await self._device.set_off()
-
-    @property
-    def assumed_state(self) -> bool:
-        """Return True if unable to access real state of the entity."""
-        return self._device.state is None
diff --git a/setup.cfg b/setup.cfg
index 12ac9c72..6e12973a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -42,7 +42,7 @@ warn_redundant_casts = true
 warn_unused_configs = true
 
 # add the modules below once we add typing for them so that we fail the build in the future if someone changes something without updating the typings
-[mypy-xknx.core.*,xknx.devices.travelcalculator,xknx.exceptions.*,xknx.io.gateway_scanner,xknx.telegram.*,]
+[mypy-xknx.core.*,xknx.devices.travelcalculator,xknx.exceptions.*,xknx.io.gateway_scanner,xknx.remote_value.remote_value,xknx.telegram.*,]
 strict = true
 ignore_errors = false
 warn_unreachable = true
diff --git a/xknx.yaml b/xknx.yaml
index 18dd1142..a960c046 100644
--- a/xknx.yaml
+++ b/xknx.yaml
@@ -157,6 +157,41 @@ groups:
                 group_address_color: "1/6/7",
                 group_address_color_state: "1/6/8",
             }
+        # Light with color
+        Diningroom.Light_rgb:
+            {
+                individual_colors:
+                    {
+                        white:
+                            {
+                                group_address_switch: "1/6/4",
+                                group_address_switch_state: "1/6/5",
+                                group_address_brightness: "1/6/6",
+                                group_address_brightness_state: "1/6/7",
+                            },
+                        red:
+                            {
+                                group_address_switch: "1/6/14",
+                                group_address_switch_state: "1/6/15",
+                                group_address_brightness: "1/6/16",
+                                group_address_brightness_state: "1/6/17",
+                            },
+                        green:
+                            {
+                                group_address_switch: "1/6/24",
+                                group_address_switch_state: "1/6/25",
+                                group_address_brightness: "1/6/26",
+                                group_address_brightness_state: "1/6/27",
+                            },
+                        blue:
+                            {
+                                group_address_switch: "1/6/34",
+                                group_address_switch_state: "1/6/35",
+                                group_address_brightness: "1/6/36",
+                                group_address_brightness_state: "1/6/37",
+                            }
+                    }
+            }
         # Light with color temperature
         Living-Room.Light_CT:
             {
diff --git a/xknx/config/config_v1.py b/xknx/config/config_v1.py
index be3f36bb..02b9dfd2 100644
--- a/xknx/config/config_v1.py
+++ b/xknx/config/config_v1.py
@@ -23,6 +23,7 @@ from xknx.devices import (
 )
 from xknx.exceptions import XKNXException
 from xknx.io import ConnectionConfig, ConnectionType
+from xknx.telegram import IndividualAddress
 
 logger = logging.getLogger("xknx.log")
 
@@ -51,7 +52,7 @@ class ConfigV1:
         """Parse the general section of xknx.yaml."""
         if "general" in doc:
             if "own_address" in doc["general"]:
-                self.xknx.own_address = doc["general"]["own_address"]
+                self.xknx.own_address = IndividualAddress(doc["general"]["own_address"])
             if "rate_limit" in doc["general"]:
                 self.xknx.rate_limit = doc["general"]["rate_limit"]
             if "multicast_group" in doc["general"]:
diff --git a/xknx/config/schema.py b/xknx/config/schema.py
index 5cc6ef65..c49fd032 100644
--- a/xknx/config/schema.py
+++ b/xknx/config/schema.py
@@ -147,46 +147,99 @@ class LightSchema:
     CONF_RGBW = "rgbw"
     CONF_COLOR_TEMPERATURE = "color_temperature"
     CONF_COLOR = "color"
+    CONF_INDIVIDUAL_COLORS = "individual_colors"
 
     CONF_COLOR_TEMP_MODE = "mode"
     CONF_MIN_KELVIN = "min_kelvin"
     CONF_MAX_KELVIN = "max_kelvin"
 
+    CONF_RED = "red"
+    CONF_GREEN = "green"
+    CONF_BLUE = "blue"
+    CONF_WHITE = "white"
+
     DEFAULT_COLOR_TEMP_MODE = "absolute"
     DEFAULT_MIN_KELVIN = 2700  # 370 mireds
     DEFAULT_MAX_KELVIN = 6000  # 166 mireds
 
-    SCHEMA = BaseDeviceSchema.SCHEMA.extend(
+    COLOR_SCHEMA = vol.Schema(
         {
-            vol.Required(CONF_SWITCH): RemoteValueSchema.SCHEMA.extend(
-                {
-                    vol.Required(CONF_ADDRESS): ensure_group_address,
-                }
-            ),
-            vol.Optional(CONF_BRIGHTNESS): RemoteValueSchema.SCHEMA.extend(
-                {vol.Required(CONF_ADDRESS): ensure_group_address}
-            ),
-            vol.Optional(CONF_RGBW): RemoteValueSchema.SCHEMA.extend(
-                {vol.Required(CONF_ADDRESS): ensure_group_address}
-            ),
-            vol.Optional(CONF_COLOR): RemoteValueSchema.SCHEMA.extend(
+            vol.Optional(CONF_SWITCH): RemoteValueSchema.SCHEMA,
+            vol.Required(CONF_BRIGHTNESS): RemoteValueSchema.SCHEMA.extend(
                 {vol.Required(CONF_ADDRESS): ensure_group_address}
             ),
-            vol.Optional(CONF_COLOR_TEMPERATURE): RemoteValueSchema.SCHEMA.extend(
+        }
+    )
+
+    SCHEMA = vol.All(
+        BaseDeviceSchema.SCHEMA.extend(
+            {
+                vol.Optional(CONF_SWITCH): RemoteValueSchema.SCHEMA.extend(
+                    {vol.Required(CONF_ADDRESS): ensure_group_address}
+                ),
+                vol.Optional(CONF_BRIGHTNESS): RemoteValueSchema.SCHEMA.extend(
+                    {vol.Required(CONF_ADDRESS): ensure_group_address}
+                ),
+                vol.Exclusive(CONF_RGBW, "color"): RemoteValueSchema.SCHEMA.extend(
+                    {vol.Required(CONF_ADDRESS): ensure_group_address}
+                ),
+                vol.Exclusive(CONF_COLOR, "color"): RemoteValueSchema.SCHEMA.extend(
+                    {vol.Required(CONF_ADDRESS): ensure_group_address}
+                ),
+                vol.Exclusive(CONF_INDIVIDUAL_COLORS, "color"): {
+                    vol.Inclusive(CONF_RED, "colors"): COLOR_SCHEMA,
+                    vol.Inclusive(CONF_GREEN, "colors"): COLOR_SCHEMA,
+                    vol.Inclusive(CONF_BLUE, "colors"): COLOR_SCHEMA,
+                    vol.Optional(CONF_WHITE): COLOR_SCHEMA,
+                },
+                vol.Optional(CONF_COLOR_TEMPERATURE): RemoteValueSchema.SCHEMA.extend(
+                    {
+                        vol.Required(CONF_ADDRESS): ensure_group_address,
+                        vol.Optional(
+                            CONF_COLOR_TEMP_MODE, default=DEFAULT_COLOR_TEMP_MODE
+                        ): enum(ColorTempModes),
+                        vol.Optional(
+                            CONF_MIN_KELVIN, default=DEFAULT_MIN_KELVIN
+                        ): vol.All(vol.Coerce(int), vol.Range(min=1)),
+                        vol.Optional(
+                            CONF_MAX_KELVIN, default=DEFAULT_MAX_KELVIN
+                        ): vol.All(vol.Coerce(int), vol.Range(min=1)),
+                    }
+                ),
+            },
+        ),
+        vol.Any(
+            vol.Schema(
                 {
-                    vol.Required(CONF_ADDRESS): ensure_group_address,
-                    vol.Optional(
-                        CONF_COLOR_TEMP_MODE, default=DEFAULT_COLOR_TEMP_MODE
-                    ): enum(ColorTempModes),
-                    vol.Optional(CONF_MIN_KELVIN, default=DEFAULT_MIN_KELVIN): vol.All(
-                        vol.Coerce(int), vol.Range(min=1)
-                    ),
-                    vol.Optional(CONF_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All(
-                        vol.Coerce(int), vol.Range(min=1)
-                    ),
-                }
+                    vol.Required(CONF_SWITCH): RemoteValueSchema.SCHEMA.extend(
+                        {vol.Required(CONF_ADDRESS): object}
+                    )
+                },
+                extra=vol.ALLOW_EXTRA,
             ),
-        }
+            vol.Schema(
+                {
+                    vol.Required(CONF_INDIVIDUAL_COLORS): {
+                        vol.Required(CONF_RED): {
+                            vol.Required(CONF_SWITCH): {
+                                vol.Required(CONF_ADDRESS): object
+                            }
+                        },
+                        vol.Required(CONF_GREEN): {
+                            vol.Required(CONF_SWITCH): {
+                                vol.Required(CONF_ADDRESS): object
+                            }
+                        },
+                        vol.Required(CONF_BLUE): {
+                            vol.Required(CONF_SWITCH): {
+                                vol.Required(CONF_ADDRESS): object
+                            }
+                        },
+                    },
+                },
+                extra=vol.ALLOW_EXTRA,
+            ),
+        ),
     )
 
 
diff --git a/xknx/devices/light.py b/xknx/devices/light.py
index 34480547..91be195a 100644
--- a/xknx/devices/light.py
+++ b/xknx/devices/light.py
@@ -12,7 +12,7 @@ It provides functionality for
 """
 from enum import Enum
 import logging
-from typing import Optional
+from typing import Optional, Tuple
 
 from xknx.remote_value import (
     RemoteValueColorRGB,
@@ -34,6 +34,57 @@ class ColorTempModes(Enum):
     relative = "DPT-5.001"
 
 
+class _SwitchAndBrightness:
+    def __init__(
+        self,
+        xknx,
+        name,
+        feature_name: str,
+        group_address_switch=None,
+        group_address_switch_state=None,
+        group_address_brightness=None,
+        group_address_brightness_state=None,
+        after_update_cb=None,
+    ):
+        self.switch = RemoteValueSwitch(
+            xknx,
+            group_address_switch,
+            group_address_switch_state,
+            device_name=name,
+            feature_name=feature_name + "_state",
+            after_update_cb=after_update_cb,
+        )
+        self.brightness = RemoteValueScaling(
+            xknx,
+            group_address_brightness,
+            group_address_brightness_state,
+            device_name=name,
+            feature_name=feature_name + "_brightness",
+            after_update_cb=after_update_cb,
+            range_from=0,
+            range_to=255,
+        )
+
+    @property
+    def is_on(self):
+        """Return if light is on."""
+        return self.switch.value
+
+    async def set_on(self):
+        """Switch light on."""
+        if self.switch.initialized:
+            await self.switch.on()
+
+    async def set_off(self):
+        """Switch light off."""
+        if self.switch.initialized:
+            await self.switch.off()
+
+    def __eq__(self, other):
+        """Compare for quality."""
+        return self.__dict__ == other.__dict__
+
+
 # pylint: disable=too-many-public-methods, too-many-instance-attributes
 class Light(Device):
     """Class for managing a light."""
@@ -58,6 +109,22 @@ class Light(Device):
         group_address_tunable_white_state=None,
         group_address_color_temperature=None,
         group_address_color_temperature_state=None,
+        group_address_switch_red=None,
+        group_address_switch_red_state=None,
+        group_address_brightness_red=None,
+        group_address_brightness_red_state=None,
+        group_address_switch_green=None,
+        group_address_switch_green_state=None,
+        group_address_brightness_green=None,
+        group_address_brightness_green_state=None,
+        group_address_switch_blue=None,
+        group_address_switch_blue_state=None,
+        group_address_brightness_blue=None,
+        group_address_brightness_blue_state=None,
+        group_address_switch_white=None,
+        group_address_switch_white_state=None,
+        group_address_brightness_white=None,
+        group_address_brightness_white_state=None,
         min_kelvin=None,
         max_kelvin=None,
         device_updated_cb=None,
@@ -122,6 +189,50 @@ class Light(Device):
             after_update_cb=self.after_update,
         )
 
+        self.red = _SwitchAndBrightness(
+            xknx,
+            self.name,
+            "red",
+            group_address_switch_red,
+            group_address_switch_red_state,
+            group_address_brightness_red,
+            group_address_brightness_red_state,
+            self.after_update,
+        )
+
+        self.green = _SwitchAndBrightness(
+            xknx,
+            self.name,
+            "green",
+            group_address_switch_green,
+            group_address_switch_green_state,
+            group_address_brightness_green,
+            group_address_brightness_green_state,
+            self.after_update,
+        )
+
+        self.blue = _SwitchAndBrightness(
+            xknx,
+            self.name,
+            "blue",
+            group_address_switch_blue,
+            group_address_switch_blue_state,
+            group_address_brightness_blue,
+            group_address_brightness_blue_state,
+            self.after_update,
+        )
+
+        self.white = _SwitchAndBrightness(
+            xknx,
+            self.name,
+            "white",
+            group_address_switch_white,
+            group_address_switch_white_state,
+            group_address_brightness_white,
+            group_address_brightness_white_state,
+            self.after_update,
+        )
+
         self.min_kelvin = min_kelvin
         self.max_kelvin = max_kelvin
 
@@ -135,6 +246,9 @@ class Light(Device):
             self.tunable_white,
             self.color_temperature,
         )
+        for color in (self.red, self.green, self.blue, self.white):
+            yield color.switch
+            yield color.brightness
 
     @property
     def supports_brightness(self):
@@ -144,12 +258,19 @@ class Light(Device):
     @property
     def supports_color(self):
         """Return if light supports color."""
-        return self.color.initialized
+        return self.color.initialized or all(
+            [c.brightness.initialized for c in (self.red, self.green, self.blue)]
+        )
 
     @property
     def supports_rgbw(self):
         """Return if light supports RGBW."""
-        return self.rgbw.initialized
+        return self.rgbw.initialized or all(
+            [
+                c.brightness.initialized
+                for c in (self.red, self.green, self.blue, self.white)
+            ]
+        )
 
     @property
     def supports_tunable_white(self):
@@ -161,6 +282,21 @@ class Light(Device):
         """Return if light supports absolute color temperature."""
         return self.color_temperature.initialized
 
+    @classmethod
+    def read_color_from_config(
+        cls, color, config
+    ) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
+        """Load color configuration from configuration structure."""
+        if "individual_colors" in config and color in config["individual_colors"]:
+            sub_config = config["individual_colors"][color]
+            return (
+                sub_config.get("group_address_switch"),
+                sub_config.get("group_address_switch_state"),
+                sub_config.get("group_address_brightness"),
+                sub_config.get("group_address_brightness_state"),
+            )
+        return None, None, None, None
+
     @classmethod
     def from_config(cls, xknx, name, config):
         """Initialize object from configuration structure."""
@@ -183,6 +319,31 @@ class Light(Device):
         min_kelvin = config.get("min_kelvin", Light.DEFAULT_MIN_KELVIN)
         max_kelvin = config.get("max_kelvin", Light.DEFAULT_MAX_KELVIN)
 
+        (
+            red_switch,
+            red_switch_state,
+            red_brightness,
+            red_brightness_state,
+        ) = cls.read_color_from_config("red", config)
+        (
+            green_switch,
+            green_switch_state,
+            green_brightness,
+            green_brightness_state,
+        ) = cls.read_color_from_config("green", config)
+        (
+            blue_switch,
+            blue_switch_state,
+            blue_brightness,
+            blue_brightness_state,
+        ) = cls.read_color_from_config("blue", config)
+        (
+            white_switch,
+            white_switch_state,
+            white_brightness,
+            white_brightness_state,
+        ) = cls.read_color_from_config("white", config)
+
         return cls(
             xknx,
             name,
@@ -198,6 +359,22 @@ class Light(Device):
             group_address_tunable_white_state=group_address_tunable_white_state,
             group_address_color_temperature=group_address_color_temperature,
             group_address_color_temperature_state=group_address_color_temperature_state,
+            group_address_switch_red=red_switch,
+            group_address_switch_red_state=red_switch_state,
+            group_address_brightness_red=red_brightness,
+            group_address_brightness_red_state=red_brightness_state,
+            group_address_switch_green=green_switch,
+            group_address_switch_green_state=green_switch_state,
+            group_address_brightness_green=green_brightness,
+            group_address_brightness_green_state=green_brightness_state,
+            group_address_switch_blue=blue_switch,
+            group_address_switch_blue_state=blue_switch_state,
+            group_address_brightness_blue=blue_brightness,
+            group_address_brightness_blue_state=blue_brightness_state,
+            group_address_switch_white=white_switch,
+            group_address_switch_white_state=white_switch_state,
+            group_address_brightness_white=white_brightness,
+            group_address_brightness_white_state=white_brightness_state,
             min_kelvin=min_kelvin,
             max_kelvin=max_kelvin,
         )
@@ -232,7 +409,51 @@ class Light(Device):
             )
         )
 
-        return '<Light name="{}" ' 'switch="{}"{}{}{}{}{} />'.format(
+        str_red_state = (
+            ""
+            if not self.red.switch.initialized
+            else f' red_state="{self.red.switch.group_addr_str()}"'
+        )
+        str_red_brightness = (
+            ""
+            if not self.red.brightness.initialized
+            else f' red_brightness="{self.red.brightness.group_addr_str()}"'
+        )
+
+        str_green_state = (
+            ""
+            if not self.green.switch.initialized
+            else f' green_state="{self.green.switch.group_addr_str()}"'
+        )
+        str_green_brightness = (
+            ""
+            if not self.green.brightness.initialized
+            else f' green_brightness="{self.green.brightness.group_addr_str()}"'
+        )
+
+        str_blue_state = (
+            ""
+            if not self.blue.switch.initialized
+            else f' blue_state="{self.blue.switch.group_addr_str()}"'
+        )
+        str_blue_brightness = (
+            ""
+            if not self.blue.brightness.initialized
+            else f' blue_brightness="{self.blue.brightness.group_addr_str()}"'
+        )
+
+        str_white_state = (
+            ""
+            if not self.white.switch.initialized
+            else f' white_state="{self.white.switch.group_addr_str()}"'
+        )
+        str_white_brightness = (
+            ""
+            if not self.white.brightness.initialized
+            else f' white_brightness="{self.white.brightness.group_addr_str()}"'
+        )
+
+        return '<Light name="{}" ' 'switch="{}"{}{}{}{}{}{}{}{}{}{}{}{}{} />'.format(
             self.name,
             self.switch.group_addr_str(),
             str_brightness,
@@ -240,20 +461,45 @@ class Light(Device):
             str_rgbw,
             str_tunable_white,
             str_color_temperature,
+            str_red_state,
+            str_red_brightness,
+            str_green_state,
+            str_green_brightness,
+            str_blue_state,
+            str_blue_brightness,
+            str_white_state,
+            str_white_brightness,
         )
 
     @property
     def state(self) -> Optional[bool]:
         """Return the current switch state of the device."""
-        return self.switch.value
+        if self.switch.value is not None:
+            return self.switch.value
+        if any(
+            [
+                c.switch.value is not None
+                for c in (self.red, self.green, self.blue, self.white)
+            ]
+        ):
+            return any(
+                [c.switch.value for c in (self.red, self.green, self.blue, self.white)]
+            )
+        return None
 
     async def set_on(self):
         """Switch light on."""
-        await self.switch.on()
+        if self.switch.initialized:
+            await self.switch.on()
+        for color in (self.red, self.green, self.blue, self.white):
+            await color.set_on()
 
     async def set_off(self):
         """Switch light off."""
-        await self.switch.off()
+        if self.switch.initialized:
+            await self.switch.off()
+        for color in (self.red, self.green, self.blue, self.white):
+            await color.set_off()
 
     @property
     def current_brightness(self):
@@ -275,10 +521,21 @@ class Light(Device):
         If the device supports RGBW, get the current RGB+White values instead.
         """
         if self.supports_rgbw:
-            if not self.rgbw.value:
-                return None, None
-            return self.rgbw.value[:3], self.rgbw.value[3]
-        return self.color.value, None
+            if self.rgbw.initialized:
+                if not self.rgbw.value:
+                    return None, None
+                return self.rgbw.value[:3], self.rgbw.value[3]
+        if self.color.initialized:
+            return self.color.value, None
+        # individual RGB addresses - white will return None when it is not initialized
+        colors = [
+            self.red.brightness.value,
+            self.green.brightness.value,
+            self.blue.brightness.value,
+        ]
+        if None in colors:
+            return None, self.white.brightness.value
+        return colors, self.white.brightness.value
 
     async def set_color(self, color, white=None):
         """
@@ -289,13 +546,36 @@ class Light(Device):
         """
         if white is not None:
             if self.supports_rgbw:
-                await self.rgbw.set(list(color) + [white])
-                return
+                if self.rgbw.initialized:
+                    await self.rgbw.set(list(color) + [white])
+                    return
+                if all(
+                    [
+                        c.brightness.initialized
+                        for c in (self.red, self.green, self.blue, self.white)
+                    ]
+                ):
+                    await self.red.brightness.set(color[0])
+                    await self.green.brightness.set(color[1])
+                    await self.blue.brightness.set(color[2])
+                    await self.white.brightness.set(white)
+                    return
             logger.warning("RGBW not supported for device %s", self.get_name())
         else:
             if self.supports_color:
-                await self.color.set(color)
-                return
+                if self.color.initialized:
+                    await self.color.set(color)
+                    return
+                if all(
+                    [
+                        c.brightness.initialized
+                        for c in (self.red, self.green, self.blue)
+                    ]
+                ):
+                    await self.red.brightness.set(color[0])
+                    await self.green.brightness.set(color[1])
+                    await self.blue.brightness.set(color[2])
+                    return
             logger.warning("Colors not supported for device %s", self.get_name())
 
     @property
diff --git a/xknx/exceptions/exception.py b/xknx/exceptions/exception.py
index f8cf90dd..649c4b19 100644
--- a/xknx/exceptions/exception.py
+++ b/xknx/exceptions/exception.py
@@ -1,5 +1,5 @@
 """Module for XKXN Exceptions."""
-from typing import Any, Dict, Optional, Tuple, Union
+from typing import Any, Optional, Tuple, Union
 
 
 class XKNXException(Exception):
@@ -31,7 +31,7 @@ class CommunicationError(XKNXException):
 class CouldNotParseTelegram(XKNXException):
     """Could not parse telegram error."""
 
-    def __init__(self, description: str, **kwargs: Dict[str, str]) -> None:
+    def __init__(self, description: str, **kwargs: str) -> None:
         """Initialize CouldNotParseTelegram class."""
         super().__init__()
         self.description = description
@@ -78,7 +78,7 @@ class UnsupportedCEMIMessage(XKNXException):
 class ConversionError(XKNXException):
     """Exception class for error while converting one type to another."""
 
-    def __init__(self, description: str, **kwargs: Dict[str, str]) -> None:
+    def __init__(self, description: str, **kwargs: str) -> None:
         """Initialize ConversionError class."""
         super().__init__()
         self.description = description
diff --git a/xknx/io/connectionstate.py b/xknx/io/connectionstate.py
index 403b273a..906a2598 100644
--- a/xknx/io/connectionstate.py
+++ b/xknx/io/connectionstate.py
@@ -1,6 +1,7 @@
 """Abstraction to send ConnectonStateRequest and wait for ConnectionStateResponse."""
 from xknx.knxip import HPAI, ConnectionStateRequest, ConnectionStateResponse, KNXIPFrame
 
+from .const import CONNECTIONSTATE_REQUEST_TIMEOUT
 from .request_response import RequestResponse
 
 
@@ -10,7 +11,12 @@ class ConnectionState(RequestResponse):
     def __init__(self, xknx, udp_client, communication_channel_id):
         """Initialize ConnectionState class."""
         self.udp_client = udp_client
-        super().__init__(xknx, self.udp_client, ConnectionStateResponse)
+        super().__init__(
+            xknx,
+            self.udp_client,
+            ConnectionStateResponse,
+            timeout_in_seconds=CONNECTIONSTATE_REQUEST_TIMEOUT,
+        )
         self.communication_channel_id = communication_channel_id
 
     def create_knxipframe(self) -> KNXIPFrame:
diff --git a/xknx/io/const.py b/xknx/io/const.py
index 7951cad3..fed3c37e 100644
--- a/xknx/io/const.py
+++ b/xknx/io/const.py
@@ -2,3 +2,7 @@
 
 DEFAULT_MCAST_GRP = "224.0.23.12"
 DEFAULT_MCAST_PORT = 3671
+
+CONNECTION_ALIVE_TIME = 120
+CONNECTIONSTATE_REQUEST_TIMEOUT = 10
+HEARTBEAT_RATE = CONNECTION_ALIVE_TIME - (CONNECTIONSTATE_REQUEST_TIMEOUT * 5)
diff --git a/xknx/io/interface.py b/xknx/io/interface.py
new file mode 100644
index 00000000..20227adf
--- /dev/null
+++ b/xknx/io/interface.py
@@ -0,0 +1,26 @@
+"""
+Abstract base for a specific KNX/IP connection (Tunneling or Routing).
+
+* It handles connection and disconnections
+* It starts and stops a udp client
+* It packs Telegrams into KNX Frames and passes them to a udp client
+"""
+from abc import ABC, abstractmethod
+
+from xknx.telegram import Telegram
+
+
+class Interface(ABC):
+    """Abstract base class for KNX/IP connections."""
+
+    @abstractmethod
+    async def connect(self) -> bool:
+        """Connect to KNX bus. Returns True on success."""
+
+    @abstractmethod
+    async def disconnect(self) -> None:
+        """Disconnect from KNX bus."""
+
+    @abstractmethod
+    async def send_telegram(self, telegram: Telegram) -> None:
+        """Send Telegram to KNX bus."""
diff --git a/xknx/io/knxip_interface.py b/xknx/io/knxip_interface.py
index 27fd0ad1..c778d75c 100644
--- a/xknx/io/knxip_interface.py
+++ b/xknx/io/knxip_interface.py
@@ -150,19 +150,19 @@ class KNXIPInterface:
             auto_reconnect=auto_reconnect,
             auto_reconnect_wait=auto_reconnect_wait,
         )
-        await self.interface.start()
+        await self.interface.connect()
 
     async def start_routing(self, local_ip: str) -> None:
         """Start KNX/IP Routing."""
         validate_ip(local_ip, address_name="Local IP address")
         logger.debug("Starting Routing from %s as %s", local_ip, self.xknx.own_address)
         self.interface = Routing(self.xknx, self.telegram_received, local_ip)
-        await self.interface.start()
+        await self.interface.connect()
 
     async def stop(self) -> None:
         """Stop connected interfae (either Tunneling or Routing)."""
         if self.interface is not None:
-            await self.interface.stop()
+            await self.interface.disconnect()
             self.interface = None
 
     def telegram_received(self, telegram):
diff --git a/xknx/io/routing.py b/xknx/io/routing.py
index 3fbd4aa0..6c8ccd3a 100644
--- a/xknx/io/routing.py
+++ b/xknx/io/routing.py
@@ -14,12 +14,13 @@ from xknx.knxip import (
 )
 from xknx.telegram import TelegramDirection
 
+from .interface import Interface
 from .udp_client import UDPClient
 
 logger = logging.getLogger("xknx.log")
 
 
-class Routing:
+class Routing(Interface):
     """Class for handling KNX/IP routing."""
 
     def __init__(self, xknx, telegram_received_callback, local_ip):
@@ -70,10 +71,23 @@ class Routing:
         """Send KNXIPFrame to connected routing device."""
         self.udpclient.send(knxipframe)
 
-    async def start(self):
+    async def connect(self) -> bool:
         """Start routing."""
-        await self.udpclient.connect()
+        try:
+            await self.udpclient.connect()
+        except OSError as ex:
+            logger.debug(
+                "Could not establish connection to KNX/IP network. %s: %s",
+                type(ex).__name__,
+                ex,
+            )
+            # close udp client to prevent open file descriptors
+            await self.udpclient.stop()
+            raise ex
+        self.xknx.connected.set()
+        return True
 
-    async def stop(self):
+    async def disconnect(self):
         """Stop routing."""
         await self.udpclient.stop()
+        self.xknx.connected.clear()
diff --git a/xknx/io/tunnel.py b/xknx/io/tunnel.py
index d81fbb9e..7fa57eaa 100644
--- a/xknx/io/tunnel.py
+++ b/xknx/io/tunnel.py
@@ -9,6 +9,8 @@ import logging
 from xknx.exceptions import CommunicationError, XKNXException
 from xknx.knxip import (
     CEMIMessageCode,
+    DisconnectRequest,
+    DisconnectResponse,
     KNXIPFrame,
     KNXIPServiceType,
     TunnellingAck,
@@ -18,14 +20,16 @@ from xknx.telegram import IndividualAddress, TelegramDirection
 
 from .connect import Connect
 from .connectionstate import ConnectionState
+from .const import HEARTBEAT_RATE
 from .disconnect import Disconnect
+from .interface import Interface
 from .tunnelling import Tunnelling
 from .udp_client import UDPClient
 
 logger = logging.getLogger("xknx.log")
 
 
-class Tunnel:
+class Tunnel(Interface):
     """Class for handling KNX/IP tunnels."""
 
     # pylint: disable=too-many-instance-attributes
@@ -71,77 +75,126 @@ class Tunnel:
         )
 
         self.udp_client.register_callback(
-            self.tunnel_reqest_received, [TunnellingRequest.service_type]
+            self._request_received,
+            [KNXIPServiceType.TUNNELLING_REQUEST, KNXIPServiceType.DISCONNECT_REQUEST],
         )
 
-    def tunnel_reqest_received(self, knxipframe, udp_client):
-        """Handle incoming tunnel request."""
-        # pylint: disable=unused-argument
-        if knxipframe.header.service_type_ident != KNXIPServiceType.TUNNELLING_REQUEST:
-            logger.warning("Service not implemented: %s", knxipframe)
-        else:
-            self.send_ack(
-                knxipframe.body.communication_channel_id,
-                knxipframe.body.sequence_counter,
+    ####################
+    #
+    # CONNECT DISCONNECT
+    #
+    ####################
+
+    async def connect(self):
+        """Connect to a KNX tunneling interface. Returns True on success."""
+        try:
+            await self.udp_client.connect()
+            await self._connect_request()
+        except (OSError, CommunicationError) as ex:
+            logger.debug(
+                "Could not establish connection to KNX/IP interface. %s: %s",
+                type(ex).__name__,
+                ex,
             )
-            # Don't handle invalid cemi frames (None) and only handle incoming L_DATA_IND frames.
-            # Ignore L_DATA_CON confirmation frames. L_DATA_REQ frames should only be outgoing.
-            if (
-                knxipframe.body.cemi is not None
-                and knxipframe.body.cemi.code is CEMIMessageCode.L_DATA_IND
-            ):
-                telegram = knxipframe.body.cemi.telegram
-                telegram.direction = TelegramDirection.INCOMING
-                if self.telegram_received_callback is not None:
-                    self.telegram_received_callback(telegram)
-
-    def send_ack(self, communication_channel_id, sequence_counter):
-        """Send tunnelling ACK after tunnelling request received."""
-        ack = TunnellingAck(
-            self.xknx,
-            communication_channel_id=communication_channel_id,
-            sequence_counter=sequence_counter,
-        )
-        self.udp_client.send(KNXIPFrame.init_from_body(ack))
+            if self.auto_reconnect:
+                self._reconnect_task = asyncio.create_task(self._reconnect())
+                return False
+            # close udp client to prevent open file descriptors
+            await self.udp_client.stop()
+            raise ex
+        else:
+            self._tunnel_established()
+            return True
 
-    async def start(self):
-        """Start tunneling."""
-        await self.connect_udp()
-        await self.connect()
+    def _tunnel_established(self):
+        """Set up interface when the tunnel is ready."""
+        self.sequence_number = 0
+        self.xknx.connected.set()
+        # self._stop_reconnect()
+        self.start_heartbeat()
+
+    def _tunnel_lost(self):
+        """Prepare for reconnection or shutdown when the connection is lost. Callback."""
+        self.xknx.connected.clear()
+        self.stop_heartbeat()
+        if self.auto_reconnect:
+            self._reconnect_task = asyncio.create_task(self._reconnect())
+        else:
+            raise CommunicationError("Tunnel connection closed.")
 
-    async def connect_udp(self):
-        """Connect udp_client."""
-        await self.udp_client.connect()
+    async def _reconnect(self):
+        """Reconnect to tunnel device."""
+        # only send disconnect request if we ever were connected
+        if self.communication_channel is not None:
+            await self._disconnect_request(True)
+            self.communication_channel = None
+        await self.udp_client.stop()
+        await asyncio.sleep(self.auto_reconnect_wait)
+        if await self.connect():
+            logger.info("Successfully reconnected to KNX bus.")
 
-    async def connect(self):
-        """Connect/build tunnel."""
+    def _stop_reconnect(self):
+        """Stop reconnect task if running."""
+        if self._reconnect_task is not None:
+            self._reconnect_task.cancel()
+            self._reconnect_task = None
+
+    async def disconnect(self):
+        """Disconnect tunneling connection."""
+        self.xknx.connected.clear()
+        self.stop_heartbeat()
+        self._stop_reconnect()
+        if self.communication_channel is not None:
+            await self._disconnect_request(False)
+            self.communication_channel = None
+        await self.udp_client.stop()
+
+    ####################
+    #
+    # OUTGOING REQUESTS
+    #
+    ####################
+
+    async def _connect_request(self) -> bool:
+        """Connect to tunnelling server. Return True if succeeded."""
         connect = Connect(self.xknx, self.udp_client)
         await connect.start()
-        if not connect.success:
-            if self.auto_reconnect:
-                logger.warning(
-                    "Could not connect to KNX. Retry in %s seconds.",
-                    self.auto_reconnect_wait,
-                )
-                self._reconnect_task = asyncio.create_task(self.schedule_reconnect())
-                return
-            raise CommunicationError(
-                "Could not establish connection", not self._is_reconnecting
+        if connect.success:
+            self.communication_channel = connect.communication_channel
+            # Use the individual address provided by the tunnelling server
+            self._src_address = IndividualAddress(connect.identifier)
+            logger.debug(
+                "Tunnel established communication_channel=%s, id=%s",
+                connect.communication_channel,
+                connect.identifier,
             )
+            return True
+        raise CommunicationError("ConnectRequest failed")
+
+    async def _connectionstate_request(self):
+        """Return state of tunnel. True if tunnel is in good shape."""
+        conn_state = ConnectionState(
+            self.xknx,
+            self.udp_client,
+            communication_channel_id=self.communication_channel,
+        )
+        await conn_state.start()
+        return conn_state.success
+
+    async def _disconnect_request(self, ignore_error=False):
+        """Disconnect from tunnel device."""
+        disconnect = Disconnect(
+            self.xknx,
+            self.udp_client,
+            communication_channel_id=self.communication_channel,
+        )
+        await disconnect.start()
+        if not disconnect.success and not ignore_error:
+            raise XKNXException("Could not disconnect channel")
         logger.debug(
-            "Tunnel established communication_channel=%s, id=%s",
-            connect.communication_channel,
-            connect.identifier,
+            "Tunnel disconnected (communication_channel: %s)",
+            self.communication_channel,
         )
-        if self._is_reconnecting:
-            logger.info("Successfully reconnected to KNX bus.")
-        self._reconnect_task = None
-        self._is_reconnecting = False
-        self.communication_channel = connect.communication_channel
-        # Use the individual address provided by the tunnelling server
-        self._src_address = IndividualAddress(connect.identifier)
-        self.sequence_number = 0
-        await self.start_heartbeat()
 
     async def send_telegram(self, telegram):
         """
@@ -158,22 +211,25 @@ class Tunnel:
         connection by sending a DISCONNECT_REQUEST frame to the other device’s
         control endpoint.
         """
-        success = await self._send_telegram_impl(telegram)
+        success = await self._tunnelling_request(telegram)
         if not success:
             logger.debug("Sending of telegram failed. Retrying a second time.")
-            success = await self._send_telegram_impl(telegram)
+            success = await self._tunnelling_request(telegram)
             if not success:
                 logger.debug("Resending telegram failed. Reconnecting to tunnel.")
-                await self.reconnect()
-                success = await self._send_telegram_impl(telegram)
+                # TODO: How to test this?
+                if self._reconnect_task is None or self._reconnect_task.done():
+                    self._tunnel_lost()
+                await self.xknx.connected.wait()
+                success = await self._tunnelling_request(telegram)
                 if not success:
                     raise CommunicationError(
-                        "Resending the telegram repeatedly failed.", False
+                        "Resending the telegram repeatedly failed.", True
                     )
-        self.increase_sequence_number()
+        self._increase_sequence_number()
 
-    async def _send_telegram_impl(self, telegram):
-        """Send Telegram to tunnelling device - implementation."""
+    async def _tunnelling_request(self, telegram):
+        """Send Telegram to tunnelling device."""
         tunnelling = Tunnelling(
             self.xknx,
             self.udp_client,
@@ -185,112 +241,96 @@ class Tunnel:
         await tunnelling.start()
         return tunnelling.success
 
-    def increase_sequence_number(self):
+    def _increase_sequence_number(self):
         """Increase sequence number."""
         self.sequence_number += 1
         if self.sequence_number == 256:
             self.sequence_number = 0
 
-    async def connectionstate(self):
-        """Return state of tunnel. True if tunnel is in good shape."""
-        conn_state = ConnectionState(
+    ####################
+    #
+    # INCOMING REQUESTS
+    #
+    ####################
+
+    def _request_received(self, knxipframe, _udp_client):
+        """Handle incoming requests."""
+        # pylint: disable=unused-argument
+        if knxipframe.header.service_type_ident is KNXIPServiceType.TUNNELLING_REQUEST:
+            self._tunnelling_request_received(knxipframe.body)
+        elif (
+            knxipframe.header.service_type_ident is KNXIPServiceType.DISCONNECT_REQUEST
+        ):
+            self._disconnect_request_received(knxipframe.body)
+        else:
+            logger.warning("Service not implemented: %s", knxipframe)
+
+    def _tunnelling_request_received(self, tunneling_request: TunnellingRequest):
+        """Handle incoming tunnel request."""
+        self._send_tunnelling_ack(
+            tunneling_request.communication_channel_id,
+            tunneling_request.sequence_counter,
+        )
+        # Don't handle invalid cemi frames (None) and only handle incoming L_DATA_IND frames.
+        # Ignore L_DATA_CON confirmation frames. L_DATA_REQ frames should only be outgoing.
+        if (
+            tunneling_request.cemi is not None
+            and tunneling_request.cemi.code is CEMIMessageCode.L_DATA_IND
+        ):
+            telegram = tunneling_request.cemi.telegram
+            telegram.direction = TelegramDirection.INCOMING
+            if self.telegram_received_callback is not None:
+                self.telegram_received_callback(telegram)
+
+    def _send_tunnelling_ack(self, communication_channel_id, sequence_counter):
+        """Send tunnelling ACK after tunnelling request received."""
+        ack = TunnellingAck(
             self.xknx,
-            self.udp_client,
-            communication_channel_id=self.communication_channel,
+            communication_channel_id=communication_channel_id,
+            sequence_counter=sequence_counter,
         )
-        await conn_state.start()
-        return conn_state.success
+        self.udp_client.send(KNXIPFrame.init_from_body(ack))
 
-    async def disconnect(self, ignore_error=False):
-        """Disconnect from tunnel device."""
-        # only send disconnect request if we ever were connected
-        if self.communication_channel is None:
-            # close udp client to prevent open file descriptors
-            await self.udp_client.stop()
-            return
-        disconnect = Disconnect(
+    def _disconnect_request_received(self, disconnect_request: DisconnectRequest):
+        """Handle incoming disconnect request."""
+        logger.warning("Received DisconnectRequest from tunnelling sever.")
+        disconnect_response = DisconnectResponse(
             self.xknx,
-            self.udp_client,
             communication_channel_id=self.communication_channel,
         )
-        await disconnect.start()
-        if not disconnect.success and not ignore_error:
-            raise XKNXException("Could not disconnect channel")
-        logger.debug(
-            "Tunnel disconnected (communication_channel: %s)",
-            self.communication_channel,
-        )
-        # close udp client to prevent open file descriptors
-        await self.udp_client.stop()
-
-    async def reconnect(self):
-        """Reconnect to tunnel device."""
-        self._is_reconnecting = True
-        await self.stop_heartbeat()
-        await self.disconnect(True)
-        self.init_udp_client()
-        await self.start()
+        self.udp_client.send(KNXIPFrame.init_from_body(disconnect_response))
+        self.communication_channel = None
+        self._tunnel_lost()
 
-    async def schedule_reconnect(self):
-        """Schedule reconnect to KNX."""
-        await asyncio.sleep(self.auto_reconnect_wait)
-        await self.reconnect()
+    ####################
+    #
+    # HEARTBEAT
+    #
+    ####################
 
-    async def stop_reconnect(self):
-        """Stop reconnect task if running."""
-        if self._reconnect_task is not None:
-            self._reconnect_task.cancel()
-            self._reconnect_task = None
-
-    async def stop(self):
-        """Stop tunneling."""
-        # XXX: set disconnect ignore_error True here. Is there actually anything
-        #      which can happen if disconnect fails? normally this fails because
-        #      we have no connection...
-        # await self.disconnect()
-        await self.stop_heartbeat()
-        await self.stop_reconnect()
-        await self.disconnect(True)
-
-    async def start_heartbeat(self):
+    def start_heartbeat(self):
         """Start heartbeat for monitoring state of tunnel, as suggested by 03.08.02 KNX Core 5.4."""
         self._heartbeat_task = asyncio.create_task(self.do_heartbeat())
 
-    async def stop_heartbeat(self):
+    def stop_heartbeat(self):
         """Stop heartbeat task if running."""
         if self._heartbeat_task is not None:
             self._heartbeat_task.cancel()
             self._heartbeat_task = None
 
     async def do_heartbeat(self):
-        """Heartbeat: Worker 'thread', endless loop for sending heartbeat requests."""
+        """Heartbeat: Worker task, endless loop for sending heartbeat requests."""
         while True:
-            await asyncio.sleep(15)
-            await self.do_heartbeat_impl()
-
-    async def do_heartbeat_impl(self):
-        """Heartbeat: checking connection state and handling result."""
-        connectionsstate = await self.connectionstate()
-        if connectionsstate:
-            await self.do_heartbeat_success()
-        else:
-            await self.do_heartbeat_failed()
+            await asyncio.sleep(HEARTBEAT_RATE)
+            if not await self._connectionstate_request():
+                await self._do_heartbeat_failed()
 
-    async def do_heartbeat_success(self):
-        """Heartbeat: handling success."""
-        self.number_heartbeat_failed = 0
-
-    async def do_heartbeat_failed(self):
+    async def _do_heartbeat_failed(self):
         """Heartbeat: handling error."""
-        self.number_heartbeat_failed = self.number_heartbeat_failed + 1
-        if self.number_heartbeat_failed > 3:
-            if not self._is_reconnecting:
-                logger.warning("Heartbeat to KNX bus failed. Reconnecting.")
-            try:
-                await self.stop_reconnect()
-                await self.reconnect()
-                self.number_heartbeat_failed = 0
-                await self.stop_heartbeat()
-            except CommunicationError as exc:
-                if exc.should_log:
-                    logger.warning(exc)
+        # first heartbeat failed - try 3 more times before disconnecting.
+        for _heartbeats_failed in range(3):
+            if await self._connectionstate_request():
+                return
+        # 3 retries failed
+        logger.warning("Heartbeat to KNX bus failed. Reconnecting.")
+        self._tunnel_lost()
diff --git a/xknx/io/udp_client.py b/xknx/io/udp_client.py
index d9ef021e..aec875a2 100644
--- a/xknx/io/udp_client.py
+++ b/xknx/io/udp_client.py
@@ -60,13 +60,11 @@ class UDPClient:
 
         def error_received(self, exc):
             """Handle errors. Callback for error received."""
-            if hasattr(self, "xknx"):
-                logger.warning("Error received: %s", exc)
+            logger.warning("Error received: %s", exc)
 
         def connection_lost(self, exc):
             """Log error. Callback for connection lost."""
-            if hasattr(self, "xknx") and exc is not None:
-                logger.info("Closing transport.")
+            logger.debug("Closing transport.")
 
     def __init__(
         self,
diff --git a/xknx/knxip/connect_response.py b/xknx/knxip/connect_response.py
index ef352ec0..7eb4aa39 100644
--- a/xknx/knxip/connect_response.py
+++ b/xknx/knxip/connect_response.py
@@ -43,9 +43,7 @@ class ConnectResponse(KNXIPBody):
 
     def calculated_length(self):
         """Get length of KNX/IP body."""
-        if self.status_code == ErrorCode.E_NO_ERROR:
-            return 2 + HPAI.LENGTH + ConnectResponse.CRD_LENGTH
-        return 2
+        return 2 + HPAI.LENGTH + ConnectResponse.CRD_LENGTH
 
     def from_knx(self, raw):
         """Parse/deserialize from KNX/IP raw data."""
@@ -67,6 +65,9 @@ class ConnectResponse(KNXIPBody):
         if self.status_code == ErrorCode.E_NO_ERROR:
             pos += self.control_endpoint.from_knx(raw[pos:])
             pos += crd_from_knx(raw[pos:])
+        else:
+            # do not parse HPAI and CRD in case of errors - just check length
+            pos += len(raw[pos:])
         return pos
 
     def to_knx(self):
@@ -84,10 +85,8 @@ class ConnectResponse(KNXIPBody):
         data = []
         data.append(self.communication_channel)
         data.append(self.status_code.value)
-
-        if self.status_code == ErrorCode.E_NO_ERROR:
-            data.extend(self.control_endpoint.to_knx())
-            data.extend(crd_to_knx())
+        data.extend(self.control_endpoint.to_knx())
+        data.extend(crd_to_knx())
 
         return data
 
diff --git a/xknx/remote_value/remote_value.py b/xknx/remote_value/remote_value.py
index 6932e9f6..07603c39 100644
--- a/xknx/remote_value/remote_value.py
+++ b/xknx/remote_value/remote_value.py
@@ -6,55 +6,75 @@ Remote value can be :
 - a group address for reading a KNX value,
 - or a group of both representing the same value.
 """
+from abc import ABC, abstractmethod
 import logging
-from typing import List
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Awaitable,
+    Callable,
+    Generator,
+    List,
+    Optional,
+    Union,
+)
 
 from xknx.exceptions import CouldNotParseTelegram
 from xknx.telegram import GroupAddress, Telegram
 from xknx.telegram.apci import GroupValueResponse, GroupValueWrite
 
+if TYPE_CHECKING:
+    from xknx.dpt import DPTArray, DPTBinary
+    from xknx.telegram.address import GroupAddressableType
+    from xknx.xknx import XKNX
+
+    AsyncCallback = Callable[[], Awaitable[None]]
+    DPTPayload = Union[DPTArray, DPTBinary]
+
 logger = logging.getLogger("xknx.log")
 
 
-class RemoteValue:
+class RemoteValue(ABC):
     """Class for managing remote knx value."""
 
     # pylint: disable=too-many-instance-attributes
     def __init__(
         self,
-        xknx,
-        group_address=None,
-        group_address_state=None,
-        sync_state=True,
-        device_name=None,
-        feature_name=None,
-        after_update_cb=None,
-        passive_group_addresses: List[str] = None,
+        xknx: "XKNX",
+        group_address: Optional["GroupAddressableType"] = None,
+        group_address_state: Optional["GroupAddressableType"] = None,
+        sync_state: bool = True,
+        device_name: Optional[str] = None,
+        feature_name: Optional[str] = None,
+        after_update_cb: Optional["AsyncCallback"] = None,
+        passive_group_addresses: Optional[List["GroupAddressableType"]] = None,
     ):
         """Initialize RemoteValue class."""
         # pylint: disable=too-many-arguments
         self.xknx = xknx
-        self.passive_group_addresses = RemoteValue.get_passive_group_addresses(
-            passive_group_addresses
-        )
+
         if group_address is not None:
             group_address = GroupAddress(group_address)
         if group_address_state is not None:
             group_address_state = GroupAddress(group_address_state)
+        self.group_address: Optional[GroupAddress] = group_address
+        self.group_address_state: Optional[GroupAddress] = group_address_state
+
+        self.passive_group_addresses: List[
+            GroupAddress
+        ] = RemoteValue.get_passive_group_addresses(passive_group_addresses)
 
-        self.group_address = group_address
-        self.group_address_state = group_address_state
-        self.device_name = "Unknown" if device_name is None else device_name
-        self.feature_name = "Unknown" if feature_name is None else feature_name
+        self.device_name: str = "Unknown" if device_name is None else device_name
+        self.feature_name: str = "Unknown" if feature_name is None else feature_name
         self.after_update_cb = after_update_cb
-        self.payload = None
+        self.payload: Optional["DPTPayload"] = None
 
         if sync_state and self.group_address_state:
             self.xknx.state_updater.register_remote_value(
                 self, tracker_options=sync_state
             )
 
-    def __del__(self):
+    def __del__(self) -> None:
         """Destructor. Removing self from StateUpdater if was registered."""
         try:
             self.xknx.state_updater.unregister_remote_value(self)
@@ -62,7 +82,7 @@ class RemoteValue:
             pass
 
     @property
-    def initialized(self):
+    def initialized(self) -> bool:
         """Evaluate if remote value is initialized with group address."""
         return bool(
             self.group_address_state
@@ -71,19 +91,19 @@ class RemoteValue:
         )
 
     @property
-    def readable(self):
+    def readable(self) -> bool:
         """Evaluate if remote value should be read from bus."""
         return bool(self.group_address_state)
 
     @property
-    def writable(self):
+    def writable(self) -> bool:
         """Evaluate if remote value has a group_address set."""
         return bool(self.group_address)
 
-    def has_group_address(self, group_address):
+    def has_group_address(self, group_address: GroupAddress) -> bool:
         """Test if device has given group address."""
 
-        def _internal_addresses():
+        def _internal_addresses() -> Generator[Optional[GroupAddress], None, None]:
             """Yield all group_addresses."""
             yield self.group_address
             yield self.group_address_state
@@ -91,7 +111,9 @@ class RemoteValue:
 
         return group_address in _internal_addresses()
 
-    def payload_valid(self, payload):
+    @abstractmethod
+    # TODO: typing - remove Optional
+    def payload_valid(self, payload: Optional["DPTPayload"]) -> bool:
         """Test if telegram payload may be parsed - to be implemented in derived class.."""
         # pylint: disable=unused-argument
         logger.warning(
@@ -99,19 +121,19 @@ class RemoteValue:
         )
         return True
 
-    def from_knx(self, payload):
+    @abstractmethod
+    def from_knx(self, payload: "DPTPayload") -> Any:
         """Convert current payload to value - to be implemented in derived class."""
-        # pylint: disable=unused-argument
-        logger.warning("'from_knx()' not implemented for %s", self.__class__.__name__)
 
-    def to_knx(self, value):
+    @abstractmethod
+    def to_knx(self, value: Any) -> "DPTPayload":
         """Convert value to payload - to be implemented in derived class."""
-        # pylint: disable=unused-argument
-        logger.warning("'to_knx()' not implemented for %s", self.__class__.__name__)
 
-    async def process(self, telegram, always_callback=False):
+    async def process(self, telegram: Telegram, always_callback: bool = False) -> bool:
         """Process incoming or outgoing telegram."""
-        if not self.has_group_address(telegram.destination_address):
+        if not isinstance(
+            telegram.destination_address, GroupAddress
+        ) or not self.has_group_address(telegram.destination_address):
             return False
         if not isinstance(
             telegram.payload,
@@ -123,17 +145,17 @@ class RemoteValue:
             raise CouldNotParseTelegram(
                 "payload not a GroupValueWrite or GroupValueResponse",
                 payload=telegram.payload,
-                destination_address=telegram.destination_address,
-                source_address=telegram.source_address,
+                destination_address=str(telegram.destination_address),
+                source_address=str(telegram.source_address),
                 device_name=self.device_name,
                 feature_name=self.feature_name,
             )
         if not self.payload_valid(telegram.payload.value):
             raise CouldNotParseTelegram(
                 "payload invalid",
-                payload=telegram.payload,
-                destination_address=telegram.destination_address,
-                source_address=telegram.source_address,
+                payload=str(telegram.payload),
+                destination_address=str(telegram.destination_address),
+                source_address=str(telegram.source_address),
                 device_name=self.device_name,
                 feature_name=self.feature_name,
             )
@@ -149,23 +171,26 @@ class RemoteValue:
         return True
 
     @property
-    def value(self):
+    def value(self) -> Any:
         """Return current value."""
         if self.payload is None:
             return None
         return self.from_knx(self.payload)
 
-    async def _send(self, payload, response=False):
+    async def _send(self, payload: "DPTPayload", response: bool = False) -> None:
         """Send payload as telegram to KNX bus."""
-        telegram = Telegram(
-            destination_address=self.group_address,
-            payload=(
-                GroupValueResponse(payload) if response else GroupValueWrite(payload)
-            ),
-        )
-        await self.xknx.telegrams.put(telegram)
+        if self.group_address is not None:
+            telegram = Telegram(
+                destination_address=self.group_address,
+                payload=(
+                    GroupValueResponse(payload)
+                    if response
+                    else GroupValueWrite(payload)
+                ),
+            )
+            await self.xknx.telegrams.put(telegram)
 
-    async def set(self, value, response=False):
+    async def set(self, value: Any, response: bool = False) -> None:
         """Set new value."""
         if not self.initialized:
             logger.info(
@@ -188,14 +213,14 @@ class RemoteValue:
         await self._send(payload, response)
         # self.payload is set and after_update_cb() called when the outgoing telegram is processed.
 
-    async def respond(self):
+    async def respond(self) -> None:
         """Send current payload as GroupValueResponse telegram to KNX bus."""
         if self.payload is not None:
             await self._send(self.payload, response=True)
 
     async def read_state(self, wait_for_result: bool = False) -> None:
         """Send GroupValueRead telegram for state address to KNX bus."""
-        if self.readable:
+        if self.group_address_state is not None:
             # pylint: disable=import-outside-toplevel
             # TODO: send a ReadRequset and start a timeout from here instead of ValueReader
             #       cancel timeout form process(); delete ValueReader
@@ -217,11 +242,11 @@ class RemoteValue:
                 await value_reader.send_group_read()
 
     @property
-    def unit_of_measurement(self):
+    def unit_of_measurement(self) -> Optional[str]:
         """Return the unit of measurement."""
         return None
 
-    def group_addr_str(self):
+    def group_addr_str(self) -> str:
         """Return object as readable string."""
         return "{}/{}/{}/{}".format(
             self.group_address.__repr__(),
@@ -230,7 +255,7 @@ class RemoteValue:
             self.value,
         )
 
-    def __str__(self):
+    def __str__(self) -> str:
         """Return object as string representation."""
         return '<{} device_name="{}" feature_name="{}" {}/>'.format(
             self.__class__.__name__,
@@ -239,7 +264,7 @@ class RemoteValue:
             self.group_addr_str(),
         )
 
-    def __eq__(self, other):
+    def __eq__(self, other: Any) -> bool:
         """Equal operator."""
         for key, value in self.__dict__.items():
             if key == "after_update_cb":
@@ -256,7 +281,9 @@ class RemoteValue:
         return True
 
     @staticmethod
-    def get_passive_group_addresses(passive_group_addresses: List[str]) -> List:
+    def get_passive_group_addresses(
+        passive_group_addresses: Optional[List["GroupAddressableType"]],
+    ) -> List[GroupAddress]:
         """Obtain passive state group addresses."""
         if passive_group_addresses is None:
             return []
diff --git a/xknx/telegram/address.py b/xknx/telegram/address.py
index 42516c96..3836421c 100644
--- a/xknx/telegram/address.py
+++ b/xknx/telegram/address.py
@@ -18,8 +18,14 @@ from typing import Optional, Tuple, Union
 
 from xknx.exceptions import CouldNotParseAddress
 
+# TODO: typing - remove need for Optional here
+GroupAddressableType = Optional[Union["GroupAddress", str, int, Tuple[int, int]]]
+IndividualAddressableType = Optional[
+    Union["IndividualAddress", str, int, Tuple[int, int]]
+]
 
-def address_tuple_to_int(address: Tuple) -> int:
+
+def address_tuple_to_int(address: Tuple[int, int]) -> int:
     """
     Convert the tuple `address` to an integer.
 
@@ -80,22 +86,22 @@ class IndividualAddress(BaseAddress):
         r"^(?P<area>\d{1,2})\.(?P<main>\d{1,2})\.(?P<line>\d{1,3})$"
     )
 
-    def __init__(self, address: Union[object, str, int, tuple]) -> None:
+    def __init__(self, address: IndividualAddressableType) -> None:
         """Initialize Address class."""
         super().__init__()
         if isinstance(address, IndividualAddress):
-            self.raw: int = address.raw
+            self.raw = address.raw
         elif isinstance(address, str):
             if address.isdigit():
-                self.raw: int = int(address)  # type: ignore
+                self.raw = int(address)
             else:
-                self.raw: int = self.__string_to_int(address)  # type: ignore
+                self.raw = self.__string_to_int(address)
         elif isinstance(address, tuple) and len(address) == 2:
-            self.raw: int = address_tuple_to_int(address)  # type: ignore
+            self.raw = address_tuple_to_int(address)
         elif isinstance(address, int):
-            self.raw: int = address  # type: ignore
+            self.raw = address
         elif address is None:
-            self.raw: int = 0  # type: ignore
+            self.raw = 0
         else:
             raise CouldNotParseAddress(address)
 
@@ -186,7 +192,7 @@ class GroupAddress(BaseAddress):
 
     def __init__(
         self,
-        address: Union[object, str, tuple, int],
+        address: GroupAddressableType,
         levels: GroupAddressType = GroupAddressType.LONG,
     ) -> None:
         """Initialize Address class."""
diff --git a/xknx/telegram/address_filter.py b/xknx/telegram/address_filter.py
index 7a76776e..20276b4f 100644
--- a/xknx/telegram/address_filter.py
+++ b/xknx/telegram/address_filter.py
@@ -42,7 +42,7 @@ class AddressFilter:
         for part in pattern.split("/"):
             self.level_filters.append(AddressFilter.LevelFilter(part))
         if len(self.level_filters) > 3:
-            raise ConversionError("Too many parts within pattern.", pattern=pattern)  # type: ignore
+            raise ConversionError("Too many parts within pattern.", pattern=pattern)
 
     def match(self, address: Union[str, GroupAddress]) -> bool:
         """Test if provided address matches Addressfilter."""
diff --git a/xknx/telegram/telegram.py b/xknx/telegram/telegram.py
index 3b4280c2..b702c233 100644
--- a/xknx/telegram/telegram.py
+++ b/xknx/telegram/telegram.py
@@ -33,12 +33,10 @@ class Telegram:
 
     def __init__(
         self,
-        destination_address: Union[GroupAddress, IndividualAddress] = GroupAddress(
-            None
-        ),
+        destination_address: Union[GroupAddress, IndividualAddress] = GroupAddress(0),
         direction: TelegramDirection = TelegramDirection.OUTGOING,
         payload: Any = None,
-        source_address: IndividualAddress = IndividualAddress(None),
+        source_address: IndividualAddress = IndividualAddress(0),
     ) -> None:
         """Initialize Telegram class."""
         self.destination_address = destination_address
diff --git a/xknx/xknx.py b/xknx/xknx.py
index d06f68d1..eff9a526 100644
--- a/xknx/xknx.py
+++ b/xknx/xknx.py
@@ -54,6 +54,7 @@ class XKNX:
         self.state_updater = StateUpdater(self)
         self.knxip_interface = None
         self.started = asyncio.Event()
+        self.connected = asyncio.Event()
         self.address_format = address_format
         self.own_address = IndividualAddress(own_address)
         self.rate_limit = rate_limit


Test patch:
diff --git a/requirements/testing.txt b/requirements/testing.txt
index 48a86084..5fc7de8a 100644
--- a/requirements/testing.txt
+++ b/requirements/testing.txt
@@ -7,9 +7,9 @@ flake8-isort==4.0.0
 pydocstyle==5.1.1
 pylint==2.6.0
 pylint-strict-informational==0.1
-pytest==6.2.0
+pytest==6.2.1
 pytest-cov==2.10.1
 pytest-timeout==1.4.2
-setuptools==51.0.0
+setuptools==51.1.0.post20201221
 tox==3.20.1
 mypy==0.790
diff --git a/test/config_tests/config_v1_test.py b/test/config_tests/config_v1_test.py
index 5479f439..be7b10bc 100644
--- a/test/config_tests/config_v1_test.py
+++ b/test/config_tests/config_v1_test.py
@@ -23,6 +23,7 @@ from xknx.devices import (
 )
 from xknx.exceptions import XKNXException
 from xknx.io import ConnectionConfig, ConnectionType
+from xknx.telegram import IndividualAddress
 import yaml
 
 
@@ -54,7 +55,7 @@ class TestConfig(unittest.TestCase):
     #
     def test_config_general(self):
         """Test reading general section from config file."""
-        self.assertEqual(TestConfig.xknx.own_address, "15.15.249")
+        self.assertEqual(TestConfig.xknx.own_address, IndividualAddress("15.15.249"))
         self.assertEqual(TestConfig.xknx.rate_limit, 18)
         self.assertEqual(TestConfig.xknx.multicast_group, "224.1.2.3")
         self.assertEqual(TestConfig.xknx.multicast_port, 1337)
@@ -197,6 +198,38 @@ class TestConfig(unittest.TestCase):
             ),
         )
 
+    def test_config_individual_rgb(self):
+        """Test reading Light with with dimming and color address."""
+        expected = Light(
+            TestConfig.xknx,
+            "Diningroom.Light_rgb",
+            group_address_switch_white="1/6/4",
+            group_address_switch_white_state="1/6/5",
+            group_address_brightness_white="1/6/6",
+            group_address_brightness_white_state="1/6/7",
+            group_address_switch_red="1/6/14",
+            group_address_switch_red_state="1/6/15",
+            group_address_brightness_red="1/6/16",
+            group_address_brightness_red_state="1/6/17",
+            group_address_switch_green="1/6/24",
+            group_address_switch_green_state="1/6/25",
+            group_address_brightness_green="1/6/26",
+            group_address_brightness_green_state="1/6/27",
+            group_address_switch_blue="1/6/34",
+            group_address_switch_blue_state="1/6/35",
+            group_address_brightness_blue="1/6/36",
+            group_address_brightness_blue_state="1/6/37",
+            min_kelvin=Light.DEFAULT_MIN_KELVIN,
+            max_kelvin=Light.DEFAULT_MAX_KELVIN,
+        )
+        result = TestConfig.xknx.devices["Diningroom.Light_rgb"]
+
+        print(f"result: {result} \nexpected: {expected}")
+        self.assertEqual(
+            result,
+            expected,
+        )
+
     def test_config_color_temperature(self):
         """Test reading Light with with dimming and color temperature address."""
         self.assertEqual(
diff --git a/test/config_tests/resources/light/invalid_3.yaml b/test/config_tests/resources/light/invalid_3.yaml
new file mode 100644
index 00000000..3b261fd6
--- /dev/null
+++ b/test/config_tests/resources/light/invalid_3.yaml
@@ -0,0 +1,25 @@
+name: "two or more values in the same group of exclusion 'color' @ .*"
+friendly_name: "Office"
+switch:
+  address: "2/1/5"
+  state_address: "2/4/5"
+  state_update: "expire 60"
+  passive_state_addresses: ["8/8/8"]
+brightness:
+  address: "2/5/5"
+  state_address: "2/5/6"
+  state_update: "expire 60"
+  passive_state_addresses: ["8/8/8"]
+rgbw:
+  address: "2/4/5"
+  state_address: "2/4/6"
+color:
+  address: "2/4/5"
+  state_address: "2/4/6"
+color_temperature:
+  address: "2/5/7"
+  state_address: "2/5/8"
+  state_update: "expire 60"
+  mode: absolute
+  min_kelvin: 2550
+  max_kelvin: 6200
\ No newline at end of file
diff --git a/test/config_tests/resources/light/valid_5.yaml b/test/config_tests/resources/light/valid_5.yaml
new file mode 100644
index 00000000..6e480fff
--- /dev/null
+++ b/test/config_tests/resources/light/valid_5.yaml
@@ -0,0 +1,10 @@
+name: "office"
+friendly_name: "Office"
+switch:
+  address: "2/1/5"
+  state_address: "2/4/5"
+  state_update: "expire 60"
+  passive_state_addresses: ["8/8/8"]
+color:
+  address: "2/4/5"
+  state_address: "2/4/6"
\ No newline at end of file
diff --git a/test/config_tests/resources/light/valid_rgb.yaml b/test/config_tests/resources/light/valid_rgb.yaml
new file mode 100644
index 00000000..d93861d8
--- /dev/null
+++ b/test/config_tests/resources/light/valid_rgb.yaml
@@ -0,0 +1,24 @@
+name: "office"
+friendly_name: "Office"
+individual_colors:
+  red:
+    switch:
+      address: "2/1/5"
+      state_address: "2/4/5"
+    brightness:
+      address: "2/5/7"
+      state_address: "2/5/8"
+  green:
+    switch:
+      address: "2/1/5"
+      state_address: "2/4/5"
+    brightness:
+      address: "2/5/7"
+      state_address: "2/5/8"
+  blue:
+    switch:
+      address: "2/1/5"
+      state_address: "2/4/5"
+    brightness:
+      address: "2/5/7"
+      state_address: "2/5/8"
diff --git a/test/config_tests/resources/light/valid_rgbw.yaml b/test/config_tests/resources/light/valid_rgbw.yaml
new file mode 100644
index 00000000..47d1ad43
--- /dev/null
+++ b/test/config_tests/resources/light/valid_rgbw.yaml
@@ -0,0 +1,31 @@
+name: "office"
+friendly_name: "Office"
+individual_colors:
+  white:
+    switch:
+      address: "2/1/5"
+      state_address: "2/4/5"
+    brightness:
+      address: "2/5/7"
+      state_address: "2/5/8"
+  red:
+    switch:
+      address: "2/1/5"
+      state_address: "2/4/5"
+    brightness:
+      address: "2/5/7"
+      state_address: "2/5/8"
+  green:
+    switch:
+      address: "2/1/5"
+      state_address: "2/4/5"
+    brightness:
+      address: "2/5/7"
+      state_address: "2/5/8"
+  blue:
+    switch:
+      address: "2/1/5"
+      state_address: "2/4/5"
+    brightness:
+      address: "2/5/7"
+      state_address: "2/5/8"
diff --git a/test/config_tests/resources/light/valid_rgbw_no_switch.yaml b/test/config_tests/resources/light/valid_rgbw_no_switch.yaml
new file mode 100644
index 00000000..8b1a7934
--- /dev/null
+++ b/test/config_tests/resources/light/valid_rgbw_no_switch.yaml
@@ -0,0 +1,21 @@
+name: "office"
+friendly_name: "Office"
+switch:
+  address: "2/1/5"
+individual_colors:
+  white:
+    brightness:
+      address: "2/5/7"
+      state_address: "2/5/8"
+  red:
+    brightness:
+      address: "2/5/7"
+      state_address: "2/5/8"
+  green:
+    brightness:
+      address: "2/5/7"
+      state_address: "2/5/8"
+  blue:
+    brightness:
+      address: "2/5/7"
+      state_address: "2/5/8"
diff --git a/test/core_tests/state_updater_test.py b/test/core_tests/state_updater_test.py
index 4f5ac83a..c4cae037 100644
--- a/test/core_tests/state_updater_test.py
+++ b/test/core_tests/state_updater_test.py
@@ -10,6 +10,7 @@ from xknx.remote_value import RemoteValue
 from xknx.telegram import GroupAddress
 
 
+@patch.multiple(RemoteValue, __abstractmethods__=set())
 class TestStateUpdater(unittest.TestCase):
     """Test class for state updater."""
 
@@ -19,6 +20,7 @@ class TestStateUpdater(unittest.TestCase):
         """Set up test class."""
         self.loop = asyncio.new_event_loop()
         asyncio.set_event_loop(self.loop)
+        patch.multiple(RemoteValue, __abstractmethods__=set())
 
     def tearDown(self):
         """Tear down test class."""
diff --git a/test/devices_tests/light_test.py b/test/devices_tests/light_test.py
index 2e54fb9d..19ce18ab 100644
--- a/test/devices_tests/light_test.py
+++ b/test/devices_tests/light_test.py
@@ -65,6 +65,44 @@ class TestLight(unittest.TestCase):
         light = Light(xknx, "Diningroom.Light_1", group_address_switch="1/6/4")
         self.assertFalse(light.supports_color)
 
+    def test_supports_individual_color_true(self):
+        """Test supports_color true."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "Diningroom.Light_1",
+            group_address_switch_red="1/1/1",
+            group_address_switch_red_state="1/1/2",
+            group_address_brightness_red="1/1/3",
+            group_address_brightness_red_state="1/1/4",
+            group_address_switch_green="1/1/5",
+            group_address_switch_green_state="1/1/6",
+            group_address_brightness_green="1/1/7",
+            group_address_brightness_green_state="1/1/8",
+            group_address_switch_blue="1/1/9",
+            group_address_switch_blue_state="1/1/10",
+            group_address_brightness_blue="1/1/11",
+            group_address_brightness_blue_state="1/1/12",
+        )
+        self.assertTrue(light.supports_color)
+
+    def test_supports_individual_color_false(self):
+        """Test supports_color false."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "Diningroom.Light_1",
+            group_address_switch_red="1/1/1",
+            group_address_switch_red_state="1/1/2",
+            group_address_brightness_red="1/1/3",
+            group_address_brightness_red_state="1/1/4",
+            group_address_switch_green="1/1/5",
+            group_address_switch_green_state="1/1/6",
+            group_address_brightness_green="1/1/7",
+            group_address_brightness_green_state="1/1/8",
+        )
+        self.assertFalse(light.supports_color)
+
     #
     # TEST SUPPORT COLOR RGBW
     #
@@ -91,6 +129,52 @@ class TestLight(unittest.TestCase):
         )
         self.assertFalse(light.supports_rgbw)
 
+    def test_supports_individual_rgbw_true(self):
+        """Test supports_rgbw true."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "Diningroom.Light_1",
+            group_address_switch_red="1/1/1",
+            group_address_switch_red_state="1/1/2",
+            group_address_brightness_red="1/1/3",
+            group_address_brightness_red_state="1/1/4",
+            group_address_switch_green="1/1/5",
+            group_address_switch_green_state="1/1/6",
+            group_address_brightness_green="1/1/7",
+            group_address_brightness_green_state="1/1/8",
+            group_address_switch_blue="1/1/9",
+            group_address_switch_blue_state="1/1/10",
+            group_address_brightness_blue="1/1/11",
+            group_address_brightness_blue_state="1/1/12",
+            group_address_switch_white="1/1/13",
+            group_address_switch_white_state="1/1/14",
+            group_address_brightness_white="1/1/15",
+            group_address_brightness_white_state="1/1/16",
+        )
+        self.assertTrue(light.supports_rgbw)
+
+    def test_supports_individual_rgbw_false(self):
+        """Test supports_color false."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "Diningroom.Light_1",
+            group_address_switch_red="1/1/1",
+            group_address_switch_red_state="1/1/2",
+            group_address_brightness_red="1/1/3",
+            group_address_brightness_red_state="1/1/4",
+            group_address_switch_green="1/1/5",
+            group_address_switch_green_state="1/1/6",
+            group_address_brightness_green="1/1/7",
+            group_address_brightness_green_state="1/1/8",
+            group_address_switch_blue="1/1/9",
+            group_address_switch_blue_state="1/1/10",
+            group_address_brightness_blue="1/1/11",
+            group_address_brightness_blue_state="1/1/12",
+        )
+        self.assertFalse(light.supports_rgbw)
+
     #
     # TEST SUPPORT TUNABLE WHITE
     #
@@ -179,6 +263,73 @@ class TestLight(unittest.TestCase):
         self.assertEqual(len(telegrams), 6)
         self.assertListEqual(telegrams, test_telegrams)
 
+    def test_sync_individual_color(self):
+        """Test sync function / sending group reads to KNX bus. Testing with a Light without dimm functionality."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "Diningroom.Light_1",
+            group_address_switch_red="1/1/1",
+            group_address_switch_red_state="1/1/2",
+            group_address_brightness_red="1/1/3",
+            group_address_brightness_red_state="1/1/4",
+            group_address_switch_green="1/1/5",
+            group_address_switch_green_state="1/1/6",
+            group_address_brightness_green="1/1/7",
+            group_address_brightness_green_state="1/1/8",
+            group_address_switch_blue="1/1/9",
+            group_address_switch_blue_state="1/1/10",
+            group_address_brightness_blue="1/1/11",
+            group_address_brightness_blue_state="1/1/12",
+            group_address_switch_white="1/1/13",
+            group_address_switch_white_state="1/1/14",
+            group_address_brightness_white="1/1/15",
+            group_address_brightness_white_state="1/1/16",
+        )
+        self.loop.run_until_complete(light.sync())
+
+        self.assertEqual(xknx.telegrams.qsize(), 8)
+
+        telegrams = [xknx.telegrams.get_nowait() for _ in range(8)]
+
+        test_telegrams = [
+            Telegram(
+                destination_address=GroupAddress("1/1/2"),
+                payload=GroupValueRead(),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/4"),
+                payload=GroupValueRead(),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/6"),
+                payload=GroupValueRead(),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/8"),
+                payload=GroupValueRead(),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/10"),
+                payload=GroupValueRead(),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/12"),
+                payload=GroupValueRead(),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/14"),
+                payload=GroupValueRead(),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/16"),
+                payload=GroupValueRead(),
+            ),
+        ]
+
+        self.assertEqual(len(set(telegrams)), 8)
+        self.assertEqual(set(telegrams), set(test_telegrams))
+
     #
     # SYNC WITH STATE ADDRESS
     #
@@ -256,6 +407,55 @@ class TestLight(unittest.TestCase):
             ),
         )
 
+    def test_set_on_individual_color(self):
+        """Test switching on a Light."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "Diningroom.Light_1",
+            group_address_switch_red="1/1/1",
+            group_address_switch_red_state="1/1/2",
+            group_address_brightness_red="1/1/3",
+            group_address_brightness_red_state="1/1/4",
+            group_address_switch_green="1/1/5",
+            group_address_switch_green_state="1/1/6",
+            group_address_brightness_green="1/1/7",
+            group_address_brightness_green_state="1/1/8",
+            group_address_switch_blue="1/1/9",
+            group_address_switch_blue_state="1/1/10",
+            group_address_brightness_blue="1/1/11",
+            group_address_brightness_blue_state="1/1/12",
+            group_address_switch_white="1/1/13",
+            group_address_switch_white_state="1/1/14",
+            group_address_brightness_white="1/1/15",
+            group_address_brightness_white_state="1/1/16",
+        )
+        self.loop.run_until_complete(light.set_on())
+        self.assertEqual(xknx.telegrams.qsize(), 4)
+
+        telegrams = [xknx.telegrams.get_nowait() for _ in range(4)]
+
+        test_telegrams = [
+            Telegram(
+                destination_address=GroupAddress("1/1/1"),
+                payload=GroupValueWrite(DPTBinary(True)),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/5"),
+                payload=GroupValueWrite(DPTBinary(True)),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/9"),
+                payload=GroupValueWrite(DPTBinary(True)),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/13"),
+                payload=GroupValueWrite(DPTBinary(True)),
+            ),
+        ]
+        self.assertEqual(len(set(telegrams)), 4)
+        self.assertEqual(set(telegrams), set(test_telegrams))
+
     #
     # TEST SET OFF
     #
@@ -279,6 +479,55 @@ class TestLight(unittest.TestCase):
             ),
         )
 
+    def test_set_off_individual_color(self):
+        """Test switching off a Light."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "Diningroom.Light_1",
+            group_address_switch_red="1/1/1",
+            group_address_switch_red_state="1/1/2",
+            group_address_brightness_red="1/1/3",
+            group_address_brightness_red_state="1/1/4",
+            group_address_switch_green="1/1/5",
+            group_address_switch_green_state="1/1/6",
+            group_address_brightness_green="1/1/7",
+            group_address_brightness_green_state="1/1/8",
+            group_address_switch_blue="1/1/9",
+            group_address_switch_blue_state="1/1/10",
+            group_address_brightness_blue="1/1/11",
+            group_address_brightness_blue_state="1/1/12",
+            group_address_switch_white="1/1/13",
+            group_address_switch_white_state="1/1/14",
+            group_address_brightness_white="1/1/15",
+            group_address_brightness_white_state="1/1/16",
+        )
+        self.loop.run_until_complete(light.set_off())
+        self.assertEqual(xknx.telegrams.qsize(), 4)
+
+        telegrams = [xknx.telegrams.get_nowait() for _ in range(4)]
+
+        test_telegrams = [
+            Telegram(
+                destination_address=GroupAddress("1/1/1"),
+                payload=GroupValueWrite(DPTBinary(False)),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/5"),
+                payload=GroupValueWrite(DPTBinary(False)),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/9"),
+                payload=GroupValueWrite(DPTBinary(False)),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/13"),
+                payload=GroupValueWrite(DPTBinary(False)),
+            ),
+        ]
+        self.assertEqual(len(set(telegrams)), 4)
+        self.assertEqual(set(telegrams), set(test_telegrams))
+
     #
     # TEST SET BRIGHTNESS
     #
@@ -351,6 +600,89 @@ class TestLight(unittest.TestCase):
                 "Colors not supported for device %s", "TestLight"
             )
 
+    def test_set_individual_color(self):
+        """Test setting the color of a Light."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "Diningroom.Light_1",
+            group_address_switch_red="1/1/1",
+            group_address_switch_red_state="1/1/2",
+            group_address_brightness_red="1/1/3",
+            group_address_brightness_red_state="1/1/4",
+            group_address_switch_green="1/1/5",
+            group_address_switch_green_state="1/1/6",
+            group_address_brightness_green="1/1/7",
+            group_address_brightness_green_state="1/1/8",
+            group_address_switch_blue="1/1/9",
+            group_address_switch_blue_state="1/1/10",
+            group_address_brightness_blue="1/1/11",
+            group_address_brightness_blue_state="1/1/12",
+        )
+        self.loop.run_until_complete(light.set_color([23, 24, 25]))
+        self.assertEqual(xknx.telegrams.qsize(), 3)
+        telegrams = [xknx.telegrams.get_nowait() for _ in range(3)]
+
+        test_telegrams = [
+            Telegram(
+                destination_address=GroupAddress("1/1/3"),
+                payload=GroupValueWrite(DPTArray(23)),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/7"),
+                payload=GroupValueWrite(DPTArray(24)),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/11"),
+                payload=GroupValueWrite(DPTArray(25)),
+            ),
+        ]
+
+        self.assertEqual(len(set(telegrams)), 3)
+        self.assertEqual(set(telegrams), set(test_telegrams))
+
+        self.loop.run_until_complete(
+            xknx.devices.process(
+                Telegram(
+                    destination_address=GroupAddress("1/1/4"),
+                    payload=GroupValueWrite(DPTArray(23)),
+                )
+            )
+        )
+        self.loop.run_until_complete(
+            xknx.devices.process(
+                Telegram(
+                    destination_address=GroupAddress("1/1/8"),
+                    payload=GroupValueWrite(DPTArray(24)),
+                )
+            )
+        )
+        self.loop.run_until_complete(
+            xknx.devices.process(
+                Telegram(
+                    destination_address=GroupAddress("1/1/12"),
+                    payload=GroupValueWrite(DPTArray(25)),
+                )
+            )
+        )
+        self.assertEqual(light.current_color, ([23, 24, 25], None))
+
+    def test_set_individual_color_not_possible(self):
+        """Test setting the color of a non light without color."""
+        # pylint: disable=invalid-name
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "TestLight",
+            group_address_switch_red="1/1/1",
+        )
+        with patch("logging.Logger.warning") as mock_warn:
+            self.loop.run_until_complete(light.set_color((23, 24, 25)))
+            self.assertEqual(xknx.telegrams.qsize(), 0)
+            mock_warn.assert_called_with(
+                "Colors not supported for device %s", "TestLight"
+            )
+
     #
     # TEST SET COLOR AS RGBW
     #
@@ -395,6 +727,117 @@ class TestLight(unittest.TestCase):
                 "RGBW not supported for device %s", "TestLight"
             )
 
+    def test_set_individual_color_rgbw(self):
+        """Test setting RGBW value of a Light."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "Diningroom.Light_1",
+            group_address_switch_red="1/1/1",
+            group_address_switch_red_state="1/1/2",
+            group_address_brightness_red="1/1/3",
+            group_address_brightness_red_state="1/1/4",
+            group_address_switch_green="1/1/5",
+            group_address_switch_green_state="1/1/6",
+            group_address_brightness_green="1/1/7",
+            group_address_brightness_green_state="1/1/8",
+            group_address_switch_blue="1/1/9",
+            group_address_switch_blue_state="1/1/10",
+            group_address_brightness_blue="1/1/11",
+            group_address_brightness_blue_state="1/1/12",
+            group_address_switch_white="1/1/13",
+            group_address_switch_white_state="1/1/14",
+            group_address_brightness_white="1/1/15",
+            group_address_brightness_white_state="1/1/16",
+        )
+        self.loop.run_until_complete(light.set_color([23, 24, 25], white=26))
+        self.assertEqual(xknx.telegrams.qsize(), 4)
+        telegrams = [xknx.telegrams.get_nowait() for _ in range(4)]
+
+        test_telegrams = [
+            Telegram(
+                destination_address=GroupAddress("1/1/3"),
+                payload=GroupValueWrite(DPTArray(23)),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/7"),
+                payload=GroupValueWrite(DPTArray(24)),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/11"),
+                payload=GroupValueWrite(DPTArray(25)),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/15"),
+                payload=GroupValueWrite(DPTArray(26)),
+            ),
+        ]
+
+        self.assertEqual(len(set(telegrams)), 4)
+        self.assertEqual(set(telegrams), set(test_telegrams))
+
+        self.loop.run_until_complete(
+            xknx.devices.process(
+                Telegram(
+                    destination_address=GroupAddress("1/1/4"),
+                    payload=GroupValueWrite(DPTArray(23)),
+                )
+            )
+        )
+        self.loop.run_until_complete(
+            xknx.devices.process(
+                Telegram(
+                    destination_address=GroupAddress("1/1/8"),
+                    payload=GroupValueWrite(DPTArray(24)),
+                )
+            )
+        )
+        self.loop.run_until_complete(
+            xknx.devices.process(
+                Telegram(
+                    destination_address=GroupAddress("1/1/12"),
+                    payload=GroupValueWrite(DPTArray(25)),
+                )
+            )
+        )
+        self.loop.run_until_complete(
+            xknx.devices.process(
+                Telegram(
+                    destination_address=GroupAddress("1/1/16"),
+                    payload=GroupValueWrite(DPTArray(26)),
+                )
+            )
+        )
+        self.assertEqual(light.current_color, ([23, 24, 25], 26))
+
+    def test_set_individual_color_rgbw_not_possible(self):
+        """Test setting RGBW value of a non light without color."""
+        # pylint: disable=invalid-name
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "TestLight",
+            group_address_switch_red="1/1/1",
+            group_address_switch_red_state="1/1/2",
+            group_address_brightness_red="1/1/3",
+            group_address_brightness_red_state="1/1/4",
+            group_address_switch_green="1/1/5",
+            group_address_switch_green_state="1/1/6",
+            group_address_brightness_green="1/1/7",
+            group_address_brightness_green_state="1/1/8",
+            group_address_switch_blue="1/1/9",
+            group_address_switch_blue_state="1/1/10",
+            group_address_brightness_blue="1/1/11",
+            group_address_brightness_blue_state="1/1/12",
+        )
+        with patch("logging.Logger.warning") as mock_warn:
+            self.loop.run_until_complete(light.set_color((23, 24, 25), 26))
+
+            self.assertEqual(xknx.telegrams.qsize(), 0)
+            mock_warn.assert_called_with(
+                "RGBW not supported for device %s", "TestLight"
+            )
+
     #
     # TEST SET TUNABLE WHITE
     #
@@ -500,6 +943,41 @@ class TestLight(unittest.TestCase):
         self.loop.run_until_complete(light.process(telegram))
         self.assertEqual(light.state, False)
 
+    def test_process_color_switch(self):
+        """Test process / reading telegrams from telegram queue. Test if switch position is processed correctly."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "TestLight",
+            group_address_switch_red="1/1/1",
+            group_address_switch_red_state="1/1/2",
+            group_address_brightness_red="1/1/3",
+            group_address_brightness_red_state="1/1/4",
+            group_address_switch_green="1/1/5",
+            group_address_switch_green_state="1/1/6",
+            group_address_brightness_green="1/1/7",
+            group_address_brightness_green_state="1/1/8",
+            group_address_switch_blue="1/1/9",
+            group_address_switch_blue_state="1/1/10",
+            group_address_brightness_blue="1/1/11",
+            group_address_brightness_blue_state="1/1/12",
+        )
+        self.assertEqual(light.state, None)
+
+        telegram = Telegram(
+            destination_address=GroupAddress("1/1/2"),
+            payload=GroupValueWrite(DPTBinary(True)),
+        )
+        self.loop.run_until_complete(light.process(telegram))
+        self.assertEqual(light.state, True)
+
+        telegram = Telegram(
+            destination_address=GroupAddress("1/1/2"),
+            payload=GroupValueWrite(DPTBinary(False)),
+        )
+        self.loop.run_until_complete(light.process(telegram))
+        self.assertEqual(light.state, False)
+
     def test_process_switch_callback(self):
         """Test process / reading telegrams from telegram queue. Test if callback is called."""
         # pylint: disable=no-self-use
@@ -595,6 +1073,46 @@ class TestLight(unittest.TestCase):
         self.loop.run_until_complete(light.process(telegram))
         self.assertEqual(light.current_color, ((23, 24, 25), None))
 
+    def test_process_individual_color(self):
+        """Test process / reading telegrams from telegram queue. Test if color is processed."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "TestLight",
+            group_address_switch_red="1/1/1",
+            group_address_switch_red_state="1/1/2",
+            group_address_brightness_red="1/1/3",
+            group_address_brightness_red_state="1/1/4",
+            group_address_switch_green="1/1/5",
+            group_address_switch_green_state="1/1/6",
+            group_address_brightness_green="1/1/7",
+            group_address_brightness_green_state="1/1/8",
+            group_address_switch_blue="1/1/9",
+            group_address_switch_blue_state="1/1/10",
+            group_address_brightness_blue="1/1/11",
+            group_address_brightness_blue_state="1/1/12",
+        )
+        self.assertEqual(light.current_color, (None, None))
+
+        telegrams = [
+            Telegram(
+                destination_address=GroupAddress("1/1/4"),
+                payload=GroupValueWrite(DPTArray(42)),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/8"),
+                payload=GroupValueWrite(DPTArray(43)),
+            ),
+            Telegram(
+                destination_address=GroupAddress("1/1/12"),
+                payload=GroupValueWrite(DPTArray(44)),
+            ),
+        ]
+
+        for telegram in telegrams:
+            self.loop.run_until_complete(light.process(telegram))
+        self.assertEqual(light.current_color, ([42, 43, 44], None))
+
     def test_process_color_rgbw(self):
         """Test process / reading telegrams from telegram queue. Test if RGBW is processed."""
         xknx = XKNX()
@@ -613,6 +1131,37 @@ class TestLight(unittest.TestCase):
         self.loop.run_until_complete(light.process(telegram))
         self.assertEqual(light.current_color, ([23, 24, 25], 26))
 
+    def test_process_individual_color_rgbw(self):
+        """Test process / reading telegrams from telegram queue. Test if RGBW is processed."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "Diningroom.Light_1",
+            group_address_switch_red="1/1/1",
+            group_address_switch_red_state="1/1/2",
+            group_address_brightness_red="1/1/3",
+            group_address_brightness_red_state="1/1/4",
+            group_address_switch_green="1/1/5",
+            group_address_switch_green_state="1/1/6",
+            group_address_brightness_green="1/1/7",
+            group_address_brightness_green_state="1/1/8",
+            group_address_switch_blue="1/1/9",
+            group_address_switch_blue_state="1/1/10",
+            group_address_brightness_blue="1/1/11",
+            group_address_brightness_blue_state="1/1/12",
+            group_address_switch_white="1/1/13",
+            group_address_switch_white_state="1/1/14",
+            group_address_brightness_white="1/1/15",
+            group_address_brightness_white_state="1/1/16",
+        )
+        self.assertEqual(light.current_color, (None, None))
+        telegram = Telegram(
+            destination_address=GroupAddress("1/1/16"),
+            payload=GroupValueWrite(DPTArray(42)),
+        )
+        self.loop.run_until_complete(light.process(telegram))
+        self.assertEqual(light.current_color, (None, 42))
+
     def test_process_tunable_white(self):
         """Test process / reading telegrams from telegram queue. Test if tunable white is processed."""
         xknx = XKNX()
@@ -786,6 +1335,22 @@ class TestLight(unittest.TestCase):
             group_address_color_temperature_state="1/7/10",
             group_address_rgbw="1/7/11",
             group_address_rgbw_state="1/7/12",
+            group_address_switch_red="1/1/1",
+            group_address_switch_red_state="1/1/2",
+            group_address_brightness_red="1/1/3",
+            group_address_brightness_red_state="1/1/4",
+            group_address_switch_green="1/1/5",
+            group_address_switch_green_state="1/1/6",
+            group_address_brightness_green="1/1/7",
+            group_address_brightness_green_state="1/1/8",
+            group_address_switch_blue="1/1/9",
+            group_address_switch_blue_state="1/1/10",
+            group_address_brightness_blue="1/1/11",
+            group_address_brightness_blue_state="1/1/12",
+            group_address_switch_white="1/1/13",
+            group_address_switch_white_state="1/1/14",
+            group_address_brightness_white="1/1/15",
+            group_address_brightness_white_state="1/1/16",
         )
         self.assertTrue(light.has_group_address(GroupAddress("1/7/1")))
         self.assertTrue(light.has_group_address(GroupAddress("1/7/2")))
@@ -799,4 +1364,22 @@ class TestLight(unittest.TestCase):
         self.assertTrue(light.has_group_address(GroupAddress("1/7/10")))
         self.assertTrue(light.has_group_address(GroupAddress("1/7/11")))
         self.assertTrue(light.has_group_address(GroupAddress("1/7/12")))
+
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/1")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/2")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/3")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/4")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/5")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/6")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/7")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/8")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/9")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/10")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/11")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/12")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/13")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/14")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/15")))
+        self.assertTrue(light.has_group_address(GroupAddress("1/1/16")))
+
         self.assertFalse(light.has_group_address(GroupAddress("1/7/13")))
diff --git a/test/io_tests/tunnel_test.py b/test/io_tests/tunnel_test.py
index 7f93147c..ab7ba401 100644
--- a/test/io_tests/tunnel_test.py
+++ b/test/io_tests/tunnel_test.py
@@ -32,7 +32,7 @@ class TestTunnelling(unittest.TestCase):
         """Tear down test class."""
         self.loop.close()
 
-    @patch("xknx.io.Tunnel.send_ack")
+    @patch("xknx.io.Tunnel._send_tunnelling_ack")
     def test_tunnel_request_received(self, send_ack_mock):
         """Test Tunnel for calling send_ack on normal frames."""
         # LDataInd GroupValueWrite from 1.1.22 to to 5/1/22 with DPT9 payload 0C 3F
@@ -47,7 +47,7 @@ class TestTunnelling(unittest.TestCase):
         self.tg_received_mock.assert_called_once_with(telegram)
         send_ack_mock.assert_called_once_with(0x02, 0x21)
 
-    @patch("xknx.io.Tunnel.send_ack")
+    @patch("xknx.io.Tunnel._send_tunnelling_ack")
     def test_tunnel_request_received_unsupported_frames(self, send_ack_mock):
         """Test Tunnel sending ACK for unsupported frames."""
         # LDataInd APciPhysAddrRead from 0.0.1 to 0/0/0 broadcast - ETS scan for devices in programming mode
diff --git a/test/knxip_tests/connect_response_test.py b/test/knxip_tests/connect_response_test.py
index 2c9ba785..3ca0047f 100644
--- a/test/knxip_tests/connect_response_test.py
+++ b/test/knxip_tests/connect_response_test.py
@@ -133,19 +133,117 @@ class Test_KNXIP_ConnectResponse(unittest.TestCase):
         with self.assertRaises(CouldNotParseKNXIP):
             knxipframe.from_knx(raw)
 
-    def test_connect_response_connection_error(self):
-        """Test parsing and streaming connection response KNX/IP packet with error."""
-        raw = (0x06, 0x10, 0x02, 0x06, 0x00, 0x08, 0x00, 0x24)
+    def test_connect_response_connection_error_gira(self):
+        """
+        Test parsing and streaming connection response KNX/IP packet with error e_no_more_connections.
+        HPAI and CRD normal. This was received from Gira devices (2020).
+        """
+        raw = (
+            0x06,
+            0x10,
+            0x02,
+            0x06,
+            0x00,
+            0x14,
+            0xC0,
+            0x24,
+            0x08,
+            0x01,
+            0x0A,
+            0x01,
+            0x00,
+            0x29,
+            0x0E,
+            0x57,
+            0x04,
+            0x04,
+            0x00,
+            0x00,
+        )
         xknx = XKNX()
         knxipframe = KNXIPFrame(xknx)
         knxipframe.from_knx(raw)
         self.assertTrue(isinstance(knxipframe.body, ConnectResponse))
         self.assertEqual(knxipframe.body.status_code, ErrorCode.E_NO_MORE_CONNECTIONS)
-        self.assertEqual(knxipframe.body.communication_channel, 0)
+        self.assertEqual(knxipframe.body.communication_channel, 192)
 
         knxipframe2 = KNXIPFrame(xknx)
         knxipframe2.init(KNXIPServiceType.CONNECT_RESPONSE)
         knxipframe2.body.status_code = ErrorCode.E_NO_MORE_CONNECTIONS
+        knxipframe2.body.communication_channel = 192
+        knxipframe2.body.request_type = ConnectRequestType.TUNNEL_CONNECTION
+        knxipframe2.body.control_endpoint = HPAI(ip_addr="10.1.0.41", port=3671)
+        knxipframe2.body.identifier = 0
         knxipframe2.normalize()
 
         self.assertEqual(knxipframe2.to_knx(), list(raw))
+
+    def test_connect_response_connection_error_lox(self):
+        """
+        Test parsing and streaming connection response KNX/IP packet with error e_no_more_connections.
+        HPAI given, CRD all zero. This was received from Loxone device (2020).
+        """
+        raw = (
+            0x06,
+            0x10,
+            0x02,
+            0x06,
+            0x00,
+            0x14,
+            0x00,
+            0x24,
+            0x08,
+            0x01,
+            0xC0,
+            0xA8,
+            0x01,
+            0x01,
+            0x0E,
+            0x57,
+            0x00,
+            0x00,
+            0x00,
+            0x00,
+        )
+        xknx = XKNX()
+        knxipframe = KNXIPFrame(xknx)
+        knxipframe.from_knx(raw)
+        self.assertTrue(isinstance(knxipframe.body, ConnectResponse))
+        self.assertEqual(knxipframe.body.status_code, ErrorCode.E_NO_MORE_CONNECTIONS)
+        self.assertEqual(knxipframe.body.communication_channel, 0)
+
+        # no further tests: the current API can't (and shouldn't) create such odd packets
+
+    def test_connect_response_connection_error_mdt(self):
+        """
+        Test parsing and streaming connection response KNX/IP packet with error e_no_more_connections.
+        HPAI and CRD all zero. This was received from MDT device (2020).
+        """
+        raw = (
+            0x06,
+            0x10,
+            0x02,
+            0x06,
+            0x00,
+            0x08,
+            0x00,
+            0x24,
+            0x00,
+            0x00,
+            0x00,
+            0x00,
+            0x00,
+            0x00,
+            0x00,
+            0x00,
+            0x00,
+            0x00,
+        )
+        xknx = XKNX()
+        knxipframe = KNXIPFrame(xknx)
+        knxipframe.from_knx(raw)
+        self.assertTrue(isinstance(knxipframe.body, ConnectResponse))
+        self.assertEqual(knxipframe.body.status_code, ErrorCode.E_NO_MORE_CONNECTIONS)
+        self.assertEqual(knxipframe.body.communication_channel, 0)
+
+        # no further tests: the current API can't (and shouldn't) create such odd packets
diff --git a/test/remote_value_tests/remote_value_test.py b/test/remote_value_tests/remote_value_test.py
index 4ac9071a..c68a97e1 100644
--- a/test/remote_value_tests/remote_value_test.py
+++ b/test/remote_value_tests/remote_value_test.py
@@ -11,6 +11,7 @@ from xknx.telegram import GroupAddress, Telegram
 from xknx.telegram.apci import GroupValueWrite
 
 
+@patch.multiple(RemoteValue, __abstractmethods__=set())
 class TestRemoteValue(unittest.TestCase):
     """Test class for RemoteValue objects."""
 
@@ -33,26 +34,6 @@ class TestRemoteValue(unittest.TestCase):
                 "'payload_valid()' not implemented for %s", "RemoteValue"
             )
 
-    def test_warn_to_knx(self):
-        """Test for warning if to_knx is not implemented."""
-        xknx = XKNX()
-        remote_value = RemoteValue(xknx)
-        with patch("logging.Logger.warning") as mock_warn:
-            remote_value.to_knx(23)
-            mock_warn.assert_called_with(
-                "'to_knx()' not implemented for %s", "RemoteValue"
-            )
-
-    def test_warn_from_knx(self):
-        """Test for warning if from_knx is not implemented."""
-        xknx = XKNX()
-        remote_value = RemoteValue(xknx)
-        with patch("logging.Logger.warning") as mock_warn:
-            remote_value.from_knx(DPTBinary(0))
-            mock_warn.assert_called_with(
-                "'from_knx()' not implemented for %s", "RemoteValue"
-            )
-
     def test_info_set_uninitialized(self):
         """Test for info if RemoteValue is not initialized."""
         xknx = XKNX()
diff --git a/test/str_test.py b/test/str_test.py
index 2d95776a..335ad9e7 100644
--- a/test/str_test.py
+++ b/test/str_test.py
@@ -1,6 +1,7 @@
 """Unit test for String representations."""
 import asyncio
 import unittest
+from unittest.mock import patch
 
 from xknx import XKNX
 from xknx.devices import (
@@ -72,9 +73,11 @@ class TestStringRepresentations(unittest.TestCase):
         """Tear down test class."""
         self.loop.close()
 
+    @patch.multiple(RemoteValue, __abstractmethods__=set())
     def test_remote_value(self):
         """Test string representation of remote value."""
         xknx = XKNX()
+        # pylint: disable=abstract-class-instantiated
         remote_value = RemoteValue(
             xknx,
             group_address="1/2/3",