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/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)}")