Repository: XKNX/xknx
Pull request #479 (2020-10-23 21:03:22)

Add DEVICE_CLASS support for cover
<!--
  You are awesome! Thanks for contributing to our project!
  Please, DO NOT DELETE ANY TEXT from this template!.
-->
DEVICE_CLASS will be supported for covers as it is already done for binary_sensors. It will be possible in future to define the cover DEVICE_CLASS directly in the YAML file in home assistant. For the time being the DEVICE_CLASS definition could only be done by customization.
<!--
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.
-->


Fixes # (issue)

## Type of change
<!--
Please tick the applicable options.
NOTE: Ticking multiple options most likely indicates
that your change is to big and it is suggested to split it into several smaller PRs.
-->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [x] This change requires a documentation update

## Checklist:

- [x] I have performed a self-review of my own code
- [x] The documentation has been adjusted accordingly
- [x] The changes generate no new warnings
- [x] Tests have been added that prove the fix is effective or that the feature works
- [x] The changes are documented in the changelog
- [x] The Homeassistant plugin has been adjusted in case of new config options


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

Coverage increased (+0.002%) to 93.75% when pulling **2b794077327f24732ca7aff78299dfe968a37eb6 on KNXBroker:cover_class** into **9eedf83878c394229e13e64986a727659713f94d on XKNX:master**.


Patch:
diff --git a/changelog.md b/changelog.md
index b3ef0a15..cec7d858 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,6 +1,6 @@
 # Changelog
 
-## Unreleased Changes
+## 0.15.3 Opposite day! 2020-10-29
 
 ### Devices
 
@@ -12,6 +12,8 @@
 ### Bugfixes
 
 - HA Switch entity: keep state without state_address
+- Cover: fix `set_position` without writable position / auto_stop_if_necessary
+- handle unsupported CEMI Messages without loosing tunnel connection
 
 ## 0.15.2 Winter is coming
 
diff --git a/docs/climate.md b/docs/climate.md
index 51eb550e..c3f10dd5 100644
--- a/docs/climate.md
+++ b/docs/climate.md
@@ -13,44 +13,40 @@ Climate are representations of KNX HVAC/Climate controls.
 
 ## [](#header-2)Interface
 
