fizzctl 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fizzctl/__init__.py +10 -0
- fizzctl/animations.py +145 -0
- fizzctl/blobs.py +353 -0
- fizzctl/capture.py +214 -0
- fizzctl/cfg.py +118 -0
- fizzctl/cli.py +409 -0
- fizzctl/effects.py +223 -0
- fizzctl/hid.py +255 -0
- fizzctl/keymap.py +178 -0
- fizzctl/protocol.py +97 -0
- fizzctl/udev_rules.py +89 -0
- fizzctl-0.2.0.dist-info/METADATA +179 -0
- fizzctl-0.2.0.dist-info/RECORD +15 -0
- fizzctl-0.2.0.dist-info/WHEEL +4 -0
- fizzctl-0.2.0.dist-info/entry_points.txt +4 -0
fizzctl/hid.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Thin hidapi wrapper for the K617 vendor interface (interface 1).
|
|
2
|
+
|
|
3
|
+
The K617 exposes two HID interfaces:
|
|
4
|
+
* interface 0 — boot keyboard / media keys (standard)
|
|
5
|
+
* interface 1 — vendor-defined usage page 0xFF00, holds the feature
|
|
6
|
+
reports used by every protocol (report IDs 0x05/0x06/0x08).
|
|
7
|
+
|
|
8
|
+
We always talk to interface 1 via hidapi's `send_feature_report` /
|
|
9
|
+
`get_feature_report`. These map to USB SET_REPORT / GET_REPORT control
|
|
10
|
+
transfers — the exact URBs you will see in USBPcap captures.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
import hid
|
|
18
|
+
|
|
19
|
+
from .protocol import PID, VID
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NoDeviceError(Exception):
|
|
23
|
+
"""The K617 vendor interface could not be found or opened."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UdevRequiredError(NoDeviceError):
|
|
27
|
+
"""The HID node exists but cannot be opened — udev rules are missing."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def udev_rules_installed() -> bool:
|
|
31
|
+
return os.path.exists("/etc/udev/rules.d/99-k617.rules")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class K617:
|
|
35
|
+
def __init__(self, debug: bool = False):
|
|
36
|
+
self.debug = debug
|
|
37
|
+
self._dev = self._open()
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _open():
|
|
41
|
+
try:
|
|
42
|
+
uid = os.geteuid()
|
|
43
|
+
except AttributeError:
|
|
44
|
+
uid = None
|
|
45
|
+
paths = []
|
|
46
|
+
for d in hid.enumerate(VID, PID):
|
|
47
|
+
iface = int(d.get("interface_number") or 0)
|
|
48
|
+
if iface == 1:
|
|
49
|
+
paths.append(d["path"])
|
|
50
|
+
if not paths:
|
|
51
|
+
msg = (
|
|
52
|
+
"Could not find your K617 keyboard. Make sure it is "
|
|
53
|
+
"plugged in (and in wireless mode if it supports it), "
|
|
54
|
+
"then try again."
|
|
55
|
+
)
|
|
56
|
+
if uid != 0:
|
|
57
|
+
msg += (
|
|
58
|
+
"\nIf it is plugged in, your user may lack permission "
|
|
59
|
+
"to see it — try running `fizzctl setup-udev` first."
|
|
60
|
+
)
|
|
61
|
+
raise NoDeviceError(msg)
|
|
62
|
+
path = paths[0]
|
|
63
|
+
display = path.decode(errors="replace") if isinstance(path, bytes) else str(path)
|
|
64
|
+
try:
|
|
65
|
+
dev = hid.Device(path=path) if hasattr(hid, "Device") else \
|
|
66
|
+
K617._open_ctypes(path)
|
|
67
|
+
return dev
|
|
68
|
+
except Exception as e:
|
|
69
|
+
if uid != 0 and not udev_rules_installed():
|
|
70
|
+
raise UdevRequiredError(
|
|
71
|
+
f"Found your K617 but it could not be opened ({display}).\n"
|
|
72
|
+
"The udev rules that let you access the keyboard without "
|
|
73
|
+
"sudo are not installed."
|
|
74
|
+
) from e
|
|
75
|
+
if uid != 0:
|
|
76
|
+
raise NoDeviceError(
|
|
77
|
+
f"Found your K617 but it could not be opened ({display}: {e}). "
|
|
78
|
+
"Your user lacks permission to access it. Try running "
|
|
79
|
+
"`fizzctl setup-udev` (or unplug/replug the keyboard), "
|
|
80
|
+
"then rerun this command."
|
|
81
|
+
) from e
|
|
82
|
+
raise NoDeviceError(
|
|
83
|
+
f"Found your K617 but it could not be opened ({display}: {e}). "
|
|
84
|
+
"Is another program controlling the keyboard right now?"
|
|
85
|
+
) from e
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _open_ctypes(path):
|
|
89
|
+
"""Open via the trezor 'hid' ctypes module (hid.device/open_path)."""
|
|
90
|
+
dev = hid.device()
|
|
91
|
+
dev.open_path(path) # accepts str or bytes
|
|
92
|
+
return dev
|
|
93
|
+
|
|
94
|
+
def send_feature(self, data: bytes) -> None:
|
|
95
|
+
"""Send an arbitrary feature report (bytes includes the report ID)."""
|
|
96
|
+
self._dev.send_feature_report(data)
|
|
97
|
+
|
|
98
|
+
def get_feature(self, report_id: int, size: int) -> bytes:
|
|
99
|
+
"""Read a feature report (returns exactly `size` bytes on success)."""
|
|
100
|
+
raw = self._dev.get_feature_report(report_id, size)
|
|
101
|
+
return bytes(raw) if raw is not None else bytes(size)
|
|
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
|
+
def close(self) -> None:
|
|
115
|
+
if self._dev is not None:
|
|
116
|
+
try:
|
|
117
|
+
self._dev.close()
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def open_device(debug: bool = False) -> K617 | None:
|
|
123
|
+
"""Open the K617, prompting to install udev rules when access is denied.
|
|
124
|
+
|
|
125
|
+
Returns the device, or None if it could not be opened (the reason is
|
|
126
|
+
already printed). When the rules are missing the user is asked whether
|
|
127
|
+
to install them now, then the open is retried.
|
|
128
|
+
"""
|
|
129
|
+
from .udev_rules import install_udev_rules
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
return K617(debug=debug)
|
|
133
|
+
except UdevRequiredError as e:
|
|
134
|
+
print(f"Trouble opening your K617: {e}")
|
|
135
|
+
try:
|
|
136
|
+
answer = input("Install the udev rules now? [Y/n] ").strip().lower()
|
|
137
|
+
except EOFError:
|
|
138
|
+
answer = "y"
|
|
139
|
+
if answer not in ("", "y", "yes"):
|
|
140
|
+
print("Skipped. You can install them later with `fizzctl setup-udev`.")
|
|
141
|
+
return None
|
|
142
|
+
print("Installing udev rules (you may be asked for your password)...")
|
|
143
|
+
try:
|
|
144
|
+
install_udev_rules()
|
|
145
|
+
except Exception as ie:
|
|
146
|
+
print(f"error: could not install udev rules: {ie}")
|
|
147
|
+
return None
|
|
148
|
+
print("udev rules installed. Reopening the keyboard...")
|
|
149
|
+
last_err = None
|
|
150
|
+
for _ in range(8): # udev permission changes settle slowly
|
|
151
|
+
try:
|
|
152
|
+
return K617(debug=debug)
|
|
153
|
+
except NoDeviceError as e2:
|
|
154
|
+
last_err = e2
|
|
155
|
+
time.sleep(1.0)
|
|
156
|
+
print(f"error: {last_err}")
|
|
157
|
+
print("Hint: unplug and replug the keyboard, then run the command again.")
|
|
158
|
+
return None
|
|
159
|
+
except NoDeviceError as e:
|
|
160
|
+
print(e)
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _kind(frame: bytes) -> str:
|
|
165
|
+
from .protocol import frame_kind
|
|
166
|
+
|
|
167
|
+
return frame_kind(frame)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def rgb_sequence(led_colors: dict[int, tuple[int, int, int]]) -> list[bytes]:
|
|
171
|
+
"""Build the RGB-write payloads: [INIT, P1(no SEC yet), P2, EXEC].
|
|
172
|
+
|
|
173
|
+
led_colors maps LED index -> (r, g, b). Led indices live in
|
|
174
|
+
protocol.LED_INDEX.
|
|
175
|
+
|
|
176
|
+
NOTE: the EXEC frame commits to flash (5AA5 magic). Do not loop this.
|
|
177
|
+
"""
|
|
178
|
+
from .blobs import RGB_EXEC, RGB_INIT, RGB_SEC
|
|
179
|
+
from .protocol import NAME_TO_INDEX, RESTORE_CONSTANT_FRAMES, set_key_color
|
|
180
|
+
|
|
181
|
+
canvas = bytearray(1032)
|
|
182
|
+
canvas[0:5] = bytes.fromhex("0609bc0040")
|
|
183
|
+
for idx, rgb in led_colors.items():
|
|
184
|
+
if isinstance(idx, str):
|
|
185
|
+
idx = NAME_TO_INDEX[idx]
|
|
186
|
+
set_key_color(canvas, idx, rgb)
|
|
187
|
+
canvas[660:660 + len(RGB_SEC)] = RGB_SEC
|
|
188
|
+
p2 = RESTORE_CONSTANT_FRAMES[2] # routing, never modify
|
|
189
|
+
return [bytes(RGB_INIT), bytes(canvas), p2, RGB_EXEC]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def rgb_all(color: tuple[int, int, int]) -> list[bytes]:
|
|
193
|
+
from .protocol import LED_INDEX
|
|
194
|
+
|
|
195
|
+
return rgb_sequence({idx: color for idx in LED_INDEX})
|
|
196
|
+
|
|
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).
|
|
234
|
+
"""
|
|
235
|
+
from time import sleep
|
|
236
|
+
|
|
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)
|
|
248
|
+
if dev.debug:
|
|
249
|
+
print(f" [{i}/5] {_kind(block)}")
|
|
250
|
+
sleep(0.06)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def send_per_key(dev: K617, frame: bytes) -> None:
|
|
254
|
+
"""Send a single 382-byte per-key report (no handshake needed)."""
|
|
255
|
+
dev._dev.send_feature_report(frame)
|
fizzctl/keymap.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""K617 KEYMAP block (06 04 d4) encoder.
|
|
2
|
+
|
|
3
|
+
The keymap block is 1032 bytes = 8-byte command header +
|
|
4
|
+
256 four-byte records. Records are indexed by MATRIX COLUMN and
|
|
5
|
+
hold, for each physical key, what the key SENDS as a USB HID usage code.
|
|
6
|
+
|
|
7
|
+
Layout (all offsets relative to block start):
|
|
8
|
+
+0x00 header 06 04 d4 00 40 00 00 00
|
|
9
|
+
+0x08 col records 256 slots * 4B; slot n holds the record for the
|
|
10
|
+
matrix column n (slot = column index, 0-based:
|
|
11
|
+
offset = 8 + col*4). Content by key type:
|
|
12
|
+
normal key -> 00 00 00 <HID>
|
|
13
|
+
modifier -> 06 00 00 <HID>
|
|
14
|
+
has FN layer -> 02 00 00 <FN index>
|
|
15
|
+
Fn key -> 20 00 00 00
|
|
16
|
+
unassigned -> 00 00 00 00
|
|
17
|
+
+0x218 region B FN keys' BASE-layer output, in row-major physical
|
|
18
|
+
order (top row -> bottom row, left -> right):
|
|
19
|
+
normal -> 00 00 00 <HID> modifier -> 06 00 00 <HID>
|
|
20
|
+
+0x2d8 region C FN keys' FN-layer output, same order as region B:
|
|
21
|
+
normal FN -> 00 00 00 <HID>
|
|
22
|
+
media FN -> 04 00 00 <media code>
|
|
23
|
+
special -> 4B big-endian value (type 9 fns)
|
|
24
|
+
|
|
25
|
+
The FN index stored in the col records is the 0-based position of that
|
|
26
|
+
key in the region-B/C row-major list.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
HEADER = bytes.fromhex("0604d40040000000")
|
|
32
|
+
|
|
33
|
+
# Windows VK code -> USB HID keyboard usage code, for the K617's key set.
|
|
34
|
+
VK2HID = {}
|
|
35
|
+
VK2HID[0x30] = 0x27 # 0
|
|
36
|
+
VK2HID.update({0x31 + i: 0x1e + i for i in range(9)}) # 1-9 -> a-row
|
|
37
|
+
VK2HID.update({ord('A') + i: 0x04 + i for i in range(26)}) # A-Z
|
|
38
|
+
VK2HID.update({0x70 + i: 0x3a + i for i in range(12)}) # F1-F12
|
|
39
|
+
VK2HID.update({
|
|
40
|
+
0x08: 0x2a, # backspace
|
|
41
|
+
0x09: 0x2b, # tab
|
|
42
|
+
0x0d: 0x28, # enter
|
|
43
|
+
0x14: 0x39, # capslock
|
|
44
|
+
0x1b: 0x29, # esc
|
|
45
|
+
0x20: 0x2c, # space
|
|
46
|
+
0x21: 0x4b, # pageup
|
|
47
|
+
0x22: 0x4e, # pagedown
|
|
48
|
+
0x23: 0x4d, # end
|
|
49
|
+
0x24: 0x4a, # home
|
|
50
|
+
0x25: 0x50, # left
|
|
51
|
+
0x26: 0x52, # up
|
|
52
|
+
0x27: 0x4f, # right
|
|
53
|
+
0x28: 0x51, # down
|
|
54
|
+
0x2d: 0x49, # insert
|
|
55
|
+
0x2e: 0x4c, # delete
|
|
56
|
+
0x2c: 0x46, # print screen
|
|
57
|
+
0x5b: 0xe3, # lwin
|
|
58
|
+
0x5d: 0x65, # rwin (stored as a normal key, not a modifier)
|
|
59
|
+
0xa0: 0xe1, # lshift
|
|
60
|
+
0xa1: 0xe5, # rshift
|
|
61
|
+
0xa2: 0xe0, # lctrl
|
|
62
|
+
0xa3: 0xe4, # rctrl
|
|
63
|
+
0xa4: 0xe2, # lalt
|
|
64
|
+
0xa5: 0xe6, # ralt
|
|
65
|
+
0xa6: 0xe3, # lwin
|
|
66
|
+
0xba: 0x33, # ;
|
|
67
|
+
0xbb: 0x2e, # =
|
|
68
|
+
0xbc: 0x36, # ,
|
|
69
|
+
0xbd: 0x2d, # -
|
|
70
|
+
0xbe: 0x37, # .
|
|
71
|
+
0xbf: 0x38, # /
|
|
72
|
+
0xc0: 0x35, # `
|
|
73
|
+
0xdb: 0x2f, # [
|
|
74
|
+
0xdc: 0x31, # backslash
|
|
75
|
+
0xdd: 0x30, # ]
|
|
76
|
+
0xde: 0x34, # '
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
REGION_B_BASE = 0x218 # FN keys' base-layer outputs
|
|
80
|
+
REGION_C_BASE = 0x2d8 # FN keys' fn-layer outputs
|
|
81
|
+
N_COLS = 256
|
|
82
|
+
N_ROWS = 5 # physical keyboard rows of the 60% layout
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class MediaCode:
|
|
87
|
+
"""Maps a cfg FN media type-4 code to the on-wire media byte."""
|
|
88
|
+
|
|
89
|
+
cfg_code: int
|
|
90
|
+
wire: int
|
|
91
|
+
name: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# type-4 FN media codes. The on-wire byte is the USB HID Consumer Page
|
|
95
|
+
# usage code (Play/Pause confirmed by capture cfg_r4: cfg 0x22 -> 0xcd,
|
|
96
|
+
# which is consumer usage 0x00cd). The rest follow the same table.
|
|
97
|
+
MEDIA_CODES = {
|
|
98
|
+
0x22: MediaCode(0x22, 0xcd, "play/pause"), # captured in cfg_r4
|
|
99
|
+
0x26: MediaCode(0x26, 0xe9, "vol+"),
|
|
100
|
+
0x27: MediaCode(0x27, 0xea, "vol-"),
|
|
101
|
+
0x28: MediaCode(0x28, 0xe2, "mute"),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class KeymapEncoder:
|
|
106
|
+
"""Builds a 1032-byte 06 04 d4 block from a CfgIni."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, cfg, vk2hid=None):
|
|
109
|
+
self.cfg = cfg
|
|
110
|
+
self.vk2hid = vk2hid if vk2hid is not None else VK2HID
|
|
111
|
+
self.b = bytearray(1032)
|
|
112
|
+
self.b[0:8] = HEADER
|
|
113
|
+
|
|
114
|
+
def _is_mod(self, vk):
|
|
115
|
+
return 0xA0 <= vk <= 0xA6 or vk == 0x5B
|
|
116
|
+
|
|
117
|
+
def _row_major_fn_keys(self):
|
|
118
|
+
# order by row (geom y, bucketed by ~36px spacing), then by geom x
|
|
119
|
+
def key(i):
|
|
120
|
+
k = self.cfg.keys[i]
|
|
121
|
+
return (k.geom[1] // 36, k.geom[0])
|
|
122
|
+
|
|
123
|
+
return sorted(
|
|
124
|
+
(idx for idx in self.cfg.fn if idx in self.cfg.keys), key=key
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def build(self):
|
|
128
|
+
fn_keys = self._row_major_fn_keys()
|
|
129
|
+
fn_index = {idx: pos for pos, idx in enumerate(fn_keys)}
|
|
130
|
+
|
|
131
|
+
# region A: col records, written directly to self.b
|
|
132
|
+
cols = {k.matrix[0]: k for k in (self.cfg.keys[i] for i in self.cfg.keys)}
|
|
133
|
+
for col in range(N_COLS):
|
|
134
|
+
off = 8 + col * 4
|
|
135
|
+
key = cols.get(col)
|
|
136
|
+
if key is None:
|
|
137
|
+
rec = b"\x00\x00\x00\x00"
|
|
138
|
+
else:
|
|
139
|
+
vk = key.behavior[1]
|
|
140
|
+
if vk == 0xFA: # Fn key
|
|
141
|
+
rec = b"\x20\x00\x00\x00"
|
|
142
|
+
elif key.index in fn_index: # has FN layer
|
|
143
|
+
rec = bytes([2, 0, 0, fn_index[key.index]])
|
|
144
|
+
elif self._is_mod(vk):
|
|
145
|
+
rec = bytes([6, 0, 0, self._hid(vk)])
|
|
146
|
+
else:
|
|
147
|
+
rec = bytes([0, 0, 0, self._hid(vk)])
|
|
148
|
+
self.b[off:off + 4] = rec
|
|
149
|
+
|
|
150
|
+
# region B/C in the same row-major order
|
|
151
|
+
for pos, idx in enumerate(fn_keys):
|
|
152
|
+
key = self.cfg.keys[idx]
|
|
153
|
+
vk = key.behavior[1]
|
|
154
|
+
b_off = REGION_B_BASE + pos * 4
|
|
155
|
+
c_off = REGION_C_BASE + pos * 4
|
|
156
|
+
base = bytes([6 if self._is_mod(vk) else 0, 0, 0, self._hid(vk)])
|
|
157
|
+
self.b[b_off:b_off + 4] = base
|
|
158
|
+
self.b[c_off:c_off + 4] = self._fn_output(idx)
|
|
159
|
+
|
|
160
|
+
return bytes(self.b)
|
|
161
|
+
|
|
162
|
+
def _hid(self, vk):
|
|
163
|
+
return self.vk2hid.get(vk, 0)
|
|
164
|
+
|
|
165
|
+
def _fn_output(self, key_idx):
|
|
166
|
+
"""4-byte region-C entry for key_idx's FN function."""
|
|
167
|
+
t, code, extra = self.cfg.fn[key_idx]
|
|
168
|
+
if t == 4: # media key
|
|
169
|
+
wire = self._media_wire(code)
|
|
170
|
+
return bytes([4, 0, 0, wire])
|
|
171
|
+
if t == 9: # special function: packed value
|
|
172
|
+
return extra.to_bytes(4, "big")
|
|
173
|
+
# t == 2 -> normal keycode (VK code) -> HID usage
|
|
174
|
+
return bytes([0, 0, 0, self._hid(code)])
|
|
175
|
+
|
|
176
|
+
def _media_wire(self, code):
|
|
177
|
+
m = MEDIA_CODES.get(code)
|
|
178
|
+
return m.wire if m else 0
|
fizzctl/protocol.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Device identity, report IDs and known-good frame knowledge for the K617 Fizz.
|
|
2
|
+
|
|
3
|
+
Nothing in this module talks to hardware; it's pure constants + helpers.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from .blobs import CONST_CANVAS, CONST_EXEC, CONST_MODE, CONST_ROUTING, FW_FRAMES
|
|
8
|
+
|
|
9
|
+
VID = 0x258A
|
|
10
|
+
PID = 0x0049
|
|
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
|
+
# ---- known-good packet interiors (inlined in blobs.py from captures) ----
|
|
21
|
+
# Index in the static-effect template (FW_FRAMES):
|
|
22
|
+
# 0 = INIT (05 83 b6 00 00 00)
|
|
23
|
+
# 1 = mode/config (06 08 b8 00 40 ...)
|
|
24
|
+
# 2 = RGB canvas base (06 09 bc 00 40 ...)
|
|
25
|
+
# 3 = routing (06 09 c0 00 40 ...) — same family as P2, never hand-edit
|
|
26
|
+
# 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
|
+
|
|
29
|
+
# Constant frames of the Restore sequence (06 xx xx 00 40 ...), 1032 bytes each.
|
|
30
|
+
RESTORE_CONSTANT_FRAMES: list[bytes] = [
|
|
31
|
+
CONST_MODE, # 06 08 b8 00 40
|
|
32
|
+
CONST_CANVAS, # 06 09 bc 00 40
|
|
33
|
+
CONST_ROUTING, # 06 09 c0 00 40
|
|
34
|
+
CONST_EXEC, # 06 03 b6 ... 5AA5 commit
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
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]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def frame_kind(frame: bytes) -> str:
|
|
48
|
+
"""Identify a known frame / report by its lead bytes."""
|
|
49
|
+
if not frame:
|
|
50
|
+
return "empty"
|
|
51
|
+
rid = frame[0]
|
|
52
|
+
if rid == 0x05:
|
|
53
|
+
return "INIT(05)"
|
|
54
|
+
if rid == 0x06:
|
|
55
|
+
head = frame[1:5]
|
|
56
|
+
if head == bytes.fromhex("08b80040"):
|
|
57
|
+
return "MODE(06 08 b8)"
|
|
58
|
+
if head == bytes.fromhex("09bc0040"):
|
|
59
|
+
return "CANVAS(06 09 bc)"
|
|
60
|
+
if head == bytes.fromhex("09c00040"):
|
|
61
|
+
return "ROUTING(06 09 c0)"
|
|
62
|
+
if head == bytes.fromhex("03b60000"):
|
|
63
|
+
return "EXEC(06 03 b6)"
|
|
64
|
+
return f"BLOCK(06 {frame[1]:02x} {frame[2]:02x})"
|
|
65
|
+
if rid == 0x08:
|
|
66
|
+
return "PERKEY(08 0a 7a 01)" if frame[1:4] == bytes.fromhex("0a7a01") else f"REP08?({frame[1]:02x})"
|
|
67
|
+
return f"report {rid:#04x}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---- known split-plane RGB layout inside the 1032-byte CANVAS block ----
|
|
71
|
+
BLUE_BASE = 8
|
|
72
|
+
GREEN_BASE = 134
|
|
73
|
+
RED_BASE = 260
|
|
74
|
+
|
|
75
|
+
# LED index map (LED index -> key), rows with 14 data + 7 gap, stride 21.
|
|
76
|
+
LED_INDEX = {
|
|
77
|
+
21: "Esc", 22: "1", 23: "2", 24: "3", 25: "4", 26: "5", 27: "6",
|
|
78
|
+
28: "7", 29: "8", 30: "9", 31: "0", 32: "-", 33: "=", 34: "Bksp",
|
|
79
|
+
42: "Tab", 43: "Q", 44: "W", 45: "E", 46: "R", 47: "T", 48: "Y",
|
|
80
|
+
49: "U", 50: "I", 51: "O", 52: "P", 53: "[", 54: "]", 55: "\\",
|
|
81
|
+
63: "CapsLk", 64: "A", 65: "S", 66: "D", 67: "F", 68: "G", 69: "H",
|
|
82
|
+
70: "J", 71: "K", 72: "L", 73: ";", 74: "'", 76: "Enter",
|
|
83
|
+
84: "LShift", 86: "Z", 87: "X", 88: "C", 89: "V", 90: "B", 91: "N",
|
|
84
|
+
92: "M", 93: ",", 94: ".", 95: "/", 97: "RShift",
|
|
85
|
+
105: "LCtrl", 106: "LWin", 107: "LAlt", 110: "Space", 113: "RAlt",
|
|
86
|
+
114: "Fn", 117: "Menu", 118: "RCtrl",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
NAME_TO_INDEX = {name: idx for idx, name in LED_INDEX.items()}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def set_key_color(frame: bytearray, led_index: int, rgb: tuple[int, int, int]) -> None:
|
|
93
|
+
"""Patch one key's RGB into a 1032-byte CANVAS frame (in place)."""
|
|
94
|
+
r, g, b = rgb
|
|
95
|
+
frame[RED_BASE + led_index] = r
|
|
96
|
+
frame[GREEN_BASE + led_index] = g
|
|
97
|
+
frame[BLUE_BASE + led_index] = b
|
fizzctl/udev_rules.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Udev rules for the Redragon K617 Fizz (VID 258a, PID 0049).
|
|
2
|
+
|
|
3
|
+
The rules grant the current user access to the vendor HID interface(s) via the
|
|
4
|
+
``uaccess`` tag (systemd-logind) and a permissive mode so ``fizzctl`` works
|
|
5
|
+
without root. Install with ``fizzctl setup-udev`` — the command escalates
|
|
6
|
+
its own privileged steps via ``sudo`` (prompting for a password if needed),
|
|
7
|
+
so you do not need ``sudo fizzctl`` itself.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
|
|
16
|
+
UDEV_DIR = "/etc/udev/rules.d"
|
|
17
|
+
UDEV_FILE = "99-k617.rules"
|
|
18
|
+
|
|
19
|
+
RULES = """\
|
|
20
|
+
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="258a", ATTRS{idProduct}=="0049", MODE="0666", TAG+="uaccess"
|
|
21
|
+
SUBSYSTEM=="usb", ATTRS{idVendor}=="258a", ATTRS{idProduct}=="0049", MODE="0666", TAG+="uaccess"
|
|
22
|
+
SUBSYSTEM=="input", ATTRS{idVendor}=="258a", ATTRS{idProduct}=="0049", MODE="0660", TAG+="uaccess"
|
|
23
|
+
KERNEL=="event*", ATTRS{idVendor}=="258a", ATTRS{idProduct}=="0049", MODE="0660", TAG+="uaccess"
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_root() -> bool:
|
|
28
|
+
return hasattr(os, "geteuid") and os.geteuid() == 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _reload_udev() -> None:
|
|
32
|
+
"""Reload udev rules and re-trigger device events (needs root)."""
|
|
33
|
+
res = subprocess.run(["udevadm", "control", "--reload-rules"], capture_output=True, text=True)
|
|
34
|
+
if res.returncode != 0:
|
|
35
|
+
print(f"warning: udevadm reload failed ({res.stderr.strip() or res.returncode})")
|
|
36
|
+
print("unplug/replug the keyboard, or run: sudo udevadm trigger")
|
|
37
|
+
return
|
|
38
|
+
subprocess.run(["udevadm", "trigger"], capture_output=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _install_as_root(target: str) -> int:
|
|
42
|
+
try:
|
|
43
|
+
os.makedirs(UDEV_DIR, exist_ok=True)
|
|
44
|
+
with open(target, "w", encoding="utf-8") as fh:
|
|
45
|
+
fh.write(RULES)
|
|
46
|
+
print(f"installed udev rules -> {target}")
|
|
47
|
+
except PermissionError as e:
|
|
48
|
+
print(f"error: cannot write {target}: {e}")
|
|
49
|
+
return 1
|
|
50
|
+
_reload_udev()
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def install_udev_rules() -> int:
|
|
55
|
+
"""Install the udev rules file and reload udev (elevating via sudo)."""
|
|
56
|
+
target = os.path.join(UDEV_DIR, UDEV_FILE)
|
|
57
|
+
|
|
58
|
+
if _is_root():
|
|
59
|
+
return _install_as_root(target)
|
|
60
|
+
|
|
61
|
+
# Not root: escalate the privileged steps. The binary is often installed
|
|
62
|
+
# in a user-local path (e.g. ~/.local/bin), so `sudo fizzctl` would fail
|
|
63
|
+
# with "command not found" — instead we only elevate the syscalls that need
|
|
64
|
+
# root, in a single sudo session (one password prompt).
|
|
65
|
+
if shutil.which("sudo") is None:
|
|
66
|
+
print("error: not running as root and `sudo` is not available")
|
|
67
|
+
return 1
|
|
68
|
+
|
|
69
|
+
with tempfile.NamedTemporaryFile(
|
|
70
|
+
mode="w", encoding="utf-8", suffix=".rules", delete=False, prefix="k617-",
|
|
71
|
+
) as fh:
|
|
72
|
+
fh.write(RULES)
|
|
73
|
+
src = fh.name
|
|
74
|
+
try:
|
|
75
|
+
cmd = (
|
|
76
|
+
f"install -o root -g root -m 0644 {src!r} {target!r} "
|
|
77
|
+
"&& udevadm control --reload-rules "
|
|
78
|
+
"&& udevadm trigger"
|
|
79
|
+
)
|
|
80
|
+
print("elevating to root via sudo to install udev rules…")
|
|
81
|
+
res = subprocess.run(["sudo", "sh", "-c", cmd])
|
|
82
|
+
if res.returncode != 0:
|
|
83
|
+
print("error: sudo install/udevadm failed (see output above)")
|
|
84
|
+
return 1
|
|
85
|
+
finally:
|
|
86
|
+
os.unlink(src)
|
|
87
|
+
|
|
88
|
+
print(f"installed udev rules -> {target}")
|
|
89
|
+
return 0
|