fizzctl 0.2.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.
- fizzctl/__init__.py +10 -0
- fizzctl/animations.py +145 -0
- fizzctl/blobs.py +353 -0
- fizzctl/capture.py +214 -0
- fizzctl/cfg.py +118 -0
- fizzctl/cli.py +409 -0
- fizzctl/effects.py +223 -0
- fizzctl/hid.py +255 -0
- fizzctl/keymap.py +178 -0
- fizzctl/protocol.py +97 -0
- fizzctl/udev_rules.py +89 -0
- fizzctl-0.2.0.dist-info/METADATA +179 -0
- fizzctl-0.2.0.dist-info/RECORD +15 -0
- fizzctl-0.2.0.dist-info/WHEEL +4 -0
- fizzctl-0.2.0.dist-info/entry_points.txt +4 -0
fizzctl/cli.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""fizzctl — command-line interface.
|
|
2
|
+
|
|
3
|
+
User commands (``fizzctl``):
|
|
4
|
+
fizzctl rgb <color> [--brightness N] # whole-board solid color (flash write)
|
|
5
|
+
fizzctl effect <name> # firmware-native effect (FLASH WRITE)
|
|
6
|
+
fizzctl key <key> <color> # paint one key (FLASH WRITE)
|
|
7
|
+
fizzctl paint <key>=<color>... # paint many keys (FLASH WRITE)
|
|
8
|
+
fizzctl animate <name> # host-side animation (volatile stream)
|
|
9
|
+
fizzctl keymap <Cfg.ini> # write full keymap from Cfg.ini (FLASH WRITE)
|
|
10
|
+
fizzctl setup-udev # install 99-k617.rules (needs root)
|
|
11
|
+
|
|
12
|
+
Dev commands (``fizzctl-dev``, reverse-engineering toolkit):
|
|
13
|
+
fizzctl-dev list
|
|
14
|
+
fizzctl-dev cfg <Cfg.ini>
|
|
15
|
+
fizzctl-dev inspect <cap.json>
|
|
16
|
+
fizzctl-dev diff <capA.json> <capB.json>
|
|
17
|
+
fizzctl-dev export <cap.json> <frames.json>
|
|
18
|
+
fizzctl-dev replay <frames.json> [--delay-ms N]
|
|
19
|
+
plus every user command above
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
from .animations import cmd_animate
|
|
27
|
+
from .capture import diff_captures, export_frames, load_frames, load_tshark_json, significant
|
|
28
|
+
from .cfg import CfgIni
|
|
29
|
+
from .hid import K617, NoDeviceError, open_device
|
|
30
|
+
from .keymap import KeymapEncoder
|
|
31
|
+
from .protocol import RESTORE_CONSTANT_FRAMES
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_list(_):
|
|
35
|
+
import hid
|
|
36
|
+
from .protocol import PID, VID
|
|
37
|
+
|
|
38
|
+
for d in hid.enumerate(VID, PID):
|
|
39
|
+
print(d["path"], "iface", d.get("interface_number"), "usage", hex(d.get("usage_page", 0)))
|
|
40
|
+
if not hid.enumerate(VID, PID):
|
|
41
|
+
print("no K617 found (is it plugged in?)")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cmd_cfg(args):
|
|
45
|
+
cfg = CfgIni(args.cfg)
|
|
46
|
+
print(f"[FN] {len(cfg.fn)} entries")
|
|
47
|
+
for idx, be in cfg.fn_entries:
|
|
48
|
+
print(f" K{idx:<3} {', '.join(f'0x{b:02X}' for b in be)}")
|
|
49
|
+
print(f"[KEY] {len(cfg.keys)} entries")
|
|
50
|
+
for idx, e in cfg.key_entries:
|
|
51
|
+
print(f" K{idx:<3} matrix={e.matrix} {', '.join(f'0x{b:02X}' for b in e.behavior)}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_inspect(args):
|
|
55
|
+
recs = load_tshark_json(args.capture)
|
|
56
|
+
out = significant(recs)
|
|
57
|
+
print(f"{len(recs)} USB records, {len(out)} host->device writes")
|
|
58
|
+
for r in out:
|
|
59
|
+
print(" ", r)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_diff(args):
|
|
63
|
+
a = load_tshark_json(args.a)
|
|
64
|
+
b = load_tshark_json(args.b)
|
|
65
|
+
changes = diff_captures(a, b)
|
|
66
|
+
print(f"aligned {len(significant(a))} vs {len(significant(b))} writes; {len(changes)} differ")
|
|
67
|
+
for c in changes:
|
|
68
|
+
print(f"\nframe {c['frame']}: {c['kind_a']} ({c['len_a']}B) -> {c['kind_b']} ({c['len_b']}B), "
|
|
69
|
+
f"{c['diff_count']} bytes differ")
|
|
70
|
+
for off, ca, cb in c["offsets"]:
|
|
71
|
+
print(f" +{off:#06x} {ca:>2} -> {cb:>2}")
|
|
72
|
+
if not changes:
|
|
73
|
+
print("no differences — both captures identical")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_export(args):
|
|
77
|
+
recs = load_tshark_json(args.capture)
|
|
78
|
+
export_frames(recs, args.out)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cmd_replay(args):
|
|
82
|
+
frames = load_frames(args.frames)
|
|
83
|
+
print(f"{len(frames)} frames loaded from {args.frames}")
|
|
84
|
+
for i, f in enumerate(frames):
|
|
85
|
+
from .protocol import frame_kind
|
|
86
|
+
print(f" {i}: {frame_kind(f)} ({len(f)}B)")
|
|
87
|
+
try:
|
|
88
|
+
dev = open_device(debug=args.debug)
|
|
89
|
+
except NoDeviceError:
|
|
90
|
+
return 1
|
|
91
|
+
if dev is None:
|
|
92
|
+
return 1
|
|
93
|
+
try:
|
|
94
|
+
dev.send_sequence(frames, delay_ms=args.delay_ms)
|
|
95
|
+
finally:
|
|
96
|
+
dev.close()
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_keymap(args):
|
|
101
|
+
cfg = CfgIni(args.cfg)
|
|
102
|
+
keymap = KeymapEncoder(cfg).build()
|
|
103
|
+
# exact capture order: INIT, INIT, MODE, CANVAS, ROUTING, KEYMAP, EXEC
|
|
104
|
+
frames = [
|
|
105
|
+
bytes.fromhex("050581000000"), # INIT
|
|
106
|
+
bytes.fromhex("0583b6000000"), # INIT
|
|
107
|
+
RESTORE_CONSTANT_FRAMES[0], # MODE
|
|
108
|
+
RESTORE_CONSTANT_FRAMES[1], # CANVAS
|
|
109
|
+
RESTORE_CONSTANT_FRAMES[2], # ROUTING
|
|
110
|
+
keymap, # 06 04 d4 keymap block
|
|
111
|
+
RESTORE_CONSTANT_FRAMES[3], # EXEC (5AA5 commit)
|
|
112
|
+
]
|
|
113
|
+
if args.debug:
|
|
114
|
+
print(f"built {len(frames)} frames from {args.cfg}")
|
|
115
|
+
print(f" keymap block: {len(keymap)}B, must equal 1032")
|
|
116
|
+
if len(keymap) != 1032:
|
|
117
|
+
print("error: keymap block is not 1032 bytes")
|
|
118
|
+
return 1
|
|
119
|
+
try:
|
|
120
|
+
dev = open_device(debug=args.debug)
|
|
121
|
+
except NoDeviceError:
|
|
122
|
+
return 1
|
|
123
|
+
if dev is None:
|
|
124
|
+
return 1
|
|
125
|
+
try:
|
|
126
|
+
dev.send_sequence(frames, delay_ms=args.delay_ms)
|
|
127
|
+
finally:
|
|
128
|
+
dev.close()
|
|
129
|
+
print(f"wrote keymap from {args.cfg} to flash")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_rgb(args):
|
|
134
|
+
"""Shortcut for `effect fixed-on <color>` (whole-board solid color).
|
|
135
|
+
|
|
136
|
+
Examples:
|
|
137
|
+
fizzctl rgb ff0000
|
|
138
|
+
fizzctl rgb 00ff00 --brightness 4
|
|
139
|
+
"""
|
|
140
|
+
args.name = "fixed-on"
|
|
141
|
+
args.speed = None
|
|
142
|
+
return cmd_effect(args)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_effect(args):
|
|
146
|
+
"""Run a firmware-native effect (flash write).
|
|
147
|
+
|
|
148
|
+
Examples:
|
|
149
|
+
fizzctl effect rainbow # default speed/brightness
|
|
150
|
+
fizzctl effect rainbow --speed 2 --brightness 4
|
|
151
|
+
fizzctl effect waterfall --color ff8800
|
|
152
|
+
fizzctl effect static --brightness 1
|
|
153
|
+
"""
|
|
154
|
+
from .effects import EFFECT_ACCEPTS_COLOR, EFFECT_DEFAULTS, EFFECT_ID, encode_firmware_effect
|
|
155
|
+
from .hid import send_firmware_effect
|
|
156
|
+
|
|
157
|
+
name = args.name
|
|
158
|
+
if name is None:
|
|
159
|
+
print("Firmware effects (22). Run like: fizzctl effect rainbow --speed 2 --brightness 4")
|
|
160
|
+
for n, eid in EFFECT_ID.items():
|
|
161
|
+
defs = EFFECT_DEFAULTS[n]
|
|
162
|
+
color = "yes" if n in EFFECT_ACCEPTS_COLOR else "-"
|
|
163
|
+
print(f" {n:18s} id={eid:#04x} color:{color:3s} default sb={defs[0]}.{defs[1]}")
|
|
164
|
+
return 0
|
|
165
|
+
if name not in EFFECT_ID:
|
|
166
|
+
print(f"unknown effect {name!r}. Available ({', '.join(EFFECT_ID)}):")
|
|
167
|
+
for n, eid in EFFECT_ID.items():
|
|
168
|
+
defs = EFFECT_DEFAULTS[n]
|
|
169
|
+
color = "yes" if n in EFFECT_ACCEPTS_COLOR else "-"
|
|
170
|
+
print(f" {n:18s} id={eid:#04x} color:{color:3s} default sb={defs[0]}.{defs[1]}")
|
|
171
|
+
return 1
|
|
172
|
+
|
|
173
|
+
color = None
|
|
174
|
+
if args.color:
|
|
175
|
+
color = parse_color(args.color)
|
|
176
|
+
if color is None:
|
|
177
|
+
print(f"bad color {args.color!r}: use a name or hex")
|
|
178
|
+
return 1
|
|
179
|
+
|
|
180
|
+
frames = encode_firmware_effect(name, color, speed=args.speed, brightness=args.brightness)
|
|
181
|
+
try:
|
|
182
|
+
dev = open_device(debug=args.debug)
|
|
183
|
+
except NoDeviceError:
|
|
184
|
+
return 1
|
|
185
|
+
if dev is None:
|
|
186
|
+
return 1
|
|
187
|
+
try:
|
|
188
|
+
send_firmware_effect(dev, frames)
|
|
189
|
+
finally:
|
|
190
|
+
dev.close()
|
|
191
|
+
desc = f"effect {name}"
|
|
192
|
+
if color:
|
|
193
|
+
desc += f" (color=#{color[0]:02x}{color[1]:02x}{color[2]:02x})"
|
|
194
|
+
if args.speed is not None or args.brightness is not None:
|
|
195
|
+
desc += f" (speed={args.speed}, brightness={args.brightness})"
|
|
196
|
+
print(f"applied {desc}")
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def cmd_key(args):
|
|
201
|
+
"""Paint a single key via the CANVAS + 5AA5 execute path (flash write),
|
|
202
|
+
persistent across reboots."""
|
|
203
|
+
from .hid import rgb_sequence, send_rgb
|
|
204
|
+
from .protocol import NAME_TO_INDEX
|
|
205
|
+
|
|
206
|
+
color = parse_color(args.color)
|
|
207
|
+
if color is None:
|
|
208
|
+
print(f"bad color {args.color!r}")
|
|
209
|
+
return 1
|
|
210
|
+
if args.key not in NAME_TO_INDEX:
|
|
211
|
+
print(f"unknown key {args.key!r}. Available: {', '.join(sorted(NAME_TO_INDEX))}")
|
|
212
|
+
return 1
|
|
213
|
+
frames = rgb_sequence({args.key: color})
|
|
214
|
+
try:
|
|
215
|
+
dev = open_device(debug=args.debug)
|
|
216
|
+
except NoDeviceError:
|
|
217
|
+
return 1
|
|
218
|
+
if dev is None:
|
|
219
|
+
return 1
|
|
220
|
+
try:
|
|
221
|
+
send_rgb(dev, frames)
|
|
222
|
+
print(f"painted {args.key} -> #{color[0]:02x}{color[1]:02x}{color[2]:02x}")
|
|
223
|
+
finally:
|
|
224
|
+
dev.close()
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_paint(args):
|
|
229
|
+
"""Paint many keys via the CANVAS + 5AA5 execute path (flash write):
|
|
230
|
+
fizzctl paint W=ff0000 A=00ff00 S=0000ff D=ffffff
|
|
231
|
+
"""
|
|
232
|
+
from .hid import rgb_sequence, send_rgb
|
|
233
|
+
from .protocol import NAME_TO_INDEX
|
|
234
|
+
|
|
235
|
+
colors = {}
|
|
236
|
+
for spec in args.specs:
|
|
237
|
+
if "=" not in spec:
|
|
238
|
+
print(f"bad spec {spec!r}: expected KEY=COLOR")
|
|
239
|
+
return 1
|
|
240
|
+
key, c = spec.split("=", 1)
|
|
241
|
+
if key not in NAME_TO_INDEX:
|
|
242
|
+
print(f"unknown key {key!r}")
|
|
243
|
+
return 1
|
|
244
|
+
color = parse_color(c)
|
|
245
|
+
if color is None:
|
|
246
|
+
print(f"bad color {c!r}")
|
|
247
|
+
return 1
|
|
248
|
+
colors[key] = color
|
|
249
|
+
frames = rgb_sequence(colors)
|
|
250
|
+
try:
|
|
251
|
+
dev = open_device(debug=args.debug)
|
|
252
|
+
except NoDeviceError:
|
|
253
|
+
return 1
|
|
254
|
+
if dev is None:
|
|
255
|
+
return 1
|
|
256
|
+
try:
|
|
257
|
+
send_rgb(dev, frames)
|
|
258
|
+
print(f"painted {len(colors)} keys")
|
|
259
|
+
finally:
|
|
260
|
+
dev.close()
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def parse_color(s: str) -> tuple[int, int, int] | None:
|
|
265
|
+
from .effects import parse_color as _pc
|
|
266
|
+
|
|
267
|
+
return _pc(s)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _kind(frame: bytes) -> str:
|
|
271
|
+
from .protocol import frame_kind
|
|
272
|
+
|
|
273
|
+
return frame_kind(frame)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _build_parser(dev: bool) -> argparse.ArgumentParser:
|
|
277
|
+
p = argparse.ArgumentParser(
|
|
278
|
+
prog="fizzctl" if not dev else "fizzctl-dev",
|
|
279
|
+
description="Redragon K617 Fizz controller"
|
|
280
|
+
if not dev
|
|
281
|
+
else "Redragon K617 reverse-engineering toolkit (dev)",
|
|
282
|
+
)
|
|
283
|
+
p.add_argument("--debug", action="store_true", help="verbose frame-level logging")
|
|
284
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
285
|
+
|
|
286
|
+
if dev:
|
|
287
|
+
sub.add_parser("list", help="list K617 HID interfaces")
|
|
288
|
+
pc = sub.add_parser("cfg", help="parse and dump a Cfg.ini")
|
|
289
|
+
pc.add_argument("cfg")
|
|
290
|
+
pi = sub.add_parser("inspect", help="summarise a tshark JSON capture")
|
|
291
|
+
pi.add_argument("capture")
|
|
292
|
+
pd = sub.add_parser("diff", help="diff two tshark JSON captures")
|
|
293
|
+
pd.add_argument("a")
|
|
294
|
+
pd.add_argument("b")
|
|
295
|
+
pe = sub.add_parser("export", help="extract host->device writes to frames.json")
|
|
296
|
+
pe.add_argument("capture")
|
|
297
|
+
pe.add_argument("out")
|
|
298
|
+
pr = sub.add_parser("replay", help="replay exported frames via hidapi")
|
|
299
|
+
pr.add_argument("frames")
|
|
300
|
+
pr.add_argument("--delay-ms", type=int, default=30)
|
|
301
|
+
|
|
302
|
+
px = sub.add_parser("rgb",
|
|
303
|
+
help="solid color: rgb red -b 4 (shortcut for `effect fixed-on`)",
|
|
304
|
+
description="""
|
|
305
|
+
Examples:
|
|
306
|
+
fizzctl rgb red
|
|
307
|
+
fizzctl rgb ff0000 --brightness 4
|
|
308
|
+
fizzctl rgb green -b 3
|
|
309
|
+
""".rstrip(),
|
|
310
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
311
|
+
px.add_argument("color")
|
|
312
|
+
px.add_argument("-b", "--brightness", type=int, help="0..4 (level; higher = brighter)")
|
|
313
|
+
|
|
314
|
+
peff = sub.add_parser("effect",
|
|
315
|
+
help="run a firmware-native effect (flash write); run without a name to list all",
|
|
316
|
+
description="""
|
|
317
|
+
Examples:
|
|
318
|
+
fizzctl effect rainbow # defaults from stock config
|
|
319
|
+
fizzctl effect rainbow --speed 2 --brightness 4
|
|
320
|
+
fizzctl effect waterfall --color cyan
|
|
321
|
+
fizzctl effect # lists all 22 effects
|
|
322
|
+
""".rstrip(),
|
|
323
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
324
|
+
peff.add_argument("name", nargs="?",
|
|
325
|
+
help="effect name (omit or run `effect` alone to list all)")
|
|
326
|
+
peff.add_argument("-c", "--color", help="base color (name or hex) — only for color-capable effects")
|
|
327
|
+
peff.add_argument("-s", "--speed", type=int, help="0..4 (level; higher = faster)")
|
|
328
|
+
peff.add_argument("-b", "--brightness", type=int, help="0..4 (level; higher = brighter)")
|
|
329
|
+
|
|
330
|
+
pk = sub.add_parser("key",
|
|
331
|
+
help="paint one key: key W red (flash write)",
|
|
332
|
+
description="""
|
|
333
|
+
Examples:
|
|
334
|
+
fizzctl key W ff0000
|
|
335
|
+
fizzctl key A yellow
|
|
336
|
+
""".rstrip(),
|
|
337
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
338
|
+
pk.add_argument("key")
|
|
339
|
+
pk.add_argument("color")
|
|
340
|
+
|
|
341
|
+
pp = sub.add_parser("paint",
|
|
342
|
+
help="paint many keys: paint W=ff0000 A=00ff00 (flash write)",
|
|
343
|
+
description="""
|
|
344
|
+
Examples:
|
|
345
|
+
fizzctl paint W=ff0000 A=00ff00 S=ffff00 D=ff00ff
|
|
346
|
+
fizzctl paint W=red A=orange S=yellow D=green
|
|
347
|
+
""".rstrip(),
|
|
348
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
349
|
+
pp.add_argument("specs", nargs="+")
|
|
350
|
+
|
|
351
|
+
pa = sub.add_parser("animate",
|
|
352
|
+
help="host-side per-key animation: animate chase -c red -s 2 (volatile stream)",
|
|
353
|
+
description="""
|
|
354
|
+
Examples:
|
|
355
|
+
fizzctl animate rainbow --fps 30 --duration 10
|
|
356
|
+
fizzctl animate chase --color yellow --speed 2
|
|
357
|
+
fizzctl animate solid --color ff0000
|
|
358
|
+
fizzctl animate # lists all animations
|
|
359
|
+
""".rstrip(),
|
|
360
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
361
|
+
pa.add_argument("name", nargs="?",
|
|
362
|
+
help="animation name (omit or run `animate` alone to list all)")
|
|
363
|
+
pa.add_argument("-c", "--color", default="ff0000", help="base color (name or hex)")
|
|
364
|
+
pa.add_argument("-s", "--speed", type=float, default=1.0, help="animation speed multiplier")
|
|
365
|
+
pa.add_argument("-f", "--fps", type=int, default=30, help="frames per second")
|
|
366
|
+
pa.add_argument("--duration", type=float, help="stop after N seconds (default: until Ctrl+C)")
|
|
367
|
+
|
|
368
|
+
prs = sub.add_parser("keymap",
|
|
369
|
+
help="write the keymap from a Cfg.ini: keymap Cfg.ini (flash write)",
|
|
370
|
+
description="""
|
|
371
|
+
Examples:
|
|
372
|
+
fizzctl keymap captures/cfg-runs/cfg_r2_stock.ini
|
|
373
|
+
""".rstrip(),
|
|
374
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
375
|
+
prs.add_argument("cfg")
|
|
376
|
+
prs.add_argument("--delay-ms", type=int, default=30)
|
|
377
|
+
|
|
378
|
+
psudev = sub.add_parser("setup-udev", help="install 99-k617.rules + reload udev (needs root)")
|
|
379
|
+
|
|
380
|
+
return p
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def main(dev: bool = False) -> int:
|
|
384
|
+
p = _build_parser(dev)
|
|
385
|
+
args = p.parse_args()
|
|
386
|
+
|
|
387
|
+
fn = {
|
|
388
|
+
"list": cmd_list, "cfg": cmd_cfg, "inspect": cmd_inspect,
|
|
389
|
+
"diff": cmd_diff, "export": cmd_export, "replay": cmd_replay,
|
|
390
|
+
"rgb": cmd_rgb, "effect": cmd_effect, "key": cmd_key,
|
|
391
|
+
"paint": cmd_paint, "animate": cmd_animate, "keymap": cmd_keymap,
|
|
392
|
+
"setup-udev": cmd_setup_udev,
|
|
393
|
+
}[args.cmd]
|
|
394
|
+
return fn(args)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def main_dev() -> int:
|
|
398
|
+
"""Dev entry point: full toolkit including capture/RE tools."""
|
|
399
|
+
return main(dev=True)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def cmd_setup_udev(args) -> int:
|
|
403
|
+
from .udev_rules import install_udev_rules
|
|
404
|
+
|
|
405
|
+
return install_udev_rules()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
if __name__ == "__main__":
|
|
409
|
+
sys.exit(main_dev())
|
fizzctl/effects.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Firmware-native effects and the Sinodragon per-key protocol.
|
|
2
|
+
|
|
3
|
+
Two independent protocols:
|
|
4
|
+
|
|
5
|
+
* Firmware effects — a 5-frame burst, all built from the fw-static template
|
|
6
|
+
by patching a few bytes:
|
|
7
|
+
MODE[29..31] = base color (R,G,B)
|
|
8
|
+
EXEC[21] = effect_id (selects rainbow/snake/wheel/...)
|
|
9
|
+
EXEC[39] = packed nibbles (high=speed, low=brightness)
|
|
10
|
+
Sending requires the mandatory GET_REPORT(0x06, 1032) handshake after INIT
|
|
11
|
+
(without it the firmware silently ignores the burst).
|
|
12
|
+
|
|
13
|
+
* Per-key paint — a SINGLE 382-byte feature report ``08 0a 7a 01`` followed by
|
|
14
|
+
96 RGB triplets in a 16-col x 6-row column-major raster (pos = col*6+row).
|
|
15
|
+
No handshake, no flash commit, host-side (volatile) — bytes are re-applied
|
|
16
|
+
every frame for animations. Verified on hardware: Esc=1, Menu=77, RCtrl=83
|
|
17
|
+
(an earlier mapping had Esc=0/Menu=65/RCtrl=71, which lands on dead
|
|
18
|
+
positions).
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Firmware effects
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
# Baseline is the captured fw-static template (init + mode + canvas + routing
|
|
27
|
+
# + exec). Every other effect only differs in the 3-5 bytes listed below.
|
|
28
|
+
# Indexes into base_frames(): 0=INIT, 1=MODE, 2=CANVAS, 3=ROUTING, 4=EXEC.
|
|
29
|
+
from .blobs import RGB_EXEC, RGB_INIT, RGB_SEC
|
|
30
|
+
from .protocol import base_frames
|
|
31
|
+
|
|
32
|
+
# Official Redragon software effect menu (order from K617 software).
|
|
33
|
+
# The effect id byte at EXEC[21] equals the 1-indexed position in that menu:
|
|
34
|
+
# 8 independently-captured effects all land exactly
|
|
35
|
+
# on their menu position (Fixed_on=0x01, Rainbow=0x03, ... Blossom=0x11), so
|
|
36
|
+
# the un-captured ids are inferred by position and marked ``pending-live-verify``.
|
|
37
|
+
# ``name`` is the canonical slug; ``aliases`` keep old short names working.
|
|
38
|
+
EFFECTS = [
|
|
39
|
+
# (name, aliases, id, accepts_color, default_sb, notes)
|
|
40
|
+
("fixed-on", ("static",), 0x01, True, 0x33, ""),
|
|
41
|
+
("respire", (), 0x02, True, 0x33, ""),
|
|
42
|
+
("rainbow", (), 0x03, True, 0x33, ""),
|
|
43
|
+
("flash-away", (), 0x04, True, 0x33, ""),
|
|
44
|
+
("raindrops", (), 0x05, True, 0x33, ""),
|
|
45
|
+
("rainbow-wheel", ("wheel",), 0x06, True, 0x33, ""),
|
|
46
|
+
("ripples-shining", (), 0x07, True, 0x33, ""),
|
|
47
|
+
("stars-twinkle", ("star-twinkle",), 0x08, True, 0x33, ""),
|
|
48
|
+
("shadow-disappear", (), 0x09, True, 0x33, ""),
|
|
49
|
+
("retro-snake", ("snake",), 0x0a, True, 0x44, ""),
|
|
50
|
+
("neon-stream", (), 0x0b, True, 0x44, ""),
|
|
51
|
+
("reaction", (), 0x0c, True, 0x44, ""),
|
|
52
|
+
("sine-wave", (), 0x0d, True, 0x44, ""),
|
|
53
|
+
("retinue-scanning", (), 0x0e, True, 0x44, ""),
|
|
54
|
+
("rotating-windmill", (), 0x0f, True, 0x33, ""),
|
|
55
|
+
("colorful-waterfall", ("waterfall",), 0x10, True, 0x33, ""),
|
|
56
|
+
("blossoming", ("rainbow-blossom",), 0x11, True, 0x44, ""),
|
|
57
|
+
("rotating-storm", (), 0x12, True, 0x33, ""),
|
|
58
|
+
("collision", (), 0x13, True, 0x33, ""),
|
|
59
|
+
("perfect", (), 0x14, True, 0x33, ""),
|
|
60
|
+
("self-define", (), 0x15, True, 0x33, ""),
|
|
61
|
+
("off", ("off",), 0x16, True, 0x00, ""),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
EFFECT_ID: dict[str, int] = {n: eid for n, _, eid, *_ in EFFECTS}
|
|
65
|
+
_ALIASES: dict[str, str] = {a: n for n, as_, *_ in EFFECTS for a in as_}
|
|
66
|
+
EFFECT_ACCEPTS_COLOR = {n for n, _, _, ac, *_ in EFFECTS if ac}
|
|
67
|
+
EFFECT_DEFAULTS = {n: (sb >> 4, sb & 0x0F) for n, _, _, _, sb, *_ in EFFECTS}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _canonical(name: str) -> str:
|
|
71
|
+
if name in EFFECT_ID:
|
|
72
|
+
return name
|
|
73
|
+
canon = _ALIASES.get(name)
|
|
74
|
+
if canon is None:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"unknown effect {name!r}; choose from {', '.join(effect_names())}"
|
|
77
|
+
)
|
|
78
|
+
return canon
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def effect_names() -> list[str]:
|
|
82
|
+
return list(EFFECT_ID)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def encode_firmware_effect(
|
|
86
|
+
name: str,
|
|
87
|
+
color: tuple[int, int, int] | None = None,
|
|
88
|
+
speed: int | None = None,
|
|
89
|
+
brightness: int | None = None,
|
|
90
|
+
) -> list[bytes]:
|
|
91
|
+
"""Encode the 5-frame burst (INIT, MODE, CANVAS, ROUTING, EXEC) for an
|
|
92
|
+
effect. Patches MODE[29..31] (color), EXEC[21] (effect_id) and
|
|
93
|
+
EXEC[69]/[71] (speed|brightness nibbles) onto the fw-static baseline.
|
|
94
|
+
|
|
95
|
+
speed/brightness are 0..4 levels (clamped to a nibble); Python ints get
|
|
96
|
+
clamped. A color is only applied when the effect accepts one.
|
|
97
|
+
"""
|
|
98
|
+
name = _canonical(name)
|
|
99
|
+
eid = EFFECT_ID[name]
|
|
100
|
+
defaults = EFFECT_DEFAULTS[name]
|
|
101
|
+
|
|
102
|
+
frames = [bytearray(f) for f in base_frames()]
|
|
103
|
+
mode, canvas, routing, exec_ = frames[1], frames[2], frames[3], frames[4]
|
|
104
|
+
|
|
105
|
+
if name in EFFECT_ACCEPTS_COLOR:
|
|
106
|
+
r, g, b = color if color is not None else (255, 0, 0)
|
|
107
|
+
mode[29], mode[30], mode[31] = r & 0xFF, g & 0xFF, b & 0xFF
|
|
108
|
+
|
|
109
|
+
exec_[21] = eid
|
|
110
|
+
|
|
111
|
+
# Byte 39 is the active speed×brightness slot (high nibble = speed,
|
|
112
|
+
# low nibble = brightness). The firmware only exposes ~5 levels per axis:
|
|
113
|
+
# defaults in stock captures are 0x33/0x44 (speed 3/4, brightness 3/4)
|
|
114
|
+
# and `off` uses 0x00, so a nibble of 4 is max and values above 4 clamp.
|
|
115
|
+
# Verified by diffing USB captures — previously bytes 69/71 were patched
|
|
116
|
+
# (a different byte layout); those are ignored by this firmware.
|
|
117
|
+
target_speed = speed if speed is not None else defaults[0]
|
|
118
|
+
target_bright = brightness if brightness is not None else defaults[1]
|
|
119
|
+
new_speed = max(0, min(4, round(target_speed)))
|
|
120
|
+
new_bright = max(0, min(4, round(target_bright)))
|
|
121
|
+
packed = ((new_speed & 0x0F) << 4) | (new_bright & 0x0F)
|
|
122
|
+
exec_[39] = packed
|
|
123
|
+
# Mirror into the effect's own table slot (each entry is 2 bytes wide
|
|
124
|
+
# starting at byte 39; slot[eid] lives at 39 + eid*2).
|
|
125
|
+
if 1 <= eid <= 19:
|
|
126
|
+
exec_[39 + eid * 2] = packed
|
|
127
|
+
|
|
128
|
+
return [bytes(frames[0])] + [bytes(f) for f in frames[1:]]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Per-key (Sinodragon) protocol — 382-byte single report
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
PERKEY_HEADER = bytes.fromhex("080a7a01")
|
|
136
|
+
PERKEY_PACKET_LEN = 382
|
|
137
|
+
SINODRAGON_LED_COUNT = 96
|
|
138
|
+
|
|
139
|
+
# K617 key name -> position in the 16x6 column-major raster (pos = col*6+row).
|
|
140
|
+
# Rows 1..4 in the 6-row raster hold rows 0..4 of the keyboard; raster row 0
|
|
141
|
+
# is "phantom"/unused except Esc which lives at col0/row1 (=1), NOT 0.
|
|
142
|
+
# All verified on hardware except where noted.
|
|
143
|
+
PER_KEY_POS = {
|
|
144
|
+
# Row 0 — number row
|
|
145
|
+
"Esc": 1, "1": 7, "2": 13, "3": 19, "4": 25,
|
|
146
|
+
"5": 31, "6": 37, "7": 43, "8": 49, "9": 55,
|
|
147
|
+
"0": 61, "-": 67, "=": 73, "Bksp": 79,
|
|
148
|
+
# Row 1 — QWERTY
|
|
149
|
+
"Tab": 2,
|
|
150
|
+
"Q": 8, "W": 14, "E": 20, "R": 26, "T": 32, "Y": 38,
|
|
151
|
+
"U": 44, "I": 50, "O": 56, "P": 62, "[": 68, "]": 74, "\\": 80,
|
|
152
|
+
# Row 2 — home row
|
|
153
|
+
"CapsLk": 3,
|
|
154
|
+
"A": 9, "S": 15, "D": 21, "F": 27, "G": 33, "H": 39,
|
|
155
|
+
"J": 45, "K": 51, "L": 57, ";": 63, "'": 69, "Enter": 81,
|
|
156
|
+
# Row 3 — bottom row
|
|
157
|
+
"LShift": 4,
|
|
158
|
+
"Z": 10, "X": 16, "C": 22, "V": 28, "B": 34, "N": 40,
|
|
159
|
+
"M": 46, ",": 52, ".": 58, "/": 64, "RShift": 82,
|
|
160
|
+
# Row 4 — modifier row
|
|
161
|
+
"LCtrl": 5, "LWin": 11, "LAlt": 17, "Space": 35, "RAlt": 53,
|
|
162
|
+
"Fn": 59, "Menu": 77, "RCtrl": 83,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def encode_per_key_frame(colors: dict[str, tuple[int, int, int]] | None = None) -> bytes:
|
|
167
|
+
"""Build the single 382-byte per-key report. `colors` maps key name ->
|
|
168
|
+
(r,g,b); keys not listed light as off. `None` = all keys black."""
|
|
169
|
+
frame = bytearray(PERKEY_PACKET_LEN)
|
|
170
|
+
frame[0:4] = PERKEY_HEADER
|
|
171
|
+
for key, (r, g, b) in (colors or {}).items():
|
|
172
|
+
pos = PER_KEY_POS.get(key)
|
|
173
|
+
if pos is None:
|
|
174
|
+
raise KeyError(f"unknown key {key!r} for per-key paint")
|
|
175
|
+
off = 4 + pos * 3
|
|
176
|
+
frame[off], frame[off + 1], frame[off + 2] = r & 0xFF, g & 0xFF, b & 0xFF
|
|
177
|
+
return bytes(frame)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def encode_per_key_solid(color: tuple[int, int, int]) -> bytes:
|
|
181
|
+
return encode_per_key_frame({key: color for key in PER_KEY_POS})
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def parse_color(s: str) -> tuple[int, int, int] | None:
|
|
185
|
+
"""Parse a color name or hex string into (r, g, b)."""
|
|
186
|
+
named = {
|
|
187
|
+
# primary
|
|
188
|
+
"red": (255, 0, 0), "green": (0, 255, 0), "blue": (0, 0, 255),
|
|
189
|
+
# secondary
|
|
190
|
+
"yellow": (255, 255, 0), "cyan": (0, 255, 255), "magenta": (255, 0, 255),
|
|
191
|
+
"purple": (128, 0, 255), "orange": (255, 165, 0), "pink": (255, 105, 180),
|
|
192
|
+
"lime": (191, 255, 0), "teal": (0, 128, 128), "violet": (238, 130, 238),
|
|
193
|
+
"brown": (165, 42, 42), "gold": (255, 215, 0), "silver": (192, 192, 192),
|
|
194
|
+
"gray": (128, 128, 128), "grey": (128, 128, 128),
|
|
195
|
+
"navy": (0, 0, 128), "maroon": (128, 0, 0), "olive": (128, 128, 0),
|
|
196
|
+
"coral": (255, 127, 80), "indigo": (75, 0, 130), "salmon": (250, 128, 114),
|
|
197
|
+
# light / shades
|
|
198
|
+
"lightred": (255, 102, 102), "lightgreen": (144, 238, 144),
|
|
199
|
+
"lightblue": (173, 216, 230),
|
|
200
|
+
"darkred": (139, 0, 0), "darkgreen": (0, 100, 0), "darkblue": (0, 0, 139),
|
|
201
|
+
# neutral
|
|
202
|
+
"white": (255, 255, 255), "off": (0, 0, 0), "black": (0, 0, 0),
|
|
203
|
+
}
|
|
204
|
+
s = s.strip().lstrip("#")
|
|
205
|
+
key = s.lower().replace("_", "").replace("-", "").replace(" ", "")
|
|
206
|
+
if key in named:
|
|
207
|
+
return named[key]
|
|
208
|
+
if len(s) == 6:
|
|
209
|
+
try:
|
|
210
|
+
return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16))
|
|
211
|
+
except ValueError:
|
|
212
|
+
return None
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# Re-export the RGB canvas pieces used by the "static canvas" path so callers
|
|
217
|
+
# only need one import site.
|
|
218
|
+
__all__ = [
|
|
219
|
+
"EFFECTS", "EFFECT_ID", "EFFECT_ACCEPTS_COLOR", "EFFECT_DEFAULTS",
|
|
220
|
+
"effect_names", "encode_firmware_effect", "PERKEY_HEADER",
|
|
221
|
+
"PERKEY_PACKET_LEN", "PER_KEY_POS",
|
|
222
|
+
"encode_per_key_frame", "encode_per_key_solid",
|
|
223
|
+
]
|