""" KNXIPInterface manages KNX/IP Tunneling or Routing connections. * It searches for available devices and connects with the corresponding connect method. * It passes KNX telegrams from the network and * provides callbacks after having received a telegram from the network. """ from __future__ import annotations import asyncio from collections.abc import Coroutine import logging import threading from typing import TYPE_CHECKING, Any, TypeVar from xknx.cemi import CEMIFrame from xknx.exceptions import ( CommunicationError, InvalidSecureConfiguration, XKNXException, ) from xknx.io import util from xknx.secure.keyring import InterfaceType, Keyring, XMLInterface, load_keyring from xknx.telegram import IndividualAddress from .connection import ConnectionConfig, ConnectionType from .const import DEFAULT_INDIVIDUAL_ADDRESS from .gateway_scanner import GatewayDescriptor, GatewayScanner from .routing import Routing, SecureRouting from .self_description import request_description from .tunnel import SecureTunnel, TCPTunnel, UDPTunnel, _Tunnel if TYPE_CHECKING: import concurrent from xknx.xknx import XKNX from .interface import Interface logger = logging.getLogger("xknx.log") T = TypeVar("T") # pylint: disable=invalid-name def knx_interface_factory( xknx: XKNX, connection_config: ConnectionConfig, ) -> KNXIPInterface: """Create KNX/IP interface from config.""" if connection_config.threaded: return KNXIPInterfaceThreaded(xknx=xknx, connection_config=connection_config) return KNXIPInterface(xknx=xknx, connection_config=connection_config) class KNXIPInterface: """Class for managing KNX/IP Tunneling or Routing connections.""" __slots__ = ("_gateway_info", "_interface", "connection_config", "xknx") def __init__( self, xknx: XKNX, connection_config: ConnectionConfig | None = None, ) -> None: """Initialize KNXIPInterface class.""" self.xknx = xknx self.connection_config = connection_config or ConnectionConfig() self._gateway_info: GatewayDescriptor | None = None self._interface: Interface | None = None async def start(self) -> None: """Start KNX/IP interface. Raise `CommunicationError` if connection fails.""" await self._start() 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 secure_config.keyring is not None: keyring = secure_config.keyring elif ( secure_config.knxkeys_file_path is not None and secure_config.knxkeys_password is not None ): keyring = await load_keyring( secure_config.knxkeys_file_path, secure_config.knxkeys_password, ) self.xknx.cemi_handler.data_secure_init(keyring=keyring) if self.connection_config.connection_type == ConnectionType.ROUTING: await self._start_routing(local_ip=local_ip) elif self.connection_config.connection_type == ConnectionType.ROUTING_SECURE: await self._start_secure_routing(local_ip=local_ip, keyring=keyring) elif ( self.connection_config.connection_type == ConnectionType.TUNNELING and gateway_ip is not None ): await self._start_tunnelling_udp( 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 gateway_ip is not None ): await self._start_tunnelling_tcp( gateway_ip=gateway_ip, gateway_port=self.connection_config.gateway_port, ) elif ( self.connection_config.connection_type == ConnectionType.TUNNELING_TCP_SECURE and gateway_ip is not None ): await self._start_secure_tunnelling_tcp( gateway_ip=gateway_ip, gateway_port=self.connection_config.gateway_port, keyring=keyring, ) else: await self._start_automatic(local_ip=local_ip, keyring=keyring) 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: 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=local_ip, scan_filter=self.connection_config.scan_filter, ).async_scan(): 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: if gateway.supports_tunnelling_tcp: if gateway.tunnelling_requires_secure: await self._start_secure_tunnelling_tcp( gateway_ip=gateway.ip_addr, gateway_port=gateway.port, gateway_descriptor=gateway, keyring=keyring, ) else: await self._start_tunnelling_tcp( gateway_ip=gateway.ip_addr, gateway_port=gateway.port, ) elif ( gateway.supports_tunnelling and not gateway.tunnelling_requires_secure ): 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(local_ip=local_ip) except CommunicationError as ex: logger.debug("Skipping %s. Could not connect: %s", gateway, ex) continue except InvalidSecureConfiguration as ex: logger.debug( "Skipping %s. Invalid secure configuration: %s", gateway, ex ) continue else: self._gateway_info = gateway break else: raise CommunicationError( f"No usable KNX/IP device found{' in keyring file' if keyring_host_filter else ''}." ) async def _start_tunnelling_tcp( self, gateway_ip: str, gateway_port: int, ) -> None: """Start KNX/IP TCP tunnel.""" tunnel_address = self.connection_config.individual_address logger.debug( "Starting tunnel to %s:%s over TCP%s", gateway_ip, gateway_port, f" requesting individual address {tunnel_address}" if tunnel_address else "", ) self._interface = TCPTunnel( self.xknx, gateway_ip=gateway_ip, gateway_port=gateway_port, individual_address=tunnel_address, cemi_received_callback=self.cemi_received, auto_reconnect=self.connection_config.auto_reconnect, auto_reconnect_wait=self.connection_config.auto_reconnect_wait, ) await self._interface.connect() async def _start_secure_tunnelling_tcp( self, gateway_ip: str, gateway_port: int, gateway_descriptor: GatewayDescriptor | None = None, keyring: Keyring | None = None, ) -> None: """Start KNX/IP Secure TCP tunnel.""" if (secure_config := self.connection_config.secure_config) is None: raise InvalidSecureConfiguration("SecureConfig missing in ConnectionConfig") if ( secure_config.user_id is not None and secure_config.user_password is not None ): user_id = secure_config.user_id user_password = secure_config.user_password device_authentication_password = ( secure_config.device_authentication_password ) elif keyring is not None: _gateway = gateway_descriptor or await request_description( gateway_ip=gateway_ip, gateway_port=gateway_port ) xml_interface = self._get_tunnel_interface_from_keyring( keyring=keyring, gateway_descriptor=_gateway, individual_address=self.connection_config.individual_address, config_user_id=secure_config.user_id, ) if ( xml_interface.user_id is None or xml_interface.decrypted_password is None ): raise InvalidSecureConfiguration( f"No user_id or password found for tunnel {xml_interface.individual_address}" ) user_id = xml_interface.user_id user_password = xml_interface.decrypted_password device_authentication_password = xml_interface.decrypted_authentication else: raise InvalidSecureConfiguration( "No `user_id` or `knxkeys_file_path` and password found in secure configuration" ) logger.debug( "Starting secure tunnel to %s:%s over TCP", gateway_ip, gateway_port, ) self._interface = SecureTunnel( self.xknx, gateway_ip=gateway_ip, gateway_port=gateway_port, auto_reconnect=self.connection_config.auto_reconnect, auto_reconnect_wait=self.connection_config.auto_reconnect_wait, user_id=user_id, user_password=user_password, device_authentication_password=device_authentication_password, cemi_received_callback=self.cemi_received, ) await self._interface.connect() @staticmethod def _get_tunnel_interface_from_keyring( keyring: Keyring, gateway_descriptor: GatewayDescriptor, individual_address: IndividualAddress | None = None, config_user_id: int | None = None, ) -> XMLInterface: """ Get tunnel interface from keyring. Precedence: configured individual address > configured user id > first free tunnel interface """ if individual_address: if xml_interface := keyring.get_tunnel_interface_by_individual_address( individual_address ): return xml_interface raise InvalidSecureConfiguration( f"Interface with individual address {individual_address} not found in keyfile" ) if not (host_ia := gateway_descriptor.individual_address): raise InvalidSecureConfiguration( "Gateway does not provide individual address" ) if config_user_id: if xml_interface := keyring.get_tunnel_interface_by_host_and_user_id( host=host_ia, user_id=config_user_id ): return xml_interface raise InvalidSecureConfiguration( f"Interface of host {host_ia} with user_id {config_user_id} not found in keyfile" ) _free_slots = [ tunnel_ia for tunnel_ia, slot in gateway_descriptor.tunnelling_slots.items() if slot.usable and slot.free ] if not _free_slots: raise InvalidSecureConfiguration( f"No free tunnelling slots found on gateway {host_ia}" ) for _tunnel_ia in _free_slots: if xml_interface := keyring.get_tunnel_interface_by_individual_address( tunnelling_slot=_tunnel_ia ): return xml_interface raise InvalidSecureConfiguration( "No credentials for any free tunnelling slot found in keyfile" ) async def _start_tunnelling_udp( self, gateway_ip: str, gateway_port: int, local_ip: str | None, ) -> None: """Start KNX/IP UDP tunnel.""" 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.") logger.debug( "Starting tunnel from %s:%s to %s:%s", local_ip, local_port, gateway_ip, gateway_port, ) self._interface = UDPTunnel( self.xknx, gateway_ip=gateway_ip, gateway_port=gateway_port, local_ip=local_ip, local_port=local_port, route_back=route_back, cemi_received_callback=self.cemi_received, auto_reconnect=self.connection_config.auto_reconnect, auto_reconnect_wait=self.connection_config.auto_reconnect_wait, ) await self._interface.connect() 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 = local_ip or await util.get_default_local_ip() if local_ip is None: raise XKNXException("No network interface found.") individual_address = ( self.connection_config.individual_address or DEFAULT_INDIVIDUAL_ADDRESS ) logger.debug( "Starting Routing from %s as %s via %s:%s", local_ip, individual_address, multicast_group, multicast_port, ) self._interface = Routing( self.xknx, individual_address=individual_address, cemi_received_callback=self.cemi_received, local_ip=local_ip, multicast_group=multicast_group, multicast_port=multicast_port, ) await self._interface.connect() async def _start_secure_routing( self, local_ip: str | None, keyring: Keyring | None = None, ) -> None: """Start KNX/IP Routing.""" if self.connection_config.secure_config is None: raise InvalidSecureConfiguration("SecureConfig missing in ConnectionConfig") backbone_key = self.connection_config.secure_config.backbone_key latency_ms = self.connection_config.secure_config.latency_ms multicast_group = self.connection_config.multicast_group multicast_port = self.connection_config.multicast_port if keyring is not None and keyring.backbone is not None: # default to manually configured values backbone_key = backbone_key or keyring.backbone.decrypted_key latency_ms = latency_ms or keyring.backbone.latency if keyring.backbone.multicast_address: multicast_group = keyring.backbone.multicast_address if not backbone_key: raise InvalidSecureConfiguration( "No backbone key found in secure configuration" ) local_ip = local_ip or await util.get_default_local_ip() if local_ip is None: raise XKNXException("No network interface found.") individual_address = ( self.connection_config.individual_address or DEFAULT_INDIVIDUAL_ADDRESS ) logger.debug( "Starting Secure Routing from %s as %s via %s:%s", local_ip, individual_address, multicast_group, multicast_port, ) self._interface = SecureRouting( self.xknx, individual_address=individual_address, cemi_received_callback=self.cemi_received, local_ip=local_ip, backbone_key=backbone_key, latency_ms=latency_ms, multicast_group=multicast_group, multicast_port=multicast_port, ) await self._interface.connect() async def stop(self) -> None: """Stop connected interfae (either Tunneling or Routing).""" if self._interface is not None: await self._interface.disconnect() self._interface = None def cemi_received(self, raw_cemi: bytes) -> None: """Pass raw CEMIFrame data to CEMIHandler. Callback for having received CEMIFrames.""" self.xknx.cemi_handler.handle_raw_cemi(raw_cemi) async def send_cemi(self, cemi: CEMIFrame) -> None: """Send CEMIFrame to connected device (either Tunneling or Routing).""" # to ease converting L_Data.req CEMI frames to L_Data.ind and local confirmation # in routing we pass `CEMIFrame` from the CEMIHandler here, instead of raw bytes if self._interface is None: raise CommunicationError("KNX/IP interface not connected") return await self._interface.send_cemi(cemi) async def gateway_info(self) -> GatewayDescriptor | None: """Get gateway descriptor from interface.""" if self._gateway_info is not None: return self._gateway_info if isinstance(self._interface, _Tunnel): return await self._interface.request_description() return None class KNXIPInterfaceThreaded(KNXIPInterface): """Class for managing KNX/IP Tunneling or Routing connections.""" __slots__ = ("_connection_thread", "_main_loop", "_thread_loop") def __init__( self, xknx: XKNX, connection_config: ConnectionConfig | None = None, ) -> None: """Initialize KNXIPInterface class.""" super().__init__(xknx, connection_config) self._main_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() self._connection_thread: threading.Thread | None = None self._thread_loop: asyncio.AbstractEventLoop | None = None def _init_connection_thread(self) -> None: """Start KNX/IP interface in its own thread.""" loop_loaded = threading.Event() self._connection_thread = threading.Thread( target=self._init_connection_loop, args=[loop_loaded], name="KNX Interface", daemon=True, ) self._connection_thread.start() loop_loaded.wait() # wait for the thread to initialize its loop def _init_connection_loop(self, loop_loaded: threading.Event) -> None: """Start KNX/IP interface in its own thread.""" self._thread_loop = asyncio.new_event_loop() asyncio.set_event_loop(self._thread_loop) loop_loaded.set() self._thread_loop.run_forever() async def _await_from_connection_thread(self, coro: Coroutine[Any, Any, T]) -> T: """Await coroutine in different thread.""" if self._thread_loop is None: raise CommunicationError("KNX connection thread not initialized.") fut = asyncio.run_coroutine_threadsafe(coro, self._thread_loop) finished = threading.Event() def fut_finished_cb(_: concurrent.futures.Future[T]) -> None: """Fire threading.Event when the future is finished.""" finished.set() fut.add_done_callback(fut_finished_cb) # wait on that event in an executor, yielding control to _main_loop await self._main_loop.run_in_executor(None, finished.wait) return fut.result() async def start(self) -> None: """Start KNX/IP interface.""" if self._connection_thread is not None or self._thread_loop is not None: raise CommunicationError("KNX threaded interface already initialized.") await self._main_loop.run_in_executor(None, self._init_connection_thread) try: return await self._await_from_connection_thread(self._start()) except CommunicationError: await self.stop() raise async def stop(self) -> None: """Stop connected interface (either Tunneling or Routing).""" if self._interface is not None: await self._await_from_connection_thread(self._interface.disconnect()) self._interface = None if self._thread_loop is not None: self._thread_loop.call_soon_threadsafe(self._thread_loop.stop) self._thread_loop = None if self._connection_thread is not None: self._connection_thread.join() self._connection_thread = None def cemi_received(self, raw_cemi: bytes) -> None: """Pass CEMIFrame to CEMIHandler. Callback for having received CEMIFrames.""" self._main_loop.call_soon_threadsafe(super().cemi_received, raw_cemi) async def send_cemi(self, cemi: CEMIFrame) -> None: """Send CEMIFrame to connected device (either Tunneling or Routing).""" if self._interface is None: raise CommunicationError("KNX/IP interface not connected") return await self._await_from_connection_thread(self._interface.send_cemi(cemi)) async def gateway_info(self) -> GatewayDescriptor | None: """Get gateway descriptor from interface.""" if self._gateway_info is not None: return self._gateway_info if isinstance(self._interface, _Tunnel): return await self._await_from_connection_thread( self._interface.request_description() ) return None