Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/beamforming-self-sounding.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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).
Binary file added docs/img/bf_waterfall.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 47 additions & 0 deletions src/BeamformingSounder.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint32_t>(kMuTxCtl);
mtc &= ~0x0700u; /* index 0 */
dev.rtw_write<uint32_t>(kMuTxCtl, mtc);
mtc = (mtc & 0xFFFFFFC0u) | 0x01u; /* STA-0 valid */
dev.rtw_write<uint32_t>(kMuTxCtl, mtc);

dev.rtw_write<uint32_t>(kMuGidTab, 0x00000000u); /* reset gid table */
dev.rtw_write<uint32_t>(kMuUserPosL, 0x00111110u); /* index-0 user pos */
dev.rtw_write<uint32_t>(kMuUserPosH, 0x00000000u);

uint16_t e = dev.rtw_read<uint16_t>(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<uint32_t>(kSndPtclCtrl);
dev.rtw_write<uint32_t>(kSndPtclCtrl, (sp & 0xFF0000FFu) | 0x00020200u);
}

} // namespace bf
} // namespace devourer

Expand Down
16 changes: 13 additions & 3 deletions src/jaguar3/RtlJaguar3Device.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
50 changes: 50 additions & 0 deletions tests/bf_waterfall.sh
Original file line number Diff line number Diff line change
@@ -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[@]}"
85 changes: 83 additions & 2 deletions tools/bf_report_decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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)")
Expand Down Expand Up @@ -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


Expand Down
Loading
Loading