This commit is contained in:
2026-09-13 13:30:21 +08:00
commit a6bbd520cf
2744 changed files with 1598795 additions and 0 deletions
+485
View File
@@ -0,0 +1,485 @@
// streamtx — stdin-driven TX for the precoder stream link.
//
// Mirrors precoder's chip-setup boilerplate (legacy 6M OFDM probe-request
// carrier, single-stream BPSK/BCC, RTL8812AU/8821AU/8811AU), but instead of
// looping on one shaped PSDU it reads a sequence of length-prefixed PSDU
// bodies from stdin and sends one probe-request per body. The encoder
// (tools/precoder/stream_tx.py) drives this binary; the two are intentionally
// split so the C++ side stays USB-only and the framing math stays in Python.
//
// On-wire frame protocol (stdin):
// <u32_le length><length bytes of descrambled PSDU body>
// EOF on stdin = orderly shutdown.
//
// Why "descrambled" body bytes: the Realtek chip applies its own scrambler
// before BCC. So the bytes we hand to `send_packet` are the bits the chip
// will scramble — i.e. the bits the encoder produced as `descramble(...)`'s
// pre-image. Symmetric on RX: DEVOURER_DUMP_BODY / DEVOURER_STREAM_OUT print
// what the chip has already descrambled. The byte stream is the same on both
// ends.
//
// Usage:
// DEVOURER_PID=0x8812 DEVOURER_CHANNEL=6 ./build/streamtx \\
// [--interval-ms MS] [--max-psdu BYTES] < bodies.bin
// uv run python tools/precoder/stream_tx.py < data.bin | \\
// ./build/streamtx
//
// Env: same conventions as the other demos (DEVOURER_VID / DEVOURER_PID /
// DEVOURER_CHANNEL / DEVOURER_SKIP_RESET).
#include <cassert>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <memory>
#include <optional>
#include <string>
#include <thread>
#include <vector>
#if defined(_MSC_VER)
/* libusb.h explicitly: the pre-seam RtlUsbAdapter.h used to pull it in
* for every consumer; the bus-neutral RtlAdapter.h no longer does. */
#include <libusb.h>
#include <io.h>
#include <fcntl.h>
#include <windows.h>
#include <process.h>
typedef int pid_t;
#define sleep(seconds) Sleep((seconds)*1000)
#elif defined(__MINGW32__) || defined(__MINGW64__)
// mingw builds: POSIX libusb/unistd PLUS io.h/fcntl.h for binary stdin.
#include <io.h>
#include <fcntl.h>
#include <unistd.h>
#include <libusb-1.0/libusb.h>
#elif defined(__ANDROID__)
#include <libusb.h>
#include <unistd.h>
#elif defined(__APPLE__)
#include <unistd.h>
#include <libusb.h>
#else
#include <unistd.h>
#include <libusb-1.0/libusb.h>
#endif
#include "DeviceSession.h"
#include "HopSchedule.h"
#include "hopset/HopsetWire.h"
#include "RadiotapBuilder.h"
#include "RtlAdapter.h"
#if defined(DEVOURER_HAVE_JAGUAR1)
#include "jaguar1/RtlJaguarDevice.h"
#endif
#include "UsbOpen.h"
#include "WiFiDriver.h"
#include "env_config.h"
#include "logger.h"
#include "stream_stdin.h"
#define USB_VENDOR_ID 0x0bda
static constexpr uint16_t kRealtekProductIds[] = {
0x8812, 0x0811, 0xa811, 0xb811, 0x8813,
};
// Identical 802.11 probe-request header to precoder; radiotap is now
// built once at startup from DEVOURER_STREAM_RATE — accepts legacy
// (6M..54M), HT (MCS0..MCS31), or VHT (VHT1SS_MCS0..VHT4SS_MCS9) carrier
// modes. Default is 6M legacy OFDM, bit-identical to the historic
// kRadiotapLegacy6M constant. Same canonical SA, same matcher in
// examples/rx/main.cpp's RX path — keep these three in lockstep, see
// CLAUDE.md.
static const std::vector<uint8_t> kStreamRadiotap =
devourer::build_stream_radiotap(devourer_tx_mode_from_env());
static const uint8_t kCanonicalSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00};
static std::vector<uint8_t> build_dot11_probe_req() {
std::vector<uint8_t> h = {
0x40, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
};
h.insert(h.end(), kCanonicalSa, kCanonicalSa + 6);
h.insert(h.end(), kCanonicalSa, kCanonicalSa + 6);
h.push_back(0x80);
h.push_back(0x00);
return h;
}
int main(int argc, char **argv) {
auto logger = std::make_shared<Logger>();
apply_logging_env(*logger); /* DEVOURER_LOG_LEVEL / DEVOURER_EVENTS / ... */
/* Events ride stderr here (overriding the stdout default): stdout is left
* clean for downstream callers that may chain this binary. */
logger->events().configure(stderr);
int interval_ms = 2;
// Lockstep sync-marker cadence: emit one marker-only frame every N data
// frames in slot-hop mode so a tracking RX keeps its slot lock (a marker only
// at each slot boundary is too sparse — a single miss drops the lock). The
// caller's FEC PSDUs are never touched; the marker rides its own frame.
int sync_every = 4;
if (const char *e = std::getenv("DEVOURER_HOP_SYNC_EVERY")) {
sync_every = std::atoi(e); // avoid std::max — windows.h's max macro
if (sync_every < 1) sync_every = 1;
}
// Sanity cap on a single PSDU body; protects against an upstream framing
// bug that would otherwise have us allocate gigabytes from a stray length
// prefix. 4096 covers any realistic legacy-6M probe-request payload.
size_t max_psdu = 4096;
long termux_fd = 0;
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
if (a == "--interval-ms" && i + 1 < argc) {
interval_ms = std::atoi(argv[++i]);
} else if (a == "--max-psdu" && i + 1 < argc) {
max_psdu = static_cast<size_t>(std::strtoul(argv[++i], nullptr, 0));
} else {
char *end = nullptr;
long v = std::strtol(a.c_str(), &end, 0);
if (end && *end == '\0' && v > 0) termux_fd = v;
}
}
// Make stdin binary so a 0x1A or CRLF doesn't corrupt PSDU bytes. Gated on
// _WIN32 (not _MSC_VER) inside the shared helper — see examples/common/stream_stdin.h.
stream_stdin::set_stdin_binary();
libusb_context *context = nullptr;
libusb_device_handle *handle = nullptr;
int rc;
/* Owns the teardown order (device -> interface -> handle -> context; see
* DeviceSession.h). Declared before every thread below, so the threads are
* joined before the adapter is released. Each early return from here on
* unwinds whatever has been adopted so far. */
devourer::DeviceSession session{logger};
if (termux_fd > 0) {
logger->info("Termux mode: wrapping fd {}", termux_fd);
libusb_set_option(NULL, LIBUSB_OPTION_NO_DEVICE_DISCOVERY);
libusb_set_option(NULL, LIBUSB_OPTION_WEAK_AUTHORITY);
libusb_init(&context);
session.adopt_context(context);
rc = libusb_wrap_sys_device(context, (intptr_t)termux_fd, &handle);
if (rc < 0) {
logger->error("libusb_wrap_sys_device: {}", rc);
return 1;
}
} else {
rc = libusb_init(&context);
if (rc < 0) return rc;
session.adopt_context(context);
uint16_t target_pid = 0;
if (const char *pid_env = std::getenv("DEVOURER_PID")) {
target_pid = static_cast<uint16_t>(std::strtoul(pid_env, nullptr, 0));
logger->info("DEVOURER_PID={:04x} (limiting to this PID)", target_pid);
}
uint16_t target_vid = USB_VENDOR_ID;
if (const char *vid_env = std::getenv("DEVOURER_VID")) {
target_vid = static_cast<uint16_t>(std::strtoul(vid_env, nullptr, 0));
}
for (uint16_t pid : kRealtekProductIds) {
if (target_pid != 0 && pid != target_pid) continue;
handle = libusb_open_device_with_vid_pid(context, target_vid, pid);
if (handle != NULL) {
logger->info("Opened device {:04x}:{:04x}", target_vid, pid);
break;
}
}
if (handle == NULL && target_pid != 0) {
handle = libusb_open_device_with_vid_pid(context, target_vid, target_pid);
}
if (handle == NULL) {
logger->error("No supported device found under VID {:04x}", target_vid);
return 1;
}
}
/* Claim-before-reset (see src/UsbOpen.h): the exclusive claim is the primary
* guard — a second devourer on this adapter gets BUSY here and bails before
* the reset, so it can't re-enumerate the adapter out from under the owner. */
std::shared_ptr<devourer::UsbDeviceLock> usb_lock;
/* Reopen variant: recovers in place when the reset re-enumerates the
* device (warm Kestrel firmware-drop through ROM / ZeroCD). */
rc = devourer::claim_interface_reset_reopen(context, handle, logger,
termux_fd == 0 && std::getenv("DEVOURER_SKIP_RESET") == nullptr, usb_lock);
if (rc != 0) {
/* The claim failed, so nothing owns the handle yet — hand it to the
* session purely so the unwind closes it. */
session.adopt_handle(handle);
return 1;
}
session.adopt_handle(handle);
session.adopt_lock(usb_lock);
WiFiDriver wifi_driver{logger};
auto stream_cfg = devourer_config_from_env();
/* FPV downlink default: disable the MAC carrier-sense gate so the video TX
* punches through co-channel traffic instead of deferring — on-air ~1.5-2.2x
* inject-rate recovery under a co-channel transmitter (SetCcaMode /
* DEVOURER_DIS_CCA). The link owns the channel, so CSMA back-off only stutters
* it. Explicit DEVOURER_DIS_CCA=0 still forces standard carrier-sense back on. */
if (std::getenv("DEVOURER_DIS_CCA") == nullptr)
stream_cfg.tuning.disable_cca = true;
auto owned_device = wifi_driver.CreateRadio(handle, nullptr, usb_lock,
stream_cfg);
/* The session owns the device from here: it is what guarantees the device
* (and its in-flight TX) dies before libusb does. */
session.adopt_device(std::move(owned_device));
IRadio *const rtlDevice = session.device();
/* Jaguar1-only research features (TXAGC override, fast-retune hopping) aren't
* on the IRadio contract — downcast for them; jag is null on Jaguar3, and
* the downcast plus its call sites compile out when Jaguar1 isn't built. */
#if defined(DEVOURER_HAVE_JAGUAR1)
RtlJaguarDevice *jag = dynamic_cast<RtlJaguarDevice *>(rtlDevice);
#endif
int channel = 6;
if (const char *ch_env = std::getenv("DEVOURER_CHANNEL")) {
channel = std::atoi(ch_env);
logger->info("DEVOURER_CHANNEL set — tuning TX to channel {}", channel);
}
/* DEVOURER_TX_POWER forces a flat TXAGC index (low single-digits for an
* attenuated/noisy bench). Useful for stress-testing the RX path's
* corruption handling — lowering this forces marginal SNR, which raises the
* chip's CRC-failure rate so the corrupted-frame surfacing path actually
* gets exercised. Unset = each family's calibrated default (SetTxPower is
* now a real flat override on EVERY generation — the old unconditional
* SetTxPower(40) here was a no-op on Jaguar1/2 and would now flatten their
* efuse per-rate table; the 8822C's 40 default lives in its reference base). */
if (const char *p = std::getenv("DEVOURER_TX_POWER"))
rtlDevice->SetTxPower(static_cast<uint8_t>(std::atoi(p)));
rtlDevice->InitWrite(SelectedChannel{.Channel = static_cast<uint8_t>(channel),
.ChannelOffset = 0,
.ChannelWidth = CHANNEL_WIDTH_20});
/* DEVOURER_TX_PWR_OVERRIDE: force an absolute per-rate TXAGC index,
* bypassing the EFUSE/SetTxPower table — the finest-grained, lowest TX-power
* knob for pushing the link into the marginal-SNR regime where the RX's
* corrupted-frame salvage path gets exercised (pairs with the B210 interferer
* in tests/fused_fec_onair.sh). Applied once and held, unlike
* txdemo's DEVOURER_TX_PWR_START ramp. Must follow InitWrite so it
* applies live. Generation-agnostic (IRadio runtime TX-power API). */
if (const char *o = std::getenv("DEVOURER_TX_PWR_OVERRIDE")) {
int idx = std::atoi(o);
rtlDevice->SetTxPowerIndexOverride(idx);
logger->info("DEVOURER_TX_PWR_OVERRIDE — forced absolute TXAGC index {}", idx);
}
/* Channel hopping for frequency diversity. DEVOURER_HOP_CHANNELS="1,6,11"
* cycles the TX channel every DEVOURER_HOP_DWELL_FRAMES PSDUs (default 1 =
* per-packet hop, which spreads an outer-FEC block's shards across channels
* so a single-channel fade/interferer only erases ~1/N of each block —
* recoverable by the RS layer; see tools/precoder/stream_fec_rs.py
* --hop-interleave). Uses FastRetune (lean intra-band retune, ~1-2 ms) unless
* DEVOURER_HOP_FAST=0 (full SetMonitorChannel) / =2 (FastRetune, no RF cache).
* Intra-band 20 MHz only; a cross-band entry falls back automatically. */
std::vector<int> hop_channels;
long hop_dwell = 1;
long hop_slot_ms = 0;
bool hop_adaptive = false;
uint32_t hop_adaptive_maskfp = 0;
std::optional<devourer::HopSchedule> hop_schedule;
const int hop_fast = std::getenv("DEVOURER_HOP_FAST")
? std::atoi(std::getenv("DEVOURER_HOP_FAST"))
: 1;
if (const char *e = std::getenv("DEVOURER_HOP_CHANNELS")) {
std::string s(e);
size_t pos = 0;
while (pos < s.size()) {
size_t c = s.find(',', pos);
std::string tok =
s.substr(pos, c == std::string::npos ? std::string::npos : c - pos);
if (!tok.empty()) {
int ch = std::atoi(tok.c_str());
if (ch > 0) hop_channels.push_back(ch);
}
if (c == std::string::npos) break;
pos = c + 1;
}
if (const char *d = std::getenv("DEVOURER_HOP_DWELL_FRAMES")) {
hop_dwell = std::strtol(d, nullptr, 0);
if (hop_dwell < 1) hop_dwell = 1;
}
if (const char *s = std::getenv("DEVOURER_HOP_SLOT_MS")) {
hop_slot_ms = std::strtol(s, nullptr, 0);
if (hop_slot_ms < 1)
throw std::invalid_argument("DEVOURER_HOP_SLOT_MS must be positive");
if (std::getenv("DEVOURER_HOP_DWELL_FRAMES"))
throw std::invalid_argument(
"hop slot and frame dwell are mutually exclusive");
}
if (const char *seed = std::getenv("DEVOURER_HOP_SEED"))
hop_schedule.emplace(devourer::HopSchedule::parse_seed(seed));
else if (hop_slot_ms > 0)
// Slot-mode sequential hopping rides the keyless schedule so it still
// emits the lockstep sync marker (same channels[slot % n] order).
hop_schedule.emplace(devourer::HopSchedule::sequential());
if (std::getenv("DEVOURER_HOP_ADAPTIVE")) {
/* Adaptive-follower compatibility: emit the v2 sync marker (v1 fields
* + generation/mask fingerprint) so an adaptive rxdemo tracks this
* stream. streamtx itself stays at generation 0 (full mask) — the
* commit authority (and its script lever) is txdemo's. */
if (!std::getenv("DEVOURER_HOP_SEED") || hop_slot_ms <= 0)
throw std::invalid_argument("DEVOURER_HOP_ADAPTIVE needs "
"DEVOURER_HOP_SEED and "
"DEVOURER_HOP_SLOT_MS");
hop_adaptive = true;
const auto keys = devourer::hopset::HopsetKeys::derive(
devourer::HopSchedule::parse_seed(std::getenv("DEVOURER_HOP_SEED")));
hop_adaptive_maskfp = devourer::hopset::mask_fp(
keys, 0, devourer::hopset::full_mask(hop_channels.size()));
}
if (!hop_channels.empty()) {
std::string list;
for (size_t i = 0; i < hop_channels.size(); ++i)
list += (i ? "," : "") + std::to_string(hop_channels[i]);
logger->info("DEVOURER_HOP_CHANNELS — stream hopping [{}] dwell={} "
"fast={}", list, hop_dwell, hop_fast);
}
}
sleep(2);
auto dot11 = build_dot11_probe_req();
std::vector<uint8_t> tx_buf;
tx_buf.reserve(kStreamRadiotap.size() + dot11.size() + max_psdu);
logger->info(
"stream TX ready (legacy 6M OFDM, ch {}); reading length-prefixed PSDUs "
"from stdin", channel);
long tx_count = 0;
const auto hop_start = std::chrono::steady_clock::now();
uint64_t last_hop_slot = UINT64_MAX;
// Per-process epoch for the lockstep sync marker (see below): lets a tracking
// RX detect a TX restart and re-anchor its slot clock.
const uint32_t hop_epoch = static_cast<uint32_t>(
std::chrono::high_resolution_clock::now().time_since_epoch().count());
std::vector<uint8_t> sync_buf;
while (true) {
std::vector<uint8_t> psdu;
uint32_t len = 0;
{
const auto r = stream_stdin::read_record(stdin, psdu, max_psdu, &len);
if (r == stream_stdin::RecordResult::Eof) break; // clean stdin close
if (r == stream_stdin::RecordResult::EofMidBody) {
logger->warn("EOF mid-PSDU (expected {} bytes)", len);
break;
}
if (r == stream_stdin::RecordResult::BadLength) {
logger->error("PSDU length {} out of range (max {}); stopping", len,
max_psdu);
break;
}
if (r != stream_stdin::RecordResult::Ok) {
logger->error("short read on stdin (expected {} bytes); record "
"truncated", len);
std::exit(2);
}
}
/* Retune to this PSDU's hop channel before sending. FastRetune/fast_retune
* is a cheap no-op when the channel is unchanged within a dwell, so calling
* it per packet is fine. */
if (!hop_channels.empty()) {
uint64_t slot =
hop_slot_ms > 0
? static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - hop_start)
.count() /
hop_slot_ms)
: static_cast<uint64_t>(tx_count / hop_dwell);
int ch = hop_schedule ? hop_schedule->channel(slot, hop_channels)
: hop_channels[slot % hop_channels.size()];
const bool slot_changed = (slot != last_hop_slot);
if (slot_changed) {
auto ev = devourer::Ev(logger->events(), "hop.dwell");
ev.f("slot", (unsigned long long)slot)
.f("round", (unsigned long long)(slot / hop_channels.size()))
.f("channel", ch);
if (hop_schedule)
ev.hexf("seed_fp", hop_schedule->fingerprint(), 8);
last_hop_slot = slot;
}
if (hop_fast)
rtlDevice->FastRetune(static_cast<uint8_t>(ch),
/*cache_rf=*/hop_fast != 2);
else
rtlDevice->SetMonitorChannel(SelectedChannel{
.Channel = static_cast<uint8_t>(ch),
.ChannelOffset = 0,
.ChannelWidth = CHANNEL_WIDTH_20});
/* Lockstep sync: emit a marker-only frame at each slot boundary AND every
* sync_every data frames, so a tracking RX keeps the TX slot clock locked
* (one marker per slot is too sparse to survive a miss). It rides its own
* frame — the caller's FEC PSDUs stay byte-for-byte untouched — with the
* canonical SA, so the RX's marker matcher sees it. Slot-hop mode only. */
if (hop_schedule && hop_slot_ms > 0 &&
(slot_changed || tx_count % sync_every == 0)) {
const uint64_t slot_us = static_cast<uint64_t>(hop_slot_ms) * 1000;
const uint64_t us = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - hop_start)
.count());
sync_buf.clear();
sync_buf.insert(sync_buf.end(), kStreamRadiotap.begin(),
kStreamRadiotap.end());
sync_buf.insert(sync_buf.end(), dot11.begin(), dot11.end());
if (hop_adaptive) {
devourer::hopset::HopSyncMarkerV2 marker;
marker.fingerprint = hop_schedule->fingerprint();
marker.epoch = hop_epoch;
marker.phase_us = static_cast<uint32_t>(us % slot_us);
marker.slot = us / slot_us;
marker.generation = 0;
marker.mask_fp = hop_adaptive_maskfp;
auto wire = devourer::hopset::HopSyncMarkerV2::encode(marker);
sync_buf.insert(sync_buf.end(), wire.begin(), wire.end());
} else {
devourer::HopSyncMarker marker{hop_schedule->fingerprint(),
hop_epoch,
static_cast<uint32_t>(us % slot_us),
us / slot_us};
auto wire = devourer::HopSyncMarker::encode(marker);
sync_buf.insert(sync_buf.end(), wire.begin(), wire.end());
}
rtlDevice->send_packet(sync_buf.data(), sync_buf.size());
}
}
tx_buf.clear();
tx_buf.insert(tx_buf.end(), kStreamRadiotap.begin(),
kStreamRadiotap.end());
tx_buf.insert(tx_buf.end(), dot11.begin(), dot11.end());
tx_buf.insert(tx_buf.end(), psdu.begin(), psdu.end());
bool ok = rtlDevice->send_packet(tx_buf.data(), tx_buf.size());
++tx_count;
// TX progress marker (event stream rides stderr in this demo, keeping
// stdout clean for downstream callers that may chain this binary).
if (tx_count <= 5 || tx_count % 500 == 0) {
devourer::Ev(logger->events(), "stream.tx")
.f("n", tx_count)
.f("ok", ok)
.f("psdu", len)
.f("total", tx_buf.size());
}
if (interval_ms > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(interval_ms));
}
}
devourer::Ev(logger->events(), "stream.done").f("sent", tx_count);
/* Device, then interface, handle and context (DeviceSession.h). Explicit
* only because the process has nothing left to do here — the destructor
* does exactly the same on every other exit path. */
session.close();
return 0;
}