-- **xknx** is the XKNX object.
-- **name** is the name of the object.
-- **group_address_temperature** KNX address of current room temperature.
-- **group_address_target_temperature** KNX address for setting the target temperature if setpoint shift is not supported.
-- **group_address_target_temperature_state** KNX address for reading the target temperature from the KNX bus. Used in for setpoint_shift calculations as base temperature.
-- **group_address_setpoint_shift** KNX address to set setpoint_shift (base temperature deviation).
-- **group_address_setpoint_shift_state** KNX address to read current setpoint_shift.
-- **setpoint_shift_mode** Enum for setpoint_shift payload encoding. Can be DPT 6.010 or DPT 9.002. Default: SetpointShiftMode.DPT6010
-- **setpoint_shift_max** Maximum value for setpoint_shift.
-- **setpoint_shift_min** Minimum value for setpoint_shift.
-- **temperature_step** Set the multiplier for setpoint_shift (DPT 6) calculations and the step size for HA UI-elements.
-- **group_address_on_off** KNX address for turning climate device on or off.
-- **group_address_on_off_state** KNX address for reading the on/off state.
-- **on_off_invert** Invert on/off.
-- **max_temp** Maximum value for target temperature.
-- **min_temp** Minimum value for target temperature.
-- **mode** ClimateMode instance for this climate device
-- **device_updated_cb** awaitable callback for each update.
-
-* **xknx** is the XKNX object.
-* **name** is the name of the object.
-* **group_address_operation_mode** KNX address for operation mode. DPT 20.102
-* **group_address_operation_mode_state** KNX address for operation mode status. DPT 20.102
-* **group_address_operation_mode_protection** KNX address for switching on/off frost/heat protection mode. DPT 1
-* **group_address_operation_mode_night** KNX address for switching on/off night nmode. DPT 1
-* **group_address_operation_mode_comfort** KNX address for switching on/off comfort mode. DPT 1
-* **group_address_operation_mode_standby** KNX address for switching on/off standby mode. DPT 1
-* **group_address_controller_status** KNX address for controller status.
-* **group_address_controller_status_state** KNX address for controller status state.
-* **group_address_controller_mode** KNX address for controller mode. DPT 20.105
-* **group_address_controller_mode_state** KNX address for controller mode status. DPT 20.105
-* **group_address_heat_cool** KNX address for switching heating / cooling mode. DPT 1
-* **group_address_heat_cool_state** KNX address for reading heating / cooling mode. DPT 1
-* **operation_modes** Overrides the supported operation modes.
-* **controller_modes** Overrides the supported controller modes.
-* **device_updated_cb** awaitable callback for each update.
-
-**Note:** `group_address_operation_mode_protection` / `group_address_operation_mode_night` / `group_address_operation_mode_comfort` / `group_address_operation_mode_standby` are not necessary if `group_address_operation_mode` was specified. When one of these is set `True`, the others will be set `False`. When one of these is set "Standby", "Comfort", "Frost_Protection" and "Night" will be set as supported. If `group_address_operation_mode_standby` is omitted, "Standby" is set when the other 3 are set to `False`.
+- `xknx` is the XKNX object.
+- `name` is the name of the object.
+- `group_address_temperature` KNX address of current room temperature.
+- `group_address_target_temperature` KNX address for setting the target temperature if setpoint shift is not supported.
+- `group_address_target_temperature_state` KNX address for reading the target temperature from the KNX bus. Used in for setpoint_shift calculations as base temperature.
+- `group_address_setpoint_shift` KNX address to set setpoint_shift (base temperature deviation).
+- `group_address_setpoint_shift_state` KNX address to read current setpoint_shift.
+- `setpoint_shift_mode` Enum for setpoint_shift payload encoding. Can be *DPT 6.010* or *DPT 9.002*. Default: SetpointShiftMode.DPT6010
+- `setpoint_shift_max` Maximum value for setpoint_shift.
+- `setpoint_shift_min` Minimum value for setpoint_shift.
+- `temperature_step` Set the multiplier for setpoint_shift (*DPT 6*) calculations and the step size for HA UI-elements.
+- `group_address_on_off` KNX address for turning climate device on or off.
+- `group_address_on_off_state` KNX address for reading the on/off state.
+- `on_off_invert` Invert on/off.
+- `max_temp` Maximum value for target temperature.
+- `min_temp` Minimum value for target temperature.
+- `mode` ClimateMode instance for this climate device
+- `group_address_operation_mode` KNX address for operation mode. *DPT 20.102*
+- `group_address_operation_mode_state` KNX address for operation mode status. *DPT 20.102*
+- `group_address_operation_mode_protection` KNX address for switching on/off frost/heat protection mode. *DPT 1*
+- `group_address_operation_mode_night` KNX address for switching on/off night nmode. *DPT 1*
+- `group_address_operation_mode_comfort` KNX address for switching on/off comfort mode. *DPT 1*
+- `group_address_operation_mode_standby` KNX address for switching on/off standby mode. *DPT 1*
+- `group_address_controller_status` KNX address for controller status.
+- `group_address_controller_status_state` KNX address for controller status state.
+- `group_address_controller_mode` KNX address for controller mode. *DPT 20.105*
+- `group_address_controller_mode_state` KNX address for controller mode status. *DPT 20.105*
+- `group_address_heat_cool` KNX address for switching heating / cooling mode. *DPT 1*
+- `group_address_heat_cool_state` KNX address for reading heating / cooling mode. *DPT 1*
+- `operation_modes` Overrides the supported operation modes.
+- `controller_modes` Overrides the supported controller modes.
+- `device_updated_cb` awaitable callback for each update.
+
+**Note:** `group_address_operation_mode_protection` / `group_address_operation_mode_night` / `group_address_operation_mode_comfort` / `group_address_operation_mode_standby` are not necessary if `group_address_operation_mode` was specified. When one of these is set `True`, the others will be set `False`. When one of these is set `Standby`, `Comfort`, `Frost_Protection` and `Night` will be set as supported. If `group_address_operation_mode_standby` is omitted, `Standby` is set when the other 3 are set to `False`.
 If only a subset of operation modes shall be used a list of supported modes may be passed to `operation_modes`.
 
 ```python
diff --git a/docs/configuration.md b/docs/configuration.md
index dc496f19..839a15e4 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -117,7 +117,7 @@ groups:
         travel_time_up: 60,
       }
 
-    # Covers without direct positioning:
+    # Covers without direct positioning and device class "shutter":
     Livingroom.Shutter_3:
       {
         group_address_long: "1/4/9",
@@ -125,6 +125,7 @@ groups:
         group_address_position_feedback: "1/4/11",
         travel_time_down: 50,
         travel_time_up: 60,
+        device_class: "shutter",
       }
 
     # Central Shutters dont have short or position address
diff --git a/docs/cover.md b/docs/cover.md
index 5307d3e3..beff650b 100644
--- a/docs/cover.md
+++ b/docs/cover.md
@@ -26,6 +26,7 @@ Shutters are simple representations of blind/roller cover actuators. With XKNX y
 - `travel_time_up` seconds to reach upper end position. Default: 22
 - `invert_position` invert position (payload for eg. set_up() and relative position). Default: False
 - `invert_angle` invert angle. Default: False
+- `device_class` may be used to store the type of cover, e.g. "shutter" for Home-Assistant (see [cover documentation](https://www.home-assistant.io/integrations/cover/) for details).
 - `device_updated_cb` awaitable callback for each update.
 
 ## [](#header-2)Example
@@ -42,7 +43,8 @@ cover = Cover(xknx,
               travel_time_down=50,
               travel_time_up=60,
               invert_position=False,
-              invert_angle=False)
+              invert_angle=False,
+              device_class='shutter')
 
 # Moving to up position
 await cover.set_up()
diff --git a/home-assistant-plugin/custom_components/xknx/cover.py b/home-assistant-plugin/custom_components/xknx/cover.py
index c677b12c..b88b1cfe 100644
--- a/home-assistant-plugin/custom_components/xknx/cover.py
+++ b/home-assistant-plugin/custom_components/xknx/cover.py
@@ -5,6 +5,7 @@ from homeassistant.components.cover import (
     ATTR_POSITION,
     ATTR_TILT_POSITION,
     DEVICE_CLASS_BLIND,
+    DEVICE_CLASSES,
     SUPPORT_CLOSE,
     SUPPORT_OPEN,
     SUPPORT_SET_POSITION,
@@ -47,6 +48,8 @@ class KNXCover(KnxEntity, CoverEntity):
     @property
     def device_class(self):
         """Return the class of this device, from component DEVICE_CLASSES."""
+        if self._device.device_class in DEVICE_CLASSES:
+            return self._device.device_class
         if self._device.supports_angle:
             return DEVICE_CLASS_BLIND
         return None
diff --git a/home-assistant-plugin/custom_components/xknx/factory.py b/home-assistant-plugin/custom_components/xknx/factory.py
index 83c48d2f..608ff40d 100644
--- a/home-assistant-plugin/custom_components/xknx/factory.py
+++ b/home-assistant-plugin/custom_components/xknx/factory.py
@@ -82,6 +82,7 @@ def _create_cover(knx_module: XKNX, config: ConfigType) -> XknxCover:
         travel_time_up=config[CoverSchema.CONF_TRAVELLING_TIME_UP],
         invert_position=config[CoverSchema.CONF_INVERT_POSITION],
         invert_angle=config[CoverSchema.CONF_INVERT_ANGLE],
+        device_class=config.get(CONF_DEVICE_CLASS),
     )
 
 
diff --git a/home-assistant-plugin/custom_components/xknx/schema.py b/home-assistant-plugin/custom_components/xknx/schema.py
index cf3177bf..8affa81d 100644
--- a/home-assistant-plugin/custom_components/xknx/schema.py
+++ b/home-assistant-plugin/custom_components/xknx/schema.py
@@ -77,6 +77,7 @@ class CoverSchema:
             ): cv.positive_int,
             vol.Optional(CONF_INVERT_POSITION, default=False): cv.boolean,
             vol.Optional(CONF_INVERT_ANGLE, default=False): cv.boolean,
+            vol.Optional(CONF_DEVICE_CLASS): cv.string,
         }
     )
 
diff --git a/xknx.yaml b/xknx.yaml
index 984c2b06..ed119385 100644
--- a/xknx.yaml
+++ b/xknx.yaml
@@ -101,7 +101,7 @@ groups:
                 travel_time_up: 60,
             }
 
-        # Covers without direct positioning:
+        # Covers without direct positioning and device class "shutter":
         Livingroom.Shutter_3:
             {
                 group_address_long: "1/4/9",
@@ -109,6 +109,7 @@ groups:
                 group_address_position_state: "1/4/11",
                 travel_time_down: 50,
                 travel_time_up: 60,
+                device_class: "shutter",
             }
 
         # Central Shutters dont have short or position address
diff --git a/xknx/__version__.py b/xknx/__version__.py
index 17369543..66489472 100644
--- a/xknx/__version__.py
+++ b/xknx/__version__.py
@@ -1,3 +1,3 @@
 """XKNX version."""
 
-__version__ = "0.15.2"
+__version__ = "0.15.3"
diff --git a/xknx/devices/cover.py b/xknx/devices/cover.py
index 4ff53c1a..c6a2f3ec 100644
--- a/xknx/devices/cover.py
+++ b/xknx/devices/cover.py
@@ -49,6 +49,7 @@ class Cover(Device):
         invert_position=False,
         invert_angle=False,
         device_updated_cb=None,
+        device_class=None,
     ):
         """Initialize Cover class."""
         # pylint: disable=too-many-arguments
@@ -110,6 +111,8 @@ class Cover(Device):
 
         self.travelcalculator = TravelCalculator(travel_time_down, travel_time_up)
 
+        self.device_class = device_class
+
     def _iter_remote_values(self):
         """Iterate the devices RemoteValue classes."""
         yield from (self.updown, self.step, self.stop_, self.position, self.angle)
@@ -128,6 +131,7 @@ class Cover(Device):
         travel_time_up = config.get("travel_time_up", cls.DEFAULT_TRAVEL_TIME_UP)
         invert_position = config.get("invert_position", False)
         invert_angle = config.get("invert_angle", False)
+        device_class = config.get("device_class")
 
         return cls(
             xknx,
@@ -143,6 +147,7 @@ class Cover(Device):
             travel_time_up=travel_time_up,
             invert_position=invert_position,
             invert_angle=invert_angle,
+            device_class=device_class,
         )
 
     def __str__(self):
@@ -270,13 +275,20 @@ class Cover(Device):
 
     async def process_group_write(self, telegram):
         """Process incoming and outgoing GROUP WRITE telegram."""
+        # call after_update to account for travelcalculator changes
         if await self.updown.process(telegram):
-            if self.updown.value == RemoteValueUpDown.Direction.UP:
+            if (
+                not self.is_opening()
+                and self.updown.value == RemoteValueUpDown.Direction.UP
+            ):
                 self.travelcalculator.start_travel_up()
-            else:
+                await self.after_update()
+            elif (
+                not self.is_closing()
+                and self.updown.value == RemoteValueUpDown.Direction.DOWN
+            ):
                 self.travelcalculator.start_travel_down()
-            # call after_update to account for travelcalculator changes
-            await self.after_update()
+                await self.after_update()
         # stop from bus
         if await self.stop_.process(telegram) or await self.step.process(telegram):
             if self.is_traveling():
diff --git a/xknx/io/knxip_interface.py b/xknx/io/knxip_interface.py
index 16163650..27fd0ad1 100644
--- a/xknx/io/knxip_interface.py
+++ b/xknx/io/knxip_interface.py
@@ -155,7 +155,7 @@ class KNXIPInterface:
     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", local_ip)
+        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()
 
diff --git a/xknx/io/udp_client.py b/xknx/io/udp_client.py
index 17e8556b..1bed0091 100644
--- a/xknx/io/udp_client.py
+++ b/xknx/io/udp_client.py
@@ -78,7 +78,7 @@ class UDPClient:
         self.transport = None
         self.callbacks = []
 
-    def data_received_callback(self, raw):
+    def data_received_callback(self, raw: bytes):
         """Parse and process KNXIP frame. Callback for having received an UDP packet."""
         if raw:
             try:
diff --git a/xknx/knxip/cemi_frame.py b/xknx/knxip/cemi_frame.py
index aa8fbbb6..29065cff 100644
--- a/xknx/knxip/cemi_frame.py
+++ b/xknx/knxip/cemi_frame.py
@@ -134,7 +134,9 @@ class CEMIFrame:
             self.code = CEMIMessageCode(raw[0])
         except ValueError:
             raise UnsupportedCEMIMessage(
-                "CEMIMessageCode not implemented: {} ".format(raw[0])
+                "CEMIMessageCode not implemented: {} in CEMI: {}".format(
+                    raw[0], raw.hex()
+                )
             )
 
         if self.code not in (
@@ -143,7 +145,9 @@ class CEMIFrame:
             CEMIMessageCode.L_DATA_CON,
         ):
             raise UnsupportedCEMIMessage(
-                "Could not handle CEMIMessageCode: {} / {}".format(self.code, raw[0])
+                "Could not handle CEMIMessageCode: {} / {} in CEMI: {}".format(
+                    self.code, raw[0], raw.hex()
+                )
             )
 
         return self.from_knx_data_link_layer(raw)
@@ -153,7 +157,7 @@ class CEMIFrame:
         if len(cemi) < 11:
             # eg. ETS Line-Scan issues L_DATA_IND with length 10
             raise UnsupportedCEMIMessage(
-                "CEMI too small. Length: {}; CEMI: {}".format(len(cemi), cemi)
+                "CEMI too small. Length: {}; CEMI: {}".format(len(cemi), cemi.hex())
             )
 
         # AddIL (Additional Info Length), as specified within
@@ -183,13 +187,17 @@ class CEMIFrame:
             self.cmd = APCICommand(tpci_apci & 0xFFC0)
         except ValueError:
             raise UnsupportedCEMIMessage(
-                "APCI not supported: {:#012b}".format(tpci_apci & 0xFFC0)
+                "APCI not supported: {:#012b} in CEMI: {}".format(
+                    tpci_apci & 0xFFC0, cemi.hex()
+                )
             )
 
         apdu = cemi[10 + addil :]
         if len(apdu) != self.mpdu_len:
             raise CouldNotParseKNXIP(
-                "APDU LEN should be {} but is {}".format(self.mpdu_len, len(apdu))
+                "APDU LEN should be {} but is {} in CEMI: {}".format(
+                    self.mpdu_len, len(apdu), cemi.hex()
+                )
             )
 
         if len(apdu) == 1:
diff --git a/xknx/knxip/tunnelling_request.py b/xknx/knxip/tunnelling_request.py
index 953765d1..83133a1a 100644
--- a/xknx/knxip/tunnelling_request.py
+++ b/xknx/knxip/tunnelling_request.py
@@ -62,10 +62,10 @@ class TunnellingRequest(KNXIPBody):
         try:
             pos += self.cemi.from_knx(raw[pos:])
         except UnsupportedCEMIMessage as unsupported_cemi_err:
-            logger.warning("CEMI not supported: %s", unsupported_cemi_err)
+            logger.debug("CEMI not supported: %s", unsupported_cemi_err)
             # Set cemi to None - this is checked in Tunnel() to send Ack even for unsupported CEMI messages.
             self.cemi = None
-            pos += len(raw)
+            return len(raw)
         return pos
 
     def to_knx(self):
diff --git a/xknx/telegram/address.py b/xknx/telegram/address.py
index cfce86d8..b70f6418 100644
--- a/xknx/telegram/address.py
+++ b/xknx/telegram/address.py
@@ -147,9 +147,13 @@ class PhysicalAddress(BaseAddress):
         """Return `True` if this address is a valid line address."""
         return not self.is_device
 
+    def __str__(self) -> str:
+        """Return object as in KNX notation (e.g. '1.2.3')."""
+        return f"{self.area}.{self.main}.{self.line}"
+
     def __repr__(self) -> str:
         """Return this object as parsable string."""
-        return 'PhysicalAddress("{0.area}.{0.main}.{0.line}")'.format(self)
+        return f'PhysicalAddress("{self}")'
 
 
 class GroupAddressType(Enum):


Test patch:
diff --git a/requirements/testing.txt b/requirements/testing.txt
index f198de05..9d7b5606 100644
--- a/requirements/testing.txt
+++ b/requirements/testing.txt
@@ -1,5 +1,5 @@
 -r production.txt
-pre-commit==2.7.1
+pre-commit==2.8.2
 isort==5.6.4
 coveralls==2.1.2
 flake8==3.8.4
@@ -7,7 +7,7 @@ flake8-isort==4.0.0
 pydocstyle==5.1.1
 pylint==2.6.0
 pylint-strict-informational==0.1
-pytest==6.1.1
+pytest==6.1.2
 pytest-cov==2.10.1
 pytest-timeout==1.4.2
 setuptools==50.3.2
diff --git a/test/config_tests/config_v1_test.py b/test/config_tests/config_v1_test.py
index 6c12b6c4..25b1b563 100644
--- a/test/config_tests/config_v1_test.py
+++ b/test/config_tests/config_v1_test.py
@@ -268,6 +268,22 @@ class TestConfig(unittest.TestCase):
             ),
         )
 
+    def test_config_cover_device_class(self):
+        """Test reading cover with device_class from config file."""
+        self.assertEqual(
+            TestConfig.xknx.devices["Livingroom.Shutter_3"],
+            Cover(
+                TestConfig.xknx,
+                "Livingroom.Shutter_3",
+                group_address_long="1/4/9",
+                group_address_short="1/4/10",
+                group_address_position_state="1/4/11",
+                travel_time_down=50,
+                travel_time_up=60,
+                device_class="shutter",
+            ),
+        )
+
     def test_config_cover_venetian(self):
         """Test reading Cover with angle from config file."""
         self.assertEqual(
diff --git a/test/devices_tests/cover_test.py b/test/devices_tests/cover_test.py
index 16e4547c..0865454c 100644
--- a/test/devices_tests/cover_test.py
+++ b/test/devices_tests/cover_test.py
@@ -397,6 +397,10 @@ class TestCover(unittest.TestCase):
             telegram, Telegram(GroupAddress("1/2/1"), payload=DPTBinary(0))
         )
         self.assertEqual(cover.travelcalculator.travel_to_position, 50)
+        self.assertTrue(cover.is_opening())
+        # process the outgoing telegram to make sure it doesn't overwrite the target position
+        self.loop.run_until_complete(cover.process(telegram))
+        self.assertEqual(cover.travelcalculator.travel_to_position, 50)
 
     def test_position_without_position_address_down(self):
         """Test moving cover down - with no absolute positioning supported."""
@@ -416,6 +420,10 @@ class TestCover(unittest.TestCase):
             telegram, Telegram(GroupAddress("1/2/1"), payload=DPTBinary(1))
         )
         self.assertEqual(cover.travelcalculator.travel_to_position, 80)
+        self.assertTrue(cover.is_closing())
+        # process the outgoing telegram to make sure it doesn't overwrite the target position
+        self.loop.run_until_complete(cover.process(telegram))
+        self.assertEqual(cover.travelcalculator.travel_to_position, 80)
 
     def test_position_without_position_address_uninitialized_up(self):
         """Test moving uninitialized cover to absolute position - with no absolute positioning supported."""
diff --git a/test/io_tests/tunnel_test.py b/test/io_tests/tunnel_test.py
new file mode 100644
index 00000000..7f93147c
--- /dev/null
+++ b/test/io_tests/tunnel_test.py
@@ -0,0 +1,70 @@
+"""Unit test for KNX/IP Tunnelling Request/Response."""
+import asyncio
+import unittest
+from unittest.mock import Mock, patch
+
+from xknx import XKNX
+from xknx.io import Tunnel
+from xknx.knxip import CEMIFrame
+from xknx.telegram import TelegramDirection
+
+
+class TestTunnelling(unittest.TestCase):
+    """Test class for xknx/io/Tunnelling objects."""
+
+    def setUp(self):
+        """Set up test class."""
+        self.loop = asyncio.new_event_loop()
+        asyncio.set_event_loop(self.loop)
+        self.xknx = XKNX()
+        self.tg_received_mock = Mock()
+        self.tunnel = Tunnel(
+            self.xknx,
+            local_ip="192.168.1.1",
+            gateway_ip="192.168.1.2",
+            gateway_port=3671,
+            telegram_received_callback=self.tg_received_mock,
+            auto_reconnect=False,
+            auto_reconnect_wait=3,
+        )
+
+    def tearDown(self):
+        """Tear down test class."""
+        self.loop.close()
+
+    @patch("xknx.io.Tunnel.send_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
+        # communication_channel_id: 0x02   sequence_counter: 0x21
+        raw = bytes.fromhex("0610 0420 0017 04 02 21 00 2900bcd011162916030080 0c 3f")
+        _cemi = CEMIFrame(self.xknx)
+        _cemi.from_knx(raw[10:])
+        telegram = _cemi.telegram
+        telegram.direction = TelegramDirection.INCOMING
+
+        self.tunnel.udp_client.data_received_callback(raw)
+        self.tg_received_mock.assert_called_once_with(telegram)
+        send_ack_mock.assert_called_once_with(0x02, 0x21)
+
+    @patch("xknx.io.Tunnel.send_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
+        # <UnsupportedCEMIMessage description="APCI not supported: 0b0100000000 in CEMI: 2900b0d000010000010100" />
+        # communication_channel_id: 0x02   sequence_counter: 0x4f
+        raw = bytes.fromhex("0610 0420 0015 04 02 4f 00 2900b0d000010000010100")
+
+        self.tunnel.udp_client.data_received_callback(raw)
+        self.tg_received_mock.assert_not_called()
+        send_ack_mock.assert_called_once_with(0x02, 0x4F)
+        send_ack_mock.reset_mock()
+
+        # LDataInd T_Connect from 1.0.250 to 1.0.255 (xknx tunnel endpoint) - ETS Line-Scan
+        # <UnsupportedCEMIMessage description="CEMI too small. Length: 10; CEMI: 2900b06010fa10ff0080" />
+        # communication_channel_id: 0x02   sequence_counter: 0x81
+        raw = bytes.fromhex("0610 0420 0014 04 02 81 00 2900b06010fa10ff0080")
+
+        self.tunnel.udp_client.data_received_callback(raw)
+        self.tg_received_mock.assert_not_called()
+        send_ack_mock.assert_called_once_with(0x02, 0x81)
diff --git a/test/knxip_tests/cemi_frame_test.py b/test/knxip_tests/cemi_frame_test.py
index 0b1fa670..b911f8fb 100644
--- a/test/knxip_tests/cemi_frame_test.py
+++ b/test/knxip_tests/cemi_frame_test.py
@@ -11,20 +11,22 @@ from xknx.telegram import PhysicalAddress, Telegram
 
 
 def get_data(code, adil, flags, src, dst, mpdu_len, tpci_apci, payload):
-    return [
-        code,
-        adil,  # adil
-        (flags >> 8) & 255,  # flags
-        flags & 255,  # flags
-        (src >> 8) & 255,  # src
-        src & 255,  # src
-        (dst >> 8) & 255,  # dst
-        dst & 255,  # dst
-        mpdu_len,  # mpdu_len
-        (tpci_apci >> 8) & 255,  # tpci_apci
-        tpci_apci & 255,  # tpci_apci
-        *payload,  # payload
-    ]
+    return bytes(
+        [
+            code,
+            adil,  # adil
+            (flags >> 8) & 255,  # flags
+            flags & 255,  # flags
+            (src >> 8) & 255,  # src
+            src & 255,  # src
+            (dst >> 8) & 255,  # dst
+            dst & 255,  # dst
+            mpdu_len,  # mpdu_len
+            (tpci_apci >> 8) & 255,  # tpci_apci
+            tpci_apci & 255,  # tpci_apci
+            *payload,  # payload
+        ]
+    )
 
 
 @fixture(name="frame")
@@ -127,7 +129,9 @@ def test_from_knx_with_not_handleable_cemi(frame):
 def test_from_knx_with_not_implemented_cemi(frame):
     """Test for having not implemented CEMI set"""
     with raises(UnsupportedCEMIMessage, match=r".*Could not handle CEMIMessageCode:.*"):
-        frame.from_knx(get_data(CEMIMessageCode.L_BUSMON_IND, 0, 0, 0, 0, 2, 0, []))
+        frame.from_knx(
+            get_data(CEMIMessageCode.L_BUSMON_IND.value, 0, 0, 0, 0, 2, 0, [])
+        )
 
 
 def test_invalid_telegram(frame):
diff --git a/test/knxip_tests/routing_indication_test.py b/test/knxip_tests/routing_indication_test.py
index e4631de1..e9939b58 100644
--- a/test/knxip_tests/routing_indication_test.py
+++ b/test/knxip_tests/routing_indication_test.py
@@ -25,26 +25,7 @@ class Test_KNXIP(unittest.TestCase):
 
     def test_from_knx(self):
         """Test parsing and streaming CEMIFrame KNX/IP packet (payload=0xf0)."""
-        raw = (
-            0x06,
-            0x10,
-            0x05,
-            0x30,
-            0x00,
-            0x12,
-            0x29,
-            0x00,
-            0xBC,
-            0xD0,
-            0x12,
-            0x02,
-            0x01,
-            0x51,
-            0x02,
-            0x00,
-            0x40,
-            0xF0,
-        )
+        raw = bytes.fromhex("0610053000122900BCD012020151020040F0")
         xknx = XKNX()
         knxipframe = KNXIPFrame(xknx)
         knxipframe.from_knx(raw)
@@ -60,26 +41,7 @@ class Test_KNXIP(unittest.TestCase):
 
     def test_from_knx_to_knx(self):
         """Test parsing and streaming CEMIFrame KNX/IP."""
-        raw = (
-            0x06,
-            0x10,
-            0x05,
-            0x30,
-            0x00,
-            0x12,
-            0x29,
-            0x00,
-            0xBC,
-            0xD0,
-            0x12,
-            0x02,
-            0x01,
-            0x51,
-            0x02,
-            0x00,
-            0x40,
-            0xF0,
-        )
+        raw = bytes.fromhex("0610053000122900BCD012020151020040F0")
         xknx = XKNX()
         knxipframe = KNXIPFrame(xknx)
         knxipframe.from_knx(raw)
@@ -107,28 +69,7 @@ class Test_KNXIP(unittest.TestCase):
         knxipframe.body.cemi.set_hops(5)
         knxipframe.normalize()
 
-        raw = (
-            0x06,
-            0x10,
-            0x05,
-            0x30,
-            0x00,
-            0x14,
-            0x29,
-            0x00,
-            0xBC,
-            0xD0,
-            0x12,
-            0x02,
-            0x01,
-            0x51,
-            0x04,
-            0x00,
-            0x80,
-            13,
-            23,
-            42,
-        )
+        raw = bytes.fromhex("0610053000142900BCD012020151040080 0d 17 2a")
 
         self.assertEqual(knxipframe.header.to_knx(), list(raw[0:6]))
         self.assertEqual(knxipframe.body.to_knx(), list(raw[6:]))
@@ -136,26 +77,7 @@ class Test_KNXIP(unittest.TestCase):
 
     def test_telegram_get(self):
         """Test parsing and streaming CEMIFrame KNX/IP packet, group read."""
-        raw = (
-            0x06,
-            0x10,
-            0x05,
-            0x30,
-            0x00,
-            0x12,
-            0x29,
-            0x00,
-            0xBC,
-            0xD0,
-            0x12,
-            0x02,
-            0x01,
-            0x51,
-            0x02,
-            0x00,
-            0x40,
-            0xF0,
-        )
+        raw = bytes.fromhex("0610053000122900BCD012020151020040F0")
         xknx = XKNX()
         knxipframe = KNXIPFrame(xknx)
         knxipframe.from_knx(raw)
@@ -178,25 +100,7 @@ class Test_KNXIP(unittest.TestCase):
     def test_EndTOEnd_group_write_binary_on(self):
         """Test parsing and streaming CEMIFrame KNX/IP packet, switch on light in my kitchen."""
         # Switch on Kitchen-L1
-        raw = (
-            0x06,
-            0x10,
-            0x05,
-            0x30,
-            0x00,
-            0x11,
-            0x29,
-            0x00,
-            0xBC,
-            0xD0,
-            0xFF,
-            0xF9,
-            0x01,
-            0x49,
-            0x01,
-            0x00,
-            0x81,
-        )
+        raw = bytes.fromhex("0610053000112900BCD0FFF90149010081")
         xknx = XKNX()
         knxipframe = KNXIPFrame(xknx)
         knxipframe.from_knx(raw)
@@ -217,25 +121,7 @@ class Test_KNXIP(unittest.TestCase):
     def test_EndTOEnd_group_write_binary_off(self):
         """Test parsing and streaming CEMIFrame KNX/IP packet, switch off light in my kitchen."""
         # Switch off Kitchen-L1
-        raw = (
-            0x06,
-            0x10,
-            0x05,
-            0x30,
-            0x00,
-            0x11,
-            0x29,
-            0x00,
-            0xBC,
-            0xD0,
-            0xFF,
-            0xF9,
-            0x01,
-            0x49,
-            0x01,
-            0x00,
-            0x80,
-        )
+        raw = bytes.fromhex("0610053000112900BCD0FFF90149010080")
         xknx = XKNX()
         knxipframe = KNXIPFrame(xknx)
         knxipframe.from_knx(raw)
@@ -256,26 +142,7 @@ class Test_KNXIP(unittest.TestCase):
     def test_EndTOEnd_group_write_1byte(self):
         """Test parsing and streaming CEMIFrame KNX/IP packet, dimm light in my kitchen."""
         # Dimm Kitchen L1 to 0x65
-        raw = (
-            0x06,
-            0x10,
-            0x05,
-            0x30,
-            0x00,
-            0x12,
-            0x29,
-            0x00,
-            0xBC,
-            0xD0,
-            0xFF,
-            0xF9,
-            0x01,
-            0x4B,
-            0x02,
-            0x00,
-            0x80,
-            0x65,
-        )
+        raw = bytes.fromhex("0610053000122900BCD0FFF9014B02008065")
         xknx = XKNX()
         knxipframe = KNXIPFrame(xknx)
         knxipframe.from_knx(raw)
@@ -298,27 +165,7 @@ class Test_KNXIP(unittest.TestCase):
     def test_EndTOEnd_group_write_2bytes(self):
         """Test parsing and streaming CEMIFrame KNX/IP packet, setting value of thermostat."""
         # Incoming Temperature from thermostat
-        raw = (
-            0x06,
-            0x10,
-            0x05,
-            0x30,
-            0x00,
-            0x13,
-            0x29,
-            0x00,
-            0xBC,
-            0xD0,
-            0x14,
-            0x02,
-            0x08,
-            0x01,
-            0x03,
-            0x00,
-            0x80,
-            0x07,
-            0xC1,
-        )
+        raw = bytes.fromhex("0610053000132900BCD01402080103008007C1")
         xknx = XKNX()
         knxipframe = KNXIPFrame(xknx)
         knxipframe.from_knx(raw)
@@ -344,25 +191,7 @@ class Test_KNXIP(unittest.TestCase):
     def test_EndTOEnd_group_read(self):
         """Test parsing and streaming CEMIFrame KNX/IP packet, group read."""
         # State request
-        raw = (
-            0x06,
-            0x10,
-            0x05,
-            0x30,
-            0x00,
-            0x11,
-            0x29,
-            0x00,
-            0xBC,
-            0xD0,
-            0xFF,
-            0xF9,
-            0x01,
-            0xB8,
-            0x01,
-            0x00,
-            0x00,
-        )
+        raw = bytes.fromhex("0610053000112900BCD0FFF901B8010000")
         xknx = XKNX()
         knxipframe = KNXIPFrame(xknx)
         knxipframe.from_knx(raw)
@@ -385,25 +214,7 @@ class Test_KNXIP(unittest.TestCase):
     def test_EndTOEnd_group_response(self):
         """Test parsing and streaming CEMIFrame KNX/IP packet, group response."""
         # Incoming state
-        raw = (
-            0x06,
-            0x10,
-            0x05,
-            0x30,
-            0x00,
-            0x11,
-            0x29,
-            0x00,
-            0xBC,
-            0xD0,
-            0x13,
-            0x01,
-            0x01,
-            0x88,
-            0x01,
-            0x00,
-            0x41,
-        )
+        raw = bytes.fromhex("0610053000112900BCD013010188010041")
         xknx = XKNX()
         knxipframe = KNXIPFrame(xknx)
         knxipframe.from_knx(raw)
@@ -439,25 +250,8 @@ class Test_KNXIP(unittest.TestCase):
         knxipframe.body.cemi.set_hops(5)
         knxipframe.normalize()
 
-        raw = (
-            0x06,
-            0x10,
-            0x05,
-            0x30,
-            0x00,
-            0x11,
-            0x29,
-            0x00,
-            0xBC,
-            0xD0,
-            0x13,
-            0x01,
-            0x01,
-            0x51,
-            0x01,
-            0x00,
-            0xBF,
-        )
+        raw = bytes.fromhex("0610053000112900BCD0130101510100BF")
+
         self.assertEqual(knxipframe.to_knx(), list(raw))
 
         knxipframe2 = KNXIPFrame(xknx)
@@ -469,5 +263,5 @@ class Test_KNXIP(unittest.TestCase):
         """Test parsing and streaming CEMIFrame KNX/IP packet with unsupported CEMICode."""
         xknx = XKNX()
         ri = RoutingIndication(xknx)
-
-        self.assertEqual(11, ri.from_knx([43, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0]))
+        raw = bytes([43, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0])
+        self.assertEqual(11, ri.from_knx(raw))
diff --git a/test/str_test.py b/test/str_test.py
index a1b3260b..09b4a7ff 100644
--- a/test/str_test.py
+++ b/test/str_test.py
@@ -510,7 +510,7 @@ class TestStringRepresentations(unittest.TestCase):
             "<DIBDeviceInformation \n"
             '\tknx_medium="KNXMedium.TP1" \n'
             '\tprogramming_mode="False" \n'
-            '\tindividual_address="PhysicalAddress("1.1.0")" \n'
+            '\tindividual_address="1.1.0" \n'
             '\tinstallation_number="2" \n'
             '\tproject_number="564" \n'
             '\tserial_number="13:37:13:37:13:37" \n'