Repository navigation
Expand file tree
/
Copy pathtcp_interface.py
More file actions
180 lines (155 loc) · 6.33 KB
/
Copy pathtcp_interface.py
File metadata and controls
180 lines (155 loc) · 6.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
"""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()