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/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
+ )