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/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
+ ]