fizzctl 0.3.0__tar.gz → 0.4.0__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,7 +1,7 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: fizzctl
3
- Version: 0.3.0
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
3
+ Version: 0.4.0
4
+ Summary: Control a Redragon K617 Fizz keyboard on Linux: solid colors, 22 firmware-native RGB effects, per-key painting, live animations, on-device key macros, 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
7
7
  Author-email: Ayoub Dya <ayoubdya@gmail.com>
@@ -20,8 +20,9 @@ Description-Content-Type: text/markdown
20
20
 
21
21
  # fizzctl
22
22
 
23
- Linux tool for controlling the Redragon K617 Fizz keyboard's RGB lighting
24
- and restoring keymaps from `Cfg.ini` files.
23
+ Control a Redragon K617 Fizz keyboard on Linux: solid colors, 22 firmware-native
24
+ RGB effects, per-key painting, live animations, on-device key macros with a
25
+ readable back, and `Cfg.ini` keymap restore — no vendor software required.
25
26
 
26
27
  ## Install
27
28
 
@@ -59,7 +60,7 @@ the trigger did not pick it up.
59
60
  | `key` | Paint a single key | yes |
60
61
  | `paint` | Paint multiple keys at once | yes |
61
62
  | `keymap` | Write the keymap from a Cfg.ini | yes |
62
- | `macro` | Bind a key that types text on press | yes |
63
+ | `macro` | Bind a key that types text, `--remove-all`, or `--read` | yes |
63
64
  | `restore` | Reset to the factory keymap, lighting and macros | yes |
64
65
  | `animate` | Host-streamed animation (volatile) | no |
65
66
 
@@ -102,9 +103,13 @@ fizzctl macro --key 2 --until-released aaaa
102
103
 
103
104
  Options: `--delay-ms` (default 30) is the delay between typed events,
104
105
  `--cycles` (default 1) plays the macro that many times per press, and
105
- `--until-released` types in a loop until the key is let go. Macros and their
106
- bindings are remembered in a local state file (`$XDG_STATE_HOME/fizzctl/`)
107
- so later macro/keymap writes never drop them. To undo:
106
+ `--until-released` types in a loop until the key is let go. Macros are read
107
+ straight back off the keyboard's flash: each write reads the current keymap
108
+ and macro table first, patches them, and writes the full table back — so
109
+ later macro/keymap writes never drop your existing macros, and nothing is
110
+ saved on the host. `fizzctl macro --read` dumps the on-device table back,
111
+ each slot shown with the key(s) bound to it and whether it repeats until the
112
+ key is released. To undo:
108
113
 
109
114
  ```bash
110
115
  fizzctl macro --remove-all # unbind every macro, keep keymap + lighting
@@ -1,7 +1,8 @@
1
1
  # fizzctl
2
2
 
3
- Linux tool for controlling the Redragon K617 Fizz keyboard's RGB lighting
4
- and restoring keymaps from `Cfg.ini` files.
3
+ Control a Redragon K617 Fizz keyboard on Linux: solid colors, 22 firmware-native
4
+ RGB effects, per-key painting, live animations, on-device key macros with a
5
+ readable back, and `Cfg.ini` keymap restore — no vendor software required.
5
6
 
6
7
  ## Install
7
8
 
@@ -39,7 +40,7 @@ the trigger did not pick it up.
39
40
  | `key` | Paint a single key | yes |
40
41
  | `paint` | Paint multiple keys at once | yes |
41
42
  | `keymap` | Write the keymap from a Cfg.ini | yes |
42
- | `macro` | Bind a key that types text on press | yes |
43
+ | `macro` | Bind a key that types text, `--remove-all`, or `--read` | yes |
43
44
  | `restore` | Reset to the factory keymap, lighting and macros | yes |
44
45
  | `animate` | Host-streamed animation (volatile) | no |
45
46
 
@@ -82,9 +83,13 @@ fizzctl macro --key 2 --until-released aaaa
82
83
 
83
84
  Options: `--delay-ms` (default 30) is the delay between typed events,
84
85
  `--cycles` (default 1) plays the macro that many times per press, and
85
- `--until-released` types in a loop until the key is let go. Macros and their
86
- bindings are remembered in a local state file (`$XDG_STATE_HOME/fizzctl/`)
87
- so later macro/keymap writes never drop them. To undo:
86
+ `--until-released` types in a loop until the key is let go. Macros are read
87
+ straight back off the keyboard's flash: each write reads the current keymap
88
+ and macro table first, patches them, and writes the full table back — so
89
+ later macro/keymap writes never drop your existing macros, and nothing is
90
+ saved on the host. `fizzctl macro --read` dumps the on-device table back,
91
+ each slot shown with the key(s) bound to it and whether it repeats until the
92
+ key is released. To undo:
88
93
 
89
94
  ```bash
