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 +3 -0
- openajazz/__main__.py +5 -0
- openajazz/cli.py +566 -0
- openajazz/control.py +118 -0
- openajazz/effects.py +160 -0
- openajazz/keys.py +222 -0
- openajazz/music.py +194 -0
- openajazz/patterns.py +76 -0
- openajazz/protocol.py +313 -0
- openajazz/service.py +140 -0
- openajazz/state.py +93 -0
- openajazz/tui.py +1162 -0
- openajazz-0.4.0.dist-info/METADATA +99 -0
- openajazz-0.4.0.dist-info/RECORD +18 -0
- openajazz-0.4.0.dist-info/WHEEL +5 -0
- openajazz-0.4.0.dist-info/entry_points.txt +2 -0
- openajazz-0.4.0.dist-info/licenses/LICENSE +21 -0
- openajazz-0.4.0.dist-info/top_level.txt +1 -0
openajazz/__init__.py
ADDED
openajazz/__main__.py
ADDED
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())
|