This commit is contained in:
2026-09-13 13:30:21 +08:00
commit a6bbd520cf
2744 changed files with 1598795 additions and 0 deletions
+697
View File
@@ -0,0 +1,697 @@
/* chanscout — passive second-adapter ground spectrum scout.
*
* Continuously surveys a configured candidate-channel plan while a separate
* primary receiver stays parked on the live video channel. This process only
* MEASURES: it retunes nothing but its own scout adapter and emits versioned
* survey.dwell records (plus scout.id / scout.plan / scout.cand identity and
* scout.health state) as JSONL on stdout. Channel-change decisions live
* entirely in downstream consumers.
*
* Dwell discipline (the reason this is not just DEVOURER_RX_SWEEP): the
* chip's FA/CCA counters are delta-on-read, so after the retune + settle the
* executor performs a DISCARD read — resetting the hardware deltas and
* draining frames still in the USB pipeline from the previous channel —
* before opening the observation window. The record's counters therefore
* span exactly observe_ms on exactly this bin.
*
* Wide candidates are surveyed as their 20 MHz constituent bins (the cheap
* FastRetune path); DEVOURER_SCOUT_FULLWIDTH_MS optionally adds a periodic
* real-width verification dwell through the full SetMonitorChannel gate.
*
* Env (demo-local; the library reads no environment):
* DEVOURER_SCOUT_PLAN required — see the grammar in ChannelDef.h
* DEVOURER_SCOUT_DWELL_MS observation window per dwell (default 100)
* DEVOURER_SCOUT_SETTLE_MS post-retune settle (default 30)
* DEVOURER_SCOUT_BACKUP_MS backup-candidate revisit bound (default 1000)
* DEVOURER_SCOUT_BG_MS background revisit bound (default 5000)
* DEVOURER_SCOUT_MAX_AGE_MS evidence freshness bound (default 60000)
* DEVOURER_SCOUT_FULLWIDTH_MS wide verification cadence (default 0=off)
* DEVOURER_SCOUT_NOTE free text echoed in scout.id (antenna, slot)
* DEVOURER_VID/PID/USB_BUS/USB_PORT adapter binding (usb_select.h)
*/
#include <libusb.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "DeviceSession.h"
#include "RxPacket.h"
#include "SignalStop.h"
#include "UsbOpen.h"
#include "WiFiDriver.h"
#include "IRtlRadio.h"
#include "caps_event.h"
#include "chanmig/ChannelDef.h"
#include "chanmig/ChannelEvents.h"
#include "chanmig/ChannelScore.h"
#include "chanmig/PrimaryFeed.h"
#include "chanmig/ScanPlan.h"
#include "chanmig/SurveyJsonl.h"
#include "chanmig/SurveyRecord.h"
#include "env_config.h"
#include "logger.h"
#include "usb_select.h"
#include <optional>
#if defined(DEVOURER_HAVE_JAGUAR1)
#include "jaguar1/RtlJaguarDevice.h"
#endif
namespace cm = devourer::chanmig;
static devourer::EventSink *g_ev = nullptr;
static std::atomic<int> g_rx_count{0};
/* The canonical devourer TX SA (examples/tx, examples/streamtx, regress.py):
* frames from it are OUR OWN video/probe traffic, split out of the occupancy
* picture so the scoring layer never mistakes wanted airtime for an
* interferer. */
static const uint8_t kDvrSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00};
/* Rolling per-dwell aggregate fed by the RX callback, drained at dwell
* boundaries (rxdemo RxAgg shape + the airtime attribution buckets). */
struct ScoutAgg {
uint32_t n = 0;
int32_t rssi_sum = 0, rssi_max = -128, snr_sum = 0, snr_min = 127;
int32_t evm_sum = 0;
uint32_t evm_n = 0;
uint32_t dvr_frames = 0;
uint64_t dvr_air_us = 0, oth_air_us = 0;
};
static std::mutex g_agg_mu;
static ScoutAgg g_agg;
static void packetProcessor(const Packet &packet) {
if (packet.RxAtrib.pkt_rpt_type == RX_PACKET_TYPE::C2H_PACKET)
return;
g_rx_count.fetch_add(1, std::memory_order_relaxed);
const auto &a = packet.RxAtrib;
/* Airtime is what occupied the channel, and the FCS was transmitted even
* where the MAC strips it before DMA - so add it back when the buffer does
* not carry it, or occupancy reads 4 bytes light on every frame. */
const uint32_t on_air_len =
static_cast<uint32_t>(packet.Data.size()) + (a.fcs_present ? 0u : 4u);
const uint32_t air =
cm::frame_airtime_us(a.data_rate, on_air_len, a.bw, a.sgi != 0);
const bool ours = packet.Data.size() >= 16 &&
std::memcmp(packet.Data.data() + 10, kDvrSa, 6) == 0;
std::lock_guard<std::mutex> lk(g_agg_mu);
if (a.rssi[0] > 0) {
++g_agg.n;
g_agg.rssi_sum += a.rssi[0];
if (a.rssi[0] > g_agg.rssi_max)
g_agg.rssi_max = a.rssi[0];
g_agg.snr_sum += a.snr[0];
if (a.snr[0] < g_agg.snr_min)
g_agg.snr_min = a.snr[0];
if (a.evm[0] != 0) {
g_agg.evm_sum += a.evm[0];
++g_agg.evm_n;
}
}
if (ours) {
++g_agg.dvr_frames;
g_agg.dvr_air_us += air;
} else {
g_agg.oth_air_us += air;
}
}
static long long steady_ms() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
static uint32_t env_u32(const char *name, uint32_t def) {
const char *e = std::getenv(name);
return (e && *e) ? static_cast<uint32_t>(std::strtoul(e, nullptr, 0)) : def;
}
/* Chunked stop-aware sleep (the repo's 50 ms convention keeps SIGINT
* latency bounded); returns false when interrupted. */
static bool nap_ms(int64_t ms) {
for (int64_t s = 0; s < ms; s += 50) {
if (g_devourer_should_stop)
return false;
const int64_t step = std::min<int64_t>(50, ms - s);
std::this_thread::sleep_for(std::chrono::milliseconds(step));
}
return !g_devourer_should_stop;
}
/* Health-state tracker: emits scout.health only on state/reason transitions
* so a long degraded stretch is one line, not a firehose. */
struct HealthReporter {
std::string last_key;
void report(const char *state, const char *reason, long long detail) {
std::string key = std::string(state) + "/" + reason;
if (key == last_key)
return;
last_key = key;
devourer::Ev(*g_ev, "scout.health")
.t()
.f("state", state)
.f("reason", reason)
.f("detail", detail);
}
void ok() {
if (last_key == "ok/")
return;
last_key = "ok/";
devourer::Ev(*g_ev, "scout.health").t().f("state", "ok").f("reason", "");
}
};
int main() {
auto logger = std::make_shared<Logger>();
apply_logging_env(*logger);
g_ev = &logger->events();
install_devourer_signal_handlers();
/* Owns the teardown order (device -> interface -> handle -> context; see
* DeviceSession.h). Declared before the RX thread below, so it is joined
* before the adapter is released. */
devourer::DeviceSession session{logger};
/* --- plan --- */
const char *plan_env = std::getenv("DEVOURER_SCOUT_PLAN");
cm::ScanPlanConfig cfg;
{
std::vector<cm::PlanParseError> errs;
const bool ok = cm::parse_scan_plan(plan_env, cfg.candidates, errs);
for (const auto &e : errs) {
logger->error("DEVOURER_SCOUT_PLAN token '{}': {}", e.token, e.reason);
devourer::Ev(*g_ev, "scout.health")
.t()
.f("state", "fatal")
.f("reason", "plan_error")
.f("token", e.token.c_str())
.f("what", e.reason.c_str());
}
/* A candidate list with ANY unparseable token must not run: a silently
* missing migration candidate is a field hazard, not a warning. */
if (!ok) {
logger->error("DEVOURER_SCOUT_PLAN is required and must parse cleanly "
"(grammar: src/chanmig/ChannelDef.h)");
return 2;
}
}
cfg.dwell_ms = static_cast<int>(env_u32("DEVOURER_SCOUT_DWELL_MS", 100));
cfg.settle_ms = static_cast<int>(env_u32("DEVOURER_SCOUT_SETTLE_MS", 30));
cfg.backup_revisit_ms =
static_cast<int>(env_u32("DEVOURER_SCOUT_BACKUP_MS", 1000));
cfg.bg_revisit_ms = static_cast<int>(env_u32("DEVOURER_SCOUT_BG_MS", 5000));
cfg.fullwidth_ms =
static_cast<int>(env_u32("DEVOURER_SCOUT_FULLWIDTH_MS", 0));
cfg.max_age_ms = env_u32("DEVOURER_SCOUT_MAX_AGE_MS", 60000);
const uint32_t plan_hash = cfg.plan_hash();
/* --- advise mode (DEVOURER_SCOUT_ADVISE=1) --- runs the RecommendEngine
* in-process (it already owns the survey records) and tails the primary
* receiver's JSONL for the active-link delivery evidence. It emits only
* advice; it retunes nothing. */
const bool advise = std::getenv("DEVOURER_SCOUT_ADVISE") != nullptr &&
std::strcmp(std::getenv("DEVOURER_SCOUT_ADVISE"), "0") != 0;
std::optional<cm::RecommendEngine> engine;
cm::PrimaryFeedReader feed;
cm::ChannelDef active_def;
if (advise) {
const char *act = std::getenv("DEVOURER_SCOUT_ACTIVE");
std::string aerr;
if (act == nullptr || !cm::parse_chan_token(act, active_def, aerr)) {
logger->error("DEVOURER_SCOUT_ADVISE needs a valid DEVOURER_SCOUT_ACTIVE "
"channel token ({})", act ? aerr : "unset");
return 2;
}
cm::PolicyConfig pol;
if (const char *pf = std::getenv("DEVOURER_SCOUT_POLICY")) {
std::string perr;
if (!cm::parse_policy_file(pf, pol, &perr))
logger->warn("DEVOURER_SCOUT_POLICY {}: {} — using defaults", pf, perr);
}
engine.emplace(pol, cfg.candidates, plan_hash, active_def);
}
/* --- adapter --- */
libusb_context *ctx = nullptr;
int rc = libusb_init(&ctx);
if (rc < 0)
return rc;
session.adopt_context(ctx);
libusb_set_option(ctx, LIBUSB_OPTION_LOG_LEVEL,
std::getenv("DEVOURER_USB_DEBUG") ? LIBUSB_LOG_LEVEL_DEBUG
: LIBUSB_LOG_LEVEL_WARNING);
/* The scout accepts any devourer-supported chip; the default PID list is
* rxdemo's. Two identical adapters are told apart per-process via
* DEVOURER_USB_BUS/PORT (strict, no fallback). */
static constexpr uint16_t kPids[] = {
0x8812, 0x0811, 0xa811, 0xb811, 0x8813, 0xb812, 0xb82c, 0xc811,
0xc82c, 0xc82e, 0xc812, 0x881a, 0x881b, 0x881c, 0xa81a, 0xe822,
0xa82a,
};
UsbPick pick;
libusb_device_handle *handle = open_selected_usb(
ctx, logger, kPids, sizeof(kPids) / sizeof(kPids[0]), &pick);
if (handle == nullptr)
return 1;
std::shared_ptr<devourer::UsbDeviceLock> usb_lock;
rc = devourer::claim_interface_reset_reopen(
ctx, handle, logger, std::getenv("DEVOURER_SKIP_RESET") == nullptr,
usb_lock);
if (rc != 0) {
/* The claim failed, so nothing owns the handle yet — hand it to the
* session purely so the unwind closes it. */
session.adopt_handle(handle);
return 1;
}
session.adopt_handle(handle);
session.adopt_lock(usb_lock);
WiFiDriver driver(logger);
auto owned_device = driver.CreateRadio(handle, ctx, usb_lock,
devourer_config_from_env());
if (!owned_device) {
logger->error("No driver for this chip in this build — exiting");
return 1;
}
/* The session owns the device from here: it is what guarantees the device
* (and its in-flight TX) dies before libusb does. */
session.adopt_device(std::move(owned_device));
IRadio *const dev = session.device();
devourer::emit_adapter_caps(*g_ev, dev);
const devourer::AdapterCaps caps = dev->GetAdapterCaps();
/* Stable scout identity = the physical binding + silicon (FNV-1a). This is
* the calibration-domain key: evidence is only comparable within one
* scout_id, and the aggregator resets when it changes. */
uint32_t scout_id = 2166136261u;
{
auto fold = [&scout_id](const void *p, size_t n) {
const uint8_t *b = static_cast<const uint8_t *>(p);
for (size_t i = 0; i < n; i++) {
scout_id ^= b[i];
scout_id *= 16777619u;
}
};
fold(&pick.vid, sizeof(pick.vid));
fold(&pick.pid, sizeof(pick.pid));
fold(&pick.bus, sizeof(pick.bus));
fold(pick.port.data(), pick.port.size());
fold(caps.chip_name, std::strlen(caps.chip_name));
}
{
devourer::Ev ev(*g_ev, "scout.id");
ev.t().f("role", "scout");
char id[16];
std::snprintf(id, sizeof(id), "%04x:%04x", pick.vid, pick.pid);
ev.f("usb_id", id)
.f("bus", pick.bus)
.f("port", pick.port.empty() ? "?" : pick.port.c_str())
.f("usb_speed", pick.speed)
.f("chip", caps.chip_name)
.f("gen", devourer::generation_name(caps.generation));
ev.hexf("scout_id", scout_id, 8);
if (const char *note = std::getenv("DEVOURER_SCOUT_NOTE"))
ev.f("note", note);
}
{
/* One CLOCK_REALTIME sample so offline tooling can align this stream
* with the primary receiver's (both processes log monotonic t). */
const long long epoch_ms =
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
devourer::Ev ev(*g_ev, "scout.plan");
ev.t().f("v", cm::kSurveySchemaV);
ev.hexf("plan", plan_hash, 8);
ev.f("epoch_unix_ms", epoch_ms)
.f("dwell_ms", cfg.dwell_ms)
.f("settle_ms", cfg.settle_ms)
.f("backup_ms", cfg.backup_revisit_ms)
.f("bg_ms", cfg.bg_revisit_ms)
.f("fullwidth_ms", cfg.fullwidth_ms)
.f("max_age_ms", (long long)cfg.max_age_ms)
.f("n_candidates", (unsigned long long)cfg.candidates.size())
.f("spec", plan_env);
}
for (size_t i = 0; i < cfg.candidates.size(); ++i) {
const cm::ChannelDef &c = cfg.candidates[i];
uint8_t bins[4];
const int nb = cm::constituent_bins(c, bins);
int ib[4];
for (int k = 0; k < nb; k++)
ib[k] = bins[k];
devourer::Ev ev(*g_ev, "scout.cand");
ev.f("i", (unsigned long long)i)
.f("chan", c.str().c_str())
.f("center_mhz", c.center_mhz())
.f("backup", c.backup)
.f("no_ir", c.no_ir)
.f("dfs", c.dfs);
ev.arr("bins", ib, static_cast<size_t>(nb));
}
cm::ScanScheduler sched(cfg);
/* --- RX loop on a worker thread (rxdemo sweep pattern) --- */
IRadio *devp = dev;
IRtlRadio *const rtl = dynamic_cast<IRtlRadio *>(dev);
if (!rtl)
logger->warn("chanscout: frame-free FA/CCA/NHM is Realtek-only (IRtlRadio) "
"— dwells carry frame stats only on this radio");
const cm::ScanScheduler::DwellPlan first = sched.next(steady_ms());
std::thread rx([devp, rtl, first, &logger]() {
try {
devp->Init(packetProcessor, first.def.to_selected());
} catch (const std::exception &e) {
logger->error("scout RX bring-up failed: {}", e.what());
g_devourer_should_stop = true;
}
});
/* Let bring-up finish before the first retune (a retune racing FW download
* or calibration interleaves register writes). A frame proves RX is live;
* a silent band falls through after the conservative 10 s cap. */
for (int w = 0; w < 10000 && !g_devourer_should_stop && g_rx_count == 0;
w += 50)
std::this_thread::sleep_for(std::chrono::milliseconds(50));
#if defined(DEVOURER_HAVE_JAGUAR1)
/* Thermal health where the sensor exists (Jaguar1 RF 0x42 poller). */
auto *j1 = dynamic_cast<RtlJaguarDevice *>(devp);
const uint32_t thermal_ms = env_u32("DEVOURER_THERMAL_POLL_MS", 0);
if (j1 != nullptr && thermal_ms > 0)
j1->start_thermal_poller(thermal_ms,
static_cast<int>(env_u32(
"DEVOURER_THERMAL_WARN_DELTA", 15)));
#endif
HealthReporter health;
uint64_t seq = 0;
int energy_invalid_streak = 0;
bool energy_ever_valid = false;
int quiet_dwells = 0;
bool ever_active = false;
std::vector<int64_t> retune_ring;
int64_t retune_p95_baseline = 0;
bool last_was_wide = false;
/* Per-bin last-successful-observation, for the stale-survey health check. */
struct BinAge {
uint8_t ch;
int64_t last_ok_ms;
};
std::vector<BinAge> bin_ages;
/* Advise-mode feed follower + decision cadence. The primary feed is a plain
* append-only file (never a FIFO — a wedged scout must not stall the video
* receiver); open it lazily and read to EOF each pump. */
std::FILE *feed_fp = nullptr;
std::string feed_path;
if (advise) {
if (const char *fp = std::getenv("DEVOURER_SCOUT_PRIMARY_FEED"))
feed_path = fp;
else
logger->warn("DEVOURER_SCOUT_ADVISE without DEVOURER_SCOUT_PRIMARY_FEED "
"— recommendations will lack active-link evidence");
}
std::string feed_buf;
int64_t last_decide_ms = 0, last_emit_ms = 0;
cm::Reason last_reason = cm::Reason::HoldPrimaryTelemetryStale;
const int64_t kDecideEveryMs = 2000, kHoldEveryMs = 10000;
auto pump_feed = [&](int64_t now) {
if (!advise || feed_path.empty() || engine == std::nullopt)
return;
if (feed_fp == nullptr) {
feed_fp = std::fopen(feed_path.c_str(), "r");
if (feed_fp == nullptr)
return; /* primary not up yet; retry next pump */
}
char chunk[4096];
size_t n;
while ((n = std::fread(chunk, 1, sizeof(chunk), feed_fp)) > 0) {
feed_buf.append(chunk, n);
size_t nl;
while ((nl = feed_buf.find('\n')) != std::string::npos) {
std::string_view line(feed_buf.data(), nl);
cm::ActiveLinkWindow w;
if (feed.line(line, now, w))
engine->ingest_active(w);
feed_buf.erase(0, nl + 1);
}
}
std::clearerr(feed_fp); /* EOF now, but more may append — keep the handle */
};
while (!g_devourer_should_stop) {
const int64_t t_start = steady_ms();
cm::ScanScheduler::DwellPlan plan = sched.next(t_start);
if (!plan.valid)
break;
cm::SurveyDwell d;
d.seq = seq++;
d.def = plan.def;
d.round = plan.round;
d.plan_hash = plan_hash;
d.t_start_ms = t_start;
d.settle_ms = cfg.settle_ms;
d.scout_id = scout_id;
d.adapter_gen = static_cast<uint8_t>(caps.generation);
if (plan.full_width)
d.flags |= cm::kFlagFullWidth;
/* retune. FastRetune is the same-width lean path, so the first bin dwell
* after a wide verification dwell must go through the full gate to
* restore 20 MHz — otherwise every subsequent "bin" would observe at the
* candidate's width. */
bool tuned = false;
const auto rt0 = std::chrono::steady_clock::now();
try {
if (plan.full_width) {
devp->SetMonitorChannel(plan.def.to_selected());
last_was_wide = true;
} else if (last_was_wide) {
devp->SetMonitorChannel(plan.def.to_selected());
last_was_wide = false;
} else {
devp->FastRetune(plan.bin_ch, /*cache_rf=*/true);
}
tuned = true;
} catch (const std::exception &e) {
logger->warn("scout retune ch{} failed: {}", plan.bin_ch, e.what());
}
d.retune_us = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - rt0)
.count();
if (!tuned) {
d.flags |= cm::kFlagRetuneFailed;
d.t_end_ms = steady_ms();
d.observe_ms = 0;
cm::emit_survey_dwell(*g_ev, d);
sched.complete(plan, steady_ms(), false);
const int cf = sched.consecutive_failures();
if (cf >= 15) {
health.report("wedged", "retune_fail", cf);
break; /* supervisor restarts us; exit code says why */
}
if (cf >= 5)
health.report("degraded", "retune_fail", cf);
nap_ms(50);
continue;
}
/* settle, then the DISCARD BARRIER: reset the delta counters and drain
* frames that raced in from the previous channel. */
if (!nap_ms(cfg.settle_ms))
d.flags |= cm::kFlagTruncated;
if (rtl)
(void)rtl->GetRxEnergy(/*with_nhm=*/false);
{
std::lock_guard<std::mutex> lk(g_agg_mu);
g_agg = ScoutAgg{};
}
const int64_t t_obs = steady_ms();
if (!nap_ms(cfg.dwell_ms))
d.flags |= cm::kFlagTruncated;
RxEnergy e = rtl ? rtl->GetRxEnergy(/*with_nhm=*/true) : RxEnergy{};
ScoutAgg agg;
{
std::lock_guard<std::mutex> lk(g_agg_mu);
agg = g_agg;
g_agg = ScoutAgg{};
}
d.t_end_ms = steady_ms();
d.observe_ms = d.t_end_ms - t_obs;
d.valid_fa = e.valid_fa;
d.fa_ofdm = e.fa_ofdm;
d.fa_cck = e.fa_cck;
d.cca_ofdm = e.cca_ofdm;
d.cca_cck = e.cca_cck;
d.valid_igi = e.valid_igi;
d.igi = e.igi;
d.valid_nhm = e.valid_nhm;
if (e.valid_nhm) {
uint32_t total = 0, peak = 0;
int peak_k = 0;
for (int k = 0; k < 12; k++) {
d.nhm[k] = e.nhm[k];
total += e.nhm[k];
if (e.nhm[k] > peak) {
peak = e.nhm[k];
peak_k = k;
}
}
d.nhm_dur = e.nhm_duration;
d.nhm_peak = static_cast<uint8_t>(peak_k);
d.nhm_busy_pct = static_cast<uint8_t>(
total ? 100 * (total - e.nhm[0]) / total : 0);
} else {
d.flags |= cm::kFlagNhmMissing;
}
/* Producer-side counter plausibility (the aggregator re-checks): a delta
* beyond ~1000 events/ms of observation is a wrapped/reset counter. */
if (e.valid_fa && d.observe_ms > 0) {
const uint64_t ceiling =
1000ull * static_cast<uint64_t>(d.observe_ms);
if (d.cca_ofdm > ceiling || d.fa_ofdm > ceiling ||
d.cca_cck > ceiling || d.fa_cck > ceiling)
d.flags |= cm::kFlagCounterSuspect;
}
d.frames = agg.n;
if (agg.n) {
d.rssi_mean_raw = agg.rssi_sum / static_cast<int>(agg.n);
d.rssi_max_raw = agg.rssi_max;
d.snr_mean_raw = agg.snr_sum / static_cast<int>(agg.n);
d.snr_min_raw = agg.snr_min;
}
if (agg.evm_n) {
d.evm_mean_raw = agg.evm_sum / static_cast<int>(agg.evm_n);
d.evm_valid = true;
}
d.dvr_frames = agg.dvr_frames;
d.dvr_air_us = agg.dvr_air_us;
d.oth_air_us = agg.oth_air_us;
/* A generation whose energy counters were once valid going invalid is a
* read failure, not a quiet channel — flag the record before it flies. */
if (e.valid_fa)
energy_ever_valid = true;
energy_invalid_streak = e.valid_fa ? 0 : energy_invalid_streak + 1;
if (energy_ever_valid && !e.valid_fa)
d.flags |= cm::kFlagReadFailed;
cm::emit_survey_dwell(*g_ev, d);
const bool ok = (d.flags & (cm::kFlagRetuneFailed | cm::kFlagTruncated)) == 0;
sched.complete(plan, d.t_end_ms, ok);
if (advise && engine != std::nullopt)
engine->ingest_dwell(d, d.t_end_ms);
/* --- health --- */
if (energy_ever_valid && energy_invalid_streak >= 10)
health.report("degraded", "energy_read_fail", energy_invalid_streak);
const bool active = agg.n > 0 || (e.valid_fa && (e.cca_ofdm | e.cca_cck));
if (active) {
ever_active = true;
quiet_dwells = 0;
} else if (++quiet_dwells >= 50 && ever_active) {
/* Everything silent after having heard traffic: a wedged RX front end
* looks exactly like a suddenly-empty world — flag it, let the bench
* decide. */
health.report("degraded", "rx_stalled", quiet_dwells);
}
if (!plan.full_width) {
retune_ring.push_back(d.retune_us);
if (retune_ring.size() > 100)
retune_ring.erase(retune_ring.begin());
if (retune_ring.size() == 100) {
std::vector<int64_t> sorted = retune_ring;
std::sort(sorted.begin(), sorted.end());
const int64_t p95 = sorted[95];
if (retune_p95_baseline == 0)
retune_p95_baseline = p95 > 0 ? p95 : 1;
else if (p95 > 5 * retune_p95_baseline)
health.report("degraded", "usb_congested", p95);
}
}
#if defined(DEVOURER_HAVE_JAGUAR1)
if (j1 != nullptr && thermal_ms > 0) {
const auto t = j1->get_thermal_snapshot();
if (t.valid && t.delta >= static_cast<int>(env_u32(
"DEVOURER_THERMAL_WARN_DELTA", 15)))
health.report("degraded", "thermal", t.delta);
}
#endif
{
/* Stale-survey: any bin unobserved past the freshness bound. */
BinAge *slot = nullptr;
for (BinAge &b : bin_ages)
if (b.ch == plan.bin_ch)
slot = &b;
if (slot == nullptr) {
bin_ages.push_back(BinAge{plan.bin_ch, d.t_end_ms});
slot = &bin_ages.back();
}
if (ok)
slot->last_ok_ms = d.t_end_ms;
bool stale = false;
long long worst = 0;
for (const BinAge &b : bin_ages) {
const int64_t age = d.t_end_ms - b.last_ok_ms;
if (age > cfg.max_age_ms) {
stale = true;
if (age > worst)
worst = age;
}
}
if (stale)
health.report("degraded", "stale_survey", worst);
else if (sched.consecutive_failures() == 0 &&
energy_invalid_streak < 10 && quiet_dwells < 50)
health.ok();
}
/* --- advise: pump the primary feed, decide on a cadence --- */
if (advise && engine != std::nullopt) {
const int64_t now = d.t_end_ms;
pump_feed(now);
engine->note_scout_health(sched.consecutive_failures() < 5);
if (now - last_decide_ms >= kDecideEveryMs) {
last_decide_ms = now;
cm::Decision dec = engine->decide(now);
/* Emit on a recommendation, a reason change, or the steady hold
* cadence — so a dashboard's counterfactual stays fresh without spam. */
if (dec.kind == cm::Decision::Kind::Recommend ||
dec.primary_reason != last_reason ||
now - last_emit_ms >= kHoldEveryMs) {
cm::emit_decision(*g_ev, dec, active_def);
cm::emit_ranking(*g_ev, dec);
last_reason = dec.primary_reason;
last_emit_ms = now;
}
}
}
}
if (feed_fp != nullptr)
std::fclose(feed_fp);
devp->StopRxLoop();
if (rx.joinable())
rx.join();
devp->Stop();
session.close();
return sched.consecutive_failures() >= 15 ? 3 : 0;
}