fizzctl 0.2.0__tar.gz → 0.2.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: fizzctl
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Control a Redragon K617 Fizz keyboard on Linux: solid colors, 22 firmware-native RGB effects, per-key painting, live animations, and Cfg.ini keymap restore — no vendor software required
5
5
  Keywords: redragon,k617,fizz,rgb,keyboard,backlight,led,hid,linux
6
6
  Author: Ayoub Dya
@@ -14,7 +14,6 @@ Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Programming Language :: Python :: 3.14
15
15
  Classifier: Topic :: System :: Hardware :: Hardware Drivers
16
16
  Classifier: Topic :: System :: Hardware
17
- Requires-Dist: dpkt>=1.9.8
18
17
  Requires-Dist: hidapi>=0.15.0
19
18
  Requires-Python: >=3.14
20
19
  Description-Content-Type: text/markdown
@@ -91,9 +90,9 @@ fizzctl rgb 00ff00 --brightness 4 # or: rgb 00ff00 -b 4
91
90
  ### Firmware effects
92
91
 
93
92
  All 22 effects from the official Redragon software are supported, with full
94
- speed and brightness control. `speed` and `brightness` are 5 levels (0-4,
95
- matching the firmware); higher = faster / brighter. Run `fizzctl effect`
96
- with no name to list every effect:
93
+ speed and brightness control. `speed` is 1..5 and `brightness` is 0..4 (5
94
+ levels each, matching the firmware); higher = faster / brighter. Run
95
+ `fizzctl effect` with no name to list every effect:
97
96
 
98
97
  ```bash
99
98
  fizzctl effect
@@ -70,9 +70,9 @@ fizzctl rgb 00ff00 --brightness 4 # or: rgb 00ff00 -b 4
70
70
  ### Firmware effects
71
71
 
72
72
  All 22 effects from the official Redragon software are supported, with full
73
- speed and brightness control. `speed` and `brightness` are 5 levels (0-4,
74
- matching the firmware); higher = faster / brighter. Run `fizzctl effect`
75
- with no name to list every effect:
73
+ speed and brightness control. `speed` is 1..5 and `brightness` is 0..4 (5
74
+ levels each, matching the firmware); higher = faster / brighter. Run
75
+ `fizzctl effect` with no name to list every effect:
76
76
 
77
77
  ```bash
78
78
  fizzctl effect
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fizzctl"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Control a Redragon K617 Fizz keyboard on Linux: solid colors, 22 firmware-native RGB effects, per-key painting, live animations, and Cfg.ini keymap restore — no vendor software required"
5
5
  readme = "README.md"
6
6
  keywords = [
@@ -25,10 +25,7 @@ classifiers = [
25
25
  "Topic :: System :: Hardware",
26
26
  ]
27
27
  requires-python = ">=3.14"
28
- dependencies = [
29
- "dpkt>=1.9.8",
30
- "hidapi>=0.15.0",
31
- ]
28
+ dependencies = ["hidapi>=0.15.0"]
32
29
 
33
30
  [project.license]
34
31
  text = "MIT"
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "fizzctl"
3
+ version = "0.2.1"
4
+ description = "Control a Redragon K617 Fizz keyboard on Linux: solid colors, 22 firmware-native RGB effects, per-key painting, live animations, and Cfg.ini keymap restore — no vendor software required"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [{ name = "Ayoub Dya", email = "ayoubdya@gmail.com" }]
8
+ keywords = [
9
+ "redragon",
10
+ "k617",
11
+ "fizz",
12
+ "rgb",
13
+ "keyboard",
14
+ "backlight",
15
+ "led",
16
+ "hid",
17
+ "linux",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Environment :: Console",
22
+ "Intended Audience :: End Users/Desktop",
23
+ "Operating System :: POSIX :: Linux",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: System :: Hardware :: Hardware Drivers",
27
+ "Topic :: System :: Hardware",
28
+ ]
29
+ requires-python = ">=3.14"
30
+ dependencies = ["hidapi>=0.15.0"]
31
+
32
+ [project.scripts]
33
+ fizzctl = "fizzctl:main"
34
+ fizzctl-dev = "fizzctl:main_dev"
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.12.13,<0.13.0"]
38
+ build-backend = "uv_build"
39
+
40
+ [tool.uv]
41
+ package = true
@@ -1,11 +1,22 @@
1
1
  """Baked firmware bytes, inlined as hex so the package ships no binary files.
2
2
 
3
3
  All constants come from USB captures of the OEM software:
4
- * CONST_MODE / CONST_CANVAS / CONST_ROUTING / CONST_EXEC — the
4
+ * CONST_MODE / CONST_CANVAS / CONST_ROUTING / CONST_EXEC — the
5
5
  four constant 1032-byte frames of the Restore sequence.
6
- * FW_FRAMES — the 5-frame static-effect template used for RGB writes.
6
+ * FW_TEMPLATE — the 5-frame effect template: shared MODE and ROUTING,
7
+ a distinct CANVAS, and five byte overrides of CONST_EXEC.
8
+ * RGB_EXEC / RGB_SEC — the whole-board solid-color variant.
7
9
  """
8
10
 
11
+
12
+ def _patch(base: bytes, diffs: dict[int, int]) -> bytes:
13
+ """Copy `base`, overriding a handful of byte offsets."""
14
+ b = bytearray(base)
15
+ for off, val in diffs.items():
16
+ b[off] = val
17
+ return bytes(b)
18
+
19
+
9
20
  CONST_MODE = bytes.fromhex(
10
21
  "0608b80040000000000000000000000000000000000000000000000000ff0000"
11
22
  "0000ff00ff00ffff00ff00ff00ffffffffffff00000000ff00ff00ffff00ff00"
@@ -150,49 +161,9 @@ CONST_EXEC = bytes.fromhex(
150
161
  "0000000000000000"
151
162
  )
152
163
 
153
- FW_FRAMES = [
154
- # frame 0: 6B
155
- bytes.fromhex(
156
- "0583b6000000"
157
- ),
158
- # frame 1: 1032B
159
- bytes.fromhex(
160
- "0608b80040000000000000000000000000000000000000000000000000ff0000"
161
- "0000ff00ff00ffff00ff00ff00ffffffffffff00000000ff00ff00ffff00ff00"
162
- "ff00ffffffffffff00000000ff00ff00ffff00ff00ff00ffffffffffff000000"
163
- "00ff00ff00ffff00ff00ff00ffffffffffff00000000ff00ff00ffff00ff00ff"
164
- "00ffffffffffff00000000ff00ff00ffff00ff00ff00ffffffffffff00000000"
165
- "ff00ff00ffff00ff00ff00ffffffffffff00000000ff00ff00ffff00ff00ff00"
166
- "ffffffffffff00000000ff00ff00ffff00ff00ff00ffffffffffff00000000ff"
167
- "00ff00ffff00ff00ff00ffffffffffff00000000ff00ff00ffff00ff00ff00ff"
168
- "ffffffffff00000000ff00ff00ffff00ff00ff00ffffffffffff00000000ff00"
169
- "ff00ffff00ff00ff00ffffffffffff00000000ff00ff00ffff00ff00ff00ffff"
170
- "ffffffff00000000ff00ff00ffff00ff00ff00ffffffffffff00000000ff00ff"
171
- "00ffff00ff00ff00ffffffffffff00000000ff00ff00ffff00ff00ff00ffffff"
172
- "ffffff00000000ff00ff00ffff00ff00ff00ffffffffffff00000000ff00ff00"
173
- "ffff00ff00ff00ffffffffffff00000000ff00ff00ffff00ff00ff00ffffffff"
174
- "ffff00000000ff00ff00ffff00ff00ff00ffffffffffff00000000ff00ff00ff"
175
- "ff00ff00ff00ffffffffff000000000000000000000000000000000000000000"
176
- "0000000000000000000000000000000000000000000000000000000000000000"
177
- "0000000000000000000000000000000000000000000000000000000000000000"
178
- "0000000000000000000000000000000000000000000000000000000000000000"
179
- "0000000000000000000000000000000000000000000000000000000000000000"
180
- "0000000000000000000000000000000000000000000000000000000000000000"
181
- "0000000000000000000000000000000000000000000000000000000000000000"
182
- "0000000000000000000000000000000000000000000000000000000000000000"
183
- "0000000000000000000000000000000000000000000000000000000000000000"
184
- "0000000000000000000000000000000000000000000000000000000000000000"
185
- "0000000000000000000000000000000000000000000000000000000000000000"
186
- "0000000000000000000000000000000000000000000000000000000000000000"
187
- "0000000000000000000000000000000000000000000000000000000000000000"
188
- "0000000000000000000000000000000000000000000000000000000000000000"
189
- "0000000000000000000000000000000000000000000000000000000000000000"
190
- "0000000000000000000000000000000000000000000000000000000000000000"
191
- "0000000000000000000000000000000000000000000000000000000000000000"
192
- "0000000000000000"
193
- ),
194
- # frame 2: 1032B
195
- bytes.fromhex(
164
+ INIT = bytes.fromhex("0583b6000000")
165
+
166
+ FW_CANVAS = bytes.fromhex(
196
167
  "0609bc0040000000000000000000000000000000000000000000000000000000"
197
168
  "00000000000000000000000000000000000000ff00ff00ff0000000000000000"
198
169
  "0000000000000000ffffff00ff0000000000000000000000000000000000ff00"
@@ -226,89 +197,18 @@ FW_FRAMES = [
226
197
  "0000000000000000000000000000000000000000000000000000000000000000"
227
198
  "0000000000000000000000000000000000000000000000000000000000000000"
228
199
  "0000000000000000"
229
- ),
230
- # frame 3: 1032B
231
- bytes.fromhex(
232
- "0609c00040000000000000000000ffffffffffffff0000000000000000000000"
233
- "000000ffffffffff00000000000000000000000000000000ffffffffff000000"
234
- "000000000000000000000000ff000000ffff0000000000000000000000000000"
235
- "00ff000000000000000000000000000000000000000000000000000000000000"
236
- "0000000000000000000000000000000000000000000000000000000000000000"
237
- "0000000000000000000000000000000000000000000000000000000000000000"
238
- "0000000000000000000000000000000000000000000000000000000000000000"
239
- "0000000000000000000000000000000000000000000000000000000000000000"
240
- "0000000000000000000000000000000000000000000000000000000000000000"
241
- "0000000000000000000000000000000000000000000000000000000000000000"
242
- "0000000000000000000000000000000000000000000000000000000000000000"
243
- "0000000000000000000000000000000000000000000000000000000000000000"
244
- "0000000000000000ffffffffffffff0000000000000000000000000000ffffff"
245
- "ff0000000000000000000000000000000000ffffffff00000000000000000000"
246
- "0000000000000000000000000000000000000000000000000000000000000000"
247
- "0000000000000000000000000000000000000000000000000000000000000000"
248
- "0000000000000000000000000000000000000000000000000000000000000000"
249
- "0000000000000000000000000000000000000000000000000000000000000000"
250
- "0000000000000000000000000000000000000000000000000000000000000000"
251
- "0000000000000000000000000000000000000000000000000000000000000000"
252
- "0000000000000000000000000000000000000000000000000000000000000000"
253
- "0000000000000000000000000000000000000000000000000000000000000000"
254
- "0000000000000000000000000000000000000000000000000000000000000000"
255
- "0000000000000000000000000000000000000000000000000000000000000000"
256
- "000000000000000000000000000000000000000000000000ff00ff0000000000"
257
- "000000000000000000000000ffffff0000000000000000000000000000000000"
258
- "ff0000000000000000000000000000000000000000ff00ff0000000000000000"
259
- "0000000000000000000000000000000000000000000000000000000000000000"
260
- "0000000000000000000000000000000000000000000000000000000000000000"
261
- "0000000000000000000000000000000000000000000000000000000000000000"
262
- "0000000000000000000000000000000000000000000000000000000000000000"
263
- "0000000000000000000000000000000000000000000000000000000000000000"
264
- "0000000000000000"
265
- ),
266
- # frame 4: 1032B
267
- bytes.fromhex(
268
- "0603b600000000000000000000005aa503030000000120010000000055550100"
269
- "00000000ffff0034073307330733073307330733073307330733073307330733"
270
- "07330733073307330733073307335aa500100744074407440744074407440744"
271
- "0404040404040404040400000000000000000000000000000000000000000000"
272
- "0000000000000000005aa5030300000000000000000000000000000000000000"
273
- "0000000000000000000000000000000000000000000000000000000000000000"
274
- "0000000000000000000000000000000000000000000000000000000000000000"
275
- "0000000000000000000000000000000000000000000000000000000000000000"
276
- "0000000000000000000000000000000000000000000000000000000000000000"
277
- "0000000000000000000000000000000000000000000000000000000000000000"
278
- "0000000000000000000000000000000000000000000000000000000000000000"
279
- "0000000000000000000000000000000000000000000000000000000000000000"
280
- "0000000000000000000000000000000000000000000000000000000000000000"
281
- "0000000000000000000000000000000000000000000000000000000000000000"
282
- "0000000000000000000000000000000000000000000000000000000000000000"
283
- "0000000000000000000000000000000000000000000000000000000000000000"
284
- "0000000000000000000000000000000000000000000000000000000000000000"
285
- "0000000000000000000000000000000000000000000000000000000000000000"
286
- "0000000000000000000000000000000000000000000000000000000000000000"
287
- "0000000000000000000000000000000000000000000000000000000000000000"
288
- "0000000000000000000000000000000000000000000000000000000000000000"
289
- "0000000000000000000000000000000000000000000000000000000000000000"
290
- "0000000000000000000000000000000000000000000000000000000000000000"
291
- "0000000000000000000000000000000000000000000000000000000000000000"
292
- "0000000000000000000000000000000000000000000000000000000000000000"
293
- "0000000000000000000000000000000000000000000000000000000000000000"
294
- "0000000000000000000000000000000000000000000000000000000000000000"
295
- "0000000000000000000000000000000000000000000000000000000000000000"
296
- "0000000000000000000000000000000000000000000000000000000000000000"
297
- "0000000000000000000000000000000000000000000000000000000000000000"
298
- "0000000000000000000000000000000000000000000000000000000000000000"
299
- "0000000000000000000000000000000000000000000000000000000000000000"
300
- "0000000000000000"
301
- ),
302
- ]
200
+ )
303
201
 
202
+ EXEC_DIFFS = {18: 0, 21: 1, 38: 0, 39: 52, 141: 0}
304
203
 
204
+ FW_TEMPLATE = [
205
+ INIT,
206
+ CONST_MODE,
207
+ FW_CANVAS,
208
+ CONST_ROUTING,
209
+ _patch(CONST_EXEC, EXEC_DIFFS),
210
+ ]
305
211
 
306
- # --- RGB static-color protocol ---
307
- RGB_INIT = bytes.fromhex("0583b6000000")
308
- # P1 canvas is BUILT (header + split-plane colors + SEC at 660), not taken from
309
- # a template. P2 routing is the same 06 09 c0 block used by the Restore
310
- # sequence (matches RESTORE_CONSTANT_FRAMES[2] exactly). EXEC must be the
311
- # static-color variant: effect_id=0x01, brightness=0x15.
312
212
  RGB_SEC = bytes.fromhex(
313
213
  "ffffffffff000000000000000000000000000000ffffffffff00000000000000"
314
214
  "00000000000000000000ffffff00ff000000000000000000000000000000ff00"
@@ -1,8 +1,7 @@
1
1
  """USB capture import/diff/export for the K617 OEM software.
2
2
 
3
- Inputs accepted:
3
+ Input accepted:
4
4
  * Wireshark JSON : `tshark -r cap.pcapng -T json > cap.json` (recommended)
5
- * raw .pcap : Linux usbmon capture, parsed with dpkt
6
5
 
7
6
  The job:
8
7
  1. pull every HID feature-report payload out of the USB stream,
@@ -147,24 +146,6 @@ def _parse_usb_layer(u: dict, setup: dict | None = None) -> FrameCapture | None:
147
146
  )
148
147
 
149
148
 
150
- def load_pcap_usbmon(path: str) -> list[FrameCapture]:
151
- """Minimal Linux usbmon .pcap reader (dpkt)."""
152
- import dpkt
153
-
154
- out: list[FrameCapture] = []
155
- with open(path, "rb") as fh:
156
- pcap = dpkt.pcap.Reader(fh)
157
- for _, buf in pcap:
158
- try:
159
- u = dpkt.usbmon.LinuxUSB(buf)
160
- except Exception:
161
- continue
162
- data = bytes(u.transfer_buffer) if u.xfer_type & 0x80 else None
163
- direction = "IN" if u.urb_type == 0x55 else "OUT"
164
- out.append(FrameCapture(direction=direction, transfer="urb", request=None, report_id=None, data=data or b""))
165
- return out
166
-
167
-
168
149
  def significant(records: list[FrameCapture], direction: str = "OUT") -> list[FrameCapture]:
169
150
  """Keep only payload-bearing writes from the host (the interesting direction)."""
170
151
  return [r for r in records if r.direction == direction and len(r.data) > 0]
@@ -104,15 +104,3 @@ class CfgIni:
104
104
  @property
105
105
  def key_entries(self) -> list[tuple[int, KeyEntry]]:
106
106
  return sorted(self.keys.items())
107
-
108
-
109
- if __name__ == "__main__":
110
- import sys
111
- cfg = CfgIni(sys.argv[1] if len(sys.argv) > 1 else "Cfg.ini")
112
- print(f"OPT: {len(cfg.opt)} keys")
113
- print(f"[FN] {len(cfg.fn)} mappings")
114
- for idx, be in cfg.fn_entries:
115
- print(f" K{idx:<3} = {', '.join(f'0x{b:02X}' for b in be)}")
116
- print(f"[KEY] {len(cfg.keys)} keys")
117
- for idx, e in cfg.key_entries:
118
- print(f" K{idx:<3} = {e.matrix} {', '.join(f'0x{b:02X}' for b in e.behavior)}")
@@ -26,7 +26,7 @@ import sys
26
26
  from .animations import cmd_animate
27
27
  from .capture import diff_captures, export_frames, load_frames, load_tshark_json, significant
28
28
  from .cfg import CfgIni
29
- from .hid import K617, NoDeviceError, open_device
29
+ from .hid import NoDeviceError, open_device, send_burst
30
30
  from .keymap import KeymapEncoder
31
31
  from .protocol import RESTORE_CONSTANT_FRAMES
32
32
 
@@ -91,7 +91,7 @@ def cmd_replay(args):
91
91
  if dev is None:
92
92
  return 1
93
93
  try:
94
- dev.send_sequence(frames, delay_ms=args.delay_ms)
94
+ send_burst(dev, frames, handshake=False, delay_ms=args.delay_ms)
95
95
  finally:
96
96
  dev.close()
97
97
  return 0
@@ -123,7 +123,7 @@ def cmd_keymap(args):
123
123
  if dev is None:
124
124
  return 1
125
125
  try:
126
- dev.send_sequence(frames, delay_ms=args.delay_ms)
126
+ send_burst(dev, frames, handshake=False, delay_ms=args.delay_ms)
127
127
  finally:
128
128
  dev.close()
129
129
  print(f"wrote keymap from {args.cfg} to flash")
@@ -151,24 +151,22 @@ def cmd_effect(args):
151
151
  fizzctl effect waterfall --color ff8800
152
152
  fizzctl effect static --brightness 1
153
153
  """
154
- from .effects import EFFECT_ACCEPTS_COLOR, EFFECT_DEFAULTS, EFFECT_ID, encode_firmware_effect
155
- from .hid import send_firmware_effect
154
+ from .effects import (
155
+ EFFECT_ACCEPTS_COLOR, EFFECT_DEFAULTS, EFFECT_ID,
156
+ _ALIASES, encode_firmware_effect, parse_color,
157
+ )
156
158
 
157
- name = args.name
158
- if name is None:
159
+ name = _ALIASES.get(args.name, args.name)
160
+ if name is None or name not in EFFECT_ID:
161
+ if name is not None:
162
+ print(f"unknown effect {name!r}")
159
163
  print("Firmware effects (22). Run like: fizzctl effect rainbow --speed 2 --brightness 4")
160
164
  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]
165
+ spd, bri = EFFECT_DEFAULTS[n]
166
+ spd = spd + 1 if spd else 0 # stored 0-based, displayed 1..5
169
167
  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
168
+ print(f" {n:18s} id={eid:#04x} color:{color:3s} default speed={spd} brightness={bri}")
169
+ return 0 if name is None else 1
172
170
 
173
171
  color = None
174
172
  if args.color:
@@ -185,7 +183,7 @@ def cmd_effect(args):
185
183
  if dev is None:
186
184
  return 1
187
185
  try:
188
- send_firmware_effect(dev, frames)
186
+ send_burst(dev, frames)
189
187
  finally:
190
188
  dev.close()
191
189
  desc = f"effect {name}"
@@ -198,38 +196,17 @@ def cmd_effect(args):
198
196
 
199
197
 
200
198
  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
199
+ """Paint a single key (`key W red` == `paint W=red`)."""
200
+ args.specs = [f"{args.key}={args.color}"]
201
+ return cmd_paint(args)
226
202
 
227
203
 
228
204
  def cmd_paint(args):
229
205
  """Paint many keys via the CANVAS + 5AA5 execute path (flash write):
230
206
  fizzctl paint W=ff0000 A=00ff00 S=0000ff D=ffffff
231
207
  """
232
- from .hid import rgb_sequence, send_rgb
208
+ from .effects import parse_color
209
+ from .hid import rgb_sequence
233
210
  from .protocol import NAME_TO_INDEX
234
211
 
235
212
  colors = {}
@@ -237,7 +214,7 @@ def cmd_paint(args):
237
214
  if "=" not in spec:
238
215
  print(f"bad spec {spec!r}: expected KEY=COLOR")
239
216
  return 1
240
- key, c = spec.split("=", 1)
217
+ key, c = spec.rsplit("=", 1)
241
218
  if key not in NAME_TO_INDEX:
242
219
  print(f"unknown key {key!r}")
243
220
  return 1
@@ -254,25 +231,13 @@ def cmd_paint(args):
254
231
  if dev is None:
255
232
  return 1
256
233
  try:
257
- send_rgb(dev, frames)
258
- print(f"painted {len(colors)} keys")
234
+ send_burst(dev, frames)
235
+ print(f"painted {len(colors)} key{'s' if len(colors) != 1 else ''}")
259
236
  finally:
260
237
  dev.close()
261
238
  return 0
262
239
 
263
240
 
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
241
  def _build_parser(dev: bool) -> argparse.ArgumentParser:
277
242
  p = argparse.ArgumentParser(
278
243
  prog="fizzctl" if not dev else "fizzctl-dev",
@@ -323,8 +288,8 @@ Examples:
323
288
  formatter_class=argparse.RawDescriptionHelpFormatter)
324
289
  peff.add_argument("name", nargs="?",
325
290
  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)")
291
+ peff.add_argument("-c", "--color", help="base color (name or hex) — RGB/single-color mode")
292
+ peff.add_argument("-s", "--speed", type=int, help="1..5 (level; higher = faster)")
328
293
  peff.add_argument("-b", "--brightness", type=int, help="0..4 (level; higher = brighter)")
329
294
 
330
295
  pk = sub.add_parser("key",
@@ -4,12 +4,24 @@ Two independent protocols:
4
4
 
5
5
  * Firmware effects — a 5-frame burst, all built from the fw-static template
6
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)
7
+ MODE[218..220] = base color (R,G,B) — the real color field (per-effect
8
+ slot; sine-wave uses MODE[281..283])
9
+ EXEC[38+2*(id-1)] = per-effect RGB/color toggle: 0x07 multicolor,
10
+ 0x00 render the MODE base color
11
+ EXEC[21] = effect_id (selects rainbow/snake/wheel/...)
12
+ EXEC[39] = packed nibbles (high=speed-1 (0..4), low=brightness 0..4)
10
13
  Sending requires the mandatory GET_REPORT(0x06, 1032) handshake after INIT
11
14
  (without it the firmware silently ignores the burst).
12
15
 
16
+ Speed is stored 0-based: the firmware displays ``nibble + 1`` as speed
17
+ 1..5, so passing ``--speed 1`` stores 0. Verified on hardware (sine-wave:
18
+ sending 1/2/3 showed 2/3/4 before the fix). Brightness is stored raw
19
+ (0..4). The color mechanism was verified from OEM captures (same effect in
20
+ red/green/blue differs only at the effect's MODE color slots, with that
21
+ effect's flag byte zeroed); all stock templates bake every flag byte to
22
+ 0x07 (multicolor), so the base color needs the effect's own flag flipped to
23
+ 0x00 to take effect — EXEC[56] (the old assumption) is only snake's slot.
24
+
13
25
  * Per-key paint — a SINGLE 382-byte feature report ``08 0a 7a 01`` followed by
14
26
  96 RGB triplets in a 16-col x 6-row column-major raster (pos = col*6+row).
15
27
  No handshake, no flash commit, host-side (volatile) — bytes are re-applied
@@ -26,7 +38,6 @@ from __future__ import annotations
26
38
  # Baseline is the captured fw-static template (init + mode + canvas + routing
27
39
  # + exec). Every other effect only differs in the 3-5 bytes listed below.
28
40
  # 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
41
  from .protocol import base_frames
31
42
 
32
43
  # Official Redragon software effect menu (order from K617 software).
@@ -61,6 +72,23 @@ EFFECTS = [
61
72
  ("off", ("off",), 0x16, True, 0x00, ""),
62
73
  ]
63
74
 
75
+ # The base color slot in the MODE frame is effect-specific. Verified:
76
+ # fixed-on (0x01) -> MODE[29..31] live-tested green on hardware
77
+ # snake (0x0a) -> MODE[218,219,220] live-tested blue on hardware
78
+ # sine-wave (0x0d) -> MODE[280..284]-ish: red/green OEM captures moved
79
+ # bytes [281]=R and [282]=G (byte [280]=0xff constant; B at [283]
80
+ # inferred since both captured colors had B=0). Everything else
81
+ # defaults to the [218..220] slot (snake-verified).
82
+ _COLOR_SLOTS: dict[str, tuple[int, int, int]] = {
83
+ "fixed-on": (29, 30, 31),
84
+ "sine-wave": (281, 282, 283),
85
+ }
86
+
87
+
88
+ def _color_slot(name: str) -> tuple[int, int, int]:
89
+ return _COLOR_SLOTS.get(name, (218, 219, 220))
90
+
91
+
64
92
  EFFECT_ID: dict[str, int] = {n: eid for n, _, eid, *_ in EFFECTS}
65
93
  _ALIASES: dict[str, str] = {a: n for n, as_, *_ in EFFECTS for a in as_}
66
94
  EFFECT_ACCEPTS_COLOR = {n for n, _, _, ac, *_ in EFFECTS if ac}
@@ -89,11 +117,24 @@ def encode_firmware_effect(
89
117
  brightness: int | None = None,
90
118
  ) -> list[bytes]:
91
119
  """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.
120
+ effect. Patches the effect's MODE color slot, EXEC[21] (effect_id), its
121
+ EXEC[38+2*(id-1)] RGB/color toggle and the speed/brightness onto the
122
+ fw-static baseline.
123
+
124
+ The color mechanism was established from OEM captures: applying the same
125
+ effect in red/green/blue differs ONLY at the MODE color slot (snake:
126
+ MODE[218..220]; sine-wave: MODE[281..283]). Each effect owns an
127
+ RGB/color toggle byte at EXEC[38+2*(id-1)]: 0x07 = multicolor animation,
128
+ 0x00 = render the MODE base color instead. Stock templates bake every
129
+ toggle to 0x07 (the OEM's "RGB" checkbox is ON by default), which is why
130
+ colors silently never took effect before — and EXEC[56] is only snake's
131
+ toggle, not a global one.
94
132
 
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.
133
+ OEM slider ranges: speed 1..5, brightness 0..4 (5 levels each). Values
134
+ are clamped when the user passes them; missing flags keep the template
135
+ default (so ``off`` at 0x00 is preserved). A color is only applied when
136
+ the effect accepts one; when no color is given the baked RGB-mode flag
137
+ is left untouched.
97
138
  """
98
139
  name = _canonical(name)
99
140
  eid = EFFECT_ID[name]
@@ -102,28 +143,31 @@ def encode_firmware_effect(
102
143
  frames = [bytearray(f) for f in base_frames()]
103
144
  mode, canvas, routing, exec_ = frames[1], frames[2], frames[3], frames[4]
104
145
 
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
146
+ if color is not None and name in EFFECT_ACCEPTS_COLOR:
147
+ r, g, b = color[0] & 0xFF, color[1] & 0xFF, color[2] & 0xFF
148
+ ro, go, bo = _color_slot(name)
149
+ mode[ro], mode[go], mode[bo] = r, g, b
150
+ # EXEC[38 + 2*(eid-1)] is this effect's RGB/single-color flag byte
151
+ # (0x07 = RGB/random, 0x00 = render MODE color). EXEC[56] is the
152
+ # snake (eid 10) slot -- not a global toggle, which is why sine stayed
153
+ # multicolor before the per-effect slot was used.
154
+ exec_[38 + 2 * (eid - 1)] = 0x00
108
155
 
109
156
  exec_[21] = eid
110
157
 
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)))
158
+ # EXEC[39] is the live speed×brightness value (high nibble = speed,
159
+ # low nibble = brightness). Evidence: an OEM solid-color capture carries
160
+ # 0x32 there, the fw-static template 0x34, and the OEM keeps per-effect
161
+ # remembered values in a 20-slot table at EXEC[39 + 2*(id-1)] (ids 1..20;
162
+ # the same capture shows saved 0x44 in slot 5 and 0x22 in slot 12). The
163
+ # value must land in both places or per-effect speed/brightness is ignored.
164
+ # Speed is stored 0-based: the OEM displays nibble+1 (hardware-verified).
165
+ new_speed = (max(1, min(5, round(speed))) - 1) if speed is not None else defaults[0]
166
+ new_bright = max(0, min(4, round(brightness))) if brightness is not None else defaults[1]
121
167
  packed = ((new_speed & 0x0F) << 4) | (new_bright & 0x0F)
122
168
  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
169
+ if 1 <= eid <= 20:
170
+ exec_[39 + 2 * (eid - 1)] = packed
127
171
 
128
172
  return [bytes(frames[0])] + [bytes(f) for f in frames[1:]]
129
173
 
@@ -134,7 +178,6 @@ def encode_firmware_effect(
134
178
 
135
179
  PERKEY_HEADER = bytes.fromhex("080a7a01")
136
180
  PERKEY_PACKET_LEN = 382
137
- SINODRAGON_LED_COUNT = 96
138
181
 
139
182
  # K617 key name -> position in the 16x6 column-major raster (pos = col*6+row).
140
183
  # Rows 1..4 in the 6-row raster hold rows 0..4 of the keyboard; raster row 0
@@ -16,7 +16,7 @@ import time
16
16
 
17
17
  import hid
18
18
 
19
- from .protocol import PID, VID
19
+ from .protocol import PID, VID, frame_kind
20
20
 
21
21
 
22
22
  class NoDeviceError(Exception):
@@ -100,17 +100,6 @@ class K617:
100
100
  raw = self._dev.get_feature_report(report_id, size)
101
101
  return bytes(raw) if raw is not None else bytes(size)
102
102
 
103
- def send_sequence(self, frames: list[bytes], delay_ms: int = 30) -> None:
104
- """Send a list of frames sequentially (init -> data -> commit)."""
105
- from time import sleep
106
-
107
- for i, frame in enumerate(frames):
108
- kind = _kind(frame)
109
- self.send_feature(frame)
110
- if self.debug:
111
- print(f" [{i + 1}/{len(frames)}] {kind}")
112
- sleep(delay_ms / 1000)
113
-
114
103
  def close(self) -> None:
115
104
  if self._dev is not None:
116
105
  try:
@@ -161,12 +150,6 @@ def open_device(debug: bool = False) -> K617 | None:
161
150
  return None
162
151
 
163
152
 
164
- def _kind(frame: bytes) -> str:
165
- from .protocol import frame_kind
166
-
167
- return frame_kind(frame)
168
-
169
-
170
153
  def rgb_sequence(led_colors: dict[int, tuple[int, int, int]]) -> list[bytes]:
171
154
  """Build the RGB-write payloads: [INIT, P1(no SEC yet), P2, EXEC].
172
155
 
@@ -175,7 +158,7 @@ def rgb_sequence(led_colors: dict[int, tuple[int, int, int]]) -> list[bytes]:
175
158
 
176
159
  NOTE: the EXEC frame commits to flash (5AA5 magic). Do not loop this.
177
160
  """
178
- from .blobs import RGB_EXEC, RGB_INIT, RGB_SEC
161
+ from .blobs import INIT, RGB_EXEC, RGB_SEC
179
162
  from .protocol import NAME_TO_INDEX, RESTORE_CONSTANT_FRAMES, set_key_color
180
163
 
181
164
  canvas = bytearray(1032)
@@ -186,68 +169,29 @@ def rgb_sequence(led_colors: dict[int, tuple[int, int, int]]) -> list[bytes]:
186
169
  set_key_color(canvas, idx, rgb)
187
170
  canvas[660:660 + len(RGB_SEC)] = RGB_SEC
188
171
  p2 = RESTORE_CONSTANT_FRAMES[2] # routing, never modify
189
- return [bytes(RGB_INIT), bytes(canvas), p2, RGB_EXEC]
190
-
172
+ return [bytes(INIT), bytes(canvas), p2, RGB_EXEC]
191
173
 
192
- def rgb_all(color: tuple[int, int, int]) -> list[bytes]:
193
- from .protocol import LED_INDEX
194
174
 
195
- return rgb_sequence({idx: color for idx in LED_INDEX})
175
+ def send_burst(dev: K617, frames: list[bytes], handshake: bool = True,
176
+ delay_ms: int = 60) -> None:
177
+ """Send an RGB write burst: INIT -> [mandatory GET handshake] -> blocks.
196
178
 
197
-
198
- def send_rgb(dev: K617, frames: list[bytes]) -> None:
199
- """Send the RGB sequence WITH the mandatory GET_REPORT handshake.
200
-
201
- Protocol: INIT -> GET (handshake) -> P1 -> P2 -> EXEC.
202
- Without the handshake the firmware silently ignores the writes.
203
- """
204
- from time import sleep
205
-
206
- init, canvas, p2, exec_ = frames
207
- dev.send_feature(init)
208
- if dev.debug:
209
- print(" [1/4] INIT")
210
- sleep(0.06)
211
- resp = dev.get_feature(0x06, 1032) # mandatory handshake
212
- if dev.debug:
213
- print(f" [handshake] get_feature(0x06, 1032) -> {len(resp)}B")
214
- sleep(0.06)
215
- dev.send_feature(canvas)
216
- if dev.debug:
217
- print(" [2/4] CANVAS")
218
- sleep(0.06)
219
- dev.send_feature(p2)
220
- if dev.debug:
221
- print(" [3/4] ROUTING")
222
- sleep(0.06)
223
- dev.send_feature(exec_)
224
- if dev.debug:
225
- print(" [4/4] EXEC")
226
-
227
-
228
- # firmware effects + per-key paint (see fizzctl.effects)
229
- def send_firmware_effect(dev: K617, frames: list[bytes]) -> None:
230
- """Send the 5-frame firmware-effect burst WITH the mandatory handshake.
231
-
232
- Protocol: INIT -> GET (handshake) -> MODE -> CANVAS -> ROUTING -> EXEC.
233
- The EXEC block commits the effect selection to flash (5AA5 magic).
179
+ ``handshake=True`` (rgb/effect/key/paint): the firmware ignores the burst
180
+ unless we poll GET_REPORT(0x06, 1032) after INIT. ``handshake=False``
181
+ (keymap/replay) skips it.
234
182
  """
235
183
  from time import sleep
236
184
 
237
- init, *blocks = frames
238
- dev.send_feature(init)
239
- if dev.debug:
240
- print(" [1/5] INIT")
241
- sleep(0.06)
242
- resp = dev.get_feature(0x06, 1032) # mandatory handshake
243
- if dev.debug:
244
- print(f" [handshake] get_feature(0x06, 1032) -> {len(resp)}B")
245
- sleep(0.06)
246
- for i, block in enumerate(blocks, start=2):
247
- dev.send_feature(block)
185
+ for i, frame in enumerate(frames, start=1):
186
+ dev.send_feature(frame)
248
187
  if dev.debug:
249
- print(f" [{i}/5] {_kind(block)}")
250
- sleep(0.06)
188
+ print(f" [{i}/{len(frames)}] {frame_kind(frame)}")
189
+ sleep(delay_ms / 1000)
190
+ if handshake and i == 1:
191
+ resp = dev.get_feature(0x06, 1032)
192
+ if dev.debug:
193
+ print(f" [handshake] get_feature(0x06, 1032) -> {len(resp)}B")
194
+ sleep(delay_ms / 1000)
251
195
 
252
196
 
253
197
  def send_per_key(dev: K617, frame: bytes) -> None:
@@ -4,27 +4,18 @@ Nothing in this module talks to hardware; it's pure constants + helpers.
4
4
  """
5
5
  from __future__ import annotations
6
6
 
7
- from .blobs import CONST_CANVAS, CONST_EXEC, CONST_MODE, CONST_ROUTING, FW_FRAMES
7
+ from .blobs import CONST_CANVAS, CONST_EXEC, CONST_MODE, CONST_ROUTING, FW_TEMPLATE
8
8
 
9
9
  VID = 0x258A
10
10
  PID = 0x0049
11
11
 
12
- # ---- report IDs seen in HID descriptors (interface 1, vendor usage 0xFF00) ----
13
- REPORT_IDS = [0x05, 0x06, 0x08]
14
-
15
- # ---- known frame sizes ----
16
- SIZE_INIT = 6
17
- SIZE_BLOCK = 1032
18
- SIZE_PERKEY = 382
19
-
20
12
  # ---- known-good packet interiors (inlined in blobs.py from captures) ----
21
- # Index in the static-effect template (FW_FRAMES):
13
+ # Index in the static-effect template (base_frames()):
22
14
  # 0 = INIT (05 83 b6 00 00 00)
23
15
  # 1 = mode/config (06 08 b8 00 40 ...)
24
16
  # 2 = RGB canvas base (06 09 bc 00 40 ...)
25
17
  # 3 = routing (06 09 c0 00 40 ...) — same family as P2, never hand-edit
26
18
  # 4 = EXEC/commit (06 03 b6 00 00 ...) — contains 5A A5 flash-commit magic
27
- FRAME_INIT, FRAME_MODE, FRAME_CANVAS, FRAME_ROUTING, FRAME_EXEC = range(5)
28
19
 
29
20
  # Constant frames of the Restore sequence (06 xx xx 00 40 ...), 1032 bytes each.
30
21
  RESTORE_CONSTANT_FRAMES: list[bytes] = [
@@ -35,13 +26,9 @@ RESTORE_CONSTANT_FRAMES: list[bytes] = [
35
26
  ]
36
27
 
37
28
 
38
- def base_frames(effect: str = "fw-static") -> list[bytes]:
39
- """Return the captured static-effect frame template.
40
-
41
- `effect` is accepted for API compatibility; only the built-in template is
42
- shipped (no data files on disk anymore).
43
- """
44
- return [bytes(f) for f in FW_FRAMES]
29
+ def base_frames() -> list[bytes]:
30
+ """Return the captured static-effect frame template."""
31
+ return [bytes(f) for f in FW_TEMPLATE]
45
32
 
46
33
 
47
34
  def frame_kind(frame: bytes) -> str:
@@ -1,46 +0,0 @@
1
- [project]
2
- name = "fizzctl"
3
- version = "0.2.0"
4
- description = "Control a Redragon K617 Fizz keyboard on Linux: solid colors, 22 firmware-native RGB effects, per-key painting, live animations, and Cfg.ini keymap restore — no vendor software required"
5
- readme = "README.md"
6
- license = { text = "MIT" }
7
- authors = [
8
- { name = "Ayoub Dya", email = "ayoubdya@gmail.com" }
9
- ]
10
- keywords = [
11
- "redragon",
12
- "k617",
13
- "fizz",
14
- "rgb",
15
- "keyboard",
16
- "backlight",
17
- "led",
18
- "hid",
19
- "linux",
20
- ]
21
- classifiers = [
22
- "Development Status :: 4 - Beta",
23
- "Environment :: Console",
24
- "Intended Audience :: End Users/Desktop",
25
- "Operating System :: POSIX :: Linux",
26
- "Programming Language :: Python :: 3",
27
- "Programming Language :: Python :: 3.14",
28
- "Topic :: System :: Hardware :: Hardware Drivers",
29
- "Topic :: System :: Hardware",
30
- ]
31
- requires-python = ">=3.14"
32
- dependencies = [
33
- "dpkt>=1.9.8",
34
- "hidapi>=0.15.0",
35
- ]
36
-
37
- [project.scripts]
38
- fizzctl = "fizzctl:main"
39
- fizzctl-dev = "fizzctl:main_dev"
40
-
41
- [build-system]
42
- requires = ["uv_build>=0.12.13,<0.13.0"]
43
- build-backend = "uv_build"
44
-
45
- [tool.uv]
46
- package = true
File without changes
File without changes