90
95
  fizzctl macro --remove-all # unbind every macro, keep keymap + lighting
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "fizzctl"
3
- version = "0.3.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"
3
+ version = "0.4.0"
4
+ description = "Control a Redragon K617 Fizz keyboard on Linux: solid colors, 22 firmware-native RGB effects, per-key painting, live animations, on-device key macros, and Cfg.ini keymap restore — no vendor software required"
5
5
  readme = "README.md"
6
6
  keywords = [
7
7
  "redragon",
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "fizzctl"
3
- version = "0.3.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"
3
+ version = "0.4.0"
4
+ description = "Control a Redragon K617 Fizz keyboard on Linux: solid colors, 22 firmware-native RGB effects, per-key painting, live animations, on-device key macros, and Cfg.ini keymap restore — no vendor software required"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
7
7
  authors = [{ name = "Ayoub Dya", email = "ayoubdya@gmail.com" }]
@@ -7,4 +7,4 @@ by :func:`main_dev` via the ``fizzctl-dev`` console script.
7
7
  from .cli import main, main_dev
8
8
 
9
9
  __all__ = ["main", "main_dev"]
10
- __version__ = "0.3.0"
10
+ __version__ = "0.4.0"
@@ -7,8 +7,9 @@ All constants come from USB captures of the OEM software:
7
7
  a distinct CANVAS, and five byte overrides of CONST_EXEC.
8
8
  * RGB_EXEC / RGB_SEC — the whole-board solid-color variant.
9
9
  * CONST_KEYMAP — a clean 1032-byte 06 04 d4 keymap block (no macro
10
- bindings), captured from the device. The macro command uses it as the
11
- default base; KeymapEncoder can rebuild the same block from a Cfg.ini.
10
+ bindings), captured from the device. Used as the fallback base when the
11
+ device's keymap cannot be read; KeymapEncoder can rebuild the same block
12
+ from a Cfg.ini.
12
13
  """
13
14
 
14
15
 
@@ -9,6 +9,7 @@ User commands (``fizzctl``):
9
9
  fizzctl keymap <Cfg.ini> # write full keymap from Cfg.ini (FLASH WRITE)
10
10
  fizzctl restore # restore factory keymap+lighting (FLASH WRITE)
11
11
  fizzctl macro --key K <text> # bind a macro that types text (FLASH WRITE)
12
+ fizzctl macro --read # dump on-device macros + their key bindings
12
13
  fizzctl setup-udev # install 99-k617.rules (needs root)
13
14
 
14
15
  Dev commands (``fizzctl-dev``, reverse-engineering toolkit):
@@ -29,7 +30,7 @@ from pathlib import Path
29
30
  from .animations import cmd_animate
30
31
  from .capture import diff_captures, export_frames, load_frames, load_tshark_json, significant
31
32
  from .cfg import CfgIni
32
- from .hid import NoDeviceError, open_device, read_keymap, read_lighting, send_burst
33
+ from .hid import NoDeviceError, open_device, read_keymap, read_lighting, read_macro, send_burst
33
34
  from .keymap import KeymapEncoder
34
35
  from .protocol import RESTORE_CONSTANT_FRAMES
35
36
 
@@ -129,68 +130,28 @@ def _live_keymap(dev, debug: bool = False) -> bytes:
129
130
  return bytes(CONST_KEYMAP)
130
131
 
131
132
 
132
- def _apply_bindings(keymap: bytearray, bindings: dict,
133
- cache: dict | None = None) -> list[str]:
134
- """Patch every cached macro binding into ``keymap`` in place; return the
135
- keys that could not be found.
133
+ def _live_macro(dev, debug: bool = False) -> bytes:
134
+ """Read the device's current macro table so a write keeps every slot;
135
+ fall back to an empty table if the read fails."""
136
+ from .macro import build_macro_frame
137
+ try:
138
+ return read_macro(dev)
139
+ except Exception as e: # keep working on read failure
140
+ if debug:
141
+ print(f" could not read macro table ({e}); using empty table")
142
+ return build_macro_frame({})
136
143
 
