usb-rea 0.2.1__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.
usb_rea/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """usb-rea: Reverse Engineer any USB gadget."""
2
+ __version__ = "0.2.1"
usb_rea/actions.py ADDED
@@ -0,0 +1,73 @@
1
+ """Action markers: which transfers belong to which user action."""
2
+ from __future__ import annotations
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from .model import Action, Transfer
8
+
9
+
10
+ def load_markers(path: str | Path) -> list[dict]:
11
+ """Markers file: JSON list of {label, start, end?} (epoch seconds).
12
+
13
+ Missing `end` means "until the next marker starts" (or +infinity).
14
+ """
15
+ marks = json.loads(Path(path).read_text())
16
+ marks = sorted(marks, key=lambda m: m["start"])
17
+ for i, m in enumerate(marks):
18
+ if m.get("end") is None:
19
+ m["end"] = marks[i + 1]["start"] if i + 1 < len(marks) else float("inf")
20
+ return marks
21
+
22
+
23
+ def append_marker(path: str | Path, label: str, ts: float | None = None) -> dict:
24
+ p = Path(path)
25
+ marks = json.loads(p.read_text()) if p.exists() else []
26
+ now = time.time() if ts is None else ts
27
+ if marks and marks[-1].get("end") is None:
28
+ marks[-1]["end"] = now
29
+ m = {"label": label, "start": now, "end": None}
30
+ marks.append(m)
31
+ p.write_text(json.dumps(marks, indent=1))
32
+ return m
33
+
34
+
35
+ def assign(transfers: list[Transfer], marks: list[dict]) -> list[Action]:
36
+ acts = [Action(m["label"], m["start"], m["end"]) for m in marks]
37
+ for t in transfers:
38
+ for a in acts:
39
+ if a.start <= t.ts <= a.end:
40
+ a.transfers.append(t)
41
+ break
42
+ return acts
43
+
44
+
45
+ def commands_of(action: Action) -> list[Transfer]:
46
+ """Host->device messages that carry data (the interesting part)."""
47
+ return [t for t in action.transfers if t.is_out and t.data]
48
+
49
+
50
+ def segment_by_gaps(transfers: list[Transfer], labels: list[str]) -> list[dict]:
51
+ """Build markers without timestamps: split host->device traffic at the largest pauses.
52
+
53
+ For captures made in Wireshark/USBPcap where you simply clicked the actions in order with a
54
+ short pause between them. Returns markers usable by `assign`.
55
+ """
56
+ outs = sorted((t for t in transfers if t.is_out and t.data), key=lambda t: t.ts)
57
+ n = len(labels)
58
+ if n == 0 or len(outs) < n:
59
+ raise ValueError(f"{len(labels)} labels but only {len(outs)} host->device messages in the capture")
60
+ gaps = sorted(range(1, len(outs)), key=lambda i: outs[i].ts - outs[i - 1].ts, reverse=True)[:n - 1]
61
+ cuts = [0] + sorted(gaps) + [len(outs)]
62
+ marks = []
63
+ for label, a, b in zip(labels, cuts, cuts[1:]):
64
+ marks.append({"label": label, "start": outs[a].ts - 1e-6, "end": outs[b - 1].ts + 1e-6})
65
+ return marks
66
+
67
+
68
+ def read_labels(spec: str) -> list[str]:
69
+ """Labels from a file (one per line) or a ';'-separated string."""
70
+ p = Path(spec)
71
+ if p.is_file():
72
+ return [l.strip() for l in p.read_text().splitlines() if l.strip() and not l.startswith("#")]
73
+ return [s.strip() for s in spec.split(";") if s.strip()]
usb_rea/capture.py ADDED
@@ -0,0 +1,38 @@
1
+ """Capture helpers: thin wrappers around dumpcap/tshark (Linux usbmon) and USBPcapCMD (Windows)."""
2
+ from __future__ import annotations
3
+ import os
4
+ import shutil
5
+ import signal
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ PIDFILE = ".usb-rea-capture.pid"
11
+
12
+
13
+ def capture_command(out: str, bus: int = 0) -> list[str]:
14
+ if sys.platform.startswith("win"):
15
+ exe = shutil.which("USBPcapCMD.exe") or r"C:\Program Files\USBPcap\USBPcapCMD.exe"
16
+ return [exe, "-d", rf"\\.\USBPcap{bus or 1}", "-o", out, "-A"]
17
+ tool = shutil.which("dumpcap") or shutil.which("tshark")
18
+ if not tool:
19
+ raise RuntimeError("need dumpcap or tshark (apt install tshark) and `sudo modprobe usbmon`")
20
+ return [tool, "-i", f"usbmon{bus}", "-w", out, "-q"]
21
+
22
+
23
+ def start(out: str, bus: int = 0, workdir: str = ".") -> int:
24
+ proc = subprocess.Popen(capture_command(out, bus), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
25
+ Path(workdir, PIDFILE).write_text(str(proc.pid))
26
+ return proc.pid
27
+
28
+
29
+ def stop(workdir: str = ".") -> bool:
30
+ p = Path(workdir, PIDFILE)
31
+ if not p.exists():
32
+ return False
33
+ try:
34
+ os.kill(int(p.read_text()), signal.SIGINT)
35
+ except ProcessLookupError:
36
+ pass
37
+ p.unlink()
38
+ return True
usb_rea/checksums.py ADDED
@@ -0,0 +1,46 @@
1
+ """Checksum / CRC algorithms usb-rea can recognise. Self-contained: copied verbatim into generated drivers."""
2
+
3
+
4
+ def _crc(data, width, poly, init, refin, refout, xorout):
5
+ top, mask = 1 << (width - 1), (1 << width) - 1
6
+ crc = init
7
+ for byte in data:
8
+ if refin:
9
+ byte = int("{:08b}".format(byte)[::-1], 2)
10
+ crc ^= byte << (width - 8)
11
+ for _ in range(8):
12
+ crc = ((crc << 1) ^ poly) & mask if crc & top else (crc << 1) & mask
13
+ if refout:
14
+ crc = int("{:0{w}b}".format(crc, w=width)[::-1], 2)
15
+ return crc ^ xorout
16
+
17
+
18
+ def _xor(data):
19
+ x = 0
20
+ for c in data:
21
+ x ^= c
22
+ return x
23
+
24
+
25
+ # name -> (width in bytes, function)
26
+ CHECKSUMS = {
27
+ "sum8": (1, lambda b: sum(b) & 0xFF),
28
+ "xor8": (1, _xor),
29
+ "neg_sum8": (1, lambda b: (-sum(b)) & 0xFF),
30
+ "inv_sum8": (1, lambda b: (~sum(b)) & 0xFF),
31
+ "crc8": (1, lambda b: _crc(b, 8, 0x07, 0x00, False, False, 0x00)),
32
+ "crc8_maxim": (1, lambda b: _crc(b, 8, 0x31, 0x00, True, True, 0x00)),
33
+ "sum16": (2, lambda b: sum(b) & 0xFFFF),
34
+ "crc16_ccitt_false": (2, lambda b: _crc(b, 16, 0x1021, 0xFFFF, False, False, 0x0000)),
35
+ "crc16_xmodem": (2, lambda b: _crc(b, 16, 0x1021, 0x0000, False, False, 0x0000)),
36
+ "crc16_modbus": (2, lambda b: _crc(b, 16, 0x8005, 0xFFFF, True, True, 0x0000)),
37
+ "crc16_kermit": (2, lambda b: _crc(b, 16, 0x1021, 0x0000, True, True, 0x0000)),
38
+ }
39
+
40
+
41
+ def compute(algo, data):
42
+ return CHECKSUMS[algo][1](bytes(data))
43
+
44
+
45
+ def width(algo):
46
+ return CHECKSUMS[algo][0]
usb_rea/cli.py ADDED
@@ -0,0 +1,180 @@
1
+ """usb-rea command line."""
2
+ from __future__ import annotations
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+
10
+
11
+ def _int(s):
12
+ return int(s, 0)
13
+
14
+
15
+ def main(argv=None) -> int:
16
+ p = argparse.ArgumentParser(prog="usb-rea", description="Reverse Engineer any USB gadget")
17
+ p.add_argument("--version", action="version", version=__version__)
18
+ sub = p.add_subparsers(dest="cmd")
19
+
20
+ c = sub.add_parser("capture", help="start/stop a usbmon/USBPcap capture")
21
+ c.add_argument("op", choices=["start", "stop"])
22
+ c.add_argument("-o", "--out", default="capture.pcapng")
23
+ c.add_argument("--bus", type=int, default=0)
24
+
25
+ m = sub.add_parser("mark", help="mark the action you are about to do in the vendor app")
26
+ m.add_argument("label")
27
+ m.add_argument("-m", "--markers", default="capture.pcapng.actions.json")
28
+
29
+ for name, hlp in (("transfers", "list parsed transfers"), ("infer", "infer protocol (JSON)"),
30
+ ("report", "write evidence report (markdown)"), ("scaffold", "write pyusb driver"),
31
+ ("dissector", "write a Wireshark Lua dissector")):
32
+ s = sub.add_parser(name, help=hlp)
33
+ s.add_argument("capture")
34
+ s.add_argument("-m", "--markers")
35
+ s.add_argument("-l", "--labels", help="no markers? actions in the order you clicked them, "
36
+ "';'-separated or a file with one per line; split at the pauses between them")
37
+ s.add_argument("-d", "--device", type=int)
38
+ s.add_argument("-o", "--out")
39
+ if name == "dissector":
40
+ s.add_argument("-n", "--name", default="usbrea", help="protocol/filter name in Wireshark")
41
+
42
+ h = sub.add_parser("hid", help="decode a HID report descriptor (from a capture or hex)")
43
+ h.add_argument("source", help="capture file, or descriptor hex (e.g. from /sys/class/hidraw/*/device/report_descriptor)")
44
+ h.add_argument("--decode", metavar="HEX", help="also decode one report payload")
45
+ h.add_argument("--type", default="Input", choices=["Input", "Output", "Feature"], help="report type for --decode")
46
+ h.add_argument("--items", action="store_true", help="print every descriptor item")
47
+
48
+ d = sub.add_parser("demo", help="generate a synthetic capture and run the whole pipeline")
49
+ d.add_argument("-o", "--outdir", default="usb-rea-demo")
50
+ d.add_argument("--device", choices=["lamp", "fan", "dial"], default="lamp")
51
+
52
+ r = sub.add_parser("replay", help="send one packet to a real device (requires --yes)")
53
+ r.add_argument("vid", type=_int)
54
+ r.add_argument("pid", type=_int)
55
+ r.add_argument("endpoint", type=_int)
56
+ r.add_argument("hex")
57
+ r.add_argument("--setup", help="8-byte setup packet hex for control transfers")
58
+ r.add_argument("--yes", action="store_true")
59
+
60
+ sub.add_parser("mcp", help="run the MCP server (stdio)")
61
+
62
+ a = p.parse_args(argv)
63
+ if a.cmd is None:
64
+ p.print_help()
65
+ return 0
66
+ if a.cmd == "capture":
67
+ from . import capture
68
+ if a.op == "start":
69
+ print(f"capturing to {a.out} (pid {capture.start(a.out, a.bus)})")
70
+ else:
71
+ print("stopped" if capture.stop() else "no capture running")
72
+ return 0
73
+ if a.cmd == "mark":
74
+ from .actions import append_marker
75
+ print(json.dumps(append_marker(a.markers, a.label)))
76
+ return 0
77
+ if a.cmd == "replay":
78
+ from .replay import replay
79
+ try:
80
+ print(replay(a.vid, a.pid, a.endpoint, bytes.fromhex(a.hex),
81
+ bytes.fromhex(a.setup) if a.setup else None, confirm=a.yes))
82
+ except PermissionError as e:
83
+ print(e, file=sys.stderr)
84
+ return 2
85
+ return 0
86
+ if a.cmd == "mcp":
87
+ from .mcp_server import run
88
+ run()
89
+ return 0
90
+ if a.cmd == "hid":
91
+ return _hid(a)
92
+ if a.cmd == "demo":
93
+ return _demo(Path(a.outdir), a.device)
94
+ if a.cmd == "transfers":
95
+ from .pcap import read_transfers
96
+ for t in read_transfers(a.capture):
97
+ print(f"{t.frame:6d} dev {t.device:3d} ep 0x{t.endpoint:02x} {t.kind:9s} "
98
+ f"{'OUT' if t.is_out else 'IN '} {(t.setup.hex() + ' | ') if t.setup else ''}{t.data.hex(' ')}")
99
+ return 0
100
+
101
+ from .pipeline import analyze, sha256
102
+ labels = None
103
+ if getattr(a, "labels", None):
104
+ from .actions import read_labels
105
+ labels = read_labels(a.labels)
106
+ proto = analyze(a.capture, a.markers, a.device, labels)
107
+ if a.cmd == "infer":
108
+ text = json.dumps(proto.to_dict(), indent=1, default=str)
109
+ elif a.cmd == "report":
110
+ from .report import render
111
+ text = render(proto, a.capture, sha256(a.capture))
112
+ elif a.cmd == "dissector":
113
+ from .dissector import generate
114
+ text = generate(proto, a.name)
115
+ else:
116
+ from .scaffold import generate
117
+ text = generate(proto)
118
+ if a.out:
119
+ Path(a.out).write_text(text)
120
+ print(f"wrote {a.out}")
121
+ else:
122
+ print(text)
123
+ return 0
124
+
125
+
126
+ def _hid(a) -> int:
127
+ from . import hid
128
+ src = Path(a.source)
129
+ if src.exists() and src.suffix in (".pcap", ".pcapng", ".cap"):
130
+ from .pcap import read_transfers
131
+ found = hid.from_transfers(read_transfers(src))
132
+ if not found:
133
+ print("no HID report descriptor (GET_DESCRIPTOR 0x22) in this capture; "
134
+ "replug the device while capturing, or pass the descriptor hex", file=sys.stderr)
135
+ return 1
136
+ descs = [(f"device {d}, interface {i}", raw) for (d, i), raw in sorted(found.items())]
137
+ elif src.exists():
138
+ raw = src.read_bytes()
139
+ descs = [(str(src), raw)]
140
+ else:
141
+ descs = [("hex", bytes.fromhex(a.source.replace("0x", "").replace(",", " ")))]
142
+ for name, raw in descs:
143
+ desc = hid.parse(raw)
144
+ print(f"# {name}: {len(raw)} bytes, top level {', '.join(desc.top_usages) or '?'}")
145
+ if a.items:
146
+ print(hid.dump(desc))
147
+ for r in desc.reports:
148
+ print(f"\n{r.title()}")
149
+ for f in r.fields:
150
+ print(f" {f.describe()}")
151
+ if a.decode:
152
+ print(f"\n{a.type} {a.decode}:")
153
+ for label, v in hid.decode(desc, a.type, bytes.fromhex(a.decode)):
154
+ print(f" {label} = {v}")
155
+ return 0
156
+
157
+
158
+ def _demo(outdir: Path, device: str = "lamp") -> int:
159
+ from .synth import make_capture
160
+ from .pipeline import analyze, sha256
161
+ from .report import render
162
+ from .scaffold import generate
163
+ outdir.mkdir(parents=True, exist_ok=True)
164
+ cap, _ = make_capture(outdir / f"{device}.pcap", device=device)
165
+ proto = analyze(cap)
166
+ (outdir / "report.md").write_text(render(proto, str(cap), sha256(cap)))
167
+ (outdir / f"{device}_driver.py").write_text(generate(proto))
168
+ from .dissector import generate as dissector
169
+ (outdir / f"{device}.lua").write_text(dissector(proto))
170
+ (outdir / "__init__.py").touch()
171
+ print(f"captured {sum(len(c.samples) for c in proto.commands)} labelled actions, "
172
+ f"device {proto.vid:04x}:{proto.pid:04x}\n")
173
+ for c in proto.commands:
174
+ print(f"{c.name:12s} {c.confidence:4.0%} " + " | ".join(f.describe() for f in c.fields))
175
+ print(f"\nwrote {outdir}/report.md, {outdir}/{device}_driver.py and the Wireshark dissector {outdir}/{device}.lua")
176
+ return 0
177
+
178
+
179
+ if __name__ == "__main__":
180
+ sys.exit(main())
usb_rea/dissector.py ADDED
@@ -0,0 +1,271 @@
1
+ """Generate a Wireshark Lua dissector from an inferred protocol.
2
+
3
+ The dissector is a postdissector: it looks at the payload Wireshark already extracted for every
4
+ USB transfer (bulk/interrupt `usb.capdata`, control `usb.data_fragment`, HID `usbhid.data`), matches
5
+ it against the inferred commands (endpoint or setup packet, length, constant bytes) and labels each
6
+ field, decodes values, and verifies checksums. It is pure Lua with no bit library, so it runs on
7
+ Wireshark 3.x (Lua 5.2) and 4.x (Lua 5.4).
8
+ """
9
+ from __future__ import annotations
10
+ import re
11
+
12
+ from .infer import Protocol
13
+
14
+ # name -> (width, lua expression over `d`, a list of byte values)
15
+ _LUA_CHECKSUMS = {
16
+ "sum8": "sum(d) % 256",
17
+ "xor8": "xorall(d)",
18
+ "neg_sum8": "(256 - sum(d) % 256) % 256",
19
+ "inv_sum8": "255 - sum(d) % 256",
20
+ "sum16": "sum(d) % 65536",
21
+ "crc8": "crc(d, 8, 0x07, 0x00, false, false, 0x00)",
22
+ "crc8_maxim": "crc(d, 8, 0x31, 0x00, true, true, 0x00)",
23
+ "crc16_ccitt_false": "crc(d, 16, 0x1021, 0xFFFF, false, false, 0x0000)",
24
+ "crc16_xmodem": "crc(d, 16, 0x1021, 0x0000, false, false, 0x0000)",
25
+ "crc16_modbus": "crc(d, 16, 0x8005, 0xFFFF, true, true, 0x0000)",
26
+ "crc16_kermit": "crc(d, 16, 0x1021, 0x0000, true, true, 0x0000)",
27
+ }
28
+
29
+ _HELPERS = r'''
30
+ -- ---- arithmetic-only helpers (no bit library needed) ----
31
+ local function bxor(a, b)
32
+ local r, p = 0, 1
33
+ while a > 0 or b > 0 do
34
+ if a % 2 ~= b % 2 then r = r + p end
35
+ a, b, p = math.floor(a / 2), math.floor(b / 2), p * 2
36
+ end
37
+ return r
38
+ end
39
+
40
+ local function reflect(v, w)
41
+ local r = 0
42
+ for _ = 1, w do r = r * 2 + v % 2; v = math.floor(v / 2) end
43
+ return r
44
+ end
45
+
46
+ local function sum(d) local s = 0 for _, b in ipairs(d) do s = s + b end return s end
47
+ local function xorall(d) local x = 0 for _, b in ipairs(d) do x = bxor(x, b) end return x end
48
+
49
+ local function crc(d, w, poly, init, refin, refout, xorout)
50
+ local top, mod, c = 2 ^ (w - 1), 2 ^ w, init
51
+ for _, b in ipairs(d) do
52
+ if refin then b = reflect(b, 8) end
53
+ c = bxor(c, b * 2 ^ (w - 8))
54
+ for _ = 1, 8 do
55
+ if c >= top then c = bxor((c * 2) % mod, poly) else c = (c * 2) % mod end
56
+ end
57
+ end
58
+ if refout then c = reflect(c, w) end
59
+ return math.floor(bxor(c, xorout))
60
+ end
61
+
62
+ local function bytes_of(r)
63
+ local t = {}
64
+ for i = 0, r:len() - 1 do t[#t + 1] = r(i, 1):uint() end
65
+ return t
66
+ end
67
+
68
+ -- show the integer the user most likely picked when the raw byte is consistent with it
69
+ -- (brightness 25% -> 0x40 -> 25.098 is shown as 25)
70
+ local function fmt(v, scale)
71
+ local n = math.floor(v + 0.5)
72
+ if math.abs(v - n) <= 0.5 / math.abs(scale or 1) + 1e-9 then return string.format("%d", n) end
73
+ return string.format("%.4g", v)
74
+ end
75
+ '''
76
+
77
+
78
+ def _ident(name: str) -> str:
79
+ s = re.sub(r"\W+", "_", name).strip("_").lower() or "cmd"
80
+ return s if not s[0].isdigit() else "cmd_" + s
81
+
82
+
83
+ def _lua_str(s: str) -> str:
84
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ") + '"'
85
+
86
+
87
+ def generate(proto: Protocol, name: str = "usbrea") -> str:
88
+ p = _ident(name)
89
+ ids = f"{proto.vid:04x}:{proto.pid:04x}" if proto.vid is not None else "unknown id"
90
+ out = [f'''-- Wireshark dissector for USB device {ids}.
91
+ -- Generated by usb-rea (https://github.com/emirmrkaljevic/usb-rea) from captured traffic;
92
+ -- every command below is backed by frames listed in the accompanying report.md.
93
+ --
94
+ -- Use: wireshark -X lua_script:{p}.lua capture.pcapng
95
+ -- tshark -X lua_script:{p}.lua -r capture.pcapng -Y {p}
96
+ -- or copy this file to your personal Lua plugins folder (Help > About Wireshark > Folders).
97
+ -- Filter fields: {p}.command, {p}.<command>.<field>, {p}.checksum_ok
98
+ ''']
99
+ out.append(_HELPERS)
100
+ out.append(f'''
101
+ local P = Proto({_lua_str(p)}, {_lua_str(f"usb-rea: USB device {ids}")})
102
+ local f_command = ProtoField.string("{p}.command", "Command")
103
+ local f_checksum_ok = ProtoField.bool("{p}.checksum_ok", "Checksum OK")
104
+ local f_unknown = ProtoField.bytes("{p}.unknown", "Unknown bytes")
105
+ local fields = {{ f_command, f_checksum_ok, f_unknown }}
106
+ local e_bad_checksum = ProtoExpert.new("{p}.checksum_bad", "Checksum mismatch", expert.group.CHECKSUM, expert.severity.WARN)
107
+ P.experts = {{ e_bad_checksum }}
108
+
109
+ local function pf(f) fields[#fields + 1] = f; return f end
110
+ ''')
111
+ cmds = []
112
+ for c in proto.commands:
113
+ cid = _ident(c.name)
114
+ roles = []
115
+ for f in c.fields:
116
+ if f.kind in ("const", "part"):
117
+ continue
118
+ idx = "" if f.index == 0 else str(f.index)
119
+ if f.kind == "command":
120
+ fid, decl = f"{cid}.command_id", f'ProtoField.uint8("{p}.{cid}.command_id", "Command id", base.HEX)'
121
+ elif f.kind in ("value", "bitfield"):
122
+ label = c.name if f.index == 0 else f"{c.name}[{f.index}]"
123
+ fid = f"{cid}.value{idx}"
124
+ decl = f'ProtoField.double("{p}.{fid}", {_lua_str(label)})'
125
+ elif f.kind == "lookup":
126
+ fid = f"{cid}.value{idx}"
127
+ vs = ", ".join(f"[{v}] = {_lua_str(f'{k:g}')}" for k, v in sorted(f.table.items()))
128
+ decl = f'ProtoField.uint8("{p}.{fid}", {_lua_str(c.name + " (lookup)")}, base.HEX, {{ {vs} }})'
129
+ elif f.kind == "counter":
130
+ fid = f"{cid}.counter"
131
+ decl = f'ProtoField.uint{8 * f.width}("{p}.{fid}", "Sequence counter")'
132
+ elif f.kind == "checksum":
133
+ fid = f"{cid}.checksum"
134
+ decl = f'ProtoField.uint{8 * f.width}("{p}.{fid}", {_lua_str(f.algo)}, base.HEX)'
135
+ else:
136
+ fid, decl = None, None
137
+ var = "f_" + re.sub(r"\W", "_", fid) if fid else "f_unknown"
138
+ if decl:
139
+ out.append(f"local {var} = pf({decl})\n")
140
+ role = {"kind": f.kind, "var": var, "off": f.offset, "w": f.width, "le": f.endian == "le"}
141
+ if f.kind in ("value", "bitfield"):
142
+ role.update(scale=f.scale, bias=f.bias, idx=f.index)
143
+ if f.kind == "bitfield":
144
+ role.update(bo=f.bit_offset, bw=f.bit_width)
145
+ if f.kind == "lookup":
146
+ role.update(idx=f.index, table={v: k for k, v in f.table.items()})
147
+ if f.kind == "checksum":
148
+ role.update(algo=f.algo, a=f.covers[0], z=f.covers[1])
149
+ roles.append(role)
150
+ consts = [(f.offset, f.const) for f in c.fields if f.kind in ("const", "command")]
151
+ setup = None
152
+ if c.setup:
153
+ s = c.setup
154
+ setup = (s[0], s[1], s[2] | s[3] << 8)
155
+ cmds.append((c, cid, roles, consts, setup))
156
+
157
+ out.append("\nlocal COMMANDS = {\n")
158
+ for c, cid, roles, consts, setup in cmds:
159
+ frames = ", ".join(map(str, c.frames[:8]))
160
+ out.append(f" -- {c.name}: {c.confidence:.0%} confidence, evidence frames {frames}\n")
161
+ cs = ", ".join(f"{{{o}, 0x{v:02x}}}" for o, v in consts)
162
+ su = f"{{0x{setup[0]:02x}, 0x{setup[1]:02x}, 0x{setup[2]:04x}}}" if setup else "nil"
163
+ rs = []
164
+ for r in roles:
165
+ parts = [f'kind = "{r["kind"]}"', f'f = {r["var"]}', f'off = {r["off"]}', f'w = {r["w"]}',
166
+ f'le = {"true" if r["le"] else "false"}']
167
+ for k in ("scale", "bias"):
168
+ if k in r:
169
+ parts.append(f"{k} = {r[k]!r}")
170
+ for k in ("idx", "bo", "bw", "a", "z"):
171
+ if k in r:
172
+ parts.append(f"{k} = {r[k]}")
173
+ if "table" in r:
174
+ parts.append("table = { " + ", ".join(f"[{b}] = {v!r}" for b, v in sorted(r["table"].items())) + " }")
175
+ if "algo" in r:
176
+ parts.append(f'check = function(d) return {_LUA_CHECKSUMS[r["algo"]]} end')
177
+ rs.append(" { " + ", ".join(parts) + " },\n")
178
+ out.append(f''' {{ name = {_lua_str(c.name)}, ep = {c.endpoint}, control = {"true" if setup else "false"}, setup = {su},
179
+ length = {c.length}, consts = {{ {cs} }},
180
+ roles = {{
181
+ {"".join(rs)} }} }},
182
+ ''')
183
+ out.append("}\n")
184
+ out.append(f'''
185
+ P.fields = fields
186
+
187
+ local fd_capdata = Field.new("usb.capdata")
188
+ local fd_fragment = Field.new("usb.data_fragment")
189
+ local fd_endpoint = Field.new("usb.endpoint_address")
190
+ local fd_reqtype = Field.new("usb.bmRequestType")
191
+ local fd_request = Field.new("usb.setup.bRequest")
192
+ local fd_wvalue = Field.new("usb.setup.wValue")
193
+ local ok_hid, fd_hid = pcall(Field.new, "usbhid.data")
194
+ if not ok_hid then fd_hid = nil end
195
+
196
+ local function payload()
197
+ for _, fd in ipairs({{ fd_capdata, fd_fragment, fd_hid }}) do
198
+ local fi = fd and fd()
199
+ if fi and fi.range and fi.len > 0 then return fi.range end
200
+ end
201
+ end
202
+
203
+ local function matches(c, r)
204
+ if r:len() ~= c.length then return false end
205
+ if c.control then
206
+ local rt, rq, wv = fd_reqtype(), fd_request(), fd_wvalue()
207
+ if not (rt and rq and wv) then return false end
208
+ if rt.value ~= c.setup[1] or rq.value ~= c.setup[2] or wv.value ~= c.setup[3] then return false end
209
+ else
210
+ local ep = fd_endpoint()
211
+ if not ep or ep.value ~= c.ep then return false end
212
+ end
213
+ for _, k in ipairs(c.consts) do
214
+ if r(k[1], 1):uint() ~= k[2] then return false end
215
+ end
216
+ return true
217
+ end
218
+
219
+ local function raw(role, r)
220
+ local s = r(role.off, role.w)
221
+ return role.le and s:le_uint() or s:uint(), s
222
+ end
223
+
224
+ function P.dissector(tvb, pinfo, tree)
225
+ local r = payload()
226
+ if not r then return end
227
+ for _, c in ipairs(COMMANDS) do
228
+ if matches(c, r) then
229
+ local t = tree:add(P, r)
230
+ t:add(f_command, r, c.name)
231
+ local vals, ok = {{}}, nil
232
+ for _, role in ipairs(c.roles) do
233
+ local v, s = raw(role, r)
234
+ local add = role.le and t.add_le or t.add
235
+ if role.kind == "value" then
236
+ local x = (v - role.bias) / role.scale
237
+ add(t, role.f, s, x); vals[#vals + 1] = fmt(x, role.scale)
238
+ elseif role.kind == "bitfield" then
239
+ local bits = math.floor(v / 2 ^ role.bo) % 2 ^ role.bw
240
+ local x = (bits - role.bias) / role.scale
241
+ add(t, role.f, s, x):append_text(string.format(" (bits %d-%d)", role.bo, role.bo + role.bw - 1))
242
+ vals[#vals + 1] = fmt(x, role.scale)
243
+ elseif role.kind == "lookup" then
244
+ add(t, role.f, s); vals[#vals + 1] = role.table[v] and fmt(role.table[v]) or string.format("0x%02x?", v)
245
+ elseif role.kind == "checksum" then
246
+ local want = role.check(bytes_of(r(role.a, role.z - role.a)))
247
+ local item = add(t, role.f, s)
248
+ ok = (ok ~= false) and (v == want)
249
+ if v == want then item:append_text(" [correct]")
250
+ else
251
+ item:append_text(string.format(" [incorrect, should be 0x%x]", want))
252
+ item:add_proto_expert_info(e_bad_checksum)
253
+ end
254
+ elseif role.kind == "unknown" then
255
+ t:add(f_unknown, s)
256
+ else
257
+ add(t, role.f, s)
258
+ end
259
+ end
260
+ if ok ~= nil then t:add(f_checksum_ok, ok):set_generated() end
261
+ pinfo.cols.protocol:set({_lua_str(p.upper())})
262
+ pinfo.cols.info:set(c.name .. (#vals > 0 and (" " .. table.concat(vals, " ")) or "")
263
+ .. (ok == false and " [bad checksum]" or ""))
264
+ return
265
+ end
266
+ end
267
+ end
268
+
269
+ register_postdissector(P)
270
+ ''')
271
+ return "".join(out)