Repository: XKNX/xknx
Pull request #1239 (2023-04-08 08:56:20)

Resolve IP from hostname or adapter name
for gateway_ip or local_ip

<!--
  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.
-->


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

- [ ] The documentation has been adjusted accordingly
- [ ] 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/1239?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
> Merging [#1239](https://codecov.io/gh/XKNX/xknx/pull/1239?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (d35b8cd) into [main](https://codecov.io/gh/XKNX/xknx/commit/c4bb7c3871d930891dbf8a437ddb7dd7fe3e0919?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (c4bb7c3) will **decrease** coverage by `0.07%`.
> The diff coverage is `70.83%`.

> :exclamation: Current head d35b8cd differs from pull request most recent head 9b8a6ff. Consider uploading reports for the commit 9b8a6ff to get more accurate results

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

```diff
@@            Coverage Diff             @@
##             main    #1239      +/-   ##
==========================================
- Coverage   96.58%   96.52%   -0.07%     
==========================================
  Files         149      149              
  Lines        9479     9485       +6     
==========================================
  Hits         9155     9155              
- Misses        324      330       +6     
```


| [Impacted Files](https://codecov.io/gh/XKNX/xknx/pull/1239?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/1239?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/util.py](https://codecov.io/gh/XKNX/xknx/pull/1239?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9pby91dGlsLnB5) | `75.00% <36.36%> (-11.12%)` | :arrow_down: |
| [xknx/io/knxip\_interface.py](https://codecov.io/gh/XKNX/xknx/pull/1239?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9pby9rbnhpcF9pbnRlcmZhY2UucHk=) | `88.12% <100.00%> (-0.11%)` | :arrow_down: |

farmio: I haven't yet tested it myself 🤣.  @marvin-w did you?
farmio: Works locally 🎉

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 116fe833..cfa9ad87 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -8,6 +8,10 @@ nav_order: 2
 
 # Unreleased changes
 
+### Connection
+
+- Resolve IP addresses from hostname or adapter name for `gateway_ip` or `local_ip`.
+
 ### Bugfixes
 
 - Handle empty list for group addresses in RemoteValue.
diff --git a/xknx/io/connection.py b/xknx/io/connection.py
index e5f5f5bc..448901e8 100644
--- a/xknx/io/connection.py
+++ b/xknx/io/connection.py
@@ -37,8 +37,8 @@ class ConnectionConfig:
         * 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
-    * local_ip: Local ip of the interface though which KNXIPInterface should connect.
-    * gateway_ip: IP of KNX/IP tunneling device.
+    * local_ip: Local ip or interface name though which xknx should connect.
+    * gateway_ip: IP or hostname of KNX/IP tunneling device.
     * gateway_port: Port of KNX/IP tunneling device.
     * route_back: For UDP TUNNELING connection.
         The KNXnet/IP Server shall use the IP address and port in the received IP package
diff --git a/xknx/io/knxip_interface.py b/xknx/io/knxip_interface.py
index 6bf85fa9..d07167ff 100644
--- a/xknx/io/knxip_interface.py
+++ b/xknx/io/knxip_interface.py
@@ -73,6 +73,10 @@ class KNXIPInterface:
 
     async def _start(self) -> None:
         """Start interface. Connecting KNX/IP device with the selected method."""
+        if gateway_ip := self.connection_config.gateway_ip:
+            gateway_ip = await util.validate_ip(gateway_ip, address_name="Gateway IP")
+        if local_ip := self.connection_config.local_ip:
+            local_ip = await util.validate_ip(local_ip, address_name="Local IP")
         keyring: Keyring | None = None
         if secure_config := self.connection_config.secure_config:
             if (
@@ -86,39 +90,44 @@ class KNXIPInterface:
         self.xknx.cemi_handler.data_secure_init(keyring=keyring)
 
         if self.connection_config.connection_type == ConnectionType.ROUTING:
-            await self._start_routing()
+            await self._start_routing(local_ip=local_ip)
         elif self.connection_config.connection_type == ConnectionType.ROUTING_SECURE:
-            await self._start_secure_routing(keyring=keyring)
+            await self._start_secure_routing(local_ip=local_ip, keyring=keyring)
         elif (
             self.connection_config.connection_type == ConnectionType.TUNNELING
-            and self.connection_config.gateway_ip is not None
+            and gateway_ip is not None
         ):
             await self._start_tunnelling_udp(
-                gateway_ip=self.connection_config.gateway_ip,
+                gateway_ip=gateway_ip,
                 gateway_port=self.connection_config.gateway_port,
+                local_ip=local_ip,
             )
         elif (
             self.connection_config.connection_type == ConnectionType.TUNNELING_TCP
-            and self.connection_config.gateway_ip is not None
+            and gateway_ip is not None
         ):
             await self._start_tunnelling_tcp(
-                gateway_ip=self.connection_config.gateway_ip,
+                gateway_ip=gateway_ip,
                 gateway_port=self.connection_config.gateway_port,
             )
         elif (
             self.connection_config.connection_type
             == ConnectionType.TUNNELING_TCP_SECURE
-            and self.connection_config.gateway_ip is not None
+            and gateway_ip is not None
         ):
             await self._start_secure_tunnelling_tcp(
-                gateway_ip=self.connection_config.gateway_ip,
+                gateway_ip=gateway_ip,
                 gateway_port=self.connection_config.gateway_port,
                 keyring=keyring,
             )
         else:
-            await self._start_automatic(keyring=keyring)
+            await self._start_automatic(local_ip=local_ip, keyring=keyring)
 
-    async def _start_automatic(self, keyring: Keyring | None) -> None:
+    async def _start_automatic(
+        self,
+        local_ip: str | None,
+        keyring: Keyring | None,
+    ) -> None:
         """Start GatewayScanner and connect to the found device."""
         keyring_host_filter: set[IndividualAddress] = set()
         if keyring:
@@ -138,10 +147,9 @@ class KNXIPInterface:
                     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,
+            local_ip=local_ip,
             scan_filter=self.connection_config.scan_filter,
         ).async_scan():
             if (
@@ -171,9 +179,10 @@ class KNXIPInterface:
                     await self._start_tunnelling_udp(
                         gateway_ip=gateway.ip_addr,
                         gateway_port=gateway.port,
+                        local_ip=local_ip,
                     )
                 elif gateway.supports_routing and not gateway.routing_requires_secure:
-                    await self._start_routing()
+                    await self._start_routing(local_ip=local_ip)
             except CommunicationError as ex:
                 logger.debug("Skipping %s. Could not connect: %s", gateway, ex)
                 continue
@@ -196,7 +205,6 @@ class KNXIPInterface:
         gateway_port: int,
     ) -> None:
         """Start KNX/IP TCP tunnel."""
-        util.validate_ip(gateway_ip, address_name="Gateway IP address")
         tunnel_address = self.connection_config.individual_address
 
         logger.debug(
@@ -263,7 +271,6 @@ class KNXIPInterface:
                 "No `user_id` or `knxkeys_file_path` and password found in secure configuration"
             )
 
-        util.validate_ip(gateway_ip, address_name="Gateway IP address")
         logger.debug(
             "Starting secure tunnel to %s:%s over TCP",
             gateway_ip,
@@ -339,21 +346,19 @@ class KNXIPInterface:
         self,
         gateway_ip: str,
         gateway_port: int,
+        local_ip: str | None,
     ) -> None:
         """Start KNX/IP UDP tunnel."""
-        util.validate_ip(gateway_ip, address_name="Gateway IP address")
-        local_ip = self.connection_config.local_ip or util.find_local_ip(
-            gateway_ip=gateway_ip
-        )
         local_port = self.connection_config.local_port
         route_back = self.connection_config.route_back
+
+        local_ip = local_ip or util.find_local_ip(gateway_ip=gateway_ip)
         if local_ip is None:
             local_ip = await util.get_default_local_ip(gateway_ip)
             if local_ip is None:
                 raise XKNXException("No network interface found.")
             route_back = True
             logger.debug("Falling back to default interface and enabling route back.")
-        util.validate_ip(local_ip, address_name="Local IP address")
 
         logger.debug(
             "Starting tunnel from %s:%s to %s:%s",
@@ -375,14 +380,15 @@ class KNXIPInterface:
         )
         await self._interface.connect()
 
-    async def _start_routing(self) -> None:
+    async def _start_routing(self, local_ip: str | None) -> None:
         """Start KNX/IP Routing."""
         multicast_group = self.connection_config.multicast_group
         multicast_port = self.connection_config.multicast_port
-        local_ip = self.connection_config.local_ip or await util.get_default_local_ip()
+
+        local_ip = local_ip or await util.get_default_local_ip()
         if local_ip is None:
             raise XKNXException("No network interface found.")
-        util.validate_ip(local_ip, address_name="Local IP address")
+
         individual_address = (
             self.connection_config.individual_address or DEFAULT_INDIVIDUAL_ADDRESS
         )
@@ -406,6 +412,7 @@ class KNXIPInterface:
 
     async def _start_secure_routing(
         self,
+        local_ip: str | None,
         keyring: Keyring | None = None,
     ) -> None:
         """Start KNX/IP Routing."""
@@ -427,10 +434,10 @@ class KNXIPInterface:
                 "No backbone key found in secure configuration"
             )
 
-        local_ip = self.connection_config.local_ip or await util.get_default_local_ip()
+        local_ip = local_ip or await util.get_default_local_ip()
         if local_ip is None:
             raise XKNXException("No network interface found.")
-        util.validate_ip(local_ip, address_name="Local IP address")
+
         individual_address = (
             self.connection_config.individual_address or DEFAULT_INDIVIDUAL_ADDRESS
         )
diff --git a/xknx/io/util.py b/xknx/io/util.py
index f242662b..7d1eb6e7 100644
--- a/xknx/io/util.py
+++ b/xknx/io/util.py
@@ -25,7 +25,7 @@ async def get_default_local_ip(remote_ip: str = DEFAULT_MCAST_GRP) -> str | None
             await loop.sock_connect(sock, (remote_ip, DEFAULT_MCAST_PORT))
             local_ip = sock.getsockname()[0]
             logger.debug("Using local ip: %s", local_ip)
-            return cast(str, local_ip)
+            return local_ip  # type: ignore[no-any-return]
         except Exception:  # pylint: disable=broad-except
             logger.warning(
                 "The system could not auto detect the source ip for %s on your operating system",
@@ -44,6 +44,20 @@ def get_local_interface_name(local_ip: str) -> str:
     return next((link.nice_name for link in get_local_ips() if link.ip == local_ip), "")
 
 
+def get_ip_for_adapter_name(name: str) -> str | None:
+    """Return the ip for the given interface name."""
+    return next(
+        (
+            ip.ip  # type: ignore[misc] # IPv6 would return tuple
+            for iface in ifaddr.get_adapters()
+            if name in (iface.name, iface.nice_name)
+            for ip in iface.ips
+            if ip.is_IPv4
+        ),
+        None,
+    )
+
+
 def find_local_ip(gateway_ip: str) -> str | None:
     """Find local IP address on same subnet as gateway."""
     gateway = ipaddress.IPv4Address(gateway_ip)
@@ -56,9 +70,25 @@ def find_local_ip(gateway_ip: str) -> str | None:
     return None
 
 
-def validate_ip(address: str, address_name: str = "IP address") -> None:
-    """Raise an exception if address cannot be parsed as IPv4 address."""
+async def validate_ip(address: str, address_name: str = "IP address") -> str:
+    """
+    Return IPv4 address parsed or resolved as a string.
+
+    Valid addresses are IPv4 strings, adapter names or hostnames.
+    Raises XKNXException if address is not a valid IPv4 address or cannot be resolved.
+    """
     try:
         ipaddress.IPv4Address(address)
+        return address
     except ipaddress.AddressValueError as ex:
-        raise XKNXException(f"{address_name} is not a valid IPv4 address.") from ex
+        logger.debug(
+            "%s is not a valid IPv4 address: %s. Trying to resolve...",
+            address_name,
+            ex,
+        )
+    if adapter_ip := get_ip_for_adapter_name(address):
+        return adapter_ip
+    try:
+        return await asyncio.to_thread(socket.gethostbyname, address)
+    except socket.gaierror as ex:
+        raise XKNXException(f"Could not resolve {address_name}: {address}") from ex


Test patch:
diff --git a/test/io_tests/knxip_interface_test.py b/test/io_tests/knxip_interface_test.py
index a20f539e..9a9c18bb 100644
--- a/test/io_tests/knxip_interface_test.py
+++ b/test/io_tests/knxip_interface_test.py
@@ -37,7 +37,7 @@ class TestKNXIPInterface:
         interface = knx_interface_factory(self.xknx, connection_config)
         with patch("xknx.io.KNXIPInterface._start_automatic") as start_automatic_mock:
             await interface.start()
-            start_automatic_mock.assert_called_once_with(keyring=None)
+            start_automatic_mock.assert_called_once_with(local_ip=None, keyring=None)
             assert threading.active_count() == 1
 
         async def gateway_generator_mock(_):
@@ -180,7 +180,8 @@ class TestKNXIPInterface:
         # without gateway_ip automatic is called
         gateway_ip = "127.0.0.2"
         connection_config = ConnectionConfig(
-            connection_type=ConnectionType.TUNNELING, gateway_ip=gateway_ip
+            connection_type=ConnectionType.TUNNELING,
+            gateway_ip=gateway_ip,
         )
         with patch(
             "xknx.io.KNXIPInterface._start_tunnelling_udp"
@@ -190,6 +191,7 @@ class TestKNXIPInterface:
             start_tunnelling_udp.assert_called_once_with(
                 gateway_ip=gateway_ip,
                 gateway_port=3671,
+                local_ip=None,
             )
         with patch("xknx.io.tunnel.UDPTunnel.connect") as connect_udp:
             interface = knx_interface_factory(self.xknx, connection_config)
@@ -278,7 +280,7 @@ class TestKNXIPInterface:
         with patch("xknx.io.KNXIPInterface._start_routing") as start_routing:
             interface = knx_interface_factory(self.xknx, connection_config)
             await interface.start()
-            start_routing.assert_called_once_with()
+            start_routing.assert_called_once_with(local_ip=local_ip)
 
         with patch("xknx.io.routing.Routing.connect") as connect_routing:
             interface = knx_interface_factory(self.xknx, connection_config)
@@ -308,7 +310,7 @@ class TestKNXIPInterface:
         ) as start_automatic_mock:
             interface = knx_interface_factory(self.xknx, connection_config)
             await interface.start()
-            start_automatic_mock.assert_called_once_with(keyring=None)
+            start_automatic_mock.assert_called_once_with(local_ip=None, keyring=None)
 
     async def test_threaded_send_cemi(self):
         """Test sending cemi with threaded connection."""