This commit is contained in:
2026-09-13 13:30:21 +08:00
commit a6bbd520cf
2744 changed files with 1598795 additions and 0 deletions
+756
View File
@@ -0,0 +1,756 @@
/* chanmig — the coordinated channel-migration endpoint (issue #278).
*
* --role ground : owns the primary RX (duplex: RX video + TX proposals on one
* claimed adapter), runs the MigProposer + PeerClock, takes
* operator commands on stdin, and follows the drone's
* authoritative status.
* --role drone : the video TX. Runs the MigResponder, is the final schedule
* authority, arms its ACK responder, and airs generation-
* tagged markers on the destination at activation.
*
* Control frames are their OWN 802.11 frames (canonical-SA probe-req header +
* the "CM" magic + an authenticated MigWire message) — video PSDUs are never
* touched, satisfying "do not bury migration commands in caller-owned payload
* bytes". The pure state machines (src/chanmig/Mig*.h) do all the logic; this
* binary only stamps TSFs, encodes/MACs, transmits, retunes, and emits the
* migrate.* JSONL. Both endpoints derive a control key from DEVOURER_MIG_KEY.
*
* Env (demo-local): DEVOURER_MIG_ROLE (or --role), DEVOURER_MIG_KEY (control
* key hex), DEVOURER_MIG_ALLOWED (drone allowed-channel list),
* DEVOURER_MIG_RESCUE, DEVOURER_MIG_LINK (link id), DEVOURER_MIG_SYNTH_PPS/
* _LEN (drone synthetic video), DEVOURER_MIG_LEAD_MS, DEVOURER_MIG_DROP /
* _DROP_RX (deterministic fault filters), DEVOURER_CHANNEL (start channel),
* plus the usual device-selection vars. */
#include <libusb.h>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "DeviceSession.h"
#include "RadiotapBuilder.h"
#include "RxPacket.h"
#include "SignalStop.h"
#include "UsbOpen.h"
#include "WiFiDriver.h"
#include "IRtlRadio.h"
#include "chanmig/ChannelDef.h"
#include "chanmig/JsonlLite.h"
#include "chanmig/MigClock.h"
#include "chanmig/MigConfig.h"
#include "chanmig/MigGate.h"
#include "chanmig/MigProposer.h"
#include "chanmig/MigResponder.h"
#include "chanmig/MigWire.h"
#include "env_config.h"
#include "logger.h"
#include "usb_select.h"
namespace cm = devourer::chanmig;
using devourer::Ev;
static devourer::EventSink *g_ev = nullptr;
static IRadio *g_dev = nullptr;
/* Realtek-only view of g_dev for the frame-free energy probe; null elsewhere. */
static IRtlRadio *g_rtl = nullptr;
static std::mutex g_dev_mu; /* serialize send/retune against the RX thread */
/* The pure state machines are single-threaded by design; the demo drives them
* from the RX callback, the tick loop, and (ground) the operator thread, so
* every machine entry point takes this lock. Ordering: g_sm_mu before g_dev_mu
* (send/retune/read_tsf take g_dev_mu inside an already-held g_sm_mu). */
static std::recursive_mutex g_sm_mu;
static cm::MigKey g_key;
static uint32_t g_link = 0;
static std::atomic<bool> g_stop{false};
static const uint8_t kCanonicalSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00};
/* deterministic fault filters (demo-side, for the on-air failure scripts) */
static std::vector<cm::DropRule> g_drop_tx, g_drop_rx;
static int g_tx_idx[8] = {}, g_rx_idx[8] = {};
static long long now_ms() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
static std::vector<uint8_t> probe_req_hdr() {
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;
}
/* Build + transmit one migration control/marker frame: robust 6M radiotap +
* probe-req header + the authenticated MigWire bytes. The caller has already
* stamped tx_tsf. Honors the TX drop filter (deterministic fault injection). */
static void tx_mig(const cm::MigMsg &m) {
if (cm::drop_matches(g_drop_tx, m.type, ++g_tx_idx[m.type & 7])) {
Ev(*g_ev, "migrate.drop").f("dir", "tx").f("type", m.type);
return;
}
std::vector<uint8_t> frame =
devourer::build_stream_radiotap(devourer::parse_tx_mode_str("6M"));
const auto hdr = probe_req_hdr();
frame.insert(frame.end(), hdr.begin(), hdr.end());
const auto body = cm::mig_encode(m, g_key);
frame.insert(frame.end(), body.begin(), body.end());
std::lock_guard<std::mutex> lk(g_dev_mu);
if (g_dev)
g_dev->send_packet(frame.data(), frame.size());
}
/* Emit a migrate.state / migrate.* event for a MigAction of the logging kind. */
static void emit_action(const char *role, const cm::MigAction &a) {
if (a.kind == cm::MigAction::EmitEvent) {
Ev(*g_ev, "migrate.state")
.t()
.f("role", role)
.f("code", a.code);
} else if (a.kind == cm::MigAction::GateNotify) {
Ev(*g_ev, "migrate.gate_notify").t().f("role", role).f("result", a.code);
} else if (a.kind == cm::MigAction::Done) {
Ev(*g_ev, "migrate.done").t().f("role", role).f("code", a.code);
}
}
/* ---------------- ground ---------------- */
struct Ground {
cm::MigProposer prop;
cm::PeerClock clock;
cm::MigParams params;
cm::ChannelDef source, rescue;
/* #279 automation gate */
cm::MigMode mode = cm::MigMode::Advisory;
cm::GateState gate_state;
cm::GatePolicy gate_policy;
cm::Decision latest_rec; /* reconstructed from the recommend feed */
bool have_rec = false;
int64_t rec_age_ms = 0; /* when the latest rec arrived (monotonic) */
bool in_flight = false;
int64_t inhibit_until_ms = INT64_MIN;
bool approve_next = false;
bool pinned = false;
cm::ChannelDef pinned_ch;
Ground(cm::MigParams p, uint32_t epoch, cm::ChannelDef src, cm::ChannelDef rsc)
: prop(p, g_link, epoch, src), params(p), source(src), rescue(rsc) {}
};
static Ground *g_ground = nullptr;
static void ground_do(const std::vector<cm::MigAction> &acts) {
for (const cm::MigAction &a : acts) {
switch (a.kind) {
case cm::MigAction::SendUnicast:
case cm::MigAction::SendBroadcast: {
cm::MigMsg m = a.msg; /* ground stamps no tx_tsf (drone owns the clock) */
tx_mig(m);
break;
}
case cm::MigAction::RetuneTo: {
Ev(*g_ev, "migrate.retune").t().f("role", "ground")
.f("chan", a.channel.str().c_str());
{
std::lock_guard<std::mutex> lk(g_dev_mu);
if (g_dev)
g_dev->SetMonitorChannel(a.channel.to_selected());
}
ground_do(g_ground->prop.on_retune_done(now_ms()));
break;
}
case cm::MigAction::GateNotify:
/* keep the autonomous-gate anti-oscillation state honest */
if (a.code == 0)
cm::mig_gate_on_confirmed(g_ground->gate_state,
g_ground->prop.current_channel(), now_ms());
else
cm::mig_gate_on_rolledback(g_ground->gate_state,
g_ground->prop.current_channel(),
g_ground->gate_policy, now_ms());
g_ground->in_flight = false;
emit_action("ground", a);
break;
default:
emit_action("ground", a);
break;
}
}
}
/* Parse one channel.recommend / channel.hold feed line into the ground's
* latest advisory Decision (a minimal reconstruction — target, gen, score,
* confidence — sufficient for the gate). */
static void ground_ingest_rec(std::string_view line, int64_t now) {
Ground &g = *g_ground;
const bool rec = devourer::chanmig::jsonl_ev_is(line, "channel.recommend");
const bool hold = !rec && devourer::chanmig::jsonl_ev_is(line, "channel.hold");
if (!rec && !hold)
return;
cm::Decision d;
std::string s, hexs;
long long iv;
double dv;
if (devourer::chanmig::jsonl_str(line, "gen", &hexs))
d.evidence_gen = std::strtoull(hexs.c_str(), nullptr, 16);
else if (devourer::chanmig::jsonl_int(line, "gen", &iv))
d.evidence_gen = static_cast<uint64_t>(iv);
if (rec && devourer::chanmig::jsonl_str(line, "to", &s)) {
std::string err;
cm::ChannelDef tgt;
if (!cm::parse_chan_token(s, tgt, err))
return;
d.kind = cm::Decision::Kind::Recommend;
d.target = tgt;
d.primary_reason = cm::Reason::RecommendBetterCandidate;
cm::CandidateScore c;
c.def = tgt;
c.qualified = true;
c.score = devourer::chanmig::jsonl_num(line, "score", &dv) ? dv : 1.0;
c.confidence = devourer::chanmig::jsonl_num(line, "conf", &dv) ? dv : 1.0;
d.ranking.push_back(c);
} else {
d.kind = cm::Decision::Kind::Hold;
}
g.latest_rec = d;
g.have_rec = true;
g.rec_age_ms = now;
}
/* Run the automation gate on a cadence; in automatic/manual mode a Propose
* verdict starts a migration through the proposer. Emits migrate.gate. */
static void ground_gate_tick(int64_t now) {
Ground &g = *g_ground;
if (g.mode == cm::MigMode::Off || g.mode == cm::MigMode::Advisory)
return;
cm::GateInputs in;
in.mode = g.mode;
in.rec = g.have_rec ? &g.latest_rec : nullptr;
in.scout_survey_age_ms = g.have_rec ? now - g.rec_age_ms : INT64_MAX;
in.scout_max_age_ms = 5000;
in.clock_synced = g.clock.ready();
in.control_link_margin = 1.0; /* status/ack margin — simplified for the demo */
in.in_flight = g.in_flight ||
g.prop.state() != cm::MigState::Stable;
in.approve_next = g.approve_next;
in.inhibit_until_ms = g.inhibit_until_ms;
in.pinned = g.pinned ? &g.pinned_ch : nullptr;
cm::GateOutcome o = cm::mig_gate_decide(in, g.gate_state, g.gate_policy, now);
Ev(*g_ev, "migrate.gate").t().f("mode", cm::mig_mode_name(g.mode))
.f("verdict", static_cast<int>(o.verdict))
.f("reason", cm::gate_reason_name(o.reason));
if (o.verdict == cm::GateVerdict::Propose) {
g.approve_next = false;
/* Shadow actuation (DEVOURER_MIG_SHADOW): the gate decides and the verdict
* is recorded, but no proposal is actually sent — the #279 ladder's first
* rung, quantifying false moves without touching the live link. */
static const bool shadow = std::getenv("DEVOURER_MIG_SHADOW") != nullptr;
if (shadow) {
Ev(*g_ev, "migrate.shadow").t().f("to", o.target.str().c_str())
.f("gen", (unsigned long long)o.evidence_gen);
return;
}
g.in_flight = true;
ground_do(g.prop.start(o.target, static_cast<uint32_t>(o.evidence_gen),
g.rescue, now));
}
}
static void ground_rx(const Packet &pkt) {
if (pkt.RxAtrib.pkt_rpt_type == RX_PACKET_TYPE::C2H_PACKET)
return;
if (pkt.Data.size() < 24 + cm::kMigHeader)
return;
const uint8_t *body = pkt.Data.data() + 24;
const size_t blen = pkt.Data.size() - 24;
cm::MigMsg m;
if (cm::mig_decode(body, blen, g_key, g_link, m) == cm::MigReason::None) {
if (cm::drop_matches(g_drop_rx, m.type, ++g_rx_idx[m.type & 7]))
return;
std::lock_guard<std::recursive_mutex> lk(g_sm_mu);
/* feed the clock from the drone's TSF-stamped frames */
if (m.tx_tsf != 0)
g_ground->clock.add(m.tx_tsf, pkt.RxAtrib.tsfl);
/* #280: the drone's target-validation result is reporting-only — the
* ground logs it and flags a disagreement with its own scout evidence,
* but never applies a mask/target from it (ground authority for downlink
* quality; drone authority only to veto a locally-unsafe destination). */
if (m.type == cm::MT_VALIDATION) {
Ev(*g_ev, "migrate.validation").t().f("chan", m.target.str().c_str())
.f("method", m.method).f("result", m.result)
.f("reason", cm::mig_reason_name(static_cast<cm::MigReason>(m.reason)))
.f("nhm_busy", m.nhm_busy_pct).f("valid", m.energy_valid);
if (m.result == 1 && g_ground->have_rec &&
g_ground->latest_rec.kind == cm::Decision::Kind::Recommend &&
g_ground->latest_rec.target.same_rf(m.target))
Ev(*g_ev, "migrate.disagree").t().f("chan", m.target.str().c_str())
.f("ground_view", "clean").f("drone_view", "veto");
return;
}
ground_do(g_ground->prop.on_message(m, now_ms()));
return;
}
/* not a control frame — canonical-SA video counts toward verify */
if (pkt.Data.size() >= 16 &&
std::memcmp(pkt.Data.data() + 10, kCanonicalSa, 6) == 0) {
std::lock_guard<std::recursive_mutex> lk(g_sm_mu);
ground_do(g_ground->prop.on_video(now_ms()));
}
}
/* ---------------- drone ---------------- */
struct Drone {
cm::MigResponder resp;
cm::MigParams params;
std::atomic<bool> markers_on{false};
cm::ChannelDef marker_ch;
uint32_t marker_seq = 0;
std::atomic<bool> pump_on{true};
Drone(cm::MigParams p, uint32_t epoch, cm::ChannelDef cur, cm::MigCaps caps)
: resp(p, g_link, epoch, cur, caps), params(p) {}
};
static Drone *g_drone = nullptr;
static uint64_t read_tsf() {
std::lock_guard<std::mutex> lk(g_dev_mu);
return g_dev ? g_dev->ReadTsf() : 0;
}
static void drone_do(const std::vector<cm::MigAction> &acts) {
for (const cm::MigAction &a : acts) {
switch (a.kind) {
case cm::MigAction::SendUnicast:
case cm::MigAction::SendBroadcast: {
cm::MigMsg m = a.msg;
m.tx_tsf = read_tsf(); /* stamp at send time */
tx_mig(m);
break;
}
case cm::MigAction::RetuneTo: {
Ev(*g_ev, "migrate.retune").t().f("role", "drone")
.f("chan", a.channel.str().c_str());
bool ok = true;
{
std::lock_guard<std::mutex> lk(g_dev_mu);
try {
if (g_dev)
g_dev->SetMonitorChannel(a.channel.to_selected());
} catch (const std::exception &) {
ok = false;
}
}
drone_do(g_drone->resp.on_retune_done(ok, now_ms(), read_tsf()));
break;
}
case cm::MigAction::StartDrain:
g_drone->pump_on.store(false);
drone_do(g_drone->resp.on_drain_done(now_ms()));
break;
case cm::MigAction::ResumePump:
g_drone->pump_on.store(true);
break;
case cm::MigAction::ArmMarkers:
g_drone->marker_ch = a.channel;
g_drone->markers_on.store(true);
break;
case cm::MigAction::StopMarkers:
g_drone->markers_on.store(false);
break;
case cm::MigAction::EmitEvent:
/* the variant-B probe asks us to sample the destination now (we are on
* the target channel after the probe retune + a settle). */
if (a.code == 200) {
static const int settle_ms =
std::getenv("DEVOURER_MIG_PROBE_SETTLE_MS")
? std::atoi(std::getenv("DEVOURER_MIG_PROBE_SETTLE_MS"))
: 5;
static const int dwell_ms =
std::getenv("DEVOURER_MIG_PROBE_DWELL_MS")
? std::atoi(std::getenv("DEVOURER_MIG_PROBE_DWELL_MS"))
: 30;
std::this_thread::sleep_for(std::chrono::milliseconds(settle_ms));
double busy = 0.0;
bool valid = false;
{
std::lock_guard<std::mutex> lk(g_dev_mu);
if (g_rtl) {
(void)g_rtl->GetRxEnergy(false); /* reset the delta counters */
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(dwell_ms));
{
std::lock_guard<std::mutex> lk(g_dev_mu);
if (g_rtl) {
RxEnergy e = g_rtl->GetRxEnergy(true);
if (e.valid_nhm) {
uint32_t total = 0;
for (int k = 0; k < 12; k++)
total += e.nhm[k];
busy = total ? static_cast<double>(total - e.nhm[0]) / total : 0.0;
valid = true;
} else if (e.valid_fa) {
const double sec = dwell_ms / 1000.0;
busy = (e.cca_ofdm / (sec > 0 ? sec : 1)) / 1000.0;
if (busy > 1.0)
busy = 1.0;
valid = true;
}
}
}
Ev(*g_ev, "migrate.probe").t().f("busy", busy).f("valid", valid);
drone_do(g_drone->resp.on_probe_sample(busy, valid, now_ms()));
} else {
emit_action("drone", a);
}
break;
default:
emit_action("drone", a);
break;
}
}
}
static void drone_rx(const Packet &pkt) {
if (pkt.RxAtrib.pkt_rpt_type == RX_PACKET_TYPE::C2H_PACKET)
return;
if (pkt.Data.size() < 24 + cm::kMigHeader)
return;
cm::MigMsg m;
/* The RX frame carries a trailing FCS after the message, so mig_decode reads
* the fixed-length body and locates the MAC after it (trailer ignored). */
if (cm::mig_decode(pkt.Data.data() + 24, pkt.Data.size() - 24, g_key, g_link,
m) == cm::MigReason::None) {
if (cm::drop_matches(g_drop_rx, m.type, ++g_rx_idx[m.type & 7]))
return;
/* Read the TSF (a USB control transfer) OUTSIDE the SM lock so it never
* blocks the machine behind the pump thread's send. */
const uint64_t tsf = read_tsf();
std::lock_guard<std::recursive_mutex> lk(g_sm_mu);
drone_do(g_drone->resp.on_message(m, now_ms(), tsf));
}
}
/* ---------------- shared helpers ---------------- */
static cm::ChannelDef chan_from_env(const char *name, cm::ChannelDef def) {
const char *e = std::getenv(name);
if (!e || !*e)
return def;
cm::ChannelDef d;
std::string err;
if (cm::parse_chan_token(e, d, err))
return d;
return def;
}
int main(int argc, char **argv) {
auto logger = std::make_shared<Logger>();
apply_logging_env(*logger);
g_ev = &logger->events();
install_devourer_signal_handlers();
/* 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. */
devourer::DeviceSession session{logger};
std::string role;
for (int i = 1; i + 1 < argc; i++)
if (std::strcmp(argv[i], "--role") == 0)
role = argv[i + 1];
if (role.empty())
if (const char *r = std::getenv("DEVOURER_MIG_ROLE"))
role = r;
if (role != "ground" && role != "drone") {
logger->error("chanmig needs --role ground|drone (or DEVOURER_MIG_ROLE)");
return 2;
}
/* control key + link id */
const char *keytext = std::getenv("DEVOURER_MIG_KEY");
g_key = cm::MigKey::derive(keytext ? devourer::HopSchedule::parse_seed(keytext)
: devourer::HopSchedule::Key{});
g_link = 0xC1;
if (const char *l = std::getenv("DEVOURER_MIG_LINK"))
g_link = static_cast<uint32_t>(std::strtoul(l, nullptr, 0));
if (const char *d = std::getenv("DEVOURER_MIG_DROP"))
cm::parse_drop_spec(d, g_drop_tx);
if (const char *d = std::getenv("DEVOURER_MIG_DROP_RX"))
cm::parse_drop_spec(d, g_drop_rx);
int channel = 36;
if (const char *c = std::getenv("DEVOURER_CHANNEL"))
channel = std::atoi(c);
cm::ChannelDef source;
source.band = channel <= 14 ? 2 : 5;
source.primary = static_cast<uint8_t>(channel);
source.width = CHANNEL_WIDTH_20;
cm::normalize(source);
cm::ChannelDef rescue = chan_from_env("DEVOURER_MIG_RESCUE", source);
cm::MigParams params;
if (const char *l = std::getenv("DEVOURER_MIG_LEAD_MS"))
params.lead_ms = std::atoll(l);
/* random boot epoch (never persisted — orphans stale commits for free) */
uint32_t epoch;
{
FILE *ur = std::fopen("/dev/urandom", "rb");
if (!ur || std::fread(&epoch, sizeof(epoch), 1, ur) != 1)
epoch = static_cast<uint32_t>(now_ms());
if (ur)
std::fclose(ur);
epoch |= 1;
}
/* open the adapter (duplex: RX + TX on one claimed handle) */
libusb_context *ctx = nullptr;
if (libusb_init(&ctx) < 0)
return 1;
session.adopt_context(ctx);
static constexpr uint16_t kPids[] = {0x8812, 0xc812, 0xa81a, 0x881a,
0xc82c, 0xe822, 0x8813};
UsbPick pick;
libusb_device_handle *handle = open_selected_usb(
ctx, logger, kPids, sizeof(kPids) / sizeof(kPids[0]), &pick);
if (!handle)
return 1;
std::shared_ptr<devourer::UsbDeviceLock> lock;
if (devourer::claim_interface_reset_reopen(
ctx, handle, logger, std::getenv("DEVOURER_SKIP_RESET") == nullptr,
lock) != 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(lock);
/* Jaguar3 needs the RX filters opened at bring-up for reliable duplex. */
#ifdef _WIN32
_putenv_s("DEVOURER_TX_WITH_RX", "thread");
#else
::setenv("DEVOURER_TX_WITH_RX", "thread", 1);
#endif
WiFiDriver driver(logger);
auto owned_device =
driver.CreateRadio(handle, ctx, lock, devourer_config_from_env());
if (!owned_device) {
logger->error("no driver for this chip");
return 1;
}
/* 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 dev = session.device();
g_dev = dev;
g_rtl = dynamic_cast<IRtlRadio *>(dev);
if (!g_rtl)
logger->warn("chanmig: the frame-free energy probe is Realtek-only "
"(IRtlRadio) — probe samples are invalid on this radio");
Ev(*g_ev, "migrate.id").t().f("role", role.c_str())
.f("chip", pick.pid).f("source", source.str().c_str())
.hexf("link", g_link, 0).hexf("epoch", epoch, 8);
if (role == "drone") {
cm::MigCaps caps;
if (const char *al = std::getenv("DEVOURER_MIG_ALLOWED")) {
std::vector<cm::PlanParseError> errs;
cm::parse_mig_allowed(al, caps.allowed, errs);
}
/* #280 variant B: opt-in single-radio pre-commit probe (research). Off by
* default — variant A (legality/caps checks) is the product baseline. */
caps.probe = std::getenv("DEVOURER_MIG_PROBE") != nullptr;
if (const char *v = std::getenv("DEVOURER_MIG_VETO_BUSY"))
caps.veto_busy_frac = std::atof(v);
static Drone drone(params, epoch, source, caps);
g_drone = &drone;
dev->InitWrite(source.to_selected());
/* link-derived unicast for the ACK responder */
devourer::MacAddr am{{0x57, 0x42, 0x75, static_cast<uint8_t>(g_link >> 8),
static_cast<uint8_t>(g_link), 0x01}};
if (!dev->SetAckResponder(am)) {
logger->error("chanmig: ACK responder arm was refused; aborting drone "
"bring-up instead of running with a silent responder");
return 1;
}
std::thread rx([&] { dev->StartRxLoop(drone_rx); });
/* synthetic video pump */
const int pps = std::getenv("DEVOURER_MIG_SYNTH_PPS")
? std::atoi(std::getenv("DEVOURER_MIG_SYNTH_PPS"))
: 200;
const int plen = std::getenv("DEVOURER_MIG_SYNTH_LEN")
? std::atoi(std::getenv("DEVOURER_MIG_SYNTH_LEN"))
: 200;
std::thread pump([&] {
auto rt = devourer::build_stream_radiotap(devourer_tx_mode_from_env());
auto hdr = probe_req_hdr();
std::vector<uint8_t> f;
const int gap_us = pps > 0 ? 1000000 / pps : 5000;
while (!g_stop.load() && !g_devourer_should_stop) {
if (g_drone->pump_on.load()) {
f.clear();
f.insert(f.end(), rt.begin(), rt.end());
f.insert(f.end(), hdr.begin(), hdr.end());
f.resize(f.size() + plen, 0xAB);
std::lock_guard<std::mutex> lk(g_dev_mu);
if (g_dev)
g_dev->send_packet(f.data(), f.size());
}
std::this_thread::sleep_for(std::chrono::microseconds(gap_us));
}
});
/* marker + tick loop */
long long next_marker = 0;
while (!g_stop.load() && !g_devourer_should_stop) {
const long long t = now_ms();
const uint64_t tsf = read_tsf(); /* USB control read OUTSIDE the SM lock */
{
std::lock_guard<std::recursive_mutex> lk(g_sm_mu);
drone_do(drone.resp.on_tick(t, tsf));
if (drone.markers_on.load() && t >= next_marker) {
next_marker = t + 20;
cm::MigMsg mk;
mk.type = cm::MT_MARKER;
mk.link_id = g_link;
mk.drone_epoch = epoch;
mk.generation = drone.resp.generation();
mk.aired_on = drone.marker_ch;
mk.tx_tsf = tsf;
tx_mig(mk);
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
g_stop.store(true);
dev->StopRxLoop();
if (pump.joinable())
pump.join();
if (rx.joinable())
rx.join();
} else {
static Ground ground(params, epoch, source, rescue);
g_ground = &ground;
if (const char *m = std::getenv("DEVOURER_MIG_MODE")) {
std::string ms(m);
ground.mode = ms == "off" ? cm::MigMode::Off
: ms == "manual" ? cm::MigMode::Manual
: ms == "automatic" ? cm::MigMode::Automatic
: cm::MigMode::Advisory;
}
dev->InitWrite(source.to_selected());
std::thread rx([&] { dev->StartRxLoop(ground_rx); });
/* operator command reader (stdin) — echoed as migrate.op for audit. */
std::thread ops([&] {
char line[128];
while (!g_stop.load() && std::fgets(line, sizeof(line), stdin)) {
std::string s(line);
while (!s.empty() && (s.back() == '\n' || s.back() == '\r'))
s.pop_back();
Ev(*g_ev, "migrate.op").t().f("cmd", s.c_str());
const char *sp = std::strchr(s.c_str(), ' ');
if (s.rfind("propose", 0) == 0) {
cm::ChannelDef tgt;
std::string err;
if (sp && cm::parse_chan_token(sp + 1, tgt, err)) {
std::lock_guard<std::recursive_mutex> lk(g_sm_mu);
ground.in_flight = true;
ground_do(ground.prop.start(tgt, 0, rescue, now_ms()));
} else
logger->warn("propose <chanspec> — bad target ({})", err);
} else if (s.rfind("mode", 0) == 0 && sp) {
std::string ms(sp + 1);
ground.mode = ms == "off" ? cm::MigMode::Off
: ms == "manual" ? cm::MigMode::Manual
: ms == "automatic" ? cm::MigMode::Automatic
: cm::MigMode::Advisory;
} else if (s == "approve-next" || s == "approve") {
ground.approve_next = true;
} else if (s.rfind("inhibit", 0) == 0 && sp) {
ground.inhibit_until_ms = now_ms() + std::atoll(sp + 1) * 1000;
} else if (s.rfind("pin", 0) == 0) {
cm::ChannelDef p;
std::string err;
if (sp && cm::parse_chan_token(sp + 1, p, err)) {
ground.pinned = true;
ground.pinned_ch = p;
} else
ground.pinned = false; /* "pin none" */
} else if (s == "status" || s == "why") {
Ev(*g_ev, "migrate.status").t()
.f("state", cm::mig_state_name(ground.prop.state()))
.f("mode", cm::mig_mode_name(ground.mode))
.f("chan", ground.prop.current_channel().str().c_str())
.f("clock_ready", ground.clock.ready())
.f("resid_p99_us", (long long)ground.clock.residual_p99());
}
}
});
/* recommend feed follower (automatic/manual mode consumes it) */
std::FILE *feed = nullptr;
std::string feed_buf, feed_path;
if (const char *fp = std::getenv("DEVOURER_MIG_RECOMMEND_FEED"))
feed_path = fp;
int64_t next_gate = 0;
while (!g_stop.load() && !g_devourer_should_stop) {
const int64_t now = now_ms();
std::unique_lock<std::recursive_mutex> lk(g_sm_mu);
ground_do(ground.prop.on_tick(now));
if (!feed_path.empty()) {
if (!feed)
feed = std::fopen(feed_path.c_str(), "r");
if (feed) {
char chunk[2048];
size_t n;
while ((n = std::fread(chunk, 1, sizeof(chunk), feed)) > 0) {
feed_buf.append(chunk, n);
size_t nl;
while ((nl = feed_buf.find('\n')) != std::string::npos) {
ground_ingest_rec(std::string_view(feed_buf.data(), nl), now);
feed_buf.erase(0, nl + 1);
}
}
std::clearerr(feed);
}
}
if (now >= next_gate) {
next_gate = now + 2000;
ground_gate_tick(now);
}
lk.unlock(); /* release before sleeping so the RX thread isn't starved */
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
if (feed)
std::fclose(feed);
g_stop.store(true);
dev->StopRxLoop();
if (ops.joinable())
ops.detach(); /* blocked on fgets; process exit reaps it */
if (rx.joinable())
rx.join();
}
{
std::lock_guard<std::mutex> lk(g_dev_mu);
g_dev = nullptr;
g_rtl = nullptr;
}
dev->Stop();
session.close();
return 0;
}
+697
View File
@@ -0,0 +1,697 @@
/* chanscout — passive second-adapter ground spectrum scout.
*
* Continuously surveys a configured candidate-channel plan while a separate
* primary receiver stays parked on the live video channel. This process only
* MEASURES: it retunes nothing but its own scout adapter and emits versioned
* survey.dwell records (plus scout.id / scout.plan / scout.cand identity and
* scout.health state) as JSONL on stdout. Channel-change decisions live
* entirely in downstream consumers.
*
* Dwell discipline (the reason this is not just DEVOURER_RX_SWEEP): the
* chip's FA/CCA counters are delta-on-read, so after the retune + settle the
* executor performs a DISCARD read — resetting the hardware deltas and
* draining frames still in the USB pipeline from the previous channel —
* before opening the observation window. The record's counters therefore
* span exactly observe_ms on exactly this bin.
*
* Wide candidates are surveyed as their 20 MHz constituent bins (the cheap
* FastRetune path); DEVOURER_SCOUT_FULLWIDTH_MS optionally adds a periodic
* real-width verification dwell through the full SetMonitorChannel gate.
*
* Env (demo-local; the library reads no environment):
* DEVOURER_SCOUT_PLAN required — see the grammar in ChannelDef.h
* DEVOURER_SCOUT_DWELL_MS observation window per dwell (default 100)
* DEVOURER_SCOUT_SETTLE_MS post-retune settle (default 30)
* DEVOURER_SCOUT_BACKUP_MS backup-candidate revisit bound (default 1000)
* DEVOURER_SCOUT_BG_MS background revisit bound (default 5000)
* DEVOURER_SCOUT_MAX_AGE_MS evidence freshness bound (default 60000)
* DEVOURER_SCOUT_FULLWIDTH_MS wide verification cadence (default 0=off)
* DEVOURER_SCOUT_NOTE free text echoed in scout.id (antenna, slot)
* DEVOURER_VID/PID/USB_BUS/USB_PORT adapter binding (usb_select.h)
*/
#include <libusb.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "DeviceSession.h"
#include "RxPacket.h"
#include "SignalStop.h"
#include "UsbOpen.h"
#include "WiFiDriver.h"
#include "IRtlRadio.h"
#include "caps_event.h"
#include "chanmig/ChannelDef.h"
#include "chanmig/ChannelEvents.h"
#include "chanmig/ChannelScore.h"
#include "chanmig/PrimaryFeed.h"
#include "chanmig/ScanPlan.h"
#include "chanmig/SurveyJsonl.h"
#include "chanmig/SurveyRecord.h"
#include "env_config.h"
#include "logger.h"
#include "usb_select.h"
#include <optional>
#if defined(DEVOURER_HAVE_JAGUAR1)
#include "jaguar1/RtlJaguarDevice.h"
#endif
namespace cm = devourer::chanmig;
static devourer::EventSink *g_ev = nullptr;
static std::atomic<int> g_rx_count{0};
/* The canonical devourer TX SA (examples/tx, examples/streamtx, regress.py):
* frames from it are OUR OWN video/probe traffic, split out of the occupancy
* picture so the scoring layer never mistakes wanted airtime for an
* interferer. */
static const uint8_t kDvrSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00};
/* Rolling per-dwell aggregate fed by the RX callback, drained at dwell
* boundaries (rxdemo RxAgg shape + the airtime attribution buckets). */
struct ScoutAgg {
uint32_t n = 0;
int32_t rssi_sum = 0, rssi_max = -128, snr_sum = 0, snr_min = 127;
int32_t evm_sum = 0;
uint32_t evm_n = 0;
uint32_t dvr_frames = 0;
uint64_t dvr_air_us = 0, oth_air_us = 0;
};
static std::mutex g_agg_mu;
static ScoutAgg g_agg;
static void packetProcessor(const Packet &packet) {
if (packet.RxAtrib.pkt_rpt_type == RX_PACKET_TYPE::C2H_PACKET)
return;
g_rx_count.fetch_add(1, std::memory_order_relaxed);
const auto &a = packet.RxAtrib;
/* Airtime is what occupied the channel, and the FCS was transmitted even
* where the MAC strips it before DMA - so add it back when the buffer does
* not carry it, or occupancy reads 4 bytes light on every frame. */
const uint32_t on_air_len =
static_cast<uint32_t>(packet.Data.size()) + (a.fcs_present ? 0u : 4u);
const uint32_t air =
cm::frame_airtime_us(a.data_rate, on_air_len, a.bw, a.sgi != 0);
const bool ours = packet.Data.size() >= 16 &&
std::memcmp(packet.Data.data() + 10, kDvrSa, 6) == 0;
std::lock_guard<std::mutex> lk(g_agg_mu);
if (a.rssi[0] > 0) {
++g_agg.n;
g_agg.rssi_sum += a.rssi[0];
if (a.rssi[0] > g_agg.rssi_max)
g_agg.rssi_max = a.rssi[0];
g_agg.snr_sum += a.snr[0];
if (a.snr[0] < g_agg.snr_min)
g_agg.snr_min = a.snr[0];
if (a.evm[0] != 0) {
g_agg.evm_sum += a.evm[0];
++g_agg.evm_n;
}
}
if (ours) {
++g_agg.dvr_frames;
g_agg.dvr_air_us += air;
} else {
g_agg.oth_air_us += air;
}
}
static long long steady_ms() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
static uint32_t env_u32(const char *name, uint32_t def) {
const char *e = std::getenv(name);
return (e && *e) ? static_cast<uint32_t>(std::strtoul(e, nullptr, 0)) : def;
}
/* Chunked stop-aware sleep (the repo's 50 ms convention keeps SIGINT
* latency bounded); returns false when interrupted. */
static bool nap_ms(int64_t ms) {
for (int64_t s = 0; s < ms; s += 50) {
if (g_devourer_should_stop)
return false;
const int64_t step = std::min<int64_t>(50, ms - s);
std::this_thread::sleep_for(std::chrono::milliseconds(step));
}
return !g_devourer_should_stop;
}
/* Health-state tracker: emits scout.health only on state/reason transitions
* so a long degraded stretch is one line, not a firehose. */
struct HealthReporter {
std::string last_key;
void report(const char *state, const char *reason, long long detail) {
std::string key = std::string(state) + "/" + reason;
if (key == last_key)
return;
last_key = key;
devourer::Ev(*g_ev, "scout.health")
.t()
.f("state", state)
.f("reason", reason)
.f("detail", detail);
}
void ok() {
if (last_key == "ok/")
return;
last_key = "ok/";
devourer::Ev(*g_ev, "scout.health").t().f("state", "ok").f("reason", "");
}
};
int main() {
auto logger = std::make_shared<Logger>();
apply_logging_env(*logger);
g_ev = &logger->events();
install_devourer_signal_handlers();
/* Owns the teardown order (device -> interface -> handle -> context; see
* DeviceSession.h). Declared before the RX thread below, so it is joined
* before the adapter is released. */
devourer::DeviceSession session{logger};
/* --- plan --- */
const char *plan_env = std::getenv("DEVOURER_SCOUT_PLAN");
cm::ScanPlanConfig cfg;
{
std::vector<cm::PlanParseError> errs;
const bool ok = cm::parse_scan_plan(plan_env, cfg.candidates, errs);
for (const auto &e : errs) {
logger->error("DEVOURER_SCOUT_PLAN token '{}': {}", e.token, e.reason);
devourer::Ev(*g_ev, "scout.health")
.t()
.f("state", "fatal")
.f("reason", "plan_error")
.f("token", e.token.c_str())
.f("what", e.reason.c_str());
}
/* A candidate list with ANY unparseable token must not run: a silently
* missing migration candidate is a field hazard, not a warning. */
if (!ok) {
logger->error("DEVOURER_SCOUT_PLAN is required and must parse cleanly "
"(grammar: src/chanmig/ChannelDef.h)");
return 2;
}
}
cfg.dwell_ms = static_cast<int>(env_u32("DEVOURER_SCOUT_DWELL_MS", 100));
cfg.settle_ms = static_cast<int>(env_u32("DEVOURER_SCOUT_SETTLE_MS", 30));
cfg.backup_revisit_ms =
static_cast<int>(env_u32("DEVOURER_SCOUT_BACKUP_MS", 1000));
cfg.bg_revisit_ms = static_cast<int>(env_u32("DEVOURER_SCOUT_BG_MS", 5000));
cfg.fullwidth_ms =
static_cast<int>(env_u32("DEVOURER_SCOUT_FULLWIDTH_MS", 0));
cfg.max_age_ms = env_u32("DEVOURER_SCOUT_MAX_AGE_MS", 60000);
const uint32_t plan_hash = cfg.plan_hash();
/* --- advise mode (DEVOURER_SCOUT_ADVISE=1) --- runs the RecommendEngine
* in-process (it already owns the survey records) and tails the primary
* receiver's JSONL for the active-link delivery evidence. It emits only
* advice; it retunes nothing. */
const bool advise = std::getenv("DEVOURER_SCOUT_ADVISE") != nullptr &&
std::strcmp(std::getenv("DEVOURER_SCOUT_ADVISE"), "0") != 0;
std::optional<cm::RecommendEngine> engine;
cm::PrimaryFeedReader feed;
cm::ChannelDef active_def;
if (advise) {
const char *act = std::getenv("DEVOURER_SCOUT_ACTIVE");
std::string aerr;
if (act == nullptr || !cm::parse_chan_token(act, active_def, aerr)) {
logger->error("DEVOURER_SCOUT_ADVISE needs a valid DEVOURER_SCOUT_ACTIVE "
"channel token ({})", act ? aerr : "unset");
return 2;
}
cm::PolicyConfig pol;
if (const char *pf = std::getenv("DEVOURER_SCOUT_POLICY")) {
std::string perr;
if (!cm::parse_policy_file(pf, pol, &perr))
logger->warn("DEVOURER_SCOUT_POLICY {}: {} — using defaults", pf, perr);
}
engine.emplace(pol, cfg.candidates, plan_hash, active_def);
}
/* --- adapter --- */
libusb_context *ctx = nullptr;
int rc = libusb_init(&ctx);
if (rc < 0)
return rc;
session.adopt_context(ctx);
libusb_set_option(ctx, LIBUSB_OPTION_LOG_LEVEL,
std::getenv("DEVOURER_USB_DEBUG") ? LIBUSB_LOG_LEVEL_DEBUG
: LIBUSB_LOG_LEVEL_WARNING);
/* The scout accepts any devourer-supported chip; the default PID list is
* rxdemo's. Two identical adapters are told apart per-process via
* DEVOURER_USB_BUS/PORT (strict, no fallback). */
static constexpr uint16_t kPids[] = {
0x8812, 0x0811, 0xa811, 0xb811, 0x8813, 0xb812, 0xb82c, 0xc811,
0xc82c, 0xc82e, 0xc812, 0x881a, 0x881b, 0x881c, 0xa81a, 0xe822,
0xa82a,
};
UsbPick pick;
libusb_device_handle *handle = open_selected_usb(
ctx, logger, kPids, sizeof(kPids) / sizeof(kPids[0]), &pick);
if (handle == nullptr)
return 1;
std::shared_ptr<devourer::UsbDeviceLock> usb_lock;
rc = devourer::claim_interface_reset_reopen(
ctx, handle, logger, 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 driver(logger);
auto owned_device = driver.CreateRadio(handle, ctx, usb_lock,
devourer_config_from_env());
if (!owned_device) {
logger->error("No driver for this chip in this build — exiting");
return 1;
}
/* 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 dev = session.device();
devourer::emit_adapter_caps(*g_ev, dev);
const devourer::AdapterCaps caps = dev->GetAdapterCaps();
/* Stable scout identity = the physical binding + silicon (FNV-1a). This is
* the calibration-domain key: evidence is only comparable within one
* scout_id, and the aggregator resets when it changes. */
uint32_t scout_id = 2166136261u;
{
auto fold = [&scout_id](const void *p, size_t n) {
const uint8_t *b = static_cast<const uint8_t *>(p);
for (size_t i = 0; i < n; i++) {
scout_id ^= b[i];
scout_id *= 16777619u;
}
};
fold(&pick.vid, sizeof(pick.vid));
fold(&pick.pid, sizeof(pick.pid));
fold(&pick.bus, sizeof(pick.bus));
fold(pick.port.data(), pick.port.size());
fold(caps.chip_name, std::strlen(caps.chip_name));
}
{
devourer::Ev ev(*g_ev, "scout.id");
ev.t().f("role", "scout");
char id[16];
std::snprintf(id, sizeof(id), "%04x:%04x", pick.vid, pick.pid);
ev.f("usb_id", id)
.f("bus", pick.bus)
.f("port", pick.port.empty() ? "?" : pick.port.c_str())
.f("usb_speed", pick.speed)
.f("chip", caps.chip_name)
.f("gen", devourer::generation_name(caps.generation));
ev.hexf("scout_id", scout_id, 8);
if (const char *note = std::getenv("DEVOURER_SCOUT_NOTE"))
ev.f("note", note);
}
{
/* One CLOCK_REALTIME sample so offline tooling can align this stream
* with the primary receiver's (both processes log monotonic t). */
const long long epoch_ms =
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
devourer::Ev ev(*g_ev, "scout.plan");
ev.t().f("v", cm::kSurveySchemaV);
ev.hexf("plan", plan_hash, 8);
ev.f("epoch_unix_ms", epoch_ms)
.f("dwell_ms", cfg.dwell_ms)
.f("settle_ms", cfg.settle_ms)
.f("backup_ms", cfg.backup_revisit_ms)
.f("bg_ms", cfg.bg_revisit_ms)
.f("fullwidth_ms", cfg.fullwidth_ms)
.f("max_age_ms", (long long)cfg.max_age_ms)
.f("n_candidates", (unsigned long long)cfg.candidates.size())
.f("spec", plan_env);
}
for (size_t i = 0; i < cfg.candidates.size(); ++i) {
const cm::ChannelDef &c = cfg.candidates[i];
uint8_t bins[4];
const int nb = cm::constituent_bins(c, bins);
int ib[4];
for (int k = 0; k < nb; k++)
ib[k] = bins[k];
devourer::Ev ev(*g_ev, "scout.cand");
ev.f("i", (unsigned long long)i)
.f("chan", c.str().c_str())
.f("center_mhz", c.center_mhz())
.f("backup", c.backup)
.f("no_ir", c.no_ir)
.f("dfs", c.dfs);
ev.arr("bins", ib, static_cast<size_t>(nb));
}
cm::ScanScheduler sched(cfg);
/* --- RX loop on a worker thread (rxdemo sweep pattern) --- */
IRadio *devp = dev;
IRtlRadio *const rtl = dynamic_cast<IRtlRadio *>(dev);
if (!rtl)
logger->warn("chanscout: frame-free FA/CCA/NHM is Realtek-only (IRtlRadio) "
"— dwells carry frame stats only on this radio");
const cm::ScanScheduler::DwellPlan first = sched.next(steady_ms());
std::thread rx([devp, rtl, first, &logger]() {
try {
devp->Init(packetProcessor, first.def.to_selected());
} catch (const std::exception &e) {
logger->error("scout RX bring-up failed: {}", e.what());
g_devourer_should_stop = true;
}
});
/* Let bring-up finish before the first retune (a retune racing FW download
* or calibration interleaves register writes). A frame proves RX is live;
* a silent band falls through after the conservative 10 s cap. */
for (int w = 0; w < 10000 && !g_devourer_should_stop && g_rx_count == 0;
w += 50)
std::this_thread::sleep_for(std::chrono::milliseconds(50));
#if defined(DEVOURER_HAVE_JAGUAR1)
/* Thermal health where the sensor exists (Jaguar1 RF 0x42 poller). */
auto *j1 = dynamic_cast<RtlJaguarDevice *>(devp);
const uint32_t thermal_ms = env_u32("DEVOURER_THERMAL_POLL_MS", 0);
if (j1 != nullptr && thermal_ms > 0)
j1->start_thermal_poller(thermal_ms,
static_cast<int>(env_u32(
"DEVOURER_THERMAL_WARN_DELTA", 15)));
#endif
HealthReporter health;
uint64_t seq = 0;
int energy_invalid_streak = 0;
bool energy_ever_valid = false;
int quiet_dwells = 0;
bool ever_active = false;
std::vector<int64_t> retune_ring;
int64_t retune_p95_baseline = 0;
bool last_was_wide = false;
/* Per-bin last-successful-observation, for the stale-survey health check. */
struct BinAge {
uint8_t ch;
int64_t last_ok_ms;
};
std::vector<BinAge> bin_ages;
/* Advise-mode feed follower + decision cadence. The primary feed is a plain
* append-only file (never a FIFO — a wedged scout must not stall the video
* receiver); open it lazily and read to EOF each pump. */
std::FILE *feed_fp = nullptr;
std::string feed_path;
if (advise) {
if (const char *fp = std::getenv("DEVOURER_SCOUT_PRIMARY_FEED"))
feed_path = fp;
else
logger->warn("DEVOURER_SCOUT_ADVISE without DEVOURER_SCOUT_PRIMARY_FEED "
"— recommendations will lack active-link evidence");
}
std::string feed_buf;
int64_t last_decide_ms = 0, last_emit_ms = 0;
cm::Reason last_reason = cm::Reason::HoldPrimaryTelemetryStale;
const int64_t kDecideEveryMs = 2000, kHoldEveryMs = 10000;
auto pump_feed = [&](int64_t now) {
if (!advise || feed_path.empty() || engine == std::nullopt)
return;
if (feed_fp == nullptr) {
feed_fp = std::fopen(feed_path.c_str(), "r");
if (feed_fp == nullptr)
return; /* primary not up yet; retry next pump */
}
char chunk[4096];
size_t n;
while ((n = std::fread(chunk, 1, sizeof(chunk), feed_fp)) > 0) {
feed_buf.append(chunk, n);
size_t nl;
while ((nl = feed_buf.find('\n')) != std::string::npos) {
std::string_view line(feed_buf.data(), nl);
cm::ActiveLinkWindow w;
if (feed.line(line, now, w))
engine->ingest_active(w);
feed_buf.erase(0, nl + 1);
}
}
std::clearerr(feed_fp); /* EOF now, but more may append — keep the handle */
};
while (!g_devourer_should_stop) {
const int64_t t_start = steady_ms();
cm::ScanScheduler::DwellPlan plan = sched.next(t_start);
if (!plan.valid)
break;
cm::SurveyDwell d;
d.seq = seq++;
d.def = plan.def;
d.round = plan.round;
d.plan_hash = plan_hash;
d.t_start_ms = t_start;
d.settle_ms = cfg.settle_ms;
d.scout_id = scout_id;
d.adapter_gen = static_cast<uint8_t>(caps.generation);
if (plan.full_width)
d.flags |= cm::kFlagFullWidth;
/* retune. FastRetune is the same-width lean path, so the first bin dwell
* after a wide verification dwell must go through the full gate to
* restore 20 MHz — otherwise every subsequent "bin" would observe at the
* candidate's width. */
bool tuned = false;
const auto rt0 = std::chrono::steady_clock::now();
try {
if (plan.full_width) {
devp->SetMonitorChannel(plan.def.to_selected());
last_was_wide = true;
} else if (last_was_wide) {
devp->SetMonitorChannel(plan.def.to_selected());
last_was_wide = false;
} else {
devp->FastRetune(plan.bin_ch, /*cache_rf=*/true);
}
tuned = true;
} catch (const std::exception &e) {
logger->warn("scout retune ch{} failed: {}", plan.bin_ch, e.what());
}
d.retune_us = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - rt0)
.count();
if (!tuned) {
d.flags |= cm::kFlagRetuneFailed;
d.t_end_ms = steady_ms();
d.observe_ms = 0;
cm::emit_survey_dwell(*g_ev, d);
sched.complete(plan, steady_ms(), false);
const int cf = sched.consecutive_failures();
if (cf >= 15) {
health.report("wedged", "retune_fail", cf);
break; /* supervisor restarts us; exit code says why */
}
if (cf >= 5)
health.report("degraded", "retune_fail", cf);
nap_ms(50);
continue;
}
/* settle, then the DISCARD BARRIER: reset the delta counters and drain
* frames that raced in from the previous channel. */
if (!nap_ms(cfg.settle_ms))
d.flags |= cm::kFlagTruncated;
if (rtl)
(void)rtl->GetRxEnergy(/*with_nhm=*/false);
{
std::lock_guard<std::mutex> lk(g_agg_mu);
g_agg = ScoutAgg{};
}
const int64_t t_obs = steady_ms();
if (!nap_ms(cfg.dwell_ms))
d.flags |= cm::kFlagTruncated;
RxEnergy e = rtl ? rtl->GetRxEnergy(/*with_nhm=*/true) : RxEnergy{};
ScoutAgg agg;
{
std::lock_guard<std::mutex> lk(g_agg_mu);
agg = g_agg;
g_agg = ScoutAgg{};
}
d.t_end_ms = steady_ms();
d.observe_ms = d.t_end_ms - t_obs;
d.valid_fa = e.valid_fa;
d.fa_ofdm = e.fa_ofdm;
d.fa_cck = e.fa_cck;
d.cca_ofdm = e.cca_ofdm;
d.cca_cck = e.cca_cck;
d.valid_igi = e.valid_igi;
d.igi = e.igi;
d.valid_nhm = e.valid_nhm;
if (e.valid_nhm) {
uint32_t total = 0, peak = 0;
int peak_k = 0;
for (int k = 0; k < 12; k++) {
d.nhm[k] = e.nhm[k];
total += e.nhm[k];
if (e.nhm[k] > peak) {
peak = e.nhm[k];
peak_k = k;
}
}
d.nhm_dur = e.nhm_duration;
d.nhm_peak = static_cast<uint8_t>(peak_k);
d.nhm_busy_pct = static_cast<uint8_t>(
total ? 100 * (total - e.nhm[0]) / total : 0);
} else {
d.flags |= cm::kFlagNhmMissing;
}
/* Producer-side counter plausibility (the aggregator re-checks): a delta
* beyond ~1000 events/ms of observation is a wrapped/reset counter. */
if (e.valid_fa && d.observe_ms > 0) {
const uint64_t ceiling =
1000ull * static_cast<uint64_t>(d.observe_ms);
if (d.cca_ofdm > ceiling || d.fa_ofdm > ceiling ||
d.cca_cck > ceiling || d.fa_cck > ceiling)
d.flags |= cm::kFlagCounterSuspect;
}
d.frames = agg.n;
if (agg.n) {
d.rssi_mean_raw = agg.rssi_sum / static_cast<int>(agg.n);
d.rssi_max_raw = agg.rssi_max;
d.snr_mean_raw = agg.snr_sum / static_cast<int>(agg.n);
d.snr_min_raw = agg.snr_min;
}
if (agg.evm_n) {
d.evm_mean_raw = agg.evm_sum / static_cast<int>(agg.evm_n);
d.evm_valid = true;
}
d.dvr_frames = agg.dvr_frames;
d.dvr_air_us = agg.dvr_air_us;
d.oth_air_us = agg.oth_air_us;
/* A generation whose energy counters were once valid going invalid is a
* read failure, not a quiet channel — flag the record before it flies. */
if (e.valid_fa)
energy_ever_valid = true;
energy_invalid_streak = e.valid_fa ? 0 : energy_invalid_streak + 1;
if (energy_ever_valid && !e.valid_fa)
d.flags |= cm::kFlagReadFailed;
cm::emit_survey_dwell(*g_ev, d);
const bool ok = (d.flags & (cm::kFlagRetuneFailed | cm::kFlagTruncated)) == 0;
sched.complete(plan, d.t_end_ms, ok);
if (advise && engine != std::nullopt)
engine->ingest_dwell(d, d.t_end_ms);
/* --- health --- */
if (energy_ever_valid && energy_invalid_streak >= 10)
health.report("degraded", "energy_read_fail", energy_invalid_streak);
const bool active = agg.n > 0 || (e.valid_fa && (e.cca_ofdm | e.cca_cck));
if (active) {
ever_active = true;
quiet_dwells = 0;
} else if (++quiet_dwells >= 50 && ever_active) {
/* Everything silent after having heard traffic: a wedged RX front end
* looks exactly like a suddenly-empty world — flag it, let the bench
* decide. */
health.report("degraded", "rx_stalled", quiet_dwells);
}
if (!plan.full_width) {
retune_ring.push_back(d.retune_us);
if (retune_ring.size() > 100)
retune_ring.erase(retune_ring.begin());
if (retune_ring.size() == 100) {
std::vector<int64_t> sorted = retune_ring;
std::sort(sorted.begin(), sorted.end());
const int64_t p95 = sorted[95];
if (retune_p95_baseline == 0)
retune_p95_baseline = p95 > 0 ? p95 : 1;
else if (p95 > 5 * retune_p95_baseline)
health.report("degraded", "usb_congested", p95);
}
}
#if defined(DEVOURER_HAVE_JAGUAR1)
if (j1 != nullptr && thermal_ms > 0) {
const auto t = j1->get_thermal_snapshot();
if (t.valid && t.delta >= static_cast<int>(env_u32(
"DEVOURER_THERMAL_WARN_DELTA", 15)))
health.report("degraded", "thermal", t.delta);
}
#endif
{
/* Stale-survey: any bin unobserved past the freshness bound. */
BinAge *slot = nullptr;
for (BinAge &b : bin_ages)
if (b.ch == plan.bin_ch)
slot = &b;
if (slot == nullptr) {
bin_ages.push_back(BinAge{plan.bin_ch, d.t_end_ms});
slot = &bin_ages.back();
}
if (ok)
slot->last_ok_ms = d.t_end_ms;
bool stale = false;
long long worst = 0;
for (const BinAge &b : bin_ages) {
const int64_t age = d.t_end_ms - b.last_ok_ms;
if (age > cfg.max_age_ms) {
stale = true;
if (age > worst)
worst = age;
}
}
if (stale)
health.report("degraded", "stale_survey", worst);
else if (sched.consecutive_failures() == 0 &&
energy_invalid_streak < 10 && quiet_dwells < 50)
health.ok();
}
/* --- advise: pump the primary feed, decide on a cadence --- */
if (advise && engine != std::nullopt) {
const int64_t now = d.t_end_ms;
pump_feed(now);
engine->note_scout_health(sched.consecutive_failures() < 5);
if (now - last_decide_ms >= kDecideEveryMs) {
last_decide_ms = now;
cm::Decision dec = engine->decide(now);
/* Emit on a recommendation, a reason change, or the steady hold
* cadence — so a dashboard's counterfactual stays fresh without spam. */
if (dec.kind == cm::Decision::Kind::Recommend ||
dec.primary_reason != last_reason ||
now - last_emit_ms >= kHoldEveryMs) {
cm::emit_decision(*g_ev, dec, active_def);
cm::emit_ranking(*g_ev, dec);
last_reason = dec.primary_reason;
last_emit_ms = now;
}
}
}
}
if (feed_fp != nullptr)
std::fclose(feed_fp);
devp->StopRxLoop();
if (rx.joinable())
rx.join();
devp->Stop();
session.close();
return sched.consecutive_failures() >= 15 ? 3 : 0;
}
+319
View File
@@ -0,0 +1,319 @@
/* chipstate — read a chip's registers WITHOUT touching it.
*
* Every other entry point into this driver reconfigures the chip on the way in:
* `claim_interface_then_reset` re-enumerates it, `Init`/`InitWrite` re-run the
* power sequence, reload the BB/RF/MAC tables and re-calibrate. That is exactly
* what you must not do when the question is "what state did the previous
* session leave this chip in?" — the act of looking destroys the evidence.
*
* So this tool: open, claim, DO NOT reset, construct the device (chip identity
* resolves from SYS_CFG2 at construction, no bring-up needed), dump, exit. The
* output is the DEVOURER_DUMP_CANARY format, so two dumps diff directly:
*
* sudo build/chipstate --pid 0x8812 > degraded.canary
* # ... VBUS power-cycle the adapter ...
* sudo build/chipstate --pid 0x8812 > healthy.canary
* python3 tests/canary_diff.py degraded.canary healthy.canary --strict
*
* Reading a powered-down chip returns garbage or fails — that is a real answer
* about the chip, not a tool error, so it is reported rather than hidden.
*
* Note --init: it runs a normal bring-up before dumping, which is what you want
* for a healthy-reference dump on a chip that has just been power-cycled and is
* therefore not configured at all.
*/
#include <cstdio>
#include <cstdlib>
#include <cstdint>
#include <cstring>
#include <memory>
#if __has_include(<libusb.h>)
#include <libusb.h>
#else
#include <libusb-1.0/libusb.h>
#endif
#include <vector>
#include "DeviceSession.h"
#include "IRtlRadio.h"
#include "RtlAdapter.h"
#include "UsbOpen.h"
#include "WiFiDriver.h"
#include "logger.h"
#include "SignalStop.h"
namespace {
/* Same list the other demos' open loop iterates; --pid narrows to one. */
const uint16_t kRealtekPids[] = {0x8812, 0x8813, 0x881a, 0x0811, 0xa811,
0x0820, 0x0821, 0x8822, 0x0120, 0x012d,
0xb82c, 0xc811, 0xc812, 0xa81a};
/* One --peek/--poke, kept in argv order so a poke-then-peek verifies the
* write inside a single claim. */
struct RegOp {
bool write = false;
uint16_t addr = 0;
uint16_t end = 0; /* peek range: inclusive last addr (== addr if single) */
uint32_t val = 0; /* poke */
int width = 1; /* poke: 1/2/4 */
};
struct Args {
uint16_t vid = 0x0bda;
int pid = -1;
int channel = 6;
bool init = false;
bool no_claim = false;
std::vector<RegOp> ops;
};
void usage() {
std::fprintf(stderr,
"usage: chipstate [--vid 0xNNNN] [--pid 0xNNNN] [--init] "
"[--channel N]\n"
" [--peek 0xA[-0xB]]... [--poke 0xA=0xV[:W]]...\n"
" default: attach read-only, no USB reset, no bring-up.\n"
" --init : run a full bring-up first (for a healthy reference\n"
" dump on a freshly power-cycled adapter).\n"
" --peek : dump register byte(s) over the vendor-control path\n"
" (range inclusive, 16 bytes/row) instead of the\n"
" canary set. Bypasses chip dispatch — any die.\n"
" --poke : write a register (width W = 1/2/4, default from the\n"
" value magnitude). The bench-bisection intervention\n"
" lever; ops run in argv order, so a trailing --peek\n"
" verifies the write in the same claim.\n"
" --no-claim : (peek/poke only) skip the interface claim —\n"
" vendor control rides EP0 with device recipient, so\n"
" registers stay reachable while another process\n"
" (e.g. an armed rxdemo) owns the interface.\n");
}
/* Range-checked address parse: a silently-wrapped register (0x12345 ->
* 0x2345) on a poke tool is a corruption hazard, so out-of-range is a parse
* error, never a truncation. */
bool parse_reg_addr(const char *s, char **end, uint16_t &out) {
unsigned long v = std::strtoul(s, end, 0);
if (*end == s || v > 0xffff)
return false;
out = static_cast<uint16_t>(v);
return true;
}
bool parse_peek(const char *s, RegOp &op) {
char *end = nullptr;
if (!parse_reg_addr(s, &end, op.addr))
return false;
op.end = op.addr;
if (*end == '-') {
if (!parse_reg_addr(end + 1, &end, op.end) || op.end < op.addr)
return false;
}
return *end == '\0';
}
bool parse_poke(const char *s, RegOp &op) {
char *end = nullptr;
op.write = true;
if (!parse_reg_addr(s, &end, op.addr))
return false;
if (*end != '=')
return false;
const char *vs = end + 1;
unsigned long v = std::strtoul(vs, &end, 0);
if (end == vs || v > 0xfffffffful)
return false;
op.val = static_cast<uint32_t>(v);
if (*end == ':') {
op.width = static_cast<int>(std::strtoul(end + 1, &end, 0));
if (op.width != 1 && op.width != 2 && op.width != 4)
return false;
/* An explicit width the value doesn't fit is a mistake, not a mask. */
if (op.width < 4 && (op.val >> (op.width * 8)) != 0)
return false;
} else {
op.width = op.val <= 0xff ? 1 : op.val <= 0xffff ? 2 : 4;
}
return *end == '\0';
}
/* Raw register client over the transport layer — deliberately below
* CreateRadio so it works on any die, configured or not. */
int run_reg_ops(libusb_device_handle *handle, Logger_t logger,
libusb_context *ctx,
std::shared_ptr<devourer::UsbDeviceLock> lock,
const std::vector<RegOp> &ops) {
RtlAdapter adapter(handle, logger, ctx, lock);
/* A failed vendor-control read throws (UsbTransport::ctrl_read) — on a
* powered-down or wedged chip that is a real answer about the chip, so
* report which op died and exit nonzero instead of terminating. */
try {
for (const RegOp &op : ops) {
if (op.write) {
if (op.width == 4)
adapter.rtw_write32(op.addr, op.val);
else if (op.width == 2)
adapter.rtw_write16(op.addr, static_cast<uint16_t>(op.val));
else
adapter.rtw_write8(op.addr, static_cast<uint8_t>(op.val));
std::printf("poke 0x%04x = 0x%0*x\n", op.addr, op.width * 2, op.val);
} else {
for (uint32_t row = op.addr & ~0xfu; row <= op.end; row += 16) {
std::printf("0x%04x:", row);
for (uint32_t i = row; i < row + 16; ++i) {
if (i < op.addr || i > op.end)
std::printf(" ");
else
std::printf(" %02x", adapter.rtw_read8(static_cast<uint16_t>(i)));
}
std::printf("\n");
}
}
}
} catch (const std::exception &e) {
std::fflush(stdout);
logger->error("register access failed ({}) — chip powered down / wedged? "
"That is the finding, not a tool error.", e.what());
return 4;
}
std::fflush(stdout);
return 0;
}
} // namespace
int main(int argc, char **argv) {
Args a;
for (int i = 1; i < argc; ++i) {
const char *v = (i + 1 < argc) ? argv[i + 1] : nullptr;
if (!std::strcmp(argv[i], "--vid") && v) {
a.vid = static_cast<uint16_t>(std::strtol(v, nullptr, 0));
++i;
} else if (!std::strcmp(argv[i], "--pid") && v) {
a.pid = static_cast<int>(std::strtol(v, nullptr, 0));
++i;
} else if (!std::strcmp(argv[i], "--channel") && v) {
a.channel = static_cast<int>(std::strtol(v, nullptr, 0));
++i;
} else if (!std::strcmp(argv[i], "--init")) {
a.init = true;
} else if (!std::strcmp(argv[i], "--no-claim")) {
a.no_claim = true;
} else if (!std::strcmp(argv[i], "--peek") && v) {
RegOp op;
if (!parse_peek(v, op)) {
usage();
return 2;
}
a.ops.push_back(op);
++i;
} else if (!std::strcmp(argv[i], "--poke") && v) {
RegOp op;
if (!parse_poke(v, op)) {
usage();
return 2;
}
a.ops.push_back(op);
++i;
} else {
usage();
return 2;
}
}
auto logger = std::make_shared<Logger>();
install_devourer_signal_handlers();
devourer::DeviceSession session{logger};
libusb_context *ctx = nullptr;
if (libusb_init(&ctx) < 0) {
logger->error("libusb_init failed");
return 3;
}
session.adopt_context(ctx);
libusb_device_handle *handle = nullptr;
if (a.pid >= 0) {
handle = libusb_open_device_with_vid_pid(ctx, a.vid,
static_cast<uint16_t>(a.pid));
} else {
for (uint16_t pid : kRealtekPids) {
handle = libusb_open_device_with_vid_pid(ctx, a.vid, pid);
if (handle)
break;
}
}
if (!handle) {
logger->error("no adapter found (vid {:04x})", a.vid);
return 3;
}
/* --no-claim + reg ops: EP0 vendor control with device recipient does not
* need the interface, so peeks/pokes work while another process (a live
* armed rxdemo) owns it — the concurrent-intervention mode. */
if (a.no_claim) {
if (a.ops.empty()) {
logger->error("--no-claim is peek/poke-only (the canary dump needs the "
"claimed device)");
session.adopt_handle(handle);
return 2;
}
session.adopt_handle(handle);
return run_reg_ops(handle, logger, ctx, nullptr, a.ops);
}
/* do_reset=false is the whole point: a USB reset re-runs the chip's own boot
* and would wipe the state we came to read. Only --init opts into disturbing
* the chip, and even then the reset stays off so the bring-up starts from
* whatever the chip currently holds. */
std::shared_ptr<devourer::UsbDeviceLock> lock;
if (devourer::claim_interface_then_reset(
handle, devourer::find_wifi_interface(handle), logger,
/*do_reset=*/false, lock) != 0) {
session.adopt_handle(handle);
return 3;
}
session.adopt_handle(handle);
session.adopt_lock(lock);
/* --peek/--poke: raw transport-level register access, no device
* construction at all — the chip is not even identified, let alone
* configured, so this works mid-experiment on any die. */
if (!a.ops.empty())
return run_reg_ops(handle, logger, ctx, lock, a.ops);
devourer::DeviceConfig cfg;
/* Leave the chip exactly as found. Without this the device destructor runs
* the card-disable sequence on the way out, so the tool would power down the
* very state it exists to inspect — one look and the evidence is gone. */
cfg.tuning.teardown_power_down = false;
WiFiDriver driver(logger);
std::unique_ptr<IRadio> owned = driver.CreateRadio(handle, ctx, lock, cfg);
if (!owned) {
logger->error("CreateRadio failed (chip support not built?)");
return 3;
}
session.adopt_device(std::move(owned));
IRadio *const dev = session.device();
if (a.init) {
logger->info("chipstate: --init, running a full bring-up before the dump");
dev->InitWrite(SelectedChannel{.Channel = static_cast<uint8_t>(a.channel),
.ChannelOffset = 0,
.ChannelWidth = CHANNEL_WIDTH_20});
} else {
logger->info("chipstate: read-only attach (no USB reset, no bring-up) — "
"the chip is being read exactly as the last session left it");
}
auto *rtl = dynamic_cast<IRtlRadio *>(dev);
if (!rtl) {
logger->error("chipstate: no canary register dump on this radio (not a "
"Realtek backend)");
return 4;
}
rtl->DumpChipState();
return 0;
}
@@ -0,0 +1,102 @@
/* BfReportDetect — event-stream detector for beamforming self-sounding
* reports, shared by rxdemo (2-adapter rig: separate capture radio) and
* txdemo (single-radio: the sounder captures its own reports via
* StartRxLoop). Header-only so the demos stay identical consumers.
*
* A VHT/HT Compressed Beamforming report is a management Action frame —
* subtype 0xD0 (Action) or 0xE0 (Action No-Ack; CSI reports are sent No-Ack)
* — whose body begins with a category + action of VHT cat 0x15 act 0x00, or
* HT cat 0x07 act 0x00. When an armed beamformee replies to our NDPA+NDP the
* report's SA is the beamformee MAC, the decode-level confirmation the
* unassociated responder produced CSI.
*
* Emission goes through `bf_events` — a module-level EventSink pointer the
* hosting demo sets right after constructing its Logger
* (`devourer::bf::bf_events = &logger->events();`). Unset = detector inert.
*
* DEVOURER_BF_DETECT_REPORT modes:
* 1 `bf.report` summary event (Nc/Nr/BW/Ng, SA) per report
* 2 `bf.any` FC/category event for EVERY frame (subtype survey)
* 3 mode 1 + capped CSI-payload hexdump (`bf.csi`, first frames)
* 4 mode 1 + `bf.report_raw` full frame hex (for the decoder,
* tools/bf_report_decode.py) */
#ifndef BF_REPORT_DETECT_H
#define BF_REPORT_DETECT_H
#include <cstdio>
#include <cstdlib>
#include "Event.h"
#include "RxPacket.h"
namespace devourer::bf {
/* Event sink for every emission in this header. The demo points it at its
* Logger's sink before the RX loop starts; nullptr disables the detector's
* output entirely. */
inline devourer::EventSink *bf_events = nullptr;
inline void detect_report(const Packet &packet) {
const char *mode_s = std::getenv("DEVOURER_BF_DETECT_REPORT");
if (!mode_s || packet.Data.size() < 29 || bf_events == nullptr)
return;
const char mode = mode_s[0];
const uint8_t *d = packet.Data.data();
const uint8_t cat = d[24], act = d[25];
const bool vht = (cat == 0x15 && act == 0x00);
const bool ht = (cat == 0x07 && act == 0x00);
if (mode == '2') {
static int any = 0;
if (++any <= 4000) {
const unsigned fc16 = (unsigned(d[0]) << 8) | d[1];
devourer::Ev(*bf_events, "bf.any")
.hexf("fc", fc16, 4)
.hexf("cat", cat, 2)
.hexf("act", act, 2)
.f("crc", packet.RxAtrib.crc_err ? 1 : 0)
.f("len", packet.Data.size());
}
}
const uint8_t sub = d[0] & 0xF0;
if ((sub == 0xD0 || sub == 0xE0) && (vht || ht)) {
static int rpt = 0;
++rpt;
const uint8_t *mc = d + 26; /* VHT MIMO control field */
unsigned nc = (mc[0] & 0x07) + 1, nr = ((mc[0] >> 3) & 0x07) + 1;
unsigned chw = (mc[0] >> 6) & 0x03, ng = mc[1] & 0x03;
char sa[18];
std::snprintf(sa, sizeof(sa), "%02x:%02x:%02x:%02x:%02x:%02x", d[10],
d[11], d[12], d[13], d[14], d[15]);
devourer::Ev(*bf_events, "bf.report")
.f("kind", vht ? "VHT" : "HT")
.f("n", rpt)
.f("sa", sa)
.f("nc", nc)
.f("nr", nr)
.f("bw", chw)
.f("ng", ng)
.f("len", packet.Data.size());
if (mode == '3' && rpt <= 6) {
size_t off = 26 + 3; /* hdr(24)+cat+act+mimoctrl(3) */
/* Only the backends that append an FCS have four bytes to drop here;
* on MediaTek those bytes are CSI, not a checksum. */
const size_t fcs = packet.RxAtrib.fcs_present ? 4u : 0u;
size_t end = packet.Data.size() >= fcs ? packet.Data.size() - fcs : off;
size_t n = end > off ? end - off : 0;
devourer::Ev(*bf_events, "bf.csi")
.f("fcs", packet.RxAtrib.fcs_present ? 1 : 0)
.f("len", n)
.hex("csi", d + off, n < 40 ? n : 40);
}
if (mode == '4' && rpt <= 200) {
/* Full-frame hex — consumed by tools/bf_report_decode.py. */
devourer::Ev(*bf_events, "bf.report_raw")
.f("fcs", packet.RxAtrib.fcs_present ? 1 : 0)
.hex("frame", d, packet.Data.size());
}
}
}
} // namespace devourer::bf
#endif /* BF_REPORT_DETECT_H */
@@ -0,0 +1,102 @@
#ifndef DEVOURER_EXAMPLES_DEVICE_SESSION_H
#define DEVOURER_EXAMPLES_DEVICE_SESSION_H
/* DeviceSession — the teardown-order invariant for the demos, in one place.
*
* The caller owns libusb (see the architecture note in CLAUDE.md), which means
* the caller also owns the order things die in. That order is not arbitrary:
*
* 1. destroy the IRadio — quiesces TX (cancels and reaps the in-flight
* bulk-OUT URBs) and drops the transport, all
* while the libusb context is still valid;
* 2. libusb_release_interface — the chip is no longer being driven;
* 3. libusb_close — no transfer references the handle any more;
* 4. libusb_exit — nothing references the context any more.
*
* Getting this backwards is not a leak, it is a crash: a device destroyed
* after libusb_exit reaps its completions against a freed context and dies
* inside libusb, and only under enough TX load to keep URBs outstanding at
* exit (which is why it hides from low-duty runs).
*
* Holding all four in one object makes the order structural instead of a
* sequence every demo has to re-type at every early return: adopt each piece
* as it is acquired, then just `return`.
*
* Header-only and demo-local by design — the library never touches libusb
* lifetime. */
#include <memory>
#include <utility>
#include <libusb.h>
#include "IRadio.h"
#include "UsbDeviceLock.h"
#include "UsbOpen.h" /* find_wifi_interface — the interface the claim used */
#include "logger.h"
namespace devourer {
class DeviceSession {
public:
explicit DeviceSession(Logger_t logger) : _logger{std::move(logger)} {}
DeviceSession(const DeviceSession &) = delete;
DeviceSession &operator=(const DeviceSession &) = delete;
~DeviceSession() { close(); }
/* Adopt each resource as it is acquired, so an early return from any point
* in the bring-up unwinds whatever exists so far, in order. */
void adopt_context(libusb_context *ctx) { _ctx = ctx; }
/* `iface` defaults to whichever interface the claim helpers pick — the
* vendor bulk one, which is 2 on a composite RTL8822BU, not 0. Pass it
* explicitly only when the demo claimed a fixed interface itself. */
void adopt_handle(libusb_device_handle *handle, int iface = -1) {
_handle = handle;
_iface = iface >= 0 ? iface : find_wifi_interface(handle);
}
void adopt_lock(std::shared_ptr<UsbDeviceLock> lock) {
_lock = std::move(lock);
}
void adopt_device(std::unique_ptr<IRadio> dev) { _dev = std::move(dev); }
libusb_context *context() const { return _ctx; }
libusb_device_handle *handle() const { return _handle; }
IRadio *device() const { return _dev.get(); }
const std::shared_ptr<UsbDeviceLock> &lock() const { return _lock; }
/* Explicit teardown for demos that have work to do after the adapter is
* released (final statistics, a summary event). Idempotent; the destructor
* calls it. */
void close() {
_dev.reset();
if (_handle != nullptr) {
/* Tolerant: an adapter that already dropped off the bus (a TX-path
* wedge, an unplug) fails the release — log it, never abort. */
const int rc = libusb_release_interface(_handle, _iface);
if (rc != 0 && _logger)
_logger->info("libusb_release_interface rc={} (device already gone?)",
rc);
libusb_close(_handle);
_handle = nullptr;
}
if (_ctx != nullptr) {
libusb_exit(_ctx);
_ctx = nullptr;
}
_lock.reset();
}
private:
Logger_t _logger;
std::unique_ptr<IRadio> _dev;
std::shared_ptr<UsbDeviceLock> _lock;
libusb_device_handle *_handle = nullptr;
libusb_context *_ctx = nullptr;
int _iface = 0;
};
} /* namespace devourer */
#endif /* DEVOURER_EXAMPLES_DEVICE_SESSION_H */
+96
View File
@@ -0,0 +1,96 @@
/* Shared `adapter.caps` machine-event emitter for the demos.
*
* One place that serializes IRadio::GetAdapterCaps() (src/AdapterCaps.h) to
* the JSONL event plane so rxdemo / txdemo / doctor / txpower all emit the same
* schema — a dependent app or test script consumes one event instead of calling
* the C++ API. Emit it right after CreateRadio (the caps are static and
* resolved at construction — no bring-up needed). Mirrors the txpwr.caps
* emission in examples/txpower/main.cpp: booleans as 0/1, chip_id as a hex
* string, bandwidths + frequency spans as arrays (absent band -> null). */
#ifndef DEVOURER_CAPS_EVENT_H
#define DEVOURER_CAPS_EVENT_H
#include "AdapterCaps.h"
#include "Event.h"
#include "IRadio.h"
namespace devourer {
inline void emit_adapter_caps(EventSink &sink, IRadio *dev) {
const AdapterCaps c = dev->GetAdapterCaps();
/* Supported channel widths as an MHz int array (kBw* -> MHz). */
int bw[6];
int nbw = 0;
if (c.bw_mask & kBw5) bw[nbw++] = 5;
if (c.bw_mask & kBw10) bw[nbw++] = 10;
if (c.bw_mask & kBw20) bw[nbw++] = 20;
if (c.bw_mask & kBw40) bw[nbw++] = 40;
if (c.bw_mask & kBw80) bw[nbw++] = 80;
if (c.bw_mask & kBw160) bw[nbw++] = 160;
Ev ev(sink, "adapter.caps");
ev.f("supported", c.supported ? 1 : 0)
.f("chip", c.chip_name)
.f("names", c.marketing_names)
.hexf("chip_id", c.chip_id, 2)
.f("gen", generation_name(c.generation))
.f("variant", c.variant)
.f("transport", c.transport)
.f("tx_chains", c.tx_chains)
.f("rx_chains", c.rx_chains)
.f("n_ss", c.tx.n_ss)
.f("stbc", c.tx.stbc_ok ? 1 : 0)
.f("ldpc", c.tx.ldpc_ok ? 1 : 0)
.f("sgi", c.tx.sgi_ok ? 1 : 0)
.f("bw_max", c.tx.bw_max_mhz)
.arr("bw", bw, static_cast<size_t>(nbw))
.f("txpwr_max", c.txpwr.index_max)
.f("txpwr_step_qdb", c.txpwr.step_qdb)
.f("txpwr_step_measured", c.txpwr.step_measured ? 1 : 0)
.f("txpwr_min_qdb", c.txpwr.offset_min_qdb)
.f("txpwr_max_qdb", c.txpwr.offset_max_qdb)
.f("txpwr_rate_diffs", c.txpwr.rate_diffs ? 1 : 0)
.f("txpwr_rate_diffs_hw", c.txpwr.rate_diffs_hw_table ? 1 : 0)
.f("txpwr_rate_diffs_measured", c.txpwr.rate_diffs_measured ? 1 : 0);
auto band = [&ev](const char *k, const BandRange &b) {
if (b.valid) {
const int v[2] = {b.min_mhz, b.max_mhz};
ev.arr(k, v, 2);
} else {
ev.f(k, nullptr);
}
};
band("tune_2g4", c.tune_2g4);
band("tune_5g", c.tune_5g);
band("char_2g4", c.characterized_2g4);
band("char_5g", c.characterized_5g);
/* FEC RX truth table (ldpc above is the TX side, TxCaps.ldpc_ok). */
ev.f("ldpc_rx_ht", c.ldpc_rx_ht ? 1 : 0)
.f("ldpc_rx_vht", c.ldpc_rx_vht ? 1 : 0)
.f("ldpc_rx_flag", c.ldpc_rx_flag ? 1 : 0)
.f("vht_2g4", c.vht_2g4_ok ? 1 : 0);
ev.f("per_pkt_txpwr", c.per_packet_txpower ? 1 : 0)
.f("per_pkt_txpwr_steps", c.per_pkt_txpwr_steps)
.f("per_pkt_txpwr_step_qdb", c.per_pkt_txpwr_step_qdb)
.f("per_pkt_txpwr_min_qdb", c.per_pkt_txpwr_min_qdb)
.f("per_pkt_txpwr_max_qdb", c.per_pkt_txpwr_max_qdb)
.f("per_pkt_txpwr_measured", c.per_pkt_txpwr_measured ? 1 : 0)
.f("narrowband", c.narrowband_ok ? 1 : 0)
.f("fastretune", c.fastretune_ok ? 1 : 0)
.f("ack_responder", c.ack_responder_ok ? 1 : 0)
.f("tx_retry_limit", c.tx_retry_limit_ok ? 1 : 0)
.f("he_er_su", c.he_er_su_ok ? 1 : 0)
.f("per_chain_rssi", c.per_chain_rssi ? 1 : 0)
.f("hw_rx_tsf", c.hw_rx_timestamp ? 1 : 0)
.f("hw_beacon_txtsf", c.hw_beacon_txtsf ? 1 : 0)
.f("xtal_cap_max", c.xtal_cap_max)
.f("xtal_cap_default", c.xtal_cap_default);
}
} // namespace devourer
#endif /* DEVOURER_CAPS_EVENT_H */
+313
View File
@@ -0,0 +1,313 @@
#include "env_config.h"
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include "RadiotapBuilder.h"
namespace {
/* Flag semantics: set and not "0". (The library's historical readers were a
* mix of presence-checks and =='1' checks; no script sets a flag to 0, so this
* covers both.) */
bool env_flag(const char *name) {
const char *e = std::getenv(name);
return e != nullptr && std::strcmp(e, "0") != 0;
}
const char *env_str(const char *name) { return std::getenv(name); }
/* Case-insensitive ASCII string equality (strcasecmp is POSIX-only). */
bool str_ieq(const char *a, const char *b) {
for (; *a && *b; ++a, ++b)
if (std::tolower(static_cast<unsigned char>(*a)) !=
std::tolower(static_cast<unsigned char>(*b)))
return false;
return *a == *b;
}
/* strtol with base auto-detect (0x-hex or decimal), matching the parse the
* library's readers used per knob. */
bool env_long(const char *name, long *out) {
const char *e = std::getenv(name);
if (!e || !*e)
return false;
*out = std::strtol(e, nullptr, 0);
return true;
}
/* DEVOURER_RX_MODE spelling -> RxMode (UsbTransport RX-ring strategy). Accepts
* hyphen or underscore; unrecognised falls back to the default async ring. */
devourer::RxMode parse_rx_mode(const char *s) {
if (str_ieq(s, "sync"))
return devourer::RxMode::Sync;
if (str_ieq(s, "reorder-pool") || str_ieq(s, "reorder_pool") ||
str_ieq(s, "pool"))
return devourer::RxMode::ReorderPool;
if (str_ieq(s, "spsc-fat") || str_ieq(s, "spsc_fat") || str_ieq(s, "spsc"))
return devourer::RxMode::SpscFat;
if (str_ieq(s, "decoupled"))
return devourer::RxMode::Decoupled;
return devourer::RxMode::Async;
}
} // namespace
devourer::DeviceConfig devourer_config_from_env() {
devourer::DeviceConfig cfg;
long v = 0;
/* ---- rx ---- */
cfg.rx.keep_corrupted = env_flag("DEVOURER_RX_KEEP_CORRUPTED");
cfg.rx.enable_with_tx = env_str("DEVOURER_TX_WITH_RX") != nullptr;
if (const char *e = env_str("DEVOURER_RX_CSI_MASK"))
cfg.rx.csi_mask = e;
if (const char *e = env_str("DEVOURER_RX_NBI"))
cfg.rx.nbi = e;
if (const char *e = env_str("DEVOURER_RX_PATHS"))
cfg.rx.path_spec = e;
if (env_long("DEVOURER_RX_URBS", &v))
cfg.rx.urbs = static_cast<int>(v);
if (env_long("DEVOURER_RX_URB_BYTES", &v))
cfg.rx.urb_bytes = static_cast<int>(v);
if (const char *e = env_str("DEVOURER_RX_MODE"))
cfg.rx.rx_mode = parse_rx_mode(e);
if (env_long("DEVOURER_RX_POOL_SPARE", &v))
cfg.rx.pool_spare = static_cast<int>(v);
if (const char *e = env_str("DEVOURER_RX_POOL_EXHAUST")) {
if (str_ieq(e, "drop"))
cfg.rx.pool_exhaust = devourer::PoolExhaust::Drop;
else if (str_ieq(e, "backpressure") || str_ieq(e, "bp"))
cfg.rx.pool_exhaust = devourer::PoolExhaust::Backpressure;
}
if (env_long("DEVOURER_RX_RING_MS", &v))
cfg.rx.ring_ms = static_cast<int>(v);
cfg.rx.phy_status_8821c = !env_flag("DEVOURER_8821C_NO_PHYST");
cfg.rx.abs_noise_floor = env_flag("DEVOURER_RX_NOISE_FLOOR");
if (env_long("DEVOURER_IGI", &v))
cfg.rx.igi = static_cast<uint8_t>(v & 0x7f);
if (const char *e = env_str("DEVOURER_ACK_RESPONDER"))
cfg.rx.ack_responder = devourer::parse_mac(e);
/* ---- tx ---- */
if (env_long("DEVOURER_TX_EP", &v))
cfg.tx.ep = static_cast<uint8_t>(v);
if (env_long("DEVOURER_TX_TIMEOUT_MS", &v))
cfg.tx.timeout_ms = static_cast<unsigned>(v);
cfg.tx.legacy_8812_desc = env_flag("DEVOURER_TX_LEGACY_8812_DESC");
if (env_long("DEVOURER_TX_PWR", &v))
cfg.tx.power_index = static_cast<int>(v & 0x3f);
if (env_long("DEVOURER_TX_RF_BW", &v))
cfg.tx.rf_bw = static_cast<uint8_t>(v & 0x3);
cfg.tx.cw_tone = env_flag("DEVOURER_CW_TONE");
if (env_long("DEVOURER_CW_TONE_GAIN", &v))
cfg.tx.cw_tone_gain = static_cast<uint8_t>(v) & 0x1F;
if (env_long("DEVOURER_TX_USB_AGG", &v) && v > 0)
cfg.tx.usb_agg_max = static_cast<unsigned>(v);
if (env_long("DEVOURER_TX_REPORT", &v)) /* sampling divisor N, 0..255 */
cfg.tx.report = static_cast<int>(v < 0 ? 0 : v > 255 ? 255 : v);
if (const char *e = env_str("DEVOURER_TX_AMPDU_MODE")) {
devourer::AmpduMode m;
if (devourer::parse_ampdu_mode(e, m)) {
cfg.tx.ampdu = m;
} else {
/* A silently-ignored spec means a bench that thinks it measured
* aggregation and didn't — fail loudly like RETRY_FALLBACK below. */
std::fprintf(stderr,
"devourer [W] DEVOURER_TX_AMPDU_MODE='%s' unparsable — "
"A-MPDU stays off\n", e);
std::fflush(stderr);
}
}
if (env_long("DEVOURER_ACK_TIMEOUT_US", &v) && v >= 1)
/* 1..255; out-of-range low keeps the library default (a 0 collapsing
* to 1 us would write off every frame). */
cfg.tx.ack_timeout_us = static_cast<int>(v > 255 ? 255 : v);
if (env_long("DEVOURER_TX_RETRY_LIMIT", &v))
cfg.tx.retry_limit = static_cast<int>(v < 0 ? 0 : (v > 63 ? 63 : v));
if (const char *e = env_str("DEVOURER_TX_RETRY_FALLBACK")) {
if (str_ieq(e, "off")) {
cfg.tx.retry_fallback = devourer::RetryFallback::Off;
} else {
/* A floor form was measured and rejected — the fw reinterprets
* DATA_RTY_LOWEST_RATE inside the RA-group rate space (see the
* RetryFallback enum note in DeviceConfig.h). */
std::fprintf(stderr,
"devourer [W] DEVOURER_TX_RETRY_FALLBACK='%s' unsupported "
"(only \"off\") — keeping the firmware ladder\n", e);
std::fflush(stderr); /* the diagnostic plane is per-line flushed */
}
}
/* ---- bf ---- */
if (const char *snd = env_str("DEVOURER_BF_ARM_SOUNDER")) {
cfg.bf.arm_sounder = true;
/* "aa:bb:..:ff" also programs the self-MAC; a bare "1" arms only. */
cfg.bf.sounder_self_mac = devourer::parse_mac(snd);
}
if (const char *e = env_str("DEVOURER_BF_ARM_BFEE"))
cfg.bf.beamformee_of = devourer::parse_mac(e);
cfg.bf.mu = env_flag("DEVOURER_BF_ARM_BFEE_MU");
if (const char *e = env_str("DEVOURER_BF_TXBF"))
cfg.bf.txbf_peer = devourer::parse_mac(e);
if (const char *e = env_str("DEVOURER_TX_NDPA")) {
int p = std::atoi(e);
cfg.bf.ndpa_period = p > 0 ? p : 1;
}
/* ---- MediaTek MT7612U ---- */
/* Folded in here rather than read inside the backend, so neither the C
* library nor the device class consults ambient process state. */
if (const char *e = env_str("DEVOURER_MT7612U_FW_DIR"))
cfg.mt7612u.firmware_dir = std::string(e);
/* ---- tuning ---- */
/* Defaults ON, so this reads the negation: only an explicit 0 disables it. */
if (const char *e = env_str("DEVOURER_TEARDOWN_POWER_DOWN"))
cfg.tuning.teardown_power_down = (std::atoi(e) != 0);
cfg.tuning.skip_iqk = env_flag("DEVOURER_SKIP_IQK");
cfg.tuning.force_iqk = env_flag("DEVOURER_FORCE_IQK");
cfg.tuning.disable_iqk = env_flag("DEVOURER_DISABLE_IQK");
cfg.tuning.skip_txpwr = env_flag("DEVOURER_SKIP_TXPWR");
cfg.tuning.skip_txgapk = env_flag("DEVOURER_SKIP_TXGAPK");
cfg.tuning.skip_trx_reassert = env_flag("DEVOURER_SKIP_TRX_REASSERT");
cfg.tuning.skip_rfe_init = env_flag("DEVOURER_SKIP_RFEINIT");
cfg.tuning.skip_coex = env_flag("DEVOURER_SKIP_COEX");
cfg.tuning.skip_dig = env_flag("DEVOURER_SKIP_DIG");
/* Default-on knob: unset = tracking on; only "0" disables it. */
if (const char *e = env_str("DEVOURER_THERMAL_TRACK"))
cfg.tuning.thermal_track = std::strcmp(e, "0") != 0;
cfg.tuning.disable_cca = env_flag("DEVOURER_DIS_CCA");
if (env_long("DEVOURER_FASTRETUNE_FW", &v) && v >= 0)
cfg.tuning.fastretune_fw = static_cast<int>(v);
if (env_long("DEVOURER_KFR_OFLD", &v) && v >= 0)
cfg.tuning.kestrel_fastretune_ofld = static_cast<int>(v);
if (env_long("DEVOURER_FW_TABLE_OFLD", &v) && v >= 0)
cfg.tuning.fw_table_offload = static_cast<int>(v);
/* Jaguar3 per-packet power-bank step size (qdB per 0x1e70 offset-index
* step; default 4 = 1 dB) — bench slope-calibration override. */
if (env_long("DEVOURER_TXPKT_STEP_QDB", &v) && v > 0)
cfg.tuning.txpkt_step_qdb = static_cast<int>(v);
if (env_long("DEVOURER_RFE", &v))
cfg.tuning.rfe_type = static_cast<uint8_t>(v);
/* Raw codes — each backend masks to its own field width (J2/J3 3-bit,
* RTL8733B 4-bit ADC at 0x9f0[3:0], whose default codes 0xa/0xb a 0x7
* mask would silently corrupt). */
if (env_long("DEVOURER_NB_DAC", &v))
cfg.tuning.nb_dac = static_cast<uint8_t>(v & 0xf);
if (env_long("DEVOURER_NB_ADC", &v))
cfg.tuning.nb_adc = static_cast<uint8_t>(v & 0xf);
if (env_long("DEVOURER_XTAL_CAP", &v))
cfg.tuning.xtal_cap = static_cast<uint8_t>(v & 0x7f);
cfg.tuning.cfo_track = env_flag("DEVOURER_CFO_TRACK");
if (const char *e = env_str("DEVOURER_REGULATION")) {
if (str_ieq(e, "ETSI"))
cfg.tuning.regulation = devourer::Regulation::ETSI;
else if (str_ieq(e, "MKK"))
cfg.tuning.regulation = devourer::Regulation::MKK;
else if (str_ieq(e, "WW"))
cfg.tuning.regulation = devourer::Regulation::WW;
else
cfg.tuning.regulation = devourer::Regulation::FCC;
}
cfg.tuning.txpwr_by_rate = env_flag("DEVOURER_ENABLE_TXPWR_BY_RATE");
cfg.tuning.phydm_watchdog = env_flag("DEVOURER_PHYDM_WATCHDOG");
if (const char *e = env_str("DEVOURER_8814_FWDL");
e && std::strcmp(e, "rtw88") == 0)
cfg.tuning.fwdl_8814 = devourer::Fwdl8814Path::Rtw88;
if (env_long("DEVOURER_8814_FWDL_CHUNK", &v))
cfg.tuning.fwdl_8814_chunk = static_cast<uint32_t>(v);
if (const char *e = env_str("DEVOURER_DPDT_MODE")) {
if (str_ieq(e, "legacy"))
cfg.tuning.dpdt_8822e = devourer::Dpdt8822eMode::Legacy;
else if (str_ieq(e, "bit24"))
cfg.tuning.dpdt_8822e = devourer::Dpdt8822eMode::Bit24;
else if (str_ieq(e, "skip"))
cfg.tuning.dpdt_8822e = devourer::Dpdt8822eMode::Skip;
else
cfg.tuning.dpdt_8822e = devourer::Dpdt8822eMode::EfemPinmux;
}
/* ---- debug ---- */
cfg.debug.dump_canary = env_flag("DEVOURER_DUMP_CANARY");
cfg.debug.bb_dump = env_flag("DEVOURER_BB_DUMP");
cfg.debug.efuse_dump = env_flag("DEVOURER_EFUSE_DUMP");
cfg.debug.log_writes = env_flag("DEVOURER_LOG_WRITES");
cfg.debug.log_txpwr = env_flag("DEVOURER_LOG_TXPWR");
cfg.debug.kestrel_fw_log = env_flag("DEVOURER_KESTREL_FWLOG");
cfg.debug.kestrel_cca_on = env_flag("DEVOURER_KESTREL_CCA_ON");
cfg.debug.kestrel_trigger_f2p = env_flag("DEVOURER_KESTREL_TRIGGER_F2P");
if (const char *e = env_str("DEVOURER_REPLAY_WSEQ"))
cfg.debug.replay_wseq = e;
if (env_long("DEVOURER_TX_QSEL", &v))
cfg.debug.tx_qsel = static_cast<uint8_t>(v & 0x1f);
if (env_long("DEVOURER_TX_RATEID", &v))
cfg.debug.tx_rateid = static_cast<uint8_t>(v & 0x1f);
/* "max[/density[/rty]]" — A-MPDU spike descriptor overrides. */
if (const char *e = env_str("DEVOURER_TX_AMPDU")) {
char *end = nullptr;
long maxn = std::strtol(e, &end, 0);
if (maxn > 0) {
cfg.debug.tx_ampdu_max = static_cast<uint8_t>(maxn & 0x1f);
if (end && *end == '/') {
cfg.debug.tx_ampdu_density =
static_cast<uint8_t>(std::strtol(end + 1, &end, 0) & 0x7);
if (end && *end == '/')
cfg.debug.tx_ampdu_rty =
static_cast<uint8_t>(std::strtol(end + 1, nullptr, 0) & 0x3f);
}
}
}
cfg.debug.hop_prof = env_flag("DEVOURER_HOP_PROF");
cfg.debug.gaintab_dbg = env_flag("DEVOURER_GAINTAB_DBG");
/* ---- usb ---- */
if (const char *e = env_str("TMPDIR"); e && *e)
cfg.usb.lock_dir = e;
if (env_str("DEVOURER_RX_ZEROCOPY")) /* default true; =0 forces the heap path */
cfg.usb.rx_zerocopy = env_flag("DEVOURER_RX_ZEROCOPY");
return cfg;
}
devourer::TxMode devourer_tx_mode_from_env() {
const char *raw = std::getenv("DEVOURER_TX_RATE");
return devourer::parse_tx_mode_str(raw ? raw : "");
}
void apply_logging_env(Logger &logger) {
if (const char *e = std::getenv("DEVOURER_LOG_LEVEL")) {
if (str_ieq(e, "trace"))
logger.set_level(Logger::Level::Trace);
else if (str_ieq(e, "debug"))
logger.set_level(Logger::Level::Debug);
else if (str_ieq(e, "info"))
logger.set_level(Logger::Level::Info);
else if (str_ieq(e, "warn"))
logger.set_level(Logger::Level::Warn);
else if (str_ieq(e, "error"))
logger.set_level(Logger::Level::Error);
else if (str_ieq(e, "silent"))
logger.set_level(Logger::Level::Silent);
else
std::fprintf(stderr, "devourer [W] DEVOURER_LOG_LEVEL='%s' unknown — "
"keeping default\n", e);
}
const auto flush = env_flag("DEVOURER_EVENT_FLUSH") ||
std::getenv("DEVOURER_EVENT_FLUSH") == nullptr
? devourer::EventSink::FlushPolicy::EveryLine
: devourer::EventSink::FlushPolicy::Never;
if (const char *e = std::getenv("DEVOURER_EVENTS")) {
if (str_ieq(e, "off"))
logger.events().disable();
else if (str_ieq(e, "stderr"))
logger.events().configure(stderr, flush);
else
logger.events().configure(stdout, flush);
} else {
logger.events().configure(stdout, flush);
}
}
+33
View File
@@ -0,0 +1,33 @@
#pragma once
/* DEVOURER_* environment-variable interface of the example binaries.
*
* The library is configured through devourer::DeviceConfig (see
* src/DeviceConfig.h) and runtime setters on IRadio; it reads no env vars.
* The demos — and the test scripts driving them — speak env vars, and this
* translator is where that mapping lives: every library-level DEVOURER_* var
* becomes a DeviceConfig field (devourer_config_from_env). Demo-local vars
* (DEVOURER_PID, DEVOURER_CHANNEL, ...) stay in each demo's own code. */
#include "DeviceConfig.h"
#include "TxMode.h"
#include "logger.h"
/* Every DeviceConfig-backed DEVOURER_* var -> a populated DeviceConfig.
* See env_config.cpp for the full mapping table. */
devourer::DeviceConfig devourer_config_from_env();
/* DEVOURER_TX_RATE parsed to a TxMode (unset -> the 6M-legacy default). */
devourer::TxMode devourer_tx_mode_from_env();
/* Logging env -> Logger configuration (docs/logging.md). Call once at
* main() start, before any threads. These configure the Logger object, not
* DeviceConfig — the library itself still reads no env:
* DEVOURER_LOG_LEVEL=trace|debug|info|warn|error|silent
* diagnostic verbosity on stderr (default debug).
* DEVOURER_EVENTS=stdout|stderr|off
* destination of the JSONL machine event stream (default stdout).
* DEVOURER_EVENT_FLUSH=0
* drop the per-event fflush (max-rate benches; default flush-per-line
* so piped consumers never stall on libc buffering). */
void apply_logging_env(Logger &logger);
+146
View File
@@ -0,0 +1,146 @@
// Shared stdin framing for the stdin-driven stream demos (streamtx,
// duplex) and their headless regression self-test
// (StreamStdinSelftest).
//
// Centralises the two things that have to stay correct on every Windows
// toolchain, so there is a single source of truth instead of one copy per
// demo:
//
// 1. set_stdin_binary() — put stdin in binary mode so a 0x1A (Ctrl-Z, which
// text-mode stdin treats as EOF) or a CRLF byte in the binary
// <u32_le len><PSDU> stream isn't translated away. Gated on _WIN32, NOT
// _MSC_VER: mingw/GCC defines _WIN32 but not _MSC_VER, yet still ships
// _setmode. A _MSC_VER gate silently leaves mingw stdin in TEXT mode and
// truncates the first PSDU ("short read on stdin (76/269)") before a
// single frame is transmitted.
//
// 2. read_exact() — the byte reader, returning a tri-state so each caller
// keeps its own short-read policy (the TX demo aborts on a truncated
// record; the duplex demo just stops its TX thread and lets RX run on).
//
// 3. read_record() — one whole <u32_le len><body> record, which is what
// every caller actually wanted. The little-endian assembly and the
// zero/oversize check were re-typed in four demos before this existed,
// and they had already drifted. Callers that need the length word before
// the body — the duplex demo escapes to a control TLV on its top bit —
// compose read_length() and read_body() instead.
//
// The result states are deliberately finer than "it failed": a producer that
// closed cleanly between records is an ordinary end of run, one that closed
// with a length word already written has lost a record, and a length out of
// range is a producer bug rather than a stream end. Each demo maps them to its
// own policy; none of them has to re-derive them.
//
// StreamStdinSelftest + tests/stream_stdin_test.cmake exercise this header
// headlessly (no libusb, no hardware), so a regression in the _WIN32 gate
// fails CI on the mingw job instead of only surfacing on a real radio.
#pragma once
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <vector>
#if defined(_WIN32)
#include <io.h>
#include <fcntl.h>
#endif
namespace stream_stdin {
// Put stdin into binary mode. No-op off Windows (POSIX has no text mode).
inline void set_stdin_binary() {
#if defined(_WIN32)
_setmode(_fileno(stdin), _O_BINARY);
#endif
}
// Put stdout into binary mode. Only the self-test's --gen path needs this, but
// it lives here so all the toolchain-gated _setmode logic stays in one place.
inline void set_stdout_binary() {
#if defined(_WIN32)
_setmode(_fileno(stdout), _O_BINARY);
#endif
}
enum class ReadResult {
Ok, // got all n bytes
Eof, // clean stream close: 0 bytes read with EOF before any byte
Short, // stream ended mid-record (truncation)
};
// Read exactly n bytes from f into buf.
inline ReadResult read_exact(std::FILE *f, void *buf, std::size_t n) {
std::size_t got = 0;
auto *p = static_cast<std::uint8_t *>(buf);
while (got < n) {
std::size_t r = std::fread(p + got, 1, n - got, f);
if (r == 0) {
if (got == 0 && std::feof(f)) return ReadResult::Eof;
return ReadResult::Short;
}
got += r;
}
return ReadResult::Ok;
}
// Read the 4-byte little-endian length prefix. `len` is untouched unless Ok.
inline ReadResult read_length(std::FILE *f, std::uint32_t &len) {
std::uint8_t b[4];
const ReadResult r = read_exact(f, b, sizeof(b));
if (r == ReadResult::Ok)
len = static_cast<std::uint32_t>(b[0]) |
(static_cast<std::uint32_t>(b[1]) << 8) |
(static_cast<std::uint32_t>(b[2]) << 16) |
(static_cast<std::uint32_t>(b[3]) << 24);
return r;
}
// Read `n` body bytes into `out`, sizing it to match. A zero-length body is Ok
// and leaves `out` empty — callers that forbid one check the length first.
inline ReadResult read_body(std::FILE *f, std::vector<std::uint8_t> &out,
std::size_t n) {
out.resize(n);
return n ? read_exact(f, out.data(), n) : ReadResult::Ok;
}
enum class RecordResult {
Ok, // a complete record is in `out`
Eof, // clean close: nothing at all where a record would have started
Short, // the stream ended part-way through a record
EofMidBody, // the length word arrived, then the stream closed before a byte
// of its body — one record lost, cleanly
BadLength, // zero, or larger than the caller's bound
};
// Read one whole <u32_le len><body> record. `max` bounds the body. When
// `raw_len` is given it receives the length word verbatim even on BadLength,
// so a caller with its own convention in the high bits can inspect it.
inline RecordResult read_record(std::FILE *f, std::vector<std::uint8_t> &out,
std::size_t max,
std::uint32_t *raw_len = nullptr) {
std::uint32_t len = 0;
switch (read_length(f, len)) {
case ReadResult::Eof:
return RecordResult::Eof;
case ReadResult::Short:
return RecordResult::Short;
case ReadResult::Ok:
break;
}
if (raw_len)
*raw_len = len;
if (len == 0 || len > max)
return RecordResult::BadLength;
switch (read_body(f, out, len)) {
case ReadResult::Ok:
return RecordResult::Ok;
case ReadResult::Eof:
return RecordResult::EofMidBody;
case ReadResult::Short:
return RecordResult::Short;
}
return RecordResult::Short; // unreachable; keeps every toolchain quiet
}
} // namespace stream_stdin
@@ -0,0 +1,230 @@
// StreamStdinSelftest — headless regression test for examples/common/stream_stdin.h.
//
// The stdin-driven stream demos (streamtx, duplex) read a binary
// <u32_le len><PSDU> stream from stdin. On Windows that only works if stdin is
// put into binary mode; the gate has to be `_WIN32` (not `_MSC_VER`) so it also
// fires under mingw/GCC. A regression there is invisible to a build-only CI job
// — it compiles fine and only corrupts bytes at runtime — so this binary
// exercises the exact set_stdin_binary() + read_record() path the demos use,
// with no libusb and no radio. It also pins what each record state means,
// since the four demos map those states onto four different policies.
//
// StreamStdinSelftest --gen writes the canonical stream to stdout (binary)
// StreamStdinSelftest reads that stream from stdin and round-trips it
//
// tests/stream_stdin_test.cmake pipes the first into the second. The canonical
// records deliberately contain 0x1A (Ctrl-Z = EOF to a text-mode read), 0x0D
// and 0x0A, so any text-mode translation truncates or mangles the stream and the
// reader reports FAIL with a non-zero exit.
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <vector>
#include "stream_stdin.h"
// Canonical self-test stream. --gen and the reader share this table, so a
// byte-for-byte mismatch on read means binary mode is broken. Total = 5 records,
// 20 body bytes (5+3+4+1+7) — kept in lockstep with the expected string in
// tests/stream_stdin_test.cmake.
static const std::vector<std::vector<uint8_t>> &canonical_records() {
static const std::vector<std::vector<uint8_t>> recs = {
{0x1A, 0x0D, 0x0A, 0x00, 0xFF},
{0x41, 0x1A, 0x42},
{0x0D, 0x0A, 0x0D, 0x0A},
{0x1A},
{0x00, 0x1A, 0x0D, 0x0A, 0x1A, 0x7F, 0x80},
};
return recs;
}
// Bound for read_record. Comfortably above the canonical records, so a
// BadLength here means the stream desynced, never that the cap was too tight.
static const std::size_t kMaxRecord = 4096;
static void put_u32_le(uint8_t *p, uint32_t v) {
p[0] = static_cast<uint8_t>(v & 0xFF);
p[1] = static_cast<uint8_t>((v >> 8) & 0xFF);
p[2] = static_cast<uint8_t>((v >> 16) & 0xFF);
p[3] = static_cast<uint8_t>((v >> 24) & 0xFF);
}
static int do_gen() {
stream_stdin::set_stdout_binary();
for (const auto &rec : canonical_records()) {
uint8_t len_bytes[4];
put_u32_le(len_bytes, static_cast<uint32_t>(rec.size()));
std::fwrite(len_bytes, 1, sizeof(len_bytes), stdout);
if (!rec.empty()) std::fwrite(rec.data(), 1, rec.size(), stdout);
}
std::fflush(stdout);
return 0;
}
static int do_check() {
stream_stdin::set_stdin_binary();
const auto &expected = canonical_records();
size_t got_records = 0, got_bytes = 0;
for (const auto &exp : expected) {
// read_record is the composed path every demo now uses: the length
// assembly and the range check live in the header rather than four times
// over, so this exercises what the demos actually run.
std::vector<uint8_t> body;
uint32_t len = 0;
const auto r = stream_stdin::read_record(stdin, body, kMaxRecord, &len);
if (r == stream_stdin::RecordResult::BadLength) {
std::fprintf(stderr,
"stream_stdin_selftest: FAIL — record %zu length %u out of "
"range (stream desynced; likely CRLF/Ctrl-Z translation)\n",
got_records, len);
return 3;
}
if (r != stream_stdin::RecordResult::Ok) {
std::fprintf(stderr,
"stream_stdin_selftest: FAIL — record %zu returned %d "
"(binary stdin likely broken: a 0x1A in a prior record was "
"read as EOF)\n",
got_records, static_cast<int>(r));
return 2;
}
if (len != exp.size()) {
std::fprintf(stderr,
"stream_stdin_selftest: FAIL — record %zu length %u != "
"expected %zu (stream desynced; likely CRLF/Ctrl-Z "
"translation)\n",
got_records, len, exp.size());
return 4;
}
if (body != exp) {
std::fprintf(stderr,
"stream_stdin_selftest: FAIL — record %zu body differs from "
"source (text-mode translation corrupted the stream)\n",
got_records);
return 5;
}
++got_records;
got_bytes += len;
}
// Confirm clean EOF right after the last record — no trailing corruption.
uint8_t extra;
if (stream_stdin::read_exact(stdin, &extra, 1) != stream_stdin::ReadResult::Eof) {
std::fprintf(stderr,
"stream_stdin_selftest: FAIL — expected EOF after %zu records "
"but more bytes followed\n",
got_records);
return 6;
}
std::fprintf(stdout, "stream_stdin_selftest: records=%zu bytes=%zu OK\n",
got_records, got_bytes);
std::fflush(stdout);
return 0;
}
// Every demo maps read_record's states onto its own policy — streamtx warns on
// one and exits 2 on another, txdemo counts a lost shard, duplex stops its TX
// thread and leaves RX running. So the states have to mean exactly what they
// say. Driven through tmpfile() rather than the pipe, since these are the cases
// a well-formed stream never produces.
static int check_state(const char *what, const std::vector<uint8_t> &bytes,
std::size_t max, stream_stdin::RecordResult want,
uint32_t want_len) {
std::FILE *f = std::tmpfile();
if (!f) {
std::fprintf(stderr, "stream_stdin_selftest: tmpfile() failed\n");
return 7;
}
if (!bytes.empty()) std::fwrite(bytes.data(), 1, bytes.size(), f);
std::rewind(f);
std::vector<uint8_t> body;
uint32_t len = 0;
const auto got = stream_stdin::read_record(f, body, max, &len);
std::fclose(f);
if (got != want) {
std::fprintf(stderr,
"stream_stdin_selftest: FAIL — %s returned %d, expected %d\n",
what, static_cast<int>(got), static_cast<int>(want));
return 7;
}
if (want_len && len != want_len) {
std::fprintf(stderr,
"stream_stdin_selftest: FAIL — %s reported length %u, "
"expected %u\n", what, len, want_len);
return 7;
}
return 0;
}
static int do_states() {
using R = stream_stdin::RecordResult;
auto rec = [](uint32_t len, std::size_t body_bytes) {
std::vector<uint8_t> v(4);
put_u32_le(v.data(), len);
v.insert(v.end(), body_bytes, 0x1A); // 0x1A: EOF to a text-mode read
return v;
};
struct Case {
const char *what;
std::vector<uint8_t> bytes;
std::size_t max;
R want;
uint32_t want_len;
};
const std::vector<Case> cases = {
{"a complete record", rec(3, 3), 16, R::Ok, 3},
{"nothing at all", {}, 16, R::Eof, 0},
{"a truncated length prefix", {0x05, 0x00}, 16, R::Short, 0},
{"a length with no body at all", rec(4, 0), 16, R::EofMidBody, 4},
{"a body cut short", rec(8, 3), 16, R::Short, 8},
{"a zero length", rec(0, 0), 16, R::BadLength, 0},
{"a length past the bound", rec(99, 0), 16, R::BadLength, 99},
};
for (const auto &c : cases) {
const int rc = check_state(c.what, c.bytes, c.max, c.want, c.want_len);
if (rc) return rc;
}
/* The duplex demo escapes to a control TLV on the length's top bit, and
* bounds that body at 256 rather than at its PSDU maximum — so it reads the
* length, masks it, and only then reads the body. That composition is the
* reason read_length and read_body are public at all, and nothing else
* covers it: a record whose length has the top bit set must arrive
* verbatim, and must not be mistaken for an oversize PSDU. */
{
std::vector<uint8_t> bytes(4);
const uint32_t escaped = 0x80000000u | 3u;
put_u32_le(bytes.data(), escaped);
const std::vector<uint8_t> want = {0x01, 0x1A, 0x0D};
bytes.insert(bytes.end(), want.begin(), want.end());
std::FILE *f = std::tmpfile();
if (!f) {
std::fprintf(stderr, "stream_stdin_selftest: tmpfile() failed\n");
return 7;
}
std::fwrite(bytes.data(), 1, bytes.size(), f);
std::rewind(f);
uint32_t len = 0;
std::vector<uint8_t> body;
const bool ok =
stream_stdin::read_length(f, len) == stream_stdin::ReadResult::Ok &&
(len & 0x80000000u) != 0 &&
stream_stdin::read_body(f, body, len & 0x7fffffffu) ==
stream_stdin::ReadResult::Ok &&
body == want;
std::fclose(f);
if (!ok) {
std::fprintf(stderr, "stream_stdin_selftest: FAIL — the control-opcode "
"escape (top length bit) did not round-trip\n");
return 7;
}
}
std::fprintf(stdout, "stream_stdin_selftest: %zu record states OK\n",
cases.size());
return 0;
}
int main(int argc, char **argv) {
if (argc > 1 && std::strcmp(argv[1], "--gen") == 0) return do_gen();
if (const int rc = do_states()) return rc;
return do_check();
}
+135
View File
@@ -0,0 +1,135 @@
#include "usb_select.h"
#include <chrono>
#include <cstdlib>
#include <thread>
/* Fill the pick descriptor from an opened handle (best-effort — identity
* logging must never fail an open that succeeded). */
static void describe(libusb_device_handle *h, uint16_t vid, uint16_t pid,
UsbPick *picked) {
if (picked == nullptr || h == nullptr)
return;
picked->vid = vid;
picked->pid = pid;
libusb_device *dev = libusb_get_device(h);
if (dev == nullptr)
return;
picked->bus = libusb_get_bus_number(dev);
uint8_t ports[8];
const int pc = libusb_get_port_numbers(dev, ports, sizeof(ports));
picked->port.clear();
for (int p = 0; p < pc; ++p)
picked->port += (picked->port.empty() ? "" : ".") + std::to_string(ports[p]);
picked->speed = libusb_get_device_speed(dev);
}
libusb_device_handle *
open_selected_usb(libusb_context *ctx, const std::shared_ptr<Logger> &logger,
const uint16_t *default_pids, size_t n_default_pids,
UsbPick *picked) {
/* DEVOURER_PID env var (hex, e.g. "0x8813") restricts the open loop to a
* single PID. Useful when multiple Realtek adapters are plugged.
* DEVOURER_VID overrides the VID (default 0x0bda Realtek) — needed to reach
* OEM-rebadged Jaguar dongles like the TP-Link Archer T2U Plus (2357:0120). */
const char *pid_env = std::getenv("DEVOURER_PID");
uint16_t target_pid = 0;
if (pid_env != nullptr) {
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 = 0x0bda;
if (const char *vid_env = std::getenv("DEVOURER_VID")) {
target_vid = static_cast<uint16_t>(std::strtoul(vid_env, nullptr, 0));
logger->info("DEVOURER_VID={:04x} (overriding default VID)", target_vid);
}
libusb_device_handle *dev_handle = nullptr;
/* DEVOURER_USB_BUS (+ optional DEVOURER_USB_PORT) select a specific device by
* USB topology when several share one VID:PID and even the serial — e.g. two
* RTL8814AU dongles (CF-938AC vs CF-960AC) that enumerate identically, so only
* the bus/port tells them apart. DEVOURER_USB_PORT is the dotted libusb port
* path (as in sysfs `devpath` / `lsusb -t`, e.g. "2.3.2"). When bus is unset,
* the VID:PID open loop below runs as before. */
if (const char *bus_env = std::getenv("DEVOURER_USB_BUS")) {
const auto want_bus = static_cast<uint8_t>(std::strtoul(bus_env, nullptr, 0));
const char *port_env = std::getenv("DEVOURER_USB_PORT");
/* A named socket is EXPECTED to hold the device — but the previous
* session's close/kill leaves the chip re-enumerating (firmware reload
* through ROM, sometimes via the ZeroCD id) for several seconds. Poll
* bounded instead of failing on the first empty scan. */
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(15);
bool waited = false;
uint16_t matched_pid = 0;
do {
libusb_device **list = nullptr;
ssize_t n = libusb_get_device_list(ctx, &list);
for (ssize_t i = 0; i < n && dev_handle == nullptr; ++i) {
libusb_device_descriptor dd{};
if (libusb_get_device_descriptor(list[i], &dd) != 0) continue;
if (dd.idVendor != target_vid) continue;
if (target_pid != 0 && dd.idProduct != target_pid) continue;
if (libusb_get_bus_number(list[i]) != want_bus) continue;
if (port_env != nullptr) {
uint8_t ports[8];
int pc = libusb_get_port_numbers(list[i], ports, sizeof(ports));
std::string path;
for (int p = 0; p < pc; ++p)
path += (path.empty() ? "" : ".") + std::to_string(ports[p]);
if (path != port_env) continue;
}
if (libusb_open(list[i], &dev_handle) == 0) {
matched_pid = dd.idProduct;
logger->info("Opened device {:04x}:{:04x} on bus {} port {}",
dd.idVendor, dd.idProduct, want_bus,
port_env ? port_env : "(any)");
}
}
if (list != nullptr) libusb_free_device_list(list, 1);
if (dev_handle != nullptr) break;
if (!waited) {
logger->warn("DEVOURER_USB_BUS={} PORT={} matched no device — waiting "
"for it to (re-)enumerate",
want_bus, port_env ? port_env : "(any)");
waited = true;
}
std::this_thread::sleep_for(std::chrono::milliseconds(500));
} while (std::chrono::steady_clock::now() < deadline);
/* Topology selection is strict: falling through to the VID:PID loop here
* would silently open a DIFFERENT adapter sharing the id (the exact
* ambiguity DEVOURER_USB_BUS exists to resolve) — fail instead. */
if (dev_handle == nullptr) {
logger->error("DEVOURER_USB_BUS={} PORT={} matched no device", want_bus,
port_env ? port_env : "(any)");
return nullptr;
}
describe(dev_handle, target_vid, matched_pid, picked);
return dev_handle;
}
for (size_t i = 0; i < n_default_pids; ++i) {
const uint16_t pid = default_pids[i];
if (target_pid != 0 && pid != target_pid) continue;
dev_handle = libusb_open_device_with_vid_pid(ctx, target_vid, pid);
if (dev_handle != nullptr) {
logger->info("Opened device {:04x}:{:04x}", target_vid, pid);
describe(dev_handle, target_vid, pid, picked);
return dev_handle;
}
}
/* DEVOURER_PID can name a PID not in the default list (e.g. 0x0120 for the
* T2U Plus). Try that direct combination once before giving up. */
if (target_pid != 0) {
dev_handle = libusb_open_device_with_vid_pid(ctx, target_vid, target_pid);
if (dev_handle != nullptr) {
logger->info("Opened device {:04x}:{:04x} (via DEVOURER_PID)",
target_vid, target_pid);
describe(dev_handle, target_vid, target_pid, picked);
return dev_handle;
}
}
logger->error("Cannot find any supported device under VID {:04x}",
target_vid);
return nullptr;
}
+43
View File
@@ -0,0 +1,43 @@
#pragma once
/* Shared demo-side USB device selection — the DEVOURER_PID / DEVOURER_VID /
* DEVOURER_USB_BUS / DEVOURER_USB_PORT open loop, factored out of rxdemo so
* multi-adapter demos (chanscout as the second radio next to a primary
* receiver) bind deterministically without a third copy of the logic.
*
* Selection rules (unchanged from the historical rxdemo behaviour):
* DEVOURER_VID=0xNNNN override the default Realtek VID (OEM-rebadged
* dongles, e.g. TP-Link 2357:xxxx).
* DEVOURER_PID=0xNNNN restrict to one PID; may name a PID outside the
* caller's default list (tried directly).
* DEVOURER_USB_BUS=N select by USB topology when several adapters share
* DEVOURER_USB_PORT=a.b.c one VID:PID (and even the serial). The port path
* is the dotted libusb port chain (sysfs devpath /
* `lsusb -t`). Topology selection is STRICT: no
* silent fallback to a different same-id adapter,
* and it polls up to 15 s for a device still
* re-enumerating after the previous session.
*
* Returns an opened (not claimed) handle, or nullptr after logging why. */
#include <libusb.h>
#include <cstdint>
#include <memory>
#include <string>
#include "logger.h"
/* Physical identity of the opened device, for role/identity logging (the
* scout.id event): what was matched and where it sits on the bus. */
struct UsbPick {
uint16_t vid = 0, pid = 0;
uint8_t bus = 0;
std::string port; /* dotted port path, empty when unavailable */
int speed = 0; /* libusb_get_device_speed enum value */
};
libusb_device_handle *
open_selected_usb(libusb_context *ctx, const std::shared_ptr<Logger> &logger,
const uint16_t *default_pids, size_t n_default_pids,
UsbPick *picked = nullptr);
+384
View File
@@ -0,0 +1,384 @@
/* doctor — adapter health triage: is this dongle dying?
*
* Motivated by a field failure mode (OpenIPC/devourer#205) where a degrading
* RTL8812AU enumerates fine, inits green — and is stone-deaf, because its
* EFUSE reads return stochastic garbage (wrong RFE/PA/LNA → wrong PHY tables)
* and its 8051 never boots firmware (non-fatal on Jaguar1). Neither shows up
* as an init failure.
*
* What it runs (see src/AdapterHealth.h for the classifier semantics):
* 1. bring-up — InitWrite; an abort is an immediate FAILING.
* 2. EFUSE probe — N fresh physical map reads cross-compared. Any
* read-to-read mismatch = dying silicon (a healthy chip
* is byte-identical every read). Also validates the
* 0x8129 EEPROM ID. (8822E: skipped by design — its OTP
* is not reliably readable post-bring-up.)
* 3. FW boot — checksum + MCU-ready outcome of the bring-up's
* firmware download.
* 4. RX smoke — count FCS-clean frames for a window. Ambient traffic
* counts; in an RF-quiet place hearing nothing is only
* SUSPECT unless --expect-traffic vouches for a source
* (bench flood / busy AP on the channel).
*
* Verdict: HEALTHY / SUSPECT / FAILING (+ reasons), exit code 0 / 1 / 2
* (3 = tool/open error). Machine-readable summary as one JSONL event:
*
* {"ev":"doctor.verdict","verdict":"FAILING","reasons":"0x12",
* "efuse_reads":4,"efuse_mismatch":3,"efuse_bad_id":4,"efuse_id":"0x1029",
* "fw_attempted":1,"fw_ready":0,"rx_ok":0,"rx_crc":7,"init":1}
*
* CLI (no environment variables — device selection included, since a rig may
* hold two same-PID/same-serial adapters where only topology tells them
* apart):
*
* --vid 0xNNNN --pid 0xNNNN adapter select (default: first Realtek PID)
* --bus N --port a.b.c topology select (dotted libusb port path)
* --channel N bring-up + listen channel (default 6)
* --reads N EFUSE stability passes (default 4)
* --listen-secs N RX smoke window (default 8; 0 = skip)
* --expect-traffic operator vouches for on-channel traffic:
* 0 frames heard upgrades to FAILING
*
* Bench protocol for a definitive verdict on a suspect unit: put it on a
* uhubctl-switchable hub port, VBUS-cycle, run doctor with a beacon flood on
* the channel and --expect-traffic, and repeat from cold a few times
* (tests/adapter_doctor_cold.sh wraps exactly that).
*/
#ifdef _WIN32
#define NOMINMAX
#endif
#if defined(__ANDROID__) || defined(_MSC_VER) || defined(__APPLE__)
#include <libusb.h>
#else
#include <libusb-1.0/libusb.h>
#endif
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <string>
#include <thread>
#include "AdapterHealth.h"
#include "caps_event.h"
#include "DeviceSession.h"
#include "RxPacket.h"
#include "SignalStop.h"
#include "UsbOpen.h"
#include "WiFiDriver.h"
#include "IRtlRadio.h"
#include "logger.h"
namespace {
/* The Realtek PIDs the demos' open loop iterates; --pid narrows to one. */
const uint16_t kRealtekPids[] = {0x8812, 0x8813, 0x881a, 0x0811, 0xa811,
0x0820, 0x0821, 0x8822, 0x0120, 0x012d,
0xb82c, 0xc811, 0xc812, 0xa81a, 0xf72b,
0xb733};
struct Args {
uint16_t vid = 0x0bda;
int pid = -1; /* -1 = iterate kRealtekPids */
int bus = -1; /* -1 = no topology filter */
std::string port;
int channel = 6;
int reads = 4;
int listen_secs = 8;
bool expect_traffic = false;
};
bool parse_int(const char *s, int &out) {
char *end = nullptr;
long v = std::strtol(s, &end, 0);
if (end == s || *end != '\0')
return false;
out = static_cast<int>(v);
return true;
}
bool parse_args(int argc, char **argv, Args &a) {
for (int i = 1; i < argc; ++i) {
const std::string k = argv[i];
auto next = [&](int &out) {
return i + 1 < argc && parse_int(argv[++i], out);
};
int v = 0;
if (k == "--vid" && next(v))
a.vid = static_cast<uint16_t>(v);
else if (k == "--pid" && next(v))
a.pid = v;
else if (k == "--bus" && next(a.bus))
;
else if (k == "--port" && i + 1 < argc)
a.port = argv[++i];
else if (k == "--channel" && next(a.channel))
;
else if (k == "--reads" && next(a.reads))
;
else if (k == "--listen-secs" && next(a.listen_secs))
;
else if (k == "--expect-traffic")
a.expect_traffic = true;
else {
std::fprintf(stderr, "devourer [W] unknown/incomplete arg: %s\n",
k.c_str());
return false;
}
}
return true;
}
/* Open by USB topology (bus + dotted port path) when a rig holds several
* same-PID/same-serial adapters — the matcher rxdemo uses, CLI-fed. */
libusb_device_handle *open_by_topology(libusb_context *ctx, const Args &a,
const std::shared_ptr<Logger> &logger) {
libusb_device_handle *handle = nullptr;
libusb_device **list = nullptr;
ssize_t n = libusb_get_device_list(ctx, &list);
for (ssize_t i = 0; i < n && handle == nullptr; ++i) {
libusb_device_descriptor dd{};
if (libusb_get_device_descriptor(list[i], &dd) != 0)
continue;
if (dd.idVendor != a.vid)
continue;
if (a.pid >= 0 && dd.idProduct != static_cast<uint16_t>(a.pid))
continue;
if (libusb_get_bus_number(list[i]) != a.bus)
continue;
if (!a.port.empty()) {
uint8_t ports[8];
int pc = libusb_get_port_numbers(list[i], ports, sizeof(ports));
std::string path;
for (int p = 0; p < pc; ++p)
path += (path.empty() ? "" : ".") + std::to_string(ports[p]);
if (path != a.port)
continue;
}
if (libusb_open(list[i], &handle) == 0)
logger->info("Opened device {:04x}:{:04x} on bus {} port {}",
dd.idVendor, dd.idProduct, a.bus,
a.port.empty() ? "(any)" : a.port.c_str());
}
if (list != nullptr)
libusb_free_device_list(list, 1);
return handle;
}
const char *yn(bool b) { return b ? "yes" : "NO"; }
} // namespace
int main(int argc, char **argv) {
Args a;
if (!parse_args(argc, argv, a))
return 3;
auto logger = std::make_shared<Logger>();
install_devourer_signal_handlers();
/* Owns the teardown order (device -> interface -> handle -> context; see
* DeviceSession.h). Declared before the RX-smoke thread below, so it is
* joined before the adapter is released. Each early return from here on
* unwinds whatever has been adopted so far. */
devourer::DeviceSession session{logger};
libusb_context *ctx = nullptr;
if (libusb_init(&ctx) < 0) {
logger->error("libusb_init failed");
return 3;
}
session.adopt_context(ctx);
libusb_device_handle *handle = nullptr;
if (a.bus >= 0) {
handle = open_by_topology(ctx, a, logger);
} else if (a.pid >= 0) {
handle = libusb_open_device_with_vid_pid(ctx, a.vid,
static_cast<uint16_t>(a.pid));
} else {
for (uint16_t pid : kRealtekPids) {
handle = libusb_open_device_with_vid_pid(ctx, a.vid, pid);
if (handle)
break;
}
}
if (!handle) {
logger->error("no adapter found (vid {:04x})", a.vid);
return 3;
}
std::shared_ptr<devourer::UsbDeviceLock> lock;
if (devourer::claim_interface_then_reset(handle, devourer::find_wifi_interface(handle), logger, true, lock) !=
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 3;
}
session.adopt_handle(handle);
session.adopt_lock(lock);
/* keep_corrupted so the RX smoke can report the corrupt-frame count too
* (informational only); enable_with_tx so Jaguar3's InitWrite keeps the RX
* filters open for the StartRxLoop smoke window. */
devourer::DeviceConfig cfg;
cfg.rx.keep_corrupted = true;
cfg.rx.enable_with_tx = true;
WiFiDriver driver(logger);
std::unique_ptr<IRadio> owned_device =
driver.CreateRadio(handle, ctx, lock, cfg);
if (!owned_device) {
logger->error("CreateRadio failed (chip support not built?)");
return 3;
}
/* 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 dev = session.device();
devourer::emit_adapter_caps(logger->events(), dev);
devourer::AdapterHealthInput in;
/* 1. bring-up */
try {
dev->InitWrite(SelectedChannel{.Channel = static_cast<uint8_t>(a.channel),
.ChannelOffset = 0,
.ChannelWidth = CHANNEL_WIDTH_20});
in.init_completed = true;
} catch (const std::exception &e) {
logger->error("bring-up FAILED: {}", e.what());
in.init_completed = false;
}
/* 3. FW boot outcome — read even after an init abort: on the fatal-DLFW
* generations (Jaguar2/3) the throw IS the fw failure, and the status
* still says which stage died (checksum vs MCU boot). */
in.fw = dev->GetFwBootStatus();
if (in.init_completed) {
/* 2. EFUSE stability */
if (auto *rtl = dynamic_cast<IRtlRadio *>(dev))
in.efuse = rtl->ProbeEfuseStability(a.reads);
else
logger->warn("doctor: the EFUSE stability probe is Realtek-only "
"(IRtlRadio) — skipped on this radio");
/* 4. RX smoke */
if (a.listen_secs > 0 && !g_devourer_should_stop) {
std::atomic<uint32_t> ok{0}, crc{0};
std::thread rx([&] {
dev->StartRxLoop([&](const Packet &p) {
if (p.RxAtrib.crc_err || p.RxAtrib.icv_err)
crc++;
else
ok++;
});
});
for (int s = 0; s < a.listen_secs && !g_devourer_should_stop; ++s)
std::this_thread::sleep_for(std::chrono::seconds(1));
dev->StopRxLoop();
rx.join();
in.rx_checked = true;
in.rx_traffic_expected = a.expect_traffic;
in.rx_frames_ok = ok.load();
in.rx_frames_crc = crc.load();
}
}
uint32_t reasons = 0;
const devourer::AdapterVerdict v =
devourer::ClassifyAdapterHealth(in, reasons);
/* ---- report ---- */
std::printf("\n== adapter doctor ==\n");
std::printf("bring-up: %s\n", yn(in.init_completed));
{
/* Per-unit identity. In the report so it can be checked against the netdev
* name the vendor driver would give the same dongle (`wlx<mac>`) — a
* one-line confirmation that the EFUSE offset is right on a chip nobody
* has measured yet. Attempted even after a failed bring-up: on Jaguar1 the
* EEPROM map may already be in by then, and false degrades to the
* unavailable line either way. */
uint8_t mac[6];
if (dev->GetPermanentMacAddress(mac))
std::printf("efuse MAC: %02x:%02x:%02x:%02x:%02x:%02x\n", mac[0],
mac[1], mac[2], mac[3], mac[4], mac[5]);
else
std::printf("efuse MAC: unavailable (unsupported chip, or "
"unprogrammed)\n");
}
if (in.efuse.supported) {
std::printf("efuse stability: %d reads, %d mismatched, %d bad-id "
"(last id 0x%04x%s)\n",
in.efuse.reads, in.efuse.mismatched_reads,
in.efuse.invalid_id_reads, in.efuse.eeprom_id,
in.efuse.eeprom_id == devourer::kRtlEepromId ? "" : " ≠ 0x8129");
if (in.efuse.mismatched_reads > 0)
std::printf(" first mismatch at map offset 0x%x\n",
in.efuse.first_mismatch_off);
} else {
std::printf("efuse stability: not probed (unsupported on this chip or "
"bring-up failed)\n");
}
if (in.fw.supported && in.fw.attempted)
std::printf("fw boot: checksum %s, MCU ready %s\n",
yn(in.fw.checksum_ok), yn(in.fw.ready_ok));
if (in.rx_checked)
std::printf("rx smoke: %u ok / %u corrupt in %d s%s\n",
in.rx_frames_ok, in.rx_frames_crc, a.listen_secs,
in.rx_frames_ok == 0 && !a.expect_traffic
? " (no traffic guarantee — inconclusive if RF-quiet)"
: "");
std::printf("verdict: %s\n", devourer::AdapterVerdictName(v));
if (reasons & devourer::kAdapterInitFailed)
std::printf(" - bring-up aborted\n");
if (reasons & devourer::kAdapterEfuseUnstable)
std::printf(" - EFUSE reads are unstable — dying silicon; retire this "
"adapter\n");
if (reasons & devourer::kAdapterEfuseIdInvalid)
std::printf(" - EFUSE EEPROM ID is corrupt (calibration untrustworthy)\n");
if (reasons & devourer::kAdapterEfuseBlank)
std::printf(" - EFUSE is blank (autoload fail) — normal on some dev "
"boards, odd on retail adapters\n");
if (reasons & devourer::kAdapterFwBootFailed)
std::printf(" - MCU never booted firmware\n");
if (reasons & devourer::kAdapterRxDeafToTraffic)
std::printf(" - deaf to a vouched traffic source\n");
if (reasons & devourer::kAdapterRxSilent)
std::printf(" - heard nothing (supply known traffic + --expect-traffic "
"for a hard verdict)\n");
/* Machine-parseable summary (consumed by tests/adapter_doctor_cold.sh). */
devourer::Ev(logger->events(), "doctor.verdict")
.f("verdict", devourer::AdapterVerdictName(v))
.hexf("reasons", reasons)
.f("efuse_reads", in.efuse.reads)
.f("efuse_mismatch", in.efuse.mismatched_reads)
.f("efuse_bad_id", in.efuse.invalid_id_reads)
.hexf("efuse_id", in.efuse.eeprom_id, 4)
.f("fw_attempted", in.fw.attempted ? 1 : 0)
.f("fw_ready", in.fw.ready_ok ? 1 : 0)
.f("rx_ok", in.rx_frames_ok)
.f("rx_crc", in.rx_frames_crc)
.f("init", in.init_completed ? 1 : 0);
dev->Stop();
session.close();
switch (v) {
case devourer::AdapterVerdict::Healthy:
return 0;
case devourer::AdapterVerdict::Suspect:
return 1;
case devourer::AdapterVerdict::Failing:
return 2;
default:
return 3;
}
}
+585
View File
@@ -0,0 +1,585 @@
// duplex — single-chip full-duplex for the precoder stream link.
//
// Combines rxdemo's RX loop (Init → infinite_read → packet callback)
// with streamtx's stdin-driven TX (read length-prefixed PSDU body →
// send_packet) on ONE claimed interface. RX runs in the main thread; TX in a
// worker thread reads stdin and calls send_packet concurrently. libusb is
// thread-safe; the two bulk endpoints (_bulk_in_ep, _bulk_out_ep) don't share
// transfer state.
//
// Used by tools/precoder/tun_p2p.py in --mode=duplex with a single PID per
// peer. Replaces the streamtx + rxdemo pair (one adapter per
// direction) with a single binary per peer (one adapter per peer, ergo two
// adapters total for a P2P link instead of four).
//
// On-wire wire format on stdin is identical to streamtx:
// <u32_le length><length bytes of descrambled PSDU body>
// EOF on stdin closes the TX side cleanly; RX keeps running until the process
// terminates.
//
// RX emission on stdout mirrors examples/rx/main.cpp's DEVOURER_STREAM_OUT path —
// one `rx.frame` JSONL event for every frame matching the canonical SA.
// stdout is the JSONL event plane (stream.* control telemetry included);
// stderr carries the human diagnostics (logger).
#include <atomic>
#include <cassert>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <mutex>
#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>
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 "RxPacket.h"
#include "RadiotapBuilder.h"
#include "RtlAdapter.h"
#include "cell/RxReceipt.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,
};
// Same probe-request header as streamtx / 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. The canonical SA matcher in the packet
// processor below is identical to examples/rx/main.cpp's, so tooling that
// consumes rx.frame events sees the same frames from either demo.
// Radiotap is MUTABLE here (the adaptive link rewrites the on-air rate live via
// the stdin SET_RATE control op). Guarded by g_rt_mu against the TX thread.
static std::mutex g_rt_mu;
static std::vector<uint8_t> g_radiotap =
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;
}
// RX callback — emits an `rx.frame` event on canonical-SA matches. Event
// lines are emitted atomically (one fwrite per line, see src/Event.h), so no
// print mutex is needed against the TX thread's emissions.
static std::atomic<long> g_rx_hits{0};
/* Event sink for the demo's JSONL emissions (packet_processor and tx_thread
* are free functions) — points at the main() Logger's sink, set before the
* TX thread spawns / Init() runs. */
static devourer::EventSink *g_ev = nullptr;
/* DEVOURER_TX_STATUS=1: surface chip-side C2H frames (TX-status reports
* from the same 8812/8821 chip we're TXing on). Best-effort 8814A TX_RPT
* decode mirrors examples/rx/main.cpp; the C2H sub-type ID isn't enumerated in
* the vendored headers so the raw hex stays in the line. */
static const bool g_tx_status_enabled =
std::getenv("DEVOURER_TX_STATUS") != nullptr;
/* DEVOURER_RX_PCTR + DEVOURER_RX_AGG_SA: per-frame rx.seq delivery ledger,
* mirroring examples/rx/main.cpp — pctr is the u32 txdemo stamps at the
* QoS-Data body start (MPDU offset 26). In this demo the SA gate is required:
* the ledger's transmitter is a different station than the canonical-SA
* rx.frame stream above (the ARQ end-to-end bench keys on unicast frames
* whose TA can't be the group-address canonical SA). */
static const bool g_rx_pctr = []() {
const char *e = std::getenv("DEVOURER_RX_PCTR");
return e != nullptr && std::strcmp(e, "0") != 0;
}();
static uint8_t g_seq_sa[6] = {};
static const bool g_seq_sa_set = []() {
const char *e = std::getenv("DEVOURER_RX_AGG_SA");
if (e == nullptr || *e == '\0')
return false;
const auto m = devourer::parse_mac(e);
if (!m)
return false;
std::memcpy(g_seq_sa, m->data(), 6);
return true;
}();
/* DEVOURER_RX_RECEIPT_MS: windowed RX receipts (src/cell/RxReceipt.h) — the
* app-layer delivery truth. Every SA-matched pctr frame is noted in a sliding
* bitmap window, and every RECEIPT_MS a receipt frame (802.11 data, TA =
* DEVOURER_RX_RECEIPT_SA, RA = the tracked DEVOURER_RX_AGG_SA transmitter,
* body = the versioned TLV) is injected on this same handle — the feedback
* path. Windows overlap, so losing individual receipt frames costs nothing.
* Requires DEVOURER_RX_PCTR + DEVOURER_RX_AGG_SA (the ledger's identity). */
static const long g_receipt_ms = []() {
const char *e = std::getenv("DEVOURER_RX_RECEIPT_MS");
return e ? std::strtol(e, nullptr, 0) : 0L;
}();
static uint8_t g_receipt_sa[6] = {0x02, 0x44, 0x52, 0x00, 0x00, 0x01};
static const bool g_receipt_sa_ok = []() {
const char *e = std::getenv("DEVOURER_RX_RECEIPT_SA");
if (e == nullptr || *e == '\0')
return true; /* keep the default */
const auto m = devourer::parse_mac(e);
if (!m)
return false;
std::memcpy(g_receipt_sa, m->data(), 6);
return true;
}();
/* DEVOURER_RX_RECEIPT_WINDOW: bits of receipt coverage (default 8192). Size
* it past the worst backlog drain — see the sizing note in RxReceipt.h. */
static const uint16_t g_receipt_window_bits = []() {
const char *e = std::getenv("DEVOURER_RX_RECEIPT_WINDOW");
const long v = e ? std::strtol(e, nullptr, 0) : 8192L;
return static_cast<uint16_t>(v < 64 ? 64 : v > 65535 ? 65535 : v);
}();
static devourer::cell::ReceiptWindow g_receipt_window{g_receipt_window_bits};
/* Concurrent send_packet callers (the stdin TX thread + the receipt timer)
* serialize here — the per-generation send paths are single-caller. */
static std::mutex g_send_mu;
/* DEVOURER_RX_SINK_SPIN_US / DEVOURER_RX_SINK_STALL_MS+_EVERY: the same
* consumer-cost models as examples/rx/main.cpp — a per-frame busy-spin (the
* inline wfb-ng FEC+AES+UDP cost PixelPilot pays on this thread) and a
* periodic multi-ms stall (GC pause / consumer preemption). Both run on the
* libusb pump thread, which is exactly the point. */
static const long g_rx_sink_spin_us = []() {
const char *e = std::getenv("DEVOURER_RX_SINK_SPIN_US");
return e ? std::strtol(e, nullptr, 0) : 0L;
}();
static const long g_rx_stall_ms = []() {
const char *e = std::getenv("DEVOURER_RX_SINK_STALL_MS");
return e ? std::strtol(e, nullptr, 0) : 0L;
}();
static const long g_rx_stall_every = []() {
const char *e = std::getenv("DEVOURER_RX_SINK_STALL_EVERY");
const long v = e ? std::strtol(e, nullptr, 0) : 100L;
return v > 0 ? v : 100L; /* 0/garbage would divide-by-zero the modulo */
}();
/* Atomic: the RX callback can run on the TX thread's event pump too (libusb's
* sync API pumps events; see AsyncRxShared in src/UsbTransport.cpp). */
static std::atomic<long> g_rx_seen{0};
static void packet_processor(const Packet &packet) {
if (packet.RxAtrib.pkt_rpt_type == RX_PACKET_TYPE::C2H_PACKET) {
if (!g_tx_status_enabled) return;
devourer::Ev(*g_ev, "fw.c2h")
.f("len", packet.Data.size())
.hex("bytes", packet.Data.data(), packet.Data.size());
if (packet.Data.size() >= 8) {
for (size_t hoff : {size_t(1), size_t(2)}) {
if (packet.Data.size() < hoff + 6) continue;
const uint8_t *h = packet.Data.data() + hoff;
uint8_t queue = h[0] & 0x1f;
uint8_t retry = h[2] & 0x3f;
uint16_t qt_raw = static_cast<uint16_t>(h[3] | (h[4] << 8));
uint32_t qt_us = static_cast<uint32_t>(qt_raw) * 256u;
uint8_t rate = h[5];
devourer::Ev(*g_ev, "tx.status")
.f("hoff", hoff)
.f("queue", queue)
.f("retry", retry)
.f("airtime_us", qt_us)
.f("rate", rate);
}
}
return;
}
const long rx_seen = ++g_rx_seen;
if (g_rx_sink_spin_us > 0) {
const auto deadline = std::chrono::steady_clock::now() +
std::chrono::microseconds(g_rx_sink_spin_us);
while (std::chrono::steady_clock::now() < deadline) {
/* busy-wait: a sleep would yield the pump thread and defeat the model */
}
}
if (g_rx_stall_ms > 0 && (rx_seen % g_rx_stall_every) == 0) {
const auto deadline = std::chrono::steady_clock::now() +
std::chrono::milliseconds(g_rx_stall_ms);
while (std::chrono::steady_clock::now() < deadline) {
/* periodic consumer hiccup */
}
}
/* rx.seq — the ARQ bench's host-delivery ground truth: one lean event per
* SA-matched frame, same fields as rxdemo's so the analyzers are shared. */
if (g_rx_pctr && g_seq_sa_set && packet.Data.size() >= 30 &&
std::memcmp(packet.Data.data() + 10, g_seq_sa, 6) == 0) {
uint32_t pctr;
std::memcpy(&pctr, packet.Data.data() + 26, 4);
if (g_receipt_ms > 0)
g_receipt_window.note(pctr);
devourer::Ev(*g_ev, "rx.seq")
.t() /* host monotonic ms — correlates a pctr gap with an rx.ring dip */
.f("pctr", (unsigned long long)pctr)
.f("tsfl", packet.RxAtrib.tsfl)
.f("seq", packet.RxAtrib.seq_num)
.f("crc", packet.RxAtrib.crc_err ? 1 : 0)
.f("paggr", packet.RxAtrib.paggr ? 1 : 0)
.f("ppdu", packet.RxAtrib.ppdu_cnt);
}
if (packet.Data.size() < 16) return;
if (std::memcmp(packet.Data.data() + 10, kCanonicalSa, 6) != 0) return;
long hits = ++g_rx_hits;
// Full field set (mirrors examples/rx/main.cpp's rx.frame) so the adaptive
// VRX can score RSSI/SNR and the VTX can read RCF/DISC bodies + ACK_SEQ.
{
const int rssi[2] = {packet.RxAtrib.rssi[0], packet.RxAtrib.rssi[1]};
const int evm[2] = {packet.RxAtrib.evm[0], packet.RxAtrib.evm[1]};
const int snr[2] = {packet.RxAtrib.snr[0], packet.RxAtrib.snr[1]};
const size_t body_len =
packet.Data.size() > 24 ? packet.Data.size() - 24 : 0;
devourer::Ev(*g_ev, "rx.frame")
.f("rate", packet.RxAtrib.data_rate)
.f("len", packet.Data.size())
.f("crc", packet.RxAtrib.crc_err ? 1 : 0)
.f("icv", packet.RxAtrib.icv_err ? 1 : 0)
.arr("rssi", rssi, 2)
.arr("evm", evm, 2)
.arr("snr", snr, 2)
.f("seq", packet.RxAtrib.seq_num)
.f("tsfl", packet.RxAtrib.tsfl)
.f("bw", packet.RxAtrib.bw)
.f("stbc", packet.RxAtrib.stbc)
.f("ldpc", packet.RxAtrib.ldpc)
.f("sgi", packet.RxAtrib.sgi)
.hex("body", packet.Data.data() + 24, body_len);
}
if (hits <= 5 || hits % 500 == 0) {
devourer::Ev(*g_ev, "stream.rx").f("hits", hits);
}
}
struct TxArgs {
class IRadio *rtl; // unique_ptr lives in main(); raw ptr OK while
// we join() before that unique_ptr goes away
int interval_ms;
size_t max_psdu;
std::atomic<bool> *should_stop;
std::shared_ptr<Logger> logger;
};
static void tx_thread(TxArgs args) {
auto dot11 = build_dot11_probe_req();
std::vector<uint8_t> tx_buf;
tx_buf.reserve(g_radiotap.size() + dot11.size() + args.max_psdu);
long tx_count = 0;
while (!args.should_stop->load()) {
// This demo reads the length itself rather than using read_record: its top
// bit escapes to a control TLV with its own, much smaller bound, so the
// length has to be inspected before the body may be read.
uint32_t len = 0;
if (stream_stdin::read_length(stdin, len) != stream_stdin::ReadResult::Ok) {
// Clean EOF or short read — TX side done. RX keeps running.
devourer::Ev(*g_ev, "stream.eof").f("tx_count", tx_count);
break;
}
// Control-opcode escape: top bit set -> the body is a control TLV (the
// adaptive link's live knobs), not a PSDU. <op:u8><payload...>.
if (len & 0x80000000u) {
uint32_t clen = len & 0x7fffffffu;
if (clen == 0 || clen > 256) break;
std::vector<uint8_t> ctl;
if (stream_stdin::read_body(stdin, ctl, clen) !=
stream_stdin::ReadResult::Ok)
break;
uint8_t op = ctl[0];
if (op == 1 && clen >= 2) { // SET_PWR <idx>
/* Flat TXAGC override via the generation-agnostic runtime TX-power
* API (previously Jaguar1-only): applies live on every family. */
args.rtl->SetTxPowerIndexOverride(ctl[1]);
} else if (op == 2 && clen >= 2) { // SET_RATE <spec ascii>
std::string spec(ctl.begin() + 1, ctl.end());
auto rt = devourer::build_stream_radiotap(devourer::parse_tx_mode_str(spec));
std::lock_guard<std::mutex> lk(g_rt_mu);
g_radiotap = std::move(rt);
} else if (op == 3 && clen >= 4) { // SET_CHAN <ch><offset><width>
args.rtl->SetMonitorChannel(SelectedChannel{
.Channel = ctl[1], .ChannelOffset = ctl[2],
.ChannelWidth = static_cast<ChannelWidth_t>(ctl[3])});
}
devourer::Ev(*g_ev, "stream.ctl").f("op", op).f("len", clen);
continue;
}
if (len == 0 || len > args.max_psdu) {
args.logger->error("tx PSDU len {} out of range (max {})", len,
args.max_psdu);
break;
}
std::vector<uint8_t> psdu;
if (stream_stdin::read_body(stdin, psdu, len) !=
stream_stdin::ReadResult::Ok) {
/* EOF mid-PSDU: `bytes` = the expected PSDU length that was cut short. */
devourer::Ev(*g_ev, "stream.eof").f("tx_count", tx_count).f("bytes", len);
break;
}
tx_buf.clear();
{
std::lock_guard<std::mutex> lk(g_rt_mu); // live rate may be rewritten
tx_buf.insert(tx_buf.end(), g_radiotap.begin(), g_radiotap.end());
}
tx_buf.insert(tx_buf.end(), dot11.begin(), dot11.end());
tx_buf.insert(tx_buf.end(), psdu.begin(), psdu.end());
bool ok;
{
std::lock_guard<std::mutex> lk(g_send_mu);
ok = args.rtl->send_packet(tx_buf.data(), tx_buf.size());
}
++tx_count;
if (tx_count <= 5 || tx_count % 500 == 0) {
devourer::Ev(*g_ev, "stream.tx")
.f("n", tx_count)
.f("ok", ok ? 1 : 0)
.f("psdu", len);
}
if (args.interval_ms > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(args.interval_ms));
}
}
}
int main(int argc, char **argv) {
auto logger = std::make_shared<Logger>();
apply_logging_env(*logger); /* DEVOURER_LOG_LEVEL / DEVOURER_EVENTS / ... */
g_ev = &logger->events();
int interval_ms = 2;
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/CRLF doesn't corrupt the length-prefixed PSDU
// stream. Gated on _WIN32 (not _MSC_VER) in 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 the TX thread below, so that thread is
* 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) {
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);
/* Match rxdemo's libusb log level convention — WARNING by
* default, DEVOURER_USB_DEBUG=1 opts into DEBUG. */
libusb_set_option(context, LIBUSB_OPTION_LOG_LEVEL,
std::getenv("DEVOURER_USB_DEBUG")
? LIBUSB_LOG_LEVEL_DEBUG
: LIBUSB_LOG_LEVEL_WARNING);
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));
}
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;
const int wifi_iface = devourer::find_wifi_interface(handle);
rc = devourer::claim_interface_then_reset(handle, wifi_iface, 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, wifi_iface);
return 1;
}
session.adopt_handle(handle, wifi_iface);
session.adopt_lock(usb_lock);
WiFiDriver wifi_driver{logger};
auto owned_device = wifi_driver.CreateRadio(handle, nullptr, usb_lock,
devourer_config_from_env());
/* 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();
int channel = 6;
if (const char *ch_env = std::getenv("DEVOURER_CHANNEL")) {
channel = std::atoi(ch_env);
}
/* DEVOURER_TX_POWER: flat TXAGC index (see streamtx). Unset = each
* family's calibrated default — SetTxPower is now a real flat override on
* EVERY generation, so the old unconditional SetTxPower(40) (a no-op on
* Jaguar1/2) is gone. */
if (const char *p = std::getenv("DEVOURER_TX_POWER"))
rtlDevice->SetTxPower(static_cast<uint8_t>(std::atoi(p)));
std::atomic<bool> should_stop{false};
// Spawn TX thread first; it'll block on stdin until our peer pushes a
// length-prefixed PSDU. Then drop into Init() (the RX loop) in the main
// thread.
TxArgs txa{rtlDevice, interval_ms, max_psdu, &should_stop, logger};
std::thread tx{tx_thread, std::move(txa)};
/* Receipt emitter (DEVOURER_RX_RECEIPT_MS): every tick, encode the current
* window and inject it as an 802.11 data frame at a fixed robust 6M —
* receipts are control-plane, not part of the adaptive-rate stream. The
* first ticks fire during bring-up and fail harmlessly (send rc=false);
* the cadence, not any one frame, is the contract. */
std::thread receipt;
if (g_receipt_ms > 0 && g_seq_sa_set && g_receipt_sa_ok) {
receipt = std::thread([rtlDevice, &should_stop]() {
const auto rt =
devourer::build_stream_radiotap(devourer::parse_tx_mode_str("6M"));
std::vector<uint8_t> frame;
std::vector<uint8_t> tlv(
devourer::cell::receipt_tlv_size(g_receipt_window_bits));
long emitted = 0;
while (!should_stop.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(g_receipt_ms));
const size_t n =
g_receipt_window.encode(g_seq_sa, tlv.data(), tlv.size());
if (n == 0)
continue; /* nothing received yet */
frame.clear();
frame.insert(frame.end(), rt.begin(), rt.end());
/* Plain (non-QoS) data header: RA = the receipted transmitter,
* TA/BSSID = the receipt identity. Body at offset 24 = the TLV. */
const uint8_t hdr[24] = {
0x08, 0x00, 0x00, 0x00,
g_seq_sa[0], g_seq_sa[1], g_seq_sa[2],
g_seq_sa[3], g_seq_sa[4], g_seq_sa[5],
g_receipt_sa[0], g_receipt_sa[1], g_receipt_sa[2],
g_receipt_sa[3], g_receipt_sa[4], g_receipt_sa[5],
g_receipt_sa[0], g_receipt_sa[1], g_receipt_sa[2],
g_receipt_sa[3], g_receipt_sa[4], g_receipt_sa[5],
0x00, 0x00};
frame.insert(frame.end(), hdr, hdr + sizeof hdr);
frame.insert(frame.end(), tlv.data(), tlv.data() + n);
bool ok;
{
std::lock_guard<std::mutex> lk(g_send_mu);
ok = rtlDevice->send_packet(frame.data(), frame.size());
}
++emitted;
if (emitted <= 3 || emitted % 50 == 0) {
devourer::Ev(*g_ev, "receipt.tx")
.t()
.f("n", emitted)
.f("ok", ok ? 1 : 0)
.f("tlv_len", n)
.f("late", (unsigned long long)g_receipt_window.late());
}
}
});
}
logger->info("duplex entering RX loop on ch {} — TX thread ready",
channel);
// RX loop. Same Init() path as rxdemo; SelectedChannel sets up the
// shared monitor-mode bring-up (StartWithMonitorMode + SetMonitorChannel).
rtlDevice->Init(packet_processor,
SelectedChannel{.Channel = static_cast<uint8_t>(channel),
.ChannelOffset = 0,
.ChannelWidth = CHANNEL_WIDTH_20});
// Init() returns only on should_stop (set by signal handler in the future
// — none wired here, so Ctrl-C ends the process abruptly and the OS reaps
// the TX thread).
should_stop = true;
if (receipt.joinable()) receipt.join();
if (tx.joinable()) tx.join();
/* 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;
}
+307
View File
@@ -0,0 +1,307 @@
// dwelltx — dwell-1 A/B injection over the firmware channel switch.
//
// The data-plane experiment (issue #272): a two-context A/B schedule where the
// radio spends one wall-clock slot per channel and each slot carries EXACTLY
// ONE admitted data-frame opportunity — "dwell-1". The channel switch at each
// slot boundary is the on-chip firmware switch when DEVOURER_FASTRETUNE_FW is
// set (Jaguar2 8822B / Jaguar3 8822C/8822E; H2C 0x1D — see
// docs/experiments/kernel-channel-switch-offload.md); otherwise the software FastRetune,
// so the two are a controlled A/B.
//
// This is the caller-side answer to the rejected MCC/FCS scheduler
// (docs/experiments/mcc-fcs-investigation.md): the library already switches channels in
// ~1 ms and hops per-packet from a radiotap CHANNEL field. What a real hopping
// DATA plane needs on top of that is bounded admission — one frame per slot,
// placed inside a window that guarantees it finishes airing before the slot
// flips, so a frame built for context A can NEVER air on B. That admission
// policy lives here, in the caller, not in the library.
//
// Each per-slot frame carries a HopSyncMarker (src/HopSchedule.h) whose `slot`
// field, run through the same public/keyed schedule, tells any receiver which
// channel the slot belongs to — so an oracle pinned to channel A that decodes
// a frame whose slot maps to B is a self-evident wrong-channel event, and the
// unique per-slot marker doubles as the duplicate/dedup key. No extra tag.
//
// Env (mapped through examples/common/env_config.h for the library knobs):
// DEVOURER_PID / DEVOURER_VID / DEVOURER_USB_BUS / DEVOURER_USB_PORT
// DEVOURER_FASTRETUNE_FW=1|2 firmware switch (1 intra-band, 2 + cross-band)
// DEVOURER_DWELL_CHANNELS=36,40 the A,B pair (exactly two)
// DEVOURER_DWELL_SLOT_MS=N wall-clock slot length (default 20)
// DEVOURER_DWELL_SLOTS=N total slots then exit (0 = run forever)
// DEVOURER_DWELL_SETTLE_US=N post-switch settle before admitting (500)
// DEVOURER_DWELL_GUARD_US=N guard before the slot end (1000)
// DEVOURER_DWELL_AIRTIME_US=N budgeted frame airtime (300 @ 6M/96B)
// DEVOURER_DWELL_LATE_US=N inject: sleep this long into each slot
// before admitting (fault: forces drops)
// DEVOURER_HOP_SEED=<hex> keyed A/B order (default public sequential)
// DEVOURER_TX_RATE / DEVOURER_TX_PWR as the other demos
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <optional>
#include <string>
#include <thread>
#include <vector>
#if defined(_MSC_VER)
#include <libusb.h>
#elif defined(__MINGW32__) || defined(__MINGW64__)
#include <libusb-1.0/libusb.h>
#include <unistd.h>
#elif defined(__APPLE__)
#include <libusb.h>
#include <unistd.h>
#else
#include <unistd.h>
#include <libusb-1.0/libusb.h>
#endif
#include "ChannelFreq.h"
#include "DeviceSession.h"
#include "Event.h"
#include "HopSchedule.h"
#include "RadiotapBuilder.h"
#include "UsbOpen.h"
#include "WiFiDriver.h"
#include "env_config.h"
#include "logger.h"
#include "usb_select.h"
namespace {
using clock_t_ = std::chrono::steady_clock;
long env_long(const char *name, long def) {
if (const char *e = std::getenv(name)) {
long v = std::strtol(e, nullptr, 0);
return v;
}
return def;
}
// Full 24-byte probe-request MAC header with the canonical SA the RX path
// already matches (kept in lockstep with examples/tx and examples/rx — see
// CLAUDE.md). Address 3 (BSSID) = broadcast, so the header is exactly 24
// bytes and the appended HopSyncMarker lands at the body boundary the RX
// oracle reports (packet.Data + 24).
std::vector<uint8_t> build_dot11_probe_req() {
static const uint8_t sa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00};
static const uint8_t bcast[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff};
std::vector<uint8_t> h = {0x40, 0x00, 0x00, 0x00};
h.insert(h.end(), bcast, bcast + 6); // Address 1 (DA)
h.insert(h.end(), sa, sa + 6); // Address 2 (SA)
h.insert(h.end(), bcast, bcast + 6); // Address 3 (BSSID)
h.push_back(0x80); // seq/frag control
h.push_back(0x00);
return h;
}
} // namespace
int main() {
auto logger = std::make_shared<Logger>();
apply_logging_env(*logger);
auto &ev = logger->events();
// Owns the teardown order (device -> interface -> handle -> context; see
// DeviceSession.h).
devourer::DeviceSession session{logger};
libusb_context *ctx = nullptr;
if (libusb_init(&ctx) < 0)
return 1;
session.adopt_context(ctx);
static const uint16_t kDefaultPids[] = {0x8812, 0xb812, 0xc811, 0xc820,
0xc82c, 0x8814, 0xb82c};
libusb_device_handle *handle = open_selected_usb(
ctx, logger, kDefaultPids, sizeof(kDefaultPids) / sizeof(kDefaultPids[0]));
if (!handle)
return 1;
std::shared_ptr<devourer::UsbDeviceLock> usb_lock;
if (devourer::claim_interface_reset_reopen(
ctx, handle, logger, std::getenv("DEVOURER_SKIP_RESET") == nullptr,
usb_lock) != 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 driver{logger};
auto cfg = devourer_config_from_env(); // honors DEVOURER_FASTRETUNE_FW
auto owned_device = driver.CreateRadio(handle, nullptr, usb_lock, cfg);
if (!owned_device)
return 1;
// 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 dev = session.device();
// --- schedule + admission parameters ---------------------------------------
std::vector<int> chans;
if (const char *e = std::getenv("DEVOURER_DWELL_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 ? c : c - pos);
if (!tok.empty())
chans.push_back(std::atoi(tok.c_str()));
if (c == std::string::npos)
break;
pos = c + 1;
}
}
if (chans.size() < 2) {
logger->error("DEVOURER_DWELL_CHANNELS must name at least two channels");
return 2;
}
const long slot_ms = env_long("DEVOURER_DWELL_SLOT_MS", 20);
const long total_slots = env_long("DEVOURER_DWELL_SLOTS", 0);
// Post-switch settle before admitting a frame. On the Kestrel 8852B the
// default IO-offload hop returns before the ~1.5 ms RF synth settle (the host
// no longer self-paces it via slow register writes), so the window must
// cover that floor or a frame airs mid-retune. 2 ms is safe on every chip.
const long settle_us = env_long("DEVOURER_DWELL_SETTLE_US", 2000);
const long guard_us = env_long("DEVOURER_DWELL_GUARD_US", 1000);
const long airtime_us = env_long("DEVOURER_DWELL_AIRTIME_US", 300);
const long late_us = env_long("DEVOURER_DWELL_LATE_US", 0);
const int hop_fast = static_cast<int>(env_long("DEVOURER_HOP_FAST", 1));
std::optional<devourer::HopSchedule> sched;
if (const char *seed = std::getenv("DEVOURER_HOP_SEED"))
sched.emplace(devourer::HopSchedule::parse_seed(seed));
else
sched.emplace(devourer::HopSchedule::sequential());
dev->InitWrite(SelectedChannel{.Channel = static_cast<uint8_t>(chans[0]),
.ChannelOffset = 0,
.ChannelWidth = CHANNEL_WIDTH_20});
if (const char *p = std::getenv("DEVOURER_TX_PWR"))
dev->SetTxPower(static_cast<uint8_t>(std::atoi(p)));
const auto radiotap = devourer::build_stream_radiotap(devourer_tx_mode_from_env());
const auto dot11 = build_dot11_probe_req();
const uint32_t seed_fp = sched->fingerprint();
devourer::Ev(ev, "dwell.start")
.arr("channels", chans.data(), chans.size())
.f("n", (long long)chans.size())
.f("slot_ms", slot_ms)
.f("settle_us", settle_us)
.f("guard_us", guard_us)
.f("airtime_us", airtime_us)
.f("fw", cfg.tuning.fastretune_fw)
.f("hop_fast", hop_fast)
.hexf("seed_fp", seed_fp, 8);
const auto t0 = clock_t_::now();
auto us_since = [&](clock_t_::time_point t) {
return std::chrono::duration_cast<std::chrono::microseconds>(t - t0).count();
};
const long slot_us = slot_ms * 1000;
int64_t cur_slot = -1;
bool admitted = false;
long admitted_ct = 0, dropped_late_ct = 0, empty_ct = 0;
std::vector<uint8_t> buf;
buf.reserve(radiotap.size() + dot11.size() + 64);
uint32_t epoch = 0;
while (true) {
const auto now = clock_t_::now();
const int64_t slot = us_since(now) / slot_us;
if (total_slots > 0 && slot >= total_slots)
break;
if (slot != cur_slot) {
// --- slot boundary: retune to this slot's channel via the fw switch ----
if (cur_slot >= 0 && !admitted)
++empty_ct; // previous slot aired nothing (shouldn't happen w/ budget)
cur_slot = slot;
admitted = false;
const size_t idx =
sched->channel_index(static_cast<uint64_t>(slot), chans.size());
const int ch = chans[idx];
const auto sw0 = clock_t_::now();
dev->FastRetune(static_cast<uint8_t>(ch), /*cache_rf=*/hop_fast != 2);
const long switch_us =
std::chrono::duration_cast<std::chrono::microseconds>(
clock_t_::now() - sw0)
.count();
devourer::Ev(ev, "dwell.slot")
.f("slot", (unsigned long long)slot)
.f("ctx", (int)idx)
.f("channel", ch)
.f("switch_us", switch_us);
// Fault injection: sleep past the admission window on purpose.
if (late_us > 0)
std::this_thread::sleep_for(std::chrono::microseconds(late_us));
}
if (!admitted) {
const int64_t slot_start_us = slot * slot_us;
const int64_t admit_open = slot_start_us + settle_us;
const int64_t admit_close = slot_start_us + slot_us - guard_us - airtime_us;
const int64_t t = us_since(clock_t_::now());
if (t < admit_open) {
std::this_thread::sleep_for(std::chrono::microseconds(admit_open - t));
continue;
}
if (t > admit_close) {
// Missed the window — DROP with a structured reason. Never air a frame
// that would cross into the next slot's channel.
++dropped_late_ct;
admitted = true;
devourer::Ev(ev, "dwell.drop")
.f("slot", (unsigned long long)slot)
.f("reason_late_us", (long long)(t - admit_close));
continue;
}
// --- admit exactly one frame, tagged by the marker's slot --------------
const size_t idx =
sched->channel_index(static_cast<uint64_t>(slot), chans.size());
buf.assign(radiotap.begin(), radiotap.end());
buf.insert(buf.end(), dot11.begin(), dot11.end());
devourer::HopSyncMarker m{seed_fp, epoch, static_cast<uint32_t>(t % slot_us),
static_cast<uint64_t>(slot)};
const auto wire = devourer::HopSyncMarker::encode(m);
buf.insert(buf.end(), wire.begin(), wire.end());
const auto air0 = clock_t_::now();
dev->send_packet(buf.data(), buf.size());
admitted = true;
++admitted_ct;
devourer::Ev(ev, "dwell.tx")
.f("slot", (unsigned long long)slot)
.f("ctx", (int)idx)
.f("channel", chans[idx])
.f("in_slot_us", (long long)(t - slot_start_us))
.f("send_us",
(long long)std::chrono::duration_cast<std::chrono::microseconds>(
clock_t_::now() - air0)
.count());
} else {
// one opportunity per slot already spent — idle until the boundary
const int64_t next_boundary = (slot + 1) * slot_us;
const int64_t t = us_since(clock_t_::now());
if (next_boundary - t > 1500)
std::this_thread::sleep_for(std::chrono::microseconds(1000));
}
}
devourer::Ev(ev, "dwell.done")
.f("admitted", admitted_ct)
.f("dropped_late", dropped_late_ct)
.f("empty", empty_ct);
/* 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;
}
+379
View File
@@ -0,0 +1,379 @@
/* kestrelprobe — staged bring-up driver for the Kestrel (Wi-Fi 6 / 802.11ax,
* RTL8852BU / RTL8852CU), the USB sibling of pcieprobe. Validates the layers
* one at a time, bottom-up:
*
* id USB open + PID-table variant + AX-native identity: the die-id
* at R_AX_SYS_CHIPINFO (0x00FC) must match the PID-selected
* variant (0x51 = 8852B, 0x52 = 8852C); cut version from
* R_AX_SYS_CFG1[15:12].
* power + mac_ax power-on sequence + efuse logical map.
* fw + firmware download (cut-selected image) + fw-ready poll.
* trx + MAC TRX init (DMAC half).
* phy + BB/RF bring-up (tables, gain, RX path).
*
* Usage: sudo kestrelprobe [id|power|fw|trx|phy] [--vid 0xNNNN] [--pid 0xNNNN]
* Default stage: id. Without --vid/--pid the open loop scans the Kestrel PID
* table (kestrel/KestrelUsbIds.h). Two cold-plug traps: the TX20U Nano first
* enumerates as a ZeroCD disk (0bda:1a2b) until usb_modeswitch flips it to
* 35bc:0108, and the in-kernel rtw89_8852bu module auto-probes the NIC at
* every enumeration — temp-blacklist it (modprobe -r does not survive a
* re-enumeration; see docs/adapter-doctor.md).
*
* Events (stdout JSONL): kestrel.<stage> with ok:true|false — exit code 0
* only if the requested stage passed. */
#if defined(__ANDROID__) || defined(_MSC_VER) || defined(__APPLE__)
#include <libusb.h>
#else
#include <libusb-1.0/libusb.h>
#endif
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <string>
#include <thread>
#include "DeviceSession.h"
#include "Event.h"
#include "RtlAdapter.h"
#include "SignalStop.h" /* g_devourer_should_stop — SIGINT/SIGTERM flag */
#include "UsbOpen.h"
#include "logger.h"
#include "kestrel/ChipVariant.h"
#include "kestrel/KestrelUsbIds.h"
#include "kestrel/RtlKestrelDevice.h"
namespace {
const char *variant_name(kestrel::ChipVariant v) {
return v == kestrel::ChipVariant::C8852B ? "8852B" : "8852C";
}
bool parse_hex16(const char *s, uint16_t &out) {
char *end = nullptr;
long v = std::strtol(s, &end, 0);
if (end == s || *end != '\0' || v < 0 || v > 0xFFFF)
return false;
out = static_cast<uint16_t>(v);
return true;
}
} /* namespace */
int main(int argc, char **argv) {
std::string stage = "id";
uint16_t want_vid = 0, want_pid = 0; /* 0 = scan the Kestrel PID table */
for (int i = 1; i < argc; ++i) {
const std::string k = argv[i];
if (k == "id" || k == "power" || k == "fw" || k == "trx" || k == "phy" ||
k == "trig" || k == "twt")
stage = k;
else if (k == "--vid" && i + 1 < argc && parse_hex16(argv[++i], want_vid))
;
else if (k == "--pid" && i + 1 < argc && parse_hex16(argv[++i], want_pid))
;
else {
std::fprintf(stderr,
"usage: %s [id|power|fw|trx|phy|trig|twt] "
"[--vid 0xNNNN] [--pid 0xNNNN]\n",
argv[0]);
return 2;
}
}
/* trig/twt (5/6) go past phy to a full TX bring-up (InitWrite) + fire the
* scheduled-UL command; a 5 GHz channel where trigger-UL matters. */
const int want = stage == "twt" ? 6 : stage == "trig" ? 5
: stage == "phy" ? 4
: stage == "trx" ? 3
: stage == "fw" ? 2
: stage == "power" ? 1
: 0;
int chan = 36;
if (const char *e = std::getenv("DEVOURER_CHANNEL"))
chan = std::atoi(e);
auto logger = std::make_shared<Logger>();
/* Owns the teardown order (interface -> handle -> context; see
* DeviceSession.h). The RtlKestrelDevice below is a stack local declared
* after this, so it is destroyed first — the device-before-libusb half of
* the invariant falls out of declaration order. Each early return from here
* on unwinds whatever has been adopted so far. */
devourer::DeviceSession session{logger};
libusb_context *ctx = nullptr;
if (libusb_init(&ctx) < 0) {
logger->error("libusb_init failed");
return 1;
}
session.adopt_context(ctx);
libusb_device_handle *handle = nullptr;
kestrel::ChipVariant variant = kestrel::ChipVariant::C8852B;
uint16_t vid = 0, pid = 0;
if (want_vid && want_pid) {
auto v = kestrel::variant_for_usb_id(want_vid, want_pid);
if (!v) {
logger->error("{:04x}:{:04x} is not in the Kestrel PID table "
"(kestrel/KestrelUsbIds.h)",
want_vid, want_pid);
return 2;
}
handle = libusb_open_device_with_vid_pid(ctx, want_vid, want_pid);
variant = *v;
vid = want_vid;
pid = want_pid;
} else {
for (const auto &kid : kestrel::kKestrelUsbIds) {
handle = libusb_open_device_with_vid_pid(ctx, kid.vid, kid.pid);
if (handle) {
variant = kid.variant;
vid = kid.vid;
pid = kid.pid;
break;
}
}
}
if (!handle) {
logger->error("no Kestrel adapter found (scanned the KestrelUsbIds "
"table). If lsusb shows 0bda:1a2b the dongle is still in "
"ZeroCD disk mode — usb_modeswitch it first; if a rtw89_* "
"module holds it, temp-blacklist (not modprobe -r).");
devourer::Ev(logger->events(), "kestrel.id")
.f("ok", false)
.f("why", "open");
return 1;
}
std::shared_ptr<devourer::UsbDeviceLock> lock;
if (devourer::claim_interface_then_reset(handle, 0, logger, true, lock) !=
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, 0);
return 1;
}
/* This probe claims interface 0 explicitly (above), so the release must
* name it rather than re-resolving. */
session.adopt_handle(handle, 0);
session.adopt_lock(lock);
/* ---- stage id: register plane only, no power, no DMA ---- */
RtlKestrelDevice dev(RtlAdapter(handle, logger, ctx, lock, {}), logger,
variant);
const kestrel::ChipInfo info = dev.ReadChipInfo();
const bool id_ok = info.matches(variant);
logger->info("id: {:04x}:{:04x} -> {} | die-id=0x{:02x} (want 0x{:02x}) "
"cut={}",
vid, pid, variant_name(variant), info.die_id,
variant == kestrel::ChipVariant::C8852B ? 0x51 : 0x52,
info.cut);
devourer::Ev(logger->events(), "kestrel.id")
.f("ok", id_ok)
.f("variant", variant_name(variant))
.hexf("vid", vid, 4)
.hexf("pid", pid, 4)
.hexf("die_id", info.die_id, 2)
.f("cut", info.cut);
if (!id_ok || want < 1) {
return id_ok ? 0 : 1;
}
/* ---- stage power: mac_ax power-on + efuse dump ---- */
kestrel::EfuseInfo efuse;
bool power_ok = false;
try {
power_ok = dev.PowerOnAndReadEfuse(efuse);
} catch (const std::exception &e) {
logger->error("power: power/efuse threw: {}", e.what());
}
char mac[18] = "??:??:??:??:??:??";
if (power_ok)
snprintf(mac, sizeof(mac), "%02x:%02x:%02x:%02x:%02x:%02x", efuse.mac[0],
efuse.mac[1], efuse.mac[2], efuse.mac[3], efuse.mac[4],
efuse.mac[5]);
logger->info("power: power_ok={} MAC={} xtal=0x{:02x} rfe=0x{:02x} "
"thermalA=0x{:02x} thermalB=0x{:02x} autoload={}",
power_ok, mac, efuse.xtal_cap, efuse.rfe_type, efuse.thermal_a,
efuse.thermal_b, efuse.autoload_ok);
devourer::Ev(logger->events(), "kestrel.power")
.f("ok", power_ok)
.f("mac", mac)
.hexf("xtal", efuse.xtal_cap, 2)
.hexf("rfe", efuse.rfe_type, 2)
.f("autoload", efuse.autoload_ok);
if (!power_ok || want < 2) {
return power_ok ? 0 : 1;
}
/* ---- stage fw: firmware download ---- */
bool fw_ok = false;
try {
/* Re-run the whole sequence so the stage is self-contained (power-on is
* cheap and idempotent from a clean handle). trx re-runs fw internally.
* trig/twt (>=5) take the full TX bring-up (InitWrite = phy + scheduler +
* self-STA role) so the fw can accept the scheduled-UL H2Cs. */
if (want >= 5) {
dev.InitWrite(SelectedChannel{.Channel = static_cast<uint8_t>(chan),
.ChannelOffset = 0,
.ChannelWidth = CHANNEL_WIDTH_20});
fw_ok = true;
} else {
fw_ok = (want >= 4) ? dev.PowerOnTrxAndPhy(efuse)
: (want >= 3) ? dev.PowerOnFwAndTrx(efuse)
: dev.PowerOnEfuseAndFw(efuse);
}
} catch (const std::exception &e) {
logger->error("fw/trx/phy: bring-up threw: {}", e.what());
}
if (want < 3) {
logger->info("fw: fw_ok={}", fw_ok);
devourer::Ev(logger->events(), "kestrel.fw").f("ok", fw_ok);
return fw_ok ? 0 : 1;
}
if (want < 4) {
/* ---- stage trx: DMAC + CMAC MAC TRX init ---- */
logger->info("trx: trx_ok={}", fw_ok);
devourer::Ev(logger->events(), "kestrel.trx").f("ok", fw_ok);
return fw_ok ? 0 : 1;
}
if (want < 5) {
/* ---- stage phy: BB + RF table apply ---- */
logger->info("phy: phy_ok={}", fw_ok);
devourer::Ev(logger->events(), "kestrel.phy").f("ok", fw_ok);
return fw_ok ? 0 : 1;
}
/* ---- stage trig: air a Basic Trigger (UL-OFDMA grant) via the F2P command.
* Self-contained fw probe — no associated STA needed; a HE monitor (or an
* 11ac witness on the legacy PPDU) decodes the aired trigger as rx.trigger.
* Fires a short burst so an SDR reads a duty cycle. ---- */
if (want == 5) {
int count = 200;
if (const char *e = std::getenv("DEVOURER_TRIGGER_COUNT"))
count = std::atoi(e);
/* Register the self MACID as an AP role (not the CLIENT role InitWrite set)
* so the fw runs its AP-side scheduler — the beacon port NET_TYPE is AP but
* the fw role must be AP too for the trigger transmitter to engage
* (role.c). SMA=TMA=BSSID for an AP self_role. */
const uint8_t ap_bssid[6] = {0x02, 0x42, 0x75, 0x05, 0xd6, 0x00};
bool aprole = dev.hal().register_ap_role(ap_bssid);
logger->info("trig: register_ap_role -> {}", aprole);
devourer::TriggerConfig cfg;
cfg.ul_bw = 0; /* 20 MHz */
cfg.n_users = 1;
cfg.users[0].aid12 = 1;
cfg.users[0].macid = 0;
cfg.users[0].ru_alloc = devourer::he_ru_alloc(242, 0); /* full 20 MHz */
cfg.users[0].ul_mcs = 0;
cfg.users[0].ss = 1;
cfg.users[0].tgt_rssi_dbm = -60;
/* The trigger frame's OWN PPDU rate (tf_wd.datarate). Must be an OFDM rate
* on 5 GHz — rate 0 is CCK 1 Mbps, which does not exist above ch14, so the
* fw cannot air the trigger there (same CCK-on-5GHz trap as the AP rate
* set). OFDM 6M (AX rate code 4) airs on both bands. */
cfg.trig_rate = chan > 14 ? 4 : 0;
if (const char *e = std::getenv("DEVOURER_TRIG_RATE"))
cfg.trig_rate = static_cast<uint16_t>(std::atoi(e));
/* mode/frexch_type are the runtime-discovered fw fields; DEVOURER_TRIG_MODE
* sweeps the 2-bit mode to find the one that airs a Basic Trigger. */
if (const char *e = std::getenv("DEVOURER_TRIG_MODE"))
cfg.mode = static_cast<uint8_t>(std::atoi(e) & 0x3);
/* DEVOURER_UL_FIXINFO=1 programs the production UL-OFDMA scheduler table
* (mode=tf_periodic) instead of the F2P test command — the fw then airs
* Triggers autonomously at `interval` for the granted macid. This is the
* canonical path (F2P_TEST has no vendor production caller). */
if (std::getenv("DEVOURER_UL_FIXINFO")) {
/* DEVOURER_UL_PEER=1 registers a distinct peer STA (macid 1) first, so the
* scheduler grants to a peer rather than the self-STA — a cheap probe of
* whether the UL-OFDMA engine airs a trigger for a registered (but not
* associated) peer, short of the full tier-C association. */
uint8_t grant_macid = 0;
if (std::getenv("DEVOURER_UL_PEER")) {
const uint8_t peer_mac[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x55};
const bool pok = dev.RegisterPeerSta(peer_mac, /*macid=*/1,
/*addr_cam_idx=*/1);
logger->info("trig: register_peer_sta(macid=1) -> {}", pok);
grant_macid = 1;
}
devourer::UlOfdmaConfig ul;
ul.mode = 3; /* tf_periodic */
ul.interval_s = 1;
ul.tf_type = 0; /* Basic */
ul.ppdu_bw = 0;
ul.n_stas = 1;
ul.stas[0].macid = grant_macid;
ul.stas[0].ru_pos = 61; /* full 20 MHz */
ul.stas[0].mcs = 0;
ul.stas[0].ss = 1;
ul.stas[0].tgt_rssi_dbm = -60;
const bool ulok = dev.ConfigureUlOfdma(ul);
logger->info("trig: ul_fixinfo(tf_periodic) -> {}", ulok);
/* Hold so the fw's periodic engine airs several rounds for the witness. */
for (int i = 0; i < 40 && !g_devourer_should_stop; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
devourer::Ev(logger->events(), "kestrel.trig")
.f("ok", ulok)
.f("mode", "ul_fixinfo")
.f("chan", chan);
dev.Stop();
return ulok ? 0 : 1;
}
int sent = 0;
for (int i = 0; i < count && !g_devourer_should_stop; ++i) {
if (dev.SendTrigger(cfg))
++sent;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
const bool ok = sent > 0;
logger->info("trig: sent {}/{} triggers (mode={})", sent, count, cfg.mode);
devourer::Ev(logger->events(), "kestrel.trig")
.f("ok", ok)
.f("sent", sent)
.f("count", count)
.f("mode", cfg.mode)
.f("chan", chan);
/* InitWrite started the WP-drain thread — join it before the session
* unwinds libusb, or a thread still inside libusb trips
* usbi_mutex_destroy (SIGABRT). */
dev.Stop();
return ok ? 0 : 1;
}
/* ---- stage twt: create an individual TWT agreement + (attempt) TWT-OFDMA
* cadence, and drain any TWT C2H the fw emits. Discovery: whether the shipped
* fw honors the (non-canonical) TWT-OFDMA func 0x03. ---- */
devourer::TwtConfig twt;
twt.broadcast = false;
twt.trigger = true;
twt.config_id = 0;
twt.flow_id = 0;
twt.ap_role = true;
twt.wake_exp = 10;
twt.wake_man = 512;
twt.trgt_tsf = dev.ReadTsf() + 100000; /* 100 ms out */
const bool twt_ok = dev.ConfigureTwt(twt);
devourer::TwtOfdmaConfig ofd;
ofd.twt_id = 0;
ofd.round_num = 4;
ofd.round_interval_us = 2000;
ofd.max_tf_retry = 3;
const bool ofdma_ok = dev.ConfigureTwtOfdma(ofd);
/* Drain the bulk-IN briefly so any TWT_NOTIFY / WAIT_ANNOUNCE C2H is routed
* (handle_c2h logs twt.notify / twt.wait_anno on the diagnostic plane). */
logger->info("twt: configure={} ofdma_cmd={} (watch for twt.notify C2H)",
twt_ok, ofdma_ok);
devourer::Ev(logger->events(), "kestrel.twt")
.f("ok", twt_ok)
.f("ofdma_cmd", ofdma_ok)
.f("chan", chan);
dev.Stop(); /* join the WP-drain thread before libusb teardown (see trig) */
return twt_ok ? 0 : 1;
}
+115
View File
@@ -0,0 +1,115 @@
/* pcieprobe — staged bring-up driver for the PCIe transport (RTL8821CE).
*
* Validates the PCIe milestones one layer at a time, bottom-up:
* id (M0) vfio open + BAR2 MMIO: chip-id @0xFC must read 0x09,
* SYS_CFG1/REG_CR sanity.
* power (M1) + TRX ring registers, pre-init, PCIe power-on sequence,
* chip version, EFUSE logical map (MAC @0xD0 must match the
* address the kernel driver reported).
* fw (M2) + init_system_cfg + firmware DLFW over the BCN TX ring
* (pass = REG_MCUFW_CTRL 0x80 == 0xC078).
* Full RX (M3) lives in rxdemo via DEVOURER_PCIE_BDF.
*
* Usage: sudo pcieprobe <bdf> [id|power|fw] (default stage: id)
* The device must be bound to vfio-pci first: tests/pcie_vfio_bind.sh <bdf>.
*
* Events (stdout JSONL): pcie.id / pcie.power / pcie.fw with ok:true|false —
* exit code 0 only if the requested stage passed. */
#include <cstdio>
#include <cstring>
#include <memory>
#include <string>
#include <vector>
#include "Event.h"
#include "PcieTransport.h"
#include "RtlAdapter.h"
#include "logger.h"
#include "jaguar2/ChipVariant.h"
#include "jaguar2/HalJaguar2.h"
#include "jaguar2/HalmacJaguar2Fw.h"
#include "jaguar2/HalmacJaguar2MacInit.h"
int main(int argc, char **argv) {
if (argc < 2) {
fprintf(stderr, "usage: %s <bdf e.g. 0000:01:00.0> [id|power|fw]\n",
argv[0]);
return 2;
}
const std::string bdf = argv[1];
const std::string stage = argc > 2 ? argv[2] : "id";
const int want = stage == "fw" ? 2 : stage == "power" ? 1 : 0;
auto logger = std::make_shared<Logger>();
auto transport = devourer::PcieTransport::Open(bdf, logger);
if (!transport) {
devourer::Ev(logger->events(), "pcie.id").f("ok", false).f("why", "open");
return 1;
}
/* ---- stage id (M0): pure MMIO register plane, no power, no DMA ---- */
RtlAdapter adapter(transport, logger, {});
const uint8_t chip_id = adapter.rtw_read8(0x00FC);
const uint32_t sys_cfg1 = adapter.rtw_read32(0x00F0);
const uint8_t cr = adapter.rtw_read8(0x0100);
const bool id_ok = chip_id == 0x09;
logger->info("M0: chip-id=0x{:02x} (want 0x09) SYS_CFG1=0x{:08x} CR=0x{:02x}",
chip_id, sys_cfg1, cr);
devourer::Ev(logger->events(), "pcie.id")
.f("ok", id_ok)
.hexf("chip_id", chip_id, 2)
.hexf("sys_cfg1", sys_cfg1, 8)
.hexf("cr", cr, 2);
if (!id_ok || want < 1)
return id_ok ? 0 : 1;
/* ---- stage power (M1): rings -> pre-init -> PCIe power-on -> EFUSE ---- */
jaguar2::HalJaguar2 hal(adapter, logger, jaguar2::ChipVariant::C8821C, {});
jaguar2::HalmacJaguar2MacInit macinit(adapter, logger,
jaguar2::ChipVariant::C8821C);
bool power_ok = false;
std::vector<uint8_t> efuse(0x200, 0xFF);
try {
/* rtw88 order: hci_setup (ring registers) precedes mac_power_on. */
transport->setup_trx_rings();
macinit.pre_init_system_cfg();
hal.power_on();
hal.read_chip_version();
hal.read_efuse_logical_map(efuse.data(), efuse.size(), /*dump=*/false);
power_ok = true;
} catch (const std::exception &e) {
logger->error("M1: power-on failed: {}", e.what());
}
/* 8821CE efuse: MAC at logical 0xD0 (rtw8821ce_efuse; the USB variant keeps
* it elsewhere). Cross-check against the kernel-reported MAC. */
char mac[18];
snprintf(mac, sizeof(mac), "%02x:%02x:%02x:%02x:%02x:%02x", efuse[0xD0],
efuse[0xD1], efuse[0xD2], efuse[0xD3], efuse[0xD4], efuse[0xD5]);
const uint16_t efuse_id =
static_cast<uint16_t>(efuse[0] | (efuse[1] << 8));
logger->info("M1: power_ok={} efuse id=0x{:04x} MAC(0xD0)={}", power_ok,
efuse_id, mac);
devourer::Ev(logger->events(), "pcie.power")
.f("ok", power_ok)
.hexf("efuse_id", efuse_id, 4)
.f("mac", mac);
if (!power_ok || want < 2)
return power_ok ? 0 : 1;
/* ---- stage fw (M2): system cfg + DLFW over the BCN ring ---- */
bool fw_ok = false;
try {
macinit.init_system_cfg(CHANNEL_WIDTH_20, hal.chip_version().cut);
jaguar2::HalmacJaguar2Fw fw(adapter, logger, jaguar2::ChipVariant::C8821C);
fw_ok = fw.download_default_firmware();
} catch (const std::exception &e) {
logger->error("M2: DLFW failed: {}", e.what());
}
const uint16_t mcufw = adapter.rtw_read16(0x0080);
logger->info("M2: fw_ok={} MCUFW_CTRL=0x{:04x} (want 0xC078)", fw_ok, mcufw);
devourer::Ev(logger->events(), "pcie.fw").f("ok", fw_ok).hexf("mcufw", mcufw, 4);
return fw_ok ? 0 : 1;
}
+282
View File
@@ -0,0 +1,282 @@
// precoder — transmit a pre-shaped PSDU produced by
// tools/precoder/encode_subcarriers.py.
//
// This is the on-air vehicle for the pre-modulator subcarrier PoC: the Python
// encoder inverts the chip's BPSK/BCC/interleaver/scrambler pipeline and emits
// the PSDU bytes that make chosen OFDM data subcarriers carry chosen bits.
// This demo wraps those bytes in the fixed radiotap + 802.11 header the chip
// and the existing TX-validation tooling expect, then streams them out.
//
// Scope (matches let-s-plan-this PoC): RTL8812AU / RTL8821AU / RTL8811AU,
// single-stream BPSK, BCC, 20 MHz. RTL8814AU is out of scope (issue #36 TX
// flakiness would mask the experiment).
//
// RATE CHOICE — legacy 6 Mbps OFDM, not HT MCS 0. The plan said HT MCS 0, but
// RtlJaguarDevice::send_packet only wires `fixed_rate` from the radiotap RATE
// field (legacy) or the VHT field — the HT MCS *index* is never read, so an
// HT-MCS radiotap with no RATE field would transmit at the MGN_1M default =
// 1 Mbps CCK (DSSS, NO OFDM subcarriers at all), defeating the whole PoC.
// Legacy 6 Mbps is BPSK rate-1/2 OFDM (48 data subcarriers, 16x3 interleaver)
// = exactly the encoder's `--phy legacy` and the plan's "48 subcarrier" prose.
// Encode shaped PSDUs with `--phy legacy` (the encoder default) to match.
//
// Usage:
// DEVOURER_PID=0x8812 DEVOURER_CHANNEL=6 ./build/precoder --psdu shaped.bin
// [--count N] [--interval-ms MS] (Termux: pass the numeric USB fd as argv[1])
//
// Env: DEVOURER_VID / DEVOURER_PID / DEVOURER_CHANNEL / DEVOURER_SKIP_RESET —
// same conventions as the other demos.
#include <cassert>
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iostream>
#include <memory>
#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 <windows.h>
#include <process.h>
typedef int pid_t;
#define sleep(seconds) Sleep((seconds)*1000)
#elif defined(__ANDROID__)
#include <libusb.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 "RtlAdapter.h"
#include "UsbOpen.h"
#include "WiFiDriver.h"
#include "env_config.h"
#include "logger.h"
#define USB_VENDOR_ID 0x0bda
// Same PID set as the RX/TX demos. The precoder PoC targets the single-stream
// Jaguar parts; pin one with DEVOURER_PID (e.g. 0x8812).
static constexpr uint16_t kRealtekProductIds[] = {
0x8812, 0x0811, 0xa811, 0xb811, 0x8813,
};
// Legacy 6 Mbps OFDM radiotap header (13 bytes). Presence = RATE(bit2) |
// TX_FLAGS(bit15); it_present = 0x00008004. Field layout after the 8-byte
// header: RATE @ offset 8 = 0x0c (12 * 500 kbps = 6 Mbps; numerically == the
// MGN_6M enum send_packet feeds to MRateToHwRate -> DESC_RATE6M), one pad byte
// for TX_FLAGS' 2-byte alignment, TX_FLAGS @ 10-11 = 0x0008, one trailing pad.
// Kept at exactly 13 bytes (0x0d) so send_packet's `len != 0x0d -> vht=true`
// heuristic leaves us on the non-VHT path (rate_id 8).
static const uint8_t kRadiotapLegacy6M[13] = {
0x00, 0x00, 0x0d, 0x00, 0x04, 0x80, 0x00,
0x00, 0x0c, 0x00, 0x08, 0x00, 0x00};
// Canonical TX-validation source MAC — shared with examples/tx/main.cpp,
// examples/rx/main.cpp's `rx.txhit` event matcher, tests/regress.py
// (CANONICAL_SA) and tests/inject_beacon.py. Change all of them together if it
// ever moves.
static const uint8_t kCanonicalSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00};
// 802.11 probe-request mgmt header (24 bytes), mirroring examples/tx/main.cpp's
// frame. DATA frames (ToDS) get silently NAKed by the chip in monitor mode —
// the plan's "Data frame" wording is superseded by txdemo's hard-won
// probe-request, which the SA matcher recognises identically (addr2 at +10).
static std::vector<uint8_t> build_dot11_probe_req() {
std::vector<uint8_t> h = {
0x40, 0x00, // frame control: mgmt / probe request
0x00, 0x00, // duration
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, // addr1 / DA (broadcast)
};
h.insert(h.end(), kCanonicalSa, kCanonicalSa + 6); // addr2 / SA
h.insert(h.end(), kCanonicalSa, kCanonicalSa + 6); // addr3 / BSSID
h.push_back(0x80); // seq_ctl
h.push_back(0x00);
return h; // 24 bytes
}
static bool read_file(const std::string &path, std::vector<uint8_t> &out) {
std::ifstream f(path, std::ios::binary);
if (!f) return false;
out.assign(std::istreambuf_iterator<char>(f),
std::istreambuf_iterator<char>());
return true;
}
int main(int argc, char **argv) {
auto logger = std::make_shared<Logger>();
apply_logging_env(*logger); /* DEVOURER_LOG_LEVEL / DEVOURER_EVENTS / ... */
std::string psdu_path;
long count = -1; // -1 == loop forever (like txdemo)
int interval_ms = 2; // ~500 fps, gentle on the bulk EP
long termux_fd = 0;
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
if (a == "--psdu" && i + 1 < argc) {
psdu_path = argv[++i];
} else if (a == "--count" && i + 1 < argc) {
count = std::strtol(argv[++i], nullptr, 0);
} else if (a == "--interval-ms" && i + 1 < argc) {
interval_ms = std::atoi(argv[++i]);
} else {
// A bare numeric arg is the Termux USB fd (libusb_wrap_sys_device).
char *end = nullptr;
long v = std::strtol(a.c_str(), &end, 0);
if (end && *end == '\0' && v > 0) termux_fd = v;
}
}
if (psdu_path.empty()) {
logger->error("usage: precoder --psdu <shaped.bin> [--count N] "
"[--interval-ms MS]");
return 2;
}
std::vector<uint8_t> psdu;
if (!read_file(psdu_path, psdu) || psdu.empty()) {
logger->error("cannot read PSDU file (or it is empty): {}", psdu_path);
return 2;
}
logger->info("loaded {} PSDU bytes from {}", psdu.size(), psdu_path);
libusb_context *context = nullptr;
libusb_device_handle *handle = nullptr;
int rc;
/* Owns the teardown order (device -> interface -> handle -> context; see
* DeviceSession.h). 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));
logger->info("DEVOURER_VID={:04x} (overriding default VID)", target_vid);
}
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;
const int wifi_iface = devourer::find_wifi_interface(handle);
rc = devourer::claim_interface_then_reset(handle, wifi_iface, 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, wifi_iface);
return 1;
}
session.adopt_handle(handle, wifi_iface);
session.adopt_lock(usb_lock);
WiFiDriver wifi_driver{logger};
auto owned_device = wifi_driver.CreateRadio(handle, nullptr, usb_lock,
devourer_config_from_env());
/* 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();
// 2.4 GHz channel 6 is the plan's matrix-validated cell for these chips.
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: flat TXAGC index. Unset = the family's calibrated
* default (SetTxPower is now a real flat override on every generation). */
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});
sleep(2);
// [ radiotap (13) | 802.11 probe-req hdr (24) | shaped PSDU ]. The MAC header
// is 24 bytes; with the PHY's 16-bit SERVICE prefix that is 16 + 24*8 = 208
// scrambled-stream bits before the body. For *exact* per-subcarrier control
// the encoder must be run with --offset 208 and the matching entry_state
// (legacy N_DBPS=24 -> the body starts mid-symbol, so the first fully
// controllable symbol is the next 24-bit boundary; see
// tools/precoder/README.md). For a byte-level round-trip (Phase A) the offset
// is irrelevant — the shaped bytes come back verbatim.
auto dot11 = build_dot11_probe_req();
std::vector<uint8_t> tx_buf;
tx_buf.reserve(sizeof(kRadiotapLegacy6M) + dot11.size() + psdu.size());
tx_buf.insert(tx_buf.end(), kRadiotapLegacy6M,
kRadiotapLegacy6M + sizeof(kRadiotapLegacy6M));
tx_buf.insert(tx_buf.end(), dot11.begin(), dot11.end());
tx_buf.insert(tx_buf.end(), psdu.begin(), psdu.end());
logger->info("TX frame (legacy 6M OFDM): {} radiotap + {} hdr + {} PSDU = {} "
"bytes total", sizeof(kRadiotapLegacy6M), dot11.size(),
psdu.size(), tx_buf.size());
long tx_count = 0;
while (count < 0 || tx_count < count) {
bool ok = rtlDevice->send_packet(tx_buf.data(), tx_buf.size());
++tx_count;
/* precoder's TX progress marker (consumed by tests/precoder_*.py). */
if (tx_count <= 10 || tx_count % 500 == 0) {
devourer::Ev(logger->events(), "tx.frame")
.f("n", tx_count)
.f("ok", ok);
}
std::this_thread::sleep_for(std::chrono::milliseconds(interval_ms));
}
/* 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;
}
@@ -0,0 +1,413 @@
/* Staged RTL8731BU / RTL8733BU USB bring-up probe.
*
* id USB/register-plane identity only
* off vendor card-disable/RF-off from the current state
* power + vendor card-enable and system-clock setup
* efuse + physical OTP read and logical-map decode
* fw + reserved-page/DDMA firmware download and WCPU ready handshake
* mac + normal-mode HALMAC queues, WMAC, and USB RX-DMA setup
* phy + pinned MAC/BB/AGC/RFK-init/RF parameter images and readback
* chan + legal 20/40 MHz or experimental 5/10 MHz tune and readback
* tssi + program factory TSSI DE offsets with closed-loop tracking off
* tssi-bb + digital TSSI common/DCK/slope state, then factory DE, still off
* tssi-thermal + OFDM/HT thermal table and baseline, tracking still off
* tssi-analog + self-reverting ANAPAR/RF tracking setup audit, still off
* tssi-enable + capped, no-packet closed-loop enable/disable audit
*
* This remains diagnostic support rather than a second driver path. The normal
* WiFiDriver factory uses the same production bring-up components; PHY/RF,
* calibration, and frame-plane work are still staged separately. */
#if __has_include(<libusb.h>)
#include <libusb.h>
#else
#include <libusb-1.0/libusb.h>
#endif
#include <cstdio>
#include <cstdlib>
#include <memory>
#include <stdexcept>
#include <string>
#include "DeviceSession.h"
#include "Event.h"
#include "RtlAdapter.h"
#include "ThermalStatus.h"
#include "UsbOpen.h"
#include "logger.h"
#include "rtl8733b/Halmac8733bMac.h"
#include "rtl8733b/Phy8733b.h"
#include "rtl8733b/Rtl8733bBringup.h"
#include "rtl8733b/Rtl8733bUsbIds.h"
namespace {
class PowerDownGuard {
public:
explicit PowerDownGuard(rtl8733b::Rtl8733bBringup &dev) : _dev(dev) {}
~PowerDownGuard() {
if (!_armed)
return;
try {
_dev.power_off();
} catch (...) {
// Never replace the diagnostic stage's result while unwinding.
}
}
void arm() { _armed = true; }
private:
rtl8733b::Rtl8733bBringup &_dev;
bool _armed = false;
};
void log_snapshot(const Logger_t &logger, rtl8733b::Rtl8733bBringup &dev,
const char *phase) {
const rtl8733b::RegisterSnapshot state = dev.read_register_snapshot();
logger->info(
"state {}: SYS_FUNC_EN=0x{:04x} RF_CTRL=0x{:02x} CR=0x{:02x} "
"MCUFW_CTRL=0x{:04x} SYS_STATUS1=0x{:08x} EXT_SYS_FUNC_EN=0x{:08x}",
phase, state.sys_func_en, state.rf_ctrl, state.cr, state.mcufw_ctrl,
state.sys_status1, state.ext_sys_func_en);
devourer::Ev(logger->events(), "rtl8733b.state")
.f("phase", phase)
.hexf("sys_func_en", state.sys_func_en, 4)
.hexf("rf_ctrl", state.rf_ctrl, 2)
.hexf("cr", state.cr, 2)
.hexf("mcufw", state.mcufw_ctrl, 4)
.hexf("sys_status1", state.sys_status1, 8)
.hexf("ext_sys_func_en", state.ext_sys_func_en, 8);
}
const char *tx_power_mode_name(rtl8733b::TxPowerPgMode8733b mode) {
switch (mode) {
case rtl8733b::TxPowerPgMode8733b::DirectIndex:
return "direct";
case rtl8733b::TxPowerPgMode8733b::TssiOffset:
return "tssi-offset";
case rtl8733b::TxPowerPgMode8733b::Unknown:
return "unknown";
}
return "unknown";
}
bool hex16(const char *text, uint16_t &out) {
char *end = nullptr;
const unsigned long value = std::strtoul(text, &end, 0);
if (end == text || *end != '\0' || value > 0xffff)
return false;
out = static_cast<uint16_t>(value);
return true;
}
libusb_device_handle *open_matching(libusb_context *ctx, uint16_t vid,
uint16_t pid, int bus, int address) {
libusb_device **list = nullptr;
const ssize_t count = libusb_get_device_list(ctx, &list);
libusb_device_handle *handle = nullptr;
for (ssize_t i = 0; i < count && handle == nullptr; ++i) {
libusb_device_descriptor desc{};
if (libusb_get_device_descriptor(list[i], &desc) != 0 ||
desc.idVendor != vid || desc.idProduct != pid)
continue;
if (bus >= 0 && libusb_get_bus_number(list[i]) != bus)
continue;
if (address >= 0 && libusb_get_device_address(list[i]) != address)
continue;
if (libusb_open(list[i], &handle) != 0)
handle = nullptr;
}
if (list != nullptr)
libusb_free_device_list(list, 1);
return handle;
}
} // namespace
int main(int argc, char **argv) {
std::string stage = "id";
uint16_t want_vid = 0, want_pid = 0;
uint16_t parsed_bus = 0, parsed_address = 0;
int want_bus = -1, want_address = -1;
uint16_t channel = 6, width = 20, offset = 0;
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
if (arg == "id" || arg == "off" || arg == "power" ||
arg == "efuse" || arg == "fw" || arg == "mac" || arg == "phy" ||
arg == "chan" || arg == "tssi" || arg == "tssi-bb" ||
arg == "tssi-thermal" || arg == "tssi-analog" ||
arg == "tssi-enable")
stage = arg;
else if (arg == "--vid" && i + 1 < argc && hex16(argv[++i], want_vid))
;
else if (arg == "--pid" && i + 1 < argc && hex16(argv[++i], want_pid))
;
else if (arg == "--bus" && i + 1 < argc &&
hex16(argv[++i], parsed_bus) && parsed_bus <= 255)
want_bus = parsed_bus;
else if (arg == "--address" && i + 1 < argc &&
hex16(argv[++i], parsed_address) && parsed_address <= 255)
want_address = parsed_address;
else if (arg == "--channel" && i + 1 < argc &&
hex16(argv[++i], channel) && channel <= 255)
;
else if (arg == "--width" && i + 1 < argc &&
hex16(argv[++i], width) &&
(width == 5 || width == 10 || width == 20 || width == 40))
;
else if (arg == "--offset" && i + 1 < argc &&
hex16(argv[++i], offset) && offset <= 2)
;
else {
std::fprintf(stderr,
"usage: %s [id|off|power|efuse|fw|mac|phy|chan|tssi|"
"tssi-bb|tssi-thermal|tssi-analog|tssi-enable] "
"[--vid N --pid N] [--bus N] [--address N] "
"[--channel N --width 5|10|20|40 --offset 0|1|2]\n",
argv[0]);
return 2;
}
}
if ((want_vid == 0) != (want_pid == 0)) {
std::fprintf(stderr, "--vid and --pid must be supplied together\n");
return 2;
}
const int wanted = stage == "off" ? -1
: stage == "tssi-enable" ? 11
: stage == "tssi-analog" ? 10
: stage == "tssi-thermal" ? 9
: stage == "tssi-bb" ? 8
: stage == "tssi" ? 7
: stage == "chan" ? 6
: stage == "phy" ? 5
: stage == "mac" ? 4
: stage == "fw" ? 3
: stage == "efuse" ? 2
: stage == "power" ? 1
: 0;
auto logger = std::make_shared<Logger>();
devourer::DeviceSession session(logger);
libusb_context *ctx = nullptr;
if (libusb_init(&ctx) < 0)
return 1;
session.adopt_context(ctx);
libusb_device_handle *handle = nullptr;
uint16_t vid = want_vid, pid = want_pid;
if (want_vid) {
handle = open_matching(ctx, want_vid, want_pid, want_bus, want_address);
} else {
for (const rtl8733b::UsbId &id : rtl8733b::kUsbIds) {
handle = open_matching(ctx, id.vid, id.pid, want_bus, want_address);
if (handle) {
vid = id.vid;
pid = id.pid;
break;
}
}
}
if (!handle) {
logger->error(
"no matching RTL8733B USB adapter found (expected 0bda:f72b or "
"0bda:b733; bus={} address={})",
want_bus, want_address);
return 1;
}
libusb_device *opened = libusb_get_device(handle);
const uint8_t bus = libusb_get_bus_number(opened);
const uint8_t address = libusb_get_device_address(opened);
std::shared_ptr<devourer::UsbDeviceLock> lock;
if (devourer::claim_interface_then_reset(handle, 0, logger, true, lock) !=
0) {
session.adopt_handle(handle, 0);
return 1;
}
session.adopt_handle(handle, 0);
session.adopt_lock(lock);
RtlAdapter adapter(handle, logger, ctx, lock);
rtl8733b::Rtl8733bBringup dev(adapter, logger);
PowerDownGuard power_down(dev);
try {
const rtl8733b::ChipInfo info = dev.read_chip_info();
const bool id_ok = info.matches();
logger->info(
"id: {:04x}:{:04x} bus={} address={} die=0x{:02x} cut={} "
"SYS_CFG1=0x{:08x} "
"secure=0x{:02x} CR=0x{:02x} SYS_STATUS1=0x{:08x} "
"MCUFW_CTRL=0x{:04x} autoload={}",
vid, pid, bus, address, info.die_id, info.cut, info.sys_cfg1,
info.secure_ctrl, info.cr, info.sys_status1, info.mcufw_ctrl,
info.efuse_autoload_ok);
devourer::Ev(logger->events(), "rtl8733b.id")
.f("ok", id_ok).hexf("vid", vid, 4).hexf("pid", pid, 4)
.f("bus", bus).f("address", address)
.hexf("die_id", info.die_id, 2).f("cut", info.cut)
.hexf("sys_cfg1", info.sys_cfg1, 8)
.hexf("secure_ctrl", info.secure_ctrl, 2).hexf("cr", info.cr, 2)
.hexf("sys_status1", info.sys_status1, 8)
.hexf("mcufw", info.mcufw_ctrl, 4);
log_snapshot(logger, dev, "identity");
if (id_ok && wanted < 0) {
const bool off_ok = dev.power_off();
log_snapshot(logger, dev, "power-off");
return off_ok ? 0 : 1;
}
if (!id_ok || wanted < 1)
return id_ok ? 0 : 1;
const bool power_ok = dev.power_on();
power_down.arm();
const uint8_t cr = adapter.rtw_read8(0x0100);
logger->info("power: ok={} CR=0x{:02x}", power_ok, cr);
devourer::Ev(logger->events(), "rtl8733b.power")
.f("ok", power_ok).hexf("cr", cr, 2);
log_snapshot(logger, dev, "power");
if (!power_ok || wanted < 2)
return power_ok ? 0 : 1;
rtl8733b::Halmac8733bMac mac(adapter, logger);
rtl8733b::EfuseInfo efuse;
const bool efuse_ok = mac.read_efuse(efuse);
devourer::Ev(logger->events(), "rtl8733b.efuse")
.f("ok", efuse_ok)
.f("used_bytes", efuse.used_bytes)
.hexf("id", efuse.id, 4)
.hexf("vid", efuse.vid, 4)
.hexf("pid", efuse.pid, 4)
.hexf("rfe_type", efuse.rfe_type, 2)
.hexf("xtal", efuse.xtal, 2)
.hexf("thermal", efuse.thermal, 2)
.hexf("trx_path", efuse.trx_path, 2)
.hexf("tx_power_calibrate", efuse.tx_power_calibrate, 2)
.f("tx_power_selector", efuse.tx_power_tracking_mode)
.f("tx_power_mode", tx_power_mode_name(efuse.tx_power_mode))
.f("tssi_path_a_programmed", efuse.tssi_power.path_a_programmed)
.f("tssi_path_b_programmed", efuse.tssi_power.path_b_programmed)
.f("tssi_trim_programmed", efuse.tssi_power.trim_programmed)
.f("tssi_ch6_cck_a", efuse.tssi_power.path_a_de[2])
.f("tssi_ch6_cck_b", efuse.tssi_power.path_b_de[2])
.f("tssi_ch6_ofdm_a", efuse.tssi_power.path_a_de[8])
.f("tssi_ch6_ofdm_b", efuse.tssi_power.path_b_de[8])
.f("tssi_ch6_trim_a", efuse.tssi_power.trim[0][0])
.f("tssi_ch6_trim_b", efuse.tssi_power.trim[0][1]);
if (!efuse_ok || wanted < 3)
return efuse_ok ? 0 : 1;
const bool fw_ok = dev.download_default_firmware(info.cut);
const uint16_t mcufw = adapter.rtw_read16(0x0080);
logger->info("fw: ok={} checksum={} ready={} MCUFW_CTRL=0x{:04x}", fw_ok,
dev.checksum_ok(), dev.ready_ok(), mcufw);
devourer::Ev(logger->events(), "rtl8733b.fw")
.f("ok", fw_ok).f("checksum", dev.checksum_ok())
.f("ready", dev.ready_ok()).hexf("mcufw", mcufw, 4);
log_snapshot(logger, dev, "firmware");
if (!fw_ok || wanted < 4)
return fw_ok ? 0 : 1;
bool mac_ok = mac.initialize(efuse);
bool stopped = false;
bool reinit_ok = false;
uint16_t stopped_cr = 0xffff;
if (mac_ok) {
mac.stop();
const rtl8733b::MacState stopped_state = mac.read_mac_state();
stopped_cr = adapter.rtw_read16(0x0100);
stopped = stopped_cr == 0 &&
(stopped_state.pq_map & (1u << 2)) == 0;
reinit_ok = stopped && mac.initialize(efuse);
mac_ok = mac_ok && stopped && reinit_ok;
devourer::Ev(logger->events(), "rtl8733b.mac_lifecycle")
.f("stopped", stopped)
.hexf("stopped_cr", stopped_cr, 4)
.f("reinit", reinit_ok);
}
const rtl8733b::MacState mac_state = mac.read_mac_state();
devourer::Ev(logger->events(), "rtl8733b.mac")
.f("ok", mac_ok)
.hexf("pq_map", mac_state.pq_map, 4)
.hexf("rqpn_hlpq", mac_state.rqpn_hlpq, 8)
.hexf("rqpn_npq", mac_state.rqpn_npq, 8)
.hexf("boundary", mac_state.reserved_boundary, 2)
.hexf("rx_boundary", mac_state.rx_boundary, 4)
.hexf("cr", mac_state.cr, 2)
.hexf("rxdma_mode", mac_state.rxdma_mode, 2)
.hexf("rx_agg", mac_state.rx_agg, 4)
.hexf("rcr", mac_state.rcr, 8);
if (!mac_ok || wanted < 5)
return mac_ok ? 0 : 1;
rtl8733b::Phy8733b phy(adapter, logger);
const bool phy_ok = phy.initialize(info.cut, efuse);
devourer::ThermalStatus thermal;
if (phy_ok) {
thermal.raw = phy.read_thermal();
thermal.baseline = efuse.thermal;
thermal.valid = thermal.raw != 0 && thermal.baseline != 0xff;
thermal.delta = thermal.valid
? static_cast<int>(thermal.raw) -
static_cast<int>(thermal.baseline)
: 0;
const char *bucket = devourer::ThermalBucket(thermal);
logger->info("RTL8733B thermal: raw={} baseline={} delta={} status={}",
thermal.raw, thermal.baseline, thermal.delta, bucket);
devourer::Ev(logger->events(), "thermal")
.f("raw", thermal.raw)
.f("baseline", thermal.valid ? static_cast<int>(thermal.baseline)
: -1)
.f("delta", thermal.delta)
.f("status", bucket);
/* Reported, not enforced. The meter is a PA-bias tracking index, not a
* calibrated junction temperature, and it is not a validated degradation
* predictor (docs/warm-tx-degradation.md) — so it grades the emitted
* `thermal` event (ThermalBucket labels this band "critical") and the
* abort decision stays with whoever is driving the probe. */
if (thermal.valid && thermal.delta >= 25)
logger->warn("RTL8733B thermal: delta {} is in the 'critical' bucket — "
"telemetry only, continuing",
thermal.delta);
}
if (!phy_ok || wanted < 6)
return phy_ok ? 0 : 1;
const ChannelWidth_t selected_width =
width == 5 ? CHANNEL_WIDTH_5
: width == 10 ? CHANNEL_WIDTH_10
: width == 40 ? CHANNEL_WIDTH_40
: CHANNEL_WIDTH_20;
const SelectedChannel selected{static_cast<uint8_t>(channel),
static_cast<uint8_t>(offset),
selected_width};
const bool channel_ok = phy.set_channel(selected);
if (!channel_ok || wanted < 7)
return channel_ok ? 0 : 1;
const bool bb_ok = wanted == 7 || phy.prepare_tssi_bb(selected, efuse);
const bool thermal_ok =
bb_ok && (wanted < 9 || phy.prepare_tssi_thermal(efuse, false));
const bool de_ok =
thermal_ok && phy.prepare_tssi_offsets(selected, efuse);
const bool analog_ok =
de_ok && (wanted < 10 || phy.audit_tssi_analog(selected, efuse));
const bool enable_ok =
analog_ok &&
(wanted < 11 || phy.audit_tssi_enable(selected, efuse, 64));
const uint8_t final_thermal = phy.read_thermal();
const int final_delta =
final_thermal != 0 && efuse.thermal != 0xff
? static_cast<int>(final_thermal) - static_cast<int>(efuse.thermal)
: 0;
logger->info("RTL8733B TSSI thermal: final={} baseline={} delta={}",
final_thermal, efuse.thermal, final_delta);
devourer::Ev(logger->events(), "rtl8733b.tssi_thermal")
.f("raw", final_thermal)
.f("baseline", efuse.thermal)
.f("delta", final_delta);
if (final_thermal == 0 || final_delta >= 25) {
logger->error("RTL8733B TSSI thermal: unsafe or unreadable final sample");
return 1;
}
return enable_ok ? 0 : 1;
} catch (const std::exception &e) {
logger->error("RTL8733B {} stage threw: {}", stage, e.what());
return 1;
}
}
File diff suppressed because it is too large Load Diff
+325
View File
@@ -0,0 +1,325 @@
# sense — Wi-Fi motion sensing you can run on your own dongles
A runnable example built on the `devourer` library that turns two cheap Realtek
Wi-Fi adapters into a **motion / presence sensor**. No SDR, no special AP — one
adapter sounds, the other answers, and the demo reads human motion out of the
802.11ac beamforming feedback the second adapter returns.
This document is written so you can **reproduce it, move it into your own
environment, and change the maths**. If you only want the theory of *why* a
beamforming report senses motion, read
[`docs/beamforming-victim-sensing.md`](../../docs/beamforming-victim-sensing.md);
this file is the hands-on half.
---
## 1. The one-paragraph idea
An 802.11ac beamformer (the *sounder*) asks a *beamformee* to measure the
per-subcarrier channel between the sounder's two transmit antennas and report it
back, compressed as **Givens rotation angles** (a phase `phi` and an amplitude
angle `psi` per subcarrier). That report is a measurement *taken at the
beamformee* of the radio channel between the two devices. When a person moves in
that channel, the multipath changes, so the reported angles **jitter frame to
frame**. Measure that jitter and you have a motion detector. This is the
mechanism behind published work such as Wi-BFI, BeamSense and BFMSense.
The demo drives *both* ends itself — a sounder and a beamformee on the same host
— so you don't need a cooperating AP. The sounder injects a short NDPA frame; the
chip hardware-generates the sounding NDP; the beamformee answers with a
compressed beamforming report; the sounder self-captures it on a concurrent RX
loop. All of that is the `devourer` beamforming self-sounding path (see
[`docs/beamforming-self-sounding.md`](../../docs/beamforming-self-sounding.md)).
---
## 2. Hardware you need
**Two USB adapters** that `devourer` supports, plugged into the same host:
| Role | Requirement | Good choice |
|------|-------------|-------------|
| **Sounder** | Can transmit + self-capture (TX-with-RX). Any supported generation works; Jaguar3 (8822CU `0bda:c812`, 8822EU `0bda:a81a`) is the best-tested. | RTL8822CU |
| **Beamformee** | Must answer a VHT sounding with a compressed report. **Two receive antennas (2T2R) give a far cleaner signal** than a 1T1R part. | RTL8822BU / RTL8822CU (2T2R) |
A 1T1R beamformee (e.g. an 8811AU) *works* but produces a much noisier, weaker
signal — its "relative channel between two antennas" is degenerate. If your
readout is jumpy, suspect the beamformee first.
**Placement matters more than anything else** — see §6.
The demo is built on macOS/Linux (libusb). It does not need root on macOS; on
Linux you may need to run as root or add a udev rule so libusb can claim the
interface.
---
## 3. Build
From the repo root:
```sh
cmake -S . -B build
cmake --build build -j --target sense
```
The decoder has a headless self-test that runs under `ctest` (no radio needed):
```sh
ctest --test-dir build -R bf_report_decode
```
---
## 4. Run it
```sh
./build/sense --channel 6 \
--sounder 0x0bda:0xc812 \
--beamformee 0x0bda:0xb82c
```
Both selectors take `[VID:]PID` (VID defaults to `0x0bda`). Find your adapters'
IDs with `lsusb` (Linux) or `system_profiler SPUSBDataType` / `ioreg -p IOUSB`
(macOS).
Startup takes a few seconds: bring up both radios → calibrate the decoder split
(~48 reports) → acquire the noise floor (~2.5 s). Then you get a live line:
```
[ CLEAR ] 0.4σ | | now 0.0003 base 0.0003 1310/s
[ MOTION ] 9.2σ |####################| now 0.0016 base 0.0003 1298/s
```
Wave your hand near the adapters and the `σ` reading climbs; the verdict flips to
`MOTION` and holds for ~1 s after you stop. `Ctrl-C` to quit.
### Reading the line
| Field | Meaning |
|-------|---------|
| `CLEAR` / `MOTION` | verdict. `MOTION·nb` = the rise is concentrated on a few tones (looks like a narrowband interferer, not broadband motion). |
| `σ` | how many noise-floor standard deviations the current energy sits above the self-calibrated floor. The detector fires at `σ ≈ k` (see §7). |
| bar | `σ` as a bar, saturating at 10σ. |
| `now` | current motion energy (mean per-tone circular variance of `phi`). |
| `base` | the self-calibrated still-floor it is measured against. |
| `N/s` | beamforming reports captured per second (health indicator; a fast Jaguar3 sounder gives ~1000–1500/s). |
---
## 5. All the knobs
**Command-line flags**
| Flag | Default | Purpose |
|------|---------|---------|
| `--channel N` | `6` | Wi-Fi channel to sound on. Try 5 GHz (36, 149) for a different multipath environment. |
| `--sounder [VID:]PID` | `0bda:8812` | sounder adapter selector |
| `--beamformee [VID:]PID` | `0bda:c812` | beamformee adapter selector |
| `--vid 0xNNNN` | `0x0bda` | default VID for both selectors (for OEM-rebadged dongles) |
| `--sensitivity low\|med\|high` | `med` | detector threshold `k` = 6 / 4 / 2.5 sigmas |
| `-v`, `--verbose` | off | show the library's bring-up logs (otherwise quieted to warnings) |
**Environment variables**
| Var | Purpose |
|-----|---------|
| `DEVOURER_SENSE_K=<sigmas>` | override the CFAR threshold `k` directly (finer than `--sensitivity`). |
| `DEVOURER_SENSE_DUMP=1` | emit every captured report as a `bf.report_raw` event on **stderr** — the input for offline analysis (§8). |
| `DEVOURER_SENSE_DEBUG=1` | print the first few captured reports' geometry (SA, Nc/Nr, MU, Ns) for a sanity check. |
---
## 6. Environment — the single biggest lever
The signal strength depends almost entirely on **how much a moving person changes
the channel between the two adapters, relative to the static part of that
channel.**
- **Separate the two adapters.** Two dongles side by side on the same hub share a
strong, short, line-of-sight channel that a hand barely perturbs. Put one on a
**USB extension a metre or more away**, ideally across the space you want to
sense. This can turn a marginal signal into an obvious one.
- **Put the person in the path.** Motion *between* or *near* the two antennas
moves the needle most.
- **Multipath helps.** A reflective room (walls, furniture) gives the channel more
structure to perturb than an anechoic free-space shot.
- **Channel width / band.** 20 MHz on 2.4 GHz is the default. 5 GHz and wider
channels change the coherence bandwidth and the per-tone structure; worth
experimenting.
Reference numbers from one indoor 20 MHz / channel-6 setup (2T2R↔1T1R, adapters
~30 cm apart):
| condition | motion energy (mean per-tone circular variance of `phi`) |
|-----------|----------------------------------------------------------|
| still | 0.0003 (floor), window-to-window jitter ~0.00002 |
| hand wave next to a dongle | 0.0006 – 0.002 (2–6× the floor) |
Your absolute numbers **will differ** — that is exactly why the detector
self-calibrates rather than using a fixed threshold. Use these only as a rough
scale.
---
## 7. How it works inside (so you can change the maths)
Everything below lives in two files:
- **`src/BfReportDecode.h`** — the report decoder + the `MotionMeter` metric.
- **`examples/sense/main.cpp`** — the `AdaptiveDetector` + display.
### 7a. Decoding the report → angles
`parse_report()` matches a VHT/HT compressed beamforming report and locates the
packed angle bits. `decode_angles()` unpacks, per subcarrier, one `phi` (phase)
and one `psi` (amplitude) angle, LSB-first, and dequantises them:
```
phi = (2q + 1) · π / 2^b_phi # dequant_phi
psi = (2q + 1) · π / 2^(b_psi + 2) # dequant_psi
```
**The bit split `(b_phi, b_psi)` matters and is easy to get wrong.** The 802.11
compressed-Givens codebook always pairs `b_phi = b_psi + 2`. For the common
10-bits-per-subcarrier 2×1 report that uniquely means **`(6, 4)`**. `pick_split()`
enforces that relationship — do **not** replace it with a naive "minimise
cross-frame variance" search: a too-coarse `psi` (e.g. 2 bits) is trivially
constant, so an unconstrained search picks a bit-*misaligned* split whose `phi`
decodes to garbage that jitters on a static channel and reads as constant motion.
This was a real bug; the self-test now pins the split to `(6,4)`.
### 7b. The motion metric (`MotionMeter`)
For each report we keep the first `phi` of every subcarrier in a sliding window
(`kWindow = 512` reports, ~0.3–0.5 s). The per-tone motion signal is the
**circular variance** of that phase over the window:
```
var[k] = 1 − |mean_over_window( e^{i·phi_k} )| # in [0, 1]
motion_energy = mean_k var[k]
```
Circular variance is 0 when a tone's phase is constant (static channel) and rises
toward 1 as it scatters (a changing channel). It is CFO-robust in the sense that
a *constant* phase offset cancels; what survives is frame-to-frame change.
Why `phi` (phase) and not `psi` (amplitude)? Measured: a hand wave moves the
phase but barely moves the coarse 4-bit amplitude ratio, so `phi` is the sensitive
signal. `psi` is decoded too, and the "broadband vs localized" flag
(`MotionMeter::localized()`) uses the per-tone shape to distinguish human motion
from a narrowband interferer.
**Things to try here:** a different aggregation (median/percentile instead of
mean over tones), a frame-to-frame `|Δphi|` "velocity" metric, a shorter window
for faster response, or weighting tones by their SNR.
### 7c. The detector (`AdaptiveDetector`)
A fixed threshold is wrong because the still-floor varies with hardware, channel
and geometry. Instead the detector self-calibrates (a CFAR-style test):
- Track the **floor** (mean still energy) and its **jitter** `dev` with a
rate-independent EMA (`kTauFloor = 4 s`; faster `kWarmTau = 0.4 s` during a
`kWarmSec = 2.5 s` warm-up). The floor **freezes while motion is held**, so a
moving subject can't drag it up and blind the detector.
- Threshold: `floor + k · max(dev, kDevFloor)`. `k` is the sensitivity
(`--sensitivity`/`DEVOURER_SENSE_K`); `kDevFloor` is a minimum jitter so the
threshold can't collapse onto a perfectly-still floor.
- **Hysteresis:** arm only after the energy stays over threshold for
`kArmSec = 0.15 s` (a lone spike can't trigger), then **hold** `kHoldSec = 1.2 s`
after it drops (no flicker; bridges the gaps in an intermittent wave).
- `σ` shown in the UI is `(energy − floor) / max(dev, kDevFloor)`.
The tunable constants are all `static constexpr` at the top of
`AdaptiveDetector` in `examples/sense/main.cpp`:
```
kWindow = 512 # metric window (reports) [in main.cpp top-level]
kWarmSec = 2.5 s # floor acquisition
kWarmTau = 0.4 s # fast tracking during warm-up
kTauFloor = 4.0 s # floor/jitter tracking constant
kArmSec = 0.15 s # dwell over threshold before MOTION
kHoldSec = 1.2 s # hold MOTION after last trigger
kDevSeed = 0.00005 # initial jitter estimate
kDevFloor = 0.00004 # minimum jitter → minimum sensitivity margin
```
If your still-floor sits at a different scale than the reference in §6, the two
numbers most likely to need adjusting are **`kDevFloor`** (raise it if you get
false alarms at rest, lower it if real motion never crosses the threshold) and
**`k`** (via `DEVOURER_SENSE_K`, no rebuild needed).
---
## 8. Experiment with your own formulas (capture → analyse loop)
You do not have to edit C++ to try new maths. Capture the raw reports and analyse
them offline:
```sh
# capture ~30 s of reports to a file (stderr carries the raw dump)
DEVOURER_SENSE_DUMP=1 ./build/sense --channel 6 \
--sounder 0x0bda:0xc812 --beamformee 0x0bda:0xb82c \
2> capture.txt
# decode + inspect with the reference Python tool
grep -F '"ev":"bf.report_raw"' capture.txt | tools/bf_report_decode.py
```
`tools/bf_report_decode.py` prints the header (Nc/Nr/BW/Ng), the chosen split,
per-stream SNR and the per-tone `|h_B/h_A|`. From the same `capture.txt` you can
compute anything you like in a few lines of Python — per-tone variance, a
different window, a spectrogram of `phi[k]` over time, a doppler estimate — and
label a run by doing a clean **still segment then a moving segment** and comparing
the two. That is exactly how the metric, window and thresholds in this demo were
chosen.
> **A note on the reference tool:** `tools/bf_report_decode.py` currently selects
> the split by cross-frame stability and can land on the degenerate `(8,2)` for a
> 10-bit report. When comparing against the C++ path, force the correct Givens
> split `(6,4)` (see §7a).
---
## 9. Limitations and honest caveats
- **Weak coupling on close adapters.** Side-by-side dongles barely see motion. §6
is not optional advice — it is the difference between working and not.
- **Coarse amplitude.** The Realtek compact codebook gives `psi` only ~4 bits, so
amplitude-based sensing is limited; this demo leans on phase.
- **Second-adapter flakiness.** Some cheap beamformees' firmware crashes on long
runs and the adapter drops off the USB bus. The demo has a stall watchdog (§10);
if it fires, replug that adapter.
- **A single-radio (`--mode self`) variant** — the sounder acting as its own
beamformee — is plausible and would remove the flaky second adapter, but is not
implemented here.
- **No passive/AP-sniffing mode.** Sensing an existing AP↔client sounding exchange
(the Wi-BFI approach) is a natural extension but is deliberately **not** shipped:
it needs an AP actively sounding VHT beamforming, which we could not validate
against. Contributions welcome.
---
## 10. Troubleshooting
| Symptom | Cause / fix |
|---------|-------------|
| `could not open VVVV:PPPP` | Adapter not found. Check `lsusb`. A prior hang can drop an adapter off the bus — **unplug/replug it**. |
| Stuck on `calibrating decoder… 0 reports (0/s)` | The beamformee isn't answering: wrong PID, it doesn't support VHT sounding, or it fell off the bus. Try `-v` to see bring-up, and confirm both adapters enumerate. |
| `report stream stalled Ns — beamformee stopped responding` | The beamformee firmware crashed. The demo stops cleanly; replug that adapter before re-running. |
| Constant `MOTION` at rest | Threshold too low for your floor: raise `DEVOURER_SENSE_K`, or `--sensitivity low`. If `base` reads `0.000` and never rises, that's the old split bug — make sure you're on a build with the `(6,4)` `pick_split`. |
| Never triggers even on a strong wave | Threshold too high, or coupling too weak. `--sensitivity high`, and **separate the adapters** (§6). |
| Needs root on Linux | libusb can't claim the interface. Run as root or add a udev rule for the adapter's VID:PID. |
---
## 11. Files
| File | What |
|------|------|
| `examples/sense/main.cpp` | the `sense` binary: adapter bring-up, sounding loop, `AdaptiveDetector`, display, watchdog. |
| `src/BfReportDecode.h` | header-only report decoder + `MotionMeter` (reusable; no libusb dependency). |
| `examples/sense/bf_report_decode_selftest.cpp` | headless `ctest` that guards the decoder against a real captured report. |
| `docs/beamforming-victim-sensing.md` | the theory: why a beamforming report measures the channel and senses motion. |
| `tools/bf_report_decode.py` | reference Python decoder for offline analysis. |
@@ -0,0 +1,157 @@
/* Headless self-test for src/BfReportDecode.h — guards the C++ port of
* tools/bf_report_decode.py against regressions. Registered as a ctest, so a
* decode break fails CI instead of only surfacing on a radio.
*
* Checks: the LSB-first BitReader and the dequant formulas on known inputs, the
* report header parse + fixed-split angle decode against a real captured MU
* report (reference psi values computed offline with a fixed (8,2) split), and
* that the split picker returns a valid split. */
#include "BfReportDecode.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
using namespace devourer::bf;
static int failures = 0;
#define CHECK(cond, msg) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s\n", msg); \
++failures; \
} \
} while (0)
static bool approx(double a, double b, double tol = 1e-4) {
return std::fabs(a - b) < tol;
}
static std::vector<uint8_t> from_hex(const std::string &h) {
std::vector<uint8_t> out;
for (size_t i = 0; i + 1 < h.size(); i += 2)
out.push_back((uint8_t)std::strtoul(h.substr(i, 2).c_str(), nullptr, 16));
return out;
}
/* A real VHT MU Compressed Beamforming report captured from an 8822CU beamformee
* (20 MHz, Nr=2 Nc=1, MU). psi[0..5] below were computed offline from these bytes
* with a fixed (b_phi=8, b_psi=2) split. */
static const char *kReportHex =
"e000000056427505d60000e04c8822ce00000000000010001500088c04c2a98dad97b09fbe"
"addaadc7bfccbde1c9e5cfe8cdf3cdf1d1eacfe5cff0d5eed9f0d9e6e1ffd70cd820e017d2"
"25d61ef093f0b9daa1ec91e687dc83e093e058f417ecfbebf9ebe9e1f1db03dc08de0dda11"
"d814de0fd808d60dd219c815c605b811b01bac20b62ac40f01f00fef00100100ff0011111001cbbf1bd5";
int main() {
/* 1. BitReader — LSB-first. byte 0xB4 = 1011 0100b; reading 3 bits gives the
* low three bits in LSB order = 0b100 = 4; next 5 bits = 0b10110 = 22. */
{
uint8_t bytes[2] = {0xB4, 0x00};
BitReader br(bytes, 2);
CHECK(br.read(3) == 4u, "BitReader low 3 bits");
CHECK(br.read(5) == 22u, "BitReader next 5 bits");
}
/* 2. dequant — psi=(2q+1)pi/2^(b+2), phi=(2q+1)pi/2^b. */
CHECK(approx(dequant_psi(0, 2), M_PI / 16.0), "dequant_psi q0 b2");
CHECK(approx(dequant_psi(3, 2), 7.0 * M_PI / 16.0), "dequant_psi q3 b2");
CHECK(approx(dequant_phi(0, 8), M_PI / 256.0), "dequant_phi q0 b8");
/* 3. parse_report on the real MU report. */
std::vector<uint8_t> frame = from_hex(kReportHex);
ReportHdr hdr;
CHECK(parse_report(frame.data(), frame.size(), hdr), "parse_report matches");
CHECK(hdr.nc == 1 && hdr.nr == 2, "nc/nr");
CHECK(hdr.bw == 0 && hdr.ng == 1, "bw/ng");
CHECK(hdr.mu && hdr.vht, "mu/vht");
CHECK(hdr.ns == 52, "ns=52");
CHECK(hdr.per_sc_bits == 10, "per_sc_bits=10 (MU)");
CHECK(hdr.angle_len == 65, "angle_len=65 (52*10 bits)");
/* 3b. The same logical report delivered WITHOUT a trailing FCS - the
* MediaTek MT7612U shape, where the MAC strips it.
*
* With slack in the buffer the MU path clamps angle_len to vbytes, so the
* flag provably changes nothing there; that equivalence is the first check.
* To show the flag actually DOES something, the second half trims the frame
* to exactly (29 + nc) + vbytes - no slack at all - which is the shape a
* real FCS-less capture has. There, believing a non-existent FCS eats four
* angle bytes and the report must be REJECTED. A control that cannot fail is
* not a control. */
{
std::vector<uint8_t> nofcs(frame.begin(), frame.end() - 4);
ReportHdr h2;
CHECK(parse_report(nofcs.data(), nofcs.size(), h2, /*fcs_present=*/false),
"parse_report matches with no FCS");
CHECK(h2.nc == hdr.nc && h2.nr == hdr.nr && h2.bw == hdr.bw &&
h2.ng == hdr.ng && h2.mu == hdr.mu && h2.vht == hdr.vht &&
h2.ns == hdr.ns && h2.per_sc_bits == hdr.per_sc_bits,
"no-FCS header identical to FCS-present");
CHECK(h2.angle_len == hdr.angle_len, "no-FCS angle_len identical");
/* Exactly the angle block, nothing after it. */
/* CHECK only records a failure and returns, so this has to gate the
* slicing too - otherwise a shorter fixture walks the iterator past the
* end (UB) on exactly the path the check was meant to protect.
*
* The rejection below is MU-specific: it bites via the MU clamp
* (vbytes 65 > ab_len 61). An SU fixture would instead depend on
* (angle_len-4)*8 % ns, which is 0 for ns=16 - so this control would pass
* spuriously there. It requires an MU fixture, which this one is. */
const size_t tight = 29u + (size_t)hdr.nc + (size_t)hdr.angle_len;
CHECK(tight <= frame.size(), "fixture long enough to build the tight case");
if (tight <= frame.size()) {
std::vector<uint8_t> snug(frame.begin(), frame.begin() + (long)tight);
ReportHdr h4;
CHECK(parse_report(snug.data(), snug.size(), h4, /*fcs_present=*/false),
"tight FCS-less report still decodes");
CHECK(h4.angle_len == hdr.angle_len, "tight FCS-less angle_len intact");
ReportHdr h5;
CHECK(!parse_report(snug.data(), snug.size(), h5, /*fcs_present=*/true),
"assuming an FCS that is not there must reject, not silently shorten");
}
}
/* 4. fixed-split decode vs offline reference. */
std::vector<double> psi;
CHECK(decode_psi(hdr, 8, 2, psi), "decode_psi ok");
CHECK(psi.size() == 52, "52 psi values");
const double ref[6] = {0.589049, 1.374447, 0.589049,
0.981748, 0.981748, 1.374447};
for (int k = 0; k < 6; ++k)
CHECK(approx(psi[k], ref[k]), "psi matches reference");
bool in_range = true;
for (double p : psi)
if (p < 0.0 || p > M_PI / 2.0)
in_range = false;
CHECK(in_range, "all psi in (0, pi/2)");
/* 5. split picker returns the standard Givens split (b_phi = b_psi + 2),
* i.e. (6,4) for this 10-bit/tone 2x1 report — NOT the degenerate (8,2) that a
* naive min-variance search picks because a 2-bit psi is trivially constant. */
{
std::vector<ReportHdr> batch(8, hdr); /* same frame repeated is enough */
int bphi = 0, bpsi = 0;
CHECK(pick_split(batch, bphi, bpsi), "pick_split found a split");
CHECK(bphi + bpsi == hdr.per_sc_bits, "split sums to per_sc_bits");
CHECK(bphi == bpsi + 2, "split obeys Givens b_phi = b_psi + 2");
CHECK(bphi == 6 && bpsi == 4, "10-bit/tone 2x1 split is (6,4)");
}
/* 6. MotionMeter — identical reports => ~zero motion energy. */
{
MotionMeter m(52, 8, 2, 16);
for (int i = 0; i < 8; ++i)
m.push(hdr);
CHECK(m.motion_energy() < 1e-9, "static channel => zero motion energy");
}
if (failures == 0)
std::printf("bf_report_decode_selftest: all checks passed\n");
return failures == 0 ? 0 : 1;
}
+577
View File
@@ -0,0 +1,577 @@
/* sense — a runnable Wi-Fi motion/presence sensor built on devourer.
*
* An 802.11ac beamforming report is a measurement taken at the beamformee: its
* per-tone Givens angles track the channel, so a moving person perturbs them
* frame-to-frame. This demo captures reports, decodes them (src/BfReportDecode.h),
* and shows a live motion readout — the per-tone cross-frame variance of the
* phase angle. See docs/beamforming-victim-sensing.md.
*
* One binary drives TWO adapters: a sounder that injects NDPAs (the MAC
* hardware-generates the NDP) and self-captures the reports, and a beamformee
* that responds in hardware. Two dongles; the effect is stronger when they are
* physically separated (a static short channel barely moves).
*/
#ifdef _WIN32
#define NOMINMAX /* keep windows.h (via libusb.h) from defining min/max macros */
#endif
#if defined(__ANDROID__) || defined(_MSC_VER) || defined(__APPLE__)
#include <libusb.h>
#else
#include <libusb-1.0/libusb.h>
#endif
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "BfReportDecode.h"
#include "DeviceSession.h"
#include "RadiotapBuilder.h"
#include "RxPacket.h"
#include "SignalStop.h"
#include "UsbOpen.h"
#include "WiFiDriver.h"
#include "env_config.h"
#include "logger.h"
using devourer::bf::MotionMeter;
using devourer::bf::parse_report;
using devourer::bf::ReportHdr;
/* Portable environment set (the demo hands arming flags to the library via env):
* POSIX setenv, or _putenv_s on Windows (MSVC + MinGW have no POSIX setenv). */
static void set_env(const char *name, const char *value) {
#ifdef _WIN32
_putenv_s(name, value);
#else
::setenv(name, value, 1);
#endif
}
/* The sounder's TA and the address a beamformee arms to respond to (matches the
* canonical SA used across devourer's TX path). */
static const uint8_t kCanonicalSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00};
/* Event sink for the demo's JSONL emissions (Sensor::feed runs on the RX
* callback) — points at the main() Logger's sink, which sense routes to
* stderr so the live stdout display stays clean. */
static devourer::EventSink *g_ev = nullptr;
static constexpr uint16_t REG_MACID = 0x0610;
static constexpr int kCalReports = 48; /* reports to calibrate the bit split */
static constexpr size_t kWindow = 512; /* variance window (~0.3 s at a fast
* Jaguar3 sounding rate — long enough
* to span human motion, short enough
* to feel live) */
static constexpr int kStallSec = 8; /* no reports for this long => the
* beamformee stopped responding; stop
* rather than sound into the void */
/* -------------------------------------------------------------- Detector ---- */
/* Adaptive presence detector. Fed the MotionMeter's energy once per report, it
* self-calibrates a noise floor and fires when the energy rises a configurable
* number of standard deviations above it (a CFAR-style test), then holds the
* verdict for a short time so a moving-then-pausing subject doesn't flicker.
*
* Why not a fixed threshold: the still-state energy floor varies with hardware,
* channel and geometry, so a magic constant tuned on one rig is wrong on the
* next. The floor tracks the quiet state with a rate-independent time constant
* but FREEZES while motion is held — otherwise a subject who keeps moving would
* slowly raise the floor and blind the detector (the classic motion-sensor
* failure). During the initial warm-up the floor latches onto the quietest
* instant seen, so a subject already moving at startup can't set a high floor. */
class AdaptiveDetector {
public:
explicit AdaptiveDetector(double k) : _k(k) {}
/* Call only once the MotionMeter window is full, so `energy` is a real
* still-state estimate and not the window-fill transient (which is ~0 and
* would peg the floor at zero -> always-MOTION). */
void update(double energy, double t) {
_energy = energy;
if (!_init) {
_floor = energy;
_dev = kDevSeed;
_last = t;
_warm_until = t + kWarmSec;
_init = true;
return;
}
double dt = t - _last;
if (dt < 0)
dt = 0;
_last = t;
bool warming = t < _warm_until;
_warm = warming;
/* Track the floor (mean still energy) and its jitter with an EMA — fast
* during warm-up to converge, slow after. Freeze while motion is held so a
* moving subject can't drag the floor up and blind the detector. */
if (warming || !_active) {
double tau = warming ? kWarmTau : kTauFloor;
double a = 1.0 - std::exp(-dt / tau);
double resid = energy - _floor;
_floor += a * resid;
_dev += a * (std::fabs(resid) - _dev);
}
_thr = _floor + _k * std::max(_dev, kDevFloor);
if (warming)
return; /* no verdict until the floor is acquired */
/* Hysteresis: arm only after the energy stays over threshold for kArmSec
* (a lone noise spike can't trigger), then hold kHoldSec after it drops (a
* moving-then-pausing subject doesn't flicker). */
if (energy > _thr) {
if (_over_since < 0.0)
_over_since = t;
if (_active || (t - _over_since) >= kArmSec) {
_active = true;
_hold = kHoldSec;
}
} else {
_over_since = -1.0;
if (_active) {
_hold -= dt;
if (_hold <= 0.0)
_active = false;
}
}
}
bool active() const { return _active; }
bool warming() const { return _warm; }
double energy() const { return _energy; }
double floor() const { return _floor; }
/* signal strength: how many floor-jitter sigmas the energy sits above the
* floor. The detector fires at sigma == k. */
double sigma() const { return (_energy - _floor) / std::max(_dev, kDevFloor); }
private:
static constexpr double kWarmSec = 2.5; /* floor-acquisition window (s) */
static constexpr double kWarmTau = 0.4; /* fast tracking during warm-up (s) */
static constexpr double kTauFloor = 4.0; /* floor/jitter tracking constant (s) */
static constexpr double kArmSec = 0.15; /* dwell over threshold before MOTION (s) */
static constexpr double kHoldSec = 1.2; /* hold MOTION after last trigger (s) */
/* Scaled to the measured signal: with the correct (6,4) split the still-channel
* phi circular-variance floor is ~0.0003 and its window-to-window jitter is
* ~0.00002; a hand wave lifts it to ~0.0006–0.002. So the minimum sensitivity
* margin must be tens of micro-units, not milli-units. */
static constexpr double kDevSeed = 0.00005; /* initial jitter estimate */
static constexpr double kDevFloor = 0.00004; /* min jitter → min sensitivity margin */
double _k;
bool _init = false, _active = false, _warm = true;
double _energy = 0, _floor = 0, _dev = 0, _thr = 0;
double _last = 0, _warm_until = 0, _hold = 0, _over_since = -1.0;
};
/* ---------------------------------------------------------------- Sensor ---- */
/* Turns a stream of report frames into a live motion signal. Thread-safe: the
* RX callback calls feed(); the display thread reads the snapshot. */
class Sensor {
public:
explicit Sensor(double k) : _det(k) {}
/* No default: the single call site has the Packet and must pass the
* frame's own flag. A default here would only let a future second
* caller compile while silently applying the Realtek rule. */
void feed(const uint8_t *frame, size_t n, bool fcs_present) {
ReportHdr hdr;
if (!parse_report(frame, n, hdr, fcs_present))
return;
if (std::getenv("DEVOURER_SENSE_DUMP") && g_ev) {
/* python-tool-compatible raw dump (events ride stderr in sense, so the
* stdout display is untouched): capture with 2>file, analyse with
* tools/bf_report_decode.py */
devourer::Ev(*g_ev, "bf.report_raw")
.f("fcs", fcs_present ? 1 : 0)
.hex("frame", frame, n);
}
if (std::getenv("DEVOURER_SENSE_DEBUG")) {
static int dbg = 0;
if (dbg < 8) {
++dbg;
std::fprintf(stderr,
"[report] len=%zu SA=%02x:%02x:%02x:%02x:%02x:%02x nc=%d "
"nr=%d mu=%d ns=%d\n",
n, frame[10], frame[11], frame[12], frame[13], frame[14],
frame[15], hdr.nc, hdr.nr, hdr.mu, hdr.ns);
}
}
std::lock_guard<std::mutex> lk(_mu);
++_total;
if (!_meter) {
/* calibration: copy full frames until we can pick a stable split */
_cal.push_back({std::vector<uint8_t>(frame, frame + n), fcs_present});
_ns = hdr.ns;
_per = hdr.per_sc_bits;
if ((int)_cal.size() >= kCalReports)
calibrate();
return;
}
if (hdr.ns != _ns)
return;
if (!_meter->push(hdr))
return;
/* Wait for a full window before feeding the detector: a partial window
* under-reports circular variance, and that transient would poison the
* self-calibrating floor. */
if (_meter->count() < kWindow)
return;
_det.update(_meter->motion_energy(), now_sec());
_localized = _meter->localized();
}
struct Snap {
bool calibrated, warming, motion, localized;
double energy, floor, sigma;
long total;
int ns;
};
Snap snapshot() {
std::lock_guard<std::mutex> lk(_mu);
bool cal = _meter != nullptr;
return Snap{cal, cal && _det.warming(),
_det.active(), _localized,
_det.energy(), _det.floor(),
cal ? _det.sigma() : 0.0,
_total, _ns};
}
private:
static double now_sec() {
using namespace std::chrono;
return duration_cast<duration<double>>(
steady_clock::now().time_since_epoch())
.count();
}
void calibrate() {
/* re-parse the copies so ReportHdr.angles point into stable storage */
std::vector<ReportHdr> batch;
batch.reserve(_cal.size());
for (auto &f : _cal) {
ReportHdr h;
/* Reparse under the SAME rule the frame arrived with. Defaulting to
* fcs_present here would reject tight MU reports and mis-derive
* per_sc_bits for SU ones on any backend that strips the FCS. */
if (parse_report(f.first.data(), f.first.size(), h, f.second))
batch.push_back(h);
}
int bphi = 0, bpsi = 0;
if (!devourer::bf::pick_split(batch, bphi, bpsi)) {
/* fallback for the 2x1 case: the standard Givens split (b_phi = b_psi + 2)
* summing to per_sc_bits — NOT a coarse split like (8,2), whose
* bit-misaligned phi decodes to garbage. */
bpsi = (_per - 2) / 2;
bphi = bpsi + 2;
}
_bphi = bphi;
_bpsi = bpsi;
_meter = std::make_unique<MotionMeter>(_ns, bphi, bpsi, kWindow);
_cal.clear();
_cal.shrink_to_fit();
}
std::mutex _mu;
/* frame bytes + whether they carry an FCS; calibrate() needs both. */
std::vector<std::pair<std::vector<uint8_t>, bool>> _cal;
std::unique_ptr<MotionMeter> _meter;
AdaptiveDetector _det;
int _ns = 0, _per = 0, _bphi = 0, _bpsi = 0;
bool _localized = false;
long _total = 0;
};
/* --------------------------------------------------------------- Display ---- */
static void run_display(Sensor &sensor) {
using namespace std::chrono;
auto last = steady_clock::now();
long last_total = 0;
std::printf("\n Wi-Fi motion sensor — move near the adapters to trigger; it "
"stays CLEAR when the room is still.\n"
" (self-calibrating; Ctrl-C to stop)\n\n");
while (!g_devourer_should_stop) {
std::this_thread::sleep_for(milliseconds(200));
auto s = sensor.snapshot();
auto now = steady_clock::now();
double dt = duration_cast<duration<double>>(now - last).count();
double rate = dt > 0 ? (s.total - last_total) / dt : 0;
last = now;
last_total = s.total;
if (!s.calibrated) {
std::printf("\r calibrating decoder… %ld reports (%.0f/s) ",
s.total, rate);
std::fflush(stdout);
continue;
}
if (s.warming) {
std::printf("\r acquiring noise floor — one moment… (%.0f rep/s) ",
rate);
std::fflush(stdout);
continue;
}
/* Signal strength = floor-jitter sigmas above the adaptive floor; the
* detector fires around a few sigma. Bar saturates at 10 sigma. */
double sig = s.sigma;
if (sig < 0)
sig = 0;
int bar = (int)(sig / 10.0 * 40);
if (bar > 40)
bar = 40;
char b[41];
for (int i = 0; i < 40; ++i)
b[i] = i < bar ? '#' : ' ';
b[40] = 0;
const char *tag = s.motion ? (s.localized ? "MOTION·nb" : " MOTION ")
: " CLEAR ";
std::printf("\r [%s] %5.1fσ |%s| now %.4f base %.4f %.0f/s ", tag, sig,
b, s.energy, s.floor, rate);
std::fflush(stdout);
}
std::printf("\n");
}
/* ------------------------------------------------------------ USB helpers --- */
/* Each adapter owns its whole stack — device, interface, handle and its own
* libusb context — through one DeviceSession, so the two of them unwind
* independently and each in the required order (see DeviceSession.h). */
struct Adapter {
explicit Adapter(const Logger_t &logger) : session(logger) {}
devourer::DeviceSession session;
libusb_device_handle *handle() const { return session.handle(); }
IRadio *dev() const { return session.device(); }
};
/* Open one adapter by VID:PID on its own libusb context, claim + reset, and build
* the device (not yet brought up). Returns false (logged) on failure. */
static bool open_adapter(Adapter &a, uint16_t vid, uint16_t pid,
const Logger_t &logger) {
libusb_context *ctx = nullptr;
if (libusb_init(&ctx) < 0)
return false;
a.session.adopt_context(ctx);
libusb_device_handle *handle = libusb_open_device_with_vid_pid(ctx, vid, pid);
if (!handle) {
logger->error("could not open {:04x}:{:04x} — is it plugged in? (a prior "
"hang can drop an adapter off the bus; unplug/replug it)",
vid, pid);
return false;
}
std::shared_ptr<devourer::UsbDeviceLock> lock;
int rc = devourer::claim_interface_then_reset(
handle, devourer::find_wifi_interface(handle), logger, true, lock);
if (rc != 0) {
/* The claim failed, so nothing owns the handle yet — hand it to the
* session purely so the unwind closes it. */
a.session.adopt_handle(handle);
return false;
}
a.session.adopt_handle(handle);
a.session.adopt_lock(lock);
WiFiDriver driver(logger);
auto owned_device =
driver.CreateRadio(handle, ctx, lock, devourer_config_from_env());
if (!owned_device)
return false;
a.session.adopt_device(std::move(owned_device));
return true;
}
static bool read_mac(libusb_device_handle *h, uint8_t mac[6]) {
/* REG_MACID is IDR0 (4 bytes @ 0x0610) + IDR4 (2 bytes @ 0x0614). Realtek
* vendor reads are 1/2/4-byte; a single 6-byte read returns garbage, so split
* it the way rtw_read32 + rtw_read16 would. */
int r1 = libusb_control_transfer(h, 0xC0, 5, REG_MACID, 0, mac, 4, 1000);
int r2 = libusb_control_transfer(h, 0xC0, 5, REG_MACID + 4, 0, mac + 4, 2, 1000);
return r1 == 4 && r2 == 2;
}
/* ------------------------------------------------------------- active mode -- */
static int run_active(uint16_t snd_vid, uint16_t snd_pid, uint16_t bfe_vid,
uint16_t bfe_pid, int channel, const Logger_t &logger,
Sensor &sensor) {
/* Both adapters are declared before any thread below, so every thread is
* joined before the session that owns the handle it touches is unwound. */
Adapter bfe{logger}, snd{logger};
/* Beamformee first: arm it (env, read at Init), bring it up on a thread. */
set_env("DEVOURER_BF_ARM_BFEE", "57:42:75:05:d6:00");
set_env("DEVOURER_BF_ARM_BFEE_MU", "1");
if (!open_adapter(bfe, bfe_vid, bfe_pid, logger)) {
logger->error("active: failed to open beamformee {:04x}", bfe_pid);
return 1;
}
std::thread bfe_thread([&bfe, channel]() {
bfe.dev()->Init([](const Packet &) {}, /* responds in hardware; RX ignored */
SelectedChannel{.Channel = (uint8_t)channel,
.ChannelOffset = 0,
.ChannelWidth = CHANNEL_WIDTH_20});
});
std::this_thread::sleep_for(std::chrono::milliseconds(1500)); /* bring-up + MAC */
uint8_t bfe_mac[6];
if (!read_mac(bfe.handle(), bfe_mac)) {
logger->error("active: could not read beamformee MAC (REG_MACID)");
g_devourer_should_stop = true;
bfe_thread.join();
return 1;
}
logger->info("active: beamformee MAC {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
bfe_mac[0], bfe_mac[1], bfe_mac[2], bfe_mac[3], bfe_mac[4],
bfe_mac[5]);
/* Sounder: arm the sounding engine (env, read at InitWrite), VHT2SS_MCS0.
* DEVOURER_TX_WITH_RX=thread must be set BEFORE InitWrite so a Jaguar3 sounder
* keeps its RX filters open for the self-capture (no-op on Jaguar1/2). */
set_env("DEVOURER_BF_ARM_SOUNDER", "1");
set_env("DEVOURER_TX_WITH_RX", "thread");
if (!open_adapter(snd, snd_vid, snd_pid, logger)) {
logger->error("active: failed to open sounder {:04x}", snd_pid);
g_devourer_should_stop = true;
bfe_thread.join();
return 1;
}
snd.dev()->InitWrite(SelectedChannel{.Channel = (uint8_t)channel,
.ChannelOffset = 0,
.ChannelWidth = CHANNEL_WIDTH_20});
snd.dev()->SetTxMode(devourer::parse_tx_mode_str("VHT2SS_MCS0"));
set_env("DEVOURER_TX_NDPA", "1"); /* send_packet marks the NDPA descriptor */
/* Self-capture the returned reports on the sounder's RX loop. */
std::thread snd_rx([&snd, &sensor]() {
snd.dev()->StartRxLoop(
[&sensor](const Packet &p) { sensor.feed(p.Data.data(), p.Data.size(), p.RxAtrib.fcs_present); });
});
std::thread disp(run_display, std::ref(sensor));
/* Build the NDPA once (10-byte rate-less radiotap + 19-byte VHT NDPA body,
* RA = beamformee MAC, TA = canonical SA). Rate is the SetTxMode default. */
std::vector<uint8_t> ndpa = {
0x00, 0x00, 0x0a, 0x00, 0x00, 0x80, 0x00, 0x00, 0x08, 0x00, /* radiotap */
0x54, 0x00, 0x64, 0x00, /* NDPA FC+dur */
bfe_mac[0], bfe_mac[1], bfe_mac[2], bfe_mac[3], bfe_mac[4], bfe_mac[5],
0x57, 0x42, 0x75, 0x05, 0xd6, 0x00, /* TA */
0x04, 0x00, 0x10 /* dialog token; STA Info: AID0, MU feedback, Nc0 */};
logger->info("active: sounding ch{} — move near the setup to see it react",
channel);
/* Sound in a loop, watching the report stream. The beamformee's firmware can
* crash on a long run (and then drop off the USB bus); if reports stop
* advancing, don't spin forever sounding into the void — report it and stop
* cleanly, so a stalled stream can't wedge the adapter. */
using clk = std::chrono::steady_clock;
long last_total = 0;
auto last_adv = clk::now();
bool stalled = false;
while (!g_devourer_should_stop) {
if (!snd.dev()->send_packet(ndpa.data(), ndpa.size()))
std::this_thread::sleep_for(std::chrono::milliseconds(2));
std::this_thread::sleep_for(std::chrono::milliseconds(3));
long t = sensor.snapshot().total;
auto now = clk::now();
if (t != last_total) {
last_total = t;
last_adv = now;
} else if (now - last_adv > std::chrono::seconds(kStallSec)) {
logger->warn("report stream stalled {}s — beamformee stopped responding; "
"stopping (unplug/replug it before re-running)",
kStallSec);
stalled = true;
g_devourer_should_stop = true;
}
}
/* Deadlock-proof shutdown: a wedged USB handle can make an RX-loop join block
* forever. Arm a force-exit safety net so the process is guaranteed to die;
* if the clean joins finish first (the normal case) this timer is killed with
* the process on return and never fires. */
std::thread([]() {
std::this_thread::sleep_for(std::chrono::seconds(4));
std::_Exit(0);
}).detach();
snd.dev()->StopRxLoop();
bfe.dev()->StopRxLoop();
disp.join();
snd_rx.join();
bfe_thread.join();
snd.dev()->Stop();
bfe.dev()->Stop();
return stalled ? 2 : 0;
}
/* ---------------------------------------------------------------- main ------ */
static uint16_t hex16(const char *s) {
return (uint16_t)std::strtoul(s, nullptr, 0);
}
int main(int argc, char **argv) {
auto logger = std::make_shared<Logger>();
apply_logging_env(*logger); /* DEVOURER_LOG_LEVEL / DEVOURER_EVENTS / ... */
/* Events go to stderr regardless of DEVOURER_EVENTS' default: stdout is the
* live motion display (\r progress lines), and a JSON line in the middle of
* it would shred the readout. Capture events with 2>file. */
logger->events().configure(stderr);
g_ev = &logger->events();
install_devourer_signal_handlers();
int channel = 6;
bool verbose = false;
double sens_k = 4.0; /* CFAR threshold in sigmas; --sensitivity overrides */
uint16_t snd_vid = 0x0bda, bfe_vid = 0x0bda;
uint16_t snd_pid = 0x8812, bfe_pid = 0xc812;
/* selector: "0xVID:0xPID" or just "0xPID" (VID defaults to 0x0bda). */
auto parse_sel = [](const std::string &s, uint16_t &v, uint16_t &p) {
auto c = s.find(':');
if (c != std::string::npos) {
v = hex16(s.substr(0, c).c_str());
p = hex16(s.substr(c + 1).c_str());
} else {
p = hex16(s.c_str());
}
};
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
auto next = [&]() -> std::string { return i + 1 < argc ? argv[++i] : ""; };
if (a == "--channel") channel = std::atoi(next().c_str());
else if (a == "--vid") { uint16_t v = hex16(next().c_str()); snd_vid = bfe_vid = v; }
else if (a == "--sounder") parse_sel(next(), snd_vid, snd_pid);
else if (a == "--beamformee") parse_sel(next(), bfe_vid, bfe_pid);
else if (a == "--sensitivity") {
std::string s = next();
sens_k = s == "high" ? 2.5 : s == "low" ? 6.0 : 4.0; /* default med */
}
else if (a == "--verbose" || a == "-v") verbose = true;
else if (a == "-h" || a == "--help") {
std::printf(
"sense — Wi-Fi motion sensing from beamforming reports\n"
" drives two adapters: a sounder + a beamformee, on one host.\n"
" --channel N (default 6)\n"
" --sensitivity low|med|high detector threshold (default med)\n"
" --vid 0xNNNN default VID for both (default 0x0bda)\n"
" --sounder [VID:]PID sounder adapter selector\n"
" --beamformee [VID:]PID beamformee adapter selector\n"
" -v, --verbose show the library's bring-up logs\n");
return 0;
}
}
/* Quiet the library's per-operation info logging so the live display owns the
* console; --verbose restores the full bring-up log, and an explicit
* DEVOURER_LOG_LEVEL (already applied by apply_logging_env above) wins over
* the quiet default. */
if (!verbose && std::getenv("DEVOURER_LOG_LEVEL") == nullptr)
logger->set_level(Logger::Level::Warn);
/* DEVOURER_SENSE_K overrides the detector threshold for on-rig fine-tuning. */
if (const char *kenv = std::getenv("DEVOURER_SENSE_K"))
sens_k = std::atof(kenv);
Sensor sensor(sens_k);
return run_active(snd_vid, snd_pid, bfe_vid, bfe_pid, channel, logger, sensor);
}
+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;
}
+262
View File
@@ -0,0 +1,262 @@
// svctx — TID-aware unequal-error-protection (UEP) injector.
//
// Reads a sequence of length-prefixed HEVC NAL units from stdin, classifies
// each by its temporal_id / criticality (svc_tx.h), and injects it at the PHY
// rate its layer deserves (robust MCS for base/IDR, fast MCS for enhancement).
// The per-packet radiotap carries the rate, so each frame is an independent
// PHY mode (radiotap-per-packet wins; see RtlJaguarDevice::send_packet).
//
// Input protocol (stdin): <u32_le len><len bytes of NAL> ... EOF
// (Length-prefixed = the AVCC/HVCC/MP4 framing many depacketizers already
// produce. A production link would feed Annex-B or RTP instead — same NAL
// header parse, trivial framing adapter.)
//
// All records are read up front, then injected in a continuous loop (so a
// receiver can capture a stable rate histogram). A live deployment would
// inject each NAL as it arrives, once.
//
// Usage:
// DEVOURER_PID=0x8812 DEVOURER_CHANNEL=6 ./build/svctx \
// [--mtu N] [--gap-us US] < nals.bin
#ifndef NOMINMAX
#define NOMINMAX // keep <windows.h> from defining min()/max() macros (breaks std::min)
#endif
#include <cassert>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#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>
typedef int pid_t;
#define sleep(seconds) Sleep((seconds)*1000)
#elif defined(__MINGW32__) || defined(__MINGW64__)
#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 "RadiotapBuilder.h"
#include "RtlAdapter.h"
#include "UsbOpen.h"
#include "WiFiDriver.h"
#include "env_config.h"
#include "logger.h"
#include "stream_stdin.h"
#include "svc_tx.h"
#define USB_VENDOR_ID 0x0bda
static constexpr uint16_t kRealtekProductIds[] = {
0x8812, 0x0811, 0xa811, 0xb811, 0x8813,
};
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;
}
static const char* mode_str(const devourer::TxMode& m) {
static char buf[24];
if (m.mode == devourer::TxMode::Mode::HT)
std::snprintf(buf, sizeof(buf), "MCS%u/%u%s%s%s", m.ht_mcs, m.bw_mhz,
m.sgi ? "/SGI" : "", m.ldpc ? "/LDPC" : "",
m.stbc ? "/STBC" : "");
else if (m.mode == devourer::TxMode::Mode::VHT)
std::snprintf(buf, sizeof(buf), "VHT%uSS_MCS%u/%u", m.vht_nss, m.vht_mcs,
m.bw_mhz);
else
std::snprintf(buf, sizeof(buf), "%uM", m.legacy_rate_500kbps / 2);
return buf;
}
int main(int argc, char** argv) {
auto logger = std::make_shared<Logger>();
apply_logging_env(*logger); /* DEVOURER_LOG_LEVEL / DEVOURER_EVENTS / ... */
size_t mtu = 1400;
long gap_us = 2000;
long termux_fd = 0;
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
if (a == "--mtu" && i + 1 < argc)
mtu = static_cast<size_t>(std::strtoul(argv[++i], nullptr, 0));
else if (a == "--gap-us" && i + 1 < argc)
gap_us = std::strtol(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;
}
}
if (mtu < 16) mtu = 16;
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). Each early return from here on unwinds whatever has
* been adopted so far. */
devourer::DeviceSession session{logger};
if (termux_fd > 0) {
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("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* p = std::getenv("DEVOURER_PID"))
target_pid = static_cast<uint16_t>(std::strtoul(p, nullptr, 0));
uint16_t target_vid = USB_VENDOR_ID;
if (const char* v = std::getenv("DEVOURER_VID"))
target_vid = static_cast<uint16_t>(std::strtoul(v, 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 {: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"); 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;
const int wifi_iface = devourer::find_wifi_interface(handle);
rc = devourer::claim_interface_then_reset(handle, wifi_iface, 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, wifi_iface);
return 1;
}
session.adopt_handle(handle, wifi_iface);
session.adopt_lock(usb_lock);
WiFiDriver wifi_driver{logger};
auto owned_device = wifi_driver.CreateRadio(handle, nullptr, usb_lock,
devourer_config_from_env());
/* 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();
int channel = 6;
if (const char* ch = std::getenv("DEVOURER_CHANNEL")) channel = std::atoi(ch);
/* DEVOURER_TX_POWER: flat TXAGC index. Unset = the family's calibrated
* default (SetTxPower is now a real flat override on every generation). */
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});
sleep(2);
// Policy (DEVOURER_SVC_LADDER or the built-in default) + precomputed
// radiotaps (one per rung — built once, reused).
const svc::LayerPolicy policy = svc::policy_from_env();
std::vector<uint8_t> rt_crit = devourer::build_stream_radiotap(policy.critical);
std::vector<std::vector<uint8_t>> rt_tid;
for (const auto& m : policy.by_tid)
rt_tid.push_back(devourer::build_stream_radiotap(m));
logger->info("SVC UEP policy: critical={}", mode_str(policy.critical));
for (size_t i = 0; i < policy.by_tid.size(); ++i)
logger->info(" T{} -> {}", i, mode_str(policy.by_tid[i]));
const auto dot11 = build_dot11_probe_req();
// Read all length-prefixed NALs up front.
std::vector<std::vector<uint8_t>> nals;
while (true) {
std::vector<uint8_t> nal;
/* Any incomplete record ends the read-ahead: this loop drains a whole clip
* before transmitting anything, so a truncated tail is simply where the
* clip stops. */
if (stream_stdin::read_record(stdin, nal, 200000) !=
stream_stdin::RecordResult::Ok)
break;
nals.push_back(std::move(nal));
}
if (nals.empty()) { logger->error("no NALs on stdin"); return 2; }
logger->info("svctx: {} NALs, mtu={}, ch{} — looping", nals.size(), mtu,
channel);
long sent[9] = {0}; // [0..7] per TID, [8] = critical
long frames = 0;
while (true) {
for (const auto& nal : nals) {
svc::NalInfo info = svc::parse_hevc_nal(nal.data(), nal.size());
const std::vector<uint8_t>& rt =
info.critical ? rt_crit
: rt_tid.empty() ? rt_crit
: rt_tid[info.tid < rt_tid.size() ? info.tid : rt_tid.size() - 1];
sent[info.critical ? 8 : (info.tid > 7 ? 7 : info.tid)]++;
// Fragment the NAL to the radio MTU; every fragment carries the layer rate.
for (size_t off = 0; off < nal.size(); off += mtu) {
size_t n = std::min(mtu, nal.size() - off);
std::vector<uint8_t> frame;
frame.reserve(rt.size() + dot11.size() + n);
frame.insert(frame.end(), rt.begin(), rt.end());
frame.insert(frame.end(), dot11.begin(), dot11.end());
frame.insert(frame.end(), nal.begin() + off, nal.begin() + off + n);
rtlDevice->send_packet(frame.data(), frame.size());
if (gap_us > 0)
std::this_thread::sleep_for(std::chrono::microseconds(gap_us));
}
/* Per-layer injection counters. JSON keys are lowercase (t3plus stands
* in for "T3+": temporal layers 3..7 aggregated). */
if (++frames % 200 == 0) {
devourer::Ev(logger->events(), "svc.stats")
.f("frames", frames)
.f("crit", sent[8])
.f("t0", sent[0])
.f("t1", sent[1])
.f("t2", sent[2])
.f("t3plus", sent[3] + sent[4] + sent[5] + sent[6] + sent[7]);
}
}
}
/* 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;
}
+120
View File
@@ -0,0 +1,120 @@
// TID -> TxMode unequal-error-protection (UEP) shim for SVC-T video over
// devourer. Maps each HEVC NAL's temporal_id (and IRAP/parameter-set
// criticality) to a PHY TxMode, so the most important layers fly at the most
// robust rate and the enhancement layers ride the fast rate — a graceful
// degradation staircase instead of a single MCS cliff.
//
// App-level (no WiFiDriver core changes): consumes devourer::TxMode +
// build_stream_radiotap + RtlJaguarDevice::send_packet.
#pragma once
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <string>
#include <utility>
#include <vector>
#include "RadiotapBuilder.h" // parse_tx_mode_str
#include "TxMode.h"
namespace svc {
// Per-temporal-layer PHY policy. by_tid[i] = mode for temporal layer i; TIDs
// past the end clamp to the last (least-protected) entry. `critical` overrides
// for frames you cannot lose: IRAP/IDR slices and parameter sets.
struct LayerPolicy {
std::vector<devourer::TxMode> by_tid;
devourer::TxMode critical;
const devourer::TxMode& mode_for(uint8_t tid, bool is_critical) const {
if (is_critical || by_tid.empty()) return critical;
return by_tid[tid < by_tid.size() ? tid : by_tid.size() - 1];
}
};
inline devourer::TxMode HT(uint8_t mcs, uint8_t bw = 20, bool ldpc = false,
bool stbc = false, bool sgi = false) {
devourer::TxMode m;
m.mode = devourer::TxMode::Mode::HT;
m.ht_mcs = mcs;
m.bw_mhz = bw;
m.ldpc = ldpc;
m.stbc = stbc;
m.sgi = sgi;
return m;
}
// Default 3-temporal-layer ladder for an 8812AU long-range link.
// critical (IDR / VPS/SPS/PPS) : MCS0 20MHz LDPC STBC — max range
// T0 base : MCS1 20MHz LDPC STBC
// T1 (->30fps) : MCS4 20MHz
// T2 (->60fps) : MCS7 40MHz SGI — max throughput
inline LayerPolicy default_policy() {
LayerPolicy p;
p.critical = HT(/*mcs*/ 0, /*bw*/ 20, /*ldpc*/ true, /*stbc*/ true);
p.by_tid = {
HT(1, 20, /*ldpc*/ true, /*stbc*/ true),
HT(4, 20),
HT(7, 40, /*ldpc*/ false, /*stbc*/ false, /*sgi*/ true),
};
return p;
}
// HEVC NAL header is 2 bytes:
// nal_unit_type = (b0 >> 1) & 0x3F
// nuh_temporal_id_plus1 = b1 & 0x07 (TID = that - 1)
// Critical = IRAP slices (16..23, incl. IDR/CRA/BLA) or parameter sets
// (VPS=32 / SPS=33 / PPS=34) — losing any of these stalls the decoder.
struct NalInfo {
uint8_t tid = 0;
bool critical = false;
uint8_t type = 0;
};
inline NalInfo parse_hevc_nal(const uint8_t* nal, size_t len) {
NalInfo n;
if (len < 2) return n; // malformed -> treat as base layer
n.type = (nal[0] >> 1) & 0x3F;
int tid = static_cast<int>(nal[1] & 0x07) - 1;
n.tid = tid < 0 ? 0 : static_cast<uint8_t>(tid);
n.critical = (n.type >= 16 && n.type <= 23) || (n.type >= 32 && n.type <= 34);
return n;
}
// Build a LayerPolicy from DEVOURER_SVC_LADDER, a ';'-separated list of
// "<key>=<spec>" where <key> is CRIT or T0/T1/T2/... and <spec> is a
// DEVOURER_TX_RATE token string (e.g. "MCS0/20/LDPC/STBC"). Tn rungs are
// ordered by index into by_tid. Unset -> default_policy().
// DEVOURER_SVC_LADDER="CRIT=MCS0/20/LDPC/STBC;T0=MCS1/20/LDPC/STBC;T1=MCS4;T2=MCS7/40/SGI"
inline LayerPolicy policy_from_env() {
const char* raw = std::getenv("DEVOURER_SVC_LADDER");
if (raw == nullptr || *raw == '\0') return default_policy();
LayerPolicy p;
p.critical = HT(0, 20, /*ldpc*/ true, /*stbc*/ true); // default if CRIT omitted
std::vector<std::pair<int, devourer::TxMode>> tids;
const std::string s(raw);
size_t i = 0;
while (i < s.size()) {
size_t sc = s.find(';', i);
std::string tok = s.substr(i, sc == std::string::npos ? std::string::npos : sc - i);
i = (sc == std::string::npos) ? s.size() : sc + 1;
size_t eq = tok.find('=');
if (eq == std::string::npos) continue;
std::string key = tok.substr(0, eq), val = tok.substr(eq + 1);
devourer::TxMode m = devourer::parse_tx_mode_str(val);
if (key == "CRIT" || key == "crit") {
p.critical = m;
} else if (key.size() >= 2 && (key[0] == 'T' || key[0] == 't')) {
tids.emplace_back(std::atoi(key.c_str() + 1), m);
}
}
std::sort(tids.begin(), tids.end(),
[](const auto& a, const auto& b) { return a.first < b.first; });
for (const auto& tm : tids) p.by_tid.push_back(tm.second);
if (p.by_tid.empty()) return default_policy();
return p;
}
} // namespace svc
+334
View File
@@ -0,0 +1,334 @@
// tdma — burst-level bandwidth TDMA demo (see docs/narrowband.md).
//
// One binary, one adapter, one role (compose scenarios by running instances):
// DEVOURER_TDMA_ROLE=tx alternate narrowband/wide BURSTS, injecting
// critical frames in the narrowband burst and
// bulk frames in the wide burst, flipping
// bandwidth with FastSetBandwidth.
// DEVOURER_TDMA_ROLE=rx-sync switch bandwidth in LOCKSTEP with the TX,
// DEVOURER_TDMA_SYNC=wallclock (shared system
// clock) or =marker (self-clock off the TX's
// per-burst marker frame — no shared clock).
// DEVOURER_TDMA_ROLE=rx-camp camp permanently on one width
// (DEVOURER_TDMA_CAMP) — mode 2's per-band RX.
//
// See tdma.h for the schedule math, the on-air TD frame tag, and the full env
// knob list. Metrics are JSONL on stdout ({"ev":"tdma.*"}).
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#if defined(_MSC_VER)
#include <libusb.h>
#elif defined(__MINGW32__) || defined(__MINGW64__)
#include <libusb-1.0/libusb.h>
#elif defined(__APPLE__) || defined(__ANDROID__)
#include <libusb.h>
#else
#include <libusb-1.0/libusb.h>
#endif
// Portable sleeps (no <unistd.h> — MSVC lacks it): std::this_thread only.
static inline void sleep_us(long us) {
std::this_thread::sleep_for(std::chrono::microseconds(us));
}
#include "DeviceSession.h"
#include "RadiotapBuilder.h"
#include "RxPacket.h"
#include "SignalStop.h"
#include "UsbOpen.h"
#include "WiFiDriver.h"
#include "env_config.h"
#include "logger.h"
#include "tdma.h"
#define USB_VENDOR_ID 0x0bda
static constexpr uint16_t kRealtekProductIds[] = {
0x8812, 0x0811, 0xa811, 0xb811, 0x8813,
};
// --- shared RX state (control thread switches, RX thread counts) ------------
static std::atomic<int> g_rx_mhz{20}; // RX's current width
static std::atomic<uint64_t> g_cnt[2][3]; // [nb?][class]
static std::atomic<int64_t> g_marker_anchor_ns{0}; // steady ns of last marker
static std::atomic<bool> g_have_anchor{false};
// TSF-sync state: the host↔hardware-TSF fit, the reconstructed tsf of the last
// marker (the anchor), and the measured TX↔RX crystal drift.
static bool g_tsf_mode = false;
static tdma::TsfClock g_clock;
static std::mutex g_clock_mu;
static std::atomic<int64_t> g_marker_tsf{0};
static std::atomic<double> g_drift_ppm{1e9}; // 1e9 = not yet measured
static uint64_t g_prev_tx_tsf = 0;
static int64_t g_prev_rx_tsf = 0;
static int64_t steady_ns() {
return std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
static void emit(const char* json) { std::fputs(json, stdout); std::fflush(stdout); }
// --- device open (mirrors examples/svctx/main.cpp) --------------------------
static libusb_device_handle* open_device(
const std::shared_ptr<Logger>& logger, libusb_context** ctx,
std::shared_ptr<devourer::UsbDeviceLock>& lock) {
if (libusb_init(ctx) < 0) return nullptr;
libusb_set_option(*ctx, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_WARNING);
uint16_t vid = USB_VENDOR_ID, pid = 0;
if (const char* v = std::getenv("DEVOURER_VID")) vid = (uint16_t)strtoul(v, 0, 0);
if (const char* p = std::getenv("DEVOURER_PID")) pid = (uint16_t)strtoul(p, 0, 0);
libusb_device_handle* h = nullptr;
for (uint16_t p : kRealtekProductIds) {
if (pid != 0 && p != pid) continue;
h = libusb_open_device_with_vid_pid(*ctx, vid, p);
if (h) break;
}
if (!h && pid != 0) h = libusb_open_device_with_vid_pid(*ctx, vid, pid);
if (!h) { logger->error("no device {:04x}:{:04x}", vid, pid); return nullptr; }
if (devourer::claim_interface_then_reset(h, devourer::find_wifi_interface(h), logger, std::getenv("DEVOURER_SKIP_RESET") == nullptr, lock) != 0) {
logger->error("claim failed (busy?)");
return nullptr;
}
return h;
}
// --- TX role ----------------------------------------------------------------
static void run_tx(IRadio* dev, const tdma::Config& c) {
dev->InitWrite(SelectedChannel{c.channel, 0, CHANNEL_WIDTH_20});
std::this_thread::sleep_for(std::chrono::seconds(2));
const auto rt_crit = devourer::build_stream_radiotap(c.crit_rate);
const auto rt_bulk = devourer::build_stream_radiotap(c.bulk_rate);
// Markers ride the robust critical rate (they must be heard to sync).
const auto& rt_marker = rt_crit;
ChannelWidth_t cur_w = CHANNEL_WIDTH_20;
int64_t last_marker_burst = -1;
uint32_t seq[3] = {0, 0, 0};
auto next_stat = std::chrono::steady_clock::now() + std::chrono::seconds(1);
fprintf(stderr, "tdma tx: nb=%dMHz/%dms wide=%dMHz/%dms crit=%s bulk=%s\n",
tdma::mhz_of(c.sched.nb_w), c.sched.nb_ms, tdma::mhz_of(c.sched.wide_w),
c.sched.wide_ms, "crit", "bulk");
while (!g_devourer_should_stop) {
auto a = c.sched.at(tdma::wall_ms());
ChannelWidth_t w = c.sched.width(a.phase);
if (w != cur_w) { dev->FastSetBandwidth(w); cur_w = w; }
if (a.phase == tdma::Phase::NB && a.burst != last_marker_burst) {
last_marker_burst = a.burst;
// Stamp the marker with the TX's hardware TSF (works TX-side — no RX
// flood starving the control read); the TSF-sync RX uses it for drift.
uint64_t tx_tsf = dev->ReadTsf();
auto f = tdma::build_frame(rt_marker, tdma::Class::Marker, seq[0]++,
(uint32_t)a.burst, tx_tsf);
dev->send_packet(f.data(), f.size());
}
tdma::Class cls =
a.phase == tdma::Phase::NB ? tdma::Class::Critical : tdma::Class::Bulk;
const auto& rt = cls == tdma::Class::Critical ? rt_crit : rt_bulk;
auto f = tdma::build_frame(rt, cls, seq[(int)cls]++, (uint32_t)a.burst);
dev->send_packet(f.data(), f.size());
if (std::chrono::steady_clock::now() >= next_stat) {
next_stat += std::chrono::seconds(1);
char buf[256];
std::snprintf(buf, sizeof(buf),
"{\"ev\":\"tdma.tx\",\"marker\":%u,\"critical\":%u,"
"\"bulk\":%u,\"width_mhz\":%d}\n",
seq[0], seq[1], seq[2], tdma::mhz_of(cur_w));
emit(buf);
}
if (c.gap_us > 0) sleep_us(c.gap_us);
}
}
// --- RX roles ---------------------------------------------------------------
static void rx_callback(const Packet& p) {
auto pr = tdma::parse_frame(p.Data.data(), p.Data.size());
if (!pr.ok || p.RxAtrib.crc_err) return;
int nb = g_rx_mhz.load(std::memory_order_relaxed) <= 10 ? 1 : 0;
g_cnt[nb][(int)pr.cls].fetch_add(1, std::memory_order_relaxed);
const int64_t host = steady_ns();
if (g_tsf_mode) {
// Feed EVERY frame into the host↔TSF fit; anchor the schedule on the
// marker's hardware TSF (de-jittered through the fit), not the callback time.
// Host time in microseconds so the least-squares sums stay well within
// double precision (ns would overflow it over a multi-second run).
std::lock_guard<std::mutex> lk(g_clock_mu);
int64_t rx_tsf = g_clock.add(p.RxAtrib.tsfl, host / 1000);
if (pr.cls == tdma::Class::Marker) {
g_marker_tsf.store(rx_tsf, std::memory_order_relaxed);
g_have_anchor.store(true, std::memory_order_relaxed);
if (getenv("TDMA_DBG") && g_clock.ready())
fprintf(stderr, "[mk] rx_tsf=%lld host_at=%.0f host_us=%lld resid=%.0f us\n",
(long long)rx_tsf, g_clock.host_at(rx_tsf), (long long)(host / 1000),
g_clock.host_at(rx_tsf) - host / 1000.0);
if (pr.tx_tsf && g_prev_tx_tsf) { // crystal drift, TX TSF vs RX TSF
double dtx = (double)(pr.tx_tsf - g_prev_tx_tsf);
double drx = (double)(rx_tsf - g_prev_rx_tsf);
double ppm = drx > 0 ? (dtx / drx - 1.0) * 1e6 : 1e9;
if (dtx > 0 && ppm > -500 && ppm < 500) g_drift_ppm.store(ppm); // sane only
}
g_prev_tx_tsf = pr.tx_tsf;
g_prev_rx_tsf = rx_tsf;
}
return;
}
if (pr.cls == tdma::Class::Marker) { // marker (steady-clock) anchor
g_marker_anchor_ns.store(host, std::memory_order_relaxed);
g_have_anchor.store(true, std::memory_order_relaxed);
}
}
// Desired RX width `guard` ms ahead, per the sync mode.
static ChannelWidth_t desired_width(const tdma::Config& c) {
if (c.role == tdma::Role::RxCamp) return c.camp_w;
if (c.sync == tdma::Sync::WallClock) {
auto a = c.sched.at(tdma::wall_ms() + c.guard_ms);
return c.sched.width(a.phase);
}
// marker / tsf: camp narrowband until a marker anchors us, then coast.
if (!g_have_anchor.load(std::memory_order_relaxed)) return c.sched.nb_w;
int64_t elapsed_ms;
if (c.sync == tdma::Sync::Tsf) {
// Anchor = the marker's hardware TSF mapped through the fit to a de-jittered
// host time (the ~1 ms callback jitter averaged out). Fit is in microseconds.
std::lock_guard<std::mutex> lk(g_clock_mu);
if (!g_clock.ready()) return c.sched.nb_w; // fit still warming up
int64_t mt = g_marker_tsf.load(std::memory_order_relaxed);
double anchor_us = g_clock.host_at(mt);
double now_us = steady_ns() / 1000.0;
elapsed_ms = (int64_t)((now_us - anchor_us) / 1000.0);
static int dbg = 0;
if (getenv("TDMA_DBG") && (dbg++ % 200 == 0))
fprintf(stderr, "[dbg] n=%lld mt=%lld anchor_us=%.0f now_us=%.0f elapsed_ms=%lld\n",
g_clock.n, (long long)mt, anchor_us, now_us, (long long)elapsed_ms);
} else {
elapsed_ms =
(steady_ns() - g_marker_anchor_ns.load(std::memory_order_relaxed)) / 1000000;
}
if (elapsed_ms < 0 || elapsed_ms > 3LL * c.sched.period()) { // stale — re-acquire
g_have_anchor.store(false, std::memory_order_relaxed);
return c.sched.nb_w;
}
int pos = (int)((elapsed_ms + c.guard_ms) % c.sched.period());
return pos < c.sched.nb_ms ? c.sched.nb_w : c.sched.wide_w;
}
static void run_rx(IRadio* dev, const tdma::Config& c) {
// Bring RX up: rx-camp at its band; rx-sync wide (the control loop corrects).
ChannelWidth_t start_w = c.role == tdma::Role::RxCamp ? c.camp_w : CHANNEL_WIDTH_20;
g_rx_mhz.store(tdma::mhz_of(start_w));
std::thread rx([&] { dev->Init(rx_callback, SelectedChannel{c.channel, 0, start_w}); });
const char* role = c.role == tdma::Role::RxCamp ? "rx-camp"
: c.sync == tdma::Sync::Tsf ? "rx-sync/tsf"
: c.sync == tdma::Sync::Marker ? "rx-sync/marker"
: "rx-sync/wallclock";
fprintf(stderr, "tdma %s: start %dMHz nb=%dMHz wide=%dMHz guard=%dms\n", role,
tdma::mhz_of(start_w), tdma::mhz_of(c.sched.nb_w),
tdma::mhz_of(c.sched.wide_w), c.guard_ms);
// Let Init finish bring-up before the control thread touches the RF registers
// — a FastSetBandwidth racing the bring-up corrupts a half-configured chip.
for (int i = 0; i < 250 && !g_devourer_should_stop; ++i)
sleep_us(10000); // ~2.5 s settle
ChannelWidth_t cur_w = start_w;
auto next_stat = std::chrono::steady_clock::now() + std::chrono::seconds(1);
while (!g_devourer_should_stop) {
if (c.role == tdma::Role::RxSync) {
ChannelWidth_t w = desired_width(c);
if (w != cur_w) {
dev->FastSetBandwidth(w);
cur_w = w;
g_rx_mhz.store(tdma::mhz_of(w), std::memory_order_relaxed);
}
}
if (std::chrono::steady_clock::now() >= next_stat) {
next_stat += std::chrono::seconds(1);
char buf[320];
std::snprintf(
buf, sizeof(buf),
"{\"ev\":\"tdma.rx\",\"nb_marker\":%llu,\"nb_critical\":%llu,"
"\"nb_bulk\":%llu,\"wide_marker\":%llu,\"wide_critical\":%llu,"
"\"wide_bulk\":%llu,\"width_mhz\":%d}\n",
(unsigned long long)g_cnt[1][0].load(), (unsigned long long)g_cnt[1][1].load(),
(unsigned long long)g_cnt[1][2].load(), (unsigned long long)g_cnt[0][0].load(),
(unsigned long long)g_cnt[0][1].load(), (unsigned long long)g_cnt[0][2].load(),
tdma::mhz_of(cur_w));
emit(buf);
}
sleep_us(2000);
}
dev->StopRxLoop();
rx.join();
// Final summary: per (RX phase-band, class). In lockstep, critical+marker land
// under the narrowband band, bulk under wide; leakage is the off-diagonal.
fprintf(stderr,
"\n=== tdma %s summary ===\n"
" marker critical bulk\n"
" narrowband %8llu %8llu %8llu\n"
" wide %8llu %8llu %8llu\n",
role, (unsigned long long)g_cnt[1][0].load(),
(unsigned long long)g_cnt[1][1].load(), (unsigned long long)g_cnt[1][2].load(),
(unsigned long long)g_cnt[0][0].load(), (unsigned long long)g_cnt[0][1].load(),
(unsigned long long)g_cnt[0][2].load());
const uint64_t correct = g_cnt[1][1].load() + g_cnt[0][2].load(); // crit@NB + bulk@wide
const uint64_t wrong = g_cnt[0][1].load() + g_cnt[1][2].load(); // off-diagonal
fprintf(stderr, " delivered(correct)=%llu off-diagonal=%llu",
(unsigned long long)correct, (unsigned long long)wrong);
if (g_tsf_mode) {
double d = g_drift_ppm.load();
if (d < 1e8) fprintf(stderr, " TX↔RX drift=%.1f ppm", d);
else fprintf(stderr, " TX↔RX drift=n/a (TX TSF read starved under send load)");
}
fprintf(stderr, "\n");
}
int main() {
auto logger = std::make_shared<Logger>();
apply_logging_env(*logger);
install_devourer_signal_handlers();
// Owns the teardown order (device -> interface -> handle -> context; see
// DeviceSession.h): the role loops below only return once their RX thread is
// joined, so the adapter is released with nothing in flight.
devourer::DeviceSession session{logger};
tdma::Config c = tdma::config_from_env();
g_tsf_mode = (c.role == tdma::Role::RxSync && c.sync == tdma::Sync::Tsf);
libusb_context* ctx = nullptr;
std::shared_ptr<devourer::UsbDeviceLock> lock;
auto* handle = open_device(logger, &ctx, lock);
session.adopt_context(ctx);
if (!handle) return 1;
session.adopt_handle(handle, devourer::find_wifi_interface(handle));
session.adopt_lock(lock);
WiFiDriver wifi(logger);
auto owned_device =
wifi.CreateRadio(handle, ctx, lock, devourer_config_from_env());
if (!owned_device) { logger->error("no driver for this chip"); return 1; }
// 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 dev = session.device();
if (c.role == tdma::Role::Tx) run_tx(dev, c);
else run_rx(dev, c);
session.close();
return 0;
}
+261
View File
@@ -0,0 +1,261 @@
// Burst-level bandwidth-TDMA support: schedule math, the on-air frame tag, and
// the env-var config — self-contained (app-level, no WiFiDriver core changes),
// shared by the tx / rx-sync / rx-camp roles in main.cpp.
//
// The idea (see docs/narrowband.md, "Burst-level bandwidth TDMA"): a transmitter
// alternates BURSTS between a robust narrowband width (5/10 MHz — ~6 dB link
// budget, for critical frames) and a wide width (20/40 MHz — throughput, for
// bulk frames), flipping bandwidth with the cheap IRadio::FastSetBandwidth.
// Narrowband is an ADC-clock-domain state, not a per-packet radiotap field, and
// a receiver decodes exactly one width at a time — so the scheme is inherently
// burst-level and the hard part is schedule synchronization.
#pragma once
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include "RadiotapBuilder.h" // devourer::build_stream_radiotap / parse_tx_mode_str
#include "SelectedChannel.h" // ChannelWidth_t
#include "TxMode.h"
namespace tdma {
// --- Frame class ------------------------------------------------------------
// Marker frames are emitted at each narrowband-burst start; the marker-sync RX
// aligns its local schedule to them. Critical rides the narrowband burst, Bulk
// the wide burst.
enum class Class : uint8_t { Marker = 0, Critical = 1, Bulk = 2 };
inline const char* class_name(Class c) {
switch (c) {
case Class::Marker: return "marker";
case Class::Critical: return "critical";
case Class::Bulk: return "bulk";
}
return "?";
}
// --- On-air frame -----------------------------------------------------------
// The canonical devourer beacon layout (SA 57:42:75:05:d6:00, so existing SA
// matchers/tests recognise it) followed by a "TD" tag. Offsets below are into
// the RX-side Packet.Data (the 802.11 MPDU, radiotap already stripped):
// [0..1] FC (0x40 probe-req) [4..9] addr1 (broadcast)
// [10..15] addr2 = SA [24..] the TD tag
static const uint8_t kSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00};
static constexpr size_t kHdrLen = 24; // 802.11 header up to the body
// 'T''D' ver cls seq[4] burst[4] tx_tsf[8]. The 8-byte TX-TSF stamp lets the
// TSF-sync RX measure the TX↔RX crystal drift (0 when the TX can't read TSF).
// v2 appends host_ns[8] — the transmitter's steady_clock at the send_packet
// call, so a witness can fit air-arrival directly against the HOST clock (the
// quantity a host-side slot scheduler actually controls), uncontaminated by
// the ReadTsf control-read round-trip. v1 frames stay parseable.
static constexpr size_t kTagLen = 20;
static constexpr size_t kTagLenV2 = 28;
static constexpr size_t kMinData = kHdrLen + kTagLen;
// Build a full TX buffer: [radiotap for this class][802.11 header][TD tag].
// A nonzero host_ns selects the v2 tag.
inline std::vector<uint8_t> build_frame(const std::vector<uint8_t>& radiotap,
Class cls, uint32_t seq, uint32_t burst,
uint64_t tx_tsf = 0,
uint64_t host_ns = 0) {
static const uint8_t hdr[kHdrLen] = {
0x40, 0x00, 0x00, 0x00, // FC + duration
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, // addr1 broadcast
0x57, 0x42, 0x75, 0x05, 0xd6, 0x00, // addr2 = SA
0x57, 0x42, 0x75, 0x05, 0xd6, 0x00, // addr3
0x80, 0x00}; // seq ctl
const size_t tag_len = host_ns ? kTagLenV2 : kTagLen;
std::vector<uint8_t> f;
f.reserve(radiotap.size() + kHdrLen + tag_len);
f.insert(f.end(), radiotap.begin(), radiotap.end());
f.insert(f.end(), hdr, hdr + kHdrLen);
uint8_t tag[kTagLenV2] = {
'T', 'D', static_cast<uint8_t>(host_ns ? 2 : 1), static_cast<uint8_t>(cls),
static_cast<uint8_t>(seq), static_cast<uint8_t>(seq >> 8),
static_cast<uint8_t>(seq >> 16), static_cast<uint8_t>(seq >> 24),
static_cast<uint8_t>(burst), static_cast<uint8_t>(burst >> 8),
static_cast<uint8_t>(burst >> 16),static_cast<uint8_t>(burst >> 24)};
for (int i = 0; i < 8; ++i) tag[12 + i] = static_cast<uint8_t>(tx_tsf >> (8 * i));
for (int i = 0; i < 8; ++i) tag[20 + i] = static_cast<uint8_t>(host_ns >> (8 * i));
f.insert(f.end(), tag, tag + tag_len);
return f;
}
struct Parsed {
bool ok = false;
uint8_t ver = 0;
Class cls = Class::Bulk;
uint32_t seq = 0;
uint32_t burst = 0;
uint64_t tx_tsf = 0;
uint64_t host_ns = 0; // v2 only: TX host steady_clock at send (0 on v1)
};
// Parse an RX Packet.Data span (802.11 MPDU) into a TD tag, if it is one of ours.
inline Parsed parse_frame(const uint8_t* data, size_t len) {
Parsed p;
if (len < kMinData) return p;
if (std::memcmp(data + 10, kSa, 6) != 0) return p; // not our SA
const uint8_t* t = data + kHdrLen;
if (t[0] != 'T' || t[1] != 'D') return p;
p.ver = t[2];
p.cls = static_cast<Class>(t[3]);
p.seq = static_cast<uint32_t>(t[4]) | (static_cast<uint32_t>(t[5]) << 8) |
(static_cast<uint32_t>(t[6]) << 16) | (static_cast<uint32_t>(t[7]) << 24);
p.burst = static_cast<uint32_t>(t[8]) | (static_cast<uint32_t>(t[9]) << 8) |
(static_cast<uint32_t>(t[10]) << 16) | (static_cast<uint32_t>(t[11]) << 24);
for (int i = 0; i < 8; ++i)
p.tx_tsf |= static_cast<uint64_t>(t[12 + i]) << (8 * i);
if (p.ver >= 2 && len >= kHdrLen + kTagLenV2)
for (int i = 0; i < 8; ++i)
p.host_ns |= static_cast<uint64_t>(t[20 + i]) << (8 * i);
p.ok = true;
return p;
}
// --- TSF clock: a running host↔hardware-TSF least-squares fit ---------------
// Every RX frame carries a hardware TSF (rx_pkt_attrib::tsfl, latched in the MAC
// at receive) and a host time (when our callback ran). The host time is noisy
// (USB batching + scheduling, ~1 ms RMS on Jaguar1); the TSF is not. Fitting
// host = a·tsf + b over many frames averages the noise out, so evaluating the
// line at a marker's tsf gives a de-jittered host time for that marker — the
// precise schedule anchor. All state in offset coords (relative to the first
// sample) to keep the sums numerically well-conditioned.
struct TsfClock {
bool init = false;
double x0 = 0, y0 = 0;
long long n = 0;
double sx = 0, sy = 0, sxx = 0, sxy = 0;
int64_t hi = 0; // 32→64-bit tsf reconstruction (low word wraps)
uint32_t plo = 0;
int64_t recon(uint32_t lo) {
if (init && lo < plo) hi += (1LL << 32);
plo = lo;
return hi + lo;
}
// Feed one frame; returns the reconstructed 64-bit tsf (µs).
int64_t add(uint32_t tsfl, int64_t host_ns) {
int64_t t = recon(tsfl);
if (!init) { x0 = (double)t; y0 = (double)host_ns; init = true; }
double xi = (double)t - x0, yi = (double)host_ns - y0;
++n; sx += xi; sy += yi; sxx += xi * xi; sxy += xi * yi;
return t;
}
bool ready() const { return n >= 16; }
// The de-jittered host time (ns) for a given reconstructed tsf.
double host_at(int64_t t) const {
double den = (double)n * sxx - sx * sx;
if (den == 0) return y0;
double a = ((double)n * sxy - sx * sy) / den;
double b = (sy - a * sx) / (double)n;
return y0 + a * ((double)t - x0) + b;
}
};
// --- Schedule ---------------------------------------------------------------
enum class Phase { NB, WIDE };
inline int64_t wall_ms() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
}
struct Schedule {
int nb_ms = 100;
int wide_ms = 100;
ChannelWidth_t nb_w = CHANNEL_WIDTH_10;
ChannelWidth_t wide_w = CHANNEL_WIDTH_20;
int64_t epoch_ms = 0; // shared wall-clock anchor (both ends must match)
int period() const { return nb_ms + wide_ms; }
// Phase active at wall-clock instant `t_ms`, plus the burst (period) index.
struct At { Phase phase; int64_t burst; int ms_into_phase; };
At at(int64_t t_ms) const {
int64_t rel = t_ms - epoch_ms;
int64_t burst = rel >= 0 ? rel / period() : (rel - period() + 1) / period();
int pos = static_cast<int>(((rel % period()) + period()) % period());
if (pos < nb_ms) return {Phase::NB, burst, pos};
return {Phase::WIDE, burst, pos - nb_ms};
}
ChannelWidth_t width(Phase p) const { return p == Phase::NB ? nb_w : wide_w; }
};
// --- Config (env) -----------------------------------------------------------
enum class Role { Tx, RxSync, RxCamp };
enum class Sync { WallClock, Marker, Tsf };
struct Config {
Role role = Role::Tx;
Sync sync = Sync::WallClock;
Schedule sched;
ChannelWidth_t camp_w = CHANNEL_WIDTH_10; // rx-camp fixed width
int guard_ms = 20; // rx-sync: switch this early
int gap_us = 500; // tx: intra-burst inter-frame gap
devourer::TxMode crit_rate; // default 6M
devourer::TxMode bulk_rate; // default MCS7
uint8_t channel = 36;
};
inline ChannelWidth_t width_of(int mhz) {
switch (mhz) {
case 5: return CHANNEL_WIDTH_5;
case 10: return CHANNEL_WIDTH_10;
case 40: return CHANNEL_WIDTH_40;
default: return CHANNEL_WIDTH_20;
}
}
inline int mhz_of(ChannelWidth_t w) {
switch (w) {
case CHANNEL_WIDTH_5: return 5;
case CHANNEL_WIDTH_10: return 10;
case CHANNEL_WIDTH_40: return 40;
default: return 20;
}
}
inline int env_int(const char* n, int dflt) {
const char* e = std::getenv(n);
return (e && *e) ? std::atoi(e) : dflt;
}
inline Config config_from_env() {
Config c;
if (const char* r = std::getenv("DEVOURER_TDMA_ROLE")) {
std::string s(r);
if (s == "rx-sync") c.role = Role::RxSync;
else if (s == "rx-camp") c.role = Role::RxCamp;
else c.role = Role::Tx;
}
if (const char* s = std::getenv("DEVOURER_TDMA_SYNC")) {
std::string v(s);
c.sync = v == "marker" ? Sync::Marker
: v == "tsf" ? Sync::Tsf
: Sync::WallClock;
}
c.sched.nb_w = width_of(env_int("DEVOURER_TDMA_NB", 10));
c.sched.wide_w = width_of(env_int("DEVOURER_TDMA_WIDE", 20));
c.sched.nb_ms = env_int("DEVOURER_TDMA_NB_MS", 100);
c.sched.wide_ms = env_int("DEVOURER_TDMA_WIDE_MS", 100);
c.sched.epoch_ms = env_int("DEVOURER_TDMA_EPOCH_MS", 0);
c.camp_w = width_of(env_int("DEVOURER_TDMA_CAMP", 10));
c.guard_ms = env_int("DEVOURER_TDMA_GUARD_MS", 20);
c.gap_us = env_int("DEVOURER_TDMA_GAP_US", 500);
c.channel = static_cast<uint8_t>(env_int("DEVOURER_CHANNEL", 36));
const char* cr = std::getenv("DEVOURER_TDMA_CRIT_RATE");
const char* br = std::getenv("DEVOURER_TDMA_BULK_RATE");
c.crit_rate = devourer::parse_tx_mode_str(cr && *cr ? cr : "6M");
c.bulk_rate = devourer::parse_tx_mode_str(br && *br ? br : "MCS7");
return c;
}
} // namespace tdma
+531
View File
@@ -0,0 +1,531 @@
// timesync — LTE-eNB-style over-the-air time distribution. One binary, one
// adapter, one role (compose scenarios by running instances):
//
// DEVOURER_TSYNC_ROLE=master TX-only. Every DEVOURER_TSYNC_INTERVAL_MS,
// broadcast a sync beacon stamped with the chip's
// hardware TSF (ReadTsf(), reliable TX-side — no
// RX flood starving the control read). This is the
// eNB distributing its SFN.
// DEVOURER_TSYNC_ROLE=slave RX-only. Lock a running fit of the master's
// broadcast TSF against this slave's own per-frame
// hardware TSF, PREDICT each beacon before it
// arrives, and emit the prediction error — how
// tightly this UE tracks the eNB. No host clock,
// no GPS.
//
// Run a master + two slaves and join the slaves' {"ev":"timesync.lock"} streams
// on `seq`: pred_master_A vs pred_master_B is the inter-UE sync error, measured
// without either slave touching a wall clock (tests/timesync_demo.sh).
//
// UPLINK TIMING ADVANCE (DEVOURER_TSYNC_UPLINK=1, roles master + ue) is the LTE
// closed-loop extension — a full-duplex master phase-measures each UE uplink
// against its TSF slot grid and feeds back a timing advance. It is EXPERIMENTAL:
// the control math converges in the headless selftest and the full-duplex
// plumbing works on-air (arrivals cluster tightly, ~±0.2 ms with a FIXED TA),
// but the closed loop does NOT converge on the bench — a fixed-TA authority test
// shows the TA shifts the UE's send-CALL time yet not the master-measured
// arrival phase. Root cause: under full-duplex, send_packet queues the frame and
// the chip airs it on its own schedule, so userspace call-timing has no
// sub-slot control over air departure (plus this bench has only one clean
// full-duplex Jaguar2/3 adapter; the 8822E desenses its RX in TX+RX). See
// docs and tests/timesync_ta_demo.sh.
//
// See timesync.h for the fit + env knobs. Metrics are JSONL on stdout.
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <mutex>
#include <thread>
#include <vector>
#if defined(_MSC_VER)
#include <libusb.h>
#elif defined(__MINGW32__) || defined(__MINGW64__)
#include <libusb-1.0/libusb.h>
#elif defined(__APPLE__) || defined(__ANDROID__)
#include <libusb.h>
#else
#include <libusb-1.0/libusb.h>
#endif
#include "DeviceSession.h"
#include "RadiotapBuilder.h"
#include "RxPacket.h"
#include "SignalStop.h"
#include "UsbOpen.h"
#include "WiFiDriver.h"
#include "env_config.h"
#include "logger.h"
#include "timesync.h"
#if defined(DEVOURER_HAVE_PCIE)
#include "PcieTransport.h"
#endif
#define USB_VENDOR_ID 0x0bda
static constexpr uint16_t kRealtekProductIds[] = {
0x8812, 0x0811, 0xa811, 0xb811, 0x8813,
};
static inline void sleep_ms(long ms) {
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
}
static void emit(const char* json) { std::fputs(json, stdout); std::fflush(stdout); }
// --- device open (mirrors examples/tdma/main.cpp) ---------------------------
static libusb_device_handle* open_device(
const std::shared_ptr<Logger>& logger, libusb_context** ctx,
std::shared_ptr<devourer::UsbDeviceLock>& lock) {
if (libusb_init(ctx) < 0) return nullptr;
libusb_set_option(*ctx, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_WARNING);
uint16_t vid = USB_VENDOR_ID, pid = 0;
if (const char* v = std::getenv("DEVOURER_VID")) vid = (uint16_t)strtoul(v, 0, 0);
if (const char* p = std::getenv("DEVOURER_PID")) pid = (uint16_t)strtoul(p, 0, 0);
libusb_device_handle* h = nullptr;
for (uint16_t p : kRealtekProductIds) {
if (pid != 0 && p != pid) continue;
h = libusb_open_device_with_vid_pid(*ctx, vid, p);
if (h) break;
}
if (!h && pid != 0) h = libusb_open_device_with_vid_pid(*ctx, vid, pid);
if (!h) { logger->error("no device {:04x}:{:04x}", vid, pid); return nullptr; }
if (devourer::claim_interface_then_reset(h, devourer::find_wifi_interface(h), logger, std::getenv("DEVOURER_SKIP_RESET") == nullptr, lock) != 0) {
logger->error("claim failed (busy?)");
return nullptr;
}
return h;
}
// --- MASTER (eNB): broadcast the hardware TSF -------------------------------
// A compact raw 802.11 beacon MPDU (canonical SA/BSSID) matching the
// bench-validated tests/beacon_tbtt.cpp beacon. The 8-byte timestamp is left
// zero; the MAC inserts the hardware TSF at each TBTT. A full kernel-AP body
// (SSID + rates + DS + TIM + ...) also inserts the TSF correctly
// (tests/beacon_fullbody.cpp) — this stays compact only because the timesync
// demo needs no extra IEs.
static std::vector<uint8_t> build_std_beacon(int interval_tu) {
return {
0x80, 0x00, 0x00, 0x00, // FC beacon + dur
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, // addr1 broadcast
0x57, 0x42, 0x75, 0x05, 0xd6, 0x00, // addr2 = SA (canonical)
0x57, 0x42, 0x75, 0x05, 0xd6, 0x00, // addr3 = BSSID
0x00, 0x00, // seq
0, 0, 0, 0, 0, 0, 0, 0, // timestamp (HW fills)
static_cast<uint8_t>(interval_tu & 0xff),
static_cast<uint8_t>((interval_tu >> 8) & 0xff), // beacon interval
0x00, 0x00, // capability
0x00, 0x03, 'T', 'B', 'T', // SSID IE
0x01, 0x01, 0x82}; // supported rates (1M)
}
static void run_master(IRadio* dev, const timesync::Config& c) {
dev->InitWrite(SelectedChannel{c.channel, 0, CHANNEL_WIDTH_20});
sleep_ms(2000);
if (c.hwbeacon) {
// Hardware-timed, hardware-TSF-stamped beacon at TBTT — no software send loop,
// no ReadTsf jitter. The MAC inserts the live TSF into the beacon at TX.
if (c.no_csma) {
// The master owns the channel: disable EDCCA so the beacon airs exactly on
// the TBTT schedule (no CSMA backoff). Collapses the downlink residual from
// ~hundreds of µs to sub-µs even on a crowded channel.
dev->SetCcaMode(true);
fprintf(stderr, "timesync master: CSMA/EDCCA disabled (master owns channel)\n");
}
auto b = build_std_beacon(c.interval_ms > 0 ? c.interval_ms * 1000 / 1024 : 100);
bool ok = dev->StartBeacon(b.data(), b.size(),
c.interval_ms > 0 ? c.interval_ms * 1000 / 1024 : 100);
fprintf(stderr, "timesync master(HW beacon): StartBeacon -> %s, ch%d\n",
ok ? "OK" : "UNSUPPORTED", c.channel);
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(c.secs);
while (!g_devourer_should_stop) {
sleep_ms(200);
if (c.secs && std::chrono::steady_clock::now() >= deadline) break;
}
return;
}
const auto rt = devourer::build_stream_radiotap(c.rate);
fprintf(stderr, "timesync master: ch%d, sync beacon every %d ms\n", c.channel,
c.interval_ms);
uint32_t seq = 0;
auto next_stat = std::chrono::steady_clock::now() + std::chrono::seconds(1);
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(c.secs);
while (!g_devourer_should_stop) {
// Stamp with the master's hardware TSF at send time. TX-side ReadTsf() is
// reliable (no bulk-IN flood), unlike on a busy receiver.
uint64_t tsf = dev->ReadTsf();
auto f = tdma::build_frame(rt, tdma::Class::Marker, seq++, 0, tsf);
dev->send_packet(f.data(), f.size());
if (std::chrono::steady_clock::now() >= next_stat) {
next_stat += std::chrono::seconds(1);
char buf[160];
std::snprintf(buf, sizeof(buf),
"{\"ev\":\"timesync.master\",\"beacons\":%u,\"tsf\":%llu}\n",
seq, (unsigned long long)tsf);
emit(buf);
}
if (c.secs && std::chrono::steady_clock::now() >= deadline) break;
sleep_ms(c.interval_ms);
}
fprintf(stderr, "timesync master: %u beacons sent\n", seq);
}
// --- SLAVE (UE): lock to the master, predict each beacon --------------------
static timesync::Recon g_recon;
static timesync::LinFit g_fit;
static std::mutex g_mu;
static uint64_t g_beacons = 0; // master frames heard
static uint64_t g_predicted = 0; // frames predicted (fit was ready)
static double g_resid_ss = 0; // Σ resid² (µs²), for RMS
static double g_resid_max = 0;
static bool g_hwbeacon = false;
static void slave_cb(const Packet& p) {
uint64_t master_tsf; uint32_t seq;
if (g_hwbeacon) {
// Standard 802.11 beacon: canonical SA at addr2, and the master's LIVE
// hardware TSF is the 8-byte timestamp field (MPDU offset 24). No TD tag.
static const uint8_t kSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00};
if (p.Data.size() < 32 || p.RxAtrib.crc_err) return;
if ((p.Data[0] & 0xfc) != 0x80) return; // beacon subtype
if (std::memcmp(p.Data.data() + 10, kSa, 6) != 0) return; // our master
master_tsf = 0;
for (int i = 0; i < 8; ++i) master_tsf |= (uint64_t)p.Data[24 + i] << (8 * i);
seq = (uint32_t)(p.RxAtrib.seq_num);
} else {
auto pr = tdma::parse_frame(p.Data.data(), p.Data.size());
if (!pr.ok || p.RxAtrib.crc_err) return;
if (pr.cls != tdma::Class::Marker || pr.tx_tsf == 0) return; // sync beacons only
master_tsf = pr.tx_tsf; seq = pr.seq;
}
std::lock_guard<std::mutex> lk(g_mu);
double local_us = (double)g_recon(p.RxAtrib.tsfl);
double master_us = (double)master_tsf;
++g_beacons;
// Predict this beacon's master TSF from the fit built on PRIOR beacons,
// evaluated at this beacon's clean local hardware TSF. resid = lock error.
if (g_fit.ready()) {
double pred = g_fit.at(local_us);
double resid = master_us - pred;
++g_predicted;
g_resid_ss += resid * resid;
if (std::fabs(resid) > g_resid_max) g_resid_max = std::fabs(resid);
char buf[256];
std::snprintf(buf, sizeof(buf),
"{\"ev\":\"timesync.lock\",\"seq\":%u,\"master_tsf\":%llu,"
"\"local_tsf\":%llu,\"pred_master\":%.1f,\"resid_us\":%.2f,"
"\"ppm\":%.2f}\n",
seq, (unsigned long long)master_tsf,
(unsigned long long)(int64_t)local_us, pred, resid, g_fit.ppm());
emit(buf);
}
g_fit.add(local_us, master_us);
}
static void run_slave(IRadio* dev, const timesync::Config& c) {
std::thread rx([&] {
dev->Init(slave_cb, SelectedChannel{c.channel, 0, CHANNEL_WIDTH_20});
});
fprintf(stderr, "timesync slave: ch%d, locking to master beacons\n", c.channel);
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(c.secs);
while (!g_devourer_should_stop) {
sleep_ms(100);
if (c.secs && std::chrono::steady_clock::now() >= deadline) break;
}
dev->StopRxLoop();
rx.join();
std::lock_guard<std::mutex> lk(g_mu);
double rms = g_predicted ? std::sqrt(g_resid_ss / (double)g_predicted) : 0;
fprintf(stderr,
"\n=== timesync slave summary ===\n"
" beacons heard : %llu\n"
" predicted : %llu\n"
" lock error (RMS) : %.2f us max %.2f us\n"
" master-vs-slave ppm: %.2f\n",
(unsigned long long)g_beacons, (unsigned long long)g_predicted, rms,
g_resid_max, g_fit.ppm());
}
// --- UPLINK TIMING ADVANCE (LTE TA), full-duplex ---------------------------
// The master broadcasts beacons AND phase-measures each UE uplink against its
// own TSF slot grid (arrival tsfl mod slot_us — reliable per-frame, no ReadTsf
// which the RX loop would starve), integrating a per-UE timing-advance it feeds
// back. The UE schedules its uplinks off the beacon-arrival cadence (a seq↔host
// fit — rate-locked to the master) minus the TA. The loop drives each uplink's
// arrival onto the master's slot boundary; open-loop it drifts across the slot
// at the crystal offset. Both nodes are full-duplex (InitWrite + StartRxLoop).
static int64_t steady_us() {
return std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
}
// Master TA state.
static timesync::Recon g_m_recon;
static std::atomic<double> g_ta_us{0};
static std::mutex g_m_mu;
static uint64_t g_uplinks = 0;
static double g_phase_ss = 0, g_phase_max = 0;
static uint64_t g_phase_n = 0;
static double g_slot_us = 20000, g_ta_gain = 0.3;
static bool g_ta_fixed = false; // DEVOURER_TSYNC_TA_FIXED: hold TA constant (authority test)
static void master_ta_cb(const Packet& p) {
auto pr = tdma::parse_frame(p.Data.data(), p.Data.size());
if (!pr.ok || p.RxAtrib.crc_err) return;
if (static_cast<uint8_t>(pr.cls) != timesync::kClassUplink) return;
std::lock_guard<std::mutex> lk(g_m_mu);
double arrival = (double)g_m_recon(p.RxAtrib.tsfl);
double phase = arrival - std::round(arrival / g_slot_us) * g_slot_us; // (-slot/2, slot/2]
double ta = g_ta_us.load();
if (!g_ta_fixed) {
ta += g_ta_gain * phase; // late (phase>0) → more TA → UE earlier
if (ta > g_slot_us) ta = g_slot_us; // clamp to one slot (no wrap — a hard
if (ta < -g_slot_us) ta = -g_slot_us; // mod jump kicks the loop)
g_ta_us.store(ta);
}
++g_uplinks; g_phase_ss += phase * phase; ++g_phase_n;
if (std::fabs(phase) > g_phase_max) g_phase_max = std::fabs(phase);
char buf[224];
std::snprintf(buf, sizeof(buf),
"{\"ev\":\"timesync.ta\",\"seq\":%u,\"n\":%llu,\"phase_us\":%.2f,"
"\"ta_us\":%.2f}\n",
pr.seq, (unsigned long long)g_uplinks, phase, ta);
emit(buf);
}
static void run_master_ta(IRadio* dev, const timesync::Config& c) {
g_slot_us = c.slot_ms * 1000.0; g_ta_gain = c.ta_gain;
if (const char* f = std::getenv("DEVOURER_TSYNC_TA_FIXED")) {
g_ta_fixed = true; g_ta_us.store(std::atof(f)); // authority test: hold TA constant
}
dev->InitWrite(SelectedChannel{c.channel, 0, CHANNEL_WIDTH_20});
std::thread rx([&] { dev->StartRxLoop(master_ta_cb); });
sleep_ms(2000);
const auto rt = devourer::build_stream_radiotap(c.rate);
fprintf(stderr, "timesync master(TA): ch%d beacons=%dms slot=%dms gain=%.2f\n",
c.channel, c.interval_ms, c.slot_ms, c.ta_gain);
uint32_t seq = 0;
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(c.secs);
while (!g_devourer_should_stop) {
// The beacon carries the current TA in its tx_tsf field (unused for cadence),
// so every beacon the UE decodes delivers the latest TA — one send per slot,
// no separate frame to drop.
int64_t ta_i = (int64_t)std::llround(g_ta_us.load()); // signed µs → 8-byte tag
uint64_t ta_b; std::memcpy(&ta_b, &ta_i, 8);
auto b = tdma::build_frame(rt, tdma::Class::Marker, seq++, 0, ta_b);
dev->send_packet(b.data(), b.size());
if (c.secs && std::chrono::steady_clock::now() >= deadline) break;
sleep_ms(c.interval_ms);
}
dev->StopRxLoop(); rx.join();
std::lock_guard<std::mutex> lk(g_m_mu);
double rms = g_phase_n ? std::sqrt(g_phase_ss / (double)g_phase_n) : 0;
fprintf(stderr,
"\n=== timesync master(TA) summary ===\n"
" uplinks measured : %llu\n"
" arrival phase (all): RMS %.2f us max %.2f us\n"
" final TA : %.2f us\n",
(unsigned long long)g_uplinks, rms, g_phase_max, g_ta_us.load());
}
// UE state. Event-driven off actual beacon arrivals (rate-locked to the master),
// so the TA directly and unambiguously shifts the uplink's arrival phase.
static std::atomic<int64_t> g_ue_beacon_us{0}; // steady_us of the last beacon
static std::atomic<uint32_t> g_ue_beacon_seq{0};
static std::atomic<bool> g_ue_have{false};
static std::atomic<double> g_ue_ta_us{0};
static std::atomic<uint64_t> g_ue_beacons{0}, g_ue_tx{0};
static void ue_cb(const Packet& p) {
auto pr = tdma::parse_frame(p.Data.data(), p.Data.size());
if (!pr.ok || p.RxAtrib.crc_err) return;
if (pr.cls == tdma::Class::Marker) {
g_ue_beacon_us.store(steady_us(), std::memory_order_relaxed);
g_ue_beacon_seq.store(pr.seq, std::memory_order_relaxed);
g_ue_have.store(true, std::memory_order_relaxed);
g_ue_beacons.fetch_add(1, std::memory_order_relaxed);
int64_t ta_i; uint64_t ta_b = pr.tx_tsf; std::memcpy(&ta_i, &ta_b, 8); // TA rides the beacon
g_ue_ta_us.store((double)ta_i, std::memory_order_relaxed);
}
}
static void run_ue(IRadio* dev, const timesync::Config& c) {
dev->InitWrite(SelectedChannel{c.channel, 0, CHANNEL_WIDTH_20});
std::thread rx([&] { dev->StartRxLoop(ue_cb); });
sleep_ms(2000);
const auto rt = devourer::build_stream_radiotap(c.rate);
const int64_t slot_us = (int64_t)c.slot_ms * 1000;
// --- Hardware-beacon uplink (fine-steered) — DEVOURER_TSYNC_HWBEACON --------
// The send_packet uplink below has no sub-slot air-departure control (the chip
// airs the queued frame on its own schedule), so the TA loop cannot converge
// (the doc's fixed-TA authority test: TA shifts the send-CALL time, not the
// measured arrival). Instead air the uplink from the BEACON engine —
// hardware-timed at the UE's TBTT — and steer that TBTT with
// AdjustBeaconTimingFine per the master's TA. The uplink is a tdma Uplink frame
// (not a beacon FC), which StartBeacon stores in the rsvd page and the engine
// airs verbatim at each TBTT, so the master's existing Uplink measurement path
// sees it unchanged. The UE owns its slot too, so drop EDCCA (like the master)
// for a crisp TBTT departure. This is the closed-loop LTE timing advance.
if (c.hwbeacon) {
dev->SetCcaMode(true);
int interval_tu = c.interval_ms * 1000 / 1024;
if (interval_tu < 1) interval_tu = 1;
auto up = tdma::build_frame(rt, static_cast<tdma::Class>(timesync::kClassUplink),
0, 0, 0);
bool ok = dev->StartBeacon(up.data(), up.size(), interval_tu);
fprintf(stderr, "timesync ue(HW-beacon uplink): StartBeacon(%d TU) -> %s; "
"steering via AdjustBeaconTimingFine\n",
interval_tu, ok ? "OK" : "FAILED");
if (!ok) { dev->StopRxLoop(); rx.join(); return; }
double applied_ta = 0; // TA already actuated into the TBTT (cumulative)
uint64_t steers = 0;
auto nstat = std::chrono::steady_clock::now() + std::chrono::seconds(1);
auto deadline2 = std::chrono::steady_clock::now() + std::chrono::seconds(c.secs);
while (!g_devourer_should_stop) {
// Apply the TA increment: more TA => UE transmits earlier => advance (<0 µs).
double ta = g_ue_ta_us.load(std::memory_order_relaxed);
double delta = ta - applied_ta;
if (std::fabs(delta) >= 3.0) {
dev->AdjustBeaconTimingFine(-(int32_t)std::llround(delta));
applied_ta = ta; // integrating loop: master measures the real arrival
++steers; g_ue_tx.fetch_add(1, std::memory_order_relaxed);
}
if (std::chrono::steady_clock::now() >= nstat) {
nstat += std::chrono::seconds(1);
char buf[192];
std::snprintf(buf, sizeof(buf),
"{\"ev\":\"timesync.ue\",\"beacons\":%llu,\"steers\":%llu,"
"\"ta_us\":%.1f}\n",
(unsigned long long)g_ue_beacons.load(),
(unsigned long long)steers, ta);
emit(buf);
}
if (c.secs && std::chrono::steady_clock::now() >= deadline2) break;
sleep_ms(50); // rate-limit fine steers (each toggles EN_BCN_FUNCTION)
}
dev->StopRxLoop(); rx.join();
fprintf(stderr,
"\n=== timesync ue(HW-beacon) summary ===\n"
" beacons heard : %llu\n"
" fine steers : %llu\n"
" final TA : %.2f us\n",
(unsigned long long)g_ue_beacons.load(), (unsigned long long)steers,
g_ue_ta_us.load());
return;
}
fprintf(stderr, "timesync ue: ch%d, uplink one frame per beacon (TA-corrected)\n",
c.channel);
uint32_t done = 0; // last beacon seq we've already answered with an uplink
auto next_stat = std::chrono::steady_clock::now() + std::chrono::seconds(1);
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(c.secs);
while (!g_devourer_should_stop) {
if (std::chrono::steady_clock::now() >= next_stat) {
next_stat += std::chrono::seconds(1);
char buf[160];
std::snprintf(buf, sizeof(buf),
"{\"ev\":\"timesync.ue\",\"beacons\":%llu,\"tx\":%llu,\"ta_us\":%.1f}\n",
(unsigned long long)g_ue_beacons.load(),
(unsigned long long)g_ue_tx.load(), g_ue_ta_us.load());
emit(buf);
}
if (!g_ue_have.load(std::memory_order_relaxed)) { sleep_ms(5); continue; }
uint32_t s = g_ue_beacon_seq.load(std::memory_order_relaxed);
if (s == done) { sleep_ms(1); continue; } // wait for the next beacon (a slot tick)
done = s;
// Aim the uplink to ARRIVE one slot after this beacon (the next boundary),
// advanced by the master's TA. TA authority is direct: earlier send → earlier
// arrival → smaller measured phase.
int64_t send_at = g_ue_beacon_us.load(std::memory_order_relaxed) + slot_us -
(int64_t)g_ue_ta_us.load(std::memory_order_relaxed);
int64_t wait = send_at - steady_us();
if (wait > 0 && wait < 2 * slot_us)
std::this_thread::sleep_for(std::chrono::microseconds(wait));
else if (wait >= 2 * slot_us)
continue; // absurd — skip this slot
auto f = tdma::build_frame(rt, static_cast<tdma::Class>(timesync::kClassUplink),
s, 0, 0);
dev->send_packet(f.data(), f.size());
g_ue_tx.fetch_add(1, std::memory_order_relaxed);
if (c.secs && std::chrono::steady_clock::now() >= deadline) break;
}
dev->StopRxLoop(); rx.join();
fprintf(stderr,
"\n=== timesync ue summary ===\n"
" beacons heard : %llu\n"
" uplinks sent : %llu\n"
" final TA : %.2f us\n",
(unsigned long long)g_ue_beacons.load(),
(unsigned long long)g_ue_tx.load(), g_ue_ta_us.load());
}
int main() {
auto logger = std::make_shared<Logger>();
apply_logging_env(*logger);
install_devourer_signal_handlers();
// Owns the teardown order (device -> interface -> handle -> context; see
// DeviceSession.h): the role loops below only return once their RX thread is
// joined, so the adapter is released with nothing in flight. On the PCIe
// path there is no handle to adopt — the transport dies with the device.
devourer::DeviceSession session{logger};
timesync::Config c = timesync::config_from_env();
g_hwbeacon = c.hwbeacon; // slave reads the standard 802.11 beacon timestamp
WiFiDriver wifi(logger);
std::unique_ptr<IRadio> owned_device;
libusb_context* ctx = nullptr;
/* DEVOURER_PCIE_BDF=0000:01:00.0 — drive a PCIe adapter (RTL8821CE) through
* the vfio transport instead of libusb (DEVOURER_PCIE builds; mirrors the
* RX/TX demos). A PCIe master collapses the software-downlink stamp→air
* floor from ~93 µs (USB submit path) to ~12 µs (the MAC TX pipeline). */
const char* pcie_bdf = std::getenv("DEVOURER_PCIE_BDF");
#if defined(DEVOURER_HAVE_PCIE)
if (pcie_bdf) {
auto transport = devourer::PcieTransport::Open(pcie_bdf, logger);
if (!transport) return 1;
owned_device =
wifi.CreateRadioPcie(std::move(transport), devourer_config_from_env());
} else
#endif
{
if (pcie_bdf) {
logger->error("DEVOURER_PCIE_BDF set but this build has DEVOURER_PCIE=OFF");
return 1;
}
std::shared_ptr<devourer::UsbDeviceLock> lock;
auto* handle = open_device(logger, &ctx, lock);
session.adopt_context(ctx);
if (!handle) return 1;
session.adopt_handle(handle, devourer::find_wifi_interface(handle));
session.adopt_lock(lock);
owned_device =
wifi.CreateRadio(handle, ctx, lock, devourer_config_from_env());
}
if (!owned_device) { logger->error("no driver for this chip"); return 1; }
// 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 dev = session.device();
if (c.role == timesync::Role::Ue) run_ue(dev, c);
else if (c.role == timesync::Role::Master && c.uplink) run_master_ta(dev, c);
else if (c.role == timesync::Role::Master) run_master(dev, c);
else run_slave(dev, c);
session.close();
return 0;
}
+124
View File
@@ -0,0 +1,124 @@
// timesync — LTE-eNB-style over-the-air time distribution: one MASTER broadcasts
// its hardware TSF periodically (a "sync beacon"), and any number of SLAVES lock
// their notion of the master clock to it from the beacons alone — no GPS at the
// slaves, only the master holds a reference. This is the 802.11 analog of an eNB
// distributing frame timing to UEs: the master's TSF is the SFN, each slave a UE
// slaving to it.
//
// A slave relates the master's broadcast TSF to its OWN per-frame hardware TSF
// (rx_pkt_attrib::tsfl) with a running least-squares fit — both are clean
// MAC-latched microsecond clocks, so the fit residual is the true lock quality
// (the ~sub-µs floor of the dual-RX TSF correlation), NOT the ~1 ms host-callback
// jitter. Each slave PREDICTS the next beacon's master TSF from its fit; the
// prediction error is how tightly it tracks the eNB. Two slaves predicting the
// SAME beacon (matched by seq) agree to within their combined residual — the
// inter-UE sync error, measured without either slave reading a host clock.
//
// App-level only (no WiFiDriver core changes); reuses the TD frame tag + SA from
// the tdma example (examples/tdma/tdma.h) so one marker layout serves both.
#pragma once
#include <cstdint>
#include <cstdlib>
#include <string>
#include "RadiotapBuilder.h" // devourer::TxMode / parse_tx_mode_str
#include "tdma.h" // tdma::build_frame / parse_frame / kSa / Class
namespace timesync {
// 32→64-bit TSF reconstruction (the MAC latches only the low 32 bits per frame;
// it wraps every ~71 min). One per clock source.
struct Recon {
int64_t hi = 0;
uint32_t plo = 0;
bool init = false;
int64_t operator()(uint32_t lo) {
if (init && lo < plo) hi += (1LL << 32);
plo = lo;
init = true;
return hi + lo;
}
};
// Incremental ordinary-least-squares fit y = a·x + b, offset-normalized to the
// first sample so the sums stay well within double precision over a long run.
// Here x = the slave's local TSF (µs), y = the master's broadcast TSF (µs).
struct LinFit {
bool init = false;
double x0 = 0, y0 = 0;
long long n = 0;
double sx = 0, sy = 0, sxx = 0, sxy = 0;
void add(double x, double y) {
if (!init) { x0 = x; y0 = y; init = true; }
double xi = x - x0, yi = y - y0;
++n; sx += xi; sy += yi; sxx += xi * xi; sxy += xi * yi;
}
bool ready() const { return n >= 16; }
double slope() const {
double den = (double)n * sxx - sx * sx;
return den == 0 ? 1.0 : ((double)n * sxy - sx * sy) / den;
}
double intercept() const { return (sy - slope() * sx) / (double)n; }
// Predicted y at x.
double at(double x) const { return y0 + slope() * (x - x0) + intercept(); }
// Inverse: the x that yields a given y (for scheduling in the fitted domain).
double inverse(double y) const {
double a = slope();
return x0 + (a == 0 ? 0 : (y - y0 - intercept()) / a);
}
// Fitted crystal offset in ppm (slope = dy/dx).
double ppm() const { return (slope() - 1.0) * 1e6; }
};
// Uplink extends the downlink beacon with two more TD-tag class codes (reusing
// tdma::build_frame; parse_frame round-trips any class value, so tdma.h is
// untouched). Uplink = UE→master frame the master phase-measures; Ta = the
// master→UE timing-advance correction.
static constexpr uint8_t kClassUplink = 3;
static constexpr uint8_t kClassTa = 4;
// --- Config (env) -----------------------------------------------------------
enum class Role { Master, Slave, Ue };
struct Config {
Role role = Role::Slave;
int interval_ms = 100; // master: sync-beacon period (LTE beacon ≈ 100 ms)
int secs = 0; // 0 = run until signalled
uint8_t channel = 36;
devourer::TxMode rate; // master beacon rate (default 6M — must be heard)
// Uplink timing-advance (full-duplex, DEVOURER_TSYNC_UPLINK=1):
bool uplink = false; // master: measure UE uplinks + feed back TA. ue: TX uplinks
bool hwbeacon = false; // master: HW-TBTT beacon (StartBeacon); slave: read 802.11 TS
bool no_csma = true; // master: disable EDCCA by DEFAULT (master owns the channel,
// beacon airs exactly at TBTT -> sub-µs); DEVOURER_TSYNC_CSMA=1 keeps CSMA
int slot_ms = 20; // uplink slot grid on the master TSF (a TDMA slot)
double ta_gain = 0.3; // master TA integrator gain (0..1; error fraction/step)
};
inline int env_int(const char* n, int dflt) {
const char* e = std::getenv(n);
return (e && *e) ? std::atoi(e) : dflt;
}
inline Config config_from_env() {
Config c;
if (const char* r = std::getenv("DEVOURER_TSYNC_ROLE")) {
std::string s(r);
c.role = (s == "master") ? Role::Master : (s == "ue") ? Role::Ue : Role::Slave;
}
c.interval_ms = env_int("DEVOURER_TSYNC_INTERVAL_MS", 100);
c.secs = env_int("DEVOURER_TSYNC_SECS", 0);
c.channel = static_cast<uint8_t>(env_int("DEVOURER_CHANNEL", 36));
c.uplink = std::getenv("DEVOURER_TSYNC_UPLINK") != nullptr;
c.hwbeacon = std::getenv("DEVOURER_TSYNC_HWBEACON") != nullptr;
c.no_csma = std::getenv("DEVOURER_TSYNC_CSMA") == nullptr; // on by default; opt out to keep CSMA
c.slot_ms = env_int("DEVOURER_TSYNC_SLOT_MS", 20);
if (const char* g = std::getenv("DEVOURER_TSYNC_TA_GAIN")) c.ta_gain = std::atof(g);
const char* rt = std::getenv("DEVOURER_TSYNC_RATE");
c.rate = devourer::parse_tx_mode_str(rt && *rt ? rt : "6M");
return c;
}
} // namespace timesync
File diff suppressed because it is too large Load Diff
+387
View File
@@ -0,0 +1,387 @@
/* txpower — reference consumer of the runtime TX-power API.
*
* Opens one adapter, brings it up for TX, prints the family's TxPowerCaps,
* then walks the requested knob sequence — a quarter-dB offset ramp
* (SetTxPowerOffsetQdb), a flat index (SetTxPowerIndexOverride), per-rate
* diffs (SetTxPowerRateDiffs), or both — echoing the applied qdB and a
* TxPowerState snapshot after every step. This is the shape of an adaptive-link
* controller's power leg: set, read back, observe saturation, react.
*
* Pure CLI configuration (no environment variables — the API is the point):
*
* --vid 0xNNNN --pid 0xNNNN adapter select (default: first Realtek PID)
* --channel N monitor channel for bring-up (default 36)
* --bw 20|40|80 bandwidth (default 20)
* --flat N force flat TXAGC index before the ramp (-1 clears)
* --offset-start Q offset ramp start, qdB (default: no ramp)
* --offset-stop Q offset ramp stop, qdB (default = start)
* --step-qdb Q ramp increment, qdB (default 4 = 1 dB)
* --step-ms N dwell per step, ms (default 500)
* --rate-diffs I,I,...,I 10 comma-separated qdB per rate
* (cck,legacy,m0..m7) or 'clear' to nullopt.
* Honoured where txpwr.caps reports
* rate_diffs=1; rate_diffs_hw=0 marks the
* software send-time fold (Kestrel)
* --flat-pulse N after --rate-diffs: force flat index N, dump
* state, then clear the override (-1) and dump
* state again — proves a flat override
* temporarily flattens the chip's per-rate
* table and the configured diffs come back once
* the override clears, all in one process
* --switch-channel N after the ramp: SetMonitorChannel(N) and re-dump
* state — proves the offset is sticky across a
* full channel set (and re-folds against the new
* channel's per-rate table)
* --retune N after the ramp: FastRetune(N) and re-dump state
* — proves the hop path leaves TXAGC alone
* --thermal print a thermal snapshot with each state line
*
* Machine-readable output (one JSONL event per step, consumed by
* tests/txpwr_offset_regcheck.sh):
*
* {"ev":"txpwr.caps","supported":1,"max":63,"step_qdb":2,...}
* {"ev":"txpwr.state","flat":-1,"offset_qdb":-24,"steps":-12,"satlo":0,
* "sathi":0,"cck":28,"ofdm":34,"mcs7":30,"rb":1,"rate_diffs":0}
*/
#ifdef _WIN32
#define NOMINMAX
#endif
#if defined(__ANDROID__) || defined(_MSC_VER) || defined(__APPLE__)
#include <libusb.h>
#else
#include <libusb-1.0/libusb.h>
#endif
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <optional>
#include <string>
#include <thread>
#include "DeviceSession.h"
#include "SignalStop.h"
#include "ThermalStatus.h"
#include "TxPower.h"
#include "UsbOpen.h"
#include "WiFiDriver.h"
#include "caps_event.h"
#include "env_config.h"
#include "logger.h"
namespace {
/* Event sink for the demo's JSONL emissions (print_state is a free function)
* — points at the main() Logger's sink, set right after Logger construction. */
devourer::EventSink *g_ev = nullptr;
/* The Realtek PIDs the demos' open loop iterates; --pid narrows to one. */
const uint16_t kRealtekPids[] = {0x8812, 0x8813, 0x881a, 0x0811, 0xa811,
0x0820, 0x0821, 0x8822, 0x0120, 0x012d,
0xb82c, 0xc811, 0xc812, 0xa81a};
struct Args {
uint16_t vid = 0x0bda;
int pid = -1; /* -1 = iterate kRealtekPids */
int channel = 36;
int bw = 20;
int flat = -2; /* -2 = untouched, -1 = clear, >=0 = force */
int offset_start = 0;
int offset_stop = 0;
bool have_ramp = false;
int step_qdb = 4;
int step_ms = 500;
int switch_channel = -1;
int retune = -1;
bool thermal = false;
bool have_rate_diffs = false;
bool rate_diffs_clear = false;
devourer::TxRateDiffsQdb rate_diffs;
int flat_pulse = 0;
bool have_flat_pulse = false;
};
bool parse_int(const char *s, int &out) {
char *end = nullptr;
long v = std::strtol(s, &end, 0);
if (end == s || *end != '\0')
return false;
out = static_cast<int>(v);
return true;
}
bool parse_args(int argc, char **argv, Args &a) {
for (int i = 1; i < argc; ++i) {
const std::string k = argv[i];
auto next = [&](int &out) {
return i + 1 < argc && parse_int(argv[++i], out);
};
int v = 0;
if (k == "--vid" && next(v))
a.vid = static_cast<uint16_t>(v);
else if (k == "--pid" && next(v))
a.pid = v;
else if (k == "--channel" && next(a.channel))
;
else if (k == "--bw" && next(a.bw))
;
else if (k == "--flat" && next(a.flat))
;
else if (k == "--offset-start" && next(a.offset_start))
a.have_ramp = true;
else if (k == "--offset-stop" && next(a.offset_stop))
a.have_ramp = true;
else if (k == "--step-qdb" && next(a.step_qdb))
;
else if (k == "--step-ms" && next(a.step_ms))
;
else if (k == "--switch-channel" && next(a.switch_channel))
;
else if (k == "--retune" && next(a.retune))
;
else if (k == "--thermal")
a.thermal = true;
else if (k == "--rate-diffs") {
if (i + 1 >= argc)
return false;
const std::string val = argv[++i];
a.have_rate_diffs = true;
if (val == "clear") {
a.rate_diffs_clear = true;
} else {
/* Parse and range-check the 10 ints here so a typo fails before the
* chip is ever brought up (every other flag validates in parse_args).
* Range is the library's [-64, 63] — a bare strtol->int8_t cast would
* silently truncate (200 -> -56), so reject out-of-range with a real
* error rather than clamp-and-surprise. */
int v[10] = {0};
int n = 0;
const char *p = val.c_str();
char *end = nullptr;
while (n < 10) {
v[n++] = static_cast<int>(std::strtol(p, &end, 10));
if (*end != ',')
break;
p = end + 1;
}
if (n != 10 || *end != '\0') {
std::fprintf(stderr, "devourer [E] --rate-diffs wants 10 comma ints "
"(cck,legacy,m0..m7) or 'clear'\n");
return false;
}
for (int j = 0; j < 10; ++j) {
if (v[j] < -64 || v[j] > 63) {
std::fprintf(stderr, "devourer [E] --rate-diffs value %d out of "
"range [-64, 63]\n",
v[j]);
return false;
}
}
a.rate_diffs.cck = static_cast<int8_t>(v[0]);
a.rate_diffs.legacy = static_cast<int8_t>(v[1]);
for (int j = 0; j < 8; ++j)
a.rate_diffs.mcs[j] = static_cast<int8_t>(v[2 + j]);
}
} else if (k == "--flat-pulse" && next(a.flat_pulse))
a.have_flat_pulse = true;
else {
std::fprintf(stderr, "devourer [W] unknown/incomplete arg: %s\n",
k.c_str());
return false;
}
}
if (a.have_ramp && a.offset_stop == 0 && a.offset_start != 0)
a.offset_stop = a.offset_start;
return true;
}
ChannelWidth_t bw_enum(int bw) {
switch (bw) {
case 40:
return CHANNEL_WIDTH_40;
case 80:
return CHANNEL_WIDTH_80;
default:
return CHANNEL_WIDTH_20;
}
}
void print_state(IRadio *dev, bool with_thermal) {
const devourer::TxPowerState s = dev->GetTxPowerState();
devourer::Ev(*g_ev, "txpwr.state")
.f("flat", s.flat_index)
.f("offset_qdb", s.offset_qdb)
.f("steps", s.offset_steps)
.f("satlo", s.saturated_low ? 1 : 0)
.f("sathi", s.saturated_high ? 1 : 0)
.f("cck", s.cck_index)
.f("ofdm", s.ofdm_index)
.f("mcs7", s.mcs7_index)
.f("rb", s.hw_readback ? 1 : 0)
.f("rate_diffs", s.rate_diffs_custom ? 1 : 0);
if (with_thermal) {
const devourer::ThermalStatus t = dev->GetThermalStatus();
devourer::Ev ev(*g_ev, "thermal");
ev.t().f("raw", t.raw);
if (t.valid)
ev.f("baseline", t.baseline).f("delta", t.delta);
else
ev.f("baseline", nullptr);
ev.f("status", devourer::ThermalBucket(t));
}
}
} // namespace
int main(int argc, char **argv) {
Args a;
if (!parse_args(argc, argv, a))
return 2;
auto logger = std::make_shared<Logger>();
apply_logging_env(*logger); /* DEVOURER_LOG_LEVEL / DEVOURER_EVENTS / ... */
g_ev = &logger->events();
install_devourer_signal_handlers();
/* Owns the teardown order (device -> interface -> handle -> context; see
* DeviceSession.h). Each early return from here on unwinds whatever has been
* adopted so far. */
devourer::DeviceSession session{logger};
libusb_context *ctx = nullptr;
if (libusb_init(&ctx) < 0) {
logger->error("libusb_init failed");
return 1;
}
session.adopt_context(ctx);
libusb_device_handle *handle = nullptr;
if (a.pid >= 0) {
handle = libusb_open_device_with_vid_pid(ctx, a.vid,
static_cast<uint16_t>(a.pid));
} else {
for (uint16_t pid : kRealtekPids) {
handle = libusb_open_device_with_vid_pid(ctx, a.vid, pid);
if (handle)
break;
}
}
if (!handle) {
logger->error("no adapter found ({:04x}:{})", a.vid,
a.pid >= 0 ? "requested pid" : "any Realtek pid");
return 1;
}
std::shared_ptr<devourer::UsbDeviceLock> lock;
if (devourer::claim_interface_then_reset(handle, devourer::find_wifi_interface(handle), logger, true, lock) !=
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(lock);
WiFiDriver driver(logger);
std::unique_ptr<IRadio> owned_device =
driver.CreateRadio(handle, ctx, lock, devourer_config_from_env());
if (!owned_device) {
logger->error("CreateRadio failed (chip support not built?)");
return 1;
}
/* 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 dev = session.device();
devourer::emit_adapter_caps(*g_ev, dev);
const devourer::TxPowerCaps caps = dev->GetTxPowerCaps();
devourer::Ev(*g_ev, "txpwr.caps")
.f("supported", caps.supported ? 1 : 0)
.f("max", caps.index_max)
.f("step_qdb", caps.step_qdb)
.f("step_measured", caps.step_measured ? 1 : 0)
.f("min_qdb", caps.offset_min_qdb)
.f("max_qdb", caps.offset_max_qdb)
.f("rate_diffs", caps.rate_diffs ? 1 : 0)
.f("rate_diffs_hw", caps.rate_diffs_hw_table ? 1 : 0)
.f("rate_diffs_measured", caps.rate_diffs_measured ? 1 : 0);
if (!caps.supported) {
logger->error("TX-power API not wired for this family yet");
dev->Stop();
return 3;
}
dev->InitWrite(SelectedChannel{.Channel = static_cast<uint8_t>(a.channel),
.ChannelOffset = 0,
.ChannelWidth = bw_enum(a.bw)});
logger->info("brought up on ch{} bw{}", a.channel, a.bw);
/* Baseline state before any knob moves (the offset=0 parity reference). */
print_state(dev, a.thermal);
if (a.flat >= -1) {
dev->SetTxPowerIndexOverride(a.flat);
logger->info("flat index override -> {}", a.flat);
print_state(dev, a.thermal);
}
if (a.have_rate_diffs) {
if (a.rate_diffs_clear) {
const bool ok = dev->SetTxPowerRateDiffs(std::nullopt);
logger->info("rate-diffs clear -> {}", ok ? "ok" : "unsupported");
} else {
const bool ok = dev->SetTxPowerRateDiffs(a.rate_diffs);
logger->info("rate-diffs -> {}", ok ? "applied" : "unsupported");
}
print_state(dev, a.thermal);
}
if (a.have_flat_pulse) {
dev->SetTxPowerIndexOverride(a.flat_pulse);
logger->info("flat pulse -> {}", a.flat_pulse);
print_state(dev, a.thermal);
dev->SetTxPowerIndexOverride(-1);
logger->info("flat pulse cleared");
print_state(dev, a.thermal);
}
if (a.have_ramp) {
const int dir = (a.offset_stop >= a.offset_start) ? 1 : -1;
const int inc = (a.step_qdb > 0 ? a.step_qdb : 4) * dir;
for (int q = a.offset_start;
(dir > 0 ? q <= a.offset_stop : q >= a.offset_stop) &&
!g_devourer_should_stop;
q += inc) {
const int applied = dev->SetTxPowerOffsetQdb(q);
devourer::Ev(*g_ev, "txpwr.offset").f("requested", q).f("applied", applied);
print_state(dev, a.thermal);
std::this_thread::sleep_for(std::chrono::milliseconds(a.step_ms));
}
}
if (a.switch_channel > 0 && !g_devourer_should_stop) {
dev->SetMonitorChannel(
SelectedChannel{.Channel = static_cast<uint8_t>(a.switch_channel),
.ChannelOffset = 0,
.ChannelWidth = bw_enum(a.bw)});
logger->info("SetMonitorChannel -> ch{} (offset must re-fold)",
a.switch_channel);
print_state(dev, a.thermal);
}
if (a.retune > 0 && !g_devourer_should_stop) {
dev->FastRetune(static_cast<uint8_t>(a.retune));
logger->info("FastRetune -> ch{} (TXAGC registers must be untouched)",
a.retune);
print_state(dev, a.thermal);
}
dev->Stop();
session.close();
return 0;
}