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
sofapython/discovery.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# discovery.py — LAN discovery of physical Sofabaton hubs via mDNS.
|
|
2
|
+
#
|
|
3
|
+
# Library-owned counterpart to the Home Assistant integration's zeroconf
|
|
4
|
+
# config flow: browses the hub service types, decodes TXT records,
|
|
5
|
+
# classifies the hub variant and (by default) filters out advertisements
|
|
6
|
+
# published by our own proxies (marked with PROXY_TXT_KEY).
|
|
7
|
+
#
|
|
8
|
+
# ``zeroconf`` is imported lazily so the package stays importable in
|
|
9
|
+
# environments that only use the parsing layers (mirrors x1_proxy's
|
|
10
|
+
# advertising path).
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Callable, Iterable, Optional
|
|
18
|
+
|
|
19
|
+
from .hub_versions import (
|
|
20
|
+
MDNS_SERVICE_TYPES,
|
|
21
|
+
classify_hub_version,
|
|
22
|
+
is_proxy_advertisement,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
DEFAULT_DISCOVERY_TIMEOUT = 5.0
|
|
28
|
+
# How long to wait for a ServiceInfo resolution per advertisement.
|
|
29
|
+
_INFO_REQUEST_TIMEOUT_MS = 3000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class DiscoveredHub:
|
|
34
|
+
"""One mDNS advertisement, normalized.
|
|
35
|
+
|
|
36
|
+
``hub_version`` is ``None`` when the advertisement carries no
|
|
37
|
+
recognisable ``HVER`` TXT record (unknown firmware lineage); the
|
|
38
|
+
raw ``txt`` dict is always preserved so callers can apply their own
|
|
39
|
+
policy, mirroring how the HA config flow re-evaluates later.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
host: str
|
|
43
|
+
port: int
|
|
44
|
+
name: str
|
|
45
|
+
mac: Optional[str]
|
|
46
|
+
txt: dict[str, str] = field(compare=False)
|
|
47
|
+
hub_version: Optional[str]
|
|
48
|
+
is_proxy: bool
|
|
49
|
+
service_type: str
|
|
50
|
+
instance_name: str = field(compare=False, default="")
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def key(self) -> tuple[str, str]:
|
|
54
|
+
"""Stable identity of the advertisement within a browse session."""
|
|
55
|
+
|
|
56
|
+
return (self.service_type, self.instance_name)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def decode_txt_properties(properties: Any) -> dict[str, str]:
|
|
60
|
+
"""Decode a zeroconf properties mapping into plain str->str.
|
|
61
|
+
|
|
62
|
+
zeroconf hands back ``dict[bytes, bytes | None]``; HA hands the
|
|
63
|
+
config flow already-decoded strings. Accept both, drop value-less
|
|
64
|
+
keys' values to empty strings, and ignore undecodable garbage.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
props: dict[str, str] = {}
|
|
68
|
+
if not properties:
|
|
69
|
+
return props
|
|
70
|
+
for key, value in dict(properties).items():
|
|
71
|
+
if isinstance(key, bytes):
|
|
72
|
+
try:
|
|
73
|
+
key = key.decode("utf-8")
|
|
74
|
+
except UnicodeDecodeError:
|
|
75
|
+
continue
|
|
76
|
+
if isinstance(value, bytes):
|
|
77
|
+
try:
|
|
78
|
+
value = value.decode("utf-8")
|
|
79
|
+
except UnicodeDecodeError:
|
|
80
|
+
continue
|
|
81
|
+
props[str(key)] = "" if value is None else str(value)
|
|
82
|
+
return props
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def normalize_advertisement(
|
|
86
|
+
service_type: str,
|
|
87
|
+
instance_name: str,
|
|
88
|
+
*,
|
|
89
|
+
host: Optional[str],
|
|
90
|
+
port: Optional[int],
|
|
91
|
+
properties: Any,
|
|
92
|
+
) -> Optional[DiscoveredHub]:
|
|
93
|
+
"""Build a :class:`DiscoveredHub` from raw advertisement details.
|
|
94
|
+
|
|
95
|
+
Returns ``None`` when the advertisement is unusable (no address) or
|
|
96
|
+
the service type is not a known hub type. Classification failures do
|
|
97
|
+
NOT reject the hub — ``hub_version`` is simply ``None``.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
if service_type not in MDNS_SERVICE_TYPES:
|
|
101
|
+
return None
|
|
102
|
+
if not host:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
txt = decode_txt_properties(properties)
|
|
106
|
+
label = instance_name.split(".")[0]
|
|
107
|
+
name = txt.get("NAME") or label
|
|
108
|
+
mac = txt.get("MAC") or None
|
|
109
|
+
try:
|
|
110
|
+
hub_version: Optional[str] = classify_hub_version(txt)
|
|
111
|
+
except ValueError:
|
|
112
|
+
hub_version = None
|
|
113
|
+
|
|
114
|
+
return DiscoveredHub(
|
|
115
|
+
host=host,
|
|
116
|
+
port=int(port) if port else 0,
|
|
117
|
+
name=name,
|
|
118
|
+
mac=mac,
|
|
119
|
+
txt=txt,
|
|
120
|
+
hub_version=hub_version,
|
|
121
|
+
is_proxy=is_proxy_advertisement(txt),
|
|
122
|
+
service_type=service_type,
|
|
123
|
+
instance_name=instance_name,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
HubCallback = Callable[[DiscoveredHub], None]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class HubBrowser:
|
|
131
|
+
"""Continuous mDNS browse for Sofabaton hubs.
|
|
132
|
+
|
|
133
|
+
Callbacks fire on the zeroconf engine thread; keep them quick and
|
|
134
|
+
hand off to your own executor/loop for real work. ``include_proxies``
|
|
135
|
+
keeps proxy advertisements (marked via PROXY_TXT_KEY) out of the
|
|
136
|
+
result set by default so applications discover *physical* hubs.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
zc: Any = None,
|
|
143
|
+
include_proxies: bool = False,
|
|
144
|
+
service_types: Iterable[str] = MDNS_SERVICE_TYPES,
|
|
145
|
+
on_added: Optional[HubCallback] = None,
|
|
146
|
+
on_updated: Optional[HubCallback] = None,
|
|
147
|
+
on_removed: Optional[HubCallback] = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
self._zc = zc
|
|
150
|
+
self._zc_owned = False
|
|
151
|
+
self._include_proxies = bool(include_proxies)
|
|
152
|
+
self._service_types = list(service_types)
|
|
153
|
+
self._on_added = on_added
|
|
154
|
+
self._on_updated = on_updated
|
|
155
|
+
self._on_removed = on_removed
|
|
156
|
+
self._browser: Any = None
|
|
157
|
+
self._lock = threading.Lock()
|
|
158
|
+
self._hubs: dict[tuple[str, str], DiscoveredHub] = {}
|
|
159
|
+
|
|
160
|
+
# -- zeroconf plumbing (overridable for tests) ----------------------
|
|
161
|
+
|
|
162
|
+
def _create_zeroconf(self) -> Any:
|
|
163
|
+
from zeroconf import IPVersion, Zeroconf
|
|
164
|
+
|
|
165
|
+
return Zeroconf(ip_version=IPVersion.V4Only)
|
|
166
|
+
|
|
167
|
+
def _create_browser(self, zc: Any) -> Any:
|
|
168
|
+
from zeroconf import ServiceBrowser
|
|
169
|
+
|
|
170
|
+
return ServiceBrowser(
|
|
171
|
+
zc, self._service_types, handlers=[self._on_service_state_change]
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# -- lifecycle -------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def start(self) -> "HubBrowser":
|
|
177
|
+
if self._browser is not None:
|
|
178
|
+
return self
|
|
179
|
+
if self._zc is None:
|
|
180
|
+
self._zc = self._create_zeroconf()
|
|
181
|
+
self._zc_owned = True
|
|
182
|
+
self._browser = self._create_browser(self._zc)
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
def stop(self) -> None:
|
|
186
|
+
browser, self._browser = self._browser, None
|
|
187
|
+
if browser is not None:
|
|
188
|
+
try:
|
|
189
|
+
browser.cancel()
|
|
190
|
+
except Exception: # pragma: no cover - engine teardown races
|
|
191
|
+
log.debug("HubBrowser: browser cancel failed", exc_info=True)
|
|
192
|
+
if self._zc_owned and self._zc is not None:
|
|
193
|
+
try:
|
|
194
|
+
self._zc.close()
|
|
195
|
+
except Exception: # pragma: no cover - engine teardown races
|
|
196
|
+
log.debug("HubBrowser: zeroconf close failed", exc_info=True)
|
|
197
|
+
self._zc = None
|
|
198
|
+
self._zc_owned = False
|
|
199
|
+
|
|
200
|
+
def __enter__(self) -> "HubBrowser":
|
|
201
|
+
return self.start()
|
|
202
|
+
|
|
203
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
204
|
+
self.stop()
|
|
205
|
+
|
|
206
|
+
# -- results ----------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def hubs(self) -> list[DiscoveredHub]:
|
|
210
|
+
"""Snapshot of currently-visible hubs, stable order."""
|
|
211
|
+
|
|
212
|
+
with self._lock:
|
|
213
|
+
return sorted(
|
|
214
|
+
self._hubs.values(), key=lambda hub: (hub.host, hub.instance_name)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# -- engine callbacks --------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def _on_service_state_change(
|
|
220
|
+
self, zeroconf: Any, service_type: str, name: str, state_change: Any
|
|
221
|
+
) -> None:
|
|
222
|
+
# Compare by enum name so fake engines in tests don't need the
|
|
223
|
+
# real zeroconf ServiceStateChange type.
|
|
224
|
+
change = getattr(state_change, "name", str(state_change))
|
|
225
|
+
if change == "Removed":
|
|
226
|
+
self._handle_removed(service_type, name)
|
|
227
|
+
return
|
|
228
|
+
if change not in ("Added", "Updated"):
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
info = None
|
|
232
|
+
try:
|
|
233
|
+
info = zeroconf.get_service_info(
|
|
234
|
+
service_type, name, timeout=_INFO_REQUEST_TIMEOUT_MS
|
|
235
|
+
)
|
|
236
|
+
except Exception: # pragma: no cover - engine-side failures
|
|
237
|
+
log.debug("HubBrowser: get_service_info(%s) failed", name, exc_info=True)
|
|
238
|
+
if info is None:
|
|
239
|
+
return
|
|
240
|
+
self._handle_info(service_type, name, info, is_update=(change == "Updated"))
|
|
241
|
+
|
|
242
|
+
def _handle_info(
|
|
243
|
+
self, service_type: str, name: str, info: Any, *, is_update: bool
|
|
244
|
+
) -> None:
|
|
245
|
+
host = self._first_ipv4(info)
|
|
246
|
+
hub = normalize_advertisement(
|
|
247
|
+
service_type,
|
|
248
|
+
name,
|
|
249
|
+
host=host,
|
|
250
|
+
port=getattr(info, "port", None),
|
|
251
|
+
properties=getattr(info, "properties", None),
|
|
252
|
+
)
|
|
253
|
+
if hub is None:
|
|
254
|
+
return
|
|
255
|
+
if hub.is_proxy and not self._include_proxies:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
with self._lock:
|
|
259
|
+
previous = self._hubs.get(hub.key)
|
|
260
|
+
self._hubs[hub.key] = hub
|
|
261
|
+
if previous is None:
|
|
262
|
+
self._emit(self._on_added, hub)
|
|
263
|
+
elif previous != hub or is_update:
|
|
264
|
+
self._emit(self._on_updated, hub)
|
|
265
|
+
|
|
266
|
+
def _handle_removed(self, service_type: str, name: str) -> None:
|
|
267
|
+
with self._lock:
|
|
268
|
+
hub = self._hubs.pop((service_type, name), None)
|
|
269
|
+
if hub is not None:
|
|
270
|
+
self._emit(self._on_removed, hub)
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _first_ipv4(info: Any) -> Optional[str]:
|
|
274
|
+
parsed = getattr(info, "parsed_addresses", None)
|
|
275
|
+
if callable(parsed):
|
|
276
|
+
try:
|
|
277
|
+
addresses = parsed()
|
|
278
|
+
except TypeError: # pragma: no cover - exotic fakes
|
|
279
|
+
addresses = []
|
|
280
|
+
for address in addresses or []:
|
|
281
|
+
if ":" not in address:
|
|
282
|
+
return address
|
|
283
|
+
return getattr(info, "host", None)
|
|
284
|
+
|
|
285
|
+
@staticmethod
|
|
286
|
+
def _emit(callback: Optional[HubCallback], hub: DiscoveredHub) -> None:
|
|
287
|
+
if callback is None:
|
|
288
|
+
return
|
|
289
|
+
try:
|
|
290
|
+
callback(hub)
|
|
291
|
+
except Exception:
|
|
292
|
+
log.exception("HubBrowser: callback failed for %s", hub.instance_name)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def discover_hubs(
|
|
296
|
+
timeout: float = DEFAULT_DISCOVERY_TIMEOUT,
|
|
297
|
+
*,
|
|
298
|
+
zc: Any = None,
|
|
299
|
+
include_proxies: bool = False,
|
|
300
|
+
) -> list[DiscoveredHub]:
|
|
301
|
+
"""One-shot blocking scan for Sofabaton hubs on the local network.
|
|
302
|
+
|
|
303
|
+
Browses both hub service types for ``timeout`` seconds and returns
|
|
304
|
+
the normalized advertisements seen. Pass an existing ``Zeroconf``
|
|
305
|
+
instance via ``zc`` to share an engine; otherwise one is created and
|
|
306
|
+
torn down for the scan.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
browser = HubBrowser(zc=zc, include_proxies=include_proxies)
|
|
310
|
+
browser.start()
|
|
311
|
+
try:
|
|
312
|
+
time.sleep(max(0.0, float(timeout)))
|
|
313
|
+
return browser.hubs
|
|
314
|
+
finally:
|
|
315
|
+
browser.stop()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Utilities for decoding framed hub traffic.
|
|
2
|
+
|
|
3
|
+
This module centralizes the registration of opcode-specific frame handlers so
|
|
4
|
+
``X1Proxy._log_frames`` can simply route frames to the appropriate handler. To
|
|
5
|
+
add support for a new opcode simply:
|
|
6
|
+
|
|
7
|
+
1. Subclass :class:`BaseFrameHandler` (or implement the :class:`FrameHandler`
|
|
8
|
+
protocol) and override :meth:`BaseFrameHandler.handle`.
|
|
9
|
+
2. Decorate the handler with :func:`register_handler`, specifying one or more
|
|
10
|
+
opcodes and (optionally) the directions that should trigger it.
|
|
11
|
+
3. Access the :class:`FrameContext` passed to ``handle`` to introspect the frame
|
|
12
|
+
payload and mutate the :class:`~.x1_proxy.X1Proxy` state.
|
|
13
|
+
|
|
14
|
+
Because handlers are registered via decorators, ``_log_frames`` never needs to
|
|
15
|
+
change when new opcodes are implemented. Direction-specific handling is achieved
|
|
16
|
+
by passing ``directions=("A→H",)`` (or ``("H→A",)``) to ``register_handler``.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Protocol, Sequence, Iterator, Optional, runtime_checkable, TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
from .protocol_const import opcode_family
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from .x1_proxy import X1Proxy
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class FrameContext:
|
|
31
|
+
"""State passed to each handler invocation."""
|
|
32
|
+
|
|
33
|
+
proxy: "X1Proxy"
|
|
34
|
+
opcode: int
|
|
35
|
+
direction: str
|
|
36
|
+
payload: bytes
|
|
37
|
+
raw: bytes
|
|
38
|
+
name: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@runtime_checkable
|
|
42
|
+
class FrameHandler(Protocol):
|
|
43
|
+
"""Interface implemented by opcode handlers."""
|
|
44
|
+
|
|
45
|
+
def matches(self, opcode: int, direction: str) -> bool: # pragma: no cover - protocol
|
|
46
|
+
"""Return ``True`` when this handler should process the frame."""
|
|
47
|
+
|
|
48
|
+
def handle(self, frame: FrameContext) -> None: # pragma: no cover - protocol
|
|
49
|
+
"""Decode the frame and mutate ``frame.proxy`` as needed."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BaseFrameHandler(FrameHandler):
|
|
53
|
+
"""Convenience base class implementing ``matches`` via attributes."""
|
|
54
|
+
|
|
55
|
+
opcodes: tuple[int, ...] | None = None
|
|
56
|
+
opcode_families_low: tuple[int, ...] | None = None
|
|
57
|
+
directions: tuple[str, ...] | None = None
|
|
58
|
+
|
|
59
|
+
def matches(self, opcode: int, direction: str) -> bool:
|
|
60
|
+
if self.opcodes is None and self.opcode_families_low is None:
|
|
61
|
+
opcode_match = True
|
|
62
|
+
else:
|
|
63
|
+
opcode_match = False
|
|
64
|
+
if self.opcodes is not None and opcode in self.opcodes:
|
|
65
|
+
opcode_match = True
|
|
66
|
+
if (
|
|
67
|
+
not opcode_match
|
|
68
|
+
and self.opcode_families_low is not None
|
|
69
|
+
and opcode_family(opcode) in self.opcode_families_low
|
|
70
|
+
):
|
|
71
|
+
opcode_match = True
|
|
72
|
+
direction_match = True if self.directions is None else direction in self.directions
|
|
73
|
+
return opcode_match and direction_match
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FrameHandlerRegistry:
|
|
77
|
+
"""Collection of registered handlers."""
|
|
78
|
+
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
self._handlers: list[FrameHandler] = []
|
|
81
|
+
|
|
82
|
+
def register(self, handler: FrameHandler) -> FrameHandler:
|
|
83
|
+
self._handlers.append(handler)
|
|
84
|
+
return handler
|
|
85
|
+
|
|
86
|
+
def iter_for(self, opcode: int, direction: str) -> Iterator[FrameHandler]:
|
|
87
|
+
for handler in self._handlers:
|
|
88
|
+
try:
|
|
89
|
+
if handler.matches(opcode, direction):
|
|
90
|
+
yield handler
|
|
91
|
+
except Exception:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
frame_handler_registry = FrameHandlerRegistry()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def register_handler(
|
|
99
|
+
handler: Optional[type[BaseFrameHandler] | FrameHandler] = None,
|
|
100
|
+
*,
|
|
101
|
+
opcodes: Sequence[int] | None = None,
|
|
102
|
+
opcode_families_low: Sequence[int] | None = None,
|
|
103
|
+
directions: Sequence[str] | None = None,
|
|
104
|
+
registry: FrameHandlerRegistry = frame_handler_registry,
|
|
105
|
+
):
|
|
106
|
+
"""Decorator used to register ``FrameHandler`` implementations."""
|
|
107
|
+
|
|
108
|
+
def _decorator(obj):
|
|
109
|
+
instance = obj() if isinstance(obj, type) else obj
|
|
110
|
+
if opcodes is not None:
|
|
111
|
+
instance.opcodes = tuple(opcodes) # type: ignore[attr-defined]
|
|
112
|
+
if opcode_families_low is not None:
|
|
113
|
+
instance.opcode_families_low = tuple(opcode_families_low) # type: ignore[attr-defined]
|
|
114
|
+
if directions is not None:
|
|
115
|
+
instance.directions = tuple(directions) # type: ignore[attr-defined]
|
|
116
|
+
registry.register(instance)
|
|
117
|
+
return obj
|
|
118
|
+
|
|
119
|
+
if handler is not None:
|
|
120
|
+
return _decorator(handler)
|
|
121
|
+
return _decorator
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = [
|
|
125
|
+
"BaseFrameHandler",
|
|
126
|
+
"FrameContext",
|
|
127
|
+
"FrameHandler",
|
|
128
|
+
"FrameHandlerRegistry",
|
|
129
|
+
"frame_handler_registry",
|
|
130
|
+
"register_handler",
|
|
131
|
+
]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Process-wide TCP listener that dispatches accepted hub sockets to
|
|
2
|
+
the right :class:`TransportBridge` by peer IP.
|
|
3
|
+
|
|
4
|
+
Before this module, each ``TransportBridge`` opened its own listening
|
|
5
|
+
socket on a unique port (8200, 8201, 8202, ...) and the hub dialled
|
|
6
|
+
back to that per-instance port. With multiple hubs on one host that
|
|
7
|
+
fanned the firewall surface and the bookkeeping out unnecessarily —
|
|
8
|
+
every hub on the LAN has a unique IP, so peer IP is already a clean
|
|
9
|
+
dispatch key.
|
|
10
|
+
|
|
11
|
+
This singleton mirrors the existing :mod:`notify_demuxer` pattern:
|
|
12
|
+
each ``TransportBridge`` registers ``(real_hub_ip, on_socket_cb)``;
|
|
13
|
+
the listener accepts on one port and routes the socket to the
|
|
14
|
+
matching bridge. Registrations also carry the hub's expected MAC
|
|
15
|
+
(from the mDNS advertisement) for a sanity-check log if the peer IP
|
|
16
|
+
maps to a different MAC than we expected.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import socket
|
|
23
|
+
import threading
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Callable, Dict, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
from .hub_logging import get_hub_logger
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger("x1proxy.listener")
|
|
30
|
+
|
|
31
|
+
# Default TCP port for the shared hub-side listener. Matches the
|
|
32
|
+
# original ``hub_listen_base`` default; user-configured values still
|
|
33
|
+
# work — the first registration's port wins (subsequent differing
|
|
34
|
+
# values log a warning, mirroring NotifyDemuxer).
|
|
35
|
+
DEFAULT_HUB_LISTEN_PORT = 8200
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
OnSocketCallback = Callable[[socket.socket, Tuple[str, int]], None]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class HubRegistration:
|
|
43
|
+
proxy_id: str
|
|
44
|
+
real_hub_ip: str
|
|
45
|
+
expected_mac_bytes: bytes # 6 bytes; all-zero if unknown
|
|
46
|
+
on_socket: OnSocketCallback
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HubListener:
|
|
50
|
+
"""Accept TCP connections on one port; dispatch by peer IP."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, listen_port: int = DEFAULT_HUB_LISTEN_PORT) -> None:
|
|
53
|
+
self.listen_port = int(listen_port)
|
|
54
|
+
self._sock: Optional[socket.socket] = None
|
|
55
|
+
self._thr: Optional[threading.Thread] = None
|
|
56
|
+
self._stop_event = threading.Event()
|
|
57
|
+
self._lock = threading.Lock()
|
|
58
|
+
# Keyed by real_hub_ip so dispatch is O(1) on accept.
|
|
59
|
+
self._by_ip: Dict[str, HubRegistration] = {}
|
|
60
|
+
self._by_proxy: Dict[str, HubRegistration] = {}
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Public API
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
def register_hub(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
proxy_id: str,
|
|
69
|
+
real_hub_ip: str,
|
|
70
|
+
on_socket: OnSocketCallback,
|
|
71
|
+
expected_mac_bytes: bytes = b"\x00" * 6,
|
|
72
|
+
) -> int:
|
|
73
|
+
"""Register one bridge; return the listen port to advertise."""
|
|
74
|
+
|
|
75
|
+
reg = HubRegistration(
|
|
76
|
+
proxy_id=proxy_id,
|
|
77
|
+
real_hub_ip=real_hub_ip,
|
|
78
|
+
expected_mac_bytes=bytes(expected_mac_bytes[:6]).ljust(6, b"\x00"),
|
|
79
|
+
on_socket=on_socket,
|
|
80
|
+
)
|
|
81
|
+
with self._lock:
|
|
82
|
+
existing = self._by_ip.get(real_hub_ip)
|
|
83
|
+
if existing is not None and existing.proxy_id != proxy_id:
|
|
84
|
+
get_hub_logger(log, proxy_id).warning(
|
|
85
|
+
"[LISTEN] replacing existing registration for %s "
|
|
86
|
+
"(was proxy=%s)",
|
|
87
|
+
real_hub_ip,
|
|
88
|
+
existing.proxy_id,
|
|
89
|
+
)
|
|
90
|
+
self._by_proxy.pop(existing.proxy_id, None)
|
|
91
|
+
self._by_ip[real_hub_ip] = reg
|
|
92
|
+
self._by_proxy[proxy_id] = reg
|
|
93
|
+
self._ensure_running_locked()
|
|
94
|
+
get_hub_logger(log, proxy_id).info(
|
|
95
|
+
"[LISTEN] registered hub %s on shared port %d",
|
|
96
|
+
real_hub_ip,
|
|
97
|
+
self.listen_port,
|
|
98
|
+
)
|
|
99
|
+
return self.listen_port
|
|
100
|
+
|
|
101
|
+
def unregister_hub(self, proxy_id: str) -> None:
|
|
102
|
+
with self._lock:
|
|
103
|
+
reg = self._by_proxy.pop(proxy_id, None)
|
|
104
|
+
if reg is not None:
|
|
105
|
+
if self._by_ip.get(reg.real_hub_ip) is reg:
|
|
106
|
+
self._by_ip.pop(reg.real_hub_ip, None)
|
|
107
|
+
get_hub_logger(log, proxy_id).info(
|
|
108
|
+
"[LISTEN] unregistered hub %s", reg.real_hub_ip
|
|
109
|
+
)
|
|
110
|
+
self._stop_if_idle_locked()
|
|
111
|
+
|
|
112
|
+
def shutdown(self) -> None:
|
|
113
|
+
with self._lock:
|
|
114
|
+
self._by_ip.clear()
|
|
115
|
+
self._by_proxy.clear()
|
|
116
|
+
self._stop_thread_locked()
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# Internals
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
def _ensure_running_locked(self) -> None:
|
|
122
|
+
if self._thr is not None and self._thr.is_alive():
|
|
123
|
+
return
|
|
124
|
+
self._stop_event = threading.Event()
|
|
125
|
+
self._sock = self._open_socket()
|
|
126
|
+
self._thr = threading.Thread(
|
|
127
|
+
target=self._accept_loop,
|
|
128
|
+
name="x1proxy-hub-listen",
|
|
129
|
+
daemon=True,
|
|
130
|
+
)
|
|
131
|
+
self._thr.start()
|
|
132
|
+
|
|
133
|
+
def _stop_if_idle_locked(self) -> None:
|
|
134
|
+
if self._by_proxy:
|
|
135
|
+
return
|
|
136
|
+
self._stop_thread_locked()
|
|
137
|
+
|
|
138
|
+
def _stop_thread_locked(self) -> None:
|
|
139
|
+
self._stop_event.set()
|
|
140
|
+
if self._sock is not None:
|
|
141
|
+
try:
|
|
142
|
+
self._sock.close()
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
self._sock = None
|
|
146
|
+
if self._thr is not None:
|
|
147
|
+
self._thr.join(timeout=1.0)
|
|
148
|
+
self._thr = None
|
|
149
|
+
|
|
150
|
+
def _open_socket(self) -> socket.socket:
|
|
151
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
152
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
153
|
+
s.bind(("0.0.0.0", self.listen_port))
|
|
154
|
+
s.listen(8)
|
|
155
|
+
s.settimeout(1.0)
|
|
156
|
+
log.info("[LISTEN] accepting hubs on *:%d", self.listen_port)
|
|
157
|
+
return s
|
|
158
|
+
|
|
159
|
+
def _accept_loop(self) -> None:
|
|
160
|
+
sock = self._sock
|
|
161
|
+
if sock is None:
|
|
162
|
+
return
|
|
163
|
+
while not self._stop_event.is_set():
|
|
164
|
+
try:
|
|
165
|
+
client, addr = sock.accept()
|
|
166
|
+
except socket.timeout:
|
|
167
|
+
continue
|
|
168
|
+
except OSError:
|
|
169
|
+
break
|
|
170
|
+
peer_ip, peer_port = addr[0], addr[1]
|
|
171
|
+
|
|
172
|
+
with self._lock:
|
|
173
|
+
reg = self._by_ip.get(peer_ip)
|
|
174
|
+
|
|
175
|
+
if reg is None:
|
|
176
|
+
log.warning(
|
|
177
|
+
"[LISTEN] dropping unrecognised hub connection from %s:%d",
|
|
178
|
+
peer_ip,
|
|
179
|
+
peer_port,
|
|
180
|
+
)
|
|
181
|
+
try:
|
|
182
|
+
client.shutdown(socket.SHUT_RDWR)
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
try:
|
|
186
|
+
client.close()
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
get_hub_logger(log, reg.proxy_id).info(
|
|
192
|
+
"[LISTEN] accepted hub %s:%d", peer_ip, peer_port
|
|
193
|
+
)
|
|
194
|
+
try:
|
|
195
|
+
reg.on_socket(client, (peer_ip, peer_port))
|
|
196
|
+
except Exception:
|
|
197
|
+
get_hub_logger(log, reg.proxy_id).exception(
|
|
198
|
+
"[LISTEN] on_socket callback raised; closing"
|
|
199
|
+
)
|
|
200
|
+
try:
|
|
201
|
+
client.shutdown(socket.SHUT_RDWR)
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
try:
|
|
205
|
+
client.close()
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
_GLOBAL_LISTENER: Optional[HubListener] = None
|
|
211
|
+
_GLOBAL_LOCK = threading.Lock()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_hub_listener(listen_port: Optional[int] = None) -> HubListener:
|
|
215
|
+
"""Return the process-wide :class:`HubListener` singleton.
|
|
216
|
+
|
|
217
|
+
The first caller fixes the listen port. Later callers requesting a
|
|
218
|
+
different port get a warning and the existing instance — matching
|
|
219
|
+
the lifecycle behaviour of :func:`get_notify_demuxer`.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
global _GLOBAL_LISTENER
|
|
223
|
+
with _GLOBAL_LOCK:
|
|
224
|
+
if _GLOBAL_LISTENER is None:
|
|
225
|
+
_GLOBAL_LISTENER = HubListener(listen_port or DEFAULT_HUB_LISTEN_PORT)
|
|
226
|
+
elif listen_port is not None and listen_port != _GLOBAL_LISTENER.listen_port:
|
|
227
|
+
log.warning(
|
|
228
|
+
"[LISTEN] existing listener on %d (ignoring requested %d)",
|
|
229
|
+
_GLOBAL_LISTENER.listen_port,
|
|
230
|
+
listen_port,
|
|
231
|
+
)
|
|
232
|
+
return _GLOBAL_LISTENER
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def reset_hub_listener_for_tests() -> None:
|
|
236
|
+
"""Test helper: drop the global singleton."""
|
|
237
|
+
|
|
238
|
+
global _GLOBAL_LISTENER
|
|
239
|
+
with _GLOBAL_LOCK:
|
|
240
|
+
if _GLOBAL_LISTENER is not None:
|
|
241
|
+
_GLOBAL_LISTENER.shutdown()
|
|
242
|
+
_GLOBAL_LISTENER = None
|