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/capture.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""USB capture import/diff/export for the K617 OEM software.
|
|
2
|
+
|
|
3
|
+
Inputs accepted:
|
|
4
|
+
* Wireshark JSON : `tshark -r cap.pcapng -T json > cap.json` (recommended)
|
|
5
|
+
* raw .pcap : Linux usbmon capture, parsed with dpkt
|
|
6
|
+
|
|
7
|
+
The job:
|
|
8
|
+
1. pull every HID feature-report payload out of the USB stream,
|
|
9
|
+
2. label it (INIT / CANVAS / ROUTING / EXEC / unknown),
|
|
10
|
+
3. diff two captures so a one-key config change stands out.
|
|
11
|
+
|
|
12
|
+
Class identifiers:
|
|
13
|
+
OUT = host -> device SET_REPORT (a config write)
|
|
14
|
+
IN = device -> host GET_REPORT response (firmware contents / ack)
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from .protocol import frame_kind
|
|
23
|
+
|
|
24
|
+
SET_REPORT = 0x09
|
|
25
|
+
GET_REPORT = 0x01
|
|
26
|
+
|
|
27
|
+
_HEX = re.compile(r"^[0-9a-fA-F\s:]+$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class FrameCapture:
|
|
32
|
+
"""One captured USB payload, normalised for diffing/replay."""
|
|
33
|
+
|
|
34
|
+
direction: str # "OUT" (host->dev) or "IN" (dev->host)
|
|
35
|
+
transfer: str # "control" / "interrupt"
|
|
36
|
+
request: int | None # HID bRequest: 0x09 SET_REPORT / 0x01 GET_REPORT
|
|
37
|
+
report_id: int | None # from wValue low byte
|
|
38
|
+
data: bytes = b""
|
|
39
|
+
meta: dict = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
def __repr__(self):
|
|
42
|
+
kind = frame_kind(self.data)
|
|
43
|
+
return f"{self.direction:>3} {self.transfer:<9} req={self.request} rid={self.report_id} {kind} {len(self.data)}B"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _as_bytes(value) -> bytes | None:
|
|
47
|
+
if isinstance(value, bytes):
|
|
48
|
+
return value
|
|
49
|
+
if isinstance(value, str):
|
|
50
|
+
s = value.strip()
|
|
51
|
+
if not s:
|
|
52
|
+
return None
|
|
53
|
+
if _HEX.match(s):
|
|
54
|
+
return bytes.fromhex(s.replace(":", ""))
|
|
55
|
+
try:
|
|
56
|
+
return s.encode("utf-8", "replace")
|
|
57
|
+
except Exception:
|
|
58
|
+
return None
|
|
59
|
+
if isinstance(value, list):
|
|
60
|
+
return bytes(int(x) for x in value)
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _first(it, n: int, default=None):
|
|
65
|
+
if isinstance(it, list):
|
|
66
|
+
return it[n] if len(it) > n else default
|
|
67
|
+
return default if it is None else it
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_tshark_json(path: str) -> list[FrameCapture]:
|
|
71
|
+
"""Parse `tshark -T json` output into FrameCapture records."""
|
|
72
|
+
with open(path) as fh:
|
|
73
|
+
packets = json.load(fh)
|
|
74
|
+
out: list[FrameCapture] = []
|
|
75
|
+
for pkt in packets:
|
|
76
|
+
layers = pkt.get("_source", {}).get("layers", {})
|
|
77
|
+
usb = layers.get("usb")
|
|
78
|
+
if not usb:
|
|
79
|
+
continue
|
|
80
|
+
usb_layers = usb if isinstance(usb, list) else [usb]
|
|
81
|
+
setup = layers.get("Setup Data")
|
|
82
|
+
for u in usb_layers:
|
|
83
|
+
if not isinstance(u, dict):
|
|
84
|
+
continue
|
|
85
|
+
fc = _parse_usb_layer(u, setup)
|
|
86
|
+
if fc is not None:
|
|
87
|
+
out.append(fc)
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parse_usb_layer(u: dict, setup: dict | None = None) -> FrameCapture | None:
|
|
92
|
+
src = _first(u.get("usb.src"), 0)
|
|
93
|
+
dst = _first(u.get("usb.dst"), 0)
|
|
94
|
+
transfer = _first(u.get("usb.transfer_type"), 0)
|
|
95
|
+
transfer = {"0x02": "control", "0x03": "interrupt"}.get(transfer, str(transfer))
|
|
96
|
+
|
|
97
|
+
data = _as_bytes(u.get("usb.data_fragment"))
|
|
98
|
+
if not data:
|
|
99
|
+
data = _as_bytes(u.get("usb.capdata"))
|
|
100
|
+
if not data and setup:
|
|
101
|
+
data = _as_bytes(setup.get("usb.data_fragment"))
|
|
102
|
+
if not data:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
setup = setup or {}
|
|
106
|
+
request = None
|
|
107
|
+
for key in ("usb.bRequest", "usb.setup.bRequest", "usbhid.setup.bRequest"):
|
|
108
|
+
key = setup.get(key) or u.get(key)
|
|
109
|
+
if key is not None:
|
|
110
|
+
request = int(key, 16) if isinstance(key, str) else int(key)
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
report_id = None
|
|
114
|
+
if request is not None:
|
|
115
|
+
wv = (setup.get("usbhid.setup.wValue") or setup.get("usb.setup.wValue")
|
|
116
|
+
or setup.get("usb.wValue"))
|
|
117
|
+
if wv is None:
|
|
118
|
+
tree = setup.get("usbhid.setup.wValue_tree")
|
|
119
|
+
if isinstance(tree, dict):
|
|
120
|
+
rid = tree.get("usbhid.setup.ReportID")
|
|
121
|
+
else:
|
|
122
|
+
rid = None
|
|
123
|
+
wv = rid
|
|
124
|
+
if wv is not None:
|
|
125
|
+
v = int(str(wv).replace("0x", ""), 16) if isinstance(wv, str) else int(wv)
|
|
126
|
+
report_id = (v & 0xFF) if v <= 0xFFFF else (int(str(wv).split()[1]) & 0xFF)
|
|
127
|
+
|
|
128
|
+
direction = "OUT"
|
|
129
|
+
bmrt = _first(u.get("usb.bmRequestType"), setup.get("usb.bmRequestType"), 0)
|
|
130
|
+
if bmrt is not None:
|
|
131
|
+
if isinstance(bmrt, str) and "0x" in bmrt:
|
|
132
|
+
direction = "OUT" if (int(bmrt, 16) & 0x80) == 0 else "IN"
|
|
133
|
+
elif ":" in str(bmrt):
|
|
134
|
+
direction = "OUT" if (int(str(bmrt)[:4], 16) & 0x80) == 0 else "IN"
|
|
135
|
+
else:
|
|
136
|
+
direction = "OUT" if src == "host" else ("IN" if dst == "host" else "OUT")
|
|
137
|
+
else:
|
|
138
|
+
direction = "OUT" if src == "host" else ("IN" if dst == "host" else "OUT")
|
|
139
|
+
|
|
140
|
+
return FrameCapture(
|
|
141
|
+
direction=direction,
|
|
142
|
+
transfer=transfer,
|
|
143
|
+
request=request,
|
|
144
|
+
report_id=report_id,
|
|
145
|
+
data=data,
|
|
146
|
+
meta={"frame": _first(u.get("usb.frame.number"), 0)},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def load_pcap_usbmon(path: str) -> list[FrameCapture]:
|
|
151
|
+
"""Minimal Linux usbmon .pcap reader (dpkt)."""
|
|
152
|
+
import dpkt
|
|
153
|
+
|
|
154
|
+
out: list[FrameCapture] = []
|
|
155
|
+
with open(path, "rb") as fh:
|
|
156
|
+
pcap = dpkt.pcap.Reader(fh)
|
|
157
|
+
for _, buf in pcap:
|
|
158
|
+
try:
|
|
159
|
+
u = dpkt.usbmon.LinuxUSB(buf)
|
|
160
|
+
except Exception:
|
|
161
|
+
continue
|
|
162
|
+
data = bytes(u.transfer_buffer) if u.xfer_type & 0x80 else None
|
|
163
|
+
direction = "IN" if u.urb_type == 0x55 else "OUT"
|
|
164
|
+
out.append(FrameCapture(direction=direction, transfer="urb", request=None, report_id=None, data=data or b""))
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def significant(records: list[FrameCapture], direction: str = "OUT") -> list[FrameCapture]:
|
|
169
|
+
"""Keep only payload-bearing writes from the host (the interesting direction)."""
|
|
170
|
+
return [r for r in records if r.direction == direction and len(r.data) > 0]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def diff_captures(a: list[FrameCapture], b: list[FrameCapture]) -> list[dict]:
|
|
174
|
+
"""Align two captures and report the changed byte positions."""
|
|
175
|
+
order_a = significant(a)
|
|
176
|
+
order_b = significant(b)
|
|
177
|
+
changes = []
|
|
178
|
+
n = min(len(order_a), len(order_b))
|
|
179
|
+
for i in range(n):
|
|
180
|
+
fa, fb = order_a[i], order_b[i]
|
|
181
|
+
if fa.data == fb.data:
|
|
182
|
+
continue
|
|
183
|
+
diffs = []
|
|
184
|
+
for j, (ca, cb) in enumerate(zip(fa.data, fb.data)):
|
|
185
|
+
if ca != cb:
|
|
186
|
+
diffs.append((j, f"{ca:02x}", f"{cb:02x}"))
|
|
187
|
+
head = diffs[:24]
|
|
188
|
+
changes.append({
|
|
189
|
+
"frame": i,
|
|
190
|
+
"kind_a": frame_kind(fa.data),
|
|
191
|
+
"kind_b": frame_kind(fb.data),
|
|
192
|
+
"len_a": len(fa.data),
|
|
193
|
+
"len_b": len(fb.data),
|
|
194
|
+
"diff_count": len(diffs),
|
|
195
|
+
"offsets": head,
|
|
196
|
+
"unique_a": next((d for d in diffs if d[0] not in {x[0] for x in head}), None),
|
|
197
|
+
"data": fb.data if len(fb.data) == len(fa.data) else b"",
|
|
198
|
+
})
|
|
199
|
+
return changes
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def export_frames(records: list[FrameCapture], path: str, direction: str = "OUT") -> None:
|
|
203
|
+
"""Write host->device SET_REPORT payloads to a JSON file for replay."""
|
|
204
|
+
frames = [{"direction": r.direction, "data_hex": r.data.hex()} for r in significant(records, direction)]
|
|
205
|
+
with open(path, "w") as fh:
|
|
206
|
+
json.dump(frames, fh, indent=1)
|
|
207
|
+
print(f"wrote {len(frames)} frames -> {path}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def load_frames(path: str) -> list[bytes]:
|
|
211
|
+
"""Load frames written by export_frames()."""
|
|
212
|
+
with open(path) as fh:
|
|
213
|
+
items = json.load(fh)
|
|
214
|
+
return [bytes.fromhex(i["data_hex"]) for i in items if i.get("direction") == "OUT"]
|
fizzctl/cfg.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Cfg.ini parser for the Redragon K617 vendor software.
|
|
2
|
+
|
|
3
|
+
Understands the [OPT], [FN] and [KEY] sections used by the OEM app. Only the
|
|
4
|
+
mapping triples matter for RE work:
|
|
5
|
+
|
|
6
|
+
[FN] K15=0x04,0x22,0x00 -> 3-byte behavior on the FN layer
|
|
7
|
+
[KEY] K15=13,51,54,79, 0x02,0x9,0x00,2,42
|
|
8
|
+
^---GUI rect---^ ^-behavior-^ ^matrix^ -> base-layer key
|
|
9
|
+
|
|
10
|
+
Behavior triple first byte:
|
|
11
|
+
0x02 normal key (byte 2 = Windows Virtual-Key code)
|
|
12
|
+
0x04 media key (byte 2 = consumer code: 0x22 play/pause, 0x27 vol-, 0x26 vol+, ...)
|
|
13
|
+
0x09 keyboard fn (bytes 2-3 = internal command, e.g. 0x0e000001 lock Win)
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import configparser
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
KEY_LINE = re.compile(
|
|
21
|
+
r"^K(?P<idx>\d+)\s*=\s*(?P<geom>[^,]+,[^,]+,[^,]+,[^,]+),\s*"
|
|
22
|
+
r"(?P<hex>(?:0x[0-9a-fA-F]+)(?:\s*,\s*0x[0-9a-fA-F]+)+)\s*"
|
|
23
|
+
r"(?:,\s*(?P<rest>[0-9,]+))?\s*$"
|
|
24
|
+
)
|
|
25
|
+
FN_LINE = re.compile(r"^K(?P<idx>\d+)\s*=\s*(?P<hex>(?:0x[0-9a-fA-F]+)(\s*,\s*0x[0-9a-fA-F]+)+)\s*$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_hex(s: str) -> tuple[int, ...]:
|
|
29
|
+
return tuple(int(h, 16) for h in re.findall(r"0x([0-9a-fA-F]+)", s))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class KeyEntry:
|
|
33
|
+
__slots__ = ("index", "geom", "behavior", "matrix")
|
|
34
|
+
|
|
35
|
+
def __init__(self, index: int | None, geom=None, behavior=None, matrix=None):
|
|
36
|
+
self.index = index
|
|
37
|
+
self.geom = geom # (x, y, x2, y2) GUI rect
|
|
38
|
+
self.behavior = behavior # (type, code, param)
|
|
39
|
+
self.matrix = matrix # (col, row) hardware address
|
|
40
|
+
|
|
41
|
+
def __repr__(self):
|
|
42
|
+
return (f"KeyEntry(K{self.index} geom={self.geom} "
|
|
43
|
+
f"behavior={[f'0x{b:02X}' for b in self.behavior]} matrix={self.matrix})")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CfgIni:
|
|
47
|
+
def __init__(self, path):
|
|
48
|
+
parser = configparser.RawConfigParser(strict=False, comment_prefixes=(";", "#", "//"))
|
|
49
|
+
parser.optionxform = str
|
|
50
|
+
with open(path, "r", errors="replace") as fh:
|
|
51
|
+
parser.read_file(fh)
|
|
52
|
+
self.path = path
|
|
53
|
+
self.opt = dict(parser.items("OPT")) if parser.has_section("OPT") else {}
|
|
54
|
+
self.fn: dict[int, tuple[int, ...]] = {}
|
|
55
|
+
self.keys: dict[int, KeyEntry] = {}
|
|
56
|
+
for section in parser.sections():
|
|
57
|
+
for opt, raw in parser.items(section):
|
|
58
|
+
line = f"{opt}={raw.strip()}"
|
|
59
|
+
m = FN_LINE.match(line)
|
|
60
|
+
if m and section == "FN":
|
|
61
|
+
self.fn[int(m.group("idx"))] = _parse_hex(m.group("hex"))
|
|
62
|
+
continue
|
|
63
|
+
m = KEY_LINE.match(line)
|
|
64
|
+
if m and section == "KEY":
|
|
65
|
+
geom = tuple(int(v) for v in m.group("geom").split(","))
|
|
66
|
+
be = _parse_hex(m.group("hex"))
|
|
67
|
+
matrix = None
|
|
68
|
+
if m.group("rest"):
|
|
69
|
+
vals = [int(v) for v in m.group("rest").split(",")]
|
|
70
|
+
matrix = tuple(vals[:2])
|
|
71
|
+
self.keys[int(m.group("idx"))] = KeyEntry(
|
|
72
|
+
int(m.group("idx")), geom, tuple(be), matrix
|
|
73
|
+
)
|
|
74
|
+
self._fill_from_raw(parser)
|
|
75
|
+
|
|
76
|
+
def _fill_from_raw(self, parser):
|
|
77
|
+
raw = {
|
|
78
|
+
s: dict(parser.items(s)) for s in parser.sections()
|
|
79
|
+
if s in ("FN", "KEY")
|
|
80
|
+
}
|
|
81
|
+
for section in ("FN", "KEY"):
|
|
82
|
+
for name, value in raw.get(section, {}).items():
|
|
83
|
+
if section == "FN" and name.startswith("K") and name.isidentifier():
|
|
84
|
+
idx = int(name[1:])
|
|
85
|
+
if idx not in self.fn:
|
|
86
|
+
self.fn[idx] = _parse_hex(value)
|
|
87
|
+
elif section == "KEY" and name.startswith("K"):
|
|
88
|
+
idx = int(name[1:])
|
|
89
|
+
if idx not in self.keys:
|
|
90
|
+
m = KEY_LINE.match(value.strip())
|
|
91
|
+
if m:
|
|
92
|
+
self.keys[idx] = KeyEntry(
|
|
93
|
+
idx,
|
|
94
|
+
tuple(int(v) for v in m.group("geom").split(",")),
|
|
95
|
+
_parse_hex(m.group("hex")),
|
|
96
|
+
tuple(int(v) for v in m.group("rest").split(",")[:2])
|
|
97
|
+
if m.group("rest") else None,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def fn_entries(self) -> list[tuple[int, tuple[int, ...]]]:
|
|
102
|
+
return sorted(self.fn.items())
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def key_entries(self) -> list[tuple[int, KeyEntry]]:
|
|
106
|
+
return sorted(self.keys.items())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
import sys
|
|
111
|
+
cfg = CfgIni(sys.argv[1] if len(sys.argv) > 1 else "Cfg.ini")
|
|
112
|
+
print(f"OPT: {len(cfg.opt)} keys")
|
|
113
|
+
print(f"[FN] {len(cfg.fn)} mappings")
|
|
114
|
+
for idx, be in cfg.fn_entries:
|
|
115
|
+
print(f" K{idx:<3} = {', '.join(f'0x{b:02X}' for b in be)}")
|
|
116
|
+
print(f"[KEY] {len(cfg.keys)} keys")
|
|
117
|
+
for idx, e in cfg.key_entries:
|
|
118
|
+
print(f" K{idx:<3} = {e.matrix} {', '.join(f'0x{b:02X}' for b in e.behavior)}")
|