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.
@@ -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
+