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