openajazz 0.4.0__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.
openajazz/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """openajazz: unofficial control for AJAZZ keyboards (AK820 Max Plus supported)."""
2
+
3
+ __version__ = "0.4.0"
openajazz/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
openajazz/cli.py ADDED
@@ -0,0 +1,566 @@
1
+ """Command-line front end (rich-formatted) for the AK820 Max Plus driver."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ import time
10
+
11
+ from rich import box
12
+ from rich.console import Console, Group
13
+ from rich.live import Live
14
+ from rich.panel import Panel
15
+ from rich.progress_bar import ProgressBar
16
+ from rich.prompt import Confirm
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from . import __version__, control, keys, state
21
+ from .protocol import DeviceError, Keyboard, find_interfaces
22
+
23
+ console = Console()
24
+ err_console = Console(stderr=True) # packet logs stay out of --json output
25
+
26
+ NAMED_COLORS = {
27
+ "off": (0, 0, 0), "black": (0, 0, 0), "white": (255, 255, 255),
28
+ "red": (255, 0, 0), "green": (0, 255, 0), "blue": (0, 0, 255),
29
+ "yellow": (255, 255, 0), "cyan": (0, 255, 255), "magenta": (255, 0, 255),
30
+ "purple": (128, 0, 255), "orange": (255, 128, 0), "pink": (255, 105, 180),
31
+ }
32
+ SLEEP_OPTIONS = {"5": 1, "15": 2, "30": 3, "never": 4}
33
+ LINK_STATES = keys.POWER_STATES # kept for older imports
34
+
35
+ # re-exported for the TUI and older callers
36
+ parse_macro_events = control.parse_macro_events
37
+
38
+
39
+ def parse_color(s: str) -> tuple[int, int, int]:
40
+ s = s.strip().lower()
41
+ if s in NAMED_COLORS:
42
+ return NAMED_COLORS[s]
43
+ h = s.lstrip("#")
44
+ if len(h) == 3:
45
+ h = "".join(c * 2 for c in h)
46
+ if len(h) != 6:
47
+ raise argparse.ArgumentTypeError(f"bad colour '{s}' (use RRGGBB, RGB or a name)")
48
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
49
+
50
+
51
+ # -- rendering --------------------------------------------------------------
52
+
53
+ def render_keyboard(colors: dict[int, tuple[int, int, int]] | None = None,
54
+ highlight: set[int] | None = None, title="AK820 Max Plus") -> Panel:
55
+ """Draw the 75% layout; keys tinted by LED colour or highlighted by code."""
56
+ rows: dict[int, list] = {}
57
+ for code, legend, led, row, col in keys.PHYSICAL_KEYS:
58
+ rows.setdefault(row, []).append((col, code, legend, led))
59
+ lines = []
60
+ for row in sorted(rows):
61
+ line = Text()
62
+ for _, code, legend, led in sorted(rows[row]):
63
+ width = 9 if legend == "Space" else 6 if len(legend) > 3 else 4
64
+ label = f"{legend:^{width}}"
65
+ if colors is not None:
66
+ r, g, b = colors.get(led, (0, 0, 0))
67
+ fg = "black" if (r * 299 + g * 587 + b * 114) > 128000 else "white"
68
+ style = f"{fg} on rgb({r},{g},{b})"
69
+ elif highlight and code in highlight:
70
+ style = "bold black on bright_yellow"
71
+ else:
72
+ style = "white on grey23"
73
+ line.append(label, style=style)
74
+ line.append(" ")
75
+ lines.append(line)
76
+ return Panel(Group(*lines), title=title, box=box.ROUNDED, expand=False)
77
+
78
+
79
+ def packet_logger(report: bytes):
80
+ err_console.print(Text("→ " + report.rstrip(b"\x00").hex(" "), style="dim cyan"))
81
+
82
+
83
+ def ok(msg: str):
84
+ console.print(f"[bold green]✓[/] {msg}")
85
+
86
+
87
+ def emit_json(data):
88
+ print(json.dumps(data, indent=2))
89
+
90
+
91
+ def open_keyboard(args) -> Keyboard:
92
+ return Keyboard(args.device, dry_run=args.dry_run,
93
+ log=packet_logger if (args.verbose or args.dry_run) else None)
94
+
95
+
96
+ class Session:
97
+ """Opens the keyboard and the state file; saves state on success."""
98
+
99
+ def __init__(self, args):
100
+ self.args = args
101
+ self.st = state.load()
102
+
103
+ def __enter__(self):
104
+ self.kb = open_keyboard(self.args)
105
+ return self.kb, self.st
106
+
107
+ def __exit__(self, exc_type, *exc):
108
+ self.kb.close()
109
+ if exc_type is None and not self.args.dry_run:
110
+ state.save(self.st)
111
+
112
+
113
+ # -- info commands ----------------------------------------------------------
114
+
115
+ def cmd_devices(args):
116
+ found = find_interfaces()
117
+ if args.json:
118
+ emit_json([{"path": i.path, "connection": i.connection, "interface": i.interface,
119
+ "control": i.is_control} for i in found])
120
+ return
121
+ table = Table(title="Detected AK820 interfaces", box=box.SIMPLE_HEAVY)
122
+ for col in ("hidraw", "connection", "usb interface", "role"):
123
+ table.add_column(col)
124
+ for i in found:
125
+ table.add_row(i.path, i.connection, i.interface,
126
+ "[bold green]control[/]" if i.is_control else "[dim]input[/]")
127
+ if not found:
128
+ console.print("[red]No AK820 Max Plus found.[/] Is the cable or dongle plugged in?")
129
+ else:
130
+ console.print(table)
131
+
132
+
133
+ def show_status(kb: Keyboard, as_json=False):
134
+ st = kb.status()
135
+ if as_json:
136
+ emit_json(None if st is None else
137
+ {"battery": st.battery, "power": st.power, "power_name": st.power_name,
138
+ "device": kb.path})
139
+ return
140
+ if st is None:
141
+ console.print(Panel("[red]No reply to status query[/] "
142
+ "(asleep, or switched off behind the dongle?)", title=kb.path))
143
+ return
144
+ rows = [Text.assemble(("Power: ", "bold"), st.power_name)]
145
+ if st.online and st.battery is not None:
146
+ colour = "green" if st.battery > 50 else "yellow" if st.battery > 20 else "red"
147
+ rows.append(Text.assemble(("Battery: ", "bold"), (f"{st.battery:3d}%", colour)))
148
+ rows.append(ProgressBar(total=100, completed=st.battery, width=30,
149
+ complete_style=colour))
150
+ title = f"Status · {kb.path}" + (" · simulated" if kb.dry_run else "")
151
+ console.print(Panel(Group(*rows), title=title, expand=False))
152
+
153
+
154
+ def cmd_status(args):
155
+ with open_keyboard(args) as kb:
156
+ show_status(kb, args.json)
157
+
158
+
159
+ def cmd_modes(args):
160
+ if args.json:
161
+ emit_json([{"mode": m, "name": n, "slug": keys.SLUG_BY_MODE.get(m),
162
+ "brightness": bool(a & keys.ATTR_BRIGHTNESS),
163
+ "speed": bool(a & keys.ATTR_SPEED),
164
+ "direction": bool(a & keys.ATTR_DIRECTION),
165
+ "color": bool(a & keys.ATTR_COLOR)}
166
+ for m, (n, a) in keys.LIGHT_MODES.items()])
167
+ return
168
+ table = Table(title="Lighting effects", box=box.SIMPLE_HEAVY)
169
+ for col in ("#", "slug", "name", "bright", "speed", "dir", "colour"):
170
+ table.add_column(col, justify="left" if col in ("slug", "name") else "center")
171
+ tick = lambda attr, bit: "[green]●[/]" if attr & bit else "[dim]·[/]"
172
+ for num, (name, attr) in keys.LIGHT_MODES.items():
173
+ table.add_row(str(num), keys.SLUG_BY_MODE.get(num, ""), name,
174
+ tick(attr, keys.ATTR_BRIGHTNESS), tick(attr, keys.ATTR_SPEED),
175
+ tick(attr, keys.ATTR_DIRECTION), tick(attr, keys.ATTR_COLOR))
176
+ console.print(table)
177
+ swatches = Text("colours: ")
178
+ for i, (name, rgb) in enumerate(keys.PALETTE):
179
+ style = "bold" if rgb is None else "black on rgb({},{},{})".format(*rgb)
180
+ swatches.append(f" {i} {name} ", style=style)
181
+ swatches.append(" ")
182
+ console.print(swatches)
183
+ console.print("[dim]directions: left-to-right (0), right-to-left (1)[/]")
184
+
185
+
186
+ def cmd_keys(args):
187
+ if args.json:
188
+ emit_json({"keys": keys.HID_USAGES, "media": keys.MEDIA_CODES,
189
+ "aliases": keys.ALIASES})
190
+ return
191
+ table = Table(title="Key names", box=box.SIMPLE, show_lines=False)
192
+ table.add_column("keys (for remap sources/targets, custom colours, macros)", max_width=70)
193
+ table.add_column("media (remap targets only)", max_width=40)
194
+ table.add_row(", ".join(keys.HID_USAGES), ", ".join(keys.MEDIA_CODES))
195
+ console.print(table)
196
+ console.print("[dim]aliases: " + ", ".join(f"{a}→{b}" for a, b in keys.ALIASES.items()) + "[/]")
197
+
198
+
199
+ # -- lighting ---------------------------------------------------------------
200
+
201
+ def cmd_light(args):
202
+ mode = keys.effect_mode(args.effect)
203
+ if mode not in keys.LIGHT_MODES:
204
+ console.print(f"[yellow]Mode {mode} isn't in the vendor list; sending anyway.[/]")
205
+ if mode in (keys.MUSIC_MODE, keys.SCREEN_MODE):
206
+ raise ValueError("music/screen modes are driven by the PC; use `openajazz music`")
207
+ with Session(args) as (kb, st):
208
+ prev = st["light"]
209
+ light = {"mode": mode,
210
+ "brightness": args.brightness or prev["brightness"],
211
+ "speed": args.speed or prev["speed"],
212
+ "direction": keys.direction_value(args.direction) if args.direction
213
+ else prev["direction"],
214
+ "color": keys.palette_index(args.color) if args.color else prev["color"]}
215
+ control.apply_light(kb, st, light)
216
+ name = keys.LIGHT_MODES.get(mode, ("?", 0))[0]
217
+ ok(f"{name}: brightness {light['brightness']}, speed {light['speed']}, "
218
+ f"{'right-to-left' if light['direction'] else 'left-to-right'}, "
219
+ f"{keys.PALETTE[light['color']][0]}")
220
+
221
+
222
+ def cmd_lights(args):
223
+ with Session(args) as (kb, st):
224
+ if args.action == "off":
225
+ control.lights_off(kb, st)
226
+ on = False
227
+ elif args.action == "on":
228
+ control.lights_on(kb, st)
229
+ on = True
230
+ else:
231
+ on = control.toggle_lights(kb, st)
232
+ ok("lights on, previous lighting restored" if on else "lights off")
233
+
234
+
235
+ def build_colors(args, base=None) -> dict[int, tuple[int, int, int]]:
236
+ colors = dict(base or {})
237
+ if args.all:
238
+ colors = {led: args.all for _, _, led, _, _ in keys.PHYSICAL_KEYS}
239
+ for spec in args.keys:
240
+ name, _, col = spec.partition("=")
241
+ code = keys.key_code(name)
242
+ if code not in keys.LED_BY_CODE:
243
+ raise KeyError(f"'{name}' is not a key on this keyboard")
244
+ colors[keys.LED_BY_CODE[code]] = parse_color(col)
245
+ return colors
246
+
247
+
248
+ def cmd_custom(args):
249
+ with Session(args) as (kb, st):
250
+ base = None if args.fresh else state.colors_from_json(st["colors"])
251
+ colors = build_colors(args, base)
252
+ console.print(render_keyboard(colors))
253
+ control.upload_custom(kb, st, colors, args.brightness)
254
+ ok(f"custom per-key colours stored ({len(colors)} keys)")
255
+
256
+
257
+ def _stop_like_ctrl_c(*_):
258
+ # `music --stop` and systemd send SIGTERM: stop cleanly and restore the lighting
259
+ raise KeyboardInterrupt
260
+
261
+
262
+ def cmd_music(args):
263
+ from . import service
264
+
265
+ if args.stop:
266
+ stopped = service.stop_background()
267
+ ok("music sync stopped, lighting restored" if stopped else "music sync wasn't running")
268
+ return
269
+ if args.background:
270
+ argv = service.music_argv(args.style, args.color, args.sensitivity, args.fps, args.device,
271
+ args.dry_run)
272
+ try:
273
+ pid = service.start_background(argv)
274
+ except RuntimeError as e:
275
+ raise ValueError(str(e))
276
+ ok(f"music sync running in the background (pid {pid}). "
277
+ "Close the terminal whenever; stop it with [bold]openajazz music --stop[/]")
278
+ return
279
+
280
+ import signal
281
+ from .music import AudioError, BANDS, Visualizer
282
+
283
+ color = parse_color(args.color) if args.color else None
284
+ signal.signal(signal.SIGTERM, _stop_like_ctrl_c)
285
+ with Session(args) as (kb, st):
286
+ latest = {"frame": {}, "levels": [0.0] * BANDS}
287
+
288
+ def on_frame(frame, levels):
289
+ latest["frame"], latest["levels"] = frame, levels
290
+
291
+ errors = []
292
+ try:
293
+ viz = Visualizer(None if args.dry_run else kb, args.style, color, args.fps,
294
+ args.sensitivity, on_frame=on_frame, on_error=errors.append)
295
+ viz.start()
296
+ except AudioError as e:
297
+ raise ValueError(str(e))
298
+ try:
299
+ if args.quiet:
300
+ print(f"music sync running ({args.style})", flush=True)
301
+ while viz.running:
302
+ time.sleep(0.5)
303
+ else:
304
+ console.print(f"[bold]music sync[/] · {args.style} · Ctrl-C to stop "
305
+ "[dim](or run with --background)[/]")
306
+ with Live(console=console, refresh_per_second=15) as live:
307
+ while viz.running:
308
+ bars = Text("".join(" ▁▂▃▄▅▆▇█"[min(8, int(l * 8.99))]
309
+ for l in latest["levels"]), style="bold magenta")
310
+ live.update(Group(render_keyboard(latest["frame"], title="live"), bars))
311
+ time.sleep(1 / 15)
312
+ except KeyboardInterrupt:
313
+ pass
314
+ finally:
315
+ viz.stop()
316
+ if errors:
317
+ raise ValueError(str(errors[0]))
318
+ if not st.get("lights_off"):
319
+ control.lights_on(kb, st) # put the stored lighting back right away
320
+ if not args.quiet:
321
+ ok("music sync stopped, lighting restored")
322
+
323
+
324
+ def cmd_autostart(args):
325
+ from . import service
326
+
327
+ try:
328
+ if args.what == "music":
329
+ service.stop_background() # one stream at a time
330
+ argv = service.music_argv(args.style, args.color, args.sensitivity, args.fps,
331
+ args.device)
332
+ service.enable_autostart(argv)
333
+ ok(f"music sync now starts at login, and it's running now "
334
+ f"([dim]{service.UNIT_PATH}[/]). Turn off with [bold]openajazz autostart off[/]")
335
+ elif args.what == "off":
336
+ ok("autostart removed" if service.disable_autostart() else "autostart wasn't on")
337
+ else:
338
+ bg = service.running_pid()
339
+ console.print(f"autostart: {service.autostart_status()}")
340
+ console.print(f"background run: {'pid ' + str(bg) if bg else 'none'}")
341
+ except (RuntimeError, subprocess.CalledProcessError) as e:
342
+ detail = getattr(e, "stderr", "") or str(e)
343
+ raise ValueError(detail.strip())
344
+
345
+
346
+ # -- settings / keys --------------------------------------------------------
347
+
348
+ def cmd_sleep(args):
349
+ with Session(args) as (kb, st):
350
+ s = st["settings"]
351
+ control.apply_settings(kb, st, SLEEP_OPTIONS[args.minutes], s["response"], s["fn"])
352
+ ok(f"sleep timer: {args.minutes}{'' if args.minutes == 'never' else ' min'}")
353
+
354
+
355
+ def cmd_response(args):
356
+ with Session(args) as (kb, st):
357
+ s = st["settings"]
358
+ control.apply_settings(kb, st, s["sleep"], args.gear, s["fn"])
359
+ ok(f"key response time: gear {args.gear}")
360
+
361
+
362
+ def cmd_fn_switch(args):
363
+ with Session(args) as (kb, st):
364
+ s = st["settings"]
365
+ control.apply_settings(kb, st, s["sleep"], s["response"], args.mode == "fn")
366
+ ok(f"Fn switch: {args.mode} [dim](no visible effect seen on hardware so far)[/]")
367
+
368
+
369
+ def cmd_remap(args):
370
+ src, dst = keys.key_code(args.src), keys.target_code(args.dst)
371
+ console.print(render_keyboard(highlight={src}))
372
+ with Session(args) as (kb, st):
373
+ control.remap(kb, st, 1 if args.fn else 0, src, dst)
374
+ ok(f"{'Fn+' if args.fn else ''}{keys.code_name(src)} → {keys.code_name(dst)}")
375
+
376
+
377
+ def cmd_restore(args):
378
+ src = keys.key_code(args.src)
379
+ with Session(args) as (kb, st):
380
+ control.restore_key(kb, st, 1 if args.fn else 0, src)
381
+ ok(f"{'Fn+' if args.fn else ''}{keys.code_name(src)} back to default "
382
+ f"({len(st['remaps'])} remaps and {len(st['macros'])} macros re-sent)")
383
+
384
+
385
+ def cmd_macro(args):
386
+ src = keys.key_code(args.src)
387
+ if not args.events:
388
+ raise ValueError("macro needs at least one event")
389
+ raw = " ".join(args.events)
390
+ events = control.parse_macro_events(args.events, args.delay)
391
+ table = Table(title=f"Macro on {'Fn+' if args.fn else ''}{keys.code_name(src)} "
392
+ f"(repeat ×{args.repeat})", box=box.SIMPLE)
393
+ for col in ("#", "key", "action", "delay"):
394
+ table.add_column(col)
395
+ for i, (code, release, delay) in enumerate(events, 1):
396
+ table.add_row(str(i), keys.code_name(code),
397
+ "[red]release[/]" if release else "[green]press[/]", f"{delay} ms")
398
+ console.print(table)
399
+ with Session(args) as (kb, st):
400
+ control.set_macro(kb, st, 1 if args.fn else 0, src, raw, args.repeat)
401
+ ok("macro stored")
402
+
403
+
404
+ def cmd_reset_keys(args):
405
+ with Session(args) as (kb, st):
406
+ control.reset_keys(kb, st)
407
+ ok("key assignments reset to default")
408
+
409
+
410
+ def cmd_factory_reset(args):
411
+ if not (args.yes or Confirm.ask("[red]Restore factory settings? All keyboard "
412
+ "data will be erased[/]", default=False)):
413
+ return
414
+ with Session(args) as (kb, st):
415
+ control.factory_reset(kb, st)
416
+ ok("factory reset sent")
417
+
418
+
419
+ def cmd_raw(args):
420
+ data = bytes.fromhex("".join(args.hex))
421
+ with open_keyboard(args) as kb:
422
+ resp = kb.raw(data, reply=args.reply)
423
+ if args.reply:
424
+ console.print(resp.hex(" ") if resp else "[yellow]no reply[/]")
425
+
426
+
427
+ UDEV_RULE = """\
428
+ # openajazz: let the logged-in user open the config interface (USB interface 0)
429
+ # of supported AJAZZ keyboards. The typing interfaces stay root-only, so nothing
430
+ # can read keystrokes through this rule.
431
+ # AK820 Max Plus: wired 1a2c:a036, 2.4G dongle 1a2c:8fff
432
+ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1a2c", ATTRS{idProduct}=="a036|8fff", DEVPATH=="*:1.0/*", TAG+="uaccess"
433
+ """
434
+ UDEV_PATH = "/etc/udev/rules.d/70-openajazz.rules"
435
+
436
+
437
+ def cmd_udev(args):
438
+ import subprocess
439
+ if not args.install:
440
+ console.print(Panel(UDEV_RULE.strip(), title=UDEV_PATH, expand=False))
441
+ console.print("install it with [bold]openajazz udev --install[/] (asks for sudo), "
442
+ "then replug the keyboard or dongle")
443
+ return
444
+ subprocess.run(["sudo", "tee", UDEV_PATH], input=UDEV_RULE.encode(),
445
+ stdout=subprocess.DEVNULL, check=True)
446
+ subprocess.run(["sudo", "udevadm", "control", "--reload"], check=True)
447
+ subprocess.run(["sudo", "udevadm", "trigger", "--subsystem-match=hidraw"], check=True)
448
+ ok(f"wrote {UDEV_PATH}; replug the keyboard or dongle if it still says permission denied")
449
+
450
+
451
+ def cmd_tui(args):
452
+ from .tui import run
453
+ run(args.device, args.dry_run)
454
+
455
+
456
+ # -- argument parsing -------------------------------------------------------
457
+
458
+ def build_parser() -> argparse.ArgumentParser:
459
+ p = argparse.ArgumentParser(
460
+ prog="openajazz",
461
+ description="Unofficial control for AJAZZ keyboards (AK820 Max Plus). Run with no command for the TUI.")
462
+ p.add_argument("--device", help="hidraw node (default: auto-detect)")
463
+ p.add_argument("-n", "--dry-run", action="store_true",
464
+ help="simulate: print packets, send nothing, don't touch saved state")
465
+ p.add_argument("-v", "--verbose", action="store_true", help="show packets sent")
466
+ p.add_argument("--json", action="store_true", help="JSON output (status/devices/modes/keys)")
467
+ p.add_argument("--version", action="version", version=__version__)
468
+ sub = p.add_subparsers(dest="command", metavar="command")
469
+
470
+ def add(name, func, help, aliases=()):
471
+ s = sub.add_parser(name, help=help, aliases=list(aliases))
472
+ s.set_defaults(func=func)
473
+ return s
474
+
475
+ add("tui", cmd_tui, "full-screen interface (default)")
476
+ add("devices", cmd_devices, "list detected interfaces", ["detect"])
477
+ add("status", cmd_status, "battery and power state", ["info"])
478
+ add("modes", cmd_modes, "list lighting effects and colours", ["effects"])
479
+ add("keys", cmd_keys, "list key and media names")
480
+ s = add("udev", cmd_udev, "show or install the Linux permission rule")
481
+ s.add_argument("--install", action="store_true", help="write it to /etc/udev/rules.d (sudo)")
482
+
483
+ s = add("light", cmd_light, "set a lighting effect")
484
+ s.add_argument("effect", help="slug (breathing), number (8) or name prefix")
485
+ s.add_argument("-b", "--brightness", type=int, help="1..5")
486
+ s.add_argument("-s", "--speed", type=int, help="1..5")
487
+ s.add_argument("-d", "--direction", help="left-to-right | right-to-left")
488
+ s.add_argument("-c", "--color", help="red green blue yellow magenta cyan white rainbow")
489
+
490
+ s = add("lights", cmd_lights, "turn all LEDs off / back on")
491
+ s.add_argument("action", choices=["on", "off", "toggle"])
492
+
493
+ s = add("custom", cmd_custom, "store per-key colours (custom mode)")
494
+ s.add_argument("keys", nargs="*", metavar="KEY=COLOR", help="e.g. esc=red w=00ff00")
495
+ s.add_argument("-a", "--all", type=parse_color, help="base colour for every key")
496
+ s.add_argument("-b", "--brightness", type=int)
497
+ s.add_argument("--fresh", action="store_true",
498
+ help="start from black instead of your last custom colours")
499
+
500
+ s = add("music", cmd_music, "audio-reactive lighting (needs openajazz[music])")
501
+ s.add_argument("--style", choices=["spectrum", "pulse", "wave"], default="spectrum")
502
+ s.add_argument("--color", help="single colour instead of rainbow")
503
+ s.add_argument("--sensitivity", type=float, default=1.0)
504
+ s.add_argument("--fps", type=int, default=30)
505
+ s.add_argument("--background", action="store_true", help="keep running after the terminal closes")
506
+ s.add_argument("--stop", action="store_true", help="stop a background run")
507
+ s.add_argument("--quiet", action="store_true", help="no live display (for services)")
508
+
509
+ s = add("autostart", cmd_autostart, "start music sync at login (systemd user service)")
510
+ s.add_argument("what", choices=["music", "off", "status"])
511
+ s.add_argument("--style", choices=["spectrum", "pulse", "wave"], default="spectrum")
512
+ s.add_argument("--color", help="single colour instead of rainbow")
513
+ s.add_argument("--sensitivity", type=float, default=1.0)
514
+ s.add_argument("--fps", type=int, default=30)
515
+
516
+ s = add("sleep", cmd_sleep, "auto-sleep timer")
517
+ s.add_argument("minutes", choices=list(SLEEP_OPTIONS))
518
+ s = add("response", cmd_response, "key response time gear (1 fastest)")
519
+ s.add_argument("gear", type=int, choices=[1, 2, 3])
520
+ s = add("fn-switch", cmd_fn_switch, "F-row default layer (experimental)")
521
+ s.add_argument("mode", choices=["normal", "fn"])
522
+
523
+ s = add("remap", cmd_remap, "assign a key or media function to a key")
524
+ s.add_argument("src")
525
+ s.add_argument("dst")
526
+ s.add_argument("--fn", action="store_true", help="Fn layer")
527
+ s = add("restore", cmd_restore, "put one key back to its default")
528
+ s.add_argument("src")
529
+ s.add_argument("--fn", action="store_true", help="Fn layer")
530
+
531
+ s = add("macro", cmd_macro, "store a keyboard macro on a key (options before SRC)")
532
+ s.add_argument("src")
533
+ s.add_argument("events", nargs=argparse.REMAINDER,
534
+ help="'+a' press, '-a' release, 'a' tap, '@ms' delay")
535
+ s.add_argument("--fn", action="store_true", help="Fn layer")
536
+ s.add_argument("-r", "--repeat", type=int, default=1)
537
+ s.add_argument("--delay", type=int, default=10, help="default delay ms")
538
+
539
+ add("reset-keys", cmd_reset_keys, "restore default key assignments")
540
+ s = add("factory-reset", cmd_factory_reset, "restore factory settings")
541
+ s.add_argument("-y", "--yes", action="store_true")
542
+
543
+ s = add("raw", cmd_raw, "send cmd+args after the AA55CC33 header")
544
+ s.add_argument("hex", nargs="+")
545
+ s.add_argument("--reply", action="store_true")
546
+ return p
547
+
548
+
549
+ def main(argv=None) -> int:
550
+ args = build_parser().parse_args(argv)
551
+ try:
552
+ if not args.command:
553
+ cmd_tui(args)
554
+ return 0
555
+ args.func(args)
556
+ except (DeviceError, KeyError, ValueError, OSError) as e:
557
+ if args.json:
558
+ emit_json({"error": str(e)})
559
+ else:
560
+ console.print(f"[bold red]error:[/] {e}")
561
+ return 1
562
+ return 0
563
+
564
+
565
+ if __name__ == "__main__":
566
+ sys.exit(main())