137
- A binding the device already has (a ``10`` record echoed in the read-back)
138
- counts as applied even though :func:`bind_macro` cannot rediscover it.
139
- When ``cache`` is given, each binding also records the key's original
140
- record and offset so it can be restored later (``--remove-all``).
141
- """
142
- from .blobs import CONST_KEYMAP
143
- from .macro import NAME_TO_HID, bind_macro, find_binding, locate_key
144
- missed = []
145
- for key, info in bindings.items():
146
- hid = NAME_TO_HID.get(key)
147
- if hid is None:
148
- missed.append(key)
149
- continue
150
- slot = int(info["slot"])
151
- mode = int(info["mode"])
152
- off = locate_key(keymap, hid)
153
- if off is not None:
154
- if cache is not None:
155
- cache["bindings"][key]["offset"] = off
156
- cache["bindings"][key]["record"] = bytes(keymap[off:off + 4]).hex()
157
- bind_macro(keymap, hid, slot, mode)
158
- else:
159
- off = find_binding(keymap, slot, mode)
160
- if off is None:
161
- missed.append(key)
162
- elif cache is not None and cache["bindings"][key].get("record") is None:
163
- # already bound in the read-back: remember where, and fall back
164
- # to the stock record as the best guess for the original
165
- cache["bindings"][key]["offset"] = off
166
- cache["bindings"][key]["record"] = bytes(CONST_KEYMAP[off:off + 4]).hex()
167
- return missed
168
-
169
-
170
- def _strip_bindings(keymap: bytearray, cache: dict) -> int:
171
- """Restore the original records of cached macro bindings in ``keymap``.
172
-
173
- Returns how many records were restored. Bindings without a recorded
174
- original (created before this feature) fall back to the stock record for
175
- their offset.
176
- """
144
+
145
+ def _binding_for_hid(keymap, hid: int) -> tuple[int, int] | None:
146
+ """``(offset, slot)`` of the macro binding whose stock record outputs
147
+ ``hid`` — i.e. which bound physical key this is."""
177
148
  from .blobs import CONST_KEYMAP
178
- from .macro import find_binding
179
- n = 0
180
- for info in cache["bindings"].values():
181
- off = info.get("offset")
182
- rec = info.get("record")
183
- if isinstance(off, int) and isinstance(rec, str) and len(rec) == 8:
184
- original = bytes.fromhex(rec)
185
- if keymap[off:off + 4] != original:
186
- keymap[off:off + 4] = original
187
- n += 1
188
- continue
189
- off = find_binding(keymap, int(info["slot"]), int(info["mode"]))
190
- if off is not None:
191
- keymap[off:off + 4] = bytes(CONST_KEYMAP[off:off + 4])
192
- n += 1
193
- return n
149
+ from .macro import collect_bindings
150
+ for off, _mode, slot in collect_bindings(keymap):
151
+ rec = CONST_KEYMAP[off:off + 4]
152
+ if rec[0] in (0x00, 0x06) and rec[3] == (hid & 0xFF):
153
+ return off, slot
154
+ return None
194
155
 
195
156
 
196
157
  def cmd_macro_remove_all(args):
@@ -202,13 +163,9 @@ def cmd_macro_remove_all(args):
202
163
  Examples:
203
164
  fizzctl macro --remove-all
204
165
  """
205
- from . import state
206
- from .macro import build_macro_frame
166
+ from .blobs import CONST_KEYMAP
167
+ from .macro import build_macro_frame, collect_bindings, slots_in_frame
207
168
 
208
- cache = state.load()
209
- if not cache["slots"] and not cache["bindings"]:
210
- print("no macros to remove")
211
- return 0
212
169
  try:
213
170
  dev = open_device(debug=args.debug)
214
171
  except NoDeviceError:
@@ -218,7 +175,13 @@ def cmd_macro_remove_all(args):
218
175
  try:
219
176
  mode_f, canvas_f, routing_f, exec_f = _live_lighting(dev, args.debug)
220
177
  base = bytearray(_live_keymap(dev, args.debug))
