|
| 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