mirror of
https://github.com/meshtastic/python.git
synced 2026-07-31 17:16:45 -04:00
close() went straight to shutdown(SHUT_RDWR) + close(). Doing that while either side still has unread data makes the stack send RST rather than FIN, and the device's FromRadio stream means there is essentially always unread data. Winsock discards data that was received but not yet read when an RST arrives, so an admin message written moments earlier is thrown away before the device reads it. Linux delivers the buffered bytes before reporting ECONNRESET, which is why this only surfaced on Windows: every one-shot TCP write there (--set, --seturl, --sendtext) reported success and did nothing. writeConfig() does not wait for an ack, so close() runs microseconds after sendall(). The device cannot compensate; it polls every 5ms and the RST beats its next read. Send FIN first with shutdown(SHUT_WR), which lets the device consume what we wrote, wait briefly for the reader thread to drain and exit, then tear down exactly as before. The full shutdown is kept so a reader still blocked in recv() is unblocked for the join in StreamInterface.close(). The wait is bounded: the device is not obliged to close just because we half-closed. Fixes #957
181 lines
6.3 KiB
Python
181 lines
6.3 KiB
Python
"""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
|
|
|
|
# How long close() gives the device to consume what we last wrote before forcing
|
|
# the connection down. See close() for why this exists.
|
|
GRACEFUL_CLOSE_TIMEOUT = 0.25
|
|
|
|
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 _wait_for_reader_exit(self, timeout: float) -> None:
|
|
"""Wait briefly for the reader thread to drain and exit after a half-close.
|
|
|
|
Returns as soon as it exits, or after timeout: the device is not obliged
|
|
to close just because we did.
|
|
"""
|
|
rx = getattr(self, "_rxThread", None)
|
|
if rx is None or rx is threading.current_thread():
|
|
return
|
|
with contextlib.suppress(Exception):
|
|
rx.join(timeout)
|
|
|
|
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:
|
|
# Half-close first. shutdown(SHUT_WR) sends FIN, which tells the
|
|
# device we are done writing and lets it consume what we last wrote
|
|
# before the connection goes away -- typically the admin message a
|
|
# one-shot command such as `--set` just sent.
|
|
#
|
|
# Going straight to shutdown(SHUT_RDWR) + close() while either side
|
|
# still has unread data makes the stack send RST instead. Winsock
|
|
# then discards data the peer had already received but not yet read,
|
|
# so the write is lost; Linux delivers it before reporting
|
|
# ECONNRESET, which is why this only bites on Windows.
|
|
with contextlib.suppress(Exception):
|
|
self.socket.shutdown(socket.SHUT_WR)
|
|
self._wait_for_reader_exit(GRACEFUL_CLOSE_TIMEOUT)
|
|
|
|
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()
|