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.
- {fizzctl-0.3.0 → fizzctl-0.4.0}/PKG-INFO +13 -8
- {fizzctl-0.3.0 → fizzctl-0.4.0}/README.md +11 -6
- {fizzctl-0.3.0 → fizzctl-0.4.0}/pyproject.toml +2 -2
- {fizzctl-0.3.0 → fizzctl-0.4.0}/pyproject.toml.orig +2 -2
- {fizzctl-0.3.0 → fizzctl-0.4.0}/src/fizzctl/__init__.py +1 -1
- {fizzctl-0.3.0 → fizzctl-0.4.0}/src/fizzctl/blobs.py +3 -2
- {fizzctl-0.3.0 → fizzctl-0.4.0}/src/fizzctl/cli.py +157 -119
- {fizzctl-0.3.0 → fizzctl-0.4.0}/src/fizzctl/hid.py +13 -4
- {fizzctl-0.3.0 → fizzctl-0.4.0}/src/fizzctl/macro.py +111 -10
- fizzctl-0.3.0/src/fizzctl/state.py +0 -59
- {fizzctl-0.3.0 → fizzctl-0.4.0}/src/fizzctl/animations.py +0 -0
- {fizzctl-0.3.0 → fizzctl-0.4.0}/src/fizzctl/capture.py +0 -0
- {fizzctl-0.3.0 → fizzctl-0.4.0}/src/fizzctl/cfg.py +0 -0
- {fizzctl-0.3.0 → fizzctl-0.4.0}/src/fizzctl/effects.py +0 -0
- {fizzctl-0.3.0 → fizzctl-0.4.0}/src/fizzctl/keymap.py +0 -0
- {fizzctl-0.3.0 → fizzctl-0.4.0}/src/fizzctl/protocol.py +0 -0
- {fizzctl-0.3.0 → fizzctl-0.4.0}/src/fizzctl/stock.ini +0 -0
- {fizzctl-0.3.0 → fizzctl-0.4.0}/src/fizzctl/udev_rules.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: fizzctl
|
|
3
|
-
Version: 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
|
-
|
|
24
|
-
|
|
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
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
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.
|
|
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,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.
|
|
11
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
(effect, color, brightness)
|
|
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
|
|
247
|
-
from .macro import
|
|
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
|
|
276
|
-
frames.append(
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
current lighting is kept
|
|
405
|
-
|
|
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,
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
237
|
+
"""Read the device's current keymap block.
|
|
238
238
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
144
|
-
"""
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
return
|
|
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
|
|
File without changes
|
|
File without changes
|