Repository: XKNX/xknx
Pull request #818 (2021-12-12 22:13:57)

Add source IP information to logs
<!--
  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.
-->
- Add source IP information to logs
- simplify HPAI string representation
- add `ValueError` exception to interface detection in GatewayScanner for edge cases 

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)
- [ ] 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:

- [ ] 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


codecov[bot]: # [Codecov](https://codecov.io/gh/XKNX/xknx/pull/818?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
> Merging [#818](https://codecov.io/gh/XKNX/xknx/pull/818?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (34e2b44) into [main](https://codecov.io/gh/XKNX/xknx/commit/a38a5d0693f278c6e6c25126b2c7255424fbff67?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (a38a5d0) will **decrease** coverage by `0.03%`.
> The diff coverage is `81.48%`.

[![Impacted file tree graph](https://codecov.io/gh/XKNX/xknx/pull/818/graphs/tree.svg?width=650&height=150&src=pr&token=irWbIygS84&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)](https://codecov.io/gh/XKNX/xknx/pull/818?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)

```diff
@@            Coverage Diff             @@
##             main     #818      +/-   ##
==========================================
- Coverage   94.77%   94.73%   -0.04%     
==========================================
  Files         109      109              
  Lines        6831     6842      +11     
==========================================
+ Hits         6474     6482       +8     
- Misses        357      360       +3     
```


| [Impacted Files](https://codecov.io/gh/XKNX/xknx/pull/818?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/io/gateway\_scanner.py](https://codecov.io/gh/XKNX/xknx/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9pby9nYXRld2F5X3NjYW5uZXIucHk=) | `80.37% <60.00%> (-1.81%)` | :arrow_down: |
| [xknx/io/udp\_client.py](https://codecov.io/gh/XKNX/xknx/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9pby91ZHBfY2xpZW50LnB5) | `54.95% <85.71%> (ø)` | |
| [xknx/io/request\_response/request\_response.py](https://codecov.io/gh/XKNX/xknx/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9pby9yZXF1ZXN0X3Jlc3BvbnNlL3JlcXVlc3RfcmVzcG9uc2UucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/io/routing.py](https://codecov.io/gh/XKNX/xknx/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9pby9yb3V0aW5nLnB5) | `34.04% <100.00%> (ø)` | |
| [xknx/io/tunnel.py](https://codecov.io/gh/XKNX/xknx/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9pby90dW5uZWwucHk=) | `38.23% <100.00%> (ø)` | |
| [xknx/knxip/hpai.py](https://codecov.io/gh/XKNX/xknx/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC9ocGFpLnB5) | `100.00% <100.00%> (ø)` | |


Patch:
diff --git a/changelog.md b/changelog.md
index 575b7045..0ae771a3 100644
--- a/changelog.md
+++ b/changelog.md
@@ -5,6 +5,8 @@
 ### Internals
 
 - Tunnel: Implement flow control according to KNX spec recommendations: wait for L_DATA.con frame before sending next L_DATA.req with 3 second timeout
+- Logging: Some changes to loggers like `knx` now includes the source/destination HPAI and a timestamp
+- Fix a rare race-condition in the gateway scanner where a non-existing interface was queried
 
 ## 0.18.13 Hold your colour 2021-11-13
 
diff --git a/xknx/io/gateway_scanner.py b/xknx/io/gateway_scanner.py
index 55ca6ada..0b7c5a3c 100644
--- a/xknx/io/gateway_scanner.py
+++ b/xknx/io/gateway_scanner.py
@@ -43,8 +43,8 @@ class GatewayDescriptor:
         port: int,
         local_interface: str,
         local_ip: str,
-        supports_tunnelling: bool = False,
         supports_routing: bool = False,
+        supports_tunnelling: bool = False,
         individual_address: IndividualAddress | None = None,
     ):
         """Initialize GatewayDescriptor class."""
@@ -57,6 +57,21 @@ class GatewayDescriptor:
         self.supports_tunnelling = supports_tunnelling
         self.individual_address = individual_address
 
+    def __repr__(self) -> str:
+        """Return object as representation string."""
+        return (
+            "GatewayDescriptor(\n"
+            f"    name={self.name},\n"
+            f"    ip_addr={self.ip_addr},\n"
+            f"    port={self.port},\n"
+            f"    local_interface={self.local_interface},\n"
+            f"    local_ip={self.local_ip},\n"
+            f"    supports_routing={self.supports_routing},\n"
+            f"    supports_tunnelling={self.supports_tunnelling},\n"
+            f"    individual_address={self.individual_address}\n"
+            ")"
+        )
+
     def __str__(self) -> str:
         """Return object as readable string."""
         return f"{self.individual_address} - {self.name} @ {self.ip_addr}:{self.port}"
@@ -145,10 +160,15 @@ class GatewayScanner:
             try:
                 af_inet = netifaces.ifaddresses(interface)[netifaces.AF_INET]
                 ip_addr = af_inet[0]["addr"]
-                await self._search_interface(interface, ip_addr)
             except KeyError:
-                logger.info("Could not connect to an KNX/IP device on %s", interface)
+                logger.debug("No IPv4 address found on %s", interface)
+                continue
+            except ValueError as err:
+                # rare case when an interface disappears during search initialisation
+                logger.debug("Invalid interface %s: %s", interface, err)
                 continue
+            else:
+                await self._search_interface(interface, ip_addr)
 
     async def _search_interface(self, interface: str, ip_addr: str) -> None:
         """Send a search request on a specific interface."""
@@ -169,8 +189,8 @@ class GatewayScanner:
 
         self._udp_clients.append(udp_client)
 
-        (local_addr, local_port) = udp_client.getsockname()
-        discovery_endpoint = HPAI(ip_addr=local_addr, port=local_port)
+        (_, local_port) = udp_client.getsockname()
+        discovery_endpoint = HPAI(ip_addr=ip_addr, port=local_port)
 
         search_request = SearchRequest(self.xknx, discovery_endpoint=discovery_endpoint)
         udp_client.send(KNXIPFrame.init_from_body(search_request))
@@ -178,6 +198,7 @@ class GatewayScanner:
     def _response_rec_callback(
         self,
         knx_ip_frame: KNXIPFrame,
+        source: HPAI,
         udp_client: UDPClient,
         interface: str = "",
     ) -> None:
@@ -214,6 +235,7 @@ class GatewayScanner:
         except StopIteration:
             pass
 
+        logger.debug("Found KNX/IP device at %s: %s", source, repr(gateway))
         self._add_found_gateway(gateway)
 
     def _add_found_gateway(self, gateway: GatewayDescriptor) -> None:
diff --git a/xknx/io/request_response/request_response.py b/xknx/io/request_response/request_response.py
index d5a1e413..325c4e05 100644
--- a/xknx/io/request_response/request_response.py
+++ b/xknx/io/request_response/request_response.py
@@ -10,7 +10,7 @@ import logging
 from typing import TYPE_CHECKING
 
 from xknx.io.udp_client import UDPClient
-from xknx.knxip import ErrorCode, KNXIPBodyResponse, KNXIPFrame
+from xknx.knxip import HPAI, ErrorCode, KNXIPBodyResponse, KNXIPFrame
 
 if TYPE_CHECKING:
     from xknx.xknx import XKNX
@@ -68,7 +68,9 @@ class RequestResponse:
         """Build knxipframe (within derived class) and send via UDP."""
         self.udpclient.send(self.create_knxipframe())
 
-    def response_rec_callback(self, knxipframe: KNXIPFrame, _: UDPClient) -> None:
+    def response_rec_callback(
+        self, knxipframe: KNXIPFrame, source: HPAI, _: UDPClient
+    ) -> None:
         """Verify and handle knxipframe. Callback from internal udpclient."""
         if not isinstance(knxipframe.body, self.awaited_response_class):
             logger.warning("Could not understand knxipframe")
diff --git a/xknx/io/routing.py b/xknx/io/routing.py
index 85c1bf60..4791a3e8 100644
--- a/xknx/io/routing.py
+++ b/xknx/io/routing.py
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Callable
 
 from xknx.core import XknxConnectionState
 from xknx.knxip import (
+    HPAI,
     CEMIFrame,
     CEMIMessageCode,
     KNXIPFrame,
@@ -55,7 +56,9 @@ class Routing(Interface):
             self.response_rec_callback, [KNXIPServiceType.ROUTING_INDICATION]
         )
 
-    def response_rec_callback(self, knxipframe: KNXIPFrame, _: UDPClient) -> None:
+    def response_rec_callback(
+        self, knxipframe: KNXIPFrame, source: HPAI, _: UDPClient
+    ) -> None:
         """Verify and handle knxipframe. Callback from internal udpclient."""
         if not isinstance(knxipframe.body, RoutingIndication):
             logger.warning("Service type not implemented: %s", knxipframe)
diff --git a/xknx/io/tunnel.py b/xknx/io/tunnel.py
index bdc55c1a..65752e0a 100644
--- a/xknx/io/tunnel.py
+++ b/xknx/io/tunnel.py
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Callable
 
 from xknx.exceptions import CommunicationError, XKNXException
 from xknx.knxip import (
+    HPAI,
     CEMIMessageCode,
     DisconnectRequest,
     DisconnectResponse,
@@ -306,7 +307,9 @@ class Tunnel(Interface):
     #
     ####################
 
-    def _request_received(self, knxipframe: KNXIPFrame, _udp_client: UDPClient) -> None:
+    def _request_received(
+        self, knxipframe: KNXIPFrame, source: HPAI, _udp_client: UDPClient
+    ) -> None:
         """Handle incoming requests."""
         if isinstance(knxipframe.body, TunnellingRequest):
             self._tunnelling_request_received(knxipframe.body)
diff --git a/xknx/io/udp_client.py b/xknx/io/udp_client.py
index b84a887e..2637a73f 100644
--- a/xknx/io/udp_client.py
+++ b/xknx/io/udp_client.py
@@ -10,15 +10,16 @@ import asyncio
 import logging
 import socket
 from sys import platform
+import time
 from typing import TYPE_CHECKING, Callable, Tuple, cast
 
 from xknx.exceptions import CommunicationError, CouldNotParseKNXIP, XKNXException
-from xknx.knxip import KNXIPFrame, KNXIPServiceType
+from xknx.knxip import HPAI, KNXIPFrame, KNXIPServiceType
 
 if TYPE_CHECKING:
     from xknx.xknx import XKNX
 
-    CallbackType = Callable[[KNXIPFrame, "UDPClient"], None]
+    CallbackType = Callable[[KNXIPFrame, HPAI, "UDPClient"], None]
 
 raw_socket_logger = logging.getLogger("xknx.raw_socket")
 logger = logging.getLogger("xknx.log")
@@ -51,7 +52,8 @@ class UDPClient:
             self,
             own_ip: str,
             multicast: bool = False,
-            data_received_callback: Callable[[bytes], None] | None = None,
+            data_received_callback: Callable[[bytes, tuple[str, int]], None]
+            | None = None,
         ):
             """Initialize UDPClientFactory class."""
             self.own_ip = own_ip
@@ -67,7 +69,7 @@ class UDPClient:
             """Call assigned callback. Callback for datagram received."""
             raw_socket_logger.debug("Received from %s: %s", addr, data.hex())
             if self.data_received_callback is not None:
-                self.data_received_callback(data)
+                self.data_received_callback(data, addr)
 
         def error_received(self, exc: Exception) -> None:
             """Call when a send or receive operation raises an OSError."""
@@ -97,7 +99,7 @@ class UDPClient:
 
         self.transport: asyncio.DatagramTransport | None = None
 
-    def data_received_callback(self, raw: bytes) -> None:
+    def data_received_callback(self, raw: bytes, source: tuple[str, int]) -> None:
         """Parse and process KNXIP frame. Callback for having received an UDP packet."""
         if raw:
             try:
@@ -105,24 +107,36 @@ class UDPClient:
                 knxipframe.from_knx(raw)
             except CouldNotParseKNXIP as couldnotparseknxip:
                 knx_logger.debug(
-                    "Unsupported KNXIPFrame: %s in %s",
+                    "Unsupported KNXIPFrame from %s:%s at %s: %s in %s",
+                    source[0],
+                    source[1],
+                    time.time(),
                     couldnotparseknxip.description,
                     raw.hex(),
                 )
             else:
-                knx_logger.debug("Received: %s", knxipframe)
-                self.handle_knxipframe(knxipframe)
+                knx_logger.debug(
+                    "Received from %s:%s at %s:\n%s",
+                    source[0],
+                    source[1],
+                    time.time(),
+                    knxipframe,
+                )
+                self.handle_knxipframe(knxipframe, HPAI(*source))
 
-    def handle_knxipframe(self, knxipframe: KNXIPFrame) -> None:
-        """Handle KNXIP Frame and call all callbacks which watch for the service type ident."""
+    def handle_knxipframe(self, knxipframe: KNXIPFrame, source: HPAI) -> None:
+        """Handle KNXIP Frame and call all callbacks matching the service type ident."""
         handled = False
         for callback in self.callbacks:
             if callback.has_service(knxipframe.header.service_type_ident):
-                callback.callback(knxipframe, self)
+                callback.callback(knxipframe, source, self)
                 handled = True
         if not handled:
             knx_logger.debug(
-                "Unhandled %s: %s", knxipframe.header.service_type_ident, knxipframe
+                "Unhandled %s: %s from: %s",
+                knxipframe.header.service_type_ident,
+                knxipframe,
+                source,
             )
 
     def register_callback(
@@ -206,7 +220,13 @@ class UDPClient:
 
     def send(self, knxipframe: KNXIPFrame) -> None:
         """Send KNXIPFrame to socket."""
-        knx_logger.debug("Sending: %s", knxipframe)
+        knx_logger.debug(
+            "Sending to %s:%s at %s:\n%s",
+            self.remote_addr[0],
+            self.remote_addr[1],
+            time.time(),
+            knxipframe,
+        )
         if self.transport is None:
             raise XKNXException("Transport not connected")
 
diff --git a/xknx/knxip/hpai.py b/xknx/knxip/hpai.py
index f29325da..e9da2de8 100644
--- a/xknx/knxip/hpai.py
+++ b/xknx/knxip/hpai.py
@@ -51,9 +51,13 @@ class HPAI:
         data.append(self.port & 255)
         return data
 
+    def __repr__(self) -> str:
+        """Representation of object."""
+        return f"HPAI('{self.ip_addr}', {self.port})"
+
     def __str__(self) -> str:
         """Return object as readable string."""
-        return f"<HPAI {self.ip_addr}:{self.port} />"
+        return f"{self.ip_addr}:{self.port}"
 
     def __eq__(self, other: object) -> bool:
         """Equal operator."""


Test patch:
diff --git a/test/io_tests/connect_test.py b/test/io_tests/connect_test.py
index c7f71cee..b10b5a13 100644
--- a/test/io_tests/connect_test.py
+++ b/test/io_tests/connect_test.py
@@ -50,7 +50,7 @@ class TestConnect:
         wrong_knxipframe = KNXIPFrame(xknx)
         wrong_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_REQUEST)
         with patch("logging.Logger.warning") as mock_warning:
-            connect.response_rec_callback(wrong_knxipframe, None)
+            connect.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
 
         # Response KNX/IP-Frame with error:
@@ -58,7 +58,7 @@ class TestConnect:
         err_knxipframe.init(KNXIPServiceType.CONNECT_RESPONSE)
         err_knxipframe.body.status_code = ErrorCode.E_CONNECTION_ID
         with patch("logging.Logger.debug") as mock_warning:
-            connect.response_rec_callback(err_knxipframe, None)
+            connect.response_rec_callback(err_knxipframe, HPAI(), None)
             mock_warning.assert_called_with(
                 "Error: KNX bus responded to request of type '%s' with error in '%s': %s",
                 type(connect).__name__,
@@ -71,7 +71,7 @@ class TestConnect:
         res_knxipframe.init(KNXIPServiceType.CONNECT_RESPONSE)
         res_knxipframe.body.communication_channel = 23
         res_knxipframe.body.identifier = 7
-        connect.response_rec_callback(res_knxipframe, None)
+        connect.response_rec_callback(res_knxipframe, HPAI(), None)
         assert connect.success
         assert connect.communication_channel == 23
         assert connect.identifier == 7
@@ -100,7 +100,7 @@ class TestConnect:
         wrong_knxipframe = KNXIPFrame(xknx)
         wrong_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_REQUEST)
         with patch("logging.Logger.warning") as mock_warning:
-            connect.response_rec_callback(wrong_knxipframe, None)
+            connect.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
 
         # Response KNX/IP-Frame with error:
@@ -108,7 +108,7 @@ class TestConnect:
         err_knxipframe.init(KNXIPServiceType.CONNECT_RESPONSE)
         err_knxipframe.body.status_code = ErrorCode.E_CONNECTION_ID
         with patch("logging.Logger.debug") as mock_warning:
-            connect.response_rec_callback(err_knxipframe, None)
+            connect.response_rec_callback(err_knxipframe, HPAI(), None)
             mock_warning.assert_called_with(
                 "Error: KNX bus responded to request of type '%s' with error in '%s': %s",
                 type(connect).__name__,
@@ -121,7 +121,7 @@ class TestConnect:
         res_knxipframe.init(KNXIPServiceType.CONNECT_RESPONSE)
         res_knxipframe.body.communication_channel = 23
         res_knxipframe.body.identifier = 7
-        connect.response_rec_callback(res_knxipframe, None)
+        connect.response_rec_callback(res_knxipframe, HPAI(), None)
         assert connect.success
         assert connect.communication_channel == 23
         assert connect.identifier == 7
diff --git a/test/io_tests/connectionstate_test.py b/test/io_tests/connectionstate_test.py
index ab1332e2..70c2ece5 100644
--- a/test/io_tests/connectionstate_test.py
+++ b/test/io_tests/connectionstate_test.py
@@ -51,7 +51,7 @@ class TestConnectionState:
         wrong_knxipframe = KNXIPFrame(xknx)
         wrong_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_REQUEST)
         with patch("logging.Logger.warning") as mock_warning:
-            connectionstate.response_rec_callback(wrong_knxipframe, None)
+            connectionstate.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
 
         # Response KNX/IP-Frame with error:
@@ -59,7 +59,7 @@ class TestConnectionState:
         err_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_RESPONSE)
         err_knxipframe.body.status_code = ErrorCode.E_CONNECTION_ID
         with patch("logging.Logger.debug") as mock_warning:
-            connectionstate.response_rec_callback(err_knxipframe, None)
+            connectionstate.response_rec_callback(err_knxipframe, HPAI(), None)
             mock_warning.assert_called_with(
                 "Error: KNX bus responded to request of type '%s' with error in '%s': %s",
                 type(connectionstate).__name__,
@@ -70,7 +70,7 @@ class TestConnectionState:
         # Correct Response KNX/IP-Frame:
         res_knxipframe = KNXIPFrame(xknx)
         res_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_RESPONSE)
-        connectionstate.response_rec_callback(res_knxipframe, None)
+        connectionstate.response_rec_callback(res_knxipframe, HPAI(), None)
         assert connectionstate.success
 
     async def test_connectionstate_route_back_true(self):
@@ -104,7 +104,7 @@ class TestConnectionState:
         wrong_knxipframe = KNXIPFrame(xknx)
         wrong_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_REQUEST)
         with patch("logging.Logger.warning") as mock_warning:
-            connectionstate.response_rec_callback(wrong_knxipframe, None)
+            connectionstate.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
 
         # Response KNX/IP-Frame with error:
@@ -112,7 +112,7 @@ class TestConnectionState:
         err_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_RESPONSE)
         err_knxipframe.body.status_code = ErrorCode.E_CONNECTION_ID
         with patch("logging.Logger.debug") as mock_warning:
-            connectionstate.response_rec_callback(err_knxipframe, None)
+            connectionstate.response_rec_callback(err_knxipframe, HPAI(), None)
             mock_warning.assert_called_with(
                 "Error: KNX bus responded to request of type '%s' with error in '%s': %s",
                 type(connectionstate).__name__,
@@ -123,5 +123,5 @@ class TestConnectionState:
         # Correct Response KNX/IP-Frame:
         res_knxipframe = KNXIPFrame(xknx)
         res_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_RESPONSE)
-        connectionstate.response_rec_callback(res_knxipframe, None)
+        connectionstate.response_rec_callback(res_knxipframe, HPAI(), None)
         assert connectionstate.success
diff --git a/test/io_tests/disconnect_test.py b/test/io_tests/disconnect_test.py
index 3df7786d..ea34001f 100644
--- a/test/io_tests/disconnect_test.py
+++ b/test/io_tests/disconnect_test.py
@@ -51,7 +51,7 @@ class TestDisconnect:
         wrong_knxipframe = KNXIPFrame(xknx)
         wrong_knxipframe.init(KNXIPServiceType.DISCONNECT_REQUEST)
         with patch("logging.Logger.warning") as mock_warning:
-            disconnect.response_rec_callback(wrong_knxipframe, None)
+            disconnect.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
 
         # Response KNX/IP-Frame with error:
@@ -59,7 +59,7 @@ class TestDisconnect:
         err_knxipframe.init(KNXIPServiceType.DISCONNECT_RESPONSE)
         err_knxipframe.body.status_code = ErrorCode.E_CONNECTION_ID
         with patch("logging.Logger.debug") as mock_warning:
-            disconnect.response_rec_callback(err_knxipframe, None)
+            disconnect.response_rec_callback(err_knxipframe, HPAI(), None)
             mock_warning.assert_called_with(
                 "Error: KNX bus responded to request of type '%s' with error in '%s': %s",
                 type(disconnect).__name__,
@@ -70,7 +70,7 @@ class TestDisconnect:
         # Correct Response KNX/IP-Frame:
         res_knxipframe = KNXIPFrame(xknx)
         res_knxipframe.init(KNXIPServiceType.DISCONNECT_RESPONSE)
-        disconnect.response_rec_callback(res_knxipframe, None)
+        disconnect.response_rec_callback(res_knxipframe, HPAI(), None)
         assert disconnect.success
 
     async def test_disconnect_route_back_true(self):
@@ -104,7 +104,7 @@ class TestDisconnect:
         wrong_knxipframe = KNXIPFrame(xknx)
         wrong_knxipframe.init(KNXIPServiceType.DISCONNECT_REQUEST)
         with patch("logging.Logger.warning") as mock_warning:
-            disconnect.response_rec_callback(wrong_knxipframe, None)
+            disconnect.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
 
         # Response KNX/IP-Frame with error:
@@ -112,7 +112,7 @@ class TestDisconnect:
         err_knxipframe.init(KNXIPServiceType.DISCONNECT_RESPONSE)
         err_knxipframe.body.status_code = ErrorCode.E_CONNECTION_ID
         with patch("logging.Logger.debug") as mock_warning:
-            disconnect.response_rec_callback(err_knxipframe, None)
+            disconnect.response_rec_callback(err_knxipframe, HPAI(), None)
             mock_warning.assert_called_with(
                 "Error: KNX bus responded to request of type '%s' with error in '%s': %s",
                 type(disconnect).__name__,
@@ -123,5 +123,5 @@ class TestDisconnect:
         # Correct Response KNX/IP-Frame:
         res_knxipframe = KNXIPFrame(xknx)
         res_knxipframe.init(KNXIPServiceType.DISCONNECT_RESPONSE)
-        disconnect.response_rec_callback(res_knxipframe, None)
+        disconnect.response_rec_callback(res_knxipframe, HPAI(), None)
         assert disconnect.success
diff --git a/test/io_tests/gateway_scanner_test.py b/test/io_tests/gateway_scanner_test.py
index ceb02fc0..6f6df0cf 100644
--- a/test/io_tests/gateway_scanner_test.py
+++ b/test/io_tests/gateway_scanner_test.py
@@ -154,6 +154,7 @@ class TestGatewayScanner:
         assert not gateway_scanner.found_gateways
         gateway_scanner._response_rec_callback(
             test_search_response,
+            HPAI("192.168.42.50", 0),
             udp_client_mock,
             interface="en1",
         )
@@ -163,6 +164,7 @@ class TestGatewayScanner:
 
         gateway_scanner._response_rec_callback(
             test_search_response,
+            HPAI("192.168.42.230", 0),
             udp_client_mock,
             interface="eth1",
         )
diff --git a/test/io_tests/tunnel_test.py b/test/io_tests/tunnel_test.py
index bce28aeb..3615b9cd 100644
--- a/test/io_tests/tunnel_test.py
+++ b/test/io_tests/tunnel_test.py
@@ -6,7 +6,7 @@ import pytest
 from xknx import XKNX
 from xknx.dpt import DPTArray
 from xknx.io import Tunnel
-from xknx.knxip import CEMIFrame, KNXIPFrame, TunnellingAck, TunnellingRequest
+from xknx.knxip import HPAI, CEMIFrame, KNXIPFrame, TunnellingAck, TunnellingRequest
 from xknx.knxip.knxip_enum import CEMIMessageCode
 from xknx.telegram import Telegram, TelegramDirection
 from xknx.telegram.apci import GroupValueWrite
@@ -44,7 +44,7 @@ class TestTunnel:
         telegram = _cemi.telegram
         telegram.direction = TelegramDirection.INCOMING
 
-        self.tunnel.udp_client.data_received_callback(raw)
+        self.tunnel.udp_client.data_received_callback(raw, ("192.168.1.2", 3671))
         self.tg_received_mock.assert_called_once_with(telegram)
         send_ack_mock.assert_called_once_with(0x02, 0x21)
 
@@ -56,7 +56,7 @@ class TestTunnel:
         # 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.tunnel.udp_client.data_received_callback(raw, ("192.168.1.2", 3671))
         self.tg_received_mock.assert_not_called()
         send_ack_mock.assert_called_once_with(0x02, 0x81)
 
@@ -68,7 +68,7 @@ class TestTunnel:
         # communication_channel_id: 0x02   sequence_counter: 0x4f
         raw = bytes.fromhex("0610 0420 0015 04 02 4f 00 2900b0d0000100000103f8")
 
-        self.tunnel.udp_client.data_received_callback(raw)
+        self.tunnel.udp_client.data_received_callback(raw, ("192.168.1.2", 3671))
         self.tg_received_mock.assert_not_called()
         send_ack_mock.assert_called_once_with(0x02, 0x4F)
 
@@ -93,11 +93,11 @@ class TestTunnel:
         )
         task = asyncio.create_task(self.tunnel.send_telegram(test_telegram))
         await time_travel(0)
-        self.tunnel.udp_client.handle_knxipframe(test_ack)
+        self.tunnel.udp_client.handle_knxipframe(test_ack, HPAI())
         await time_travel(0)
         assert not task.done()
         assert self.tunnel.udp_client.send.call_count == 1
-        self.tunnel.udp_client.handle_knxipframe(confirmation)
+        self.tunnel.udp_client.handle_knxipframe(confirmation, HPAI())
         await time_travel(0)
         assert task.done()
         # one call for the outgoing request and one for the ACK for the confirmation
diff --git a/test/io_tests/tunnelling_test.py b/test/io_tests/tunnelling_test.py
index 3e2a6a77..bfb4142c 100644
--- a/test/io_tests/tunnelling_test.py
+++ b/test/io_tests/tunnelling_test.py
@@ -7,6 +7,7 @@ from xknx.dpt import DPTArray
 from xknx.io import UDPClient
 from xknx.io.request_response import Tunnelling
 from xknx.knxip import (
+    HPAI,
     ErrorCode,
     KNXIPFrame,
     KNXIPServiceType,
@@ -65,7 +66,7 @@ class TestTunnelling:
         wrong_knxipframe = KNXIPFrame(xknx)
         wrong_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_REQUEST)
         with patch("logging.Logger.warning") as mock_warning:
-            tunnelling.response_rec_callback(wrong_knxipframe, None)
+            tunnelling.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
 
         # Response KNX/IP-Frame with error:
@@ -73,7 +74,7 @@ class TestTunnelling:
         err_knxipframe.init(KNXIPServiceType.TUNNELLING_ACK)
         err_knxipframe.body.status_code = ErrorCode.E_CONNECTION_ID
         with patch("logging.Logger.debug") as mock_warning:
-            tunnelling.response_rec_callback(err_knxipframe, None)
+            tunnelling.response_rec_callback(err_knxipframe, HPAI(), None)
             mock_warning.assert_called_with(
                 "Error: KNX bus responded to request of type '%s' with error in '%s': %s",
                 type(tunnelling).__name__,
@@ -84,5 +85,5 @@ class TestTunnelling:
         # Correct Response KNX/IP-Frame:
         res_knxipframe = KNXIPFrame(xknx)
         res_knxipframe.init(KNXIPServiceType.TUNNELLING_ACK)
-        tunnelling.response_rec_callback(res_knxipframe, None)
+        tunnelling.response_rec_callback(res_knxipframe, HPAI(), None)
         assert tunnelling.success
diff --git a/test/str_test.py b/test/str_test.py
index f782fd3a..72c9ef56 100644
--- a/test/str_test.py
+++ b/test/str_test.py
@@ -494,7 +494,8 @@ class TestStringRepresentations:
     def test_hpai(self):
         """Test string representation of HPAI."""
         hpai = HPAI(ip_addr="192.168.42.1", port=33941)
-        assert str(hpai) == "<HPAI 192.168.42.1:33941 />"
+        assert str(hpai) == "192.168.42.1:33941"
+        assert repr(hpai) == "HPAI('192.168.42.1', 33941)"
 
     def test_header(self):
         """Test string representation of KNX/IP-Header."""
@@ -515,7 +516,7 @@ class TestStringRepresentations:
         connect_request.data_endpoint = HPAI(ip_addr="192.168.42.2", port=33942)
         assert (
             str(connect_request)
-            == '<ConnectRequest control_endpoint="<HPAI 192.168.42.1:33941 />" data_endpoint="<HPAI 192.168.42.2:33942 />" '
+            == '<ConnectRequest control_endpoint="192.168.42.1:33941" data_endpoint="192.168.42.2:33942" '
             'request_type="ConnectRequestType.TUNNEL_CONNECTION" />'
         )
 
@@ -530,7 +531,7 @@ class TestStringRepresentations:
         assert (
             str(connect_response)
             == '<ConnectResponse communication_channel="13" status_code="ErrorCode.E_NO_ERROR" '
-            'control_endpoint="<HPAI 192.168.42.1:33941 />" '
+            'control_endpoint="192.168.42.1:33941" '
             'request_type="ConnectRequestType.TUNNEL_CONNECTION" identifier="42" />'
         )
 
@@ -542,7 +543,7 @@ class TestStringRepresentations:
         disconnect_request.control_endpoint = HPAI(ip_addr="192.168.42.1", port=33941)
         assert (
             str(disconnect_request)
-            == '<DisconnectRequest CommunicationChannelID="13" control_endpoint="<HPAI 192.168.42.1:33941 />" />'
+            == '<DisconnectRequest CommunicationChannelID="13" control_endpoint="192.168.42.1:33941" />'
         )
 
     def test_disconnect_response(self):
@@ -565,7 +566,7 @@ class TestStringRepresentations:
         )
         assert (
             str(connectionstate_request)
-            == '<ConnectionStateRequest CommunicationChannelID="23", control_endpoint="<HPAI 192.168.42.1:33941 />" />'
+            == '<ConnectionStateRequest CommunicationChannelID="23", control_endpoint="192.168.42.1:33941" />'
         )
 
     def test_connectionstate_response(self):
@@ -584,7 +585,7 @@ class TestStringRepresentations:
         search_request = SearchRequest(xknx)
         assert (
             str(search_request)
-            == '<SearchRequest discovery_endpoint="<HPAI 224.0.23.12:3671 />" />'
+            == '<SearchRequest discovery_endpoint="224.0.23.12:3671" />'
         )
 
     def test_search_response(self):
@@ -596,7 +597,7 @@ class TestStringRepresentations:
         search_response.dibs.append(DIBGeneric())
         assert (
             str(search_response)
-            == '<SearchResponse control_endpoint="<HPAI 192.168.42.1:33941 />" dibs="[\n'
+            == '<SearchResponse control_endpoint="192.168.42.1:33941" dibs="[\n'
             '<DIB dtc="0" data="" />,\n'
             '<DIB dtc="0" data="" />\n'
             ']" />'
@@ -650,7 +651,7 @@ class TestStringRepresentations:
             str(knxipframe)
             == '<KNXIPFrame <KNXIPHeader HeaderLength="6" ProtocolVersion="16" KNXIPServiceType="SEARCH_REQUEST" '
             'Reserve="0" TotalLength="0" />\n'
-            ' body="<SearchRequest discovery_endpoint="<HPAI 224.0.23.12:3671 />" />" />'
+            ' body="<SearchRequest discovery_endpoint="224.0.23.12:3671" />" />'
         )
 
     #