Skip to content

Commit 0a60faa

Browse files
committed
feat: add reusable DLCI 0x02 qhr settings decoder
Decodes any CAP-NNN-btsnoop_hci.log's DLCI 0x02 frames against the wire-confirmed RpcPacket.field5 -> qjc/qja.field4 -> qhr.fieldN nesting (PROTOCOL.md §2.2a, DECISIONS.md ADR-019), replacing one-off, per-capture manual decodes with a single general-purpose script. Unlike the original CAP-020-FINDINGS.md addendum script, it does not hardcode the HDLC Address-field/RpcPacket-header byte offset; it searches a small set of Address-field lengths and only accepts an offset that parses as a fully self-consistent, fully-consuming protobuf message, flagging disagreement between candidates instead of guessing. Verified byte-for-byte against all 6 previously wire-confirmed frames (CAP-020 #1741/#1935, CAP-021 #1895/#3619/#4315/#4976) before running against the full corpus (22 captures that open DLCI 0x02).
1 parent 5e9790e commit 0a60faa

1 file changed

Lines changed: 340 additions & 0 deletions

File tree

scripts/decode_qhr_settings.py

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Decode DLCI 0x02 ("libmaestro" Pigweed pw_hdlc channel) frames from a
4+
CAP-NNN-btsnoop_hci.log against the wire-confirmed nesting
5+
6+
HDLC frame -> unescape -> CRC-32 verify -> [Address][Control][RpcPacket bytes][CRC]
7+
RpcPacket.field5 (payload, BYTES) -> qjc/qja (deserialized) .field4 (BYTES) -> qhr.fieldN
8+
9+
This nesting, the HDLC unescape/CRC-32 method, and the `qhr` field register are
10+
all 🟢 FACT / already-confirmed per `PROTOCOL.md` §2.2a and `DECISIONS.md`
11+
ADR-019, for 4 sampled frames across CAP-020 and CAP-021
12+
(`REVERSE_ENGINEERING.md`'s `qjc`/`qja` and `qhr` entries, 2026-08-30 updates).
13+
This script mechanically applies that SAME algorithm to every DLCI 0x02 frame
14+
in any capture, instead of the prior one-off, per-frame manual decode.
15+
16+
Design note vs. the original CAP-020-FINDINGS.md addendum script: that script
17+
hardcoded a hex-editor-derived "skip 13 bytes past Address+Control before
18+
field 5" offset, which was only checked against 2 (CAP-020) + 4 (CAP-021)
19+
frames sharing one capture session (and even across those two sessions the
20+
header bytes were NOT byte-identical -- only same-length, see this script's
21+
own commit history / CHANGELOG for the verification). Rather than assume that
22+
offset generalizes to every capture (`PROTOCOL.md` §2.2a §"Not a fixed/
23+
exhaustive set" already documents that the HDLC Address field itself can be
24+
1, 2, 3, or more bytes depending on session), this script performs a small,
25+
deterministic search over Address-field lengths 1-4 and accepts an offset
26+
only when the remaining bytes parse as a FULLY self-consistent, FULLY
27+
consuming top-level protobuf message (every byte accounted for, no invalid
28+
wire type, no out-of-bounds read) that contains a field 5 of wire type 2
29+
(length-delimited) -- i.e. RpcPacket.payload. If zero or more than one
30+
Address-length candidate parses cleanly, the frame is reported UNPARSEABLE /
31+
AMBIGUOUS respectively rather than force-fit to a guessed offset, per
32+
PROJECT_RULES.md §1's "operate with zero creativity" rule.
33+
34+
This is a purely mechanical byte decode. It establishes WHICH qhr field
35+
number/value was written/read in which frame -- it does NOT itself establish
36+
what that field means or correlate it to a user action; that correlation
37+
against each capture's own CAP-NNN-EVENT-NOTES.md is a separate step (see
38+
this script's own companion analysis, not performed by this script).
39+
40+
Usage:
41+
python3 scripts/decode_qhr_settings.py <CAP-NNN-btsnoop_hci.log> [...] [--csv out.csv]
42+
43+
Requires `tshark` on PATH. For every capture log, runs exactly:
44+
tshark -r <log> -Y "btrfcomm.dlci==0x02 and btrfcomm.len>0" \\
45+
-T fields -E separator='|' \\
46+
-e frame.number -e frame.time_epoch -e frame.p2p_dir -e data.data
47+
(frame.p2p_dir 0 = Sent, 1 = Rcvd -- the convention already used throughout
48+
this project's own CAP-NNN-FINDINGS.md tshark commands, e.g. CAP-005/CAP-009.)
49+
"""
50+
import binascii
51+
import csv
52+
import struct
53+
import subprocess
54+
import sys
55+
56+
57+
def unescape_hdlc(data: bytes) -> bytes:
58+
out = bytearray()
59+
i = 0
60+
n = len(data)
61+
while i < n:
62+
b = data[i]
63+
if b == 0x7D:
64+
i += 1
65+
if i >= n:
66+
raise ValueError("truncated escape sequence")
67+
out.append(data[i] ^ 0x20)
68+
else:
69+
out.append(b)
70+
i += 1
71+
return bytes(out)
72+
73+
74+
def read_varint(data: bytes, i: int):
75+
val = 0
76+
shift = 0
77+
n = len(data)
78+
while True:
79+
if i >= n or shift > 63:
80+
return None
81+
b = data[i]
82+
val |= (b & 0x7F) << shift
83+
i += 1
84+
if not (b & 0x80):
85+
return val, i
86+
shift += 7
87+
88+
89+
def parse_message(data: bytes):
90+
"""Parse `data` as a sequence of top-level protobuf fields (tag = (field<<3)|wiretype).
91+
Returns a list of (field_num, wiretype, value) ONLY if the whole buffer is
92+
consumed with no invalid wiretype / no out-of-bounds read; else None."""
93+
i = 0
94+
n = len(data)
95+
fields = []
96+
if n == 0:
97+
return None
98+
while i < n:
99+
r = read_varint(data, i)
100+
if r is None:
101+
return None
102+
tag, i2 = r
103+
fnum = tag >> 3
104+
wt = tag & 7
105+
if fnum == 0:
106+
return None
107+
i = i2
108+
if wt == 0:
109+
r = read_varint(data, i)
110+
if r is None:
111+
return None
112+
val, i = r
113+
elif wt == 1:
114+
if i + 8 > n:
115+
return None
116+
val = data[i:i + 8]
117+
i += 8
118+
elif wt == 2:
119+
r = read_varint(data, i)
120+
if r is None:
121+
return None
122+
ln, i = r
123+
if ln < 0 or i + ln > n:
124+
return None
125+
val = data[i:i + ln]
126+
i += ln
127+
elif wt == 5:
128+
if i + 4 > n:
129+
return None
130+
val = data[i:i + 4]
131+
i += 4
132+
else:
133+
return None # wiretype 3/4 (deprecated groups) never used here
134+
fields.append((fnum, wt, val))
135+
return fields
136+
137+
138+
def find_rpc_packet_candidates(body: bytes):
139+
"""body = unescaped subframe minus its trailing 4-byte CRC. Try HDLC
140+
Address field lengths 1..4 (+ 1 Control byte) and return every
141+
(addr_len, fields) whose remainder parses as a full, self-consistent
142+
message containing a field-5 length-delimited entry (RpcPacket.payload)."""
143+
candidates = []
144+
for addr_len in (1, 2, 3, 4):
145+
start = addr_len + 1 # + 1 Control byte
146+
if start >= len(body):
147+
continue
148+
fields = parse_message(body[start:])
149+
if fields is None:
150+
continue
151+
if any(fnum == 5 and wt == 2 for fnum, wt, _ in fields):
152+
candidates.append((addr_len, fields))
153+
return candidates
154+
155+
156+
def decode_qhr(field5_bytes: bytes):
157+
"""field5_bytes = RpcPacket.payload, expected to be a serialized qjc/qja.
158+
Returns (qhr_field_num, qhr_wiretype, qhr_value) or None."""
159+
qjc_fields = parse_message(field5_bytes)
160+
if qjc_fields is None:
161+
return None
162+
f4_candidates = [v for fnum, wt, v in qjc_fields if fnum == 4 and wt == 2]
163+
if not f4_candidates:
164+
return None
165+
for qhr_bytes in f4_candidates:
166+
qhr_fields = parse_message(qhr_bytes)
167+
if qhr_fields is not None and len(qhr_fields) == 1:
168+
return qhr_fields[0]
169+
return None
170+
171+
172+
def split_subframes(raw: bytes):
173+
"""Split a raw RFCOMM payload on the 0x7E HDLC flag byte, per PROTOCOL.md
174+
§2.2a's own verification method ("split each RFCOMM payload on the 0x7E
175+
flag byte"). Returns a list of non-empty inter-flag byte spans."""
176+
parts = []
177+
cur = bytearray()
178+
for b in raw:
179+
if b == 0x7E:
180+
if cur:
181+
parts.append(bytes(cur))
182+
cur = bytearray()
183+
else:
184+
cur.append(b)
185+
if cur:
186+
parts.append(bytes(cur))
187+
return parts
188+
189+
190+
DIR_NAME = {"0": "Sent", "1": "Rcvd"}
191+
192+
WT_NAME = {0: "VARINT", 1: "FIXED64", 2: "BYTES", 5: "FIXED32"}
193+
194+
195+
def decode_capture(log_path: str):
196+
"""Yields one dict per (frame, subframe) DLCI 0x02 record."""
197+
cmd = [
198+
"tshark", "-r", log_path,
199+
"-Y", "btrfcomm.dlci==0x02 and btrfcomm.len>0",
200+
"-T", "fields", "-E", "separator=|",
201+
"-e", "frame.number", "-e", "frame.time_epoch",
202+
"-e", "frame.p2p_dir", "-e", "data.data",
203+
]
204+
proc = subprocess.run(cmd, capture_output=True, text=True, check=True)
205+
for line in proc.stdout.splitlines():
206+
line = line.strip()
207+
if not line:
208+
continue
209+
parts = line.split("|")
210+
if len(parts) != 4:
211+
continue
212+
frame_no, ts, p2p_dir, hexstr = parts
213+
if not hexstr:
214+
continue
215+
direction = DIR_NAME.get(p2p_dir, f"dir={p2p_dir}")
216+
raw = bytes.fromhex(hexstr)
217+
subframes = split_subframes(raw)
218+
for sub_idx, sub in enumerate(subframes):
219+
record = {
220+
"frame": frame_no, "timestamp": ts, "direction": direction,
221+
"subframe_index": sub_idx, "raw_hex": sub.hex(),
222+
"status": None, "qhr_field": "", "qhr_wiretype": "",
223+
"qhr_value": "", "detail": "",
224+
}
225+
if len(sub) < 5:
226+
record["status"] = "TOO_SHORT"
227+
yield record
228+
continue
229+
try:
230+
un = unescape_hdlc(sub)
231+
except ValueError as e:
232+
record["status"] = "ESCAPE_ERROR"
233+
record["detail"] = str(e)
234+
yield record
235+
continue
236+
if len(un) < 5:
237+
record["status"] = "TOO_SHORT_UNESCAPED"
238+
yield record
239+
continue
240+
body, trailer = un[:-4], un[-4:]
241+
calc = struct.pack("<I", binascii.crc32(body) & 0xFFFFFFFF)
242+
if calc != trailer:
243+
record["status"] = "CRC_MISMATCH"
244+
record["detail"] = f"calc={calc.hex()} trailer={trailer.hex()}"
245+
yield record
246+
continue
247+
candidates = find_rpc_packet_candidates(body)
248+
if not candidates:
249+
record["status"] = "NO_RPCPACKET_MATCH"
250+
yield record
251+
continue
252+
# More than one HDLC Address-field length can produce a
253+
# structurally valid top-level parse (a longer address length
254+
# can "accidentally" realign onto what is really the middle of
255+
# the true parse's own field-3/field-4 bytes, since a fixed32
256+
# field's raw bytes can themselves look like a valid tag+varint).
257+
# What matters is whether that ambiguity actually changes the
258+
# recovered qhr field -- decode every candidate down to qhr and
259+
# only flag AMBIGUOUS if they disagree; if they all agree
260+
# (empirically the common case: the shorter, "extra" leading
261+
# fields a longer addr_len misses, e.g. channel_id, sit strictly
262+
# before field 5 and never change field 5's own byte range),
263+
# report the agreed value.
264+
qhr_results = []
265+
for addr_len, top_fields in candidates:
266+
f5_list = [v for fnum, wt, v in top_fields if fnum == 5 and wt == 2]
267+
for f5 in f5_list:
268+
qhr = decode_qhr(f5)
269+
if qhr is not None:
270+
qhr_results.append((addr_len, qhr))
271+
if not qhr_results:
272+
record["status"] = "NO_QHR_MATCH"
273+
record["detail"] = f"addr_lens_tried={[c[0] for c in candidates]}"
274+
yield record
275+
continue
276+
distinct = {qhr for _, qhr in qhr_results}
277+
if len(distinct) > 1:
278+
record["status"] = "AMBIGUOUS_QHR"
279+
record["detail"] = "; ".join(
280+
f"addr_len={a}: field={q[0]} wt={q[1]} val={q[2] if q[1]==0 else q[2].hex()}"
281+
for a, q in qhr_results
282+
)
283+
yield record
284+
continue
285+
fnum, wt, val = next(iter(distinct))
286+
record["status"] = "OK"
287+
record["qhr_field"] = fnum
288+
record["qhr_wiretype"] = WT_NAME.get(wt, str(wt))
289+
if wt == 0:
290+
record["qhr_value"] = str(val)
291+
else:
292+
record["qhr_value"] = val.hex()
293+
record["detail"] = f"addr_lens_agreeing={[a for a, _ in qhr_results]}"
294+
yield record
295+
296+
297+
def main(argv):
298+
if not argv:
299+
print(__doc__)
300+
return 1
301+
csv_path = None
302+
logs = []
303+
i = 0
304+
while i < len(argv):
305+
if argv[i] == "--csv":
306+
i += 1
307+
csv_path = argv[i]
308+
else:
309+
logs.append(argv[i])
310+
i += 1
311+
312+
fieldnames = ["capture", "frame", "timestamp", "direction", "subframe_index",
313+
"status", "qhr_field", "qhr_wiretype", "qhr_value", "detail", "raw_hex"]
314+
rows = []
315+
for log_path in logs:
316+
cap_id = log_path.split("/")[-1].split("-btsnoop_hci")[0]
317+
for rec in decode_capture(log_path):
318+
row = {"capture": cap_id, **rec}
319+
rows.append(row)
320+
321+
writer = csv.DictWriter(sys.stdout, fieldnames=fieldnames)
322+
writer.writeheader()
323+
for row in rows:
324+
writer.writerow(row)
325+
326+
if csv_path:
327+
with open(csv_path, "w", newline="") as f:
328+
w = csv.DictWriter(f, fieldnames=fieldnames)
329+
w.writeheader()
330+
for row in rows:
331+
w.writerow(row)
332+
333+
ok = sum(1 for r in rows if r["status"] == "OK")
334+
print(f"# {len(rows)} DLCI 0x02 subframes across {len(logs)} capture(s); "
335+
f"{ok} decoded to a qhr field", file=sys.stderr)
336+
return 0
337+
338+
339+
if __name__ == "__main__":
340+
sys.exit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)