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/cli.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""sofapython CLI — discover hubs and drive a proxy interactively.
|
|
3
|
+
|
|
4
|
+
``sofapython discover`` one-shot mDNS scan for hubs.
|
|
5
|
+
``sofapython run`` start a proxy and open an interactive shell.
|
|
6
|
+
|
|
7
|
+
The shell is a thin UI over :class:`AsyncX1Proxy`: it reads input on the
|
|
8
|
+
executor so the event loop keeps running, so live hub/app/activity
|
|
9
|
+
events print as they happen, and every command maps to a facade call.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Awaitable, Callable, Dict, Optional
|
|
17
|
+
|
|
18
|
+
from .aio import AsyncX1Proxy, async_discover_hubs
|
|
19
|
+
from .protocol_const import BUTTONNAME_BY_CODE, ButtonName
|
|
20
|
+
|
|
21
|
+
# ----------------- helpers -----------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_int(s: str) -> int:
|
|
25
|
+
"""Parse decimal or 0x..."""
|
|
26
|
+
s = s.strip()
|
|
27
|
+
if s.lower().startswith("0x"):
|
|
28
|
+
return int(s, 16)
|
|
29
|
+
return int(s, 10)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_button(code_or_name: str) -> int | None:
|
|
33
|
+
"""Accept either numeric (e.g. 0xB0) or name (e.g. OK)."""
|
|
34
|
+
try:
|
|
35
|
+
return parse_int(code_or_name)
|
|
36
|
+
except ValueError:
|
|
37
|
+
pass
|
|
38
|
+
upper = code_or_name.upper()
|
|
39
|
+
if hasattr(ButtonName, upper):
|
|
40
|
+
return getattr(ButtonName, upper)
|
|
41
|
+
for code, name in BUTTONNAME_BY_CODE.items():
|
|
42
|
+
if name.upper() == upper:
|
|
43
|
+
return code
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _kv_list_to_dict(items) -> Dict[str, str]:
|
|
48
|
+
out: Dict[str, str] = {}
|
|
49
|
+
for it in items or []:
|
|
50
|
+
if "=" in it:
|
|
51
|
+
k, v = it.split("=", 1)
|
|
52
|
+
out[k.strip()] = v.strip()
|
|
53
|
+
else:
|
|
54
|
+
out[it.strip()] = ""
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _ainput(prompt: str) -> Optional[str]:
|
|
59
|
+
"""Read a line without blocking the event loop. None on EOF."""
|
|
60
|
+
|
|
61
|
+
def _read() -> Optional[str]:
|
|
62
|
+
try:
|
|
63
|
+
return input(prompt)
|
|
64
|
+
except EOFError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
return await asyncio.get_running_loop().run_in_executor(None, _read)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ----------------- interactive shell -----------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AsyncShell:
|
|
74
|
+
"""A small REPL over :class:`AsyncX1Proxy`."""
|
|
75
|
+
|
|
76
|
+
prompt = "x1> "
|
|
77
|
+
|
|
78
|
+
def __init__(self, proxy: AsyncX1Proxy) -> None:
|
|
79
|
+
self.p = proxy
|
|
80
|
+
self._stop = False
|
|
81
|
+
self._commands: Dict[str, Callable[[str], Awaitable[None]]] = {
|
|
82
|
+
"help": self.cmd_help,
|
|
83
|
+
"?": self.cmd_help,
|
|
84
|
+
"status": self.cmd_status,
|
|
85
|
+
"activities": self.cmd_activities,
|
|
86
|
+
"devices": self.cmd_devices,
|
|
87
|
+
"commands": self.cmd_commands,
|
|
88
|
+
"buttons": self.cmd_buttons,
|
|
89
|
+
"macros": self.cmd_macros,
|
|
90
|
+
"favorites": self.cmd_favorites,
|
|
91
|
+
"press": self.cmd_press,
|
|
92
|
+
"send": self.cmd_press, # alias
|
|
93
|
+
"start": self.cmd_start,
|
|
94
|
+
"stop": self.cmd_stop,
|
|
95
|
+
"find": self.cmd_find,
|
|
96
|
+
"proxy": self.cmd_proxy,
|
|
97
|
+
"quit": self.cmd_quit,
|
|
98
|
+
"exit": self.cmd_quit,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Live events (delivered on the loop) print as they happen.
|
|
102
|
+
proxy.on_hub_state_change(lambda up: print(f"\n[event] hub {'CONNECTED' if up else 'DISCONNECTED'}"))
|
|
103
|
+
proxy.on_client_state_change(lambda up: print(f"\n[event] app {'CONNECTED' if up else 'GONE'}"))
|
|
104
|
+
proxy.on_activity_change(
|
|
105
|
+
lambda new, old, name: print(f"\n[event] activity -> {name or '?'} ({old} -> {new})")
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# ----- read commands (facade-first, cached fallback in observe mode) ---
|
|
109
|
+
|
|
110
|
+
async def _read(self, label: str, fresh, cached):
|
|
111
|
+
try:
|
|
112
|
+
return await fresh()
|
|
113
|
+
except RuntimeError as err:
|
|
114
|
+
print(f"[{label}] {err}; showing cached")
|
|
115
|
+
return cached()
|
|
116
|
+
except TimeoutError as err:
|
|
117
|
+
print(f"[{label}] {err}")
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
async def cmd_status(self, _args: str) -> None:
|
|
121
|
+
p = self.p.sync
|
|
122
|
+
acts, _ = p.get_activities(fetch_if_missing=False)
|
|
123
|
+
devs, _ = p.get_devices(fetch_if_missing=False)
|
|
124
|
+
print("== status ==")
|
|
125
|
+
print(f"hub connected : {p.transport.is_hub_connected}")
|
|
126
|
+
print(f"app connected : {p.transport.is_client_connected}")
|
|
127
|
+
print(f"controllable : {p.can_issue_commands()}")
|
|
128
|
+
print(f"activities : {len(acts)} cached")
|
|
129
|
+
print(f"devices : {len(devs)} cached")
|
|
130
|
+
|
|
131
|
+
async def cmd_activities(self, _args: str) -> None:
|
|
132
|
+
acts = await self._read(
|
|
133
|
+
"activities",
|
|
134
|
+
self.p.activities,
|
|
135
|
+
lambda: self.p.sync.get_activities(fetch_if_missing=False)[0],
|
|
136
|
+
)
|
|
137
|
+
if not acts:
|
|
138
|
+
print("no activities")
|
|
139
|
+
return
|
|
140
|
+
print("id active name")
|
|
141
|
+
for act_id, info in sorted(acts.items()):
|
|
142
|
+
print(f"{act_id:<4} {'*' if info.get('active') else ' ':^6} {info.get('name', '')}")
|
|
143
|
+
|
|
144
|
+
async def cmd_devices(self, _args: str) -> None:
|
|
145
|
+
devs = await self._read(
|
|
146
|
+
"devices",
|
|
147
|
+
self.p.devices,
|
|
148
|
+
lambda: self.p.sync.get_devices(fetch_if_missing=False)[0],
|
|
149
|
+
)
|
|
150
|
+
if not devs:
|
|
151
|
+
print("no devices")
|
|
152
|
+
return
|
|
153
|
+
print("id name (brand)")
|
|
154
|
+
for dev_id, info in sorted(devs.items()):
|
|
155
|
+
print(f"{dev_id:<4} {info.get('name', '?')} ({info.get('brand', '')})")
|
|
156
|
+
|
|
157
|
+
async def cmd_commands(self, args: str) -> None:
|
|
158
|
+
if not args.strip():
|
|
159
|
+
print("usage: commands <device_id>")
|
|
160
|
+
return
|
|
161
|
+
dev = parse_int(args)
|
|
162
|
+
cmds = await self._read(
|
|
163
|
+
"commands",
|
|
164
|
+
lambda: self.p.commands(dev),
|
|
165
|
+
lambda: self.p.sync.get_commands_for_entity(dev, fetch_if_missing=False)[0],
|
|
166
|
+
)
|
|
167
|
+
if not cmds:
|
|
168
|
+
print("no commands")
|
|
169
|
+
return
|
|
170
|
+
for code, label in sorted(cmds.items()):
|
|
171
|
+
print(f" 0x{code:04X} {label}")
|
|
172
|
+
|
|
173
|
+
async def cmd_buttons(self, args: str) -> None:
|
|
174
|
+
if not args.strip():
|
|
175
|
+
print("usage: buttons <activity_or_device_id>")
|
|
176
|
+
return
|
|
177
|
+
ent = parse_int(args)
|
|
178
|
+
btns = await self._read(
|
|
179
|
+
"buttons",
|
|
180
|
+
lambda: self.p.buttons(ent),
|
|
181
|
+
lambda: self.p.sync.get_buttons_for_entity(ent, fetch_if_missing=False)[0],
|
|
182
|
+
)
|
|
183
|
+
if not btns:
|
|
184
|
+
print("no buttons")
|
|
185
|
+
return
|
|
186
|
+
for b in btns:
|
|
187
|
+
print(f" {b:3d} ({BUTTONNAME_BY_CODE.get(b, f'0x{b:02X}')})")
|
|
188
|
+
|
|
189
|
+
async def cmd_macros(self, args: str) -> None:
|
|
190
|
+
if not args.strip():
|
|
191
|
+
print("usage: macros <activity_id>")
|
|
192
|
+
return
|
|
193
|
+
act = parse_int(args)
|
|
194
|
+
macros = await self._read(
|
|
195
|
+
"macros",
|
|
196
|
+
lambda: self.p.macros(act),
|
|
197
|
+
lambda: self.p.sync.get_macros_for_activity(act, fetch_if_missing=False)[0],
|
|
198
|
+
)
|
|
199
|
+
if not macros:
|
|
200
|
+
print("no macros")
|
|
201
|
+
return
|
|
202
|
+
for m in macros:
|
|
203
|
+
print(f" {m.get('label', '')} (id={m.get('command_id')})")
|
|
204
|
+
|
|
205
|
+
async def cmd_favorites(self, args: str) -> None:
|
|
206
|
+
if not args.strip():
|
|
207
|
+
print("usage: favorites <activity_id>")
|
|
208
|
+
return
|
|
209
|
+
act = parse_int(args)
|
|
210
|
+
try:
|
|
211
|
+
favs = await self.p.favorites(act)
|
|
212
|
+
except (RuntimeError, TimeoutError) as err:
|
|
213
|
+
print(f"[favorites] {err}")
|
|
214
|
+
return
|
|
215
|
+
if not favs:
|
|
216
|
+
print("no favorites")
|
|
217
|
+
return
|
|
218
|
+
print(" " + ", ".join(f"slot {slot}=0x{fid:02X}" for fid, slot in favs))
|
|
219
|
+
|
|
220
|
+
# ----- control commands -------------------------------------------------
|
|
221
|
+
|
|
222
|
+
async def cmd_press(self, args: str) -> None:
|
|
223
|
+
parts = args.split()
|
|
224
|
+
if len(parts) != 2:
|
|
225
|
+
print("usage: press <entity_id> <button_name_or_code> (e.g. press 101 POWER_ON)")
|
|
226
|
+
return
|
|
227
|
+
ent = parse_int(parts[0])
|
|
228
|
+
btn = resolve_button(parts[1])
|
|
229
|
+
if btn is None:
|
|
230
|
+
print(f"unknown button {parts[1]!r}")
|
|
231
|
+
return
|
|
232
|
+
ok = await self.p.press(ent, btn)
|
|
233
|
+
print("sent" if ok else "refused (need control mode: disconnect the app)")
|
|
234
|
+
|
|
235
|
+
async def cmd_start(self, args: str) -> None:
|
|
236
|
+
if not args.strip():
|
|
237
|
+
print("usage: start <activity_id>")
|
|
238
|
+
return
|
|
239
|
+
ok = await self.p.start_activity(parse_int(args))
|
|
240
|
+
print("started" if ok else "refused (need control mode: disconnect the app)")
|
|
241
|
+
|
|
242
|
+
async def cmd_stop(self, args: str) -> None:
|
|
243
|
+
if not args.strip():
|
|
244
|
+
print("usage: stop <activity_id>")
|
|
245
|
+
return
|
|
246
|
+
ok = await self.p.stop_activity(parse_int(args))
|
|
247
|
+
print("stopped" if ok else "refused (need control mode: disconnect the app)")
|
|
248
|
+
|
|
249
|
+
async def cmd_find(self, _args: str) -> None:
|
|
250
|
+
ok = await self.p.find_remote()
|
|
251
|
+
print("sent find-remote" if ok else "refused (need control mode: disconnect the app)")
|
|
252
|
+
|
|
253
|
+
async def cmd_proxy(self, args: str) -> None:
|
|
254
|
+
arg = args.strip().lower()
|
|
255
|
+
if arg == "on":
|
|
256
|
+
await self.p.enable_proxy()
|
|
257
|
+
print("proxy enabled")
|
|
258
|
+
elif arg == "off":
|
|
259
|
+
await self.p.disable_proxy()
|
|
260
|
+
print("proxy disabled")
|
|
261
|
+
else:
|
|
262
|
+
print("usage: proxy on|off")
|
|
263
|
+
|
|
264
|
+
# ----- meta -------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
async def cmd_help(self, _args: str) -> None:
|
|
267
|
+
print("commands:")
|
|
268
|
+
print(" status hub/app state + cached counts")
|
|
269
|
+
print(" activities | devices list catalogs")
|
|
270
|
+
print(" commands <dev> | buttons <ent> per-entity detail")
|
|
271
|
+
print(" macros <act> | favorites <act> activity detail")
|
|
272
|
+
print(" press <ent> <button> send a button (alias: send)")
|
|
273
|
+
print(" start <act> | stop <act> switch activity power")
|
|
274
|
+
print(" find find-my-remote")
|
|
275
|
+
print(" proxy on|off toggle pass-through")
|
|
276
|
+
print(" quit exit")
|
|
277
|
+
print("\nreads need control mode (no app attached); otherwise cached is shown.")
|
|
278
|
+
|
|
279
|
+
async def cmd_quit(self, _args: str) -> None:
|
|
280
|
+
self._stop = True
|
|
281
|
+
|
|
282
|
+
# ----- REPL loop --------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
async def loop(self) -> None:
|
|
285
|
+
await self.cmd_help("")
|
|
286
|
+
while not self._stop:
|
|
287
|
+
line = await _ainput(self.prompt)
|
|
288
|
+
if line is None:
|
|
289
|
+
break
|
|
290
|
+
line = line.strip()
|
|
291
|
+
if not line:
|
|
292
|
+
continue
|
|
293
|
+
cmd, _, rest = line.partition(" ")
|
|
294
|
+
handler = self._commands.get(cmd)
|
|
295
|
+
if handler is None:
|
|
296
|
+
print(f"unknown command {cmd!r}; type 'help'")
|
|
297
|
+
continue
|
|
298
|
+
try:
|
|
299
|
+
await handler(rest.strip())
|
|
300
|
+
except Exception as err: # keep the shell alive on command errors
|
|
301
|
+
print(f"error: {err}")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ----------------- subcommands -----------------
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def _main_discover(argv: list[str]) -> None:
|
|
308
|
+
"""One-shot mDNS scan for physical hubs (and optionally proxies)."""
|
|
309
|
+
|
|
310
|
+
ap = argparse.ArgumentParser(
|
|
311
|
+
prog="sofapython discover",
|
|
312
|
+
description="Discover Sofabaton hubs on the local network via mDNS",
|
|
313
|
+
)
|
|
314
|
+
ap.add_argument("--timeout", type=float, default=5.0, help="scan duration in seconds (default 5)")
|
|
315
|
+
ap.add_argument("--include-proxies", action="store_true", help="also list proxy advertisements")
|
|
316
|
+
ap.add_argument("--json", action="store_true", help="emit one JSON object per hub")
|
|
317
|
+
args = ap.parse_args(argv)
|
|
318
|
+
|
|
319
|
+
# One-shot scan: the blocking discover_hubs is fine here (nothing else
|
|
320
|
+
# runs on the loop). Imported at call time so it stays monkeypatchable.
|
|
321
|
+
from .discovery import discover_hubs
|
|
322
|
+
|
|
323
|
+
hubs = discover_hubs(timeout=args.timeout, include_proxies=args.include_proxies)
|
|
324
|
+
if args.json:
|
|
325
|
+
import json as _json
|
|
326
|
+
|
|
327
|
+
for hub in hubs:
|
|
328
|
+
print(
|
|
329
|
+
_json.dumps(
|
|
330
|
+
{
|
|
331
|
+
"host": hub.host,
|
|
332
|
+
"port": hub.port,
|
|
333
|
+
"name": hub.name,
|
|
334
|
+
"mac": hub.mac,
|
|
335
|
+
"hub_version": hub.hub_version,
|
|
336
|
+
"is_proxy": hub.is_proxy,
|
|
337
|
+
"service_type": hub.service_type,
|
|
338
|
+
"txt": hub.txt,
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
if not hubs:
|
|
345
|
+
print(f"no hubs found in {args.timeout:g}s")
|
|
346
|
+
return
|
|
347
|
+
print(f"{'host':15} {'port':5} {'ver':4} {'proxy':5} name")
|
|
348
|
+
for hub in hubs:
|
|
349
|
+
print(
|
|
350
|
+
f"{hub.host:15} {hub.port:5d} {hub.hub_version or '?':4} "
|
|
351
|
+
f"{'yes' if hub.is_proxy else 'no':5} {hub.name}"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
async def _main_run(argv: list[str]) -> None:
|
|
356
|
+
ap = argparse.ArgumentParser(prog="sofapython run", description="Proxy a hub + interactive shell")
|
|
357
|
+
ap.add_argument("--hub", help="real hub IP; omit to auto-discover the first hub")
|
|
358
|
+
ap.add_argument("--hub-udp", type=int, default=8102)
|
|
359
|
+
ap.add_argument("--proxy-udp", type=int, default=8102, help="CALL_ME/NOTIFY_ME UDP port (8102 for iOS)")
|
|
360
|
+
ap.add_argument("--listen-base", type=int, default=8200)
|
|
361
|
+
ap.add_argument("--mdns-txt", action="append", help="TXT kv pair, e.g. HVER=2 (repeatable); used with --hub")
|
|
362
|
+
ap.add_argument("--mdns-name", default="X1-HUB-PROXY")
|
|
363
|
+
ap.add_argument("--disable-proxy", action="store_true", help="start with pass-through disabled")
|
|
364
|
+
ap.add_argument("--connect-timeout", type=float, default=15.0, help="seconds to wait for the hub to connect")
|
|
365
|
+
ap.add_argument("--debug", action="store_true", help="verbose engine logging")
|
|
366
|
+
ap.add_argument("--no-dump", dest="diag_dump", action="store_false")
|
|
367
|
+
ap.add_argument("--no-parse", dest="diag_parse", action="store_false")
|
|
368
|
+
args = ap.parse_args(argv)
|
|
369
|
+
|
|
370
|
+
logging.basicConfig(
|
|
371
|
+
level=logging.DEBUG if args.debug else logging.WARNING,
|
|
372
|
+
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if args.hub:
|
|
376
|
+
real_hub_ip = args.hub
|
|
377
|
+
mdns_txt = _kv_list_to_dict(args.mdns_txt)
|
|
378
|
+
mdns_instance = args.mdns_name
|
|
379
|
+
hub_version = None
|
|
380
|
+
else:
|
|
381
|
+
print("discovering hubs...")
|
|
382
|
+
found = await async_discover_hubs(timeout=5.0)
|
|
383
|
+
if not found:
|
|
384
|
+
print("no hubs found; pass --hub IP")
|
|
385
|
+
return
|
|
386
|
+
hub = found[0]
|
|
387
|
+
real_hub_ip = hub.host
|
|
388
|
+
mdns_txt = dict(hub.txt)
|
|
389
|
+
mdns_instance = hub.name
|
|
390
|
+
hub_version = hub.hub_version
|
|
391
|
+
print(f"using {hub.name} ({hub.hub_version}) at {hub.host}")
|
|
392
|
+
|
|
393
|
+
proxy = AsyncX1Proxy(
|
|
394
|
+
real_hub_ip=real_hub_ip,
|
|
395
|
+
real_hub_udp_port=args.hub_udp,
|
|
396
|
+
proxy_udp_port=args.proxy_udp,
|
|
397
|
+
hub_listen_base=args.listen_base,
|
|
398
|
+
mdns_txt=mdns_txt,
|
|
399
|
+
mdns_instance=mdns_instance,
|
|
400
|
+
hub_version=hub_version,
|
|
401
|
+
proxy_enabled=not args.disable_proxy,
|
|
402
|
+
diag_dump=args.diag_dump,
|
|
403
|
+
diag_parse=args.diag_parse,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
async with proxy:
|
|
407
|
+
print("proxy started; waiting for the hub...")
|
|
408
|
+
if await proxy.wait_connected(timeout=args.connect_timeout):
|
|
409
|
+
controllable = proxy.sync.can_issue_commands()
|
|
410
|
+
print(f"hub connected ({'control mode' if controllable else 'observe mode — an app is attached'})")
|
|
411
|
+
else:
|
|
412
|
+
print("hub not connected yet (the shell still works; events will appear when it connects)")
|
|
413
|
+
try:
|
|
414
|
+
await AsyncShell(proxy).loop()
|
|
415
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
416
|
+
pass
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
_SUBCOMMANDS = ("run", "discover")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def main(argv: list[str] | None = None) -> None:
|
|
423
|
+
"""Entry point. ``run`` (default) opens the shell; ``discover`` scans."""
|
|
424
|
+
|
|
425
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
426
|
+
if args[:1] in (["-h"], ["--help"]):
|
|
427
|
+
print(
|
|
428
|
+
"usage: sofapython [run|discover] ...\n\n"
|
|
429
|
+
"subcommands:\n"
|
|
430
|
+
" run proxy a hub + interactive shell (default; see 'run -h')\n"
|
|
431
|
+
" discover scan the LAN for Sofabaton hubs (see 'discover -h')"
|
|
432
|
+
)
|
|
433
|
+
return
|
|
434
|
+
command = "run"
|
|
435
|
+
if args and args[0] in _SUBCOMMANDS:
|
|
436
|
+
command = args.pop(0)
|
|
437
|
+
try:
|
|
438
|
+
if command == "discover":
|
|
439
|
+
asyncio.run(_main_discover(args))
|
|
440
|
+
else:
|
|
441
|
+
asyncio.run(_main_run(args))
|
|
442
|
+
except KeyboardInterrupt:
|
|
443
|
+
pass
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
if __name__ == "__main__":
|
|
447
|
+
main()
|