Repository: XKNX/xknx
Pull request #927 (2022-04-21 20:20:59)

Don't handle KNX IP Secure devices when using automatic connection mode
<!--
  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.
-->
### Discovery

 - Send SearchRequest and SearchRequestExtended simultaneously when using GatewayScanner
 - Skip SearchResponse results for Core-V2 devices - wait for SearchResponseExtended
- Identify interfaces having KNX IP Secure Tunneling required and skip if using Automatic connection mode

 ### Internals

 - make HPAI hashable and add `addr_tuple` convenice property

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/927?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
> Merging [#927](https://codecov.io/gh/XKNX/xknx/pull/927?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (a402921) into [main](https://codecov.io/gh/XKNX/xknx/commit/8e6937bd98c5280998cc29f5eecc951f1608058d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (8e6937b) will **increase** coverage by `0.23%`.
> The diff coverage is `91.66%`.

[![Impacted file tree graph](https://codecov.io/gh/XKNX/xknx/pull/927/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/927?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)

```diff
@@            Coverage Diff             @@
##             main     #927      +/-   ##
==========================================
+ Coverage   95.55%   95.79%   +0.23%     
==========================================
  Files         130      130              
  Lines        7810     7745      -65     
==========================================
- Hits         7463     7419      -44     
+ Misses        347      326      -21     
```


| [Impacted Files](https://codecov.io/gh/XKNX/xknx/pull/927?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/927/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9pby9nYXRld2F5X3NjYW5uZXIucHk=) | `96.07% <91.66%> (+9.83%)` | :arrow_up: |
| [xknx/knxip/disconnect\_request.py](https://codecov.io/gh/XKNX/xknx/pull/927/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC9kaXNjb25uZWN0X3JlcXVlc3QucHk=) | `94.73% <0.00%> (-0.51%)` | :arrow_down: |
| [xknx/knxip/disconnect\_response.py](https://codecov.io/gh/XKNX/xknx/pull/927/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC9kaXNjb25uZWN0X3Jlc3BvbnNlLnB5) | `95.23% <0.00%> (-0.42%)` | :arrow_down: |
| [xknx/knxip/connectionstate\_response.py](https://codecov.io/gh/XKNX/xknx/pull/927/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC9jb25uZWN0aW9uc3RhdGVfcmVzcG9uc2UucHk=) | `95.23% <0.00%> (-0.42%)` | :arrow_down: |
| [xknx/knxip/tunnelling\_request.py](https://codecov.io/gh/XKNX/xknx/pull/927/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC90dW5uZWxsaW5nX3JlcXVlc3QucHk=) | `92.10% <0.00%> (-0.40%)` | :arrow_down: |
| [xknx/knxip/connectionstate\_request.py](https://codecov.io/gh/XKNX/xknx/pull/927/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC9jb25uZWN0aW9uc3RhdGVfcmVxdWVzdC5weQ==) | `95.65% <0.00%> (-0.35%)` | :arrow_down: |
| [xknx/knxip/tunnelling\_ack.py](https://codecov.io/gh/XKNX/xknx/pull/927/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC90dW5uZWxsaW5nX2Fjay5weQ==) | `96.00% <0.00%> (-0.30%)` | :arrow_down: |
| [xknx/knxip/routing\_indication.py](https://codecov.io/gh/XKNX/xknx/pull/927/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC9yb3V0aW5nX2luZGljYXRpb24ucHk=) | `96.00% <0.00%> (-0.30%)` | :arrow_down: |
| [xknx/knxip/secure\_wrapper.py](https://codecov.io/gh/XKNX/xknx/pull/927/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC9zZWN1cmVfd3JhcHBlci5weQ==) | `96.87% <0.00%> (-0.19%)` | :arrow_down: |
| [xknx/knxip/connect\_request.py](https://codecov.io/gh/XKNX/xknx/pull/927/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC9jb25uZWN0X3JlcXVlc3QucHk=) | `96.87% <0.00%> (-0.19%)` | :arrow_down: |
| ... and [15 more](https://codecov.io/gh/XKNX/xknx/pull/927/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | |


Patch:
diff --git a/changelog.md b/changelog.md
index de38f0e1..b474dd89 100644
--- a/changelog.md
+++ b/changelog.md
@@ -2,6 +2,16 @@
 
 ## Unreleased changes
 
+### Discovery
+
+- Send SearchRequest and SearchRequestExtended simultaneously when using GatewayScanner
+- Skip SearchResponse results for Core-V2 devices - wait for SearchResponseExtended
+- Identify interfaces having KNX IP Secure Tunneling required and skip if using Automatic connection mode
+
+### Internals
+
+- make HPAI hashable and add `addr_tuple` convenice property
+
 ## 0.20.4 Fix exposure of time and date
 
 ### Bugfixes
diff --git a/examples/example_gatewayscanner.py b/examples/example_gatewayscanner.py
index b2c02eaa..bbfff5dc 100644
--- a/examples/example_gatewayscanner.py
+++ b/examples/example_gatewayscanner.py
@@ -3,16 +3,13 @@ import asyncio
 
 from xknx import XKNX
 from xknx.io import GatewayScanner
-from xknx.knxip import KNXIPServiceType
 
 
 async def main():
     """Search for available KNX/IP devices with GatewayScanner and print out result if a device was found."""
     xknx = XKNX()
     gatewayscanner = GatewayScanner(xknx)
-    gateways = await gatewayscanner.scan(
-        search_request_type=KNXIPServiceType.SEARCH_REQUEST_EXTENDED
-    )
+    gateways = await gatewayscanner.scan()
 
     if not gateways:
         print("No Gateways found")
diff --git a/requirements/production.txt b/requirements/production.txt
index 7730378b..e6e71d1f 100644
--- a/requirements/production.txt
+++ b/requirements/production.txt
@@ -1,2 +1,2 @@
-cryptography==35.0.0
+cryptography==37.0.0
 netifaces==0.11.0
diff --git a/xknx/io/gateway_scanner.py b/xknx/io/gateway_scanner.py
index 0ef483e5..debae22a 100644
--- a/xknx/io/gateway_scanner.py
+++ b/xknx/io/gateway_scanner.py
@@ -1,9 +1,8 @@
 """
 GatewayScanner is an abstraction for searching for KNX/IP devices on the local network.
 
-* It walks through all network interfaces
-* and sends UDP multicast search requests
-* it returns the first found device
+It walks through all network interfaces and sends UDP multicast
+SearchRequest and SearchRequestExtended frames.
 """
 from __future__ import annotations
 
@@ -15,14 +14,10 @@ from typing import TYPE_CHECKING
 import netifaces
 
 from xknx.knxip import (
-    DIB,
     HPAI,
     SRP,
-    DIBDeviceInformation,
     DIBServiceFamily,
-    DIBSuppSVCFamilies,
     DIBTypeCode,
-    KNXIPBody,
     KNXIPFrame,
     KNXIPServiceType,
     SearchRequest,
@@ -30,6 +25,14 @@ from xknx.knxip import (
     SearchResponse,
     SearchResponseExtended,
 )
+from xknx.knxip.dib import (
+    DIB,
+    DIBDeviceInformation,
+    DIBSecuredServiceFamilies,
+    DIBSuppSVCFamilies,
+    DIBTunnelingInfo,
+    TunnelingSlotStatus,
+)
 from xknx.telegram import IndividualAddress
 
 from .transport import UDPTransport
@@ -60,17 +63,25 @@ class GatewayDescriptor:
         self.name = name
         self.ip_addr = ip_addr
         self.port = port
+        self.individual_address = individual_address
         self.local_interface = local_interface
         self.local_ip = local_ip
         self.supports_routing = supports_routing
         self.supports_tunnelling = supports_tunnelling
         self.supports_tunnelling_tcp = supports_tunnelling_tcp
         self.supports_secure = supports_secure
-        self.individual_address = individual_address
+
+        self.routing_requires_secure: bool | None = None
+        self.tunnelling_requires_secure: bool | None = None
+        self.tunnelling_slots: dict[IndividualAddress, TunnelingSlotStatus] = {}
 
     def parse_dibs(self, dibs: list[DIB]) -> None:
         """Parse DIBs for gateway information."""
         for dib in dibs:
+            if isinstance(dib, DIBDeviceInformation):
+                self.name = dib.name
+                self.individual_address = dib.individual_address
+                continue
             if isinstance(dib, DIBSuppSVCFamilies):
                 self.supports_routing = dib.supports(DIBServiceFamily.ROUTING)
                 if dib.supports(DIBServiceFamily.TUNNELING):
@@ -82,9 +93,14 @@ class GatewayDescriptor:
                     DIBServiceFamily.SECURITY, version=1
                 )
                 continue
-            if isinstance(dib, DIBDeviceInformation):
-                self.name = dib.name
-                self.individual_address = dib.individual_address
+            if isinstance(dib, DIBSecuredServiceFamilies):
+                self.tunnelling_requires_secure = dib.supports(
+                    DIBServiceFamily.TUNNELING
+                )
+                self.routing_requires_secure = dib.supports(DIBServiceFamily.ROUTING)
+                continue
+            if isinstance(dib, DIBTunnelingInfo):
+                self.tunnelling_slots = dib.slots
                 continue
 
     def __repr__(self) -> str:
@@ -94,13 +110,16 @@ class GatewayDescriptor:
             f"    name={self.name},\n"
             f"    ip_addr={self.ip_addr},\n"
             f"    port={self.port},\n"
+            f"    individual_address={self.individual_address}\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"    supports_tunnelling_tcp={self.supports_tunnelling_tcp},\n"
             f"    supports_secure={self.supports_secure},\n"
-            f"    individual_address={self.individual_address}\n"
+            f"    routing_requires_secure={self.routing_requires_secure}\n"
+            f"    tunnelling_requires_secure={self.tunnelling_requires_secure}\n"
+            f"    tunnelling_slots={self.tunnelling_slots}\n"
             ")"
         )
 
@@ -122,12 +141,14 @@ class GatewayScanFilter:
         tunnelling: bool | None = None,
         tunnelling_tcp: bool | None = None,
         routing: bool | None = None,
+        secure: bool | None = None,
     ):
         """Initialize GatewayScanFilter class."""
         self.name = name
         self.tunnelling = tunnelling
         self.tunnelling_tcp = tunnelling_tcp
         self.routing = routing
+        self.secure = secure
 
     def match(self, gateway: GatewayDescriptor) -> bool:
         """Check whether the device is a gateway and given GatewayDescriptor matches the filter."""
@@ -145,6 +166,9 @@ class GatewayScanFilter:
             return False
         if self.routing is not None and self.routing != gateway.supports_routing:
             return False
+        if self.secure is not None:
+            if self.secure is not bool(gateway.tunnelling_requires_secure):
+                return False
         return (
             gateway.supports_tunnelling
             or gateway.supports_tunnelling_tcp
@@ -165,23 +189,15 @@ class GatewayScanner:
         """Initialize GatewayScanner class."""
         self.xknx = xknx
         self.timeout_in_seconds = timeout_in_seconds
-        self.stop_on_found = stop_on_found
+        self.stop_on_found = stop_on_found or 0
         self.scan_filter = scan_filter
-        self.found_gateways: list[GatewayDescriptor] = []
+        self.found_gateways: dict[HPAI, GatewayDescriptor] = {}
         self._udp_transports: list[UDPTransport] = []
         self._response_received_event = asyncio.Event()
-        self._count_upper_bound = 0
-        """Clean value of self.stop_on_found, computed when ``scan`` is called."""
 
-    async def scan(
-        self, search_request_type: KNXIPServiceType = KNXIPServiceType.SEARCH_REQUEST
-    ) -> list[GatewayDescriptor]:
+    async def scan(self) -> list[GatewayDescriptor]:
         """Scan and return a list of GatewayDescriptors on success."""
-        if self.stop_on_found is None:
-            self._count_upper_bound = 0
-        else:
-            self._count_upper_bound = max(0, self.stop_on_found)
-        await self._send_search_requests(search_request_type)
+        await self._search_all_interfaces()
         try:
             await asyncio.wait_for(
                 self._response_received_event.wait(),
@@ -190,16 +206,12 @@ class GatewayScanner:
         except asyncio.TimeoutError:
             pass
         finally:
-            self._stop()
-
-        return self.found_gateways
+            for udp_transport in self._udp_transports:
+                udp_transport.stop()
 
-    def _stop(self) -> None:
-        """Stop tearing down udp_transport."""
-        for udp_transport in self._udp_transports:
-            udp_transport.stop()
+        return list(self.found_gateways.values())
 
-    async def _send_search_requests(
+    async def _search_all_interfaces(
         self, search_request_type: KNXIPServiceType = KNXIPServiceType.SEARCH_REQUEST
     ) -> None:
         """Find all interfaces with active IPv4 connection to search for gateways."""
@@ -215,15 +227,14 @@ class GatewayScanner:
                 logger.debug("Invalid interface %s: %s", interface, err)
                 continue
             else:
-                await self._search_interface(interface, ip_addr, search_request_type)
+                await self._send_search_requests(interface, ip_addr)
 
-    async def _search_interface(
+    async def _send_search_requests(
         self,
         interface: str,
         ip_addr: str,
-        search_request_type: KNXIPServiceType = KNXIPServiceType.SEARCH_REQUEST,
     ) -> None:
-        """Send a search request on a specific interface."""
+        """Send search requests on a specific interface."""
         logger.debug("Searching on %s / %s", interface, ip_addr)
 
         udp_transport = UDPTransport(
@@ -246,24 +257,23 @@ class GatewayScanner:
         discovery_endpoint = HPAI(
             ip_addr=self.xknx.multicast_group, port=self.xknx.multicast_port
         )
-
-        search_request: KNXIPBody
-        if search_request_type == KNXIPServiceType.SEARCH_REQUEST_EXTENDED:
-            search_request = SearchRequestExtended(
-                discovery_endpoint=discovery_endpoint,
-                srps=[
-                    SRP.request_device_description(
-                        [
-                            DIBTypeCode.DEVICE_INFO,
-                            DIBTypeCode.SUPP_SVC_FAMILIES,
-                            DIBTypeCode.SECURED_SERVICE_FAMILIES,
-                            DIBTypeCode.TUNNELING_INFO,
-                        ]
-                    )
-                ],
-            )
-        else:
-            search_request = SearchRequest(discovery_endpoint=discovery_endpoint)
+        # send SearchRequestExtended requesting needed DIBs
+        search_request_extended = SearchRequestExtended(
+            discovery_endpoint=discovery_endpoint,
+            srps=[
+                SRP.request_device_description(
+                    [
+                        DIBTypeCode.DEVICE_INFO,
+                        DIBTypeCode.SUPP_SVC_FAMILIES,
+                        DIBTypeCode.SECURED_SERVICE_FAMILIES,
+                        DIBTypeCode.TUNNELING_INFO,
+                    ]
+                )
+            ],
+        )
+        udp_transport.send(KNXIPFrame.init_from_body(search_request_extended))
+        # send SearchRequest for Core-V1 devices
+        search_request = SearchRequest(discovery_endpoint=discovery_endpoint)
         udp_transport.send(KNXIPFrame.init_from_body(search_request))
 
     def _response_rec_callback(
@@ -278,6 +288,20 @@ class GatewayScanner:
             logger.warning("Could not understand knxipframe")
             return
 
+        # skip non-extended SearchResponse for Core-V2 devices
+        if knx_ip_frame.header.service_type_ident == KNXIPServiceType.SEARCH_RESPONSE:
+            if svc_families_dib := next(
+                (
+                    dib
+                    for dib in knx_ip_frame.body.dibs
+                    if isinstance(dib, DIBSuppSVCFamilies)
+                ),
+                None,
+            ):
+                if svc_families_dib.supports(DIBServiceFamily.CORE, version=2):
+                    logger.debug("Skipping SearchResponse for Core-V2 device")
+                    return
+
         gateway = GatewayDescriptor(
             ip_addr=knx_ip_frame.body.control_endpoint.ip_addr,
             port=knx_ip_frame.body.control_endpoint.port,
@@ -287,13 +311,8 @@ class GatewayScanner:
         gateway.parse_dibs(knx_ip_frame.body.dibs)
 
         logger.debug("Found KNX/IP device at %s: %s", source, repr(gateway))
-        self._add_found_gateway(gateway)
+        if self.scan_filter.match(gateway):
+            self.found_gateways[knx_ip_frame.body.control_endpoint] = gateway
 
-    def _add_found_gateway(self, gateway: GatewayDescriptor) -> None:
-        if self.scan_filter.match(gateway) and not any(
-            _gateway.individual_address == gateway.individual_address
-            for _gateway in self.found_gateways
-        ):
-            self.found_gateways.append(gateway)
-            if 0 < self._count_upper_bound <= len(self.found_gateways):
+            if len(self.found_gateways) >= self.stop_on_found:
                 self._response_received_event.set()
diff --git a/xknx/io/knxip_interface.py b/xknx/io/knxip_interface.py
index 1529ade4..e06fae8c 100644
--- a/xknx/io/knxip_interface.py
+++ b/xknx/io/knxip_interface.py
@@ -149,6 +149,7 @@ class KNXIPInterface:
     async def _start_automatic(self) -> None:
         """Start GatewayScanner and connect to the found device."""
         scan_filter = self.connection_config.scan_filter
+        scan_filter.secure = False  # secure not supported for automatic connection
         gateway, local_interface_ip = await self.find_gateway(scan_filter)
         self._gateway_info = gateway
 
diff --git a/xknx/io/tunnel.py b/xknx/io/tunnel.py
index bfdf7612..693cac7a 100644
--- a/xknx/io/tunnel.py
+++ b/xknx/io/tunnel.py
@@ -189,10 +189,7 @@ class _Tunnel(Interface):
             self.communication_channel = connect.communication_channel
             # assign data_endpoint received from server
             self._data_endpoint_addr = (
-                (
-                    connect.data_endpoint.ip_addr,
-                    connect.data_endpoint.port,
-                )
+                connect.data_endpoint.addr_tuple
                 if not connect.data_endpoint.route_back
                 else None
             )
diff --git a/xknx/knxip/hpai.py b/xknx/knxip/hpai.py
index 7ae5a0a3..5f38726c 100644
--- a/xknx/knxip/hpai.py
+++ b/xknx/knxip/hpai.py
@@ -33,6 +33,11 @@ class HPAI:
         """Return True if HPAI is a route back address information."""
         return self.ip_addr == "0.0.0.0"
 
+    @property
+    def addr_tuple(self) -> tuple[str, int]:
+        """Return tuple of ip address and port."""
+        return self.ip_addr, self.port
+
     def from_knx(self, raw: bytes) -> int:
         """Parse/deserialize from KNX/IP raw data."""
         if len(raw) < HPAI.LENGTH:
@@ -73,3 +78,7 @@ class HPAI:
     def __eq__(self, other: object) -> bool:
         """Equal operator."""
         return self.__dict__ == other.__dict__
+
+    def __hash__(self) -> int:
+        """Hash function."""
+        return hash((self.ip_addr, self.port, self.protocol))
diff --git a/xknx/secure/ip_secure.py b/xknx/secure/ip_secure.py
index b11e7ef4..032d519c 100644
--- a/xknx/secure/ip_secure.py
+++ b/xknx/secure/ip_secure.py
@@ -1,8 +1,6 @@
 """Encryption and Decryption functions for KNX/IP Datagrams."""
 from __future__ import annotations
 
-from typing import cast
-
 from cryptography.hazmat.primitives import hashes, serialization
 from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
 from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
@@ -22,12 +20,12 @@ def calculate_message_authentication_code_cbc(
         block_0 + len(additional_data).to_bytes(2, "big") + additional_data + payload
     )
     y_cipher = Cipher(algorithms.AES(key), modes.CBC(bytes(16)))
-    y_encryptor = y_cipher.encryptor()  # type: ignore[no-untyped-call]
+    y_encryptor = y_cipher.encryptor()
     y_blocks = (
         y_encryptor.update(byte_pad(blocks, block_size=16)) + y_encryptor.finalize()
     )
     # only calculate, no ctr encryption
-    return cast(bytes, y_blocks[-16:])
+    return y_blocks[-16:]
 
 
 def decrypt_ctr(
@@ -43,7 +41,7 @@ def decrypt_ctr(
     Returns a tuple of (KNX/IP frame bytes, MAC TR for verification).
     """
     cipher = Cipher(algorithms.AES(key), modes.CTR(counter_0))
-    decryptor = cipher.decryptor()  # type: ignore[no-untyped-call]
+    decryptor = cipher.decryptor()
     mac_tr = decryptor.update(mac)  # MAC is encrypted with counter 0
     decrypted_data = decryptor.update(payload) + decryptor.finalize()
 
@@ -64,7 +62,7 @@ def encrypt_data_ctr(
     Returns a tuple of encrypted data (if there is any) and encrypted MAC.
     """
     s_cipher = Cipher(algorithms.AES(key), modes.CTR(counter_0))
-    s_encryptor = s_cipher.encryptor()  # type: ignore[no-untyped-call]
+    s_encryptor = s_cipher.encryptor()
     mac = s_encryptor.update(mac_cbc)
     encrypted_data = s_encryptor.update(payload) + s_encryptor.finalize()
     return (encrypted_data, mac)
diff --git a/xknx/secure/keyring.py b/xknx/secure/keyring.py
index 69cb4658..e9464069 100644
--- a/xknx/secure/keyring.py
+++ b/xknx/secure/keyring.py
@@ -372,7 +372,7 @@ def decrypt_aes128cbc(
 ) -> bytes:
     """Decrypt data with AES 128 CBC."""
     cipher = Cipher(algorithms.AES(key), modes.CBC(initialization_vector))
-    decryptor = cipher.decryptor()  # type: ignore[no-untyped-call]
+    decryptor = cipher.decryptor()
     return bytes(decryptor.update(encrypted_data) + decryptor.finalize())
 
 


Test patch:
diff --git a/requirements/testing.txt b/requirements/testing.txt
index 4b3fbd6b..e74fac75 100644
--- a/requirements/testing.txt
+++ b/requirements/testing.txt
@@ -5,7 +5,7 @@ flake8==4.0.1
 flake8-isort==4.1.1
 pydocstyle==6.1.1
 pylint==2.13.7
-pytest==7.1.1
+pytest==7.1.2
 pytest-asyncio==0.18.3
 pytest-cov==3.0.0
 setuptools==62.1.0
diff --git a/test/io_tests/gateway_scanner_test.py b/test/io_tests/gateway_scanner_test.py
index b56623c4..2d9756d9 100644
--- a/test/io_tests/gateway_scanner_test.py
+++ b/test/io_tests/gateway_scanner_test.py
@@ -12,7 +12,8 @@ from xknx.knxip import (
     DIBServiceFamily,
     DIBSuppSVCFamilies,
     KNXIPFrame,
-    KNXIPServiceType,
+    SearchRequest,
+    SearchRequestExtended,
     SearchResponse,
 )
 from xknx.telegram import IndividualAddress
@@ -165,8 +166,6 @@ class TestGatewayScanner:
             udp_transport_mock,
             interface="en1",
         )
-
-        assert str(gateway_scanner.found_gateways[0]) == str(self.gateway_desc_both)
         assert len(gateway_scanner.found_gateways) == 1
 
         gateway_scanner._response_rec_callback(
@@ -177,6 +176,10 @@ class TestGatewayScanner:
         )
         assert len(gateway_scanner.found_gateways) == 1
 
+        assert str(
+            gateway_scanner.found_gateways[test_search_response.body.control_endpoint]
+        ) == str(self.gateway_desc_both)
+
     @patch("xknx.io.gateway_scanner.netifaces", autospec=True)
     async def test_scan_timeout(self, netifaces_mock):
         """Test gateway scanner timeout."""
@@ -193,74 +196,48 @@ class TestGatewayScanner:
         assert timed_out_scan == []
 
     @patch("xknx.io.gateway_scanner.netifaces", autospec=True)
-    @patch("xknx.io.GatewayScanner._search_interface", autospec=True)
-    async def test_send_search_requests(self, _search_interface_mock, netifaces_mock):
-        """Test finding all valid interfaces to send search requests to. No requests are sent."""
+    @patch("xknx.io.GatewayScanner._send_search_requests")
+    async def test_search_all_interfaces(
+        self, _send_search_requests_mock, netifaces_mock
+    ):
+        """Test finding all valid interfaces to send search request extended to. No requests are sent."""
         xknx = XKNX()
 
         netifaces_mock.interfaces.return_value = self.fake_interfaces
         netifaces_mock.ifaddresses = lambda interface: self.fake_ifaddresses[interface]
         netifaces_mock.AF_INET = 2
 
-        async def async_none():
-            return None
-
-        _search_interface_mock.return_value = asyncio.ensure_future(async_none())
-
         gateway_scanner = GatewayScanner(xknx, timeout_in_seconds=0)
 
         test_scan = await gateway_scanner.scan()
 
-        assert _search_interface_mock.call_count == 2
+        assert _send_search_requests_mock.call_count == 2
         expected_calls = [
-            ((gateway_scanner, "lo0", "127.0.0.1", KNXIPServiceType.SEARCH_REQUEST),),
-            ((gateway_scanner, "en1", "10.1.1.2", KNXIPServiceType.SEARCH_REQUEST),),
+            (("lo0", "127.0.0.1"),),
+            (("en1", "10.1.1.2"),),
         ]
-        assert _search_interface_mock.call_args_list == expected_calls
+        assert _send_search_requests_mock.call_args_list == expected_calls
         assert test_scan == []
 
-    @patch("xknx.io.gateway_scanner.netifaces", autospec=True)
-    @patch("xknx.io.GatewayScanner._search_interface", autospec=True)
-    async def test_send_search_request_extended(
-        self, _search_interface_mock, netifaces_mock
+    @patch("xknx.io.gateway_scanner.UDPTransport.connect")
+    @patch("xknx.io.gateway_scanner.UDPTransport.send")
+    async def test_send_search_requests(
+        self,
+        udp_transport_send_mock,
+        udp_transport_connect_mock,
     ):
-        """Test finding all valid interfaces to send search request extended to. No requests are sent."""
+        """Test if both search requests are sent per interface."""
         xknx = XKNX()
-
-        netifaces_mock.interfaces.return_value = self.fake_interfaces
-        netifaces_mock.ifaddresses = lambda interface: self.fake_ifaddresses[interface]
-        netifaces_mock.AF_INET = 2
-
-        async def async_none():
-            return None
-
-        _search_interface_mock.return_value = asyncio.ensure_future(async_none())
-
         gateway_scanner = GatewayScanner(xknx, timeout_in_seconds=0)
 
-        test_scan = await gateway_scanner.scan(KNXIPServiceType.SEARCH_REQUEST_EXTENDED)
+        await gateway_scanner._send_search_requests(interface="en1", ip_addr="10.1.1.2")
 
-        assert _search_interface_mock.call_count == 2
-        expected_calls = [
-            (
-                (
-                    gateway_scanner,
-                    "lo0",
-                    "127.0.0.1",
-                    KNXIPServiceType.SEARCH_REQUEST_EXTENDED,
-                ),
-            ),
-            (
-                (
-                    gateway_scanner,
-                    "en1",
-                    "10.1.1.2",
-                    KNXIPServiceType.SEARCH_REQUEST_EXTENDED,
-                ),
-            ),
-        ]
-        assert _search_interface_mock.call_args_list == expected_calls
-        assert test_scan == []
+        assert udp_transport_connect_mock.call_count == 1
+        assert udp_transport_send_mock.call_count == 2
+        frame_1 = udp_transport_send_mock.call_args_list[0].args[0]
+        frame_2 = udp_transport_send_mock.call_args_list[1].args[0]
+        assert isinstance(frame_1.body, SearchRequestExtended)
+        assert isinstance(frame_2.body, SearchRequest)
 
 
 def fake_router_search_response() -> KNXIPFrame: