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,434 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import ipaddress
5
+ import socket
6
+ import struct
7
+ import threading
8
+ import time
9
+ from dataclasses import dataclass
10
+ from typing import Callable, Dict, Optional
11
+
12
+ from .hub_versions import HUB_VERSION_X1, HUB_VERSION_X1S, HUB_VERSION_X2, classify_hub_version
13
+ from .hub_logging import get_hub_logger
14
+ from .protocol_const import OP_CALL_ME, SYNC0, SYNC1
15
+
16
+ log = logging.getLogger("x1proxy.notify")
17
+
18
+ NOTIFY_ME_PAYLOAD = bytes.fromhex("a55a00c1c0")
19
+ BROADCAST_LISTEN_PORT = 8100
20
+ _NOTIFY_BATCH_BYTES: dict[str, bytes] = {
21
+ HUB_VERSION_X1: bytes.fromhex("20210609"),
22
+ HUB_VERSION_X1S: bytes.fromhex("20221120"),
23
+ HUB_VERSION_X2: bytes.fromhex("20221120"),
24
+ }
25
+ _NOTIFY_FW_DEFAULTS: dict[str, int] = {
26
+ HUB_VERSION_X1: 17,
27
+ HUB_VERSION_X1S: 5,
28
+ HUB_VERSION_X2: 8,
29
+ }
30
+ _NOTIFY_MODEL_BYTES: dict[str, int] = {
31
+ HUB_VERSION_X1: 0x01,
32
+ HUB_VERSION_X1S: 0x02,
33
+ HUB_VERSION_X2: 0x03,
34
+ }
35
+
36
+
37
+ def _sum8(payload: bytes) -> int:
38
+ return sum(payload) & 0xFF
39
+
40
+
41
+ def _route_local_ip(peer_ip: str) -> str:
42
+ try:
43
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
44
+ s.connect((peer_ip, 80))
45
+ return s.getsockname()[0]
46
+ except Exception:
47
+ return "127.0.0.1"
48
+ finally:
49
+ try:
50
+ s.close()
51
+ except Exception:
52
+ pass
53
+
54
+
55
+ def _broadcast_ip(peer_ip: str) -> str:
56
+ try:
57
+ addr = ipaddress.ip_address(peer_ip)
58
+ network = ipaddress.ip_network(f"{addr}/24", strict=False)
59
+ return str(network.broadcast_address)
60
+ except ValueError:
61
+ return "255.255.255.255"
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class NotifyRegistration:
66
+ proxy_id: str
67
+ real_hub_ip: str
68
+ mdns_txt: Dict[str, str]
69
+ hub_version: str
70
+ call_me_port: int
71
+ call_me_cb: Callable[[str, int, str, int], None]
72
+ mac_bytes: bytes
73
+ device_id: bytes
74
+ call_me_hint: bytes
75
+
76
+
77
+ def _classify_or_x1(mdns_txt: Dict[str, str]) -> str:
78
+ """Classify ``mdns_txt`` for transport-layer NOTIFY routing.
79
+
80
+ NOTIFY framing only branches on the X1 vs X1S/X2 vs X2 envelope
81
+ shape; an unrecognised advertisement does not justify dropping the
82
+ listener registration, so this helper falls back to the X1 line
83
+ when classification fails. The shape is byte-compatible across the
84
+ rest of the family and the subsequent connect banner re-classifies
85
+ authoritatively.
86
+ """
87
+
88
+ try:
89
+ return classify_hub_version(mdns_txt)
90
+ except ValueError:
91
+ return HUB_VERSION_X1
92
+
93
+
94
+ def build_connect_ready_beacon(mdns_txt: Dict[str, str]) -> bytes:
95
+ """Build the UDP post-connect readiness beacon emitted by physical hubs."""
96
+
97
+ mac_bytes = NotifyDemuxer._extract_mac_bytes(mdns_txt)
98
+ hub_version = _classify_or_x1(mdns_txt)
99
+ _device_id, call_me_hint = NotifyDemuxer._build_device_identifiers(mac_bytes, hub_version)
100
+ frame = bytes([SYNC0, SYNC1, 0x07, 0xC4]) + call_me_hint + b"\x00"
101
+ return frame + bytes([_sum8(frame)])
102
+
103
+
104
+ class NotifyDemuxer:
105
+ """Listen for NOTIFY_ME broadcasts and respond for registered proxies."""
106
+
107
+ def __init__(self, listen_port: int = 8102) -> None:
108
+ self.listen_port = listen_port
109
+ self._sock: Optional[socket.socket] = None
110
+ self._thr: Optional[threading.Thread] = None
111
+ self._stop_event = threading.Event()
112
+ self._lock = threading.Lock()
113
+ self._registrations: Dict[str, NotifyRegistration] = {}
114
+ self._last_reply: Dict[tuple[str, int, str], float] = {}
115
+
116
+ # ------------------------------------------------------------------
117
+ # Public API
118
+ # ------------------------------------------------------------------
119
+ def register_proxy(
120
+ self,
121
+ proxy_id: str,
122
+ real_hub_ip: str,
123
+ mdns_txt: Dict[str, str],
124
+ call_me_port: int,
125
+ call_me_cb: Callable[[str, int, str, int], None],
126
+ ) -> None:
127
+ mac_bytes = self._extract_mac_bytes(mdns_txt)
128
+ hub_version = _classify_or_x1(mdns_txt)
129
+ device_id, call_me_hint = self._build_device_identifiers(
130
+ mac_bytes, hub_version
131
+ )
132
+ reg = NotifyRegistration(
133
+ proxy_id,
134
+ real_hub_ip,
135
+ dict(mdns_txt),
136
+ hub_version,
137
+ int(call_me_port),
138
+ call_me_cb,
139
+ mac_bytes,
140
+ device_id,
141
+ call_me_hint,
142
+ )
143
+ with self._lock:
144
+ self._registrations[proxy_id] = reg
145
+ get_hub_logger(log, proxy_id).info(
146
+ "[DEMUX] registered proxy for hub %s (CALL_ME -> %s:%d)",
147
+ real_hub_ip,
148
+ _route_local_ip(real_hub_ip),
149
+ reg.call_me_port,
150
+ )
151
+ self._ensure_running_locked()
152
+
153
+ def unregister_proxy(self, proxy_id: str) -> None:
154
+ with self._lock:
155
+ if proxy_id in self._registrations:
156
+ self._registrations.pop(proxy_id, None)
157
+ get_hub_logger(log, proxy_id).info("[DEMUX] unregistered proxy")
158
+ self._stop_if_idle_locked()
159
+
160
+ def shutdown(self) -> None:
161
+ with self._lock:
162
+ self._registrations.clear()
163
+ self._stop_thread_locked()
164
+
165
+ # ------------------------------------------------------------------
166
+ # Internals
167
+ # ------------------------------------------------------------------
168
+ def _ensure_running_locked(self) -> None:
169
+ if self._thr and self._thr.is_alive():
170
+ return
171
+ self._stop_event = threading.Event()
172
+ self._sock = self._open_socket()
173
+ self._thr = threading.Thread(
174
+ target=self._notify_loop, name="x1proxy-notify-demux", daemon=True
175
+ )
176
+ self._thr.start()
177
+
178
+ def _stop_if_idle_locked(self) -> None:
179
+ if self._registrations:
180
+ return
181
+ self._stop_thread_locked()
182
+
183
+ def _stop_thread_locked(self) -> None:
184
+ self._stop_event.set()
185
+ if self._sock is not None:
186
+ try:
187
+ self._sock.close()
188
+ except Exception:
189
+ pass
190
+ self._sock = None
191
+ if self._thr is not None:
192
+ self._thr.join(timeout=1.0)
193
+ self._thr = None
194
+ self._last_reply.clear()
195
+
196
+ def _open_socket(self) -> socket.socket:
197
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
198
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
199
+ reuseport_enabled = False
200
+ reuseport_opt = getattr(socket, "SO_REUSEPORT", None)
201
+ if reuseport_opt is not None:
202
+ try:
203
+ s.setsockopt(socket.SOL_SOCKET, reuseport_opt, 1)
204
+ reuseport_enabled = True
205
+ except OSError:
206
+ log.warning("[DEMUX] SO_REUSEPORT not available")
207
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
208
+ s.bind(("0.0.0.0", self.listen_port))
209
+ s.settimeout(1.0)
210
+ log.info(
211
+ "[DEMUX] listening for NOTIFY_ME/CALL_ME on *:%d (SO_REUSEPORT=%s)",
212
+ self.listen_port,
213
+ reuseport_enabled,
214
+ )
215
+ return s
216
+
217
+ def _notify_loop(self) -> None:
218
+ sock = self._sock
219
+ if sock is None:
220
+ return
221
+ while not self._stop_event.is_set():
222
+ try:
223
+ pkt, (src_ip, src_port) = sock.recvfrom(2048)
224
+ except socket.timeout:
225
+ continue
226
+ except OSError:
227
+ break
228
+
229
+ if pkt == NOTIFY_ME_PAYLOAD:
230
+ self._handle_notify_me(sock, pkt, src_ip, src_port)
231
+ continue
232
+
233
+ if len(pkt) >= 16 and pkt[0] == SYNC0 and pkt[1] == SYNC1:
234
+ op = (pkt[2] << 8) | pkt[3]
235
+ if op == OP_CALL_ME:
236
+ self._handle_call_me(pkt, src_ip, src_port)
237
+
238
+ def _build_notify_reply(self, reg: NotifyRegistration) -> Optional[bytes]:
239
+ name = (
240
+ reg.mdns_txt.get("NAME")
241
+ or reg.mdns_txt.get("name")
242
+ or reg.mdns_txt.get("Name")
243
+ or "X1 Hub"
244
+ ).encode("utf-8")
245
+ # Physical hubs use a variable-length UTF-8 name field. The byte after the
246
+ # sync header is a length covering the 6-byte device-id tail, 9-byte version
247
+ # block, and UTF-8 name bytes; it does not count the fixed leading 0xC2.
248
+ name_bytes = name[:30]
249
+ version_block = self._build_notify_version_block(reg)
250
+ payload_len = (len(reg.device_id) - 1) + len(version_block) + len(name_bytes)
251
+ frame = (
252
+ bytes([SYNC0, SYNC1, payload_len & 0xFF])
253
+ + reg.device_id
254
+ + version_block
255
+ + name_bytes
256
+ )
257
+ frame += bytes([_sum8(frame)])
258
+
259
+ get_hub_logger(log, reg.proxy_id).info(
260
+ "[DEMUX][REPLY] mac=%s name=%s",
261
+ reg.mac_bytes.hex(":"),
262
+ name.decode("utf-8", "ignore"),
263
+ )
264
+ return frame
265
+
266
+ def _build_notify_version_block(self, reg: NotifyRegistration) -> bytes:
267
+ hub_version = reg.hub_version
268
+ model_byte = _NOTIFY_MODEL_BYTES.get(
269
+ hub_version, _NOTIFY_MODEL_BYTES[HUB_VERSION_X1]
270
+ )
271
+ batch_bytes = _NOTIFY_BATCH_BYTES.get(
272
+ hub_version, _NOTIFY_BATCH_BYTES[HUB_VERSION_X1]
273
+ )
274
+ firmware_version = self._extract_firmware_version(reg.mdns_txt, hub_version)
275
+ tail_flags = (
276
+ b"\x00\x00" if hub_version == HUB_VERSION_X1 else b"\x01\x00"
277
+ )
278
+ return (
279
+ bytes([0x64, model_byte])
280
+ + batch_bytes
281
+ + bytes([firmware_version])
282
+ + tail_flags
283
+ )
284
+
285
+ def _extract_firmware_version(self, mdns_txt: Dict[str, str], hub_version: str) -> int:
286
+ raw = mdns_txt.get("AVER")
287
+ try:
288
+ if raw is not None:
289
+ value = int(str(raw).strip(), 10)
290
+ if 0 <= value <= 0xFF:
291
+ return value
292
+ except (TypeError, ValueError):
293
+ pass
294
+ return _NOTIFY_FW_DEFAULTS.get(hub_version, _NOTIFY_FW_DEFAULTS[HUB_VERSION_X1])
295
+
296
+ def _handle_notify_me(
297
+ self, sock: socket.socket, pkt: bytes, src_ip: str, src_port: int
298
+ ) -> None:
299
+ with self._lock:
300
+ registrations = list(self._registrations.values())
301
+
302
+ for reg in registrations:
303
+ key = (src_ip, src_port, reg.proxy_id)
304
+ now = time.monotonic()
305
+ last = self._last_reply.get(key, 0.0)
306
+ if now - last < 2.0:
307
+ continue
308
+
309
+ reply = self._build_notify_reply(reg)
310
+ if reply is None:
311
+ continue
312
+
313
+ self._last_reply[key] = now
314
+ dest_ip = _broadcast_ip(src_ip)
315
+ get_hub_logger(log, reg.proxy_id).info(
316
+ "[DEMUX] NOTIFY_ME from %s:%d -> CALL_ME=%d broadcast=%s",
317
+ src_ip,
318
+ src_port,
319
+ reg.call_me_port,
320
+ dest_ip,
321
+ )
322
+ try:
323
+ sock.sendto(reply, (dest_ip, BROADCAST_LISTEN_PORT))
324
+ except OSError:
325
+ get_hub_logger(log, reg.proxy_id).exception("[DEMUX] failed to send NOTIFY_ME reply")
326
+
327
+ def _handle_call_me(self, pkt: bytes, src_ip: str, src_port: int) -> None:
328
+ try:
329
+ app_ip = socket.inet_ntoa(pkt[10:14])
330
+ app_port = struct.unpack(">H", pkt[14:16])[0]
331
+ except Exception:
332
+ return
333
+
334
+ mac_hint = pkt[4:10]
335
+ with self._lock:
336
+ registrations = list(self._registrations.values())
337
+
338
+ reg = self._select_registration(mac_hint, registrations)
339
+ if reg is None:
340
+ log.warning(
341
+ "[DEMUX] CALL_ME from %s:%d ignored (no proxy match, mac=%s)",
342
+ src_ip,
343
+ src_port,
344
+ mac_hint.hex(":"),
345
+ )
346
+ return
347
+
348
+ get_hub_logger(log, reg.proxy_id).info(
349
+ "[DEMUX] CALL_ME from %s:%d -> app tcp %s:%d",
350
+ src_ip,
351
+ src_port,
352
+ app_ip,
353
+ app_port,
354
+ )
355
+ try:
356
+ reg.call_me_cb(src_ip, src_port, app_ip, app_port)
357
+ except Exception:
358
+ get_hub_logger(log, reg.proxy_id).exception("[DEMUX] proxy callback failed")
359
+
360
+ def _select_registration(
361
+ self, mac_hint: bytes, registrations: list[NotifyRegistration]
362
+ ) -> Optional[NotifyRegistration]:
363
+ if not registrations:
364
+ return None
365
+
366
+ has_hint = mac_hint and any(mac_hint)
367
+ if has_hint:
368
+ for reg in registrations:
369
+ if reg.call_me_hint == mac_hint:
370
+ return reg
371
+ for reg in registrations:
372
+ if reg.mac_bytes == mac_hint:
373
+ return reg
374
+
375
+ if len(registrations) == 1:
376
+ return registrations[0]
377
+
378
+ return None
379
+
380
+ @staticmethod
381
+ def _extract_mac_bytes(mdns_txt: Dict[str, str]) -> bytes:
382
+ try:
383
+ mac_raw = (
384
+ mdns_txt.get("MAC")
385
+ or mdns_txt.get("mac")
386
+ or mdns_txt.get("macaddress")
387
+ )
388
+ mac_bytes = (
389
+ bytes.fromhex(str(mac_raw).replace(":", "").replace("-", ""))
390
+ if mac_raw
391
+ else b""
392
+ )
393
+ except ValueError:
394
+ mac_bytes = b""
395
+
396
+ if len(mac_bytes) < 6:
397
+ mac_bytes = mac_bytes.ljust(6, b"\x00")
398
+ else:
399
+ mac_bytes = mac_bytes[:6]
400
+
401
+ return mac_bytes
402
+
403
+ @staticmethod
404
+ def _build_device_identifiers(mac_bytes: bytes, hub_version: str) -> tuple[bytes, bytes]:
405
+ """Return the device id used in NOTIFY_ME replies and the CALL_ME hint."""
406
+
407
+ required_prefix_byte = b"\xc2"
408
+ if hub_version == HUB_VERSION_X2:
409
+ device_id = required_prefix_byte + mac_bytes
410
+ call_me_hint = mac_bytes
411
+ return device_id, call_me_hint
412
+
413
+ static_id_suffix_byte = b"\x4b" if hub_version == HUB_VERSION_X1 else b"\x45"
414
+ unique_tail_mac = mac_bytes[0:5]
415
+ device_id = required_prefix_byte + unique_tail_mac + static_id_suffix_byte
416
+ call_me_hint = unique_tail_mac + static_id_suffix_byte
417
+
418
+ return device_id, call_me_hint
419
+
420
+
421
+ _GLOBAL_DEMUXER: Optional[NotifyDemuxer] = None
422
+
423
+
424
+ def get_notify_demuxer(listen_port: Optional[int] = None) -> NotifyDemuxer:
425
+ global _GLOBAL_DEMUXER
426
+ if _GLOBAL_DEMUXER is None:
427
+ _GLOBAL_DEMUXER = NotifyDemuxer(listen_port or 8102)
428
+ elif listen_port is not None and listen_port != _GLOBAL_DEMUXER.listen_port:
429
+ log.warning(
430
+ "[DEMUX] existing listener on %d (ignoring requested %d)",
431
+ _GLOBAL_DEMUXER.listen_port,
432
+ listen_port,
433
+ )
434
+ return _GLOBAL_DEMUXER