From 4f83cfe374aa8687d3e258b601397a901c4aef7d Mon Sep 17 00:00:00 2001 From: Mielune Date: Wed, 17 Feb 2021 23:37:10 +0100 Subject: [PATCH] Route back (#544) * Add a bind ip and port to work on containers. * Add ENV read for config. And update docs. * Add log.debug to see config overwritte by env vars. * Rollback version in file. * Remove details from changelog.md * Add details to changelog.md * Remove local_port for Routing mode. * xknx.yaml: Add config examples for connections. * Connect: Check net_bind address * Update docs/configuration.md Sure, ok for this update :) Co-authored-by: Matthias Alphart * Update xknx/io/connectionstate.py Co-authored-by: Matthias Alphart * Update xknx/io/request_response.py Ok Co-authored-by: Matthias Alphart * Update xknx/io/disconnect.py Better code design :) Co-authored-by: Matthias Alphart * Remove bind_ and add route_back. * Remove bind_ and add route_back. * Remove local_port from config. * Update tests. * Update examples. * minor updates to allow tox success. * Minor config update and add tests. * Update docs/configuration.md Co-authored-by: Matthias Alphart * Update docs/xknx.md Co-authored-by: Matthias Alphart * Update docs/xknx.md Co-authored-by: Matthias Alphart * Update docs/xknx.md Co-authored-by: Matthias Alphart * Update after revue. * Update after revue (AUTO on tests + default config file). * Fix XKNX/xknx/issues/570 in my own branch See [issue here](https://github.com/XKNX/xknx/issues/570) * fix broken tests from merging master * default parameters and changelog * Tunnel Interface changed - gateway_ip, gateway_port before local_ip, local_port added with default `0` Co-authored-by: Mielune Co-authored-by: Matthias Alphart --- changelog.md | 6 ++ docs/configuration.md | 1 + docs/xknx.md | 26 +++++- examples/example_tunnel.py | 4 +- test/config_tests/config_v1_test.py | 111 ++++++++++++++++++++++++++ test/io_tests/connect_test.py | 52 +++++++++++- test/io_tests/connectionstate_test.py | 61 +++++++++++++- test/io_tests/disconnect_test.py | 57 ++++++++++++- test/io_tests/tunnel_test.py | 4 +- xknx.yaml | 8 ++ xknx/config/config_v1.py | 51 +++++++++++- xknx/io/connect.py | 10 ++- xknx/io/connectionstate.py | 15 +++- xknx/io/disconnect.py | 15 +++- xknx/io/knxip_interface.py | 32 ++++++-- xknx/io/tunnel.py | 16 +++- xknx_v2.yaml | 3 +- 17 files changed, 443 insertions(+), 29 deletions(-) diff --git a/changelog.md b/changelog.md index b86480636..eefef6faf 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,11 @@ ## Unreleased changes +### New Features + +- Add new optional config `route_back` for connections to be able to work behind NAT. +- Read env vars after reading config file to allow dynamic config. + ### HA integration - knx_event: fire also for outgoing telegrams @@ -19,6 +24,7 @@ - return the payload (or None) in RemoteValue.payload_valid(payload) instead of bool - Light colors are represented as `Tuple[Tuple[int,int,int], int]` instead of `Tuple[List[int], int]` now - DPT 3 payloads/values are not invertable anymore. +- Tunnel: Interface changed - gateway_ip, gateway_port before local_ip, local_port added with default `0`. ## 0.16.3 Fan contributions 2021-02-06 diff --git a/docs/configuration.md b/docs/configuration.md index 79b4ffc08..598c3a79b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,6 +24,7 @@ The configuration file can contain three sections. - `gateway_ip` (required) sets the ip address of the KNX tunneling interface - `gateway_port` (optional) sets the port the KNX tunneling interface is listening on - `local_ip` (optional) sets the ip address that is used by xknx + - `route_back` (optional) When True the KNXnet/IP Server shall use the IP address and the port number from the IP package received as the target IP address or port number for the response to the KNXnet/IP Client (for NAT / Docker). Defalut: `False` - `routing` for a UDP multicast connection - `local_ip` (optional) sets the ip address that is used by xknx - Within the `groups` sections all devices are defined. For each type of device more then one section might be specified. You need to append numbers or strings to differentiate the entries, as in the example below. The appended number or string must be unique. diff --git a/docs/xknx.md b/docs/xknx.md index 28670162b..e30bdda23 100644 --- a/docs/xknx.md +++ b/docs/xknx.md @@ -146,5 +146,27 @@ async def main(): asyncio.run(main()) ``` - - +# [](#header-2)Dockerised xknx's app + +To run xknx from a container, set 'route_back=true' or use host network mode. + +Available env variables are: +- XKNX_GENERAL_OWN_ADDRESS +- XKNX_GENERAL_RATE_LIMIT +- XKNX_GENERAL_MULTICAST_GROUP +- XKNX_GENERAL_MULTICAST_PORT +- XKNX_CONNECTION_GATEWAY_IP: Your KNX Gateway IP address +- XKNX_CONNECTION_GATEWAY_PORT: Your KNX Gateway UDP port +- XKNX_CONNECTION_LOCAL_IP +- XKNX_CONNECTION_ROUTE_BACK: Set 'true' to be able to work in a container + +Example of a `docker run` with an xknx based app: + +```bash +docker run --name myapp -d \ + -e XKNX_CONNECTION_GATEWAY_IP='192.168.0.123' \ + -e XKNX_CONNECTION_LOCAL_PORT=12399 \ + -e XKNX_CONNECTION_ROUTE_BACK=true \ + -p 12300:12399/udp \ + myapp:latest +``` diff --git a/examples/example_tunnel.py b/examples/example_tunnel.py index 86a83c00e..dc8dd2998 100644 --- a/examples/example_tunnel.py +++ b/examples/example_tunnel.py @@ -30,9 +30,11 @@ async def main(): tunnel = Tunnel( xknx, - local_ip=gateway.local_ip, gateway_ip=gateway.ip_addr, gateway_port=gateway.port, + local_ip=gateway.local_ip, + local_port=0, + route_back=False, ) await tunnel.connect() diff --git a/test/config_tests/config_v1_test.py b/test/config_tests/config_v1_test.py index d0d224fea..f428ea6c6 100644 --- a/test/config_tests/config_v1_test.py +++ b/test/config_tests/config_v1_test.py @@ -1,5 +1,6 @@ """Unit test for Configuration logic.""" import asyncio +import os import unittest from unittest.mock import patch @@ -608,3 +609,113 @@ def test_config_file_error(self): mock_parse.side_effect = XKNXException() XKNX(config="xknx.yaml") self.assertEqual(mock_err.call_count, 1) + + XKNX_GENERAL_OWN_ADDRESS = "1.2.3" + XKNX_GENERAL_RATE_LIMIT = "20" + XKNX_GENERAL_MULTICAST_GROUP = "1.2.3.4" + XKNX_GENERAL_MULTICAST_PORT = "1111" + + def test_config_general_from_env(self): + os.environ["XKNX_GENERAL_OWN_ADDRESS"] = self.XKNX_GENERAL_OWN_ADDRESS + os.environ["XKNX_GENERAL_RATE_LIMIT"] = self.XKNX_GENERAL_RATE_LIMIT + os.environ["XKNX_GENERAL_MULTICAST_GROUP"] = self.XKNX_GENERAL_MULTICAST_GROUP + os.environ["XKNX_GENERAL_MULTICAST_PORT"] = self.XKNX_GENERAL_MULTICAST_PORT + self.xknx = XKNX(config="xknx.yaml") + del os.environ["XKNX_GENERAL_OWN_ADDRESS"] + del os.environ["XKNX_GENERAL_RATE_LIMIT"] + del os.environ["XKNX_GENERAL_MULTICAST_GROUP"] + del os.environ["XKNX_GENERAL_MULTICAST_PORT"] + self.assertEqual(str(self.xknx.own_address), self.XKNX_GENERAL_OWN_ADDRESS) + self.assertEqual(self.xknx.rate_limit, int(self.XKNX_GENERAL_RATE_LIMIT)) + self.assertEqual(self.xknx.multicast_group, self.XKNX_GENERAL_MULTICAST_GROUP) + self.assertEqual( + self.xknx.multicast_port, int(self.XKNX_GENERAL_MULTICAST_PORT) + ) + + XKNX_CONNECTION_GATEWAY_IP = "192.168.12.34" + XKNX_CONNECTION_GATEWAY_PORT = "1234" + XKNX_CONNECTION_LOCAL_IP = "192.168.11.11" + XKNX_CONNECTION_ROUTE_BACK = "true" + + def test_config_cnx_from_env(self): + os.environ["XKNX_CONNECTION_GATEWAY_IP"] = self.XKNX_CONNECTION_GATEWAY_IP + os.environ["XKNX_CONNECTION_GATEWAY_PORT"] = self.XKNX_CONNECTION_GATEWAY_PORT + os.environ["XKNX_CONNECTION_LOCAL_IP"] = self.XKNX_CONNECTION_LOCAL_IP + os.environ["XKNX_CONNECTION_ROUTE_BACK"] = self.XKNX_CONNECTION_ROUTE_BACK + self.xknx = XKNX(config="xknx.yaml") + del os.environ["XKNX_CONNECTION_GATEWAY_IP"] + del os.environ["XKNX_CONNECTION_GATEWAY_PORT"] + del os.environ["XKNX_CONNECTION_LOCAL_IP"] + del os.environ["XKNX_CONNECTION_ROUTE_BACK"] + self.assertEqual( + self.xknx.connection_config.gateway_ip, self.XKNX_CONNECTION_GATEWAY_IP + ) + self.assertEqual( + self.xknx.connection_config.gateway_port, + int(self.XKNX_CONNECTION_GATEWAY_PORT), + ) + self.assertEqual( + self.xknx.connection_config.local_ip, self.XKNX_CONNECTION_LOCAL_IP + ) + self.assertEqual( + self.xknx.connection_config.route_back, + bool(self.XKNX_CONNECTION_ROUTE_BACK), + ) + + def test_config_cnx_route_back(self): + os.environ["XKNX_CONNECTION_ROUTE_BACK"] = "true" + self.xknx = XKNX(config="xknx.yaml") + del os.environ["XKNX_CONNECTION_ROUTE_BACK"] + self.assertEqual( + self.xknx.connection_config.route_back, + True, + ) + os.environ["XKNX_CONNECTION_ROUTE_BACK"] = "yes" + self.xknx = XKNX(config="xknx.yaml") + del os.environ["XKNX_CONNECTION_ROUTE_BACK"] + self.assertEqual( + self.xknx.connection_config.route_back, + True, + ) + os.environ["XKNX_CONNECTION_ROUTE_BACK"] = "1" + self.xknx = XKNX(config="xknx.yaml") + del os.environ["XKNX_CONNECTION_ROUTE_BACK"] + self.assertEqual( + self.xknx.connection_config.route_back, + True, + ) + os.environ["XKNX_CONNECTION_ROUTE_BACK"] = "on" + self.xknx = XKNX(config="xknx.yaml") + del os.environ["XKNX_CONNECTION_ROUTE_BACK"] + self.assertEqual( + self.xknx.connection_config.route_back, + True, + ) + os.environ["XKNX_CONNECTION_ROUTE_BACK"] = "y" + self.xknx = XKNX(config="xknx.yaml") + del os.environ["XKNX_CONNECTION_ROUTE_BACK"] + self.assertEqual( + self.xknx.connection_config.route_back, + True, + ) + if "XKNX_CONNECTION_ROUTE_BACK" in os.environ: + del os.environ["XKNX_CONNECTION_ROUTE_BACK"] + self.xknx = XKNX(config="xknx.yaml") + self.assertEqual( + self.xknx.connection_config.route_back, + False, + ) + os.environ["XKNX_CONNECTION_ROUTE_BACK"] = "another_string" + self.xknx = XKNX(config="xknx.yaml") + del os.environ["XKNX_CONNECTION_ROUTE_BACK"] + self.assertEqual( + self.xknx.connection_config.route_back, + False, + ) + os.environ["XKNX_CONNECTION_ROUTE_BACK"] = "" + self.xknx = XKNX(config="xknx.yaml") + del os.environ["XKNX_CONNECTION_ROUTE_BACK"] + self.assertEqual( + self.xknx.connection_config.route_back, + False, + ) diff --git a/test/io_tests/connect_test.py b/test/io_tests/connect_test.py index 6e61dfb96..ef52d9e2e 100644 --- a/test/io_tests/connect_test.py +++ b/test/io_tests/connect_test.py @@ -32,7 +32,7 @@ def test_connect(self): """Test connecting from KNX bus.""" xknx = XKNX() udp_client = UDPClient(xknx, ("192.168.1.1", 0), ("192.168.1.2", 1234)) - connect = Connect(xknx, udp_client) + connect = Connect(xknx, udp_client, route_back=False) connect.timeout_in_seconds = 0 self.assertEqual(connect.awaited_response_class, ConnectResponse) @@ -83,3 +83,53 @@ def test_connect(self): self.assertTrue(connect.success) self.assertEqual(connect.communication_channel, 23) self.assertEqual(connect.identifier, 7) + + def test_connect_route_back_true(self): + """Test connecting from KNX bus.""" + xknx = XKNX() + udp_client = UDPClient(xknx, ("192.168.1.1", 0), ("192.168.1.2", 1234)) + connect = Connect(xknx, udp_client, route_back=True) + connect.timeout_in_seconds = 0 + + self.assertEqual(connect.awaited_response_class, ConnectResponse) + + # Expected KNX/IP-Frame: + exp_knxipframe = KNXIPFrame.init_from_body( + ConnectRequest(xknx, request_type=ConnectRequestType.TUNNEL_CONNECTION) + ) + with patch("xknx.io.UDPClient.send") as mock_udp_send, patch( + "xknx.io.UDPClient.getsockname" + ) as mock_udp_getsockname: + mock_udp_getsockname.return_value = ("192.168.1.3", 4321) + self.loop.run_until_complete(connect.start()) + mock_udp_send.assert_called_with(exp_knxipframe) + + # Response KNX/IP-Frame with wrong type + 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) + mock_warning.assert_called_with("Could not understand knxipframe") + + # Response KNX/IP-Frame with error: + err_knxipframe = KNXIPFrame(xknx) + 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) + mock_warning.assert_called_with( + "Error: KNX bus responded to request of type '%s' with error in '%s': %s", + type(connect).__name__, + type(err_knxipframe.body).__name__, + ErrorCode.E_CONNECTION_ID, + ) + + # Correct Response KNX/IP-Frame: + res_knxipframe = KNXIPFrame(xknx) + res_knxipframe.init(KNXIPServiceType.CONNECT_RESPONSE) + res_knxipframe.body.communication_channel = 23 + res_knxipframe.body.identifier = 7 + connect.response_rec_callback(res_knxipframe, None) + self.assertTrue(connect.success) + self.assertEqual(connect.communication_channel, 23) + self.assertEqual(connect.identifier, 7) diff --git a/test/io_tests/connectionstate_test.py b/test/io_tests/connectionstate_test.py index e0fc124ab..c2675e1b3 100644 --- a/test/io_tests/connectionstate_test.py +++ b/test/io_tests/connectionstate_test.py @@ -32,7 +32,9 @@ def test_connectionstate(self): xknx = XKNX() communication_channel_id = 23 udp_client = UDPClient(xknx, ("192.168.1.1", 0), ("192.168.1.2", 1234)) - connectionstate = ConnectionState(xknx, udp_client, communication_channel_id) + connectionstate = ConnectionState( + xknx, udp_client, communication_channel_id, route_back=False + ) connectionstate.timeout_in_seconds = 0 self.assertEqual( @@ -82,3 +84,60 @@ def test_connectionstate(self): res_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_RESPONSE) connectionstate.response_rec_callback(res_knxipframe, None) self.assertTrue(connectionstate.success) + + def test_connectionstate_route_back_true(self): + """Test connectionstateing from KNX bus.""" + xknx = XKNX() + communication_channel_id = 23 + udp_client = UDPClient(xknx, ("192.168.1.1", 0), ("192.168.1.2", 1234)) + connectionstate = ConnectionState( + xknx, udp_client, communication_channel_id, route_back=True + ) + connectionstate.timeout_in_seconds = 0 + + self.assertEqual( + connectionstate.awaited_response_class, ConnectionStateResponse + ) + self.assertEqual( + connectionstate.communication_channel_id, communication_channel_id + ) + + # Expected KNX/IP-Frame: + exp_knxipframe = KNXIPFrame.init_from_body( + ConnectionStateRequest( + xknx, + communication_channel_id=communication_channel_id, + ) + ) + with patch("xknx.io.UDPClient.send") as mock_udp_send, patch( + "xknx.io.UDPClient.getsockname" + ) as mock_udp_getsockname: + mock_udp_getsockname.return_value = ("192.168.1.3", 4321) + self.loop.run_until_complete(connectionstate.start()) + mock_udp_send.assert_called_with(exp_knxipframe) + + # Response KNX/IP-Frame with wrong type + 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) + mock_warning.assert_called_with("Could not understand knxipframe") + + # Response KNX/IP-Frame with error: + err_knxipframe = KNXIPFrame(xknx) + 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) + mock_warning.assert_called_with( + "Error: KNX bus responded to request of type '%s' with error in '%s': %s", + type(connectionstate).__name__, + type(err_knxipframe.body).__name__, + ErrorCode.E_CONNECTION_ID, + ) + + # Correct Response KNX/IP-Frame: + res_knxipframe = KNXIPFrame(xknx) + res_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_RESPONSE) + connectionstate.response_rec_callback(res_knxipframe, None) + self.assertTrue(connectionstate.success) diff --git a/test/io_tests/disconnect_test.py b/test/io_tests/disconnect_test.py index fc0a6ba64..855f15a60 100644 --- a/test/io_tests/disconnect_test.py +++ b/test/io_tests/disconnect_test.py @@ -32,7 +32,9 @@ def test_disconnect(self): xknx = XKNX() communication_channel_id = 23 udp_client = UDPClient(xknx, ("192.168.1.1", 0), ("192.168.1.2", 1234)) - disconnect = Disconnect(xknx, udp_client, communication_channel_id) + disconnect = Disconnect( + xknx, udp_client, communication_channel_id, route_back=False + ) disconnect.timeout_in_seconds = 0 self.assertEqual(disconnect.awaited_response_class, DisconnectResponse) @@ -78,3 +80,56 @@ def test_disconnect(self): res_knxipframe.init(KNXIPServiceType.DISCONNECT_RESPONSE) disconnect.response_rec_callback(res_knxipframe, None) self.assertTrue(disconnect.success) + + def test_disconnect_route_back_true(self): + """Test disconnecting from KNX bus.""" + xknx = XKNX() + communication_channel_id = 23 + udp_client = UDPClient(xknx, ("192.168.1.1", 0), ("192.168.1.2", 1234)) + disconnect = Disconnect( + xknx, udp_client, communication_channel_id, route_back=True + ) + disconnect.timeout_in_seconds = 0 + + self.assertEqual(disconnect.awaited_response_class, DisconnectResponse) + self.assertEqual(disconnect.communication_channel_id, communication_channel_id) + + # Expected KNX/IP-Frame: + exp_knxipframe = KNXIPFrame.init_from_body( + DisconnectRequest( + xknx, + communication_channel_id=communication_channel_id, + ) + ) + with patch("xknx.io.UDPClient.send") as mock_udp_send, patch( + "xknx.io.UDPClient.getsockname" + ) as mock_udp_getsockname: + mock_udp_getsockname.return_value = ("192.168.1.3", 4321) + self.loop.run_until_complete(disconnect.start()) + mock_udp_send.assert_called_with(exp_knxipframe) + + # Response KNX/IP-Frame with wrong type + 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) + mock_warning.assert_called_with("Could not understand knxipframe") + + # Response KNX/IP-Frame with error: + err_knxipframe = KNXIPFrame(xknx) + 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) + mock_warning.assert_called_with( + "Error: KNX bus responded to request of type '%s' with error in '%s': %s", + type(disconnect).__name__, + type(err_knxipframe.body).__name__, + ErrorCode.E_CONNECTION_ID, + ) + + # Correct Response KNX/IP-Frame: + res_knxipframe = KNXIPFrame(xknx) + res_knxipframe.init(KNXIPServiceType.DISCONNECT_RESPONSE) + disconnect.response_rec_callback(res_knxipframe, None) + self.assertTrue(disconnect.success) diff --git a/test/io_tests/tunnel_test.py b/test/io_tests/tunnel_test.py index c8f85dab7..4a86dedaa 100644 --- a/test/io_tests/tunnel_test.py +++ b/test/io_tests/tunnel_test.py @@ -20,12 +20,14 @@ def setUp(self): self.tg_received_mock = Mock() self.tunnel = Tunnel( self.xknx, - local_ip="192.168.1.1", gateway_ip="192.168.1.2", gateway_port=3671, + local_ip="192.168.1.1", + local_port=0, telegram_received_callback=self.tg_received_mock, auto_reconnect=False, auto_reconnect_wait=3, + route_back=False, ) def tearDown(self): diff --git a/xknx.yaml b/xknx.yaml index f8be7300c..a1a60161f 100644 --- a/xknx.yaml +++ b/xknx.yaml @@ -5,7 +5,15 @@ general: multicast_port: 1337 connection: + # One of them: auto, tunneling or routing auto: + #tunneling: + # local_ip: "192.168.111.201" + # route_back: false + # gateway_ip: "192.168.0.202" + # gateway_port: 3671 + #routing: + # local_ip: "192.168.111.201" groups: binary_sensor: diff --git a/xknx/config/config_v1.py b/xknx/config/config_v1.py index 02b9dfd2a..f8e84f78f 100644 --- a/xknx/config/config_v1.py +++ b/xknx/config/config_v1.py @@ -6,6 +6,7 @@ """ from enum import Enum import logging +import os from xknx.devices import ( BinarySensor, @@ -43,10 +44,12 @@ def __init__(self, xknx): self.xknx = xknx def parse(self, doc): - """Parse the config.""" + """Parse the config and read ENV vars.""" self.parse_general(doc) self.parse_connection(doc) self.parse_groups(doc) + self.env_general() + self.env_connection() def parse_general(self, doc): """Parse the general section of xknx.yaml.""" @@ -60,6 +63,25 @@ def parse_general(self, doc): if "multicast_port" in doc["general"]: self.xknx.multicast_port = doc["general"]["multicast_port"] + def env_general(self): + """Get Env Vars for the general section.""" + own_address = os.getenv("XKNX_GENERAL_OWN_ADDRESS", default=None) + if own_address: + logger.debug("XKNX_GENERAL_OWN_ADDRESS overwrite from env") + self.xknx.own_address = IndividualAddress(own_address) + rate_limit = os.getenv("XKNX_GENERAL_RATE_LIMIT", default=None) + if rate_limit: + logger.debug("XKNX_GENERAL_RATE_LIMIT overwrite from env") + self.xknx.rate_limit = int(rate_limit) + multicast_group = os.getenv("XKNX_GENERAL_MULTICAST_GROUP", default=None) + if multicast_group: + logger.debug("XKNX_GENERAL_MULTICAST_GROUP overwrite from env") + self.xknx.multicast_group = multicast_group + multicast_port = os.getenv("XKNX_GENERAL_MULTICAST_PORT", default=None) + if multicast_port: + logger.debug("XKNX_GENERAL_MULTICAST_PORT overwrite from env") + self.xknx.multicast_port = int(multicast_port) + def parse_connection(self, doc): """Parse the connection section of xknx.yaml.""" if "connection" in doc and hasattr(doc["connection"], "__iter__"): @@ -94,8 +116,35 @@ def _parse_connection_prefs(self, conn_type: ConnectionType, prefs) -> None: connection_config.gateway_port = value elif pref == "local_ip": connection_config.local_ip = value + elif pref == "route_back": + connection_config.route_back = value self.xknx.connection_config = connection_config + def env_connection(self): + """Read config from env vars.""" + gateway_ip = os.getenv("XKNX_CONNECTION_GATEWAY_IP", default=None) + if gateway_ip: + logger.debug("XKNX_CONNECTION_GATEWAY_IP overwrite from env") + self.xknx.connection_config.gateway_ip = gateway_ip + gateway_port = os.getenv("XKNX_CONNECTION_GATEWAY_PORT", default=None) + if gateway_port: + logger.debug("XKNX_CONNECTION_GATEWAY_PORT overwrite from env") + self.xknx.connection_config.gateway_port = int(gateway_port) + local_ip = os.getenv("XKNX_CONNECTION_LOCAL_IP", default=None) + if local_ip: + logger.debug("XKNX_CONNECTION_LOCAL_IP overwrite from env") + self.xknx.connection_config.local_ip = local_ip + route_back = os.getenv("XKNX_CONNECTION_ROUTE_BACK", default=None) + if route_back: + logger.debug("XKNX_CONNECTION_ROUTE_BACK overwrite from env") + self.xknx.connection_config.route_back = route_back.lower() in [ + "true", + "1", + "y", + "yes", + "on", + ] + def parse_groups(self, doc): """Parse the group section of xknx.yaml.""" if "groups" in doc and hasattr(doc["groups"], "__iter__"): diff --git a/xknx/io/connect.py b/xknx/io/connect.py index 7cefcef59..b84069e5b 100644 --- a/xknx/io/connect.py +++ b/xknx/io/connect.py @@ -20,18 +20,22 @@ class Connect(RequestResponse): """Class to send a ConnectRequest and wait for ConnectResponse..""" - def __init__(self, xknx: "XKNX", udp_client: "UDPClient"): + def __init__(self, xknx: "XKNX", udp_client: "UDPClient", route_back: bool = False): """Initialize Connect class.""" self.udp_client = udp_client + self.route_back = route_back super().__init__(xknx, self.udp_client, ConnectResponse) self.communication_channel = 0 self.identifier = 0 def create_knxipframe(self) -> KNXIPFrame: """Create KNX/IP Frame object to be sent to device.""" - (local_addr, local_port) = self.udp_client.getsockname() # set control_endpoint and data_endpoint to the same udp_connection - endpoint = HPAI(ip_addr=local_addr, port=local_port) + if self.route_back: + endpoint = HPAI() + else: + (local_addr, local_port) = self.udp_client.getsockname() + endpoint = HPAI(ip_addr=local_addr, port=local_port) connect_request = ConnectRequest( self.xknx, request_type=ConnectRequestType.TUNNEL_CONNECTION, diff --git a/xknx/io/connectionstate.py b/xknx/io/connectionstate.py index 599ec0ec0..8948b8a5b 100644 --- a/xknx/io/connectionstate.py +++ b/xknx/io/connectionstate.py @@ -16,10 +16,15 @@ class ConnectionState(RequestResponse): """Class to send ConnectonStateRequest and wait for ConnectionStateResponse.""" def __init__( - self, xknx: "XKNX", udp_client: "UDPClient", communication_channel_id: int + self, + xknx: "XKNX", + udp_client: "UDPClient", + communication_channel_id: int, + route_back: bool = False, ): """Initialize ConnectionState class.""" self.udp_client = udp_client + self.route_back = route_back super().__init__( xknx, self.udp_client, @@ -30,10 +35,14 @@ def __init__( def create_knxipframe(self) -> KNXIPFrame: """Create KNX/IP Frame object to be sent to device.""" - (local_addr, local_port) = self.udpclient.getsockname() + if self.route_back: + endpoint = HPAI() + else: + (local_addr, local_port) = self.udpclient.getsockname() + endpoint = HPAI(ip_addr=local_addr, port=local_port) connectionstate_request = ConnectionStateRequest( self.xknx, communication_channel_id=self.communication_channel_id, - control_endpoint=HPAI(ip_addr=local_addr, port=local_port), + control_endpoint=endpoint, ) return KNXIPFrame.init_from_body(connectionstate_request) diff --git a/xknx/io/disconnect.py b/xknx/io/disconnect.py index d01caeda9..df5bef409 100644 --- a/xknx/io/disconnect.py +++ b/xknx/io/disconnect.py @@ -15,20 +15,29 @@ class Disconnect(RequestResponse): """Class to send a DisconnectRequest and wait for a DisconnectResponse.""" def __init__( - self, xknx: "XKNX", udp_client: "UDPClient", communication_channel_id: int + self, + xknx: "XKNX", + udp_client: "UDPClient", + communication_channel_id: int, + route_back: bool = False, ): """Initialize Disconnect class.""" self.xknx = xknx self.udp_client = udp_client + self.route_back = route_back super().__init__(xknx, self.udp_client, DisconnectResponse) self.communication_channel_id = communication_channel_id def create_knxipframe(self) -> KNXIPFrame: """Create KNX/IP Frame object to be sent to device.""" - (local_addr, local_port) = self.udpclient.getsockname() + if self.route_back: + endpoint = HPAI() + else: + (local_addr, local_port) = self.udpclient.getsockname() + endpoint = HPAI(ip_addr=local_addr, port=local_port) disconnect_request = DisconnectRequest( self.xknx, communication_channel_id=self.communication_channel_id, - control_endpoint=HPAI(ip_addr=local_addr, port=local_port), + control_endpoint=endpoint, ) return KNXIPFrame.init_from_body(disconnect_request) diff --git a/xknx/io/knxip_interface.py b/xknx/io/knxip_interface.py index 677442053..510b17b2b 100644 --- a/xknx/io/knxip_interface.py +++ b/xknx/io/knxip_interface.py @@ -48,6 +48,8 @@ class ConnectionConfig: * local_ip: Local ip of the interface though which KNXIPInterface should connect. * gateway_ip: IP of KNX/IP tunneling device. * gateway_port: Port of KNX/IP tunneling device. + * route_back: the KNXnet/IP Server shall use the IP address and the port number in the IP package + received as the target IP address or port number for the response to the KNXnet/IP Client. * auto_reconnect: Auto reconnect to KNX/IP tunneling device if connection cannot be established. * auto_reconnect_wait: Wait n seconds before trying to reconnect to KNX/IP tunneling device. * scan_filter: For AUTOMATIC connection, limit scan with the given filter @@ -59,8 +61,10 @@ def __init__( self, connection_type: ConnectionType = ConnectionType.AUTOMATIC, local_ip: Optional[str] = None, + local_port: int = 0, gateway_ip: Optional[str] = None, gateway_port: int = DEFAULT_MCAST_PORT, + route_back: bool = False, auto_reconnect: bool = False, auto_reconnect_wait: int = 3, scan_filter: GatewayScanFilter = GatewayScanFilter(), @@ -69,8 +73,10 @@ def __init__( # pylint: disable=too-many-arguments self.connection_type = connection_type self.local_ip = local_ip + self.local_port = local_port self.gateway_ip = gateway_ip self.gateway_port = gateway_port + self.route_back = route_back self.auto_reconnect = auto_reconnect self.auto_reconnect_wait = auto_reconnect_wait if connection_type == ConnectionType.TUNNELING: @@ -109,11 +115,13 @@ async def start(self) -> None: and self.connection_config.gateway_ip is not None ): await self.start_tunnelling( - self.connection_config.local_ip, - self.connection_config.gateway_ip, - self.connection_config.gateway_port, - self.connection_config.auto_reconnect, - self.connection_config.auto_reconnect_wait, + local_ip=self.connection_config.local_ip, + local_port=self.connection_config.local_port, + gateway_ip=self.connection_config.gateway_ip, + gateway_port=self.connection_config.gateway_port, + auto_reconnect=self.connection_config.auto_reconnect, + auto_reconnect_wait=self.connection_config.auto_reconnect_wait, + route_back=self.connection_config.route_back, ) else: await self.start_automatic(self.connection_config.scan_filter) @@ -135,10 +143,12 @@ async def start_automatic(self, scan_filter: GatewayScanFilter) -> None: if gateway.supports_tunnelling and scan_filter.routing is not True: await self.start_tunnelling( local_interface_ip, + self.connection_config.local_port, gateway.ip_addr, gateway.port, self.connection_config.auto_reconnect, self.connection_config.auto_reconnect_wait, + route_back=self.connection_config.route_back, ) elif gateway.supports_routing: await self.start_routing(local_interface_ip) @@ -146,10 +156,12 @@ async def start_automatic(self, scan_filter: GatewayScanFilter) -> None: async def start_tunnelling( self, local_ip: Optional[str], + local_port: int, gateway_ip: str, gateway_port: int, auto_reconnect: bool, auto_reconnect_wait: int, + route_back: bool, ) -> None: """Start KNX/IP tunnel.""" # pylint: disable=too-many-arguments @@ -158,13 +170,19 @@ async def start_tunnelling( local_ip = self.find_local_ip(gateway_ip=gateway_ip) validate_ip(local_ip, address_name="Local IP address") logger.debug( - "Starting tunnel from %s to %s:%s", local_ip, gateway_ip, gateway_port + "Starting tunnel from %s:%s to %s:%s", + local_ip, + local_port, + gateway_ip, + gateway_port, ) self.interface = Tunnel( self.xknx, - local_ip=local_ip, gateway_ip=gateway_ip, gateway_port=gateway_port, + local_ip=local_ip, + local_port=local_port, + route_back=route_back, telegram_received_callback=self.telegram_received, auto_reconnect=auto_reconnect, auto_reconnect_wait=auto_reconnect_wait, diff --git a/xknx/io/tunnel.py b/xknx/io/tunnel.py index bc0d527ba..3ac8e8e18 100644 --- a/xknx/io/tunnel.py +++ b/xknx/io/tunnel.py @@ -43,9 +43,11 @@ class Tunnel(Interface): def __init__( self, xknx: "XKNX", - local_ip: str, gateway_ip: str, gateway_port: int, + local_ip: str, + local_port: int = 0, + route_back: bool = False, telegram_received_callback: Optional["TelegramCallbackType"] = None, auto_reconnect: bool = False, auto_reconnect_wait: int = 3, @@ -53,9 +55,11 @@ def __init__( """Initialize Tunnel class.""" # pylint: disable=too-many-arguments self.xknx = xknx - self.local_ip = local_ip self.gateway_ip = gateway_ip self.gateway_port = gateway_port + self.local_ip = local_ip + self.local_port = local_port + self.route_back = route_back self.telegram_received_callback = telegram_received_callback self.udp_client: UDPClient @@ -77,7 +81,9 @@ def __init__( def init_udp_client(self) -> None: """Initialize udp_client.""" self.udp_client = UDPClient( - self.xknx, (self.local_ip, 0), (self.gateway_ip, self.gateway_port) + self.xknx, + (self.local_ip, self.local_port), + (self.gateway_ip, self.gateway_port), ) self.udp_client.register_callback( @@ -158,7 +164,7 @@ async def disconnect(self) -> None: async def _connect_request(self) -> bool: """Connect to tunnelling server. Set communication_channel and src_address.""" - connect = Connect(self.xknx, self.udp_client) + connect = Connect(self.xknx, self.udp_client, route_back=self.route_back) await connect.start() if connect.success: self.communication_channel = connect.communication_channel @@ -182,6 +188,7 @@ async def _connectionstate_request(self) -> bool: self.xknx, self.udp_client, communication_channel_id=self.communication_channel, + route_back=self.route_back, ) await conn_state.start() return conn_state.success @@ -193,6 +200,7 @@ async def _disconnect_request(self, ignore_error: bool = False) -> None: self.xknx, self.udp_client, communication_channel_id=self.communication_channel, + route_back=self.route_back, ) await disconnect.start() if not disconnect.success and not ignore_error: diff --git a/xknx_v2.yaml b/xknx_v2.yaml index f564d212e..7693a1ccb 100644 --- a/xknx_v2.yaml +++ b/xknx_v2.yaml @@ -8,7 +8,8 @@ multicast_group: '224.1.2.3' multicast_port: 1337 connection: # optional type: tunneling # or routing|auto - local_ip: "192.168.0.201" + local_ip: "192.168.111.201" + route_back: false host: "192.168.0.202" port: 1337 binary_sensor: