Repository: XKNX/xknx
Pull request #1198 (2023-02-16 08:44:35)

Filter host for automatic connection if configured
<!--
  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.
-->
- When `ConnectionConfig.individual_address` is set and a Keyring is given `ConnectionType.AUTOMATIC` will try to connect to the host of this address. If not found (in keyfile or discovery) it will raise.

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)
- [ ] Code quality improvements to existing code or addition of tests

## Checklist

- [x] The documentation has been adjusted accordingly
- [x] Tests have been added that prove the fix is effective or that the feature works
- [x] The changes are documented in the changelog (docs/changelog.md)

codecov[bot]: # [Codecov](https://codecov.io/gh/XKNX/xknx/pull/1198?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
> Merging [#1198](https://codecov.io/gh/XKNX/xknx/pull/1198?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (a06bc38) into [main](https://codecov.io/gh/XKNX/xknx/commit/63b229b6e43c47df6e41b621d64d91f2a3e2f66d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (63b229b) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

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

```diff
@@           Coverage Diff           @@
##             main    #1198   +/-   ##
=======================================
  Coverage   96.40%   96.40%           
=======================================
  Files         143      143           
  Lines        8954     8961    +7     
=======================================
+ Hits         8632     8639    +7     
  Misses        322      322           
```


| [Impacted Files](https://codecov.io/gh/XKNX/xknx/pull/1198?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/io/connection.py](https://codecov.io/gh/XKNX/xknx/pull/1198?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9pby9jb25uZWN0aW9uLnB5) | `100.00% <ø> (ø)` | |
| [xknx/io/knxip\_interface.py](https://codecov.io/gh/XKNX/xknx/pull/1198?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9pby9rbnhpcF9pbnRlcmZhY2UucHk=) | `88.23% <100.00%> (+0.27%)` | :arrow_up: |
| [xknx/secure/keyring.py](https://codecov.io/gh/XKNX/xknx/pull/1198?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9zZWN1cmUva2V5cmluZy5weQ==) | `98.80% <100.00%> (+<0.01%)` | :arrow_up: |


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index ecee2134..1277fa0c 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -6,6 +6,12 @@ nav_order: 2
 
 # Changelog
 
+# Unreleased changes
+
+### Connection
+
+- When `ConnectionConfig.individual_address` is set and a Keyring is given `ConnectionType.AUTOMATIC` will try to connect to the host of this address. If not found (in keyfile or discovery) it will raise.
+
 # 2.5.0 Request IA 2023-02-14
 
 ### Connection
diff --git a/docs/xknx.md b/docs/xknx.md
index 92e9f98a..bafbf704 100644
--- a/docs/xknx.md
+++ b/docs/xknx.md
@@ -73,7 +73,10 @@ An explicit connection configuration can be used. In this case a `connection_typ
 KNX Data Secure credentials are sourced from a keyfile exported from ETS.
 IP Secure keys can be configured directly or sourced from a keyfile.
 
-For TCP tunnel connections a specific tunnel endpoint can be requested by setting `individual_address`. For SECURE tunnels this setting selects an interface from a given keyfile.
+A specific tunnel endpoint can be requested by setting `individual_address`.
+For AUTOMATIC connections this setting selects a host from a given keyfile.
+For TCP TUNNELING connections this setting requests a tunnel to that individual address.
+For SECURE tunnels this setting selects an interface from a given keyfile.
 
 # [](#header-2)Starting
 
diff --git a/xknx/io/connection.py b/xknx/io/connection.py
index e01becc9..e5f5f5bc 100644
--- a/xknx/io/connection.py
+++ b/xknx/io/connection.py
@@ -33,6 +33,7 @@ class ConnectionConfig:
         * TUNNELING connect to a specific KNX/IP tunneling device via UDP.
         * TUNNELING_TCP connect to a specific KNX/IP tunneling v2 device via TCP.
     * individual address:
+        * AUTOMATIC use a specific tunnel endpoint from a given knxkeys file
         * ROUTING the individual address used as source address for routing
         * TCP TUNNELING request a specific tunnel endpoint
         * SECURE TUNNELING use a specific tunnel endpoint from the knxkeys file
diff --git a/xknx/io/knxip_interface.py b/xknx/io/knxip_interface.py
index 5e0ec5eb..b81aa3ab 100644
--- a/xknx/io/knxip_interface.py
+++ b/xknx/io/knxip_interface.py
@@ -120,21 +120,34 @@ class KNXIPInterface:
 
     async def _start_automatic(self, keyring: Keyring | None) -> None:
         """Start GatewayScanner and connect to the found device."""
-        keyring_host_ias: set[IndividualAddress] | None = None
-        if keyring and keyring.interfaces:
-            keyring_host_ias = {
-                interface.host
-                for interface in keyring.interfaces
-                if interface.host is not None
-                and interface.type is InterfaceType.TUNNELING
-            }
+        keyring_host_filter: set[IndividualAddress] = set()
+        if keyring:
+            if required_addr := self.connection_config.individual_address:
+                _host_ia = keyring.get_tunnel_host_by_interface(
+                    tunnelling_slot=required_addr
+                )
+                if _host_ia is None:
+                    raise InvalidSecureConfiguration(
+                        f"No host for required address {required_addr} found in keyring file."
+                    )
+                keyring_host_filter.add(_host_ia)
+            else:
+                keyring_host_filter.update(
+                    interface.host
+                    for interface in keyring.interfaces
+                    if interface.host is not None
+                    and interface.type is InterfaceType.TUNNELING
+                )
 
         async for gateway in GatewayScanner(
             self.xknx,
             local_ip=self.connection_config.local_ip,
             scan_filter=self.connection_config.scan_filter,
         ).async_scan():
-            if keyring_host_ias and gateway.individual_address not in keyring_host_ias:
+            if (
+                keyring_host_filter
+                and gateway.individual_address not in keyring_host_filter
+            ):
                 logger.debug("Skipping %s. No match in keyring file", gateway)
                 continue
             try:
@@ -174,7 +187,7 @@ class KNXIPInterface:
                 break
         else:
             raise CommunicationError(
-                f"No usable KNX/IP device found{' in keyring file' if keyring_host_ias else ''}."
+                f"No usable KNX/IP device found{' in keyring file' if keyring_host_filter else ''}."
             )
 
     async def _start_tunnelling_tcp(
diff --git a/xknx/secure/keyring.py b/xknx/secure/keyring.py
index 42980632..35eac15c 100644
--- a/xknx/secure/keyring.py
+++ b/xknx/secure/keyring.py
@@ -281,6 +281,20 @@ class Keyring(AttributeReader):
 
         return None
 
+    def get_tunnel_host_by_interface(
+        self, tunnelling_slot: IndividualAddress
+    ) -> IndividualAddress | None:
+        """Get the tunnel host for a given interface."""
+        return next(
+            (
+                interface.host
+                for interface in self.interfaces
+                if interface.type is InterfaceType.TUNNELING
+                and interface.individual_address == tunnelling_slot
+            ),
+            None,
+        )
+
     def get_tunnel_interfaces_by_host(
         self, host: IndividualAddress
     ) -> list[XMLInterface]:


Test patch:
diff --git a/test/io_tests/knxip_interface_test.py b/test/io_tests/knxip_interface_test.py
index 0b3bb322..a20f539e 100644
--- a/test/io_tests/knxip_interface_test.py
+++ b/test/io_tests/knxip_interface_test.py
@@ -120,6 +120,61 @@ class TestKNXIPInterface:
                 gateway_port=3671,
             )
 
+    async def test_start_automatic_with_keyring_and_ia(self):
+        """Test starting with automatic mode and keyring and individual address."""
+        connection_config = ConnectionConfig(
+            individual_address=IndividualAddress("1.0.12"),
+            secure_config=SecureConfig(
+                knxkeys_file_path=self.knxkeys_file,
+                knxkeys_password="password",
+            ),
+        )
+        assert connection_config.connection_type == ConnectionType.AUTOMATIC
+        interface = knx_interface_factory(self.xknx, connection_config)
+
+        # in the test keyfile the only host is 1.0.0 - others shall be skipped
+        async def gateway_generator_mock(_):
+            yield GatewayDescriptor(
+                ip_addr="10.1.5.5",
+                port=3671,
+                supports_tunnelling_tcp=True,
+                individual_address=IndividualAddress("5.0.0"),
+            )
+            yield GatewayDescriptor(
+                ip_addr="10.1.0.0",
+                port=3671,
+                supports_tunnelling_tcp=True,
+                individual_address=IndividualAddress("1.0.0"),
+            )
+
+        with patch(
+            "xknx.io.knxip_interface.GatewayScanner.async_scan",
+            new=gateway_generator_mock,
+        ), patch(
+            "xknx.io.KNXIPInterface._start_tunnelling_tcp",
+        ) as start_tunnelling_tcp_mock:
+            await interface.start()
+            start_tunnelling_tcp_mock.assert_called_once_with(
+                gateway_ip="10.1.0.0",
+                gateway_port=3671,
+            )
+
+        # IA not listed in keyring
+        invalid_config = ConnectionConfig(
+            individual_address=IndividualAddress("5.5.5"),
+            secure_config=SecureConfig(
+                knxkeys_file_path=self.knxkeys_file,
+                knxkeys_password="password",
+            ),
+        )
+        assert invalid_config.connection_type == ConnectionType.AUTOMATIC
+        interface = knx_interface_factory(self.xknx, invalid_config)
+        with patch(
+            "xknx.io.knxip_interface.GatewayScanner.async_scan",
+            new=gateway_generator_mock,
+        ), pytest.raises(InvalidSecureConfiguration):
+            await interface.start()
+
     async def test_start_udp_tunnel_connection(self):
         """Test starting UDP tunnel connection."""
         # without gateway_ip automatic is called
diff --git a/test/secure_tests/keyring_test.py b/test/secure_tests/keyring_test.py
index 65c6b3f2..aaa2ade5 100644
--- a/test/secure_tests/keyring_test.py
+++ b/test/secure_tests/keyring_test.py
@@ -154,6 +154,16 @@ class TestKeyRing:
         test_device = keyring.get_device_by_interface(interface=test_interface)
         assert test_device.individual_address == IndividualAddress("1.1.10")
 
+        test_host = keyring.get_tunnel_host_by_interface(
+            tunnelling_slot=IndividualAddress("1.1.8")
+        )
+        assert test_host == IndividualAddress("1.1.0")
+
+        test_host = keyring.get_tunnel_host_by_interface(
+            tunnelling_slot=IndividualAddress("1.1.10")
+        )
+        assert test_host is None
+
         test_interface = keyring.get_tunnel_interface_by_host_and_user_id(
             host=IndividualAddress("1.1.0"), user_id=4
         )