"""TCPInterface class for interfacing with http endpoint """ # pylint: disable=R0917 import contextlib import logging import socket import threading import time from typing import Optional from meshtastic.stream_interface import StreamInterface DEFAULT_TCP_PORT = 4403 logger = logging.getLogger(__name__) class TCPInterface(StreamInterface): """Interface class for meshtastic devices over a TCP link""" def __init__( self, hostname: str, debugOut=None, noProto: bool = False, connectNow: bool = True, portNumber: int = DEFAULT_TCP_PORT, noNodes: bool = False, timeout: int = 300, ): """Constructor, opens a connection to a specified IP address/hostname Keyword Arguments: hostname {string} -- Hostname/IP address of the device to connect to timeout -- How long to wait for replies (default: 300 seconds) """ self.hostname: str = hostname self.portNumber: int = portNumber self.socket: Optional[socket.socket] = None self.reconnectLock = threading.Lock() super().__init__( debugOut=debugOut, noProto=noProto, connectNow=connectNow, noNodes=noNodes, timeout=timeout, ) def __repr__(self): rep = f"TCPInterface({self.hostname!r}" if self.debugOut is not None: rep += f", debugOut={self.debugOut!r}" if self.noProto: rep += ", noProto=True" if self.socket is None: rep += ", connectNow=False" if self.portNumber != DEFAULT_TCP_PORT: rep += f", portNumber={self.portNumber!r}" if self.noNodes: rep += ", noNodes=True" rep += ")" return rep def _socket_shutdown(self) -> None: """Shutdown the socket. Note: Broke out this line so the exception could be unit tested. """ if self.socket is not None: self.socket.shutdown(socket.SHUT_RDWR) def connect(self) -> None: """Connect the interface""" self.myConnect() super().connect() def myConnect(self) -> None: """Connect to socket (without attempting to start the interface's receive thread)""" logger.debug(f"Connecting to {self.hostname}") # type: ignore[str-bytes-safe] server_address = (self.hostname, self.portNumber) self.socket = socket.create_connection(server_address) def close(self) -> None: """Close a connection to the device.""" logger.debug("Closing TCP stream") # Sometimes the socket read might be blocked in the reader thread. # Therefore force a shutdown first to unblock reader thread reads. self._wantExit = True if self.socket is not None: with contextlib.suppress( Exception ): # Ignore errors in shutdown, because we might have a race with the server self._socket_shutdown() with contextlib.suppress(Exception): self.socket.close() self.socket = None super().close() def _writeBytes(self, b: bytes) -> None: """Write an array of bytes to our stream""" if self.socket is not None: try: self.socket.sendall(b) except OSError as e: logger.error(f"Socket send error, reconnecting: {e}") if not self._wantExit: self._reconnect() raise def _readBytes(self, length) -> Optional[bytes]: """Read an array of bytes from our stream""" if self.socket is not None: data = self.socket.recv(length) # empty byte indicates a disconnected socket, # we need to handle it to avoid an infinite loop reading from null socket if data == b"": logger.debug("Closed socket, re-connecting") if not self._wantExit: self._reconnect() return data # no socket, break reader thread self._wantExit = True return None def _reconnect(self) -> None: """Reconnect to the socket""" # Save the socket reference before attempting to acquire the lock. sock = self.socket start_config = False with self.reconnectLock: if self._wantExit: return # Don't reconnect: someone else already did it. if sock is not self.socket: return with contextlib.suppress(Exception): self._socket_shutdown() if self.socket is not None: self.socket.close() self.socket = None time.sleep(1) self.myConnect() start_config = True if start_config and not self._wantExit and self.socket is not None: self._startConfig()