diff --git a/examples/example_disconnect.py b/examples/example_disconnect.py index 717390be12..73eadfdbb8 100644 --- a/examples/example_disconnect.py +++ b/examples/example_disconnect.py @@ -9,20 +9,22 @@ async def main(): """Search for a Tunelling device, walk through all possible channels and disconnect them.""" xknx = XKNX() gatewayscanner = GatewayScanner(xknx) - await gatewayscanner.start() + gateways = await gatewayscanner.scan() - if not gatewayscanner.found: + if len(gateways) == 0: print("No Gateways found") return - if not gatewayscanner.supports_tunneling: + gateway = gateways[0] + + if not gateway.supports_tunnelling: print("Gateway does not support tunneling") return udp_client = UDPClient( xknx, - (gatewayscanner.found_local_ip, 0), - (gatewayscanner.found_ip_addr, gatewayscanner.found_port)) + (gateway.local_ip, 0), + (gateway.ip, gateway.port)) await udp_client.connect() diff --git a/examples/example_gatewayscanner.py b/examples/example_gatewayscanner.py index 15c7db482b..b902c8e9fb 100644 --- a/examples/example_gatewayscanner.py +++ b/examples/example_gatewayscanner.py @@ -9,23 +9,22 @@ 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) - await gatewayscanner.start() + gateways = await gatewayscanner.scan() - if not gatewayscanner.found: + if len(gateways) == 0: print("No Gateways found") else: - print("Gateway found: {0} / {1}:{2}".format( - gatewayscanner.found_name, - gatewayscanner.found_ip_addr, - gatewayscanner.found_port)) - if gatewayscanner.supports_tunneling: - print("- Device supports tunneling") - if gatewayscanner.supports_routing: - print("- Device supports routing, connecting via {0}".format( - gatewayscanner.found_local_ip)) - - await gatewayscanner.stop() + for gateway in gateways: + print("Gateway found: {0} / {1}:{2}".format( + gateway.name, + gateway.ip, + gateway.port)) + if gateway.supports_tunnelling: + print("- Device supports tunneling") + if gateway.supports_routing: + print("- Device supports routing, connecting via {0}".format( + gateway.local_ip)) # pylint: disable=invalid-name loop = asyncio.get_event_loop() diff --git a/examples/example_tunnel.py b/examples/example_tunnel.py index ca4f922a65..33192b14bc 100644 --- a/examples/example_tunnel.py +++ b/examples/example_tunnel.py @@ -10,25 +10,26 @@ async def main(): """Connect to a tunnel, send 2 telegrams and disconnect.""" xknx = XKNX() gatewayscanner = GatewayScanner(xknx) - await gatewayscanner.start() + gateways = await gatewayscanner.scan() - if not gatewayscanner.found: + if len(gateways) == 0: print("No Gateways found") return + gateway = gateways[0] src_address = PhysicalAddress("15.15.249") print("Connecting to {}:{} from {}".format( - gatewayscanner.found_ip_addr, - gatewayscanner.found_port, - gatewayscanner.found_local_ip)) + gateway.ip, + gateway.port, + gateway.local_ip)) tunnel = Tunnel( xknx, src_address, - local_ip=gatewayscanner.found_local_ip, - gateway_ip=gatewayscanner.found_ip_addr, - gateway_port=gatewayscanner.found_port) + local_ip=gateway.local_ip, + gateway_ip=gateway.ip, + gateway_port=gateway.port) await tunnel.connect_udp() await tunnel.connect() diff --git a/xknx/io/__init__.py b/xknx/io/__init__.py index 864270279b..b9e8449ab4 100644 --- a/xknx/io/__init__.py +++ b/xknx/io/__init__.py @@ -9,7 +9,7 @@ # flake8: noqa from .request_response import RequestResponse from .knxip_interface import KNXIPInterface, ConnectionType, ConnectionConfig -from .gateway_scanner import GatewayScanner +from .gateway_scanner import GatewayScanner, GatewayScanFilter from .routing import Routing from .tunnel import Tunnel from .disconnect import Disconnect diff --git a/xknx/io/gateway_scanner.py b/xknx/io/gateway_scanner.py index 36908f6d3d..f86d2c7f35 100644 --- a/xknx/io/gateway_scanner.py +++ b/xknx/io/gateway_scanner.py @@ -7,6 +7,7 @@ """ import asyncio +from typing import List import netifaces from xknx.knxip import (HPAI, DIBServiceFamily, DIBSuppSVCFamilies, KNXIPFrame, @@ -16,104 +17,171 @@ from .udp_client import UDPClient +class GatewayDescriptor: + """Used to return infos about the discovered gateways""" + + # pylint: disable=too-few-public-methods + + def __init__(self, + name: str, + ip: str, + port: int, + local_interface: str, + local_ip: str, + supports_tunnelling: bool = False, + supports_routing: bool = False) -> None: + """Initialize GatewayDescriptor class.""" + # pylint: disable=too-many-arguments + self.name = name + self.ip = ip + self.port = port + self.local_interface = local_interface + self.local_ip = local_ip + self.supports_routing = supports_routing + self.supports_tunnelling = supports_tunnelling + + def __str__(self): + """Return object as readable string.""" + return ' None: + """Initialize GatewayScanFilter class.""" + self.name = name + self.tunnelling = tunnelling + self.routing = routing + + def match(self, gateway: GatewayDescriptor) -> bool: + if self.name is not None and self.name != gateway.name: + return False + if self.tunnelling is not None and self.tunnelling != gateway.supports_tunnelling: + return False + if self.routing is not None and self.routing != gateway.supports_routing: + return False + return True + + class GatewayScanner(): """Class for searching KNX/IP devices.""" + # pylint: disable=too-few-public-methods # pylint: disable=too-many-instance-attributes - def __init__(self, xknx, timeout_in_seconds=4): + def __init__(self, + xknx, + timeout_in_seconds: int = 4, + stop_on_found: int = 1, + scan_filter: GatewayScanFilter = GatewayScanFilter()) -> None: """Initialize GatewayScanner class.""" self.xknx = xknx - self.response_received_or_timeout = asyncio.Event() - self.found = False - self.found_ip_addr = None - self.found_port = None - self.found_name = None - self.found_local_ip = None - self.supports_routing = False - self.supports_tunneling = False - self.udpclients = [] self.timeout_in_seconds = timeout_in_seconds - self.timeout_callback = None - self.timeout_handle = None - - def response_rec_callback(self, knxipframe, udp_client): - """Verify and handle knxipframe. Callback from internal udpclient.""" - if not isinstance(knxipframe.body, SearchResponse): - self.xknx.logger.warning("Cant understand knxipframe") - return - - if not self.found: - self.found_ip_addr = knxipframe.body.control_endpoint.ip_addr - self.found_port = knxipframe.body.control_endpoint.port - self.found_name = knxipframe.body.device_name - - for dib in knxipframe.body.dibs: - if isinstance(dib, DIBSuppSVCFamilies): - self.supports_routing = dib.supports(DIBServiceFamily.ROUTING) - self.supports_tunneling = dib.supports(DIBServiceFamily.TUNNELING) - - (self.found_local_ip, _) = udp_client.getsockname() - - self.response_received_or_timeout.set() - self.found = True - - async def start(self): - """Start searching.""" - await self.send_search_requests() - await self.start_timeout() - await self.response_received_or_timeout.wait() - await self.stop() - await self.stop_timeout() - - async def stop(self): + self.stop_on_found = stop_on_found + self.scan_filter = scan_filter + self.found_gateways = [] # List[GatewayDescriptor] + self._udp_clients = [] + self._response_received_or_timeout = asyncio.Event() + self._timeout_callback = None + self._timeout_handle = None + + async def scan(self) -> List[GatewayDescriptor]: + """Scans and returns a list of GatewayDescriptors on success.""" + await self._send_search_requests() + await self._start_timeout() + await self._response_received_or_timeout.wait() + await self._stop() + await self._stop_timeout() + return self.found_gateways + + async def _stop(self): """Stop tearing down udpclient.""" - for udpclient in self.udpclients: - await udpclient.stop() + for udp_client in self._udp_clients: + await udp_client.stop() - async def send_search_requests(self): + async def _send_search_requests(self): """Send search requests on all connected interfaces.""" # pylint: disable=no-member for interface in netifaces.interfaces(): try: af_inet = netifaces.ifaddresses(interface)[netifaces.AF_INET] ip_addr = af_inet[0]["addr"] - await self.search_interface(interface, ip_addr) + await self._search_interface(interface, ip_addr) except KeyError: self.xknx.logger.info("Could not connect to an KNX/IP device on %s", interface) continue - async def search_interface(self, interface, ip_addr): + async def _search_interface(self, interface, ip_addr): """Search on a specific interface.""" self.xknx.logger.debug("Searching on %s / %s", interface, ip_addr) - udpclient = UDPClient(self.xknx, - (ip_addr, 0), - (DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT), - multicast=True) + udp_client = UDPClient(self.xknx, + (ip_addr, 0, interface), + (DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT), + multicast=True) - udpclient.register_callback( - self.response_rec_callback, [KNXIPServiceType.SEARCH_RESPONSE]) - await udpclient.connect() + udp_client.register_callback( + self._response_rec_callback, [KNXIPServiceType.SEARCH_RESPONSE]) + await udp_client.connect() - self.udpclients.append(udpclient) + self._udp_clients.append(udp_client) - (local_addr, local_port) = udpclient.getsockname() - knxipframe = KNXIPFrame(self.xknx) - knxipframe.init(KNXIPServiceType.SEARCH_REQUEST) - knxipframe.body.discovery_endpoint = \ + (local_addr, local_port) = udp_client.getsockname() + knx_ip_frame = KNXIPFrame(self.xknx) + knx_ip_frame.init(KNXIPServiceType.SEARCH_REQUEST) + knx_ip_frame.body.discovery_endpoint = \ HPAI(ip_addr=local_addr, port=local_port) - knxipframe.normalize() - udpclient.send(knxipframe) + knx_ip_frame.normalize() + udp_client.send(knx_ip_frame) - def timeout(self): - """Handle timeout for not having received a SearchResponse.""" - self.response_received_or_timeout.set() + def _response_rec_callback(self, knx_ip_frame: KNXIPFrame, udp_client: UDPClient) -> None: + """Verify and handle knxipframe. Callback from internal udpclient.""" + if not isinstance(knx_ip_frame.body, SearchResponse): + self.xknx.logger.warning("Cant understand knxipframe") + return - async def start_timeout(self): + (found_local_ip, _) = udp_client.getsockname() + gateway = GatewayDescriptor(name=knx_ip_frame.body.device_name, + ip=knx_ip_frame.body.control_endpoint.ip_addr, + port=knx_ip_frame.body.control_endpoint.port, + local_interface=udp_client.local_addr[2], + local_ip=found_local_ip) + try: + dib = knx_ip_frame.body[DIBSuppSVCFamilies] + gateway.supports_routing = dib.supports(DIBServiceFamily.ROUTING) + gateway.supports_tunnelling = dib.supports(DIBServiceFamily.TUNNELING) + except IndexError: + pass + + self._add_found_gateway(gateway) + + def _add_found_gateway(self, gateway): + if self.scan_filter.match(gateway): + self.found_gateways.append(gateway) + if len(self.found_gateways) >= self.stop_on_found: + self._response_received_or_timeout.set() + + def _timeout(self): + """Handle timeout for not having received enough SearchResponse.""" + self._response_received_or_timeout.set() + + async def _start_timeout(self): """Start time out.""" - self.timeout_handle = self.xknx.loop.call_later( - self.timeout_in_seconds, self.timeout) + self._timeout_handle = self.xknx.loop.call_later( + self.timeout_in_seconds, self._timeout) - async def stop_timeout(self): + async def _stop_timeout(self): """Stop/cancel timeout.""" - self.timeout_handle.cancel() + self._timeout_handle.cancel() diff --git a/xknx/io/knxip_interface.py b/xknx/io/knxip_interface.py index 4882ad9b38..30cda4b559 100644 --- a/xknx/io/knxip_interface.py +++ b/xknx/io/knxip_interface.py @@ -7,11 +7,12 @@ """ from enum import Enum +from platform import system as get_os_name from xknx.exceptions import XKNXException from .const import DEFAULT_MCAST_PORT -from .gateway_scanner import GatewayScanner +from .gateway_scanner import GatewayScanner, GatewayScanFilter from .routing import Routing from .tunnel import Tunnel @@ -36,20 +37,27 @@ 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. + * scan_filter: For AUTOMATIC connection, limit scan with the given filter + * bind_to_multicast_addr: Bind to the multicast address instead of the local IP (ROUTING only) """ # pylint: disable=too-few-public-methods def __init__(self, - connection_type=ConnectionType.AUTOMATIC, - local_ip=None, - gateway_ip=None, - gateway_port=DEFAULT_MCAST_PORT): + connection_type: int = ConnectionType.AUTOMATIC, + local_ip: str = None, + gateway_ip: str = None, + gateway_port: int = DEFAULT_MCAST_PORT, + scan_filter: GatewayScanFilter = GatewayScanFilter(), + bind_to_multicast_addr: bool = True): """Initialize ConnectionConfig class.""" + # pylint: disable=too-many-arguments self.connection_type = connection_type self.local_ip = local_ip self.gateway_ip = gateway_ip self.gateway_port = gateway_port + self.scan_filter = scan_filter + self.bind_to_multicast_addr = bind_to_multicast_addr class KNXIPInterface(): @@ -64,31 +72,34 @@ def __init__(self, xknx, connection_config=ConnectionConfig()): async def start(self): """Start interface. Connecting KNX/IP device with the selected method.""" if self.connection_config.connection_type == ConnectionType.AUTOMATIC: - await self.start_automatic() + await self.start_automatic( + self.connection_config.scan_filter) elif self.connection_config.connection_type == ConnectionType.ROUTING: await self.start_routing( - self.connection_config.local_ip) + self.connection_config.local_ip, + self.connection_config.bind_to_multicast_addr) elif self.connection_config.connection_type == ConnectionType.TUNNELING: await self.start_tunnelling( self.connection_config.local_ip, self.connection_config.gateway_ip, self.connection_config.gateway_port) - async def start_automatic(self): + async def start_automatic(self, scan_filter: GatewayScanFilter): """Start GatewayScanner and connect to the found device.""" - gatewayscanner = GatewayScanner(self.xknx) - await gatewayscanner.start() - await gatewayscanner.stop() + gatewayscanner = GatewayScanner(self.xknx, scan_filter=scan_filter) + gateways = await gatewayscanner.scan() - if not gatewayscanner.found: + if len(gateways) == 0: raise XKNXException("No Gateways found") - if gatewayscanner.supports_tunneling: - await self.start_tunnelling(gatewayscanner.found_local_ip, - gatewayscanner.found_ip_addr, - gatewayscanner.found_port) - elif gatewayscanner.supports_routing: - await self.start_routing(gatewayscanner.found_local_ip) + gateway = gateways[0] + if gateway.supports_tunnelling: + await self.start_tunnelling(gateway.local_ip, + gateway.ip, + gateway.port) + elif gateway.supports_routing: + bind_to_multicast_addr = get_os_name() != "Darwin" # = Mac OS + await self.start_routing(gateway.local_ip, bind_to_multicast_addr) async def start_tunnelling(self, local_ip, gateway_ip, gateway_port): """Start KNX/IP tunnel.""" @@ -102,13 +113,14 @@ async def start_tunnelling(self, local_ip, gateway_ip, gateway_port): telegram_received_callback=self.telegram_received) await self.interface.start() - async def start_routing(self, local_ip): + async def start_routing(self, local_ip, bind_to_multicast_addr): """Start KNX/IP Routing.""" self.xknx.logger.debug("Starting Routing from %s", local_ip) self.interface = Routing( self.xknx, self.telegram_received, - local_ip) + local_ip, + bind_to_multicast_addr) await self.interface.start() async def stop(self): diff --git a/xknx/io/routing.py b/xknx/io/routing.py index 650c98740f..e91acc9826 100644 --- a/xknx/io/routing.py +++ b/xknx/io/routing.py @@ -13,7 +13,7 @@ class Routing(): """Class for handling KNX/IP routing.""" - def __init__(self, xknx, telegram_received_callback, local_ip): + def __init__(self, xknx, telegram_received_callback, local_ip, bind_to_multicast_addr): """Initialize Routing class.""" self.xknx = xknx self.telegram_received_callback = telegram_received_callback @@ -23,7 +23,7 @@ def __init__(self, xknx, telegram_received_callback, local_ip): (local_ip, 0), (DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT), multicast=True, - bind_to_multicast_addr=True) + bind_to_multicast_addr=bind_to_multicast_addr) self.udpclient.register_callback( self.response_rec_callback, diff --git a/xknx/io/udp_client.py b/xknx/io/udp_client.py index 838973dc83..255a4e32f2 100644 --- a/xknx/io/udp_client.py +++ b/xknx/io/udp_client.py @@ -137,7 +137,8 @@ def create_multicast_sock(own_ip, remote_addr, bind_to_multicast_addr): # I have no idea why we have to use different bind calls here # - bind() with multicast addr does not work with gateway search requests - # on some machines. It only works if called with own ip. + # on some machines. It only works if called with own ip. It also doesn't + # work on Mac OS. # - bind() with own_ip does not work with ROUTING_INDICATIONS on Gira # knx router - for an unknown reason. if bind_to_multicast_addr: diff --git a/xknx/knxip/search_response.py b/xknx/knxip/search_response.py index c0dfebf86b..2137cad867 100644 --- a/xknx/knxip/search_response.py +++ b/xknx/knxip/search_response.py @@ -4,6 +4,7 @@ Search Requests are used to search for KNX/IP devices within the network. With a search response the receiving party acknowledges the valid processing of the request. The search response contains all information of the found device (Name, serial number, supported features.). +It supports an array-style access to the DIBs (use classname as index). """ from .body import KNXIPBody from .dib import DIB, DIBDeviceInformation @@ -59,3 +60,9 @@ def __str__(self): return '' \ .format(self.control_endpoint, ',\n'.join(dib.__str__() for dib in self.dibs)) + + def __getitem__(self, clazz): + for dib in self.dibs: + if isinstance(dib, clazz): + return dib + raise IndexError