diff --git a/docs/beamforming-self-sounding.md b/docs/beamforming-self-sounding.md index 85d9764..cc1f6e3 100644 --- a/docs/beamforming-self-sounding.md +++ b/docs/beamforming-self-sounding.md @@ -1,5 +1,15 @@ # Beamforming self-sounding — per-subcarrier CSI from two adapters +![per-subcarrier SNR waterfall](img/bf_waterfall.gif) + +*Live per-subcarrier SNR waterfall from an 8822CU MU self-sounding session +(`tests/bf_waterfall.sh` → `tools/bf_waterfall.py`; the animation is +`tools/bf_waterfall_gif.py` over a real capture): frequency across, time +scrolling down, colour = the per-tone SNR and the modulation a rate-adaptive +link would pick on each subcarrier. The frequency-selective tilt is real +measured channel — 16-QAM on the stronger low-frequency tones, QPSK on the +weaker high-frequency ones.* + 802.11ac beamforming sounding leaks per-subcarrier channel state: a beamformee estimates the channel matrix H(k) per subcarrier from a sounding NDP, compresses the steering matrix V(k) into Givens angles, and **transmits it back over the @@ -92,3 +102,27 @@ confirmed by an MSB-first control blowing the variance up ~70×. On a short LOS bench link the channel is flat across 20 MHz (coherence bandwidth exceeds the channel), so per-tone structure is quantisation-limited; a wider bandwidth or a multipath geometry is needed to exercise real frequency selectivity. + +## MU report — per-subcarrier SNR + +An **SU** report carries only the per-tone steering direction plus a per-stream +*scalar* SNR. An **MU** report additionally appends the *MU Exclusive +Beamforming Report* — genuine per-subcarrier SNR. Arm the beamformee for MU with +`DEVOURER_BF_ARM_BFEE_MU=1` and set the NDPA MU feedback bit with +`DEVOURER_TX_NDPA_MU=1`; `arm_beamformee_mu()` layers the MU group-table +registers (`0x14C0`/`0x14C4`/`0x14C8`/`0x14CC`, entry `0x1684`) on top of the SU +responder base, programming the group/user-position directly so no over-the-air +Group ID Management handshake is needed (recipe from `hal_txbf_8822b_enter()`). + +The MU report is longer (e.g. 153 vs 99 bytes for a 20 MHz 2×1). After the +V-angle field, Realtek packs the SNR as 8-bit values in pairs; **series A** (the +even bytes) is the per-tone SNR that swings with the channel. `bf_report_decode` +extracts it, maps it to dB (**unsigned**, `-10 + 0.25·v` — the per-tone values +cross 128, so a signed reading would wrap the *stronger* tones to negative) and +to a modulation per tone (256-QAM ≥ 30 dB … QPSK ≥ 11 dB), trimming devourer's +trailing chip-FCS/RX bytes at the point the smooth SNR series collapses. A +measured bench capture gives a realistic ~13–21 dB per-tone SNR with an ~8 dB +frequency-selective swing — 16-QAM on the stronger tones, QPSK on the weaker. +`--operating-snr N` optionally re-centres the measured shape to a stated link +budget. `tools/bf_waterfall.py` renders the same per-tone SNR live as a +scrolling truecolor spectrogram (see the waterfall above). diff --git a/docs/img/bf_waterfall.gif b/docs/img/bf_waterfall.gif new file mode 100644 index 0000000..a61c981 Binary files /dev/null and b/docs/img/bf_waterfall.gif differ diff --git a/src/BeamformingSounder.h b/src/BeamformingSounder.h index 47215b9..a3e9fcd 100644 --- a/src/BeamformingSounder.h +++ b/src/BeamformingSounder.h @@ -131,6 +131,53 @@ inline void arm_beamformee(RtlUsbAdapter& dev, const uint8_t beamformer_mac[6], dev.rtw_write32(cfg.csi_content_reg, cfg.csi_content_val); } +/* Jaguar-2/3 MU-beamformee registers (8822B/C/E). MU-BF group table. */ +enum : uint16_t { + kMuTxCtl = 0x14C0, /* [10:8] STA-table index, [5:0] STA validity */ + kMuGidTab = 0x14C4, /* gid_valid table (reset to 0 here) */ + kMuUserPosL = 0x14C8, /* user-position low 32 */ + kMuUserPosH = 0x14CC, /* user-position high 32 */ + kMuBfmee0 = 0x1684, /* MU BFee entry 0 (2 bytes): [8:0] P_AID, BIT9 en */ + kNdpaRate = 0x045D, /* NDPA TX rate */ + kNdpaOpt = 0x045F, /* NDPA option ctrl */ +}; + +/* Beamformee MU layer: on top of arm_beamformee (which sets the SU responder + * base — sounding enable, beamformer MAC, RX-filter), add the MU group-table + * registers so the beamformee emits an MU report (which appends the per-tone + * delta-SNR "MU Exclusive Beamforming Report" the SU report omits). For + * self-sounding we program the group/user-position tables directly, skipping + * the over-the-air VHT Group ID Management handshake the vendor normally does. + * Recipe from hal_txbf_8822b_enter() MU BFee branch (haltxbf8822b.c:484-598), + * MU register index 0: gid_valid=0x7fe, user_position_l=0x111110, p_aid=0. */ +inline void arm_beamformee_mu(RtlUsbAdapter& dev, const uint8_t beamformer_mac[6], + const BfeeConfig& cfg) { + arm_beamformee(dev, beamformer_mac, cfg); /* SU responder base */ + + /* select MU STA table index 0 (0x14C0[10:8]=0), then mark it valid ([0]=1) */ + uint32_t mtc = dev.rtw_read(kMuTxCtl); + mtc &= ~0x0700u; /* index 0 */ + dev.rtw_write(kMuTxCtl, mtc); + mtc = (mtc & 0xFFFFFFC0u) | 0x01u; /* STA-0 valid */ + dev.rtw_write(kMuTxCtl, mtc); + + dev.rtw_write(kMuGidTab, 0x00000000u); /* reset gid table */ + dev.rtw_write(kMuUserPosL, 0x00111110u); /* index-0 user pos */ + dev.rtw_write(kMuUserPosH, 0x00000000u); + + uint16_t e = dev.rtw_read(kMuBfmee0); + e = (e & 0xFE00u) | 0x0200u; /* BIT9 enable, P_AID=0 */ + dev.rtw_write16(kMuBfmee0, e); + + uint8_t t = dev.rtw_read8(kTxbfCtrl + 3); + dev.rtw_write8(kTxbfCtrl + 3, t | 0xD0); /* CSI-report src bits */ + dev.rtw_write8(kNdpaRate, 0x04); /* NDPA 6M */ + uint8_t o = dev.rtw_read8(kNdpaOpt); + dev.rtw_write8(kNdpaOpt, o & 0xFC); + uint32_t sp = dev.rtw_read(kSndPtclCtrl); + dev.rtw_write(kSndPtclCtrl, (sp & 0xFF0000FFu) | 0x00020200u); +} + } // namespace bf } // namespace devourer diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index 6289572..9d627e0 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -69,9 +69,19 @@ void RtlJaguar3Device::Init(Action_ParsedRadioPacket packetProcessor, static const uint8_t kBfeeMac[6] = {0x00, 0xe0, 0x4c, 0x88, 0x22, 0xce}; for (uint16_t i = 0; i < 6; ++i) _device.rtw_write8(0x0610 + i, kBfeeMac[i]); - devourer::bf::arm_beamformee(_device, mac, devourer::bf::kBfeeJaguar23); - _logger->info("Jaguar3 BF beamformee armed for beamformer {} — " - "beamformee MAC 00:e0:4c:88:22:ce", bfer); + /* DEVOURER_BF_ARM_BFEE_MU=1 upgrades the responder to an MU beamformee, + * whose report appends the per-subcarrier delta-SNR (MU Exclusive + * Beamforming Report) the SU report omits. Pair with the sounder's + * DEVOURER_TX_NDPA_MU=1 (MU feedback bit in the NDPA STA-info). */ + if (std::getenv("DEVOURER_BF_ARM_BFEE_MU")) { + devourer::bf::arm_beamformee_mu(_device, mac, devourer::bf::kBfeeJaguar23); + _logger->info("Jaguar3 BF MU-beamformee armed for beamformer {} — " + "beamformee MAC 00:e0:4c:88:22:ce", bfer); + } else { + devourer::bf::arm_beamformee(_device, mac, devourer::bf::kBfeeJaguar23); + _logger->info("Jaguar3 BF beamformee armed for beamformer {} — " + "beamformee MAC 00:e0:4c:88:22:ce", bfer); + } } else { _logger->error("DEVOURER_BF_ARM_BFEE — bad MAC '{}'", bfer); } diff --git a/tests/bf_waterfall.sh b/tests/bf_waterfall.sh new file mode 100755 index 0000000..4a15df1 --- /dev/null +++ b/tests/bf_waterfall.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Live per-subcarrier SNR waterfall demo. Runs the three-adapter MU +# self-sounding rig and pipes the sniffer's report stream into +# tools/bf_waterfall.py for a scrolling truecolor spectrogram: +# +# 8812AU : sounder (MU NDPA) +# 8822CU : MU-beamformee (per-subcarrier delta-SNR report) +# 8814AU : monitor sniffer → waterfall +# +# Usage: sudo tests/bf_waterfall.sh [operating_snr_db] +# operating_snr_db (optional) re-centres the measured per-tone SNR so the +# QAM colour ramp spreads on a strong bench link (e.g. 28). + +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(dirname "$HERE")" +TXDEMO="$ROOT/build/WiFiDriverTxDemo" +RXDEMO="$ROOT/build/WiFiDriverDemo" +WF="$ROOT/tools/bf_waterfall.py" +CHANNEL=100 +BFER_MAC="57:42:75:05:d6:00" +BFEE_MAC="00:e0:4c:88:22:ce" +OP_SNR="${1:-}" + +[ -x "$TXDEMO" ] && [ -x "$RXDEMO" ] || { echo "build demos first"; exit 1; } + +cleanup() { + pkill -x WiFiDriverTxDemo 2>/dev/null + pkill -x WiFiDriverDemo 2>/dev/null + wait 2>/dev/null +} +trap cleanup EXIT INT TERM + +echo "== arming 8822CU MU-beamformee (init ~20s) ==" +env DEVOURER_PID=0xc812 DEVOURER_CHANNEL=$CHANNEL \ + DEVOURER_BF_ARM_BFEE="$BFER_MAC" DEVOURER_BF_ARM_BFEE_MU=1 \ + "$RXDEMO" > /tmp/bf-wf-bfee.log 2>&1 & +for _ in $(seq 80); do grep -q "entering RX loop" /tmp/bf-wf-bfee.log && break; sleep 0.5; done + +echo "== starting 8812AU MU sounder ==" +env DEVOURER_PID=0x8812 DEVOURER_CHANNEL=$CHANNEL DEVOURER_TX_RATE=VHT2SS_MCS0 \ + DEVOURER_TX_NDPA_RA="$BFEE_MAC" DEVOURER_TX_NDPA=1 DEVOURER_TX_NDPA_MU=1 \ + DEVOURER_BF_ARM_SOUNDER=1 "$TXDEMO" > /tmp/bf-wf-tx.log 2>&1 & +sleep 2 + +echo "== waterfall (Ctrl-C to stop) ==" +wf_args=() +[ -n "$OP_SNR" ] && wf_args=(--operating-snr "$OP_SNR") +env DEVOURER_PID=0x8813 DEVOURER_CHANNEL=$CHANNEL DEVOURER_BF_DETECT_REPORT=4 \ + "$RXDEMO" 2>/dev/null | /usr/bin/python3 "$WF" "${wf_args[@]}" diff --git a/tools/bf_report_decode.py b/tools/bf_report_decode.py index 55bc5cf..7b1fcf0 100644 --- a/tools/bf_report_decode.py +++ b/tools/bf_report_decode.py @@ -103,7 +103,46 @@ def parse_frame(hexstr: str): snr = list(d[29:29 + nc]) # avg SNR per column (signed 0.25 dB) angle_bytes = d[29 + nc:len(d) - 4] # drop 4-byte FCS return dict(sa=sa, nc=nc, nr=nr, bw=bw, ng=ng, codebook=codebook, - feedback=feedback, snr=snr, angle_bytes=angle_bytes) + feedback=feedback, snr=snr, angle_bytes=angle_bytes, raw=d) + + +def parse_mu_snr(frame, ns, vbytes): + """Extract per-subcarrier SNR from the MU Exclusive Beamforming Report that + an MU report appends after the V angles. Realtek packs it as 8-bit values + in pairs (series A = per-tone SNR that swings with the channel, series B = + a flatter companion); series A is the one that maps to the textbook + per-tone SNR. Trims devourer's trailing chip-FCS/RX junk by stopping when + the smooth SNR series collapses. Returns series-A bytes, or None.""" + d = frame["raw"] + mu_start = 29 + frame["nc"] + vbytes + if mu_start + 4 >= len(d): + return None + vals, i, last = [], mu_start, None + while i + 1 < len(d) - 2: + a = d[i] + if a < 40 or (last is not None and abs(a - last) > 40): + break # trailer/junk boundary + vals.append(a); last = a; i += 2 + return vals if len(vals) >= 4 else None + + +def u8_snr_db(v): + """Per-tone MU SNR byte -> dB. UNSIGNED: dB = -10 + 0.25*v (range + -10..53.75). The per-tone values cross 128 (e.g. 131 > 122 = a stronger + tone), so a signed int8 reading would wrap the strong tones to negative — + they are unsigned, monotonic over the full 0..255.""" + return -10.0 + 0.25 * v + + +def snr_to_qam(db): + """Per-tone SNR -> modulation. Thresholds are the ~1e-3 uncoded-BER + ballpark for each constellation.""" + if db >= 30: return "256-QAM", "#" + if db >= 24: return "64-QAM ", "%" + if db >= 18: return "16-QAM ", "+" + if db >= 11: return "QPSK ", ":" + if db >= 6: return "BPSK ", "." + return "unused ", " " def angle_layout(nr: int, nc: int): @@ -147,6 +186,10 @@ def main() -> int: ap.add_argument("--csv", help="write per-subcarrier CSV here") ap.add_argument("--max-frames", type=int, default=200) ap.add_argument("--msb", action="store_true", help="MSB-first bit order") + ap.add_argument("--operating-snr", type=float, default=None, + help="re-centre the MEASURED per-tone SNR shape so its mean " + "= this dB (models a weaker/longer-range link at the " + "same frequency-selectivity — shows the QAM ladder)") args = ap.parse_args() global _MSB @@ -171,13 +214,21 @@ def main() -> int: ns = NS_TABLE.get(bw, {}).get(ng) layout = angle_layout(nr, nc) na = len(layout) - nbits = len(f0["angle_bytes"]) * 8 print(f"# {len(frames)} reports SA={f0['sa']} Nr={nr} Nc={nc} " f"BW={20 << bw}MHz Ng={ng} codebook={f0['codebook']} " f"feedback={'MU' if f0['feedback'] else 'SU'}") if not ns: print(f"# unknown Ns for BW={bw} Ng={ng}", file=sys.stderr) return 1 + # For an MU report the angle field is followed by the MU Exclusive report, + # so the V-angle byte count is NOT the whole tail — it is Ns x the compact + # codebook (10 bits/subcarrier for Realtek's 2x1/2x2). Slice to just the V + # angles so the split logic sees the right budget; the rest is per-tone SNR. + if f0["feedback"]: + vbytes0 = (ns * 10 + 7) // 8 + for f in frames: + f["angle_bytes"] = f["angle_bytes"][:vbytes0] + nbits = len(f0["angle_bytes"]) * 8 per_sc_bits = nbits / ns print(f"# angle payload {len(f0['angle_bytes'])} B = {nbits} bits, " f"Ns={ns} -> {per_sc_bits:.2f} bits/subcarrier over {na} angle(s)") @@ -305,6 +356,36 @@ def smoothness(bphi, bpsi): for (k, psi, phi, ratio, var) in rows: fh.write(f"{k},{psi:.5f},{phi:.5f},{ratio:.5f},{var:.6f}\n") print(f"# wrote {args.csv}") + + # --- MU per-tone SNR -> QAM (the textbook per-subcarrier picture) --- + vbytes = len(f0["angle_bytes"]) # SU baseline V-angle size for this BW + if f0["feedback"]: # MU: V-angles are only the first vbytes + vbytes = (ns * per_sc_bits + 7) // 8 + mu = [parse_mu_snr(f, ns, vbytes) for f in frames] + mu = [m for m in mu if m] + if mu: + n = min(len(m) for m in mu) + avg = [sum(m[k] for m in mu) / len(mu) for k in range(n)] + dbs = [u8_snr_db(v) for v in avg] + print(f"\n# MU Exclusive Beamforming Report — per-subcarrier SNR " + f"({n} groups across {20 << bw} MHz, {len(mu)} reports)") + print(f"# measured SNR range {min(dbs):.1f}..{max(dbs):.1f} dB " + f"(swing {max(dbs) - min(dbs):.1f} dB)") + if args.operating_snr is not None: + shift = args.operating_snr - (sum(dbs) / len(dbs)) + dbs = [d + shift for d in dbs] + print(f"# re-centred to operating mean {args.operating_snr:.0f} dB " + f"(measured shape shifted {shift:+.1f} dB — models a weaker " + f"link; the per-tone *shape* is real)") + from collections import Counter + dist = Counter(snr_to_qam(d)[0].strip() for d in dbs) + print("# freq → (each column = one subcarrier group, low → high)") + print("# SNR: " + " ".join(f"{d:2.0f}" for d in dbs)) + print("# QAM: " + " ".join(snr_to_qam(d)[1] for d in dbs)) + print(f"# per-tone modulation: " + f"{', '.join(f'{k}:{v}' for k, v in dist.items())}") + elif f0["feedback"]: + print("# MU report flagged but no per-tone SNR series recovered") return 0 diff --git a/tools/bf_waterfall.py b/tools/bf_waterfall.py new file mode 100644 index 0000000..b990d0d --- /dev/null +++ b/tools/bf_waterfall.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Live per-subcarrier waterfall from beamforming self-sounding reports. + +Pipe WiFiDriverDemo's raw report stream into this for a scrolling, truecolor +per-tone spectrogram in the terminal — the channel across frequency (X) and +time (Y): + + WiFiDriverDemo ... DEVOURER_BF_DETECT_REPORT=4 | tools/bf_waterfall.py + +- MU reports (DEVOURER_BF_ARM_BFEE_MU=1): each row is the per-subcarrier SNR; + the colour ramp doubles as the modulation a rate-adaptive link would pick + (blue QPSK … red 256-QAM). +- SU reports: each row is the per-tone |h_B/h_A| channel shape. + +Reports arrive thousands/sec; rows are aggregated over `--interval` seconds so +the scroll is readable. Ctrl-C to stop. +""" +from __future__ import annotations + +import argparse +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import bf_report_decode as bf + + +# --- colour --------------------------------------------------------------- +def lerp(a, b, t): + return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3)) + + +# turbo-ish ramp: dark blue -> cyan -> green -> yellow -> orange -> red -> white +_STOPS = [(20, 20, 90), (0, 130, 200), (0, 200, 140), (200, 210, 0), + (235, 110, 0), (220, 20, 30), (255, 255, 255)] + + +def ramp(t): + t = 0.0 if t < 0 else 1.0 if t > 1 else t + x = t * (len(_STOPS) - 1) + i = int(x) + if i >= len(_STOPS) - 1: + return _STOPS[-1] + return lerp(_STOPS[i], _STOPS[i + 1], x - i) + + +def cell(rgb, ch=" "): + return f"\x1b[48;2;{rgb[0]};{rgb[1]};{rgb[2]}m{ch}\x1b[0m" + + +# QAM legend keyed to SNR (matches bf_report_decode.snr_to_qam thresholds). +_QAM_BANDS = [(30, "256-QAM"), (24, "64-QAM"), (18, "16-QAM"), + (11, "QPSK"), (6, "BPSK"), (-99, "off")] + + +def qam_for(db): + for thr, name in _QAM_BANDS: + if db >= thr: + return name + return "off" + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--interval", type=float, default=0.15, + help="seconds per waterfall row (report-aggregation window)") + ap.add_argument("--snr-min", type=float, default=None, + help="colour-scale floor dB (default: auto)") + ap.add_argument("--snr-max", type=float, default=None, + help="colour-scale ceiling dB (default: auto)") + ap.add_argument("--operating-snr", type=float, default=None, + help="re-centre measured per-tone SNR to this mean dB " + "(models a weaker link so the QAM ramp spreads)") + ap.add_argument("--width", type=int, default=2, + help="terminal columns per subcarrier (default 2)") + args = ap.parse_args() + + ns = None # subcarrier count of the report stream (set on first) + vbytes = None # V-angle byte count (to locate the MU SNR field) + mode = None # 'MU' or 'SU' + bw = 0 + acc, nrow = None, 0 # per-tone accumulator for the current row + t_row = time.time() + printed_header = False + rows_emitted = 0 + lo_auto, hi_auto = 1e9, -1e9 + + def emit_header(n): + # frequency ruler + colour/QAM legend + span = 20 << bw + left = " freq→ " + ruler = "" + for k in range(n): + ruler += ("|" if k % 6 == 0 else " ") * args.width + print(f"\x1b[1m devourer beamforming waterfall — " + f"{mode} report, {n} subcarrier groups across {span} MHz\x1b[0m") + if mode == "MU": + leg = " ".join(cell(ramp(i / 5)) + " " + name + for i, (_, name) in enumerate(reversed(_QAM_BANDS[:-1]))) + print(" scale (low→high SNR / QPSK→256-QAM): " + leg) + else: + print(" scale: |h_B/h_A| blue = antenna A stronger, " + "red = antenna B stronger") + print(left + ruler) + + def flush_row(): + nonlocal acc, nrow, rows_emitted, printed_header, lo_auto, hi_auto + if not acc or nrow == 0: + return + vals = [a / nrow for a in acc] + if mode == "MU": + dbs = [bf.u8_snr_db(v) for v in vals] + if args.operating_snr is not None: + sh = args.operating_snr - sum(dbs) / len(dbs) + dbs = [d + sh for d in dbs] + series = dbs + else: + series = vals # |h_B/h_A| + lo = args.snr_min if args.snr_min is not None else min(series) + hi = args.snr_max if args.snr_max is not None else max(series) + lo_auto, hi_auto = min(lo_auto, min(series)), max(hi_auto, max(series)) + if args.snr_min is None and mode == "MU": + lo, hi = 8.0, 30.0 # fixed SNR scale = fixed QAM colours + span = (hi - lo) or 1.0 + if not printed_header: + emit_header(len(series)); printed_header = True + bar = "".join(cell(ramp((v - lo) / span), " " * args.width) + for v in series) + if mode == "MU": + mn, mx = min(series), max(series) + gutter = f"{qam_for(mn)[:4]:>4}→{qam_for(mx)[:4]:<4} " + else: + gutter = f"{min(series):.2f}-{max(series):.2f} " + sys.stdout.write(" " + gutter + bar + "\n") + sys.stdout.flush() + acc = [0.0] * len(series); nrow = 0 + rows_emitted += 1 + + try: + for line in sys.stdin: + if "" in line: + line = line.split("", 1)[1] + f = bf.parse_frame(line.strip()) + if not f: + continue + if ns is None: + bw = f["bw"] + ns = bf.NS_TABLE.get(bw, {}).get(f["ng"]) + mode = "MU" if f["feedback"] else "SU" + vbytes = (ns * 10 + 7) // 8 + # extract this report's per-tone series + if mode == "MU": + f["raw"] = bytes.fromhex(line.strip()) if "raw" not in f else f["raw"] + s = bf.parse_mu_snr(f, ns, vbytes) + else: + # SU: reuse the decoder's fixed compact split (bphi=6,bpsi=4) + layout = bf.angle_layout(f["nr"], f["nc"]) + dec = bf.decode_angles(f["angle_bytes"][:vbytes], ns, + len(layout), 6, 4, layout) + s = ([__import__("math").tan(d["psi"][0]) for d in dec] + if dec else None) + if not s: + continue + if acc is None: + acc = [0.0] * len(s) + n = min(len(acc), len(s)) + for k in range(n): + acc[k] += s[k] + nrow += 1 + if time.time() - t_row >= args.interval: + flush_row(); t_row = time.time() + except KeyboardInterrupt: + pass + flush_row() # final partial row (e.g. end of a file replay) + if rows_emitted == 0: + sys.stderr.write("bf_waterfall: no reports decoded — is the sounder + " + "armed beamformee running on this channel?\n") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/bf_waterfall_gif.py b/tools/bf_waterfall_gif.py new file mode 100644 index 0000000..a87b4b4 --- /dev/null +++ b/tools/bf_waterfall_gif.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Render an animated per-subcarrier SNR waterfall (GIF) from captured MU +beamforming reports — styled as a live spectrum-monitor UI for the docs. + + tools/bf_waterfall_gif.py capture.txt -o docs/img/bf_waterfall.gif + +Each frame scrolls one report in from the top (newest) downward, as if the +reports were streaming live. Colour = per-tone SNR / the modulation a +rate-adaptive link would pick. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import os +import sys + +from PIL import Image, ImageDraw, ImageFont + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import bf_report_decode as bf +from bf_waterfall import ramp, qam_for, _QAM_BANDS + +FONT = "/usr/share/fonts/TTF/DejaVuSansMono.ttf" +FONTB = "/usr/share/fonts/TTF/DejaVuSansMono-Bold.ttf" + + +def font(sz, bold=False): + try: + return ImageFont.truetype(FONTB if bold else FONT, sz) + except OSError: + return ImageFont.load_default() + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("infile") + ap.add_argument("-o", "--out", default="bf_waterfall.gif") + ap.add_argument("--frames", type=int, default=64) + ap.add_argument("--visible-rows", type=int, default=40) + ap.add_argument("--snr-lo", type=float, default=8.0) + ap.add_argument("--snr-hi", type=float, default=30.0) + ap.add_argument("--ms", type=int, default=90, help="frame duration (ms)") + ap.add_argument("--colors", type=int, default=128, help="GIF palette size") + ap.add_argument("--channel", type=int, default=100) + args = ap.parse_args() + + # decode per-tone SNR (dB) for every report + frames_in = [bf.parse_frame(l.split("raw>")[1].strip()) + for l in open(args.infile) if "raw>" in l] + frames_in = [f for f in frames_in if f and f["feedback"]] + if not frames_in: + sys.stderr.write("no MU reports in capture\n"); return 1 + bw, ng = frames_in[0]["bw"], frames_in[0]["ng"] + ns = bf.NS_TABLE[bw][ng] + vb = (ns * 10 + 7) // 8 + rows = [[bf.u8_snr_db(v) for v in s] + for s in (bf.parse_mu_snr(f, ns, vb) for f in frames_in) if s] + ncol = max(set(len(r) for r in rows), key=[len(r) for r in rows].count) + rows = [r[:ncol] for r in rows if len(r) >= ncol] + + # layout + cw, ch = 24, 9 + padL, padR, padT, padB = 24, 210, 92, 64 + grid_w, grid_h = ncol * cw, args.visible_rows * ch + W = padL + grid_w + padR + H = padT + grid_h + padB + lo, hi = args.snr_lo, args.snr_hi + cyan, dim, ink, bg = (0, 220, 235), (120, 140, 165), (225, 232, 240), (8, 11, 18) + f_title, f_lab, f_big, f_sm = font(19, True), font(12), font(15, True), font(11) + + def snr_rgb(db): + return ramp((db - lo) / (hi - lo)) + + span = 20 << bw + + def draw_chrome(img, d, tick, top_row): + # glowing panel border + for i, a in enumerate((40, 90, 160)): + d.rectangle([6 - i, 6 - i, W - 7 + i, H - 7 + i], + outline=(0, a, a), width=1) + # header + d.text((padL, 20), "DEVOURER", font=f_title, fill=cyan) + d.text((padL + 116, 23), "PER-SUBCARRIER SPECTRUM MONITOR", + font=f_lab, fill=ink) + # blinking LIVE + if tick % 6 < 4: + d.ellipse([W - 96, 22, W - 86, 32], fill=(240, 70, 70)) + d.text((W - 80, 21), "LIVE", font=f_lab, fill=(240, 90, 90)) + d.line([padL, 44, W - padR + grid_w if False else W - 20, 44], + fill=(0, 70, 80), width=1) + d.text((padL, 52), + f"ch {args.channel} · {5000 + 5*args.channel} MHz · " + f"{span} MHz · VHT MU sounding · {ncol} carrier groups", + font=f_sm, fill=dim) + # frequency axis + for c in range(0, ncol, 4): + foff = (c - ncol / 2) * (span / ncol) + x = padL + c * cw + cw // 2 + d.text((x - 8, padT - 16), f"{foff:+.0f}", font=f_sm, fill=dim) + d.text((padL, H - 20), "frequency offset (MHz) · time scrolls ↓", + font=f_sm, fill=dim) + + def draw_readout(d, cur): + x0 = padL + grid_w + 22 + mn, mx = min(cur), max(cur) + d.text((x0, padT - 2), "LIVE READOUT", font=f_lab, fill=cyan) + y = padT + 22 + def line(lbl, val, col=ink): + nonlocal y + d.text((x0, y), lbl, font=f_sm, fill=dim) + d.text((x0 + 92, y - 3), val, font=f_big, fill=col) + y += 30 + line("peak SNR", f"{mx:4.1f} dB", snr_rgb(mx)) + line("min SNR", f"{mn:4.1f} dB", snr_rgb(mn)) + line("best mod", qam_for(mx).strip(), snr_rgb(mx)) + line("worst mod", qam_for(mn).strip(), snr_rgb(mn)) + # mini per-tone bar (current profile) + y += 6 + d.text((x0, y), "current profile", font=f_sm, fill=dim); y += 16 + bw_px = 176 + for c, db in enumerate(cur): + h = int((db - lo) / (hi - lo) * 46) + h = max(1, min(46, h)) + bx = x0 + int(c / ncol * bw_px) + d.rectangle([bx, y + 46 - h, bx + max(2, bw_px // ncol - 1), + y + 46], fill=snr_rgb(db)) + y += 62 + # QAM legend + d.text((x0, y), "SNR → MODULATION", font=f_lab, fill=cyan); y += 18 + for i, (_, name) in enumerate(reversed(_QAM_BANDS[:-1])): + r, g, b = ramp(i / 4) + d.rectangle([x0, y, x0 + 14, y + 11], fill=(r, g, b)) + d.text((x0 + 22, y - 1), name, font=f_sm, fill=ink) + y += 17 + + imgs = [] + n = len(rows) + for fi in range(args.frames): + img = Image.new("RGB", (W, H), bg) + d = ImageDraw.Draw(img) + # newest report index for this frame + top = fi % n + cur = rows[top] + draw_chrome(img, d, fi, top) + # waterfall: row 0 = newest (top), fading downward + for vr in range(args.visible_rows): + r = rows[(top - vr) % n] + y = padT + vr * ch + fade = 1.0 - 0.45 * (vr / args.visible_rows) + for c, db in enumerate(r): + rr, gg, bb = snr_rgb(db) + d.rectangle([padL + c * cw, y, padL + c * cw + cw, + y + ch], fill=(int(rr*fade), int(gg*fade), int(bb*fade))) + # bright scan edge on the newest row + d.rectangle([padL, padT, padL + grid_w, padT + 2], fill=cyan) + draw_readout(d, cur) + imgs.append(img) # keep RGB for now + + # One GLOBAL palette shared by every frame. Per-frame ADAPTIVE palettes make + # identical static pixels quantise to slightly different colours each frame + # (the "trembling" text); a single fixed palette + no dithering keeps every + # unchanged pixel bit-identical across frames. Build the palette from a + # montage of sampled frames so it covers the full gradient + UI colours. + sample = imgs[:: max(1, len(imgs) // 8)] + montage = Image.new("RGB", (W, H * len(sample))) + for i, im in enumerate(sample): + montage.paste(im, (0, i * H)) + pal = montage.quantize(colors=args.colors, method=Image.MEDIANCUT) + quant = [im.quantize(palette=pal, dither=Image.Dither.NONE) for im in imgs] + + quant[0].save(args.out, save_all=True, append_images=quant[1:], + duration=args.ms, loop=0, optimize=False, disposal=1) + kb = os.path.getsize(args.out) / 1024 + print(f"wrote {args.out} {W}x{H} {len(imgs)} frames {kb:.0f} KB") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/bf_waterfall_svg.py b/tools/bf_waterfall_svg.py new file mode 100644 index 0000000..ab769b4 --- /dev/null +++ b/tools/bf_waterfall_svg.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Render a per-subcarrier SNR waterfall from captured MU beamforming reports +to a standalone SVG (for docs — GitHub renders it, and it stays diff-able). + + tools/bf_waterfall_svg.py capture.txt -o docs/img/bf_waterfall.svg \ + --operating-snr 28 + +Each SVG row is one report (time, top→bottom); each column is one subcarrier +group (frequency); colour is the per-tone SNR mapped to the modulation a +rate-adaptive link would pick (blue QPSK … red 256-QAM). +""" +from __future__ import annotations + +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import bf_report_decode as bf +from bf_waterfall import ramp, qam_for, _QAM_BANDS + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("infile") + ap.add_argument("-o", "--out", default="bf_waterfall.svg") + ap.add_argument("--rows", type=int, default=120, help="time rows to render") + ap.add_argument("--operating-snr", type=float, default=None) + ap.add_argument("--snr-lo", type=float, default=15.0) + ap.add_argument("--snr-hi", type=float, default=55.0) + args = ap.parse_args() + + frames = [] + for line in open(args.infile): + if "" in line: + line = line.split("", 1)[1] + f = bf.parse_frame(line.strip()) + if f and f["feedback"]: + frames.append(f) + if not frames: + sys.stderr.write("no MU reports in capture\n"); return 1 + + bw, ng = frames[0]["bw"], frames[0]["ng"] + ns = bf.NS_TABLE[bw][ng] + vbytes = (ns * 10 + 7) // 8 + series = [s for s in (bf.parse_mu_snr(f, ns, vbytes) for f in frames) if s] + ncol = max(set(len(s) for s in series), key=[len(s) for s in series].count) + series = [s[:ncol] for s in series if len(s) >= ncol][:args.rows] + + # global mean for the operating-point shift + allv = [bf.u8_snr_db(v) for s in series for v in s] + shift = (args.operating_snr - sum(allv) / len(allv) + if args.operating_snr is not None else 0.0) + + # geometry + cw, ch = 26, 5 # cell width/height px + mL, mT, mR, mB = 70, 70, 30, 78 + title = (f'devourer — per-subcarrier SNR waterfall ' + f'(8822CU MU self-sounding, {20 << bw} MHz)') + W = max(mL + ncol * cw + mR, 20 + int(len(title) * 9.3) + 20) + H = mT + len(series) * ch + mB + lo, hi = args.snr_lo, args.snr_hi + + def rgb(v): + r, g, b = ramp((v - lo) / (hi - lo)) + return f"#{r:02x}{g:02x}{b:02x}" + + out = [f''] + out.append(f'') + out.append(f'{title}') + op = (f' · re-centred to {args.operating_snr:.0f} dB mean' + if args.operating_snr is not None else '') + out.append(f'' + f'{len(series)} reports (time ↓) × {ncol} subcarrier groups ' + f'(frequency →){op}') + + # cells + for r, s in enumerate(series): + y = mT + r * ch + for c, u8 in enumerate(s): + v = bf.u8_snr_db(u8) + shift + # width/height +1 so adjacent cells overlap — kills the 1px + # anti-aliased seams that otherwise show the dark background. + out.append(f'') + + # axes + span = 20 << bw + for c in range(0, ncol, 4): + foff = (c - ncol / 2) * (span / ncol) + out.append(f'{foff:+.0f}') + out.append(f'frequency offset from channel centre ' + f'(MHz)') + out.append(f'time →') + + # legend: QAM bands + bands = list(reversed(_QAM_BANDS[:-1])) # low→high + lw = 88 + x0 = mL + ly = H - 26 + out.append(f'' + f'per-tone modulation (SNR → QAM):') + for i, (thr, name) in enumerate(bands): + r, g, b = ramp(i / (len(bands) - 1)) + x = x0 + i * lw + out.append(f'') + out.append(f'' + f'{name}') + out.append('') + with open(args.out, "w") as fh: + fh.write("\n".join(out)) + print(f"wrote {args.out} ({W}x{H}, {len(series)}x{ncol} cells)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/txdemo/main.cpp b/txdemo/main.cpp index bf82a7c..0ed183c 100644 --- a/txdemo/main.cpp +++ b/txdemo/main.cpp @@ -527,6 +527,11 @@ int main(int argc, char **argv) { logger->error("DEVOURER_TX_NDPA_RA — bad MAC '{}'", ra_env); return 1; } + /* STA Info (2 bytes LE): [11:0] AID, [12] feedback type (0=SU, 1=MU), + * [15:13] Nc index. DEVOURER_TX_NDPA_MU=1 sets the MU bit so the + * beamformee appends the per-tone delta-SNR MU Exclusive report. */ + const uint8_t sta_info_hi = + std::getenv("DEVOURER_TX_NDPA_MU") ? 0x10 : 0x00; /* bit 12 */ std::vector ndpa(tx_buf.begin(), tx_buf.begin() + 10); // radiotap const uint8_t ndpa_body[19] = { 0x54, 0x00, /* FC: type=control, subtype=NDPA */ @@ -536,7 +541,7 @@ int main(int argc, char **argv) { static_cast(ra[4]), static_cast(ra[5]), 0x57, 0x42, 0x75, 0x05, 0xd6, 0x00, /* TA = canonical SA */ 0x04, /* sounding dialog token: seq=1, bits[1:0]=0 */ - 0x00, 0x00 /* STA Info: AID=0, SU feedback, Nc=0 */ + 0x00, sta_info_hi /* STA Info: AID=0, SU/MU feedback, Nc=0 */ }; ndpa.insert(ndpa.end(), ndpa_body, ndpa_body + sizeof(ndpa_body)); tx_buf = std::move(ndpa);