221
- removed = _strip_bindings(base, cache)
178
+ live_mf = _live_macro(dev, args.debug)
179
+ bindings = collect_bindings(base)
180
+ if not bindings and not slots_in_frame(live_mf):
181
+ print("no macros to remove")
182
+ return 0
183
+ for off, _mode, _slot in bindings:
184
+ base[off:off + 4] = bytes(CONST_KEYMAP[off:off + 4])
222
185
  frames = [
223
186
  bytes.fromhex("0583b6000000"), # INIT
224
187
  mode_f, canvas_f, routing_f, # current lighting (kept)
@@ -229,35 +192,27 @@ def cmd_macro_remove_all(args):
229
192
  send_burst(dev, frames, handshake=False, delay_ms=args.burst_ms)
230
193
  finally:
231
194
  dev.close()
232
- state.save({"slots": {}, "bindings": {}})
233
- print(f"removed {removed} macro binding(s) and cleared all macro slots")
195
+ print(f"removed {len(bindings)} macro binding(s) and cleared all macro slots")
234
196
  return 0
235
197
 
236
198
 
237
199
  def cmd_keymap(args):
238
200
  """Write a full keymap from a Cfg.ini (flash write).
239
201
 
240
- Previously created macros are re-applied on top, and the current lighting
241
- (effect, color, brightness) is kept.
202
+ Existing macro bindings and slots are read back from the device and
203
+ re-applied on top, and the current lighting (effect, color, brightness)
204
+ is kept.
242
205
 
243
206
  Examples:
244
207
  fizzctl keymap cfgs/cfg_final.ini
245
208
  """
246
- from . import state
247
- from .macro import build_macro_frame
209
+ from .blobs import CONST_KEYMAP
210
+ from .macro import collect_bindings, relocate_bindings, slots_in_frame
248
211
 
249
212
  keymap = bytearray(KeymapEncoder(CfgIni(args.cfg)).build())
250
213
  if len(keymap) != 1032:
251
214
  print("error: keymap block is not 1032 bytes")
252
215
  return 1
253
- cache = state.load()
254
- missed = _apply_bindings(keymap, cache["bindings"])
255
- if args.debug:
256
- print(f"built keymap from {args.cfg}")
257
- if cache["slots"]:
258
- print(f" re-applying {len(cache['bindings'])} macro binding(s)")
259
- if missed:
260
- print(f"warning: could not bind {', '.join(missed)} in this keymap")
261
216
  try:
262
217
  dev = open_device(debug=args.debug)
263
218
  except NoDeviceError:
@@ -266,14 +221,22 @@ def cmd_keymap(args):
266
221
  return 1
267
222
  try:
268
223
  mode, canvas, routing, exec_ = _live_lighting(dev, args.debug)
224
+ live_km = _live_keymap(dev, args.debug)
225
+ live_mf = _live_macro(dev, args.debug)
226
+ keymap, warnings = relocate_bindings(live_km, keymap, CONST_KEYMAP)
227
+ for w in warnings:
228
+ print(f"warning: {w}")
229
+ nb = len(collect_bindings(live_km))
230
+ if args.debug:
231
+ print(f"built keymap from {args.cfg} (re-applied {nb} macro binding(s))")
269
232
  # exact capture order, but with the device's live lighting blocks
270
233
  frames = [
271
234
  bytes.fromhex("050581000000"), # INIT
272
235
  bytes.fromhex("0583b6000000"), # INIT
273
236
  mode, canvas, routing, # current lighting
274
237
  ]
275
- if cache["slots"]:
276
- frames.append(build_macro_frame(state.raw_slots(cache)))
238
+ if slots_in_frame(live_mf):
239
+ frames.append(live_mf) # 06 05 dc (slots kept)
277
240
  frames += [
278
241
  bytes(keymap), # 06 04 d4 keymap block
279
242
  exec_, # EXEC (5AA5 commit)
@@ -300,7 +263,6 @@ def cmd_restore(args):
300
263
  Examples:
301
264
  fizzctl restore
302
265
  """
303
- from . import state
304
266
  from .macro import build_macro_frame
305
267
 
306
268
  keymap = bytearray(KeymapEncoder(CfgIni(_stock_cfg())).build())
@@ -326,11 +288,71 @@ def cmd_restore(args):
326
288
  send_burst(dev, frames, handshake=False, delay_ms=args.delay_ms)
327
289
  finally:
328
290
  dev.close()
329
- state.save({"slots": {}, "bindings": {}})
330
291
  print("restored factory keymap, lighting and macros")
331
292
  return 0
332
293
 
333
294
 
295
+ def cmd_macro_read(args):
296
+ """Read the on-device macro table back (dev tool).
297
+
298
+ Shows every non-empty slot with the key(s) bound to it (a slot can be
299
+ shared) and the events it types. Since the keymap is read alongside the
300
+ macro table, bindings are identified by the stock record at their live
301
+ offset (the CONST_KEYMAP oracle), and keys bound with ``--until-released``
302
+ are flagged as such.
303
+
304
+ Examples:
305
+ fizzctl macro --read
306
+ """
307
+ from .blobs import CONST_KEYMAP
308
+ from .macro import MAX_SLOTS, MODE_UNTIL_RELEASED, NAME_TO_HID, \
309
+ SLOT_BASE, SLOT_STRIDE, collect_bindings, decode_slot
310
+
311
+ names = {hid: name for name, hid in NAME_TO_HID.items()}
312
+ try:
313
+ dev = open_device(debug=args.debug)
314
+ except NoDeviceError:
315
+ return 1
316
+ if dev is None:
317
+ return 1
318
+ try:
319
+ frame = read_macro(dev)
320
+ keymap = read_keymap(dev)
321
+ finally:
322
+ dev.close()
323
+
324
+ binds: dict[int, list[tuple[str, int]]] = {}
325
+ for off, mode, slot in collect_bindings(keymap):
326
+ rec = CONST_KEYMAP[off:off + 4]
327
+ hid = rec[3] if rec[0] in (0x00, 0x06) else None
328
+ label = (names.get(hid) if hid else None) or (f"key 0x{hid:02x}" if hid else f"@+{off:#06x}")
329
+ if mode & MODE_UNTIL_RELEASED:
330
+ label += " (until released)"
331
+ binds.setdefault(slot, []).append(label)
332
+
333
+ print(f"macro table header: {frame[:5].hex(' ')}")
334
+ found = 0
335
+ for i in range(MAX_SLOTS):
336
+ base = SLOT_BASE + i * SLOT_STRIDE
337
+ cycles, events = decode_slot(frame[base:base + SLOT_STRIDE])
338
+ if not events and cycles == 0:
339
+ continue
340
+ found += 1
341
+ line = f"slot{i}:"
342
+ if cycles:
343
+ line += f" cycles={cycles}"
344
+ if i in binds:
345
+ line += " bound to: " + ", ".join(binds[i])
346
+ print(line)
347
+ ev = " ".join(f"{d}{names.get(h, f'#{h:02x}')}{'R' if r else 'P'}"
348
+ for d, h, r in events)
349
+ print(f" {ev}")
350
+ if not found:
351
+ print(" (no non-empty slots read back)")
352
+ print(f"({found} slot(s) non-empty)")
353
+ return 0
354
+
355
+
334
356
  def cmd_rgb(args):
335
357
  """Shortcut for `effect fixed-on <color>` (whole-board solid color).
336
358
 
@@ -399,22 +421,26 @@ def cmd_effect(args):
399
421
  def cmd_macro(args):
400
422
  """Bind a key to a macro that types text (flash write).
401
423
 
402
- Each macro gets its own slot, and previously created macros are re-applied.
403
- The device's current keymap is read first and used as the base, and the
404
- current lighting is kept, so neither your remaps nor your effect/color are
405
- disturbed. ``--remove-all`` unbinds every macro instead.
424
+ The device's current keymap and macro table are read back first, so
425
+ existing macros, bindings and remaps are kept with no local state file;
426
+ the current lighting is kept too. ``--remove-all`` unbinds every macro
427
+ instead.
406
428
 
407
429
  Examples:
408
430
  fizzctl macro --key CapsLock rgb
409
431
  fizzctl macro --key LAlt --delay-ms 50 --cycles 3 hello
432
+ fizzctl macro --until-released --key 2 aaaa
433
+ fizzctl macro --read # dump the on-device macro table + bindings
410
434
  fizzctl macro --remove-all
411
435
  """
412
- from . import state
413
436
  from .macro import (
414
- MODE_CYCLES, MODE_UNTIL_RELEASED, NAME_TO_HID,
415
- build_macro_frame, encode_slot, text_events,
437
+ MODE_CYCLES, MODE_UNTIL_RELEASED, MAX_SLOTS, NAME_TO_HID,
438
+ build_macro_frame, encode_slot, locate_key, slots_in_frame,
439
+ text_events,
416
440
  )
417
441
 
442
+ if args.read:
443
+ return cmd_macro_read(args)
418
444
  if args.remove_all:
419
445
  return cmd_macro_remove_all(args)
420
446
  if not args.key:
@@ -437,19 +463,6 @@ def cmd_macro(args):
437
463
  return 1
438
464
 
439
465
  mode = MODE_UNTIL_RELEASED if args.until_released else MODE_CYCLES
440
- cache = state.load()
441
- try:
442
- slot = state.alloc_slot(cache, args.key)
443
- except ValueError as e:
444
- print(e)
445
- return 1
446
- cache["slots"][str(slot)] = encode_slot(args.cycles, events).hex()
447
- cache["bindings"][args.key] = {"slot": slot, "mode": mode}
448
-
449
- if args.debug:
450
- print(f"macro: slot{slot} cycles={args.cycles} events={len(events)} "
451
- f"mode={mode:#04x} bind={args.key}")
452
-
453
466
  try:
454
467
  dev = open_device(debug=args.debug)
455
468
  except NoDeviceError:
@@ -459,25 +472,45 @@ def cmd_macro(args):
459
472
  try:
460
473
  mode_f, canvas_f, routing_f, exec_f = _live_lighting(dev, args.debug)
461
474
  base = bytearray(_live_keymap(dev, args.debug))
462
- missed = _apply_bindings(base, cache["bindings"], cache)
463
- if args.key in missed:
464
- print(f"could not find key {args.key!r} in the keymap")
465
- return 1
466
- if missed:
467
- print(f"warning: could not bind {', '.join(missed)} in the keymap")
468
- # exact capture order, but with the device's live lighting blocks
475
+ live_mf = _live_macro(dev, args.debug)
476
+ slots = slots_in_frame(live_mf)
477
+ hid = NAME_TO_HID[args.key]
478
+
479
+ off = locate_key(base, hid)
480
+ if off is None:
481
+ # already bound: the device echoes it as a `10` record, so find
482
+ # which binding this key is by its stock record instead
483
+ pos = _binding_for_hid(base, hid)
484
+ if pos is None:
485
+ print(f"could not find key {args.key!r} in the keymap")
486
+ return 1
487
+ bind_off, slot_idx = pos
488
+ else:
489
+ free = [i for i in range(MAX_SLOTS) if i not in slots]
490
+ if not free:
491
+ print(f"all {MAX_SLOTS} macro slots are in use")
492
+ return 1
493
+ bind_off, slot_idx = off, free[0]
494
+
495
+ slots[slot_idx] = encode_slot(args.cycles, events)
496
+ base[bind_off:bind_off + 4] = bytes((0x10, 0x00, mode, slot_idx))
497
+
498
+ if args.debug:
499
+ print(f"macro: slot{slot_idx} cycles={args.cycles} events={len(events)} "
500
+ f"mode={mode:#04x} bind={args.key}")
501
+
502
+ # exact capture order, but with the device's live blocks
469
503
  frames = [
470
504
  bytes.fromhex("0583b6000000"), # INIT
471
505
  mode_f, canvas_f, routing_f, # current lighting
472
- build_macro_frame(state.raw_slots(cache)), # 06 05 dc (all slots)
506
+ build_macro_frame(slots), # 06 05 dc (all slots)
473
507
  bytes(base), # 06 04 d4 (all bindings)
474
508
  exec_f, # EXEC (5AA5 commit)
475
509
  ]
476
510
  send_burst(dev, frames, handshake=False, delay_ms=args.burst_ms)
477
511
  finally:
478
512
  dev.close()
479
- state.save(cache)
480
- print(f"bound {args.key} -> macro typing {args.text!r} (slot {slot})")
513
+ print(f"bound {args.key} -> macro typing {args.text!r} (slot {slot_idx})")
481
514
  return 0
482
515
 
483
516
 
@@ -627,17 +660,21 @@ Examples:
627
660
  prs.add_argument("--delay-ms", type=int, default=30)
628
661
 
629
662
  pm = sub.add_parser("macro",
630
- help="bind a key that types TEXT, or --remove-all (flash write)",
663
+ help="bind a key that types TEXT, --remove-all, or --read (flash write)",
631
664
  description="""
632
665
  Examples:
633
666
  fizzctl macro --key CapsLock rgb
634
667
  fizzctl macro --key LAlt --delay-ms 50 --cycles 3 hello
668
+ fizzctl macro --until-released --key 2 aaaa
669
+ fizzctl macro --read # dump on-device macros + their key bindings
635
670
  fizzctl macro --remove-all
636
671
  """.rstrip(),
637
672
  formatter_class=argparse.RawDescriptionHelpFormatter)
638
673
  pm.add_argument("text", nargs="?", help="characters the macro types")
639
674
  pm.add_argument("-k", "--key",
640
675
  help="key to bind (e.g. CapsLock, LAlt, A)")
676
+ pm.add_argument("--read", action="store_true",
677
+ help="read the on-device macro table back, showing each slot's key binding")
641
678
  pm.add_argument("--remove-all", action="store_true",
642
679
  help="unbind every macro and wipe all macro slots")
643
680
  pm.add_argument("--delay-ms", type=int, default=30,
@@ -662,7 +699,7 @@ Examples:
662
699
  formatter_class=argparse.RawDescriptionHelpFormatter)
663
700
  pres.add_argument("--delay-ms", type=int, default=30)
664
701
 
665
- psudev = sub.add_parser("setup-udev", help="install 99-k617.rules + reload udev (needs root)")
702
+ prd = sub.add_parser("setup-udev", help="install 99-k617.rules + reload udev (needs root)")
666
703
 
667
704
  return p
668
705
 
@@ -676,7 +713,8 @@ def main(dev: bool = False) -> int:
676
713
  "diff": cmd_diff, "export": cmd_export, "replay": cmd_replay,
677
714
  "rgb": cmd_rgb, "effect": cmd_effect, "key": cmd_key,
678
715
  "paint": cmd_paint, "animate": cmd_animate, "keymap": cmd_keymap,
679
- "restore": cmd_restore, "macro": cmd_macro, "setup-udev": cmd_setup_udev,
716
+ "restore": cmd_restore, "macro": cmd_macro,
717
+ "setup-udev": cmd_setup_udev,
680
718
  }[args.cmd]
681
719
  return fn(args)
682
720
 
@@ -234,10 +234,19 @@ def read_lighting(dev: K617) -> list[bytes]:
234
234
 
235
235
 
236
236
  def read_keymap(dev: K617) -> bytes:
237
- """Read the device's current (base) keymap block.
237
+ """Read the device's current keymap block.
238
238
 
239
- Only the base layer comes back — macro bindings (``10`` records) are not
240
- exposed — which is why :mod:`fizzctl.state` caches them separately. The
241
- base layer is live, so it does contain your current Cfg.ini remaps.
239
+ The base layer comes back live, so it contains your current Cfg.ini
240
+ remaps. Bound macro keys are echoed back as their ``10 00 <mode> <slot>``
241
+ binding record (the original output is not recoverable from the device).
242
242
  """
243
243
  return _read_block(dev, "0584d4000000", bytes.fromhex("0604d40040"))
244
+
245
+
246
+ def read_macro(dev: K617) -> bytes:
247
+ """Read the device's macro table block.
248
+
249
+ Mirrors the keymap read (``05 84 d4``) using the macro block id ``dc``;
250
+ the live slots come back intact (hardware-verified).
251
+ """
252
+ return _read_block(dev, "0585dc000000", bytes.fromhex("0605dc0040"))
@@ -80,6 +80,32 @@ def build_macro_frame(slots: dict[int, bytes]) -> bytes:
80
80
  return bytes(frame)
81
81
 
82
82
 
83
+ def decode_slot(slot: bytes) -> tuple[int, list[tuple[int, int, bool]]]:
84
+ """Decode a 128-byte slot into ``(cycles, events)``.
85
+
86
+ ``events`` are ``(delay_ms, hid, release)`` triples in on-wire order.
87
+ """
88
+ cycles = slot[0]
89
+ events = []
90
+ off = 1
91
+ while off + 1 < len(slot) and slot[off] != 0:
92
+ b0 = slot[off]
93
+ events.append((b0 & MAX_DELAY_MS, slot[off + 1], bool(b0 & 0x80)))
94
+ off += 2
95
+ return cycles, events
96
+
97
+
98
+ def decode_macro_frame(frame: bytes) -> dict[int, tuple[int, list[tuple[int, int, bool]]]]:
99
+ """Decode every non-empty slot of a 1032-byte macro frame."""
100
+ slots = {}
101
+ for i in range(MAX_SLOTS):
102
+ base = SLOT_BASE + i * SLOT_STRIDE
103
+ slot = frame[base:base + SLOT_STRIDE]
104
+ if any(slot):
105
+ slots[i] = decode_slot(slot)
106
+ return slots
107
+
108
+
83
109
  def encode_macro_frame(slots: list[tuple[int, list[bytes]]]) -> bytes:
84
110
  """Build the 1032-byte ``06 05 dc`` frame.
85
111
 
@@ -140,24 +166,99 @@ def bind_macro(keymap: bytearray, hid: int, slot: int,
140
166
  return off
141
167
 
142
168
 
143
- def find_binding(keymap, slot: int, mode: int = MODE_CYCLES) -> int | None:
144
- """Offset of a ``10 00 <mode> <slot>`` binding already in ``keymap``.
169
+ def collect_bindings(keymap) -> list[tuple[int, int, int]]:
170
+ """Every macro binding in ``keymap`` as ``(offset, mode, slot)``.
145
171
 
146
- The device echoes macro bindings back as ``10`` records instead of the
147
- key's base output, so a read-back keymap can contain the binding even
148
- though :func:`bind_macro` cannot rediscover it. Used to treat an existing
149
- binding as "already applied" rather than a failed re-apply.
172
+ The device echoes live macro bindings back as ``10 00 <mode> <slot>``
173
+ records, so a read-back keymap contains them. Region B (FN keys) is
174
+ scanned before the column records, matching :func:`locate_key`'s order.
150
175
  """
151
- rec = bytes((ACTION_MACRO, 0x00, mode & 0xFF, slot & 0xFF))
176
+ out: list[tuple[int, int, int]] = []
152
177
  for start, count in (
153
178
  (REGION_B_BASE, (REGION_B_END - REGION_B_BASE) // 4),
154
179
  (REGION_A_BASE, (REGION_B_BASE - REGION_A_BASE) // 4),
155
180
  ):
156
181
  for pos in range(count):
157
182
  off = start + pos * 4
158
- if keymap[off:off + 4] == rec:
159
- return off
160
- return None
183
+ if keymap[off] == ACTION_MACRO:
184
+ out.append((off, keymap[off + 2], keymap[off + 3]))
185
+ return out
186
+
187
+
188
+ def slots_in_frame(frame: bytes) -> dict[int, bytes]:
189
+ """The non-empty slots of a macro frame as ``{index: 128-byte slot}``."""
190
+ slots: dict[int, bytes] = {}
191
+ for i in range(MAX_SLOTS):
192
+ base = SLOT_BASE + i * SLOT_STRIDE
193
+ slot = frame[base:base + SLOT_STRIDE]
194
+ if any(slot):
195
+ slots[i] = slot
196
+ return slots
197
+
198
+
199
+ def relocate_bindings(live: bytes, target: bytearray,
200
+ oracle: bytes | None = None) -> tuple[bytes, list[str]]:
201
+ """Re-apply the macro bindings read from ``live`` onto ``target``.
202
+
203
+ Bindings are matched to their **physical key** by the live layout, then
204
+ written at that key's position in ``target``. Layouts are free to move
205
+ keys (e.g. an ``Alt`` that is an FN-layered key at offset 660 in one
206
+ layout and a plain column-17 key at offset 76 in another); copying raw
207
+ offsets would drop the binding onto a different key.
208
+
209
+ A binding below the FN area (``< 0x218``) is a plain key, its column is
210
+ the offset's own column. A binding in the FN area is the key whose column
211
+ record points at that FN slot (``02 00 00 <slot>``). If the live layout
212
+ can't identify the key (an orphan binding left behind by an earlier
213
+ layout—nothing points at its FN slot), ``oracle`` (a captured keymap, e.g.
214
+ ``CONST_KEYMAP``) is consulted at the same offset and the key located in
215
+ ``target`` by its HID.
216
+ """
217
+ target = bytearray(target)
218
+ warnings: list[str] = []
219
+ n = 0
220
+ for off, mode, slot in collect_bindings(live):
221
+ if off < REGION_B_BASE:
222
+ col = (off - REGION_A_BASE) // 4
223
+ else:
224
+ fp = (off - REGION_B_BASE) // 4
225
+ cols = [
226
+ c for c in range((REGION_B_BASE - REGION_A_BASE) // 4)
227
+ if live[REGION_A_BASE + c * 4] == 0x02
228
+ and live[REGION_A_BASE + c * 4 + 3] == fp
229
+ ]
230
+ col = cols[0] if len(cols) == 1 else None
231
+ if col is not None:
232
+ rec_off = REGION_A_BASE + col * 4
233
+ kind = target[rec_off]
234
+ if kind == 0x02: # FN-layered key here too -> follow it
235
+ dst = REGION_B_BASE + target[rec_off + 3] * 4
236
+ elif kind in (0x00, 0x06): # plain key -> its own column record
237
+ dst = rec_off
238
+ else:
239
+ warnings.append(
240
+ f"macro binding @+{off:#06x}: column {col} is not bindable in this "
241
+ f"keymap (record {target[rec_off:rec_off + 4].hex(' ')})")
242
+ continue
243
+ elif oracle is not None:
244
+ rec = oracle[off:off + 4]
245
+ if rec[0] in (0x00, 0x06):
246
+ dst = locate_key(target, rec[3])
247
+ warnings.append(
248
+ f"macro binding @+{off:#06x}: located as key "
249
+ f"0x{rec[3]:02x} (moved from @+{off:#06x} to @+{dst:#06x})" if dst is not None
250
+ else f"macro binding @+{off:#06x}: cannot identify its key")
251
+ if dst is None:
252
+ continue
253
+ else:
254
+ warnings.append(f"macro binding @+{off:#06x}: cannot identify its key")
255
+ continue
256
+ else:
257
+ warnings.append(f"macro binding @+{off:#06x}: cannot identify its key")
258
+ continue
259
+ target[dst:dst + 4] = bytes((ACTION_MACRO, 0x00, mode & 0xFF, slot & 0xFF))
260
+ n += 1
261
+ return bytes(target), warnings
161
262
 
162
263
 
163
264
  # --------------------------------------------------------------------------
@@ -1,59 +0,0 @@
1
- """Local cache of fizzctl-created macros and their key bindings.
2
-
3
- The K617 only exposes the **base** keymap (``05 84 d4``) and a **factory**
4
- macro table (``05 85 dc``): macro bindings (the ``10 00 <mode> <slot>``
5
- records) and the user's macro slots live in an overlay the device does not
6
- return. Reading the base keymap therefore never contains them, so we remember
7
- what we created here and re-apply it on every keymap/macro write.
8
-
9
- Keymaps are *not* cached — the base keymap is read straight off the device, so
10
- binding a macro keeps your existing Cfg.ini remaps.
11
- """
12
- from __future__ import annotations
13
-
14
- import json
15
- import os
16
- from pathlib import Path
17
-
18
- from .macro import MAX_SLOTS
19
-
20
-
21
- def state_path() -> Path:
22
- base = os.environ.get("XDG_STATE_HOME") or os.path.join(Path.home(), ".local", "state")
23
- return Path(base) / "fizzctl" / "state.json"
24
-
25
-
26
- def load() -> dict:
27
- """Load the macro cache; a missing or corrupt file yields an empty cache."""
28
- try:
29
- data = json.loads(state_path().read_text())
30
- except (OSError, ValueError):
31
- data = {}
32
- if not isinstance(data, dict):
33
- data = {}
34
- data.setdefault("slots", {})
35
- data.setdefault("bindings", {})
36
- return data
37
-
38
-
39
- def save(state: dict) -> None:
40
- path = state_path()
41
- path.parent.mkdir(parents=True, exist_ok=True)
42
- path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
43
-
44
-
45
- def alloc_slot(state: dict, key: str) -> int:
46
- """Slot for ``key``: its existing one, else the lowest free slot index."""
47
- existing = state["bindings"].get(key)
48
- if existing is not None:
49
- return int(existing["slot"])
50
- used = {int(slot) for slot in state["slots"]}
51
- for i in range(MAX_SLOTS):
52
- if i not in used:
53
- return i
54
- raise ValueError(f"all {MAX_SLOTS} macro slots are in use")
55
-
56
-
57
- def raw_slots(state: dict) -> dict[int, bytes]:
58
- """The cached slots as ``{index: 128-byte slot}``."""
59
- return {int(slot): bytes.fromhex(hex_) for slot, hex_ in state["slots"].items()}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes