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/aio.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
# aio.py — asyncio facade over the threaded proxy core.
|
|
2
|
+
#
|
|
3
|
+
# The engine stays thread-based (sockets, ack waiters, mDNS); this module
|
|
4
|
+
# owns NO protocol logic. It does exactly two things:
|
|
5
|
+
#
|
|
6
|
+
# * runs blocking proxy calls in the event loop's default executor, and
|
|
7
|
+
# * marshals listener callbacks from engine threads onto the loop
|
|
8
|
+
# (plain callables via ``call_soon_threadsafe``, coroutine functions
|
|
9
|
+
# via ``run_coroutine_threadsafe``),
|
|
10
|
+
#
|
|
11
|
+
# mirroring the executor-job pattern the Home Assistant integration uses
|
|
12
|
+
# around ``X1Proxy`` today.
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import functools
|
|
17
|
+
import inspect
|
|
18
|
+
from typing import Any, Callable, Iterable, Optional
|
|
19
|
+
|
|
20
|
+
from .discovery import (
|
|
21
|
+
DEFAULT_DISCOVERY_TIMEOUT,
|
|
22
|
+
DiscoveredHub,
|
|
23
|
+
HubBrowser,
|
|
24
|
+
discover_hubs,
|
|
25
|
+
)
|
|
26
|
+
from .protocol_const import ButtonName
|
|
27
|
+
from .x1_proxy import X1Proxy
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AsyncX1Proxy",
|
|
31
|
+
"AsyncHubBrowser",
|
|
32
|
+
"async_discover_hubs",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Default deadline for an awaited read that has to fetch from the hub.
|
|
36
|
+
DEFAULT_FETCH_TIMEOUT = 10.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _marshal_callback(loop: asyncio.AbstractEventLoop, callback: Callable) -> Callable:
|
|
40
|
+
"""Wrap ``callback`` so engine-thread invocations land on ``loop``.
|
|
41
|
+
|
|
42
|
+
Sync callables are queued with ``call_soon_threadsafe``; coroutine
|
|
43
|
+
functions are scheduled as tasks via ``run_coroutine_threadsafe``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
if inspect.iscoroutinefunction(callback):
|
|
47
|
+
|
|
48
|
+
def relay(*args: Any, **kwargs: Any) -> None:
|
|
49
|
+
asyncio.run_coroutine_threadsafe(callback(*args, **kwargs), loop)
|
|
50
|
+
|
|
51
|
+
else:
|
|
52
|
+
|
|
53
|
+
def relay(*args: Any, **kwargs: Any) -> None:
|
|
54
|
+
loop.call_soon_threadsafe(functools.partial(callback, *args, **kwargs))
|
|
55
|
+
|
|
56
|
+
functools.update_wrapper(relay, callback)
|
|
57
|
+
return relay
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AsyncX1Proxy:
|
|
61
|
+
"""Asyncio wrapper around :class:`X1Proxy`.
|
|
62
|
+
|
|
63
|
+
Construct with the same keyword arguments as ``X1Proxy`` (must happen
|
|
64
|
+
inside a running event loop, or pass ``loop=``), or wrap an existing
|
|
65
|
+
engine with :meth:`wrap`.
|
|
66
|
+
|
|
67
|
+
The common surface is a small set of explicit, human-readable
|
|
68
|
+
coroutines:
|
|
69
|
+
|
|
70
|
+
* **read** — :meth:`activities`, :meth:`devices`, :meth:`commands`,
|
|
71
|
+
:meth:`buttons`, :meth:`macros`, :meth:`favorites`. These return
|
|
72
|
+
the data directly (no ``(data, ready)`` tuple): cached results
|
|
73
|
+
come back immediately, otherwise the call fetches from the hub and
|
|
74
|
+
awaits completion, raising :class:`RuntimeError` when the hub is
|
|
75
|
+
held by a connected app client and nothing is cached, or
|
|
76
|
+
:class:`TimeoutError` when the fetch never lands.
|
|
77
|
+
* **control** — :meth:`press`, :meth:`start_activity`,
|
|
78
|
+
:meth:`stop_activity`, :meth:`find_remote`.
|
|
79
|
+
|
|
80
|
+
Anything else in :data:`PROXY_METHODS` (provisioning, cache export,
|
|
81
|
+
explicit requests) is awaitable too and delegates to the engine in
|
|
82
|
+
the executor. Listener registration (``on_*``) accepts plain
|
|
83
|
+
callables and coroutine functions and always delivers on the event
|
|
84
|
+
loop. ``.sync`` exposes the underlying engine for the raw surface
|
|
85
|
+
(including the ``get_*`` snapshot getters that return tuples).
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
# Engine methods exposed as bare awaitable executor delegates. The
|
|
89
|
+
# human read/control surface (activities/devices/commands/buttons/
|
|
90
|
+
# macros/favorites/press/start_activity/stop_activity) is defined as
|
|
91
|
+
# explicit methods below and intentionally NOT listed here. Tests
|
|
92
|
+
# assert every entry exists on X1Proxy so the list cannot drift.
|
|
93
|
+
PROXY_METHODS: frozenset[str] = frozenset(
|
|
94
|
+
{
|
|
95
|
+
# advanced getters (already return plain data, not tuples)
|
|
96
|
+
"get_cached_macro_records",
|
|
97
|
+
"get_cached_activity_detail_ids",
|
|
98
|
+
"get_known_device_ids",
|
|
99
|
+
"get_known_activity_ids",
|
|
100
|
+
"get_banner_info",
|
|
101
|
+
"get_app_activations",
|
|
102
|
+
# live in-memory cache invalidation (NOT persistence: the
|
|
103
|
+
# library never writes to disk, and the cache-snapshot
|
|
104
|
+
# (de)serializers stay off the public surface — reach them
|
|
105
|
+
# via .sync if a warm-start dump is genuinely needed).
|
|
106
|
+
"clear_entity_cache",
|
|
107
|
+
"clear_devices_catalog",
|
|
108
|
+
"clear_activities_catalog",
|
|
109
|
+
# explicit hub requests
|
|
110
|
+
"request_activities",
|
|
111
|
+
"request_devices",
|
|
112
|
+
"request_activity_mapping",
|
|
113
|
+
"request_ir_command_dump",
|
|
114
|
+
"fetch_banner_info",
|
|
115
|
+
"fetch_device_input_record",
|
|
116
|
+
"fetch_device_key_sort",
|
|
117
|
+
# actions
|
|
118
|
+
"set_hub_name",
|
|
119
|
+
"set_diag_dump",
|
|
120
|
+
"resync_remote",
|
|
121
|
+
"update_discovery_identity",
|
|
122
|
+
"enable_proxy",
|
|
123
|
+
"disable_proxy",
|
|
124
|
+
# provisioning / mutation
|
|
125
|
+
"create_wifi_device",
|
|
126
|
+
"delete_device",
|
|
127
|
+
"delete_favorite",
|
|
128
|
+
"reorder_favorites",
|
|
129
|
+
"command_to_favorite",
|
|
130
|
+
"command_to_button",
|
|
131
|
+
"add_device_to_activity",
|
|
132
|
+
"play_ir_blob",
|
|
133
|
+
"persist_ir_blob",
|
|
134
|
+
"erase_configuration",
|
|
135
|
+
# backup / restore (symmetric, schema-versioned)
|
|
136
|
+
"backup_device",
|
|
137
|
+
"backup_activity",
|
|
138
|
+
"backup_hub_bundle",
|
|
139
|
+
"restore_device",
|
|
140
|
+
"restore_activity",
|
|
141
|
+
"restore_hub_bundle",
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
_LISTENER_METHODS: frozenset[str] = frozenset(
|
|
146
|
+
{
|
|
147
|
+
"on_activity_change",
|
|
148
|
+
"on_activity_list_update",
|
|
149
|
+
"on_client_state_change",
|
|
150
|
+
"on_hub_state_change",
|
|
151
|
+
"on_ota_update",
|
|
152
|
+
"on_app_activation",
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self, *, loop: Optional[asyncio.AbstractEventLoop] = None, **proxy_kwargs: Any
|
|
158
|
+
) -> None:
|
|
159
|
+
self._loop = loop or asyncio.get_running_loop()
|
|
160
|
+
self._proxy = X1Proxy(**proxy_kwargs)
|
|
161
|
+
self._init_burst_state()
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def wrap(
|
|
165
|
+
cls, proxy: X1Proxy, *, loop: Optional[asyncio.AbstractEventLoop] = None
|
|
166
|
+
) -> "AsyncX1Proxy":
|
|
167
|
+
"""Wrap an already-constructed engine (e.g. mid-migration code)."""
|
|
168
|
+
|
|
169
|
+
self = object.__new__(cls)
|
|
170
|
+
self._loop = loop or asyncio.get_running_loop()
|
|
171
|
+
self._proxy = proxy
|
|
172
|
+
self._init_burst_state()
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def _init_burst_state(self) -> None:
|
|
176
|
+
# Per-entity futures awaiting a burst completion, keyed by the
|
|
177
|
+
# engine's burst key (e.g. "commands:5", "activities"). One
|
|
178
|
+
# persistent dispatcher is registered per burst-kind on first use
|
|
179
|
+
# so awaited reads never leak listeners.
|
|
180
|
+
self._burst_waiters: dict[str, list[asyncio.Future]] = {}
|
|
181
|
+
self._burst_dispatch_kinds: set[str] = set()
|
|
182
|
+
# Set on any hub/client connection-state change (lazily wired) so
|
|
183
|
+
# the readiness waiters can wake.
|
|
184
|
+
self._state_event: Optional[asyncio.Event] = None
|
|
185
|
+
|
|
186
|
+
# -- escape hatches ----------------------------------------------------
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def sync(self) -> X1Proxy:
|
|
190
|
+
"""The underlying threaded engine."""
|
|
191
|
+
|
|
192
|
+
return self._proxy
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def state(self) -> Any:
|
|
196
|
+
"""The engine's :class:`ActivityCache` (read on the loop thread)."""
|
|
197
|
+
|
|
198
|
+
return self._proxy.state
|
|
199
|
+
|
|
200
|
+
async def run(self, func: Callable, /, *args: Any, **kwargs: Any) -> Any:
|
|
201
|
+
"""Run an arbitrary callable in the executor (escape hatch)."""
|
|
202
|
+
|
|
203
|
+
return await self._loop.run_in_executor(
|
|
204
|
+
None, functools.partial(func, *args, **kwargs)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# -- lifecycle -----------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
async def start(self) -> None:
|
|
210
|
+
await self.run(self._proxy.start)
|
|
211
|
+
|
|
212
|
+
async def stop(self) -> None:
|
|
213
|
+
await self.run(self._proxy.stop)
|
|
214
|
+
|
|
215
|
+
async def __aenter__(self) -> "AsyncX1Proxy":
|
|
216
|
+
await self.start()
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
220
|
+
await self.stop()
|
|
221
|
+
|
|
222
|
+
def set_zeroconf(self, zc: Any) -> None:
|
|
223
|
+
"""Adopt a shared Zeroconf instance (cheap; no executor needed)."""
|
|
224
|
+
|
|
225
|
+
self._proxy.set_zeroconf(zc)
|
|
226
|
+
|
|
227
|
+
# -- readiness -----------------------------------------------------------
|
|
228
|
+
#
|
|
229
|
+
# ``start()`` only spawns the transport thread; the hub TCP connect and
|
|
230
|
+
# banner handshake happen asynchronously after it returns. The proxy
|
|
231
|
+
# has two operating modes, and these waiters gate them:
|
|
232
|
+
#
|
|
233
|
+
# * observe mode — the official app is connected through the proxy;
|
|
234
|
+
# you can watch activity/state changes but cannot issue commands
|
|
235
|
+
# (the app owns the hub). Gate on :meth:`wait_connected`.
|
|
236
|
+
# * control mode — no app attached; the proxy owns the hub, so reads
|
|
237
|
+
# fetch fresh and commands/backup work. Gate on
|
|
238
|
+
# :meth:`wait_until_controllable`.
|
|
239
|
+
|
|
240
|
+
def _ensure_state_watcher(self) -> None:
|
|
241
|
+
if self._state_event is not None:
|
|
242
|
+
return
|
|
243
|
+
self._state_event = asyncio.Event()
|
|
244
|
+
|
|
245
|
+
def _on_change(*_args: Any) -> None:
|
|
246
|
+
# Fires on the engine thread; wake the loop.
|
|
247
|
+
self._loop.call_soon_threadsafe(self._state_event.set)
|
|
248
|
+
|
|
249
|
+
self._proxy.on_hub_state_change(_on_change)
|
|
250
|
+
self._proxy.on_client_state_change(_on_change)
|
|
251
|
+
|
|
252
|
+
async def _wait_for_state(
|
|
253
|
+
self, predicate: Callable[[], bool], timeout: float
|
|
254
|
+
) -> bool:
|
|
255
|
+
if predicate():
|
|
256
|
+
return True
|
|
257
|
+
self._ensure_state_watcher()
|
|
258
|
+
assert self._state_event is not None
|
|
259
|
+
deadline = self._loop.time() + timeout
|
|
260
|
+
while not predicate():
|
|
261
|
+
remaining = deadline - self._loop.time()
|
|
262
|
+
if remaining <= 0:
|
|
263
|
+
return predicate()
|
|
264
|
+
self._state_event.clear()
|
|
265
|
+
if predicate():
|
|
266
|
+
return True
|
|
267
|
+
try:
|
|
268
|
+
await asyncio.wait_for(self._state_event.wait(), remaining)
|
|
269
|
+
except TimeoutError:
|
|
270
|
+
return predicate()
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
async def wait_connected(self, timeout: float = 30.0) -> bool:
|
|
274
|
+
"""Wait until the hub is connected (observe mode can begin).
|
|
275
|
+
|
|
276
|
+
Returns ``False`` on timeout.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
return await self._wait_for_state(
|
|
280
|
+
lambda: self._proxy.transport.is_hub_connected, timeout
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
async def wait_until_controllable(self, timeout: float = 30.0) -> bool:
|
|
284
|
+
"""Wait until the proxy owns the hub (connected, no app attached).
|
|
285
|
+
|
|
286
|
+
Reads fetch fresh and commands/backup work once this returns
|
|
287
|
+
``True``; returns ``False`` on timeout.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
return await self._wait_for_state(self._proxy.can_issue_commands, timeout)
|
|
291
|
+
|
|
292
|
+
# -- listeners ------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
def on_burst_end(self, key: str, callback: Callable) -> None:
|
|
295
|
+
"""Register a burst-end listener; delivered on the event loop."""
|
|
296
|
+
|
|
297
|
+
self._proxy.on_burst_end(key, _marshal_callback(self._loop, callback))
|
|
298
|
+
|
|
299
|
+
# -- read surface --------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
async def activities(
|
|
302
|
+
self, *, timeout: float = DEFAULT_FETCH_TIMEOUT
|
|
303
|
+
) -> dict[int, dict]:
|
|
304
|
+
"""Return ``{activity_id: {name, active, ...}}`` for all activities."""
|
|
305
|
+
|
|
306
|
+
return await self._read(self._proxy.get_activities, "activities", timeout=timeout)
|
|
307
|
+
|
|
308
|
+
async def devices(
|
|
309
|
+
self, *, timeout: float = DEFAULT_FETCH_TIMEOUT
|
|
310
|
+
) -> dict[int, dict]:
|
|
311
|
+
"""Return ``{device_id: {name, brand, ...}}`` for all devices."""
|
|
312
|
+
|
|
313
|
+
return await self._read(self._proxy.get_devices, "devices", timeout=timeout)
|
|
314
|
+
|
|
315
|
+
async def commands(
|
|
316
|
+
self, device_id: int, *, timeout: float = DEFAULT_FETCH_TIMEOUT
|
|
317
|
+
) -> dict[int, str]:
|
|
318
|
+
"""Return ``{command_code: label}`` for a device."""
|
|
319
|
+
|
|
320
|
+
return await self._read(
|
|
321
|
+
self._proxy.get_commands_for_entity,
|
|
322
|
+
f"commands:{device_id & 0xFF}",
|
|
323
|
+
device_id,
|
|
324
|
+
timeout=timeout,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
async def buttons(
|
|
328
|
+
self, entity_id: int, *, timeout: float = DEFAULT_FETCH_TIMEOUT
|
|
329
|
+
) -> list[int]:
|
|
330
|
+
"""Return the button codes bound to an activity or device."""
|
|
331
|
+
|
|
332
|
+
return await self._read(
|
|
333
|
+
self._proxy.get_buttons_for_entity,
|
|
334
|
+
f"buttons:{entity_id & 0xFF}",
|
|
335
|
+
entity_id,
|
|
336
|
+
timeout=timeout,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
async def macros(
|
|
340
|
+
self, activity_id: int, *, timeout: float = DEFAULT_FETCH_TIMEOUT
|
|
341
|
+
) -> list[dict]:
|
|
342
|
+
"""Return an activity's macros as ``[{command_id, label}, ...]``."""
|
|
343
|
+
|
|
344
|
+
return await self._read(
|
|
345
|
+
self._proxy.get_macros_for_activity,
|
|
346
|
+
f"macros:{activity_id & 0xFF}",
|
|
347
|
+
activity_id,
|
|
348
|
+
timeout=timeout,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
async def favorites(self, activity_id: int) -> list[tuple[int, int]]:
|
|
352
|
+
"""Return an activity's favorites ordering as ``[(fav_id, slot), ...]``.
|
|
353
|
+
|
|
354
|
+
Unlike the other reads this is a single blocking request to the
|
|
355
|
+
hub; raises :class:`TimeoutError` if the hub does not answer. An
|
|
356
|
+
activity with no favorites returns an empty list.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
order = await self.run(self._proxy.request_favorites_order, activity_id)
|
|
360
|
+
if order is None:
|
|
361
|
+
raise TimeoutError(
|
|
362
|
+
f"timed out fetching favorites for activity {activity_id}"
|
|
363
|
+
)
|
|
364
|
+
return order
|
|
365
|
+
|
|
366
|
+
# -- control surface -----------------------------------------------------
|
|
367
|
+
|
|
368
|
+
async def press(self, entity_id: int, button: int) -> bool:
|
|
369
|
+
"""Send a button/command press to an activity or device.
|
|
370
|
+
|
|
371
|
+
Returns ``False`` if the proxy refused (a real app client holds
|
|
372
|
+
the hub).
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
return await self.run(self._proxy.send_command, entity_id, button)
|
|
376
|
+
|
|
377
|
+
async def start_activity(self, activity_id: int) -> bool:
|
|
378
|
+
"""Switch to an activity (sends its power-on)."""
|
|
379
|
+
|
|
380
|
+
return await self.run(self._proxy.send_command, activity_id, ButtonName.POWER_ON)
|
|
381
|
+
|
|
382
|
+
async def stop_activity(self, activity_id: int) -> bool:
|
|
383
|
+
"""Power off an activity."""
|
|
384
|
+
|
|
385
|
+
return await self.run(self._proxy.send_command, activity_id, ButtonName.POWER_OFF)
|
|
386
|
+
|
|
387
|
+
async def find_remote(self) -> bool:
|
|
388
|
+
"""Trigger the hub's find-my-remote signal.
|
|
389
|
+
|
|
390
|
+
Returns ``False`` if refused (a real app client holds the hub).
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
return await self.run(self._proxy.find_remote)
|
|
394
|
+
|
|
395
|
+
# -- lazy-read plumbing --------------------------------------------------
|
|
396
|
+
|
|
397
|
+
async def _read(
|
|
398
|
+
self, getter: Callable, key: str, *args: Any, timeout: float
|
|
399
|
+
) -> Any:
|
|
400
|
+
"""Resolve a lazy ``(data, ready)`` getter to complete data.
|
|
401
|
+
|
|
402
|
+
Returns cached data when already complete; otherwise kicks a hub
|
|
403
|
+
fetch and awaits the matching burst. Raises ``RuntimeError`` when
|
|
404
|
+
the hub can't be queried and nothing is cached, ``TimeoutError``
|
|
405
|
+
when the burst never lands.
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
data, ready = await self.run(getter, *args, fetch_if_missing=False)
|
|
409
|
+
if ready:
|
|
410
|
+
return data
|
|
411
|
+
if not self._proxy.can_issue_commands():
|
|
412
|
+
if not self._proxy.transport.is_hub_connected:
|
|
413
|
+
raise RuntimeError(
|
|
414
|
+
f"cannot fetch {key!r}: the hub is not connected yet "
|
|
415
|
+
"(await wait_until_controllable() first)"
|
|
416
|
+
)
|
|
417
|
+
raise RuntimeError(
|
|
418
|
+
f"cannot fetch {key!r}: an app client is connected and holds the hub"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
future = self._loop.create_future()
|
|
422
|
+
self._burst_waiters.setdefault(key, []).append(future)
|
|
423
|
+
self._ensure_burst_dispatch(key.split(":", 1)[0])
|
|
424
|
+
|
|
425
|
+
await self.run(getter, *args, fetch_if_missing=True)
|
|
426
|
+
try:
|
|
427
|
+
await asyncio.wait_for(future, timeout)
|
|
428
|
+
except TimeoutError:
|
|
429
|
+
pending = self._burst_waiters.get(key)
|
|
430
|
+
if pending and future in pending:
|
|
431
|
+
pending.remove(future)
|
|
432
|
+
raise TimeoutError(f"timed out after {timeout}s fetching {key!r}")
|
|
433
|
+
|
|
434
|
+
data, _ = await self.run(getter, *args, fetch_if_missing=False)
|
|
435
|
+
return data
|
|
436
|
+
|
|
437
|
+
def _ensure_burst_dispatch(self, kind: str) -> None:
|
|
438
|
+
if kind in self._burst_dispatch_kinds:
|
|
439
|
+
return
|
|
440
|
+
self._burst_dispatch_kinds.add(kind)
|
|
441
|
+
|
|
442
|
+
def dispatcher(full_key: str) -> None:
|
|
443
|
+
# Fires on the engine thread; hop to the loop to resolve.
|
|
444
|
+
self._loop.call_soon_threadsafe(self._resolve_burst, full_key)
|
|
445
|
+
|
|
446
|
+
self._proxy.on_burst_end(kind, dispatcher)
|
|
447
|
+
|
|
448
|
+
def _resolve_burst(self, full_key: str) -> None:
|
|
449
|
+
for future in self._burst_waiters.pop(full_key, []):
|
|
450
|
+
if not future.done():
|
|
451
|
+
future.set_result(None)
|
|
452
|
+
|
|
453
|
+
def __getattr__(self, name: str) -> Any:
|
|
454
|
+
# Note: only consulted for names not found on the class/instance,
|
|
455
|
+
# so explicit methods above always win.
|
|
456
|
+
if name in self.PROXY_METHODS:
|
|
457
|
+
target = getattr(self._proxy, name)
|
|
458
|
+
|
|
459
|
+
async def delegate(*args: Any, **kwargs: Any) -> Any:
|
|
460
|
+
return await self._loop.run_in_executor(
|
|
461
|
+
None, functools.partial(target, *args, **kwargs)
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
functools.update_wrapper(delegate, target)
|
|
465
|
+
return delegate
|
|
466
|
+
if name in self._LISTENER_METHODS:
|
|
467
|
+
register = getattr(self._proxy, name)
|
|
468
|
+
|
|
469
|
+
def add_listener(callback: Callable) -> None:
|
|
470
|
+
register(_marshal_callback(self._loop, callback))
|
|
471
|
+
|
|
472
|
+
functools.update_wrapper(add_listener, register)
|
|
473
|
+
return add_listener
|
|
474
|
+
raise AttributeError(
|
|
475
|
+
f"{type(self).__name__!s} has no attribute {name!r}; "
|
|
476
|
+
"use .sync to reach the underlying X1Proxy"
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class AsyncHubBrowser:
|
|
481
|
+
"""Asyncio wrapper around :class:`HubBrowser`.
|
|
482
|
+
|
|
483
|
+
Accepts the same callbacks (sync or async); they are delivered on
|
|
484
|
+
the event loop instead of the zeroconf engine thread. Start/stop run
|
|
485
|
+
in the executor because zeroconf engine setup/teardown blocks.
|
|
486
|
+
"""
|
|
487
|
+
|
|
488
|
+
def __init__(
|
|
489
|
+
self,
|
|
490
|
+
*,
|
|
491
|
+
loop: Optional[asyncio.AbstractEventLoop] = None,
|
|
492
|
+
zc: Any = None,
|
|
493
|
+
include_proxies: bool = False,
|
|
494
|
+
service_types: Optional[Iterable[str]] = None,
|
|
495
|
+
on_added: Optional[Callable] = None,
|
|
496
|
+
on_updated: Optional[Callable] = None,
|
|
497
|
+
on_removed: Optional[Callable] = None,
|
|
498
|
+
) -> None:
|
|
499
|
+
self._loop = loop or asyncio.get_running_loop()
|
|
500
|
+
kwargs: dict[str, Any] = {
|
|
501
|
+
"zc": zc,
|
|
502
|
+
"include_proxies": include_proxies,
|
|
503
|
+
"on_added": self._wrap(on_added),
|
|
504
|
+
"on_updated": self._wrap(on_updated),
|
|
505
|
+
"on_removed": self._wrap(on_removed),
|
|
506
|
+
}
|
|
507
|
+
if service_types is not None:
|
|
508
|
+
kwargs["service_types"] = service_types
|
|
509
|
+
self._browser = HubBrowser(**kwargs)
|
|
510
|
+
|
|
511
|
+
def _wrap(self, callback: Optional[Callable]) -> Optional[Callable]:
|
|
512
|
+
if callback is None:
|
|
513
|
+
return None
|
|
514
|
+
return _marshal_callback(self._loop, callback)
|
|
515
|
+
|
|
516
|
+
@property
|
|
517
|
+
def sync(self) -> HubBrowser:
|
|
518
|
+
return self._browser
|
|
519
|
+
|
|
520
|
+
@property
|
|
521
|
+
def hubs(self) -> list[DiscoveredHub]:
|
|
522
|
+
return self._browser.hubs
|
|
523
|
+
|
|
524
|
+
async def start(self) -> "AsyncHubBrowser":
|
|
525
|
+
await self._loop.run_in_executor(None, self._browser.start)
|
|
526
|
+
return self
|
|
527
|
+
|
|
528
|
+
async def stop(self) -> None:
|
|
529
|
+
await self._loop.run_in_executor(None, self._browser.stop)
|
|
530
|
+
|
|
531
|
+
async def __aenter__(self) -> "AsyncHubBrowser":
|
|
532
|
+
return await self.start()
|
|
533
|
+
|
|
534
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
535
|
+
await self.stop()
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
async def async_discover_hubs(
|
|
539
|
+
timeout: float = DEFAULT_DISCOVERY_TIMEOUT,
|
|
540
|
+
*,
|
|
541
|
+
zc: Any = None,
|
|
542
|
+
include_proxies: bool = False,
|
|
543
|
+
) -> list[DiscoveredHub]:
|
|
544
|
+
"""Async one-shot hub scan; the blocking browse runs in the executor."""
|
|
545
|
+
|
|
546
|
+
loop = asyncio.get_running_loop()
|
|
547
|
+
return await loop.run_in_executor(
|
|
548
|
+
None,
|
|
549
|
+
functools.partial(
|
|
550
|
+
discover_hubs, timeout=timeout, zc=zc, include_proxies=include_proxies
|
|
551
|
+
),
|
|
552
|
+
)
|