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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ build
.cache
__pycache__/
*.pyc
.venv/
.pytest_cache/
8 changes: 8 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,11 @@ add_executable(WiFiDriverTxDemo
txdemo/main.cpp
)
target_link_libraries(WiFiDriverTxDemo PUBLIC WiFiDriver PRIVATE PkgConfig::libusb)

# Pre-modulator subcarrier PoC: transmits PSDU bytes shaped by
# tools/precoder/encode_subcarriers.py so chosen OFDM data subcarriers carry
# chosen bits. Single-stream BPSK / HT MCS0 / BCC on 8812/8821/8811.
add_executable(PrecoderDemo
txdemo/precoder_demo/main.cpp
)
target_link_libraries(PrecoderDemo PUBLIC WiFiDriver PRIVATE PkgConfig::libusb)
39 changes: 39 additions & 0 deletions demo/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,45 @@ static void packetProcessor(const Packet &packet) {
hits, g_rx_count, packet.Data.size());
fflush(stdout);
}
/* DEVOURER_DUMP_SCRAMBLER=1: print the descrambler seed the chip
* recovered from this frame's SERVICE field. Consumed by
* tools/precoder/seed_probe.py --mode rx to learn the seed a precoder TX
* chip uses. CAVEAT: the seed is only trustworthy when *this* RX adapter
* is an RTL8814AU — the 8812/8821 RX descriptor doesn't expose it there
* (see FrameParser.cpp). On 8812/8821 prefer seed_probe.py --mode
* bruteforce. Gated + SA-filtered so it doesn't flood. */
static const bool dump_scrambler =
std::getenv("DEVOURER_DUMP_SCRAMBLER") != nullptr;
if (dump_scrambler && (hits <= 20 || hits % 100 == 0)) {
printf("<devourer-scrambler>seed=0x%02x rate=%u hits=%d len=%zu\n",
packet.RxAtrib.scrambler, packet.RxAtrib.data_rate, hits,
packet.Data.size());
fflush(stdout);
}
/* DEVOURER_DUMP_BODY=1: print the RX rate index (DESC_RATE*: 0x04=6M
* OFDM, 0x00=1M CCK, 0x0c+=HT/VHT MCS) and the 802.11 frame body
* (everything after the 24-byte mgmt header) as hex. Consumed by
* tests/precoder_roundtrip.py to confirm a PrecoderDemo frame flew as
* 6M OFDM and that its shaped PSDU bytes round-tripped intact — the
* two-adapter, no-SDR verification. First few hits only. */
static const bool dump_body = std::getenv("DEVOURER_DUMP_BODY") != nullptr;
if (dump_body && hits <= 5) {
/* Tier-2 health diagnostics alongside the byte mirror: rate (0x04 =
* 6M OFDM), per-stream RSSI/EVM/SNR (link quality — content-blind),
* crc (always 0: CRC-failed frames are dropped upstream, so reaching
* here is itself the decode-sanity signal). Then the body hex. */
printf("<devourer-body>rate=%u rssi=%d,%d evm=%d,%d snr=%d,%d crc=%d "
"len=%zu body=",
packet.RxAtrib.data_rate, packet.RxAtrib.rssi[0],
packet.RxAtrib.rssi[1], packet.RxAtrib.evm[0],
packet.RxAtrib.evm[1], packet.RxAtrib.snr[0],
packet.RxAtrib.snr[1], packet.RxAtrib.crc_err ? 1 : 0,
packet.Data.size());
for (size_t i = 24; i < packet.Data.size(); ++i)
printf("%02x", packet.Data[i]);
printf("\n");
fflush(stdout);
}
}
}
}
Expand Down
18 changes: 18 additions & 0 deletions src/FrameParser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ static rx_pkt_attrib rtl8812_query_rx_desc_status(uint8_t *pdesc) {
pattrib.stbc = GET_RX_STATUS_DESC_STBC_8812(pdesc);
pattrib.bw = GET_RX_STATUS_DESC_BW_8812(pdesc);

/* Descrambler seed recovered by the chip from the RX SERVICE field. This is
* read at the RTL8814AU descriptor offset (DWORD4 bits 9-15, i.e.
* GET_RX_STATUS_DESC_RX_SCRAMBLER_8814A in hal/rtl8814a_recv.h). NOTE: the
* 8812/8821 RX status descriptor does NOT lay the scrambler out here — that
* region holds the rate-info bits read just above (SPLCP/LDPC/STBC/BW occupy
* bits 0-5). The field is therefore only meaningful when the RX chip is an
* RTL8814AU; on 8812/8821 it reflects reserved/other bits and must not be
* trusted. Used solely by the DEVOURER_DUMP_SCRAMBLER diagnostic — for the
* precoder PoC (which targets 8812/8821 TX) the brute-force seed search in
* tools/precoder/seed_probe.py is the reliable path. */
pattrib.scrambler = (uint8_t)LE_BITS_TO_4BYTE(pdesc + 16, 9, 7);

/* Offset 20 */
/* pattrib.tsfl=(byte)GET_RX_STATUS_DESC_TSFL_8812(pdesc); */

Expand Down Expand Up @@ -192,6 +204,12 @@ std::vector<Packet> FrameParser::recvbuf2recvframe(std::span<uint8_t> ptr) {
* 8814AU. */
ret.back().RxAtrib.snr[2] = static_cast<int8_t>(driver_data.csi_current[0]);
ret.back().RxAtrib.snr[3] = static_cast<int8_t>(driver_data.csi_current[1]);
/* Per-stream RX EVM: streams 1/2 in rxevm, 3/4 in rxevm_cd (8814 only;
* 0 on 8812/8811). Link-quality only — see rx_pkt_attrib::evm. */
ret.back().RxAtrib.evm[0] = driver_data.rxevm[0];
ret.back().RxAtrib.evm[1] = driver_data.rxevm[1];
ret.back().RxAtrib.evm[2] = driver_data.rxevm_cd[0];
ret.back().RxAtrib.evm[3] = driver_data.rxevm_cd[1];
} else {
/* pkt_rpt_type == TX_REPORT1-CCX, TX_REPORT2-TX RTP,HIS_REPORT-USB HISR
* RTP */
Expand Down
8 changes: 8 additions & 0 deletions src/FrameParser.h
Original file line number Diff line number Diff line change
Expand Up @@ -290,11 +290,19 @@ struct rx_pkt_attrib
uint8_t stbc;
uint8_t ldpc;
uint8_t sgi;
/* Descrambler seed the chip recovered from this frame's SERVICE field.
* Trustworthy only on RTL8814AU (see FrameParser.cpp); surfaced for the
* DEVOURER_DUMP_SCRAMBLER hook in demo/main.cpp. */
uint8_t scrambler;
/* RSSI / SNR per RF path: A, B (Jaguar 8812/8811) plus C, D (8814AU). On
* non-8814 chips the [2..3] slots are zero — the upstream RX phy-status
* report reserves those bytes when only 2 paths are active. */
uint8_t rssi[4];
int8_t snr[4];
/* Per-stream RX EVM (A,B on 8812/8811; plus C,D on 8814). Raw RX-status
* bytes — a link-quality metric, NOT a per-subcarrier or content signal.
* Surfaced for the DEVOURER_DUMP_BODY Tier-2 health diagnostic. */
int8_t evm[4];
RX_PACKET_TYPE pkt_rpt_type;
};

Expand Down
255 changes: 255 additions & 0 deletions tests/precoder_roundtrip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Two-adapter, no-SDR round-trip test for the precoder PoC.

TX one devourer adapter (PrecoderDemo, shaped PSDU) and RX a second devourer
adapter (WiFiDriverDemo, monitor mode) on the same channel, then check:

1. TRANSPORT - the canonical-SA frame is received at all (hits > 0).
2. PHY RATE - it flew as 6 Mbps legacy OFDM (RX rate index == DESC_RATE6M
= 0x04), NOT the 1 Mbps CCK fallback (0x00) that an HT-MCS
radiotap would cause (see the precoder README). This is the
on-hardware check for the legacy-vs-CCK correction.
3. BYTES - the received PSDU body equals the encoder's shaped bytes.

WHAT THIS DOES *NOT* PROVE: per-subcarrier IQ. A bit-level Wi-Fi RX demodulates
to bytes (it runs the same descramble/Viterbi/deinterleave that inverts whatever
the chip did), so the bytes always round-trip regardless of whether our
subcarrier model matches the chip. Confirming that chosen OFDM data subcarriers
carry chosen values needs the IQ domain — i.e. the SDR FFT (fft_capture.py) or
a CSI-capable rig. Two Wi-Fi adapters verify the *transport + rate* precondition
for that, not the subcarrier shaping itself.

Targets RTL8812AU / RTL8821AU / RTL8811AU at 2.4 GHz (devourer RX is solid
there). Run as root, two adapters plugged:

# Generate the shaped PSDU as your normal user (uv-managed deps):
cd tools/precoder && uv run python encode_subcarriers.py \\
--pattern target.txt --scrambler-seed 0x5d --psdu-out /tmp/shaped.bin
# Then run the round-trip as root:
sudo python3 tests/precoder_roundtrip.py \\
--tx-pid 0x8812 --rx-pid 0x8813 --psdu /tmp/shaped.bin --channel 6

Omit --psdu and the script will try to generate one via `uv run` in
tools/precoder (needs the uv env; awkward under sudo — prefer pre-generating).
"""

from __future__ import annotations

import argparse
import os
import re
import subprocess
import sys
import threading
import time
from pathlib import Path

HERE = Path(__file__).resolve().parent
REPO = HERE.parent
PRECODER = REPO / "tools" / "precoder"

DESC_RATE6M = 0x04 # legacy OFDM 6 Mbps; CCK is 0x00-0x03, HT/VHT MCS is 0x0c+
N_SD_LEGACY = 48

_HIT_RE = re.compile(r"<devourer-tx-hit>")
# Tolerant of the Tier-2 health fields inserted between rate= and len=.
_BODY_RE = re.compile(r"<devourer-body>rate=(\d+).*? len=(\d+) body=([0-9a-fA-F]*)")
_HEALTH_RE = re.compile(r"rssi=([-\d,]+) evm=([-\d,]+) snr=([-\d,]+) crc=(\d+)")


def rate_name(idx: int) -> str:
if idx <= 0x03:
return f"CCK {[1, 2, 5.5, 11][idx]}M (DSSS — NOT OFDM!)"
if idx <= 0x0B:
return f"legacy OFDM {[6, 9, 12, 18, 24, 36, 48, 54][idx - 4]}M"
return f"HT/VHT MCS (idx 0x{idx:02x})"


def make_pattern(path: Path, n_sym: int, n_sd: int = N_SD_LEGACY) -> None:
"""Deterministic ±1 pattern file (stdlib only — no numpy in the harness)."""
import random
rng = random.Random(0xC0DE)
vals = [rng.choice((1, -1)) for _ in range(n_sym * n_sd)]
path.write_text("# deterministic precoder_roundtrip pattern\n"
+ "\n".join(str(v) for v in vals) + "\n")


def gen_shaped(out: Path, seed: int, n_sym: int) -> bytes:
"""Generate a shaped PSDU via the uv-managed encoder; return its bytes."""
pat = out.with_suffix(".pattern.txt")
make_pattern(pat, n_sym)
cmd = ["uv", "run", "python", "encode_subcarriers.py",
"--pattern", str(pat), "--phy", "legacy",
"--scrambler-seed", f"0x{seed:02x}", "--psdu-out", str(out)]
print(f"[gen] {' '.join(cmd)} (cwd={PRECODER})")
subprocess.run(cmd, cwd=PRECODER, check=True)
return out.read_bytes()


class Reader(threading.Thread):
"""Drain a subprocess' stdout into a line list (so readline can't wedge us)."""

def __init__(self, proc: subprocess.Popen):
super().__init__(daemon=True)
self.proc = proc
self.lines: list[str] = []
self._stop = False

def run(self) -> None:
assert self.proc.stdout is not None
for line in self.proc.stdout:
self.lines.append(line)
if self._stop:
break

def stop(self) -> None:
self._stop = True


def launch(binary: str, pid: str, vid: str, channel: int, extra_env: dict,
args_list: "list[str] | None" = None) -> subprocess.Popen:
env = dict(os.environ, DEVOURER_PID=pid, DEVOURER_VID=vid,
DEVOURER_CHANNEL=str(channel), DEVOURER_USB_QUIET="1", **extra_env)
return subprocess.Popen([binary, *(args_list or [])], env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1)


def run_test(args) -> int:
# 1. shaped PSDU bytes (the expected body).
if args.psdu:
expected = Path(args.psdu).read_bytes()
print(f"[gen] using supplied PSDU {args.psdu} ({len(expected)} bytes)")
else:
expected = gen_shaped(Path(args.workdir) / "shaped.bin",
args.seed, args.n_sym)
shaped_path = args.psdu or str(Path(args.workdir) / "shaped.bin")

if args.dry_run:
print(f"[dry-run] would TX {args.tx_bin} --psdu {shaped_path} on "
f"pid={args.tx_pid}, RX {args.rx_bin} on pid={args.rx_pid}, "
f"channel {args.channel}, {args.duration}s; expect rate=0x04 "
f"(6M OFDM) and body[:{len(expected)}] == shaped.")
return 0

# 2. RX first, give it time to bring the radio up.
print(f"[rx] launching {args.rx_bin} vid={args.rx_vid} pid={args.rx_pid} "
f"ch{args.channel}")
rx = launch(args.rx_bin, args.rx_pid, args.rx_vid, args.channel,
{"DEVOURER_DUMP_BODY": "1"})
rx_reader = Reader(rx)
rx_reader.start()
time.sleep(args.rx_warmup)

# 3. TX.
print(f"[tx] launching {args.tx_bin} --psdu {shaped_path} vid={args.tx_vid} "
f"pid={args.tx_pid}")
tx_env = dict(os.environ, DEVOURER_PID=args.tx_pid, DEVOURER_VID=args.tx_vid,
DEVOURER_CHANNEL=str(args.channel), DEVOURER_USB_QUIET="1")
tx = subprocess.Popen([args.tx_bin, "--psdu", shaped_path], env=tx_env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

# 4. collect for the duration.
deadline = time.monotonic() + args.duration
try:
while time.monotonic() < deadline:
if any(_BODY_RE.search(l) for l in rx_reader.lines):
time.sleep(1.0) # let a couple more land
break
time.sleep(0.5)
finally:
for p in (tx, rx):
p.terminate()
rx_reader.stop()
for p in (tx, rx):
try:
p.wait(timeout=3)
except subprocess.TimeoutExpired:
p.kill()

# 5. verdict.
hits = sum(1 for l in rx_reader.lines if _HIT_RE.search(l))
body_lines = [l for l in rx_reader.lines if _BODY_RE.search(l)]
bodies = [_BODY_RE.search(l) for l in body_lines]
if args.keep:
log = Path(args.workdir) / "rx.log"
log.write_text("".join(rx_reader.lines))
print(f"[keep] RX log -> {log}")

print(f"\n--- precoder round-trip ({args.tx_pid} TX -> {args.rx_pid} RX) ---")
ok = True

print(f"[1/3] transport: {hits} canonical-SA frame(s) received",
"PASS" if hits else "FAIL")
ok &= hits > 0

if not bodies:
print("[2/3] phy rate: no <devourer-body> line — FAIL")
print("[3/3] bytes: n/a — FAIL")
return 1 if not args.allow_fail else 0

m = bodies[0]
rate = int(m.group(1))
rx_body = bytes.fromhex(m.group(3))
print(f"[2/3] phy rate: idx 0x{rate:02x} = {rate_name(rate)} — "
+ ("PASS" if rate == DESC_RATE6M else "FAIL"))
ok &= rate == DESC_RATE6M

# Tier-2 link-health diagnostics (info only — content-blind, never a
# per-subcarrier or control signal; see the precoder README).
h = _HEALTH_RE.search(body_lines[0])
if h:
print(f"[ -- ] link health (info, not control): rssi={h.group(1)} "
f"evm={h.group(2)} snr={h.group(3)} crc_err={h.group(4)}")

n = min(len(expected), len(rx_body))
match = n > 0 and rx_body[:n] == expected[:n]
print(f"[3/3] bytes: {n}/{len(expected)} body bytes compared, "
f"{'identical' if match else 'DIVERGENT'} — "
+ ("PASS" if match else "FAIL"))
ok &= match
if not match and n:
diff = next((i for i in range(n) if rx_body[i] != expected[i]), None)
print(f" first diff at byte {diff}: rx={rx_body[diff]:#04x} "
f"expected={expected[diff]:#04x}")

print("RESULT:", "PASS" if ok else "FAIL")
return 0 if ok or args.allow_fail else 1


def main(argv: "list[str] | None" = None) -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--tx-pid", required=True, help="DEVOURER_PID of the TX adapter")
ap.add_argument("--rx-pid", required=True, help="DEVOURER_PID of the RX adapter")
ap.add_argument("--tx-vid", default="0x0bda", help="DEVOURER_VID of TX adapter "
"(default 0x0bda; e.g. 0x2357 for a TP-Link T2U Plus)")
ap.add_argument("--rx-vid", default="0x0bda", help="DEVOURER_VID of RX adapter")
ap.add_argument("--channel", type=int, default=6)
ap.add_argument("--psdu", help="pre-generated shaped PSDU (recommended); "
"omit to auto-generate via uv")
ap.add_argument("--seed", type=lambda s: int(s, 0), default=0x5D,
help="scrambler seed for auto-generation")
ap.add_argument("--n-sym", type=int, default=8,
help="OFDM symbols of shaped payload (auto-generation)")
ap.add_argument("--tx-bin", default=str(REPO / "build" / "PrecoderDemo"))
ap.add_argument("--rx-bin", default=str(REPO / "build" / "WiFiDriverDemo"))
ap.add_argument("--duration", type=float, default=20.0)
ap.add_argument("--rx-warmup", type=float, default=12.0,
help="seconds to let the RX radio come up before TX. Some "
"chips are slow to init (RTL8812AU RX needed ~15s on "
"the bench; 10s was flaky) — raise this if transport "
"reports 0 frames")
ap.add_argument("--workdir", default="/tmp/precoder-roundtrip")
ap.add_argument("--keep", action="store_true", help="save the RX log")
ap.add_argument("--dry-run", action="store_true",
help="generate the shaped PSDU and print the plan; no USB")
ap.add_argument("--allow-fail", action="store_true",
help="always exit 0 (report only)")
args = ap.parse_args(argv)
os.makedirs(args.workdir, exist_ok=True)
return run_test(args)


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading