sofapython 0.0.1rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sofapython/__init__.py +136 -0
- sofapython/ack.py +79 -0
- sofapython/aio.py +552 -0
- sofapython/backup_export.py +507 -0
- sofapython/blob_decoders.py +806 -0
- sofapython/cli.py +447 -0
- sofapython/commands.py +1273 -0
- sofapython/deframer.py +73 -0
- sofapython/device_create.py +1174 -0
- sofapython/devices.py +534 -0
- sofapython/discovery.py +315 -0
- sofapython/frame_handlers.py +131 -0
- sofapython/hub_listener.py +242 -0
- sofapython/hub_logging.py +152 -0
- sofapython/hub_versions.py +112 -0
- sofapython/inputs.py +501 -0
- sofapython/macros.py +669 -0
- sofapython/notify_demuxer.py +434 -0
- sofapython/opcode_handlers.py +1655 -0
- sofapython/protocol_const.py +633 -0
- sofapython/proxy_ack_waiters.py +660 -0
- sofapython/proxy_activity_ops.py +943 -0
- sofapython/proxy_backup.py +504 -0
- sofapython/proxy_backup_export.py +486 -0
- sofapython/proxy_catalog.py +915 -0
- sofapython/proxy_frame_decode.py +227 -0
- sofapython/proxy_ir_blob.py +676 -0
- sofapython/proxy_restore.py +2004 -0
- sofapython/proxy_wifi_device.py +1101 -0
- sofapython/state_helpers.py +713 -0
- sofapython/transport_bridge.py +876 -0
- sofapython/version.py +4 -0
- sofapython/wire_schema.py +164 -0
- sofapython/x1_proxy.py +1833 -0
- sofapython-0.0.1rc1.dist-info/METADATA +162 -0
- sofapython-0.0.1rc1.dist-info/RECORD +39 -0
- sofapython-0.0.1rc1.dist-info/WHEEL +4 -0
- sofapython-0.0.1rc1.dist-info/entry_points.txt +2 -0
- sofapython-0.0.1rc1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import logging
|
|
5
|
+
import random
|
|
6
|
+
import select
|
|
7
|
+
import socket
|
|
8
|
+
import struct
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from typing import Callable, Dict, Iterable, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from .hub_logging import HubLogger, LogTag, get_hub_logger
|
|
14
|
+
from .hub_listener import get_hub_listener
|
|
15
|
+
from .protocol_const import OP_CALL_ME, SYNC0, SYNC1
|
|
16
|
+
from .notify_demuxer import (
|
|
17
|
+
BROADCAST_LISTEN_PORT,
|
|
18
|
+
build_connect_ready_beacon,
|
|
19
|
+
get_notify_demuxer,
|
|
20
|
+
_broadcast_ip,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("x1proxy.transport")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sum8(b: bytes) -> int:
|
|
27
|
+
return sum(b) & 0xFF
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _route_local_ip(peer_ip: str) -> str:
|
|
31
|
+
try:
|
|
32
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
33
|
+
s.connect((peer_ip, 80))
|
|
34
|
+
return s.getsockname()[0]
|
|
35
|
+
except Exception:
|
|
36
|
+
return "127.0.0.1"
|
|
37
|
+
finally:
|
|
38
|
+
try:
|
|
39
|
+
s.close()
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _enable_keepalive(
|
|
45
|
+
sock: socket.socket, *, idle: int = 30, interval: int = 10, count: int = 3
|
|
46
|
+
) -> None:
|
|
47
|
+
try:
|
|
48
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
try: # Linux
|
|
52
|
+
TCP_KEEPIDLE = getattr(socket, "TCP_KEEPIDLE", None)
|
|
53
|
+
TCP_KEEPINTVL = getattr(socket, "TCP_KEEPINTVL", None)
|
|
54
|
+
TCP_KEEPCNT = getattr(socket, "TCP_KEEPCNT", None)
|
|
55
|
+
if TCP_KEEPIDLE is not None:
|
|
56
|
+
sock.setsockopt(socket.IPPROTO_TCP, TCP_KEEPIDLE, idle)
|
|
57
|
+
if TCP_KEEPINTVL is not None:
|
|
58
|
+
sock.setsockopt(socket.IPPROTO_TCP, TCP_KEEPINTVL, interval)
|
|
59
|
+
if TCP_KEEPCNT is not None:
|
|
60
|
+
sock.setsockopt(socket.IPPROTO_TCP, TCP_KEEPCNT, count)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
try: # macOS/Windows approx
|
|
64
|
+
TCP_KEEPALIVE = getattr(socket, "TCP_KEEPALIVE", None)
|
|
65
|
+
if TCP_KEEPALIVE is not None:
|
|
66
|
+
sock.setsockopt(socket.IPPROTO_TCP, TCP_KEEPALIVE, idle)
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _disable_nagle(sock: socket.socket) -> None:
|
|
72
|
+
"""Disable Nagle's algorithm to avoid coalescing adjacent frames."""
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _flush_buffer(
|
|
81
|
+
sock: socket.socket,
|
|
82
|
+
buf: bytearray,
|
|
83
|
+
label: str,
|
|
84
|
+
logger: HubLogger | logging.Logger | None = None,
|
|
85
|
+
) -> bool:
|
|
86
|
+
"""Try to write the entire buffer to the given socket."""
|
|
87
|
+
|
|
88
|
+
logger = logger or log
|
|
89
|
+
|
|
90
|
+
while buf:
|
|
91
|
+
try:
|
|
92
|
+
sent = sock.send(buf)
|
|
93
|
+
except (BlockingIOError, InterruptedError):
|
|
94
|
+
break
|
|
95
|
+
except OSError as exc:
|
|
96
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
97
|
+
logger.debug("%s[%s] send failed", LogTag.TRANSPORT, label, exc_info=exc)
|
|
98
|
+
buf.clear()
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
if not sent:
|
|
102
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
103
|
+
logger.debug("%s[%s] socket closed during send", LogTag.TRANSPORT, label)
|
|
104
|
+
buf.clear()
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
del buf[:sent]
|
|
108
|
+
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TransportBridge:
|
|
113
|
+
"""Own TCP/UDP sockets and bridge app↔hub traffic.
|
|
114
|
+
|
|
115
|
+
This class is deliberately transport-only: callers provide callbacks for
|
|
116
|
+
diagnostics and higher-level parsing.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
real_hub_ip: str,
|
|
122
|
+
real_hub_udp_port: int,
|
|
123
|
+
proxy_udp_port: int,
|
|
124
|
+
hub_listen_base: int,
|
|
125
|
+
*,
|
|
126
|
+
proxy_id: str,
|
|
127
|
+
mdns_instance: str,
|
|
128
|
+
mdns_txt: Dict[str, str],
|
|
129
|
+
ka_idle: int = 30,
|
|
130
|
+
ka_interval: int = 10,
|
|
131
|
+
ka_count: int = 3,
|
|
132
|
+
) -> None:
|
|
133
|
+
self.real_hub_ip = real_hub_ip
|
|
134
|
+
self.real_hub_udp_port = int(real_hub_udp_port)
|
|
135
|
+
self.proxy_udp_port = int(proxy_udp_port)
|
|
136
|
+
self.hub_listen_base = int(hub_listen_base)
|
|
137
|
+
self.proxy_id = proxy_id
|
|
138
|
+
self._log = get_hub_logger(log, self.proxy_id)
|
|
139
|
+
self.ka_idle = int(ka_idle)
|
|
140
|
+
self.ka_interval = int(ka_interval)
|
|
141
|
+
self.ka_count = int(ka_count)
|
|
142
|
+
self._mdns_instance = mdns_instance
|
|
143
|
+
self._mdns_txt = mdns_txt
|
|
144
|
+
|
|
145
|
+
self._stop = threading.Event()
|
|
146
|
+
self._hub_sock: Optional[socket.socket] = None
|
|
147
|
+
self._app_sock: Optional[socket.socket] = None
|
|
148
|
+
self._hub_lock = threading.Lock()
|
|
149
|
+
self._app_lock = threading.Lock()
|
|
150
|
+
self._wake_lock = threading.Lock()
|
|
151
|
+
|
|
152
|
+
self._call_me_thr: Optional[threading.Thread] = None
|
|
153
|
+
self._bridge_thr: Optional[threading.Thread] = None
|
|
154
|
+
self._notify_registered = False
|
|
155
|
+
self._listener_registered = False
|
|
156
|
+
self._discovery_enabled = False
|
|
157
|
+
|
|
158
|
+
self._local_to_hub = bytearray()
|
|
159
|
+
self._wake_reader: Optional[socket.socket] = None
|
|
160
|
+
self._wake_writer: Optional[socket.socket] = None
|
|
161
|
+
|
|
162
|
+
# callbacks
|
|
163
|
+
self._hub_frame_cbs: list[Callable[[bytes, int], None]] = []
|
|
164
|
+
self._app_frame_cbs: list[Callable[[bytes, int], None]] = []
|
|
165
|
+
self._hub_state_cbs: list[Callable[[bool], None]] = []
|
|
166
|
+
self._client_state_cbs: list[Callable[[bool], None]] = []
|
|
167
|
+
self._idle_cbs: list[Callable[[float], None]] = []
|
|
168
|
+
|
|
169
|
+
self._inter_command_gap = 0.2
|
|
170
|
+
|
|
171
|
+
self._chunk_id = 0
|
|
172
|
+
self._proxy_enabled = True
|
|
173
|
+
self._busy_gate: Optional[Callable[[], bool]] = None
|
|
174
|
+
# When set in the future, suppress CALL_ME pings and refuse hub-
|
|
175
|
+
# initiated reconnects until the deadline passes. Used to honour
|
|
176
|
+
# the hub's OTA-update push (opcode 0x0167): stay disconnected
|
|
177
|
+
# for several minutes while the firmware update runs.
|
|
178
|
+
self._ota_pause_until: float = 0.0
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
# Callback registration
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
def on_hub_frame(self, cb: Callable[[bytes, int], None]) -> None:
|
|
184
|
+
self._hub_frame_cbs.append(cb)
|
|
185
|
+
|
|
186
|
+
def on_app_frame(self, cb: Callable[[bytes, int], None]) -> None:
|
|
187
|
+
self._app_frame_cbs.append(cb)
|
|
188
|
+
|
|
189
|
+
def on_hub_state(self, cb: Callable[[bool], None]) -> None:
|
|
190
|
+
self._hub_state_cbs.append(cb)
|
|
191
|
+
cb(self.is_hub_connected)
|
|
192
|
+
|
|
193
|
+
def on_client_state(self, cb: Callable[[bool], None]) -> None:
|
|
194
|
+
self._client_state_cbs.append(cb)
|
|
195
|
+
cb(self.is_client_connected)
|
|
196
|
+
|
|
197
|
+
def on_idle(self, cb: Callable[[float], None]) -> None:
|
|
198
|
+
self._idle_cbs.append(cb)
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
# External control
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
@property
|
|
204
|
+
def is_hub_connected(self) -> bool:
|
|
205
|
+
with self._hub_lock:
|
|
206
|
+
return self._hub_sock is not None
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def is_client_connected(self) -> bool:
|
|
210
|
+
with self._app_lock:
|
|
211
|
+
return self._app_sock is not None
|
|
212
|
+
|
|
213
|
+
def pause_for_ota(self, seconds: float) -> None:
|
|
214
|
+
"""Drop the hub session and refuse reconnects for ``seconds`` seconds.
|
|
215
|
+
|
|
216
|
+
Invoked when the hub announces a firmware OTA update. The hub may
|
|
217
|
+
still dial us back during this window; we silently drop those
|
|
218
|
+
connections and stop sending CALL_ME pings until the pause
|
|
219
|
+
expires.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
deadline = time.time() + max(0.0, float(seconds))
|
|
223
|
+
if deadline > self._ota_pause_until:
|
|
224
|
+
self._ota_pause_until = deadline
|
|
225
|
+
self._log.warning(
|
|
226
|
+
"%s OTA pause armed for %.0fs (until %.0f)",
|
|
227
|
+
LogTag.TRANSPORT,
|
|
228
|
+
seconds,
|
|
229
|
+
deadline,
|
|
230
|
+
)
|
|
231
|
+
existing: Optional[socket.socket] = None
|
|
232
|
+
with self._hub_lock:
|
|
233
|
+
existing = self._hub_sock
|
|
234
|
+
self._hub_sock = None
|
|
235
|
+
if existing is not None:
|
|
236
|
+
try:
|
|
237
|
+
existing.shutdown(socket.SHUT_RDWR)
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
try:
|
|
241
|
+
existing.close()
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
self._notify_hub_state(False)
|
|
245
|
+
self._signal_wake()
|
|
246
|
+
|
|
247
|
+
def _ota_pause_active(self) -> bool:
|
|
248
|
+
return self._ota_pause_until > time.time()
|
|
249
|
+
|
|
250
|
+
def enable_proxy(self) -> None:
|
|
251
|
+
self._proxy_enabled = True
|
|
252
|
+
self._log.info("%s enabled", LogTag.PROXY)
|
|
253
|
+
if self._discovery_enabled and not self._notify_registered and not self.is_client_connected:
|
|
254
|
+
self._register_demuxer()
|
|
255
|
+
|
|
256
|
+
def disable_proxy(self) -> None:
|
|
257
|
+
self._proxy_enabled = False
|
|
258
|
+
self._log.info("%s disabled (existing TCP sessions stay alive)", LogTag.PROXY)
|
|
259
|
+
self._stop_notify_listener()
|
|
260
|
+
|
|
261
|
+
def can_issue_commands(self) -> bool:
|
|
262
|
+
# Two preconditions: the hub must be TCP-connected (otherwise
|
|
263
|
+
# frames go into _local_to_hub and are silently discarded by the
|
|
264
|
+
# bridge loop when it notices there's no hub socket), and the
|
|
265
|
+
# real Sofabaton app must NOT be attached to the proxy (when it
|
|
266
|
+
# is, the app owns the session and the proxy must not race it).
|
|
267
|
+
return self.is_hub_connected and not self.is_client_connected
|
|
268
|
+
|
|
269
|
+
def send_local(self, payload: bytes) -> None:
|
|
270
|
+
self._local_to_hub.extend(payload)
|
|
271
|
+
self._signal_wake()
|
|
272
|
+
|
|
273
|
+
# ------------------------------------------------------------------
|
|
274
|
+
# Networking lifecycle
|
|
275
|
+
# ------------------------------------------------------------------
|
|
276
|
+
def start(self, *, udp_port: Optional[int] = None) -> None:
|
|
277
|
+
self._stop.clear()
|
|
278
|
+
self._init_wake_channel()
|
|
279
|
+
if udp_port is not None:
|
|
280
|
+
self.proxy_udp_port = udp_port
|
|
281
|
+
demuxer = get_notify_demuxer(self.proxy_udp_port)
|
|
282
|
+
self.proxy_udp_port = demuxer.listen_port
|
|
283
|
+
|
|
284
|
+
# Hand the inbound TCP socket off to the shared listener; it
|
|
285
|
+
# dispatches by peer IP and calls _install_hub_socket on accept.
|
|
286
|
+
listener = get_hub_listener(self.hub_listen_base)
|
|
287
|
+
self.hub_listen_base = listener.register_hub(
|
|
288
|
+
proxy_id=self.proxy_id,
|
|
289
|
+
real_hub_ip=self.real_hub_ip,
|
|
290
|
+
on_socket=self._install_hub_socket,
|
|
291
|
+
)
|
|
292
|
+
self._listener_registered = True
|
|
293
|
+
|
|
294
|
+
self._call_me_thr = threading.Thread(
|
|
295
|
+
target=self._call_me_loop, name="x1proxy-call-me", daemon=True
|
|
296
|
+
)
|
|
297
|
+
self._call_me_thr.start()
|
|
298
|
+
|
|
299
|
+
self._bridge_thr = threading.Thread(
|
|
300
|
+
target=self._bridge_forever, name="x1proxy-bridge", daemon=True
|
|
301
|
+
)
|
|
302
|
+
self._bridge_thr.start()
|
|
303
|
+
|
|
304
|
+
def stop(self) -> None:
|
|
305
|
+
self._stop.set()
|
|
306
|
+
self._signal_wake()
|
|
307
|
+
self._stop_notify_listener()
|
|
308
|
+
if self._listener_registered:
|
|
309
|
+
try:
|
|
310
|
+
get_hub_listener().unregister_hub(self.proxy_id)
|
|
311
|
+
except Exception:
|
|
312
|
+
self._log.exception("%s hub listener unregister failed", LogTag.TRANSPORT)
|
|
313
|
+
self._listener_registered = False
|
|
314
|
+
|
|
315
|
+
with self._hub_lock:
|
|
316
|
+
if self._hub_sock:
|
|
317
|
+
try:
|
|
318
|
+
self._hub_sock.shutdown(socket.SHUT_RDWR)
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
try:
|
|
322
|
+
self._hub_sock.close()
|
|
323
|
+
except Exception:
|
|
324
|
+
pass
|
|
325
|
+
self._hub_sock = None
|
|
326
|
+
self._notify_hub_state(False)
|
|
327
|
+
|
|
328
|
+
with self._app_lock:
|
|
329
|
+
if self._app_sock:
|
|
330
|
+
try:
|
|
331
|
+
self._app_sock.shutdown(socket.SHUT_RDWR)
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
try:
|
|
335
|
+
self._app_sock.close()
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
self._app_sock = None
|
|
339
|
+
self._notify_client_state(False)
|
|
340
|
+
|
|
341
|
+
self._close_wake_channel()
|
|
342
|
+
self._log.info("%s stopped", LogTag.TRANSPORT)
|
|
343
|
+
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
# Internals
|
|
346
|
+
# ------------------------------------------------------------------
|
|
347
|
+
def _register_demuxer(self) -> None:
|
|
348
|
+
if self._notify_registered:
|
|
349
|
+
return
|
|
350
|
+
get_notify_demuxer(self.proxy_udp_port).register_proxy(
|
|
351
|
+
proxy_id=self.proxy_id,
|
|
352
|
+
real_hub_ip=self.real_hub_ip,
|
|
353
|
+
mdns_txt=self._mdns_txt,
|
|
354
|
+
call_me_port=self.proxy_udp_port,
|
|
355
|
+
call_me_cb=self._handle_call_me,
|
|
356
|
+
)
|
|
357
|
+
self._notify_registered = True
|
|
358
|
+
|
|
359
|
+
def update_discovery_metadata(
|
|
360
|
+
self,
|
|
361
|
+
*,
|
|
362
|
+
mdns_txt: Dict[str, str],
|
|
363
|
+
) -> None:
|
|
364
|
+
self._mdns_txt = mdns_txt
|
|
365
|
+
|
|
366
|
+
def start_notify_listener(self) -> None:
|
|
367
|
+
self._discovery_enabled = True
|
|
368
|
+
if self._proxy_enabled and not self.is_client_connected:
|
|
369
|
+
self._register_demuxer()
|
|
370
|
+
|
|
371
|
+
def stop_notify_listener(self) -> None:
|
|
372
|
+
self._discovery_enabled = False
|
|
373
|
+
self._stop_notify_listener()
|
|
374
|
+
|
|
375
|
+
def set_busy_gate(self, gate: Optional[Callable[[], bool]]) -> None:
|
|
376
|
+
"""Register a callable that suppresses CALL_ME handling when truthy.
|
|
377
|
+
|
|
378
|
+
Used to ignore proxy-client CALL_ME pings while the hub is occupied
|
|
379
|
+
with a long-running task (backup, restore, command-config sync) so
|
|
380
|
+
the in-flight TCP session is not interrupted.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
self._busy_gate = gate
|
|
384
|
+
|
|
385
|
+
def _handle_call_me(
|
|
386
|
+
self, src_ip: str, src_port: int, app_ip: str, app_port: int
|
|
387
|
+
) -> None:
|
|
388
|
+
if not self._proxy_enabled or self._stop.is_set():
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
gate = self._busy_gate
|
|
392
|
+
if gate is not None:
|
|
393
|
+
try:
|
|
394
|
+
busy = bool(gate())
|
|
395
|
+
except Exception:
|
|
396
|
+
self._log.exception("%s busy gate raised", LogTag.TRANSPORT)
|
|
397
|
+
busy = False
|
|
398
|
+
if busy:
|
|
399
|
+
self._log.info(
|
|
400
|
+
"%s APP CALL_ME from %s:%d ignored (hub busy with long-running task)",
|
|
401
|
+
LogTag.TRANSPORT,
|
|
402
|
+
src_ip,
|
|
403
|
+
src_port,
|
|
404
|
+
)
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
self._log.info(
|
|
408
|
+
"%s APP CALL_ME from %s:%d -> app tcp %s:%d",
|
|
409
|
+
LogTag.TRANSPORT,
|
|
410
|
+
src_ip,
|
|
411
|
+
src_port,
|
|
412
|
+
app_ip,
|
|
413
|
+
app_port,
|
|
414
|
+
)
|
|
415
|
+
threading.Thread(
|
|
416
|
+
target=self._handle_app_session,
|
|
417
|
+
args=((app_ip, app_port),),
|
|
418
|
+
name="x1proxy-app-connect",
|
|
419
|
+
daemon=True,
|
|
420
|
+
).start()
|
|
421
|
+
|
|
422
|
+
def _call_me_loop(self) -> None:
|
|
423
|
+
"""Send periodic UDP CALL_ME pings while the hub is not connected.
|
|
424
|
+
|
|
425
|
+
The hub responds by dialling back to the shared TCP listener
|
|
426
|
+
(see ``hub_listener.py``), which hands the accepted socket to
|
|
427
|
+
:meth:`_install_hub_socket`. This loop owns only the UDP side;
|
|
428
|
+
TCP accept lives in the shared :class:`HubListener`.
|
|
429
|
+
"""
|
|
430
|
+
|
|
431
|
+
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
432
|
+
try:
|
|
433
|
+
last = 0.0
|
|
434
|
+
while not self._stop.is_set():
|
|
435
|
+
if self.is_hub_connected:
|
|
436
|
+
time.sleep(0.3)
|
|
437
|
+
continue
|
|
438
|
+
if self._ota_pause_active():
|
|
439
|
+
time.sleep(0.5)
|
|
440
|
+
continue
|
|
441
|
+
now = time.time()
|
|
442
|
+
if now - last >= 2.0 + random.uniform(-0.25, 0.25):
|
|
443
|
+
try:
|
|
444
|
+
my_ip = _route_local_ip(self.real_hub_ip)
|
|
445
|
+
payload = (
|
|
446
|
+
b"\x00" * 6
|
|
447
|
+
+ socket.inet_aton(my_ip)
|
|
448
|
+
+ struct.pack(">H", self.hub_listen_base)
|
|
449
|
+
)
|
|
450
|
+
frame = (
|
|
451
|
+
bytes([SYNC0, SYNC1, (OP_CALL_ME >> 8) & 0xFF, OP_CALL_ME & 0xFF])
|
|
452
|
+
+ payload
|
|
453
|
+
)
|
|
454
|
+
frame += bytes([_sum8(frame)])
|
|
455
|
+
udp.sendto(frame, (self.real_hub_ip, self.real_hub_udp_port))
|
|
456
|
+
except OSError:
|
|
457
|
+
self._log.debug("%s CALL_ME send failed", LogTag.TRANSPORT, exc_info=True)
|
|
458
|
+
last = now
|
|
459
|
+
time.sleep(0.2)
|
|
460
|
+
finally:
|
|
461
|
+
try:
|
|
462
|
+
udp.close()
|
|
463
|
+
except Exception:
|
|
464
|
+
pass
|
|
465
|
+
|
|
466
|
+
def _install_hub_socket(
|
|
467
|
+
self, hub_sock: socket.socket, hub_addr: Tuple[str, int]
|
|
468
|
+
) -> None:
|
|
469
|
+
"""Callback invoked by :class:`HubListener` when the hub TCP-connects."""
|
|
470
|
+
|
|
471
|
+
if self._ota_pause_active():
|
|
472
|
+
self._log.info(
|
|
473
|
+
"%s rejecting hub TCP from %s:%d during OTA pause (%.0fs left)",
|
|
474
|
+
LogTag.TRANSPORT,
|
|
475
|
+
hub_addr[0],
|
|
476
|
+
hub_addr[1],
|
|
477
|
+
self._ota_pause_until - time.time(),
|
|
478
|
+
)
|
|
479
|
+
try:
|
|
480
|
+
hub_sock.shutdown(socket.SHUT_RDWR)
|
|
481
|
+
except Exception:
|
|
482
|
+
pass
|
|
483
|
+
try:
|
|
484
|
+
hub_sock.close()
|
|
485
|
+
except Exception:
|
|
486
|
+
pass
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
hub_sock.settimeout(0.0)
|
|
491
|
+
_disable_nagle(hub_sock)
|
|
492
|
+
_enable_keepalive(
|
|
493
|
+
hub_sock,
|
|
494
|
+
idle=self.ka_idle,
|
|
495
|
+
interval=self.ka_interval,
|
|
496
|
+
count=self.ka_count,
|
|
497
|
+
)
|
|
498
|
+
except Exception:
|
|
499
|
+
self._log.exception("%s failed to configure hub socket", LogTag.TRANSPORT)
|
|
500
|
+
try:
|
|
501
|
+
hub_sock.close()
|
|
502
|
+
except Exception:
|
|
503
|
+
pass
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
existing: Optional[socket.socket] = None
|
|
507
|
+
with self._hub_lock:
|
|
508
|
+
existing = self._hub_sock
|
|
509
|
+
self._hub_sock = hub_sock
|
|
510
|
+
if existing is not None:
|
|
511
|
+
self._log.warning(
|
|
512
|
+
"%s replacing existing hub socket on new connection from %s:%d",
|
|
513
|
+
LogTag.TRANSPORT,
|
|
514
|
+
*hub_addr,
|
|
515
|
+
)
|
|
516
|
+
try:
|
|
517
|
+
existing.shutdown(socket.SHUT_RDWR)
|
|
518
|
+
except Exception:
|
|
519
|
+
pass
|
|
520
|
+
try:
|
|
521
|
+
existing.close()
|
|
522
|
+
except Exception:
|
|
523
|
+
pass
|
|
524
|
+
|
|
525
|
+
self._notify_hub_state(True)
|
|
526
|
+
self._signal_wake()
|
|
527
|
+
self._log.info("%s connected <- HUB %s:%d (shared listener)", LogTag.TRANSPORT, *hub_addr)
|
|
528
|
+
|
|
529
|
+
def _handle_app_session(self, app_addr: Tuple[str, int]) -> None:
|
|
530
|
+
self._stop_notify_listener()
|
|
531
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
532
|
+
try:
|
|
533
|
+
s.settimeout(5.0)
|
|
534
|
+
s.connect(app_addr)
|
|
535
|
+
s.settimeout(0.0)
|
|
536
|
+
_disable_nagle(s)
|
|
537
|
+
_enable_keepalive(
|
|
538
|
+
s, idle=self.ka_idle, interval=self.ka_interval, count=self.ka_count
|
|
539
|
+
)
|
|
540
|
+
with self._app_lock:
|
|
541
|
+
if self._app_sock is not None:
|
|
542
|
+
try:
|
|
543
|
+
self._app_sock.shutdown(socket.SHUT_RDWR)
|
|
544
|
+
except Exception:
|
|
545
|
+
pass
|
|
546
|
+
try:
|
|
547
|
+
self._app_sock.close()
|
|
548
|
+
except Exception:
|
|
549
|
+
pass
|
|
550
|
+
self._app_sock = s
|
|
551
|
+
self._log.info("%s connected -> APP %s:%d", LogTag.TRANSPORT, *app_addr)
|
|
552
|
+
self._notify_client_state(True)
|
|
553
|
+
self._emit_connect_ready_beacon(app_addr[0])
|
|
554
|
+
except Exception:
|
|
555
|
+
try:
|
|
556
|
+
s.close()
|
|
557
|
+
except Exception:
|
|
558
|
+
pass
|
|
559
|
+
if self._proxy_enabled:
|
|
560
|
+
self._register_demuxer()
|
|
561
|
+
self._log.exception("%s failed to connect -> APP %s:%d", LogTag.TRANSPORT, *app_addr)
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
def _emit_connect_ready_beacon(self, app_ip: str) -> None:
|
|
565
|
+
return
|
|
566
|
+
|
|
567
|
+
def _bridge_forever(self) -> None:
|
|
568
|
+
app_to_hub = bytearray()
|
|
569
|
+
hub_to_app = bytearray()
|
|
570
|
+
app_partial_frame = bytearray()
|
|
571
|
+
|
|
572
|
+
while not self._stop.is_set():
|
|
573
|
+
with self._hub_lock:
|
|
574
|
+
hub = self._hub_sock
|
|
575
|
+
with self._app_lock:
|
|
576
|
+
app = self._app_sock
|
|
577
|
+
with self._wake_lock:
|
|
578
|
+
wake_reader = self._wake_reader
|
|
579
|
+
|
|
580
|
+
rlist: List[socket.socket] = []
|
|
581
|
+
if hub is not None:
|
|
582
|
+
rlist.append(hub)
|
|
583
|
+
if app is not None:
|
|
584
|
+
rlist.append(app)
|
|
585
|
+
if wake_reader is not None:
|
|
586
|
+
rlist.append(wake_reader)
|
|
587
|
+
|
|
588
|
+
wlist: List[socket.socket] = []
|
|
589
|
+
if hub is not None and (app_to_hub or self._local_to_hub):
|
|
590
|
+
wlist.append(hub)
|
|
591
|
+
if app is not None and hub_to_app:
|
|
592
|
+
wlist.append(app)
|
|
593
|
+
|
|
594
|
+
if not rlist and not wlist:
|
|
595
|
+
time.sleep(0.05)
|
|
596
|
+
continue
|
|
597
|
+
|
|
598
|
+
try:
|
|
599
|
+
r, w, _ = select.select(rlist, wlist, [], 0.5)
|
|
600
|
+
except (OSError, ValueError):
|
|
601
|
+
time.sleep(0.05)
|
|
602
|
+
continue
|
|
603
|
+
|
|
604
|
+
if wake_reader is not None and wake_reader in r:
|
|
605
|
+
self._drain_wake_socket(wake_reader)
|
|
606
|
+
|
|
607
|
+
if hub is not None and hub in r:
|
|
608
|
+
try:
|
|
609
|
+
data = hub.recv(65536)
|
|
610
|
+
except BlockingIOError:
|
|
611
|
+
data = None
|
|
612
|
+
except OSError as exc:
|
|
613
|
+
if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
|
|
614
|
+
data = None
|
|
615
|
+
else:
|
|
616
|
+
self._log.debug("%s hub recv failed", LogTag.TRANSPORT, exc_info=exc)
|
|
617
|
+
data = b""
|
|
618
|
+
if data is None:
|
|
619
|
+
pass
|
|
620
|
+
elif not data:
|
|
621
|
+
with self._hub_lock:
|
|
622
|
+
try:
|
|
623
|
+
hub.shutdown(socket.SHUT_RDWR)
|
|
624
|
+
except Exception:
|
|
625
|
+
pass
|
|
626
|
+
try:
|
|
627
|
+
hub.close()
|
|
628
|
+
except Exception:
|
|
629
|
+
pass
|
|
630
|
+
self._hub_sock = None
|
|
631
|
+
self._notify_hub_state(False)
|
|
632
|
+
app_to_hub.clear()
|
|
633
|
+
else:
|
|
634
|
+
self._chunk_id += 1
|
|
635
|
+
cid = self._chunk_id
|
|
636
|
+
for cb in self._hub_frame_cbs:
|
|
637
|
+
cb(data, cid)
|
|
638
|
+
if app is not None:
|
|
639
|
+
hub_to_app.extend(data)
|
|
640
|
+
|
|
641
|
+
if app is not None and app in r:
|
|
642
|
+
try:
|
|
643
|
+
data = app.recv(65536)
|
|
644
|
+
except BlockingIOError:
|
|
645
|
+
data = None
|
|
646
|
+
except OSError as exc:
|
|
647
|
+
if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
|
|
648
|
+
data = None
|
|
649
|
+
else:
|
|
650
|
+
self._log.debug("%s app recv failed", LogTag.TRANSPORT, exc_info=exc)
|
|
651
|
+
data = b""
|
|
652
|
+
if data is None:
|
|
653
|
+
pass
|
|
654
|
+
elif not data:
|
|
655
|
+
with self._app_lock:
|
|
656
|
+
try:
|
|
657
|
+
app.shutdown(socket.SHUT_RDWR)
|
|
658
|
+
except Exception:
|
|
659
|
+
pass
|
|
660
|
+
try:
|
|
661
|
+
app.close()
|
|
662
|
+
except Exception:
|
|
663
|
+
pass
|
|
664
|
+
self._app_sock = None
|
|
665
|
+
app_to_hub.clear()
|
|
666
|
+
app_partial_frame.clear()
|
|
667
|
+
hub_to_app.clear()
|
|
668
|
+
self._notify_client_state(False)
|
|
669
|
+
else:
|
|
670
|
+
self._chunk_id += 1
|
|
671
|
+
cid = self._chunk_id
|
|
672
|
+
for cb in self._app_frame_cbs:
|
|
673
|
+
cb(data, cid)
|
|
674
|
+
# Split the app-side stream into whole frames using
|
|
675
|
+
# the opcode-hi length invariant (frame_len = 5 +
|
|
676
|
+
# buf[2]). See docs/protocol/frame-format.md.
|
|
677
|
+
buffer = bytearray(app_partial_frame)
|
|
678
|
+
buffer.extend(data)
|
|
679
|
+
app_partial_frame.clear()
|
|
680
|
+
|
|
681
|
+
frames_to_send: list[bytes] = []
|
|
682
|
+
while True:
|
|
683
|
+
if len(buffer) < 2:
|
|
684
|
+
break
|
|
685
|
+
if buffer[0] != SYNC0 or buffer[1] != SYNC1:
|
|
686
|
+
idx = buffer.find(bytes([SYNC0, SYNC1]))
|
|
687
|
+
if idx < 0:
|
|
688
|
+
# Keep a trailing lone SYNC0 across reads.
|
|
689
|
+
if buffer and buffer[-1] == SYNC0:
|
|
690
|
+
del buffer[:-1]
|
|
691
|
+
else:
|
|
692
|
+
buffer.clear()
|
|
693
|
+
break
|
|
694
|
+
if idx and self._log.isEnabledFor(logging.DEBUG):
|
|
695
|
+
self._log.debug(
|
|
696
|
+
"%s drop %dB junk before sync (client→hub)",
|
|
697
|
+
LogTag.PARSE,
|
|
698
|
+
idx,
|
|
699
|
+
)
|
|
700
|
+
del buffer[:idx]
|
|
701
|
+
if len(buffer) < 5:
|
|
702
|
+
break
|
|
703
|
+
frame_len = 5 + buffer[2]
|
|
704
|
+
if len(buffer) < frame_len:
|
|
705
|
+
break
|
|
706
|
+
cand = bytes(buffer[:frame_len])
|
|
707
|
+
if cand[-1] == (_sum8(cand[:-1]) & 0xFF):
|
|
708
|
+
frames_to_send.append(cand)
|
|
709
|
+
del buffer[:frame_len]
|
|
710
|
+
continue
|
|
711
|
+
# Bad checksum at this sync — drop one byte and
|
|
712
|
+
# rescan for the next sync pair.
|
|
713
|
+
if self._log.isEnabledFor(logging.DEBUG):
|
|
714
|
+
self._log.debug(
|
|
715
|
+
"%s drop malformed frame len=%d (client→hub)",
|
|
716
|
+
LogTag.PARSE,
|
|
717
|
+
frame_len,
|
|
718
|
+
)
|
|
719
|
+
del buffer[0]
|
|
720
|
+
|
|
721
|
+
app_partial_frame.extend(buffer)
|
|
722
|
+
|
|
723
|
+
for idx, frame in enumerate(frames_to_send):
|
|
724
|
+
app_to_hub.extend(frame)
|
|
725
|
+
if hub is not None:
|
|
726
|
+
if _flush_buffer(hub, app_to_hub, "client", self._log):
|
|
727
|
+
with self._hub_lock:
|
|
728
|
+
try:
|
|
729
|
+
hub.shutdown(socket.SHUT_RDWR)
|
|
730
|
+
except Exception:
|
|
731
|
+
pass
|
|
732
|
+
try:
|
|
733
|
+
hub.close()
|
|
734
|
+
except Exception:
|
|
735
|
+
pass
|
|
736
|
+
self._hub_sock = None
|
|
737
|
+
self._notify_hub_state(False)
|
|
738
|
+
break
|
|
739
|
+
if (
|
|
740
|
+
self._inter_command_gap > 0
|
|
741
|
+
and idx + 1 < len(frames_to_send)
|
|
742
|
+
):
|
|
743
|
+
time.sleep(self._inter_command_gap)
|
|
744
|
+
|
|
745
|
+
if hub is not None and hub in w:
|
|
746
|
+
if self._local_to_hub:
|
|
747
|
+
if _flush_buffer(hub, self._local_to_hub, "local", self._log):
|
|
748
|
+
with self._hub_lock:
|
|
749
|
+
try:
|
|
750
|
+
hub.shutdown(socket.SHUT_RDWR)
|
|
751
|
+
except Exception:
|
|
752
|
+
pass
|
|
753
|
+
try:
|
|
754
|
+
hub.close()
|
|
755
|
+
except Exception:
|
|
756
|
+
pass
|
|
757
|
+
self._hub_sock = None
|
|
758
|
+
self._notify_hub_state(False)
|
|
759
|
+
app_to_hub.clear()
|
|
760
|
+
continue
|
|
761
|
+
if app_to_hub:
|
|
762
|
+
if _flush_buffer(hub, app_to_hub, "client", self._log):
|
|
763
|
+
with self._hub_lock:
|
|
764
|
+
try:
|
|
765
|
+
hub.shutdown(socket.SHUT_RDWR)
|
|
766
|
+
except Exception:
|
|
767
|
+
pass
|
|
768
|
+
try:
|
|
769
|
+
hub.close()
|
|
770
|
+
except Exception:
|
|
771
|
+
pass
|
|
772
|
+
self._hub_sock = None
|
|
773
|
+
self._notify_hub_state(False)
|
|
774
|
+
app_to_hub.clear()
|
|
775
|
+
|
|
776
|
+
if app is not None and app in w:
|
|
777
|
+
if hub_to_app:
|
|
778
|
+
if _flush_buffer(app, hub_to_app, "hub", self._log):
|
|
779
|
+
with self._app_lock:
|
|
780
|
+
try:
|
|
781
|
+
app.shutdown(socket.SHUT_RDWR)
|
|
782
|
+
except Exception:
|
|
783
|
+
pass
|
|
784
|
+
try:
|
|
785
|
+
app.close()
|
|
786
|
+
except Exception:
|
|
787
|
+
pass
|
|
788
|
+
self._app_sock = None
|
|
789
|
+
hub_to_app.clear()
|
|
790
|
+
app_partial_frame.clear()
|
|
791
|
+
self._notify_client_state(False)
|
|
792
|
+
|
|
793
|
+
for cb in self._idle_cbs:
|
|
794
|
+
cb(time.monotonic())
|
|
795
|
+
|
|
796
|
+
if self._local_to_hub:
|
|
797
|
+
with self._hub_lock:
|
|
798
|
+
if self._hub_sock is None:
|
|
799
|
+
self._local_to_hub.clear()
|
|
800
|
+
|
|
801
|
+
self._close_wake_channel()
|
|
802
|
+
|
|
803
|
+
def _init_wake_channel(self) -> None:
|
|
804
|
+
self._close_wake_channel()
|
|
805
|
+
wake_reader, wake_writer = socket.socketpair()
|
|
806
|
+
wake_reader.setblocking(False)
|
|
807
|
+
wake_writer.setblocking(False)
|
|
808
|
+
with self._wake_lock:
|
|
809
|
+
self._wake_reader = wake_reader
|
|
810
|
+
self._wake_writer = wake_writer
|
|
811
|
+
|
|
812
|
+
def _signal_wake(self) -> None:
|
|
813
|
+
with self._wake_lock:
|
|
814
|
+
wake_writer = self._wake_writer
|
|
815
|
+
if wake_writer is None:
|
|
816
|
+
return
|
|
817
|
+
try:
|
|
818
|
+
wake_writer.send(b"\x00")
|
|
819
|
+
except (BlockingIOError, InterruptedError):
|
|
820
|
+
pass
|
|
821
|
+
except OSError:
|
|
822
|
+
pass
|
|
823
|
+
|
|
824
|
+
def _drain_wake_socket(self, wake_reader: socket.socket) -> None:
|
|
825
|
+
while True:
|
|
826
|
+
try:
|
|
827
|
+
chunk = wake_reader.recv(1024)
|
|
828
|
+
except (BlockingIOError, InterruptedError):
|
|
829
|
+
return
|
|
830
|
+
except OSError:
|
|
831
|
+
return
|
|
832
|
+
if not chunk:
|
|
833
|
+
return
|
|
834
|
+
|
|
835
|
+
def _close_wake_channel(self) -> None:
|
|
836
|
+
with self._wake_lock:
|
|
837
|
+
wake_reader = self._wake_reader
|
|
838
|
+
wake_writer = self._wake_writer
|
|
839
|
+
self._wake_reader = None
|
|
840
|
+
self._wake_writer = None
|
|
841
|
+
|
|
842
|
+
for sock in (wake_reader, wake_writer):
|
|
843
|
+
if sock is None:
|
|
844
|
+
continue
|
|
845
|
+
try:
|
|
846
|
+
sock.close()
|
|
847
|
+
except Exception:
|
|
848
|
+
pass
|
|
849
|
+
|
|
850
|
+
# ------------------------------------------------------------------
|
|
851
|
+
# Notifications
|
|
852
|
+
# ------------------------------------------------------------------
|
|
853
|
+
def _notify_hub_state(self, connected: bool) -> None:
|
|
854
|
+
for cb in self._hub_state_cbs:
|
|
855
|
+
try:
|
|
856
|
+
cb(connected)
|
|
857
|
+
except Exception:
|
|
858
|
+
self._log.exception("hub state listener failed")
|
|
859
|
+
|
|
860
|
+
def _notify_client_state(self, connected: bool) -> None:
|
|
861
|
+
if connected:
|
|
862
|
+
self._stop_notify_listener()
|
|
863
|
+
elif self._proxy_enabled and self._discovery_enabled:
|
|
864
|
+
self._register_demuxer()
|
|
865
|
+
for cb in self._client_state_cbs:
|
|
866
|
+
try:
|
|
867
|
+
cb(connected)
|
|
868
|
+
except Exception:
|
|
869
|
+
self._log.exception("client state listener failed")
|
|
870
|
+
|
|
871
|
+
def _stop_notify_listener(self) -> None:
|
|
872
|
+
if self._notify_registered:
|
|
873
|
+
get_notify_demuxer().unregister_proxy(self.proxy_id)
|
|
874
|
+
self._notify_registered = False
|
|
875
|
+
|
|
876
|
+
|