async def send_telegram(self, telegram: Telegram) -> None: """Send telegram to connected device (either Tunneling or Routing).""" if self._interface is None: raise CommunicationError("KNX/IP interface not connected") return await self._await_from_different_thread( self._interface.send_telegram(telegram), self._thread_loop)
async def connect(self) -> bool: """Connect to a KNX tunneling interface. Returns True on success.""" await self.xknx.connection_manager.connection_state_changed( XknxConnectionState.CONNECTING) try: await self.transport.connect() await self.setup_tunnel() await self._connect_request() except (OSError, CommunicationError) as ex: logger.debug( "Could not establish connection to KNX/IP interface. %s: %s", type(ex).__name__, ex, ) await self.xknx.connection_manager.connection_state_changed( XknxConnectionState.DISCONNECTED) if not self._initial_connection and self.auto_reconnect: self._reconnect_task = asyncio.create_task(self._reconnect()) return False # close transport to prevent open file descriptors self.transport.stop() raise CommunicationError( "Tunnel connection could not be established") from ex else: self._tunnel_established() await self.xknx.connection_manager.connection_state_changed( XknxConnectionState.CONNECTED) return True
async def send_telegram(self, telegram: Telegram) -> None: """ Send Telegram to routing tunnelling device - retry mechanism. If a TUNNELLING_REQUEST frame is not confirmed within the TUNNELLING_REQUEST_TIME_- OUT time of one (1) second then the frame shall be repeated once with the same sequence counter value by the sending KNXnet/IP device. If the KNXnet/IP device does not receive a TUNNELLING_ACK frame within the TUNNELLING_- REQUEST_TIMEOUT (= 1 second) or the status of a received TUNNELLING_ACK frame signals any kind of error condition, the sending device shall repeat the TUNNELLING_REQUEST frame once and then terminate the connection by sending a DISCONNECT_REQUEST frame to the other device’s control endpoint. """ success = await self._tunnelling_request(telegram) if not success: logger.debug("Sending of telegram failed. Retrying a second time.") success = await self._tunnelling_request(telegram) if not success: logger.debug( "Resending telegram failed. Reconnecting to tunnel.") # TODO: How to test this? if self._reconnect_task is None or self._reconnect_task.done(): self._tunnel_lost() await self.xknx.connected.wait() success = await self._tunnelling_request(telegram) if not success: raise CommunicationError( "Resending the telegram repeatedly failed.", True) self._increase_sequence_number()
async def _tunnelling_request(self, cemi: CEMIFrame) -> bool: """Send Telegram to tunnelling device.""" if self.communication_channel is None: raise CommunicationError( "Sending telegram failed. No active communication channel.") tunnelling_request = TunnellingRequest( communication_channel_id=self.communication_channel, sequence_counter=self.sequence_number, cemi=cemi, ) if cemi.code is CEMIMessageCode.L_DATA_REQ: async def _async_wrapper() -> None: self.transport.send( KNXIPFrame.init_from_body(tunnelling_request)) await self._wait_for_tunnelling_request_confirmation( send_tunneling_request_aw=_async_wrapper(), cemi=cemi, ) else: self.transport.send(KNXIPFrame.init_from_body(tunnelling_request)) return True
async def _start_automatic(self) -> None: """Start GatewayScanner and connect to the found device.""" async for gateway in GatewayScanner( self.xknx, local_ip=self.connection_config.local_ip, scan_filter=self.connection_config. scan_filter, # secure disabled by default ).async_scan(): try: if gateway.supports_tunnelling_tcp: await self._start_tunnelling_tcp( gateway_ip=gateway.ip_addr, gateway_port=gateway.port, ) elif gateway.supports_tunnelling: await self._start_tunnelling_udp( gateway_ip=gateway.ip_addr, gateway_port=gateway.port, ) elif gateway.supports_routing: await self._start_routing( local_ip=self.connection_config.local_ip, ) except CommunicationError as ex: logger.debug("Could not connect to %s: %s", gateway, ex) continue else: self._gateway_info = gateway break else: raise CommunicationError("No usable KNX/IP device found.")
async def connect(self): """Connect/build tunnel.""" connect = Connect(self.xknx, self.udp_client) await connect.start() if not connect.success: if self.auto_reconnect: logger.warning( "Could not connect to KNX. Retry in %s seconds.", self.auto_reconnect_wait, ) self._reconnect_task = asyncio.create_task(self.schedule_reconnect()) return raise CommunicationError( "Could not establish connection", not self._is_reconnecting ) logger.debug( "Tunnel established communication_channel=%s, id=%s", connect.communication_channel, connect.identifier, ) if self._is_reconnecting: logger.info("Successfully reconnected to KNX bus.") self._reconnect_task = None self._is_reconnecting = False self.communication_channel = connect.communication_channel # Use the individual address provided by the tunnelling server self._src_address = PhysicalAddress(connect.identifier) self.sequence_number = 0 await self.start_heartbeat()
async def _do_heartbeat_failed(self) -> None: """Heartbeat: handling error.""" # first heartbeat failed - try 3 more times before disconnecting. for _heartbeats_failed in range(3): if await self._connectionstate_request(): return # 3 retries failed raise CommunicationError("No answer from tunneling server.")
def _tunnel_lost(self) -> None: """Prepare for reconnection or shutdown when the connection is lost. Callback.""" self.xknx.connected.clear() self.stop_heartbeat() if self.auto_reconnect: self._reconnect_task = asyncio.create_task(self._reconnect()) else: raise CommunicationError("Tunnel connection closed.")
async def process_telegram_outgoing(self, telegram: Telegram) -> None: """Process outgoing telegram.""" telegram_logger.debug(telegram) if self.xknx.knxip_interface is not None: await self.xknx.knxip_interface.send_telegram(telegram) if isinstance(telegram.payload, GroupValueWrite): await self.xknx.devices.process(telegram) else: raise CommunicationError("No KNXIP interface defined")
def _tunnel_lost(self) -> None: """Prepare for reconnection or shutdown when the connection is lost. Callback.""" asyncio.create_task( self.xknx.connection_manager.connection_state_changed( XknxConnectionState.DISCONNECTED)) self.stop_heartbeat() if self.auto_reconnect: self._reconnect_task = asyncio.create_task(self._reconnect()) else: raise CommunicationError("Tunnel connection closed.")
async def process_telegram_outgoing(self, telegram: Telegram) -> None: """Process outgoing telegram.""" telegram_logger.debug(telegram) if not isinstance(telegram.destination_address, InternalGroupAddress): if self.xknx.knxip_interface is None: raise CommunicationError("No KNXIP interface defined") await self.xknx.knxip_interface.send_telegram(telegram) await self.xknx.devices.process(telegram) await self._run_telegram_received_cbs(telegram)
async def _connectionstate_request(self) -> bool: """Return state of tunnel. True if tunnel is in good shape.""" if self.communication_channel is None: raise CommunicationError("No active communication channel.") conn_state = ConnectionState( transport=self.transport, communication_channel_id=self.communication_channel, local_hpai=self.local_hpai, ) await conn_state.start() return conn_state.success
async def _connectionstate_request(self) -> bool: """Return state of tunnel. True if tunnel is in good shape.""" if self.communication_channel is None: raise CommunicationError("No active communication channel.") conn_state = ConnectionState( self.xknx, self.udp_client, communication_channel_id=self.communication_channel, route_back=self.route_back, ) await conn_state.start() return conn_state.success
async def process_telegram_outgoing(self, telegram: Telegram) -> None: """Process outgoing telegram.""" telegram_logger.debug(telegram) if self.xknx.knxip_interface is not None: await self.xknx.knxip_interface.send_telegram(telegram) if isinstance(telegram.payload, GroupValueWrite): await self.xknx.devices.process(telegram) for telegram_received_cb in self.telegram_received_cbs: if telegram_received_cb.is_within_filter(telegram): await telegram_received_cb.callback(telegram) else: raise CommunicationError("No KNXIP interface defined")
def send(self, knxipframe: KNXIPFrame, addr: tuple[str, int] | None = None) -> None: """Send KNXIPFrame to socket. `addr` is ignored on TCP.""" knx_logger.debug( "Sending to %s at %s:\n%s", self.remote_hpai, time.time(), knxipframe, ) if self.transport is None: raise CommunicationError("Transport not connected") self.transport.write(knxipframe.to_knx())
async def connect(self) -> None: """Connect transport.""" await super().connect() self._private_key, self.public_key = generate_ecdh_key_pair() self._sequence_number = 0 self._sequence_number_received = -1 request_session = Session( transport=self, ecdh_client_public_key=self.public_key, ) await request_session.start() if request_session.response is None: raise CommunicationError( "Secure session could not be established. No response received." ) # SessionAuthenticate and everything else after now shall be wrapped in SecureWrapper authenticate_mac = self.handshake(request_session.response) self.initialized = True request_authentication = Authenticate( transport=self, user_id=self.user_id, message_authentication_code=authenticate_mac, ) await request_authentication.start() if request_authentication.response is None: raise CommunicationError( "Secure session could not be established. No response received." ) if ( # TODO: look for status in request/response and use `success` instead of response ? request_authentication.response.status != SecureSessionStatusCode.STATUS_AUTHENTICATION_SUCCESS): raise CommunicationError( f"Secure session authentication failed: {request_authentication.response.status}" ) self._session_status_handler = self.register_callback( self._handle_session_status, [KNXIPServiceType.SESSION_STATUS])
async def test_failed_connect_disconnect(_if_mock): """Test failing connections.""" xknx = XKNX() ia_1 = IndividualAddress("4.0.1") xknx.knxip_interface.send_telegram.side_effect = ConfirmationError("") with pytest.raises(ManagementConnectionError): await xknx.management.connect(ia_1) xknx.knxip_interface.send_telegram.side_effect = CommunicationError("") with pytest.raises(ManagementConnectionError): await xknx.management.connect(ia_1) xknx.knxip_interface.send_telegram.side_effect = None conn_1 = await xknx.management.connect(ia_1) xknx.knxip_interface.send_telegram.side_effect = ConfirmationError("") with pytest.raises(ManagementConnectionError): await xknx.management.disconnect(ia_1) xknx.knxip_interface.send_telegram.side_effect = None conn_1 = await xknx.management.connect(ia_1) xknx.knxip_interface.send_telegram.side_effect = CommunicationError("") with pytest.raises(ManagementConnectionError): await conn_1.disconnect()
async def _tunnelling_request(self, telegram: Telegram) -> bool: """Send Telegram to tunnelling device.""" if self.communication_channel is None: raise CommunicationError( "Sending telegram failed. No active communication channel.") tunnelling = Tunnelling( self.xknx, self.udp_client, telegram, self._src_address, self.sequence_number, self.communication_channel, ) await tunnelling.start() return tunnelling.success
def handshake(self, session_response: SessionResponse) -> bytes: """ Handshake with device. Returns a SessionAuthenticate KNX/IP body. """ self._peer_public_key = X25519PublicKey.from_public_bytes( session_response.ecdh_server_public_key) self.session_id = session_response.secure_session_id # verify SessionResponse MAC # TODO: get header data from actual KNX/IP frame response_header_data = bytes.fromhex("06 10 09 52 00 38") pub_keys_xor = bytes_xor( self.public_key, session_response.ecdh_server_public_key, ) if self._device_authentication_code: response_mac_cbc = calculate_message_authentication_code_cbc( key=self._device_authentication_code, additional_data=response_header_data + self.session_id.to_bytes(2, "big") + pub_keys_xor, # knx_ip_header + secure_session_id + bytes_xor(client_pub_key, server_pub_key) ) _, mac_tr = decrypt_ctr( key=self._device_authentication_code, counter_0=COUNTER_0_HANDSHAKE, mac=session_response.message_authentication_code, ) if mac_tr != response_mac_cbc: raise CommunicationError( "SessionResponse MAC verification failed.") # calculate session key ecdh_shared_secret = self._private_key.exchange(self._peer_public_key) self._session_key = sha256_hash(ecdh_shared_secret)[:16] # generate SessionAuthenticate MAC authenticate_header_data = bytes.fromhex("06 10 09 53 00 18") authenticate_mac_cbc = calculate_message_authentication_code_cbc( key=self._user_password, additional_data=authenticate_header_data + bytes(1) # reserved + self.user_id.to_bytes(1, "big") + pub_keys_xor, block_0=bytes(16), ) _, authenticate_mac = encrypt_data_ctr( key=self._user_password, counter_0=COUNTER_0_HANDSHAKE, mac_cbc=authenticate_mac_cbc, ) return authenticate_mac
async def _tunnelling_request(self, telegram: Telegram) -> bool: """Send Telegram to tunnelling device.""" if self.communication_channel is None: raise CommunicationError( "Sending telegram failed. No active communication channel.") tunnelling = Tunnelling( transport=self.transport, data_endpoint=self._data_endpoint_addr, telegram=telegram, src_address=self._src_address, sequence_counter=self.sequence_number, communication_channel_id=self.communication_channel, ) await self._wait_for_tunnelling_request_confirmation( send_tunneling_request_aw=tunnelling.start(), telegram=telegram) return tunnelling.success
async def _disconnect_request(self, ignore_error: bool = False) -> None: """Disconnect from tunnel device. Delete communication_channel.""" if self.communication_channel is not None: disconnect = Disconnect( transport=self.transport, communication_channel_id=self.communication_channel, local_hpai=self.local_hpai, ) await disconnect.start() if not disconnect.success and not ignore_error: self.communication_channel = None raise CommunicationError("Could not disconnect channel") logger.debug( "Tunnel disconnected (communication_channel: %s)", self.communication_channel, ) self.communication_channel = None
async def _tunnelling_request(self, cemi: CEMIFrame) -> bool: """Send Telegram to tunnelling device.""" if self.communication_channel is None: raise CommunicationError( "Sending telegram failed. No active communication channel.") tunnelling = Tunnelling( transport=self.transport, data_endpoint=self._data_endpoint_addr, cemi=cemi, sequence_counter=self.sequence_number, communication_channel_id=self.communication_channel, ) if cemi.code is CEMIMessageCode.L_DATA_REQ: await self._wait_for_tunnelling_request_confirmation( send_tunneling_request_aw=tunnelling.start(), cemi=cemi) else: await tunnelling.start() return tunnelling.success
def send(self, knxipframe: KNXIPFrame, addr: tuple[str, int] | None = None) -> None: """Send KNXIPFrame to socket.""" _addr = addr or self.remote_addr knx_logger.debug( "Sending to %s:%s at %s:\n %s", _addr[0], _addr[1], time.time(), knxipframe ) if self.transport is None: raise CommunicationError("Transport not connected") if self.multicast: if addr is not None: logger.warning( "Multicast send to specific address is invalid. %s", knxipframe, ) self.transport.sendto(knxipframe.to_knx(), self.remote_addr) else: self.transport.sendto(knxipframe.to_knx(), addr=_addr)
async def _connect_request(self) -> bool: """Connect to tunnelling server. Set communication_channel and src_address.""" connect = Connect(self.xknx, self.udp_client, route_back=self.route_back) await connect.start() if connect.success: self.communication_channel = connect.communication_channel # Use the individual address provided by the tunnelling server self._src_address = IndividualAddress(connect.identifier) logger.debug( "Tunnel established communication_channel=%s, id=%s", connect.communication_channel, connect.identifier, ) return True raise CommunicationError( f"ConnectRequest failed. Status code: {connect.response_status_code}" )
async def connect(self) -> bool: """Start routing.""" await self.xknx.connection_manager.connection_state_changed( XknxConnectionState.CONNECTING) try: await self.udp_transport.connect() except OSError as ex: logger.debug( "Could not establish connection to KNX/IP network. %s: %s", type(ex).__name__, ex, ) await self.xknx.connection_manager.connection_state_changed( XknxConnectionState.DISCONNECTED) # close udp transport to prevent open file descriptors self.udp_transport.stop() raise CommunicationError("Routing could not be started") from ex await self.xknx.connection_manager.connection_state_changed( XknxConnectionState.CONNECTED) return True
async def _connect_request(self) -> bool: """Connect to tunnelling server. Set communication_channel and src_address.""" connect = Connect(transport=self.transport, local_hpai=self.local_hpai) await connect.start() if connect.success: self.communication_channel = connect.communication_channel # assign data_endpoint received from server self._data_endpoint_addr = (connect.data_endpoint.addr_tuple if not connect.data_endpoint.route_back else None) # Use the individual address provided by the tunnelling server self._src_address = IndividualAddress(connect.identifier) self.xknx.current_address = self._src_address logger.debug( "Tunnel established communication_channel=%s, address=%s", connect.communication_channel, self._src_address, ) return True raise CommunicationError( f"ConnectRequest failed. Status code: {connect.response_status_code}" )
async def _send_cemi(self, cemi: CEMIFrame) -> None: """ Send CEMI Frame to tunnelling server - retry mechanism. A transport layer confirmation shall be awaited before sending the next telegram. If a TUNNELLING_REQUEST frame is not confirmed within the TUNNELLING_REQUEST_TIMEOUT time of one (1) second then the frame shall be repeated once with the same sequence counter value by the sending KNXnet/IP device. If the KNXnet/IP device does not receive a TUNNELLING_ACK frame within the TUNNELLING_REQUEST_TIMEOUT (= 1 second) or the status of a received TUNNELLING_ACK frame signals any kind of error condition, the sending device shall repeat the TUNNELLING_REQUEST frame once and then terminate the connection by sending a DISCONNECT_REQUEST frame to the other devices control endpoint. """ async with self._send_telegram_lock: try: if await self._tunnelling_request(cemi): return logger.debug( "Sending of telegram failed. Retrying a second time.") if await self._tunnelling_request(cemi): return logger.debug( "Resending telegram failed. Reconnecting to tunnel.") if self._reconnect_task is None or self._reconnect_task.done(): self._tunnel_lost() await self.xknx.connection_manager.connected.wait() if not await self._tunnelling_request(cemi): raise CommunicationError( "Resending the telegram repeatedly failed.", True) finally: self._increase_sequence_number()
async def send_telegram(self, telegram: "Telegram") -> None: """Send telegram to connected device (either Tunneling or Routing).""" if self.interface is not None: await self.interface.send_telegram(telegram) else: raise CommunicationError("KNX/IP interface not connected")
def getsockname(self) -> Tuple[str, int]: """Return socket IP and port.""" if self.transport is None: raise CommunicationError( "No transport defined. Socket information not resolveable") return cast(Tuple[str, int], self.transport.get_extra_info("sockname"))