first
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user