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,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