From d546fd87e05ae5d94bb360881cf24e2cc7c1a265 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:29:18 +0300 Subject: [PATCH 1/3] Logging redesign: JSONL machine events + leveled stderr diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ~37 organically-grown XML-ish tags with a two-plane logging system (docs/logging.md is the schema source of truth): - Machine events: JSON Lines on stdout, one object per line, the event name always serialized first ({"ev":"rx.txhit",...}) so shell consumers can grep -F without a JSON parser. src/Event.h is the dependency-free emitter (inline buffer + thread-local spill, 64 KiB cap with "truncated":true, JSON escaping, hex/array helpers). - Human diagnostics: stderr, `devourer [I] msg` (level letter T/D/I/W/E), via the rewritten src/logger.h (new Trace level, EventSink member). Both planes write each line with a single fwrite + fflush: per-line atomicity across the RX/coex/TX threads, and a piped consumer (test script, AI agent) sees every event at emission — no more libc full-buffering stalls (tests/pipe_latency_check.py proves it on hardware; setvbuf(_IOLBF) was not an option, MSVC full-buffers it). The 44 scattered per-site fflush() calls are gone. Production builds stop paying for verbosity: DEVOURER_LOG_MAX_LEVEL (CMake: TRACE..SILENT) compiles trace/debug out entirely, including argument evaluation at the new DVR_TRACE/DVR_DEBUG hot-path macros; unset it derives from NDEBUG, preserving the old behavior. Runtime level + event routing stay per-Logger — the library still reads no environment; demos map DEVOURER_LOG_LEVEL / DEVOURER_EVENTS / DEVOURER_EVENT_FLUSH in examples/common/env_config (apply_logging_env). Migrated atomically (no compat layer): every library + demo emitter, 29 python consumers (via the new shared tests/devourer_events.py parser), 41 shell scripts, and the docs. "Failed to send packet" string-matching in regress.py is replaced by a structured tx.fail event. Register dumps meant for kernel diffing (canary/bb/efuse/txpwr) deliberately stay text on the diagnostic plane; canary_diff.py strips the new prefix itself. sense and streamtx route events to stderr (their stdout is a live display / data path). Hardware-validated: regress 4/4 on ch6 (+ devourer cells on ch36), bench_init, 5286 on-air rx.frame events with all fields, txpwr regcheck 12 PASS / 0 FAIL (J2+J3), hop.dwell/hop.prof/hop.done, canary extraction round-trip CLEAN, bf.report 2764 events vs 0 in the unarmed control. ctest 12/12 incl. the new log_event_selftest; INFO/SILENT compile-gate configs build clean. Out-of-tree consumers grepping the old tags must migrate to the schema in docs/logging.md. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 29 +- CMakeLists.txt | 36 ++ docs/adapter-doctor.md | 2 +- docs/beamforming-victim-sensing.md | 2 +- docs/bench-testing-near-field.md | 4 +- docs/frequency-hopping.md | 2 +- docs/fused-fec.md | 2 +- docs/logging.md | 160 +++++++++ docs/rx-spectrum-sensing.md | 29 +- docs/startup-time.md | 6 +- examples/common/BfReportDetect.h | 69 ++-- examples/common/env_config.cpp | 35 ++ examples/common/env_config.h | 13 + examples/doctor/main.cpp | 33 +- examples/duplex/main.cpp | 108 +++--- examples/precoder/main.cpp | 12 +- examples/rx/main.cpp | 434 ++++++++++++++----------- examples/sense/README.md | 4 +- examples/sense/main.cpp | 29 +- examples/streamtx/main.cpp | 37 +-- examples/svctx/main.cpp | 14 +- examples/tx/main.cpp | 126 ++++--- examples/txpower/main.cpp | 58 ++-- src/DeviceConfig.h | 4 +- src/Event.h | 374 +++++++++++++++++++++ src/HopProf.h | 31 +- src/InitTimer.h | 26 +- src/RadiotapBuilder.cpp | 5 +- src/RtlUsbAdapter.cpp | 12 +- src/RtlUsbAdapter.h | 12 +- src/RxPacket.h | 2 +- src/jaguar1/FirmwareManager.cpp | 2 +- src/jaguar1/FrameParser.cpp | 6 +- src/jaguar1/HalModule.cpp | 2 +- src/jaguar1/Iqk8812a.cpp | 4 +- src/jaguar1/PhydmWatchdog.cpp | 2 +- src/jaguar1/PowerTracking8812a.cpp | 6 +- src/jaguar1/RadioManagementModule.cpp | 6 +- src/jaguar1/RtlJaguarDevice.cpp | 54 ++- src/jaguar2/HalJaguar2.cpp | 5 +- src/jaguar3/RadioManagementJaguar3.cpp | 5 +- src/logger.h | 180 +++++++--- tests/adapter_doctor_cold.sh | 4 +- tests/adaptive_onair.sh | 14 +- tests/antenna_decorrelation.py | 49 ++- tests/bench_init.py | 34 +- tests/bf_report_sniff.sh | 10 +- tests/bf_selfsound_jaguar2.sh | 22 +- tests/bf_selfsound_jaguar3.sh | 10 +- tests/canary_diff.py | 20 +- tests/cold_first_init_8812au.sh | 8 +- tests/compare_8814_decorrelation.sh | 6 +- tests/devourer_events.py | 55 ++++ tests/dis_cca_onair.sh | 37 ++- tests/eu_rx_validate.sh | 10 +- tests/fused_fec_headtohead.sh | 16 +- tests/fused_fec_onair.sh | 2 +- tests/hop_parity_check.sh | 4 +- tests/inject_beacon.py | 6 +- tests/j3_dig_penalty_sweep.sh | 30 +- tests/jaguar2_8821c_bringup_smoke.sh | 2 +- tests/jaguar2_8821c_bw_txhit.sh | 6 +- tests/jaguar2_8821c_rx_txhit.sh | 6 +- tests/jaguar2_8821c_tx_txhit.sh | 6 +- tests/jaguar2_8822b_rx_smoke.sh | 4 +- tests/jaguar3_tx_sniff.sh | 14 +- tests/link_health_onair.sh | 9 +- tests/link_health_selftest.cpp | 2 +- tests/link_probe.py | 48 ++- tests/log_event_selftest.cpp | 163 ++++++++++ tests/mrc_mobility.py | 41 +-- tests/per_mcs_power_ceiling.sh | 6 +- tests/pipe_latency_check.py | 112 +++++++ tests/precoder_roundtrip.py | 36 +- tests/precoder_stream_roundtrip.py | 33 +- tests/precoder_stream_smoke.py | 8 +- tests/regress.py | 47 +-- tests/run_antenna_decorrelation.sh | 14 +- tests/run_hop_validation.sh | 8 +- tests/run_stream_hop_validation.sh | 6 +- tests/rx_energy_check.py | 13 +- tests/rx_energy_probe.sh | 4 +- tests/rx_noise_floor_onair.sh | 33 +- tests/rx_path_mask_check.sh | 21 +- tests/rx_quality_selftest.cpp | 2 +- tests/rx_spectrum_sweep.py | 30 +- tests/rx_spectrum_sweep.sh | 4 +- tests/rx_tone_localize.py | 2 +- tests/saturation_knee_sweep.sh | 6 +- tests/sounding_map.py | 26 +- tests/stbc_1t1r_check.sh | 8 +- tests/stbc_sanity.sh | 13 +- tests/svc_uep_onair.sh | 2 +- tests/test_canary_diff.py | 48 ++- tests/thermal_gain_sweep.py | 39 +-- tests/thermal_hwcheck.sh | 15 +- tests/tx_stats_congestion.sh | 6 +- tests/txbf_apply_onair.sh | 20 +- tests/txpkt_pwr_ofset_onair.sh | 2 +- tests/txpwr_offset_onair.sh | 4 +- tests/txpwr_offset_regcheck.sh | 23 +- tests/validate_jaguar3_physts.sh | 18 +- tests/verify_stream_fields.sh | 12 +- tools/bf_report_decode.py | 36 +- tools/bf_waterfall.py | 14 +- tools/bf_waterfall_svg.py | 10 +- tools/canary_kernel_dump.sh | 6 +- tools/precoder/README.md | 12 +- tools/precoder/adaptive_link.py | 36 +- tools/precoder/corruption_analysis.py | 38 +-- tools/precoder/corruption_survey.py | 43 +-- tools/precoder/csi_dump.py | 36 +- tools/precoder/fused_fec_link.py | 4 +- tools/precoder/fused_fec_rx.py | 20 +- tools/precoder/score.py | 2 +- tools/precoder/seed_probe.py | 27 +- tools/precoder/stream_rx.py | 24 +- tools/precoder/tun_p2p.py | 27 +- 118 files changed, 2414 insertions(+), 1162 deletions(-) create mode 100644 docs/logging.md create mode 100644 src/Event.h create mode 100644 tests/devourer_events.py create mode 100644 tests/log_event_selftest.cpp create mode 100644 tests/pipe_latency_check.py diff --git a/CLAUDE.md b/CLAUDE.md index 133d7c1..ccfa375 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,8 +86,8 @@ rows are not authoritative for LDPC/STBC — that driver strips those bits), `--keep-logs` puts per-cell logs at `/tmp/devourer-regress-last/`. Full semantics: `tests/README.md`. -Startup-time benchmarking: `tests/bench_init.py` (per-stage `init-timing:` -lines from `src/InitTimer.h`; methodology + numbers in `docs/startup-time.md`). +Startup-time benchmarking: `tests/bench_init.py` (per-stage `init.timing` +events from `src/InitTimer.h`; methodology + numbers in `docs/startup-time.md`). On-air TX throughput: measure **Mbps via SDR duty × PHY rate** (`tests/bench_onair.py`), never monitor-sniffer frame counts — a sensitive receiver decodes weak frames and masks a real drop. Keep a known-good control @@ -105,6 +105,25 @@ survive re-enumeration, temp-blacklist instead; and `authorized`-toggle "cold" leaves chip state (real VBUS cold via `REGRESS_VBUS_MAP` / uhubctl — never on xhci root ports on this rig). +## Logging + +Two planes (`docs/logging.md` is the schema source of truth): **machine events** += JSON Lines on stdout, one object per line, first field always +`{"ev":"",...}` — so `grep -F '"ev":"rx.txhit"'` works without a JSON +parser, and `tests/devourer_events.py` (`iter_events`/`parse_event`) is the +python helper every test script uses. **Human diagnostics** = stderr, +`devourer [I] msg` (level letter T/D/I/W/E). Every line is written with one +fwrite + flush, so piped consumers never stall on buffering and threads never +interleave mid-line. `2>/dev/null` gives a pure event stream. + +Demo knobs: `DEVOURER_LOG_LEVEL=trace..silent` (stderr verbosity, default +debug), `DEVOURER_EVENTS=stdout|stderr|off`, `DEVOURER_EVENT_FLUSH=0` +(max-rate benches). Compile-time floor: `-DDEVOURER_LOG_MAX_LEVEL=WARN` +compiles trace/debug out entirely (args included at `DVR_TRACE`/`DVR_DEBUG` +sites) for production builds; unset = NDEBUG-derived. Exceptions kept as +diffable text on the diagnostic plane: canary / bb / efuse / txpwr register +dumps (kernel cross-validation format). + ## Configuration **The library reads no environment.** Construction-time knobs live in @@ -155,7 +174,7 @@ Knob-specific facts that aren't obvious from the field docs: saves/restores `0x808`). Toggle spec `0xAA:0xBB[:0xCC]@` cycles masks on a timer for mobility/MRC measurements (`docs/measuring-spatial-diversity.md`, `tests/mrc_mobility.py`). `DEVOURER_RX_ALLPATHS=1` emits per-chain - RSSI/SNR/EVM on a separate `` tag (C/D nonzero only on the + RSSI/SNR/EVM as a separate `rx.path` event (C/D nonzero only on the 8814AU). - `DEVOURER_RX_CSI_MASK` / `DEVOURER_RX_NBI` (RX per-tone equalizer mask / narrowband notch, `src/ToneMask.h`) apply at RX-loop start and revert on a @@ -166,7 +185,7 @@ Knob-specific facts that aren't obvious from the field docs: `crc_err`/`icv_err` set — the entry point for the fused-FEC salvage layer (`docs/fused-fec.md`). Opt-in: a body with a corrupt tail is the worst-case input for an IP-stack consumer that didn't ask for it. -- `DEVOURER_THERMAL_POLL_MS=N` emits `` lines from the RF +- `DEVOURER_THERMAL_POLL_MS=N` emits `thermal` events from the RF 0x42 meter: `raw` is 0..63 thermal units (~1.5–2 °C each, **not** absolute °C — hence bucketed status, not a fake temperature), `delta` = raw − EFUSE baseline. Jaguar1 has no hard thermal TX shutdown — a rising delta is @@ -306,6 +325,6 @@ dev->send_packet(buffer, len); // buffer[0..] = radiotap header, then 802.11 The canonical test beacon (`examples/tx/main.cpp`) uses SA `57:42:75:05:d6:00` — the same constant is hardcoded into -`examples/rx/main.cpp` as the `` matcher and into +`examples/rx/main.cpp` as the `rx.txhit` event matcher and into `tests/regress.py` (`CANONICAL_SA`). Change all three together if it ever moves. diff --git a/CMakeLists.txt b/CMakeLists.txt index 466ba0e..fbf3bbd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,24 @@ option(DEVOURER_JAGUAR2_8821C "RTL8811CU / 8821CU (Jaguar2, rtl8821c, 1T1R)" option(DEVOURER_JAGUAR3_8822C "RTL8812CU / 8822CU (Jaguar3, rtl8822c)" ON) option(DEVOURER_JAGUAR3_8822E "RTL8812EU / 8822EU (Jaguar3, rtl8822e)" ON) +# Compile-time diagnostics floor (src/logger.h). Calls below the floor — +# including their argument expressions at DVR_TRACE/DVR_DEBUG sites — compile +# to nothing. Empty (the default) keeps the NDEBUG-derived behavior: release +# builds drop trace/debug, debug builds keep everything. Example production +# firmware build: -DDEVOURER_LOG_MAX_LEVEL=WARN +set(DEVOURER_LOG_MAX_LEVEL "" CACHE STRING + "Compile-time log floor: TRACE, DEBUG, INFO, WARN, ERROR or SILENT (empty = NDEBUG-derived)") +set_property(CACHE DEVOURER_LOG_MAX_LEVEL PROPERTY STRINGS + "" TRACE DEBUG INFO WARN ERROR SILENT) +if(NOT DEVOURER_LOG_MAX_LEVEL STREQUAL "") + set(_devourer_ll_valid TRACE DEBUG INFO WARN ERROR SILENT) + if(NOT DEVOURER_LOG_MAX_LEVEL IN_LIST _devourer_ll_valid) + message(FATAL_ERROR + "DEVOURER_LOG_MAX_LEVEL=${DEVOURER_LOG_MAX_LEVEL} — must be one of " + "TRACE, DEBUG, INFO, WARN, ERROR, SILENT (or empty).") + endif() +endif() + if(DEVOURER_8814 AND NOT DEVOURER_JAGUAR1) message(FATAL_ERROR "DEVOURER_8814=ON requires DEVOURER_JAGUAR1=ON (8814 reuses the Jaguar1 HAL).") @@ -57,6 +75,7 @@ pkg_check_modules(libusb REQUIRED IMPORTED_TARGET libusb-1.0) add_library(devourer # --- generation-agnostic core (always compiled) --- src/logger.h + src/Event.h hal/basic_types.h hal/hal_com_reg.h src/ieee80211_radiotap.h @@ -99,6 +118,13 @@ add_library(devourer target_compile_features(devourer PUBLIC cxx_std_20) +# PUBLIC: logger.h is header-only, so the library and its consumers must agree +# on the compile-time log floor (ODR). +if(NOT DEVOURER_LOG_MAX_LEVEL STREQUAL "") + target_compile_definitions(devourer PUBLIC + DEVOURER_LOG_MAX_LEVEL=DEVOURER_LL_${DEVOURER_LOG_MAX_LEVEL}) +endif() + # Link devourer with libusb as found via pkg-config. target_link_libraries(devourer PUBLIC PkgConfig::libusb) @@ -347,6 +373,16 @@ target_link_libraries(ToneMaskSelftest PRIVATE devourer) add_test(NAME tone_mask_math COMMAND ToneMaskSelftest) +# Headless guard for the JSONL event emitter (src/Event.h) — the machine +# event stream every test script parses (docs/logging.md): first-field +# serialization guarantee, escaping, spill + truncation paths. +add_executable(LogEventSelftest + tests/log_event_selftest.cpp +) +target_link_libraries(LogEventSelftest PRIVATE devourer) + +add_test(NAME log_event_json COMMAND LogEventSelftest) + # Headless guard for the sweep/hop bin-list grammar (src/SweepSpec.h) — the # channel-list / channel-range / MHz-range parsing behind DEVOURER_RX_SWEEP and # DEVOURER_HOP_CHANNELS, so a grammar regression fails `ctest` instead of only diff --git a/docs/adapter-doctor.md b/docs/adapter-doctor.md index f3e345b..b7b2d77 100644 --- a/docs/adapter-doctor.md +++ b/docs/adapter-doctor.md @@ -42,7 +42,7 @@ build/doctor --bus 3 --port 2.3.3 # topology select (two same-PID adapters) (same trap as [startup-time](startup-time.md) benchmarking). Verdict → exit code: `HEALTHY`=0, `SUSPECT`=1, `FAILING`=2 (3 = tool / -open error), plus a `` machine line and per-reason +open error), plus a `doctor.verdict` machine event and per-reason text. The classifier is pure logic (`src/AdapterHealth.h`, ctest'd in `tests/adapter_health_selftest.cpp`); grading rules and their rationale live next to the code. diff --git a/docs/beamforming-victim-sensing.md b/docs/beamforming-victim-sensing.md index 93d4d22..c6824fc 100644 --- a/docs/beamforming-victim-sensing.md +++ b/docs/beamforming-victim-sensing.md @@ -200,7 +200,7 @@ DEVOURER_PID=0x8812 DEVOURER_CHANNEL=6 DEVOURER_TX_RATE=VHT2SS_MCS0 \ DEVOURER_TX_NDPA_RA= DEVOURER_TX_NDPA=1 \ DEVOURER_BF_ARM_SOUNDER=1 DEVOURER_TX_WITH_RX=thread \ DEVOURER_BF_DETECT_REPORT=4 DEVOURER_TX_GAP_US=4000 \ - ./build/txdemo 2>/dev/null | grep '' > cap.raw + ./build/txdemo 2>/dev/null | grep -F '"ev":"bf.report_raw"' > cap.raw ``` Beamformee (the client), on its adapter: diff --git a/docs/bench-testing-near-field.md b/docs/bench-testing-near-field.md index ce104db..35cbb06 100644 --- a/docs/bench-testing-near-field.md +++ b/docs/bench-testing-near-field.md @@ -65,7 +65,7 @@ multipath-desense regime. `rxdemo` classifies each window and says so in plain language: ``` - verdict=SATURATED rssi_dbm=-33 snr_db=19.5 evm_db=-22.5 + {"ev":"link.health","verdict":"SATURATED","rssi_dbm":-33,"snr_db":19.5,"evm_db":-22.5, cause="strong RSSI but poor EVM — receiver front-end overload and/or the strong signal self-jamming via reflections (near-field). SNR alone can look fine here" @@ -78,7 +78,7 @@ multipath-desense regime. antenna is the answer. If it says `SATURATED`, the answer is the opposite — and no amount of antenna tuning will fix a receiver that is clipping. -## The link doctor (``) +## The link doctor (the `link.health` event) `src/LinkHealth.h` maps the RX sensor tuple to one of: `SATURATED`, `INTERFERENCE`, `WEAK`, `MARGINAL`, `HEALTHY`, `NO_SIGNAL`, each with a diff --git a/docs/frequency-hopping.md b/docs/frequency-hopping.md index aae02d0..d724529 100644 --- a/docs/frequency-hopping.md +++ b/docs/frequency-hopping.md @@ -306,7 +306,7 @@ every generation overrides it with a lean path built from the tricks above: | RTL8822CU (Jaguar3) | ~12 ms | **~1.9 ms** | ~0.21 ms | 9 | | RTL8812EU (Jaguar3) | ~12 ms | **~2.4 ms** | ~0.27 ms | 9 | -(Median `switch_us` over a 1/6/11 hop set; per-stage numbers from +(Median `hop.dwell` switch_us over a 1/6/11 hop set; per-stage numbers from `DEVOURER_HOP_PROF=1`. Every hop microsecond is USB round-trips: one register read or write is one synchronous control transfer, whose latency is a property of the chip's EP0 handling — it varies 5× across the family — so the only code diff --git a/docs/fused-fec.md b/docs/fused-fec.md index 6cd89ae..fcd3d50 100644 --- a/docs/fused-fec.md +++ b/docs/fused-fec.md @@ -211,7 +211,7 @@ for both receivers. Only the receiver — and thus the inner decode — differs: | `svc_spatial_uep_sim.py` | per-SVC-layer spatial UEP — the 3-axis staircase (MCS + FEC + STBC), quantifying the spatial knob's survival-SNR contribution | | `spatial_sbi_sim.py` | spatial diversity keeps corruption localized — how combining (by fade correlation ρ) moves frames out of the frame-wide-loss bin into the SBI-salvageable one | | `fused_fec_link.py` | chip-path `FusedFecSender` / `FusedFecReceiver` (baseline-vs-SBI) | -| `fused_fec_tx.py` / `fused_fec_rx.py` | chip-path CLIs (bytes ↔ `streamtx` / ``) | +| `fused_fec_tx.py` / `fused_fec_rx.py` | chip-path CLIs (bytes ↔ `streamtx` / `rx.frame` events) | | `fec_fusion_sim.py` | offline simulation: quantify SBI gain, size sub-blocks, no hardware | | `soft_erasure_fec.py` | errors-and-erasures Reed-Solomon (BCH form) + soft-reliability GMD; the reference that quantifies the inner-vs-outer soft-information question | | `fec_ab_sim.py` | the SBI-vs-plain-block-FEC A/B over measured channels (does SBI beat just adding parity, at equal overhead?) | diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 0000000..ecf56ca --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,160 @@ +# Logging + +Devourer's output is split into two planes: + +| Plane | Stream | Format | Audience | +|---|---|---|---| +| **Machine events** | stdout | JSON Lines — one JSON object per line | test scripts, AI agents, telemetry consumers | +| **Human diagnostics** | stderr | `devourer [I] message` (level letter T/D/I/W/E) | people debugging | + +`rxdemo 2>/dev/null` therefore yields a pure JSONL stream; `2>&1` into one log +is also safe — event parsers skip non-event lines. + +## The event contract + +Every event line serializes the event name **first**, exactly: + +```json +{"ev":"rx.txhit","hits":3,"total_rx":10,"len":247} +``` + +so shell consumers may `grep -F '"ev":"rx.txhit"'` without a JSON parser, and +everything richer goes through `json.loads` — see `tests/devourer_events.py` +(`iter_events` / `parse_event`), the shared helper the python test scripts use. + +Conventions: + +- Event names are lowercase dotted namespaces (`rx.frame`, `txpwr.state`). +- Register addresses/values/masks are hex **strings** (`"addr":"0x0808"`). +- Per-chain metrics are arrays (`"rssi":[-52,-60]`). +- A field the chip can't provide is JSON `null` (the old text format used `-`). +- `t` is monotonic **ms since process start**; periodic/marker events carry it, + per-frame events rely on `seq`/`tsfl` instead. +- Byte blobs (frame bodies, C2H payloads) are lowercase hex strings. +- A line is hard-capped at 64 KiB: an overflowing field is dropped (→ `null`) + and `"truncated":true` is appended. + +Emission is a single `fwrite` of the complete line followed by `fflush` +(`src/Event.h`) — per-line atomicity across threads (RX loop / coex / TX), and +a piped consumer (python subprocess, AI agent) sees each event immediately; +there is no libc full-buffering stall. `DEVOURER_EVENT_FLUSH=0` drops the +per-line flush for max-rate benches. + +## Demo environment knobs + +Mapped in `examples/common/env_config.cpp` (`apply_logging_env`); the library +itself reads no environment — programmatic consumers configure the same things +on the `Logger` object (`set_level`, `set_diag_stream`, `events().configure`). + +| Var | Values | Meaning | +|---|---|---| +| `DEVOURER_LOG_LEVEL` | `trace`,`debug` (default),`info`,`warn`,`error`,`silent` | stderr diagnostic verbosity | +| `DEVOURER_EVENTS` | `stdout` (default), `stderr`, `off` | event-stream destination — `sense` and `streamtx` default to stderr themselves (their stdout is a display / data path) | +| `DEVOURER_EVENT_FLUSH` | `0` | disable per-event flush | + +## Compile-time gating + +`DEVOURER_LOG_MAX_LEVEL` (CMake cache var: `TRACE`/`DEBUG`/`INFO`/`WARN`/ +`ERROR`/`SILENT`) is the compile-time floor: calls below it — including their +argument expressions at `DVR_TRACE`/`DVR_DEBUG` macro sites — compile to +nothing. Unset, the floor derives from `NDEBUG`: release builds drop +trace/debug, debug builds keep everything. Production firmware build: + +```sh +cmake -S . -B build -DDEVOURER_LOG_MAX_LEVEL=WARN +``` + +Hot-path trace/debug sites in the library go through `DVR_TRACE(logger, ...)` +/ `DVR_DEBUG(logger, ...)` so a disabled level also skips evaluating the +arguments (one branch). `info/warn/error` are plain `logger->` methods. + +## Event schema + +Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets]; +`|null` marks fields that go null when the chip doesn't expose them. + +### Init / infrastructure +| ev | emitter | fields | +|---|---|---| +| `init.timing` | L (`src/InitTimer.h`) + demos | stage ("scope.stage", e.g. "demo.first_rx_frame", "txdemo.first_tx_submit"), ms | +| `debug.wreg` | L (`DEVOURER_LOG_WRITES`) | addr "0x0nnn", width, val "0x…" | +| `hop.prof` | L (`DEVOURER_HOP_PROF`) | gen, ch, `_us`…, total_us | +| `tx.fail` | L (send failure; regress.py keys on it) | {status, actual_len, timeout} or {rc, timeout} | + +### RX plane +| ev | emitter | fields | +|---|---|---| +| `rx.pkt` | RX | n, len (first 10 + every 100th frame) | +| `rx.frame` | RX (`DEVOURER_STREAM_OUT`), duplex | rate, len, crc, icv, rssi[2], evm[2], snr[2], seq, tsfl, bw, stbc, ldpc, sgi, body hex | +| `rx.body` | RX (`DEVOURER_DUMP_BODY`) | rate, rssi[2], evm[2], snr[2], crc, len, body hex | +| `rx.corrupt` | RX (`DEVOURER_RX_DUMP_ALL`) | len, crc, icv, rate, bw, stbc, ldpc, sgi, rssi[2], evm[2], snr[2] | +| `rx.txhit` | RX, TX | hits, total_rx, len — canonical-SA (57:42:75:05:d6:00) matcher | +| `rx.count` | TX (its RX thread) | total, len | +| `rx.path` | RX (`DEVOURER_RX_ALLPATHS`) | seq, rssi[4], snr[4], evm[4] | +| `rx.path_mask` | L (toggle spec) | t, mask "0xNN" | +| `rx.scrambler` | RX (`DEVOURER_DUMP_SCRAMBLER`) | seed "0xNN", rate, hits, len | +| `rx.energy` | RX (`DEVOURER_RX_ENERGY_MS` / sweep) | t, [ch], cca_ofdm\|null, cca_cck\|null, fa_ofdm\|null, fa_cck\|null, igi\|null, [retune_us], frames, rssi_mean, rssi_max, snr_mean, snr_min, evm_mean | +| `rx.nhm` | RX | [ch], peak, busy, dur, hist[12] | +| `rx.quality` | RX (`DEVOURER_RXQUALITY`) | verdict, frames, rssi_mean_dbm, rssi_max_dbm, snr_mean_db, snr_min_db, evm_db\|null, noise_floor_dbm\|null, igi | +| `link.health` | RX (`DEVOURER_LINKHEALTH`) | verdict, rssi_dbm, snr_db, evm_db\|null, frames, fa_ofdm\|null, igi\|null, [igi_floor], [igi_ceil], cause, fix | +| `fw.c2h` | RX, duplex (`DEVOURER_TX_STATUS`) | len, bytes hex | + +### TX plane +| ev | emitter | fields | +|---|---|---| +| `tx.frame` | TX | n, rc — precoder demo variant: n, ok | +| `tx.stats` | TX | submitted, failed, was_timeout, last_rc | +| `tx.status` | RX, duplex (C2H TX_RPT decode) | hoff, queue, retry, airtime_us, rate | +| `tx.queue` | RX (`DEVOURER_QUEUE_POLL_MS`, 8814) | q1…q5 "0x%08x" | +| `tx.contx` | TX (continuous mode) | mcs, t_ms | + +### TX power / thermal +| ev | emitter | fields | +|---|---|---| +| `txpwr.set` | TX | index, t_ms | +| `txpwr.readback` | TX | index, cck1m, ofdm6m, mcs7, rb | +| `txpwr.state` | txpower | flat, offset_qdb, steps, satlo, sathi, cck, ofdm, mcs7, rb | +| `txpwr.caps` | txpower | supported, max, step_qdb, step_measured, min_qdb, max_qdb | +| `txpwr.offset` | txpower | requested, applied | +| `thermal` | RX, TX, txpower | t, raw, baseline\|null, [delta], status | + +### Hopping +| ev | emitter | fields | +|---|---|---| +| `hop.dwell` | TX, duplex | dwell, round, channel, frame, switch_us, t_ms, [mode] | +| `hop.done` | TX, duplex | frames, dwells | + +### Beamforming / CSI +| ev | emitter | fields | +|---|---|---| +| `bf.report` | BfReportDetect.h | kind, n, sa, nc, nr, bw, ng, len | +| `bf.any` | BfReportDetect.h | fc "0xNNNN", cat, act, crc, len | +| `bf.report_raw` | BfReportDetect.h, sense (stderr) | frame hex | +| `bf.csi` | BfReportDetect.h (mode 3) | len, csi hex | +| `csi.hit` / `csi.wedged` | RX (`DEVOURER_RX_DUMP_CSI`) | hit, selector "0x…", value "0x…" / selector | + +### Stream demos / misc +| ev | emitter | fields | +|---|---|---| +| `stream.rx` / `stream.ctl` / `stream.eof` / `stream.tx` | duplex (stdout), streamtx (stderr) | hits / op, len / tx_count, [bytes] / n, ok, psdu, [total] | +| `stream.done` | streamtx (stderr) | sent | +| `svc.stats` | svctx | frames, crit, t0, t1, t2, t3plus | +| `doctor.verdict` | doctor | verdict, reasons "0x…", efuse_reads, efuse_mismatch, efuse_bad_id, efuse_id, fw_attempted, fw_ready, rx_ok, rx_crc, init | + +## Not JSON by design + +Register dumps meant for line-by-line diffing against kernel output keep their +text format on the **diagnostic plane** (stderr, `devourer [I] ` prefix): +the `DEVOURER_DUMP_CANARY` canary block (`KIND 0xADDR = 0xVALUE`, matching +`tools/canary_kernel_dump.sh` / `iwpriv read`; `tests/canary_diff.py` strips +the prefix itself), `DEVOURER_BB_DUMP`, `DEVOURER_EFUSE_DUMP`, +`DEVOURER_LOG_TXPWR`. The doctor tool's human report stays on stdout — its +machine summary is the `doctor.verdict` event. + +## Schema stability + +Event names and listed fields are an interface: test scripts and out-of-tree +consumers (OpenIPC integrations) parse them. Additive changes (new fields, new +events) are safe; renames/removals need the same-PR consumer sweep this schema +landed with. This table is the source of truth — update it with any emitter +change. diff --git a/docs/rx-spectrum-sensing.md b/docs/rx-spectrum-sensing.md index f2b63d1..712aa95 100644 --- a/docs/rx-spectrum-sensing.md +++ b/docs/rx-spectrum-sensing.md @@ -35,24 +35,25 @@ whose per-tone SNR / V-angle variance localises an interferer to ~1 MHz. ## `DEVOURER_RX_ENERGY_MS` — the energy sensor -`rxdemo` with `DEVOURER_RX_ENERGY_MS=N` emits one `` -line every `N` ms: +`rxdemo` with `DEVOURER_RX_ENERGY_MS=N` emits one `rx.energy` +event every `N` ms: -``` -cca_ofdm=.. cca_cck=.. fa_ofdm=.. fa_cck=.. igi=.. frames=N - rssi_mean=.. rssi_max=.. snr_mean=.. snr_min=.. +```json +{"ev":"rx.energy","t":..,"cca_ofdm":..,"cca_cck":..,"fa_ofdm":..,"fa_cck":.., + "igi":..,"frames":N,"rssi_mean":..,"rssi_max":..,"snr_mean":..,"snr_min":..} ``` -`cca_*`/`fa_*`/`igi` are frame-free (`IRtlDevice::GetRxEnergy`); the FA/CCA counts -are the delta since the previous line (each read resets the hardware counters). +`cca_*`/`fa_*`/`igi` are frame-free (`IRtlDevice::GetRxEnergy`, `null` on a chip +that doesn't expose them); the FA/CCA counts +are the delta since the previous event (each read resets the hardware counters). `rssi_*`/`snr_*`/`frames` are the rolling per-frame aggregate over the interval. The same call also fills the **NHM power histogram**, emitted as a companion -`` line (kept on a distinct tag so the `` format -its regex consumers key on is untouched): +`rx.nhm` event (kept distinct so the `rx.energy` fields its consumers key on +are untouched): -``` -peak=.. busy=.. dur=.. hist=b0,b1,..,b11 +```json +{"ev":"rx.nhm","peak":..,"busy":..,"dur":..,"hist":[b0,b1,..,b11]} ``` `peak` is the fullest bucket (0 = noise floor, higher = energy in a higher power @@ -89,7 +90,7 @@ both an unambiguous detection: - **collapse** — a strong co-located carrier saturates the AGC, the RX goes deaf, and the count (and received frames) fall toward zero (the 1T1R 8821AU / 8821CU). -The `` histogram is the corroborating signal: the tone moves the +The `rx.nhm` histogram is the corroborating signal: the tone moves the distribution's mass into higher power buckets (measured: peak bucket 5→8 on the 8822CU under a co-located CW tone), so `peak` rises and the high-index `hist` buckets fill where the baseline had zeros. @@ -108,8 +109,8 @@ frequency, sweep. With `DEVOURER_RX_SWEEP="1,6,11"` the sensor cycles the listed bins — the RX loop runs on a worker thread while the main thread retunes between reads via `IRtlDevice::FastRetune` (the lean intra-band hop path every generation implements; `DEVOURER_RX_SWEEP_FULL=1` forces the full -`SetMonitorChannel` per dwell for A/B) — and emits one `ch=N` -line per bin. Aggregating those into an energy-vs-frequency bar chart peaks (or, +`SetMonitorChannel` per dwell for A/B) — and emits one `rx.energy` +event (tagged `"ch":N`) per bin. Aggregating those into an energy-vs-frequency bar chart peaks (or, on the saturating 1T1R parts, dips) at the tone's channel. The bin spec uses the SweepSpec grammar (`src/SweepSpec.h`, shared with diff --git a/docs/startup-time.md b/docs/startup-time.md index f497d22..c08a832 100644 --- a/docs/startup-time.md +++ b/docs/startup-time.md @@ -9,9 +9,9 @@ RF-quiet rooms) that make naive startup benchmarks lie. "Ready" is measured, not inferred: - **devourer RX** — `exec → first 802.11 frame delivered` (`rxdemo`, - its `init-timing: demo.first_rx_frame` line). + its `init.timing` event, stage `demo.first_rx_frame`). - **devourer TX** — `exec → first bulk-OUT submitted` (`txdemo`, - `init-timing: txdemo.first_tx_submit`; includes the demo's settle sleep — + the `txdemo.first_tx_submit` timing stage; includes the demo's settle sleep — that's the user-visible latency). - **kernel** — `insmod → netdev registered → monitor-mode setup → first tcpdump frame`. Same finish line: a frame in hand. @@ -126,7 +126,7 @@ Three things the harness needs to be honest: probe (e.g. on a marginal unit) leaves the chip half-wedged mid-fwdl, and whatever the next driver does runs from that state. -Per-stage numbers come from the `init-timing: . = N ms` +Per-stage numbers come from the `{"ev":"init.timing","stage":".","ms":N}` lines the library emits (`src/InitTimer.h`); `bench_init.py` aggregates medians and writes a markdown report. A/B variants (`--variants`) isolate libusb log level, USB reset, and the TX-power loop. diff --git a/examples/common/BfReportDetect.h b/examples/common/BfReportDetect.h index f954260..5a6a991 100644 --- a/examples/common/BfReportDetect.h +++ b/examples/common/BfReportDetect.h @@ -1,5 +1,5 @@ -/* BfReportDetect — console detector for beamforming self-sounding reports, - * shared by rxdemo (2-adapter rig: separate capture radio) and +/* 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. * @@ -10,11 +10,15 @@ * 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 summary (Nc/Nr/BW/Ng, SA) per report - * 2 FC/category of EVERY frame (subtype survey) - * 3 mode 1 + capped CSI-payload hexdump (first frames) - * 4 mode 1 + full frame hex (for the decoder, + * 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 @@ -22,13 +26,19 @@ #include #include +#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() < 27) + if (!mode_s || packet.Data.size() < 27 || bf_events == nullptr) return; const char mode = mode_s[0]; const uint8_t *d = packet.Data.data(); @@ -37,10 +47,15 @@ inline void detect_report(const Packet &packet) { const bool ht = (cat == 0x07 && act == 0x00); if (mode == '2') { static int any = 0; - if (++any <= 4000) - printf("fc=%02x%02x cat=%02x act=%02x crc=%u " - "len=%zu\n", d[0], d[1], cat, act, - packet.RxAtrib.crc_err ? 1u : 0u, packet.Data.size()); + 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)) { @@ -49,25 +64,31 @@ inline void detect_report(const Packet &packet) { 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; - printf("%s n=%d sa=%02x:%02x:%02x:%02x:%02x:%02x " - "Nc=%u Nr=%u BW=%u Ng=%u len=%zu\n", - vht ? "VHT" : "HT", rpt, d[10], d[11], d[12], d[13], d[14], - d[15], nc, nr, chw, ng, packet.Data.size()); + 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) */ size_t end = packet.Data.size() >= 4 ? packet.Data.size() - 4 : off; - printf(" csi[%zu]:", end - off); - for (size_t i = off; i < end && i < off + 40; ++i) - printf(" %02x", d[i]); - printf("\n"); + size_t n = end > off ? end - off : 0; + devourer::Ev(*bf_events, "bf.csi") + .f("len", n) + .hex("csi", d + off, n < 40 ? n : 40); } if (mode == '4' && rpt <= 200) { - printf(""); - for (size_t i = 0; i < packet.Data.size(); ++i) - printf("%02x", d[i]); - printf("\n"); + /* Full-frame hex — consumed by tools/bf_report_decode.py. */ + devourer::Ev(*bf_events, "bf.report_raw") + .hex("frame", d, packet.Data.size()); } - fflush(stdout); } } diff --git a/examples/common/env_config.cpp b/examples/common/env_config.cpp index 28e5b56..543379d 100644 --- a/examples/common/env_config.cpp +++ b/examples/common/env_config.cpp @@ -142,3 +142,38 @@ 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); + } +} diff --git a/examples/common/env_config.h b/examples/common/env_config.h index f0d154d..cfe7f73 100644 --- a/examples/common/env_config.h +++ b/examples/common/env_config.h @@ -11,6 +11,7 @@ #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. */ @@ -18,3 +19,15 @@ 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); diff --git a/examples/doctor/main.cpp b/examples/doctor/main.cpp index f25a15d..4fde07d 100644 --- a/examples/doctor/main.cpp +++ b/examples/doctor/main.cpp @@ -21,11 +21,11 @@ * (bench flood / busy AP on the channel). * * Verdict: HEALTHY / SUSPECT / FAILING (+ reasons), exit code 0 / 1 / 2 - * (3 = tool/open error). Machine-readable summary on one line: + * (3 = tool/open error). Machine-readable summary as one JSONL event: * - * 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 + * {"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 @@ -121,7 +121,8 @@ bool parse_args(int argc, char **argv, Args &a) { else if (k == "--expect-traffic") a.expect_traffic = true; else { - std::fprintf(stderr, "unknown/incomplete arg: %s\n", k.c_str()); + std::fprintf(stderr, "devourer [W] unknown/incomplete arg: %s\n", + k.c_str()); return false; } } @@ -318,15 +319,19 @@ int main(int argc, char **argv) { std::printf(" - heard nothing (supply known traffic + --expect-traffic " "for a hard verdict)\n"); - std::printf("verdict=%s reasons=0x%x efuse_reads=%d " - "efuse_mismatch=%d efuse_bad_id=%d efuse_id=0x%04x " - "fw_attempted=%d fw_ready=%d rx_ok=%u rx_crc=%u init=%d\n", - devourer::AdapterVerdictName(v), reasons, in.efuse.reads, - in.efuse.mismatched_reads, in.efuse.invalid_id_reads, - in.efuse.eeprom_id, in.fw.attempted ? 1 : 0, - in.fw.ready_ok ? 1 : 0, in.rx_frames_ok, in.rx_frames_crc, - in.init_completed ? 1 : 0); - std::fflush(stdout); + /* 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(); libusb_close(handle); diff --git a/examples/duplex/main.cpp b/examples/duplex/main.cpp index ca2bc52..ce60514 100644 --- a/examples/duplex/main.cpp +++ b/examples/duplex/main.cpp @@ -18,9 +18,9 @@ // terminates. // // RX emission on stdout mirrors examples/rx/main.cpp's DEVOURER_STREAM_OUT path — -// `rate=R len=L body=HEX` for every frame matching the -// canonical SA. Other stdout output is suppressed; stderr carries logger and -// counters. +// 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 #include @@ -81,8 +81,8 @@ static constexpr uint16_t kRealtekProductIds[] = { // (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 any tooling that -// already grep'd lines keeps working unchanged. +// 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; @@ -102,13 +102,16 @@ static std::vector build_dot11_probe_req() { return h; } -// RX callback — emits `` on canonical-SA matches. Wrapped in -// a mutex against the TX thread's printf calls so the two log streams don't -// interleave mid-line. (The TX thread only writes to stderr, RX to stdout, so -// in practice they don't collide, but keeping the mutex is cheap.) -static std::mutex g_print_mu; +// 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 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 @@ -119,11 +122,9 @@ static const bool g_tx_status_enabled = static void packet_processor(const Packet &packet) { if (packet.RxAtrib.pkt_rpt_type == RX_PACKET_TYPE::C2H_PACKET) { if (!g_tx_status_enabled) return; - std::lock_guard lk(g_print_mu); - std::printf("len=%zu bytes=", packet.Data.size()); - for (size_t i = 0; i < packet.Data.size(); ++i) - std::printf("%02x", packet.Data[i]); - std::printf("\n"); + 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; @@ -133,38 +134,45 @@ static void packet_processor(const Packet &packet) { uint16_t qt_raw = static_cast(h[3] | (h[4] << 8)); uint32_t qt_us = static_cast(qt_raw) * 256u; uint8_t rate = h[5]; - std::printf("hoff=%zu queue=%u retry=%u " - "airtime_us=%u rate=%u\n", - hoff, queue, retry, qt_us, rate); + devourer::Ev(*g_ev, "tx.status") + .f("hoff", hoff) + .f("queue", queue) + .f("retry", retry) + .f("airtime_us", qt_us) + .f("rate", rate); } } - std::fflush(stdout); return; } if (packet.Data.size() < 16) return; if (std::memcmp(packet.Data.data() + 10, kCanonicalSa, 6) != 0) return; long hits = ++g_rx_hits; - std::lock_guard lk(g_print_mu); - // Full field set (mirrors examples/rx/main.cpp) so the adaptive VRX can score RSSI/SNR - // and the VTX can read RCF/DISC bodies + ACK_SEQ. - std::printf("rate=%u len=%zu crc_err=%u icv_err=%u " - "rssi=%d,%d evm=%d,%d snr=%d,%d seq=%u tsfl=%u " - "bw=%u stbc=%u ldpc=%u sgi=%u body=", - packet.RxAtrib.data_rate, packet.Data.size(), - packet.RxAtrib.crc_err ? 1u : 0u, packet.RxAtrib.icv_err ? 1u : 0u, - packet.RxAtrib.rssi[0], packet.RxAtrib.rssi[1], - packet.RxAtrib.evm[0], packet.RxAtrib.evm[1], - packet.RxAtrib.snr[0], packet.RxAtrib.snr[1], - packet.RxAtrib.seq_num, packet.RxAtrib.tsfl, - packet.RxAtrib.bw, packet.RxAtrib.stbc, - packet.RxAtrib.ldpc, packet.RxAtrib.sgi); - for (size_t i = 24; i < packet.Data.size(); ++i) - std::printf("%02x", packet.Data[i]); - std::printf("\n"); - std::fflush(stdout); + // 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) { - std::fprintf(stderr, "rx hits=%ld\n", hits); - std::fflush(stderr); + devourer::Ev(*g_ev, "stream.rx").f("hits", hits); } } @@ -188,7 +196,7 @@ static void tx_thread(TxArgs args) { if (stream_stdin::read_exact(stdin, len_bytes, sizeof(len_bytes)) != stream_stdin::ReadResult::Ok) { // Clean EOF or short read — TX side done. RX keeps running. - std::fprintf(stderr, "tx EOF after %ld PSDUs\n", tx_count); + devourer::Ev(*g_ev, "stream.eof").f("tx_count", tx_count); break; } uint32_t len = static_cast(len_bytes[0]) @@ -220,20 +228,20 @@ static void tx_thread(TxArgs args) { .Channel = ctl[1], .ChannelOffset = ctl[2], .ChannelWidth = static_cast(ctl[3])}); } - std::fprintf(stderr, "ctl op=%u len=%u\n", op, clen); + devourer::Ev(*g_ev, "stream.ctl").f("op", op).f("len", clen); continue; } if (len == 0 || len > args.max_psdu) { - std::fprintf(stderr, - "tx PSDU len %u out of range (max %zu)\n", - len, args.max_psdu); + args.logger->error("tx PSDU len {} out of range (max {})", len, + args.max_psdu); break; } std::vector psdu(len); if (stream_stdin::read_exact(stdin, psdu.data(), len) != stream_stdin::ReadResult::Ok) { - std::fprintf(stderr, "tx EOF mid-PSDU (%u bytes)\n", len); + /* 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(); @@ -246,10 +254,10 @@ static void tx_thread(TxArgs args) { bool ok = args.rtl->send_packet(tx_buf.data(), tx_buf.size()); ++tx_count; if (tx_count <= 5 || tx_count % 500 == 0) { - std::fprintf(stderr, - "tx #%ld ok=%d psdu=%u\n", - tx_count, ok ? 1 : 0, len); - std::fflush(stderr); + 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)); @@ -259,6 +267,8 @@ static void tx_thread(TxArgs args) { int main(int argc, char **argv) { auto logger = std::make_shared(); + apply_logging_env(*logger); /* DEVOURER_LOG_LEVEL / DEVOURER_EVENTS / ... */ + g_ev = &logger->events(); int interval_ms = 2; size_t max_psdu = 4096; diff --git a/examples/precoder/main.cpp b/examples/precoder/main.cpp index 915d413..aa6cb8f 100644 --- a/examples/precoder/main.cpp +++ b/examples/precoder/main.cpp @@ -79,8 +79,9 @@ static const uint8_t kRadiotapLegacy6M[13] = { 0x00, 0x0c, 0x00, 0x08, 0x00, 0x00}; // Canonical TX-validation source MAC — shared with examples/tx/main.cpp, -// examples/rx/main.cpp's `` matcher, tests/regress.py (CANONICAL_SA) -// and tests/inject_beacon.py. Change all of them together if it ever moves. +// 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 @@ -110,6 +111,7 @@ static bool read_file(const std::string &path, std::vector &out) { int main(int argc, char **argv) { auto logger = std::make_shared(); + apply_logging_env(*logger); /* DEVOURER_LOG_LEVEL / DEVOURER_EVENTS / ... */ std::string psdu_path; long count = -1; // -1 == loop forever (like txdemo) @@ -246,9 +248,11 @@ int main(int argc, char **argv) { 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) { - printf("TX #%ld ok=%d\n", tx_count, ok ? 1 : 0); - fflush(stdout); + devourer::Ev(logger->events(), "tx.frame") + .f("n", tx_count) + .f("ok", ok); } std::this_thread::sleep_for(std::chrono::milliseconds(interval_ms)); } diff --git a/examples/rx/main.cpp b/examples/rx/main.cpp index fadd094..011a251 100644 --- a/examples/rx/main.cpp +++ b/examples/rx/main.cpp @@ -55,8 +55,12 @@ static int g_rx_count = 0; static RtlJaguarDevice *g_rtl_device = nullptr; #endif -/* Process-start reference for the init-timing lines (see src/InitTimer.h). - * `init-timing: demo.first_rx_frame` is the end-to-end "ready to RX" mark: +/* Event sink for the demo's own JSONL emissions (packetProcessor is a free + * function) — points at the main() Logger's sink, set before Init(). */ +static devourer::EventSink *g_ev = nullptr; + +/* Process-start reference for the init.timing events (see src/InitTimer.h). + * stage=demo.first_rx_frame is the end-to-end "ready to RX" mark: * exec → first 802.11 frame delivered to the packet processor. */ static const std::chrono::steady_clock::time_point g_proc_start = std::chrono::steady_clock::now(); @@ -67,14 +71,14 @@ static long long ms_since_start() { } /* DEVOURER_TX_STATUS=1: surface chip-side C2H frames (TX status reports, - * various diagnostic pings) on `` with a raw hex dump, plus + * various diagnostic pings) on `fw.c2h` events with a raw hex dump, plus * a best-effort decode of the 8814A TX_RPT payload layout. The C2H * sub-type ID isn't enumerated in the vendored headers, so the decode is * speculative — the raw hex stays in the line so an observer can * validate the sub-type against on-air capture. * * DEVOURER_QUEUE_POLL_MS=N: periodic snapshot of the 8814A REG_FIFOPAGE_INFO - * registers, throttled to one `` line per second on RX hook. + * registers, throttled to one `tx.queue` event per second on RX hook. * 8814-only (8812/8821 don't expose these registers as per-queue free pages). */ static const bool g_tx_status_enabled = std::getenv("DEVOURER_TX_STATUS") != nullptr; @@ -84,7 +88,7 @@ static const uint32_t g_qd_poll_ms = []() -> uint32_t { }(); /* DEVOURER_THERMAL_POLL_MS=N: periodic snapshot of the chip thermal meter - * (RF[A][0x42][15:10]), one `` line per interval. Works on + * (RF[A][0x42][15:10]), one `thermal` event per interval. Works on * every Jaguar member. 0 = disabled. DEVOURER_THERMAL_WARN_DELTA overrides the * warn threshold (thermal units above the EFUSE baseline; default 15). */ static const uint32_t g_thermal_poll_ms = []() -> uint32_t { @@ -98,8 +102,8 @@ static const int g_thermal_warn_delta = []() -> int { /* DEVOURER_RX_DUMP_CSI=hex,hex,... (or "0x1a,0x20,0x40"): F2 research * spike. On each canonical-SA RX frame (first N frames), read BB - * dbgport 0x8FC at each selector and emit - * selector=0xNN value=0xNNNNNNNN + * dbgport 0x8FC at each selector and emit a csi.hit event + * {"ev":"csi.hit","selector":"0xNN","value":"0xNNNNNNNN"} * * This is a SELECTOR-SWEEP framework — the actual per-subcarrier IQ * selector is missing from in-tree sources (see BbDbgportReader.h for @@ -110,7 +114,7 @@ static const int g_thermal_warn_delta = []() -> int { * * BRICK RISK: enabling this writes to 0x8FC while RX is live. If the * chip stops responding after a sweep, the reader self-wedges (see - * ) and refuses further writes; recover with + * csi.wedged) and refuses further writes; recover with * libusb_reset_device / usbreset / power-cycle. */ static const std::vector g_csi_selectors = []() -> std::vector { const char *e = std::getenv("DEVOURER_RX_DUMP_CSI"); @@ -135,7 +139,7 @@ static constexpr int kCsiMaxFrames = 8; /* DEVOURER_RX_ENERGY_MS=N: periodic frame-free RX energy / channel-busy * telemetry — the read side of DEVOURER_CW_TONE. Each interval emits one - * line combining the chip's phydm FA/CCA counters + IGI + * rx.energy event combining the chip's phydm FA/CCA counters + IGI * (IRtlDevice::GetRxEnergy, frame-free, all three generations) with a rolling * per-frame RSSI/SNR aggregate. A second adapter running this detects the first * adapter's CW carrier as a jump in cca_ofdm / fa_ofdm and a rise in igi. @@ -145,8 +149,8 @@ static const uint32_t g_rx_energy_ms = []() -> uint32_t { return (e && *e) ? static_cast(std::strtoul(e, nullptr, 0)) : 0; }(); -/* DEVOURER_LINKHEALTH=1 — emit a verdict line alongside - * each window (src/LinkHealth.h): the sensor tuple classified +/* DEVOURER_LINKHEALTH=1 — emit a link.health verdict event alongside + * each rx.energy window (src/LinkHealth.h): the sensor tuple classified * into a plain-language cause + fix. Rides the DEVOURER_RX_ENERGY_MS cadence, so * that must be set too (a linkhealth verdict needs the same window snapshot). */ static const bool g_linkhealth = []() -> bool { @@ -154,7 +158,7 @@ static const bool g_linkhealth = []() -> bool { return e && *e && std::strcmp(e, "0") != 0; }(); -/* DEVOURER_RXQUALITY=1 — emit a line from the library's +/* DEVOURER_RXQUALITY=1 — emit an rx.quality event from the library's * GetRxQuality() feed (the runtime API a linked adaptive-link controller reads). * Rides the DEVOURER_RX_ENERGY_MS cadence like linkhealth. */ static const bool g_rxquality = []() -> bool { @@ -166,7 +170,7 @@ static const bool g_rxquality = []() -> bool { * sweep. Cycle the listed bins (SweepSpec grammar: channels, channel ranges, * or MHz ranges — the latter for issue-#149-style narrowband maps), dwelling * DEVOURER_RX_SWEEP_DWELL_MS (default 300) on each, and emit one - * ch=N line per bin. The RX loop runs on a worker thread so + * rx.energy event (with ch=N) per bin. The RX loop runs on a worker thread so * the main thread is free to retune (FastRetune) between reads — a live * energy-vs-frequency map that localizes an interferer (peaks, or dips on the * 1T1R parts that saturate, at the tone's channel). Empty = disabled. */ @@ -201,7 +205,7 @@ static RxAgg g_rxagg; /* The canonical txdemo beacon SA (same constant as examples/tx/main.cpp and * tests/regress.py CANONICAL_SA — change all three together): the - * matcher and the "canon" aggregate filter below. */ + * rx.txhit matcher and the "canon" aggregate filter below. */ static const uint8_t kTxSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; /* DEVOURER_RX_AGG_SA: restrict the per-frame aggregate to frames whose SA @@ -240,8 +244,8 @@ static bool agg_sa_match(const Packet &packet) { } /* Emit the frame-free NHM power histogram (IRtlDevice::GetRxEnergy fills it) as - * a distinct line so it never disturbs the - * format its regex consumers key on. `peak` = the fullest bucket (0 = quiet + * a distinct rx.nhm event so it never disturbs the rx.energy + * fields its consumers key on. `peak` = the fullest bucket (0 = quiet * noise floor, higher = energy is landing in a higher power band, e.g. under an * interferer); `busy` = percent of samples above the lowest bucket; `hist` = * the 12 raw bucket counts (IGI-referenced, low→high power). ch<0 omits the @@ -251,23 +255,18 @@ static void emit_nhm(const RxEnergy &e, int ch) { return; uint32_t total = 0, peak = 0; int peak_k = 0; + int hist[12]; for (int k = 0; k < 12; k++) { total += e.nhm[k]; if (e.nhm[k] > peak) { peak = e.nhm[k]; peak_k = k; } + hist[k] = static_cast(e.nhm[k]); } int busy = total ? static_cast(100 * (total - e.nhm[0]) / total) : 0; - char hist[96]; - int off = 0; - for (int k = 0; k < 12; k++) - off += std::snprintf(hist + off, sizeof(hist) - off, "%s%u", - k ? "," : "", e.nhm[k]); + devourer::Ev ev(*g_ev, "rx.nhm"); if (ch >= 0) - printf("ch=%d peak=%d busy=%d dur=%u hist=%s\n", ch, peak_k, - busy, e.nhm_duration, hist); - else - printf("peak=%d busy=%d dur=%u hist=%s\n", peak_k, busy, - e.nhm_duration, hist); - fflush(stdout); + ev.f("ch", ch); + ev.f("peak", peak_k).f("busy", busy).f("dur", e.nhm_duration) + .arr("hist", hist, 12); } static void packetProcessor(const Packet &packet) { @@ -276,10 +275,9 @@ static void packetProcessor(const Packet &packet) { * 802.11 MPDU layout) doesn't try to read SA bytes from a C2H payload. */ if (packet.RxAtrib.pkt_rpt_type == RX_PACKET_TYPE::C2H_PACKET) { if (g_tx_status_enabled) { - printf("len=%zu bytes=", packet.Data.size()); - for (size_t i = 0; i < packet.Data.size(); ++i) - printf("%02x", packet.Data[i]); - printf("\n"); + devourer::Ev(*g_ev, "fw.c2h") + .f("len", packet.Data.size()) + .hex("bytes", packet.Data.data(), packet.Data.size()); /* Best-effort 8814A TX_RPT decode. The GET_8814A_C2H_TX_RPT_* * macros (hal/rtl8814a_cmd.h:118-125) read from a "_Header" pointer * — which, in upstream Realtek code, points one or two bytes past @@ -295,12 +293,14 @@ static void packetProcessor(const Packet &packet) { uint16_t qt_raw = static_cast(h[3] | (h[4] << 8)); uint32_t qt_us = static_cast(qt_raw) * 256u; uint8_t rate = h[5]; - printf("hoff=%zu queue=%u retry=%u " - "airtime_us=%u rate=%u\n", - hoff, queue, retry, qt_us, rate); + devourer::Ev(*g_ev, "tx.status") + .f("hoff", hoff) + .f("queue", queue) + .f("retry", retry) + .f("airtime_us", qt_us) + .f("rate", rate); } } - fflush(stdout); } return; } @@ -318,36 +318,40 @@ static void packetProcessor(const Packet &packet) { } if (g_rx_count == 1) { - printf("init-timing: demo.first_rx_frame = %lld ms\n", - ms_since_start()); - fflush(stdout); + devourer::Ev(*g_ev, "init.timing") + .f("stage", "demo.first_rx_frame") + .f("ms", ms_since_start()); } if (g_rx_count <= 10 || g_rx_count % 100 == 0) { - printf("RX pkt #%d (len=%zu)\n", g_rx_count, packet.Data.size()); - fflush(stdout); + devourer::Ev(*g_ev, "rx.pkt") + .f("n", g_rx_count) + .f("len", packet.Data.size()); } - /* DEVOURER_RX_DUMP_ALL=1: emit a `` line for EVERY - * frame regardless of SA, with chip-flag bits and phy-soft metrics. + /* DEVOURER_RX_DUMP_ALL=1: emit an `rx.corrupt` event for EVERY frame + * regardless of SA, with chip-flag bits and phy-soft metrics. * Consumed by tools/precoder/corruption_survey.py for the FEC-design * corruption-pattern survey. Pairs with DEVOURER_RX_KEEP_CORRUPTED to * also pass through chip-FCS-error frames. The body is omitted from this - * line by design (a hot survey would inflate the log past usable size); + * event by design (a hot survey would inflate the log past usable size); * pkt_len + the chip flags + phy metrics is what aggregates carry. */ static const bool dump_all = std::getenv("DEVOURER_RX_DUMP_ALL") != nullptr; if (dump_all) { - printf("len=%zu crc_err=%u icv_err=%u " - "rate=%u bw=%u stbc=%u ldpc=%u sgi=%u rssi=%d,%d evm=%d,%d snr=%d,%d\n", - packet.Data.size(), - packet.RxAtrib.crc_err ? 1u : 0u, - packet.RxAtrib.icv_err ? 1u : 0u, - packet.RxAtrib.data_rate, - packet.RxAtrib.bw, packet.RxAtrib.stbc, - packet.RxAtrib.ldpc, packet.RxAtrib.sgi, - packet.RxAtrib.rssi[0], packet.RxAtrib.rssi[1], - packet.RxAtrib.evm[0], packet.RxAtrib.evm[1], - packet.RxAtrib.snr[0], packet.RxAtrib.snr[1]); - fflush(stdout); + 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]}; + devourer::Ev(*g_ev, "rx.corrupt") + .f("len", packet.Data.size()) + .f("crc", packet.RxAtrib.crc_err ? 1 : 0) + .f("icv", packet.RxAtrib.icv_err ? 1 : 0) + .f("rate", packet.RxAtrib.data_rate) + .f("bw", packet.RxAtrib.bw) + .f("stbc", packet.RxAtrib.stbc) + .f("ldpc", packet.RxAtrib.ldpc) + .f("sgi", packet.RxAtrib.sgi) + .arr("rssi", rssi, 2) + .arr("evm", evm, 2) + .arr("snr", snr, 2); } /* BF self-sounding report detector (DEVOURER_BF_DETECT_REPORT modes 1-4) — * shared with txdemo's single-radio capture, see BfReportDetect.h. */ @@ -362,9 +366,10 @@ static void packetProcessor(const Packet &packet) { static int hits = 0; ++hits; if (hits <= 10 || hits % 100 == 0) { - printf("txdemo SA match: hits=%d total_rx=%d len=%zu\n", - hits, g_rx_count, packet.Data.size()); - fflush(stdout); + devourer::Ev(*g_ev, "rx.txhit") + .f("hits", hits) + .f("total_rx", g_rx_count) + .f("len", packet.Data.size()); } #if defined(DEVOURER_HAVE_JAGUAR1) /* F2: BB-dbgport sweep on the first kCsiMaxFrames canonical-SA frames. @@ -374,16 +379,16 @@ static void packetProcessor(const Packet &packet) { for (uint32_t sel : g_csi_selectors) { uint32_t v = g_rtl_device->read_bb_dbgport(sel); if (g_rtl_device->bb_dbgport_wedged()) { - printf("after selector=0x%08x — reader " - "refusing further writes. Recover with " - "libusb_reset_device / usbreset.\n", sel); - fflush(stdout); + /* reader refuses further writes; recover with + * libusb_reset_device / usbreset */ + devourer::Ev(*g_ev, "csi.wedged").hexf("selector", sel, 8); break; } - printf("hit=%d selector=0x%08x value=0x%08x\n", - hits, sel, v); + devourer::Ev(*g_ev, "csi.hit") + .f("hit", hits) + .hexf("selector", sel, 8) + .hexf("value", v, 8); } - fflush(stdout); } #endif /* DEVOURER_DUMP_SCRAMBLER=1: print the descrambler seed the chip @@ -396,10 +401,11 @@ static void packetProcessor(const Packet &packet) { static const bool dump_scrambler = std::getenv("DEVOURER_DUMP_SCRAMBLER") != nullptr; if (dump_scrambler && (hits <= 20 || hits % 100 == 0)) { - printf("seed=0x%02x rate=%u hits=%d len=%zu\n", - packet.RxAtrib.scrambler, packet.RxAtrib.data_rate, hits, - packet.Data.size()); - fflush(stdout); + devourer::Ev(*g_ev, "rx.scrambler") + .hexf("seed", packet.RxAtrib.scrambler, 2) + .f("rate", packet.RxAtrib.data_rate) + .f("hits", hits) + .f("len", packet.Data.size()); } /* DEVOURER_DUMP_BODY=1: print the RX rate index (DESC_RATE*: 0x04=6M * OFDM, 0x00=1M CCK, 0x0c+=HT/VHT MCS) and the 802.11 frame body @@ -423,30 +429,31 @@ static void packetProcessor(const Packet &packet) { std::getenv("DEVOURER_RX_KEEP_CORRUPTED") != nullptr; const bool corrupted = packet.RxAtrib.crc_err || packet.RxAtrib.icv_err; /* DEVOURER_RX_ALLPATHS=1: emit all four RX chains (A,B,C,D) of per-stream - * RSSI / SNR / EVM on a distinct line. Opt-in and - * separate so the canonical two-path / - * format its regex consumers key on stays untouched. Paths C/D are - * non-zero only on the 8814AU (4T4R); on 8812/8821 they read 0. Consumed - * by tests/antenna_decorrelation.py to measure inter-chain envelope - * correlation and realised diversity gain (spatial-diversity axis). */ + * RSSI / SNR / EVM on a distinct `rx.path` event. Opt-in and separate so + * the canonical two-path `rx.frame`/`rx.body` events stay untouched. + * Paths C/D are non-zero only on the 8814AU (4T4R); on 8812/8821 they + * read 0. Consumed by tests/antenna_decorrelation.py to measure + * inter-chain envelope correlation and realised diversity gain + * (spatial-diversity axis). */ static const bool rxpath_out = std::getenv("DEVOURER_RX_ALLPATHS") != nullptr; if (rxpath_out && (!corrupted || keep_corrupted)) { - printf("seq=%u rssi=%d,%d,%d,%d snr=%d,%d,%d,%d " - "evm=%d,%d,%d,%d\n", - packet.RxAtrib.seq_num, packet.RxAtrib.rssi[0], - packet.RxAtrib.rssi[1], packet.RxAtrib.rssi[2], - packet.RxAtrib.rssi[3], packet.RxAtrib.snr[0], - packet.RxAtrib.snr[1], packet.RxAtrib.snr[2], - packet.RxAtrib.snr[3], packet.RxAtrib.evm[0], - packet.RxAtrib.evm[1], packet.RxAtrib.evm[2], - packet.RxAtrib.evm[3]); - fflush(stdout); + const int rssi[4] = {packet.RxAtrib.rssi[0], packet.RxAtrib.rssi[1], + packet.RxAtrib.rssi[2], packet.RxAtrib.rssi[3]}; + const int snr[4] = {packet.RxAtrib.snr[0], packet.RxAtrib.snr[1], + packet.RxAtrib.snr[2], packet.RxAtrib.snr[3]}; + const int evm[4] = {packet.RxAtrib.evm[0], packet.RxAtrib.evm[1], + packet.RxAtrib.evm[2], packet.RxAtrib.evm[3]}; + devourer::Ev(*g_ev, "rx.path") + .f("seq", packet.RxAtrib.seq_num) + .arr("rssi", rssi, 4) + .arr("snr", snr, 4) + .arr("evm", evm, 4); } if (stream_out && (!corrupted || keep_corrupted)) { /* Per-stream phy soft metrics (RSSI / EVM / SNR for paths A,B; on * 8814AU paths C,D would also be non-zero but we surface only A,B - * here to stay aligned with 's format). These are + * here to stay aligned with rx.body's fields). These are * link-quality measurements at the PHY before decoding — same * source as the Tier-2 diagnostics — so a consumer like * corruption_analysis.py can correlate BER with link quality on a @@ -461,42 +468,46 @@ static void packetProcessor(const Packet &packet) { * frame devourer received carries the bandwidth / STBC / FEC / guard * interval the transmitter encoded. Valid on 8812/8821; on 8814AU the * RX descriptor doesn't expose these at this offset (FrameParser.cpp), - * so they read as the chip's defaults there. Inserted before body= so - * the trailing hex-dump pattern downstream regex consumers key on is - * unchanged. */ - printf("rate=%u len=%zu crc_err=%u icv_err=%u " - "rssi=%d,%d evm=%d,%d snr=%d,%d seq=%u tsfl=%u " - "bw=%u stbc=%u ldpc=%u sgi=%u body=", - packet.RxAtrib.data_rate, packet.Data.size(), - packet.RxAtrib.crc_err ? 1u : 0u, - packet.RxAtrib.icv_err ? 1u : 0u, - packet.RxAtrib.rssi[0], packet.RxAtrib.rssi[1], - packet.RxAtrib.evm[0], packet.RxAtrib.evm[1], - packet.RxAtrib.snr[0], packet.RxAtrib.snr[1], - packet.RxAtrib.seq_num, packet.RxAtrib.tsfl, - packet.RxAtrib.bw, packet.RxAtrib.stbc, - packet.RxAtrib.ldpc, packet.RxAtrib.sgi); - for (size_t i = 24; i < packet.Data.size(); ++i) - printf("%02x", packet.Data[i]); - printf("\n"); - fflush(stdout); + * so they read as the chip's defaults there. */ + 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 (dump_body && hits <= 5) { /* Tier-2 health diagnostics alongside the byte mirror: rate (0x04 = * 6M OFDM), per-stream RSSI/EVM/SNR (link quality — content-blind), * crc (always 0: CRC-failed frames are dropped upstream, so reaching * here is itself the decode-sanity signal). Then the body hex. */ - printf("rate=%u rssi=%d,%d evm=%d,%d snr=%d,%d crc=%d " - "len=%zu body=", - packet.RxAtrib.data_rate, packet.RxAtrib.rssi[0], - packet.RxAtrib.rssi[1], packet.RxAtrib.evm[0], - packet.RxAtrib.evm[1], packet.RxAtrib.snr[0], - packet.RxAtrib.snr[1], packet.RxAtrib.crc_err ? 1 : 0, - packet.Data.size()); - for (size_t i = 24; i < packet.Data.size(); ++i) - printf("%02x", packet.Data[i]); - printf("\n"); - fflush(stdout); + 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.body") + .f("rate", packet.RxAtrib.data_rate) + .arr("rssi", rssi, 2) + .arr("evm", evm, 2) + .arr("snr", snr, 2) + .f("crc", packet.RxAtrib.crc_err ? 1 : 0) + .f("len", packet.Data.size()) + .hex("body", packet.Data.data() + 24, body_len); } } } @@ -507,6 +518,9 @@ int main() { int rc; auto logger = std::make_shared(); + apply_logging_env(*logger); /* DEVOURER_LOG_LEVEL / DEVOURER_EVENTS / ... */ + g_ev = &logger->events(); + devourer::bf::bf_events = g_ev; /* BfReportDetect.h emissions */ /* SIGINT/SIGTERM -> clean shutdown (Stop() below). Without this the harness's * `timeout` SIGTERM killed us mid-RX, leaving the chip's USB core hung. */ @@ -605,7 +619,9 @@ int main() { return 1; } - logger->info("init-timing: demo.open_device = {} ms", ms_since_start()); + devourer::Ev(*g_ev, "init.timing") + .f("stage", "demo.open_device") + .f("ms", ms_since_start()); /* Claim-before-reset: the kernel's exclusive interface claim is the primary * guard against a second devourer driving this adapter — it returns BUSY, and * bailing on BUSY *before* the reset keeps a second launch from re-enumerating @@ -616,7 +632,9 @@ int main() { rc = devourer::claim_interface_then_reset( dev_handle, 0, logger, std::getenv("DEVOURER_SKIP_RESET") == nullptr, usb_lock); - logger->info("init-timing: demo.usb_reset = {} ms", ms_since_start()); + devourer::Ev(*g_ev, "init.timing") + .f("stage", "demo.usb_reset") + .f("ms", ms_since_start()); if (rc != 0) { /* BUSY => another process owns the adapter; any other error => open failed. * Either way, exit cleanly rather than asserting. */ @@ -634,7 +652,9 @@ int main() { logger->error("No driver for this chip in this build — exiting"); return 1; } - logger->info("init-timing: demo.create_device = {} ms", ms_since_start()); + devourer::Ev(*g_ev, "init.timing") + .f("stage", "demo.create_device") + .f("ms", ms_since_start()); /* The BB-debug-port / queue-depth / thermal research helpers are Jaguar1-only, * so they live on RtlJaguarDevice rather than the IRtlDevice interface. The * whole block compiles out when Jaguar1 support isn't built; when it is, the @@ -654,9 +674,12 @@ int main() { while (!qd_emitter_stop.load()) { if (g_rtl_device != nullptr) { auto q = g_rtl_device->get_queue_depth(); - printf("q1=0x%08x q2=0x%08x q3=0x%08x q4=0x%08x " - "q5=0x%08x\n", q[0], q[1], q[2], q[3], q[4]); - fflush(stdout); + devourer::Ev(*g_ev, "tx.queue") + .hexf("q1", q[0], 8) + .hexf("q2", q[1], 8) + .hexf("q3", q[2], 8) + .hexf("q4", q[3], 8) + .hexf("q5", q[4], 8); } for (uint32_t slept = 0; slept < g_qd_poll_ms && !qd_emitter_stop.load(); slept += 50) { @@ -678,14 +701,13 @@ int main() { while (!therm_emitter_stop.load()) { if (g_rtl_device != nullptr) { auto t = g_rtl_device->get_thermal_snapshot(); - if (t.valid) { - printf("raw=%u baseline=%u delta=%+d status=%s\n", - t.raw, t.baseline, t.delta, ThermalBucket(t)); - } else { - printf("raw=%u baseline=none status=%s\n", - t.raw, ThermalBucket(t)); - } - fflush(stdout); + 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", ThermalBucket(t)); } for (uint32_t slept = 0; slept < g_thermal_poll_ms && !therm_emitter_stop.load(); @@ -702,7 +724,7 @@ int main() { * so it runs off the base device pointer, not the Jaguar1 downcast. The thread * sleeps one interval first (so its first read lands after bring-up completes, * not mid-init), then each interval reads GetRxEnergy() + drains the rolling - * frame aggregate and emits one line. Concurrency caveat: + * frame aggregate and emits one rx.energy event. Concurrency caveat: * the FA/CCA reads share libusb with the RX bulk loop (like the thermal * poller) — keep the cadence conservative (>= a few hundred ms). */ std::atomic energy_emitter_stop{false}; @@ -725,27 +747,35 @@ int main() { agg = g_rxagg; g_rxagg = RxAgg{}; } - char fao[16], fac[16], cco[16], ccc[16], igi[16]; - auto u = [](char *b, bool v, uint32_t x) { - if (v) std::snprintf(b, 16, "%u", x); else std::snprintf(b, 16, "-"); - }; - u(fao, e.valid_fa, e.fa_ofdm); - u(fac, e.valid_fa, e.fa_cck); - u(cco, e.valid_fa, e.cca_ofdm); - u(ccc, e.valid_fa, e.cca_cck); - if (e.valid_igi) std::snprintf(igi, 16, "%u", e.igi); - else std::snprintf(igi, 16, "-"); int rssi_mean = agg.n ? agg.rssi_sum / static_cast(agg.n) : 0; int snr_mean = agg.n ? agg.snr_sum / static_cast(agg.n) : 0; int evm_mean = agg.evm_n ? agg.evm_sum / static_cast(agg.evm_n) : 0; - printf("cca_ofdm=%s cca_cck=%s fa_ofdm=%s fa_cck=%s " - "igi=%s frames=%u rssi_mean=%d rssi_max=%d snr_mean=%d " - "snr_min=%d evm_mean=%d\n", - cco, ccc, fao, fac, igi, agg.n, rssi_mean, - agg.n ? agg.rssi_max : 0, snr_mean, agg.n ? agg.snr_min : 0, - evm_mean); - fflush(stdout); + { + /* chip-counter fields go null when the chip doesn't expose them */ + devourer::Ev ev(*g_ev, "rx.energy"); + ev.t(); + if (e.valid_fa) + ev.f("cca_ofdm", e.cca_ofdm) + .f("cca_cck", e.cca_cck) + .f("fa_ofdm", e.fa_ofdm) + .f("fa_cck", e.fa_cck); + else + ev.f("cca_ofdm", nullptr) + .f("cca_cck", nullptr) + .f("fa_ofdm", nullptr) + .f("fa_cck", nullptr); + if (e.valid_igi) + ev.f("igi", e.igi); + else + ev.f("igi", nullptr); + ev.f("frames", agg.n) + .f("rssi_mean", rssi_mean) + .f("rssi_max", agg.n ? agg.rssi_max : 0) + .f("snr_mean", snr_mean) + .f("snr_min", agg.n ? agg.snr_min : 0) + .f("evm_mean", evm_mean); + } emit_nhm(e, -1); /* DEVOURER_LINKHEALTH=1 — classify the window into a plain-language * verdict + fix (src/LinkHealth.h). Rides the energy cadence and the @@ -771,37 +801,53 @@ int main() { in.igi_min = 0x1c; /* J1/J2 DIG floor — the saturation hint */ in.igi_max = 0x7f; /* J3 ceiling — never a false 'weak' rail */ devourer::LinkHealthVerdict h = devourer::classify_link_health(in); - char evmb[16]; - if (in.evm_valid) std::snprintf(evmb, 16, "%.1f", h.evm_db); - else std::snprintf(evmb, 16, "-"); - printf("verdict=%s rssi_dbm=%d snr_db=%.1f " - "evm_db=%s frames=%u fa_ofdm=%s igi=%s%s%s cause=\"%s\" " - "fix=\"%s\"\n", - h.label, h.rssi_dbm, h.snr_db, evmb, agg.n, fao, igi, - h.igi_at_floor ? " igi_floor=1" : "", - h.igi_at_ceiling ? " igi_ceil=1" : "", h.cause, h.fix); - fflush(stdout); + devourer::Ev ev(*g_ev, "link.health"); + ev.f("verdict", h.label) + .f("rssi_dbm", h.rssi_dbm) + .f("snr_db", h.snr_db); + if (in.evm_valid) + ev.f("evm_db", h.evm_db); + else + ev.f("evm_db", nullptr); + ev.f("frames", agg.n); + if (e.valid_fa) + ev.f("fa_ofdm", e.fa_ofdm); + else + ev.f("fa_ofdm", nullptr); + if (e.valid_igi) + ev.f("igi", e.igi); + else + ev.f("igi", nullptr); + if (h.igi_at_floor) + ev.f("igi_floor", 1); + if (h.igi_at_ceiling) + ev.f("igi_ceil", 1); + ev.f("cause", h.cause).f("fix", h.fix); } /* DEVOURER_RXQUALITY=1 — dogfood the library GetRxQuality() feed: the * same window a linked controller (fluke_gs) would read, incl. the * passive noise-floor. NB it drains the device-internal accumulator + * calls GetRxEnergy itself, so its counters are independent of the - * / lines above (which use the + * rx.energy/link.health events above (which use the * demo's own g_rxagg). */ if (g_rxquality) { devourer::RxQuality q = dev->GetRxQuality(); - char nf[24], evmb[16]; - if (q.nf_valid) std::snprintf(nf, sizeof nf, "%.1f", q.noise_floor_dbm); - else std::snprintf(nf, sizeof nf, "-"); - if (q.evm_valid) std::snprintf(evmb, sizeof evmb, "%.1f", q.evm_mean_db); - else std::snprintf(evmb, sizeof evmb, "-"); - printf("verdict=%s frames=%u rssi_mean_dbm=%d " - "rssi_max_dbm=%d snr_mean_db=%.1f snr_min_db=%.1f evm_db=%s " - "noise_floor_dbm=%s igi=%d\n", - q.label, q.frames, q.rssi_mean_dbm, q.rssi_max_dbm, - q.snr_mean_db, q.snr_min_db, evmb, nf, - q.igi_valid ? q.igi : -1); - fflush(stdout); + devourer::Ev ev(*g_ev, "rx.quality"); + ev.f("verdict", q.label) + .f("frames", q.frames) + .f("rssi_mean_dbm", q.rssi_mean_dbm) + .f("rssi_max_dbm", q.rssi_max_dbm) + .f("snr_mean_db", q.snr_mean_db) + .f("snr_min_db", q.snr_min_db); + if (q.evm_valid) + ev.f("evm_db", q.evm_mean_db); + else + ev.f("evm_db", nullptr); + if (q.nf_valid) + ev.f("noise_floor_dbm", q.noise_floor_dbm); + else + ev.f("noise_floor_dbm", nullptr); + ev.f("igi", q.igi_valid ? q.igi : -1); } nap(g_rx_energy_ms); } @@ -849,12 +895,11 @@ int main() { g_rx_sweep.size(), g_rx_sweep_dwell_ms); IRtlDevice *dev = rtlDevice.get(); SelectedChannel first{static_cast(g_rx_sweep[0]), ch_offset, width}; - std::thread rx([dev, first]() { + std::thread rx([dev, first, &logger]() { try { dev->Init(packetProcessor, first); } catch (const std::exception &e) { - printf("RX-sweep bring-up failed: %s\n", e.what()); - fflush(stdout); + logger->error("RX-sweep bring-up failed: {}", e.what()); } }); /* Let bring-up complete before the first retune: a retune racing the @@ -899,25 +944,34 @@ int main() { agg = g_rxagg; g_rxagg = RxAgg{}; } - char cco[16], ccc[16], fao[16], fac[16], igi[16]; - auto u = [](char *b, bool v, uint32_t x) { - if (v) std::snprintf(b, 16, "%u", x); else std::snprintf(b, 16, "-"); - }; - u(cco, e.valid_fa, e.cca_ofdm); - u(ccc, e.valid_fa, e.cca_cck); - u(fao, e.valid_fa, e.fa_ofdm); - u(fac, e.valid_fa, e.fa_cck); - u(igi, e.valid_igi, e.igi); int rssi_mean = agg.n ? agg.rssi_sum / static_cast(agg.n) : 0; int snr_mean = agg.n ? agg.snr_sum / static_cast(agg.n) : 0; int evm_mean = agg.evm_n ? agg.evm_sum / static_cast(agg.evm_n) : 0; - printf("ch=%d cca_ofdm=%s cca_cck=%s fa_ofdm=%s " - "fa_cck=%s igi=%s retune_us=%lld frames=%u rssi_mean=%d " - "rssi_max=%d snr_mean=%d snr_min=%d evm_mean=%d\n", - ch, cco, ccc, fao, fac, igi, retune_us, agg.n, rssi_mean, - agg.n ? agg.rssi_max : 0, snr_mean, agg.n ? agg.snr_min : 0, - evm_mean); - fflush(stdout); + { + devourer::Ev ev(*g_ev, "rx.energy"); + ev.t().f("ch", ch); + if (e.valid_fa) + ev.f("cca_ofdm", e.cca_ofdm) + .f("cca_cck", e.cca_cck) + .f("fa_ofdm", e.fa_ofdm) + .f("fa_cck", e.fa_cck); + else + ev.f("cca_ofdm", nullptr) + .f("cca_cck", nullptr) + .f("fa_ofdm", nullptr) + .f("fa_cck", nullptr); + if (e.valid_igi) + ev.f("igi", e.igi); + else + ev.f("igi", nullptr); + ev.f("retune_us", retune_us) + .f("frames", agg.n) + .f("rssi_mean", rssi_mean) + .f("rssi_max", agg.n ? agg.rssi_max : 0) + .f("snr_mean", snr_mean) + .f("snr_min", agg.n ? agg.snr_min : 0) + .f("evm_mean", evm_mean); + } emit_nhm(e, ch); } dev->StopRxLoop(); diff --git a/examples/sense/README.md b/examples/sense/README.md index 18082fd..dce366e 100644 --- a/examples/sense/README.md +++ b/examples/sense/README.md @@ -125,7 +125,7 @@ Wave your hand near the adapters and the `σ` reading climbs; the verdict flips | Var | Purpose | |-----|---------| | `DEVOURER_SENSE_K=` | override the CFAR threshold `k` directly (finer than `--sensitivity`). | -| `DEVOURER_SENSE_DUMP=1` | print every captured report as a `HEX` line on **stderr** — the input for offline analysis (§8). | +| `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. | --- @@ -264,7 +264,7 @@ DEVOURER_SENSE_DUMP=1 ./build/sense --channel 6 \ 2> capture.txt # decode + inspect with the reference Python tool -grep '' capture.txt | tools/bf_report_decode.py +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, diff --git a/examples/sense/main.cpp b/examples/sense/main.cpp index b56d690..36bf75e 100644 --- a/examples/sense/main.cpp +++ b/examples/sense/main.cpp @@ -59,6 +59,11 @@ static void set_env(const char *name, const char *value) { /* 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 @@ -175,13 +180,11 @@ class Sensor { ReportHdr hdr; if (!parse_report(frame, n, hdr)) return; - if (std::getenv("DEVOURER_SENSE_DUMP")) { - /* python-tool-compatible raw dump (stderr, so the stdout display is - * untouched): capture with 2>file, analyse with tools/bf_report_decode.py */ - std::fprintf(stderr, ""); - for (size_t i = 0; i < n; ++i) - std::fprintf(stderr, "%02x", frame[i]); - std::fprintf(stderr, "\n"); + 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").hex("frame", frame, n); } if (std::getenv("DEVOURER_SENSE_DEBUG")) { static int dbg = 0; @@ -479,6 +482,12 @@ static uint16_t hex16(const char *s) { int main(int argc, char **argv) { auto logger = std::make_shared(); + 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; @@ -523,8 +532,10 @@ int main(int argc, char **argv) { } /* Quiet the library's per-operation info logging so the live display owns the - * console; --verbose restores the full bring-up log. */ - if (!verbose) + * 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. */ diff --git a/examples/streamtx/main.cpp b/examples/streamtx/main.cpp index 94ac463..0be0d26 100644 --- a/examples/streamtx/main.cpp +++ b/examples/streamtx/main.cpp @@ -104,6 +104,10 @@ static std::vector build_dot11_probe_req() { int main(int argc, char **argv) { auto logger = std::make_shared(); + 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; // Sanity cap on a single PSDU body; protects against an upstream framing @@ -285,9 +289,7 @@ int main(int argc, char **argv) { auto r = stream_stdin::read_exact(stdin, len_bytes, sizeof(len_bytes)); if (r == stream_stdin::ReadResult::Eof) break; // clean stdin close if (r == stream_stdin::ReadResult::Short) { - std::fprintf(stderr, - "stream_tx_demo: short read on stdin len-prefix; record " - "truncated\n"); + logger->error("short read on stdin len-prefix; record truncated"); std::exit(2); } } @@ -296,23 +298,20 @@ int main(int argc, char **argv) { | (static_cast(len_bytes[2]) << 16) | (static_cast(len_bytes[3]) << 24); if (len == 0 || len > max_psdu) { - std::fprintf(stderr, - "stream_tx_demo: PSDU length %u out of range (max %zu); " - "stopping\n", len, max_psdu); + logger->error("PSDU length {} out of range (max {}); stopping", len, + max_psdu); break; } std::vector psdu(len); { auto r = stream_stdin::read_exact(stdin, psdu.data(), len); if (r == stream_stdin::ReadResult::Eof) { - std::fprintf(stderr, - "stream_tx_demo: EOF mid-PSDU (expected %u bytes)\n", len); + logger->warn("EOF mid-PSDU (expected {} bytes)", len); break; } if (r == stream_stdin::ReadResult::Short) { - std::fprintf(stderr, - "stream_tx_demo: short read mid-PSDU (expected %u bytes); " - "record truncated\n", len); + logger->error("short read mid-PSDU (expected {} bytes); record " + "truncated", len); std::exit(2); } } @@ -339,21 +338,21 @@ int main(int argc, char **argv) { tx_buf.insert(tx_buf.end(), psdu.begin(), psdu.end()); bool ok = rtlDevice->send_packet(tx_buf.data(), tx_buf.size()); ++tx_count; - // Tag matches precoder's so existing log-watchers keep working; route - // to stderr to leave stdout clean for downstream callers that may chain - // this binary. + // 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) { - std::fprintf(stderr, - "TX #%ld ok=%d psdu=%u total=%zu\n", - tx_count, ok ? 1 : 0, len, tx_buf.size()); - std::fflush(stderr); + 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)); } } - std::fprintf(stderr, "done; sent %ld PSDUs\n", tx_count); + devourer::Ev(logger->events(), "stream.done").f("sent", tx_count); libusb_release_interface(handle, 0); libusb_close(handle); libusb_exit(context); diff --git a/examples/svctx/main.cpp b/examples/svctx/main.cpp index b405ec0..c711483 100644 --- a/examples/svctx/main.cpp +++ b/examples/svctx/main.cpp @@ -97,6 +97,7 @@ static const char* mode_str(const devourer::TxMode& m) { int main(int argc, char** argv) { auto logger = std::make_shared(); + apply_logging_env(*logger); /* DEVOURER_LOG_LEVEL / DEVOURER_EVENTS / ... */ size_t mtu = 1400; long gap_us = 2000; @@ -225,11 +226,16 @@ int main(int argc, char** argv) { 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) { - printf("frames=%ld crit=%ld T0=%ld T1=%ld T2=%ld T3+=%ld\n", frames, - sent[8], sent[0], sent[1], sent[2], - sent[3] + sent[4] + sent[5] + sent[6] + sent[7]); - fflush(stdout); + 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]); } } } diff --git a/examples/tx/main.cpp b/examples/tx/main.cpp index e4fc317..3ebec58 100644 --- a/examples/tx/main.cpp +++ b/examples/tx/main.cpp @@ -60,8 +60,12 @@ static constexpr uint16_t kRealtekProductIds[] = { 0xe822, 0xa82a, /* RTL8822EU (Jaguar3 EU) */ }; -/* Process-start reference for the init-timing lines (see src/InitTimer.h). - * `init-timing: txdemo.first_tx_submit` is the end-to-end "ready to TX" mark: +/* Event sink for the demo's own JSONL emissions (packetProcessor is a free + * function) — points at the main() Logger's sink, set before InitWrite(). */ +static devourer::EventSink *g_ev = nullptr; + +/* Process-start reference for the init.timing events (see src/InitTimer.h). + * stage=txdemo.first_tx_submit is the end-to-end "ready to TX" mark: * exec → first bulk-OUT submitted. */ static const std::chrono::steady_clock::time_point g_proc_start = std::chrono::steady_clock::now(); @@ -120,8 +124,9 @@ static void packetProcessor(const Packet &packet) { /* RX liveness marker for the TX+RX=thread mode: first frame + every 500th. * Without it a deaf RX loop is indistinguishable from a quiet channel. */ if (g_rx_count == 1 || g_rx_count % 500 == 0) { - printf("total=%d len=%zu\n", g_rx_count, packet.Data.size()); - fflush(stdout); + devourer::Ev(*g_ev, "rx.count") + .f("total", g_rx_count) + .f("len", packet.Data.size()); } /* BF self-sounding report detector (DEVOURER_BF_DETECT_REPORT modes 1-4, * shared with rxdemo): with DEVOURER_TX_WITH_RX=thread this is how @@ -135,9 +140,11 @@ static void packetProcessor(const Packet &packet) { if (std::memcmp(packet.Data.data() + 10, kTxSa, 6) == 0) { static int hits = 0; ++hits; - printf("RX from txdemo SA: hits=%d total_rx=%d len=%zu\n", - hits, g_rx_count, packet.Data.size()); - fflush(stdout); + /* rx.txhit fields are parsed by tests/regress.py — keep names stable. */ + devourer::Ev(*g_ev, "rx.txhit") + .f("hits", hits) + .f("total_rx", g_rx_count) + .f("len", packet.Data.size()); } } } @@ -172,6 +179,9 @@ int main(int argc, char **argv) { int rc; auto logger = std::make_shared(); + apply_logging_env(*logger); /* DEVOURER_LOG_LEVEL / DEVOURER_EVENTS / ... */ + g_ev = &logger->events(); + devourer::bf::bf_events = g_ev; /* BfReportDetect.h emits through this */ /* SIGINT/SIGTERM -> break the TX loop and run a clean chip de-init, so the * harness's `timeout` doesn't leave the adapter's USB core hung. */ @@ -276,7 +286,9 @@ int main(int argc, char **argv) { logger->info("Vendor/Product ID: {:04x}:{:04x}", desc.idVendor, desc.idProduct); - logger->info("init-timing: txdemo.open_device = {} ms", ms_since_start()); + devourer::Ev(*g_ev, "init.timing") + .f("stage", "txdemo.open_device") + .f("ms", ms_since_start()); /* Claim-before-reset (see src/UsbOpen.h): the exclusive interface claim is the * primary guard — a second devourer on this adapter gets BUSY and we bail here * before the reset, so it can't re-enumerate the adapter out from under the @@ -286,7 +298,9 @@ int main(int argc, char **argv) { rc = devourer::claim_interface_then_reset( handle, 0, logger, !termux_mode && std::getenv("DEVOURER_SKIP_RESET") == nullptr, usb_lock); - logger->info("init-timing: txdemo.usb_reset = {} ms", ms_since_start()); + devourer::Ev(*g_ev, "init.timing") + .f("stage", "txdemo.usb_reset") + .f("ms", ms_since_start()); if (rc != 0) { libusb_close(handle); libusb_exit(context); @@ -407,7 +421,9 @@ int main(int argc, char **argv) { logger->error("No driver for this chip in this build — exiting"); return 1; } - logger->info("init-timing: txdemo.create_device = {} ms", ms_since_start()); + devourer::Ev(*g_ev, "init.timing") + .f("stage", "txdemo.create_device") + .f("ms", ms_since_start()); /* Jaguar1-only research features (TX-mode default, fast-retune hopping, * thermal telemetry, TXAGC override, BB-reg probe) are not part of the @@ -510,7 +526,9 @@ int main(int argc, char **argv) { .ChannelWidth = init_width}); write_sentinel(0xBEEF, "post-init/pre-TX"); - logger->info("init-timing: txdemo.init_write = {} ms", ms_since_start()); + devourer::Ev(*g_ev, "init.timing") + .f("stage", "txdemo.init_write") + .f("ms", ms_since_start()); std::thread usb_thread(usb_event_loop, logger, context); @@ -703,7 +721,7 @@ int main(int argc, char **argv) { * DEVOURER_HOP_CHANNELS="1,6,11" is set the TX loop dwells DWELL_FRAMES * frames on each listed channel, then retunes to the next via * SetMonitorChannel, cycling for HOP_ROUNDS full passes (0 = forever). Each - * retune is wall-clock timed and announced as a marker so a + * retune is wall-clock timed and announced as a hop.dwell event so a * wideband SDR receiver can correlate which frames landed on which channel * and confirm none are dropped across the retune. Intra-band (e.g. all of * 1/6/11 in 2.4 GHz) is the cheap case — no band switch, no IQK — so the @@ -748,7 +766,7 @@ int main(int argc, char **argv) { * set, force the per-rate TXAGC index to an absolute value and step it up by * STEP every STEP_MS, in one continuous TX session (chip never stops, so we * observe cumulative heating). Each (re-)apply re-runs the channel-set so the - * new index reaches the TXAGC registers. A marker is emitted + * new index reaches the TXAGC registers. A txpwr.set event is emitted * on every change so the harness can correlate gain index with the thermal / * SDR streams. Without START, behaviour is unchanged (EFUSE per-rate power). */ bool pwr_ramp = false; @@ -759,7 +777,7 @@ int main(int argc, char **argv) { /* DEVOURER_TX_MCS_SWEEP="MCS0,MCS2,MCS4,..." — the MCS-headroom axis of the * link probe: step the on-air rate through the list every DEVOURER_TX_MCS_STEP_MS - * (default 2000), emitting a mcs= marker per step. A + * (default 2000), emitting a tx.contx event (mcs=) per step. A * beacon-feed rate sweep (decoupled from the idle-hold HW carrier, like the * power ramp) so the ground reads decodable per-frame SNR/delivery at each MCS * and picks the highest rate the link holds. */ @@ -787,19 +805,22 @@ int main(int argc, char **argv) { if (!rtlDevice->GetTxPowerCaps().supported) return; rtlDevice->SetTxPowerIndexOverride(idx); - printf("index=%d t_ms=%lld\n", idx, - static_cast(ms_since_start())); + devourer::Ev(*g_ev, "txpwr.set") + .f("index", idx) + .f("t_ms", ms_since_start()); if (txpwr_readback) { /* Confirm the TXAGC writes landed: representative path-A indices from * GetTxPowerState — register readback where the family's TXAGC block is * readable (rb=1), the software shadow otherwise (Jaguar2 / 8814A, * whose TXAGC ports are write-only). */ const devourer::TxPowerState st = rtlDevice->GetTxPowerState(); - printf("index=%d cck1m=%d ofdm6m=%d mcs7=%d rb=%d\n", - idx, st.cck_index, st.ofdm_index, st.mcs7_index, - st.hw_readback ? 1 : 0); + devourer::Ev(*g_ev, "txpwr.readback") + .f("index", idx) + .f("cck1m", st.cck_index) + .f("ofdm6m", st.ofdm_index) + .f("mcs7", st.mcs7_index) + .f("rb", st.hw_readback ? 1 : 0); } - fflush(stdout); }; if (const char *e = std::getenv("DEVOURER_TX_PWR_START")) { pwr_ramp = true; @@ -899,8 +920,9 @@ int main(int argc, char **argv) { while (!g_devourer_should_stop) { if (tx_count == 0) { - logger->info("init-timing: txdemo.first_tx_submit = {} ms", - ms_since_start()); + devourer::Ev(*g_ev, "init.timing") + .f("stage", "txdemo.first_tx_submit") + .f("ms", ms_since_start()); if (pwr_ramp) { apply_txpwr(pwr_cur); pwr_next_step_ms = ms_since_start() + pwr_step_ms; @@ -917,9 +939,9 @@ int main(int argc, char **argv) { (tx_count == 0 || ms_since_start() >= mcs_next_step_ms)) { if (tx_count != 0) mcs_idx = (mcs_idx + 1) % mcs_sweep.size(); rtlDevice->SetTxMode(mcs_sweep[mcs_idx].first); - printf("mcs=%s t_ms=%ld\n", - mcs_sweep[mcs_idx].second.c_str(), ms_since_start()); - fflush(stdout); + devourer::Ev(*g_ev, "tx.contx") + .f("mcs", mcs_sweep[mcs_idx].second) + .f("t_ms", ms_since_start()); mcs_next_step_ms = ms_since_start() + mcs_step_ms; } /* Retune at each dwell boundary. The first iteration (frames_in_dwell==0, @@ -930,14 +952,14 @@ int main(int argc, char **argv) { if (total_dwells > 0 && dwell_no >= total_dwells) break; int ch = hop_channels[dwell_no % hop_channels.size()]; auto sw0 = std::chrono::steady_clock::now(); - const char *mode = ""; + const char *mode = nullptr; if (hop_radiotap) { /* Library retunes from the CHANNEL field on the next send_packet; the * 802.11 body is beacon_frame past its 10-byte radiotap. */ tx_buf = build_frame_with_channel(devourer::chan_to_freq(ch), beacon_frame + 10, sizeof(beacon_frame) - 10); - mode = " radiotap"; + mode = "radiotap"; } else if (hop_fast) { /* IRtlDevice virtual: every generation implements the lean fast path @@ -945,7 +967,7 @@ int main(int argc, char **argv) { * band-change fallback to the full set). */ rtlDevice->FastRetune(static_cast(ch), /*cache_rf=*/hop_fast != 2); - mode = " fast"; + mode = "fast"; } else { rtlDevice->SetMonitorChannel(SelectedChannel{ @@ -957,11 +979,17 @@ int main(int argc, char **argv) { std::chrono::duration_cast( std::chrono::steady_clock::now() - sw0) .count(); - printf("dwell=%ld round=%ld channel=%d frame=%ld " - "switch_us=%lld t_ms=%lld%s\n", - dwell_no, dwell_no / static_cast(hop_channels.size()), ch, - tx_count, switch_us, ms_since_start(), mode); - fflush(stdout); + { + devourer::Ev ev(*g_ev, "hop.dwell"); + ev.f("dwell", dwell_no) + .f("round", dwell_no / static_cast(hop_channels.size())) + .f("channel", ch) + .f("frame", tx_count) + .f("switch_us", switch_us) + .f("t_ms", ms_since_start()); + if (mode != nullptr) + ev.f("mode", mode); + } } if (stbc_toggle) { devourer::TxMode m = tx_mode_base; @@ -973,16 +1001,16 @@ int main(int argc, char **argv) { if (!hop_channels.empty() && ++frames_in_dwell >= hop_dwell) frames_in_dwell = 0; if (tx_count <= 10 || tx_count % 500 == 0) { - printf("TX #%ld rc=%d\n", tx_count, rc); + devourer::Ev(*g_ev, "tx.frame").f("n", tx_count).f("rc", rc); /* TX submission health — the driver-drop / congestion feed (xtx). A * climbing failed with was_timeout=1 is a full TX FIFO (recoverable * back-pressure); a hard rc is a broken path. */ auto ts = rtlDevice->GetTxStats(); - printf("submitted=%llu failed=%llu was_timeout=%d " - "last_rc=%d\n", - (unsigned long long)ts.submitted, (unsigned long long)ts.failed, - ts.last_was_timeout ? 1 : 0, ts.last_error_rc); - fflush(stdout); + devourer::Ev(*g_ev, "tx.stats") + .f("submitted", (unsigned long long)ts.submitted) + .f("failed", (unsigned long long)ts.failed) + .f("was_timeout", ts.last_was_timeout ? 1 : 0) + .f("last_rc", ts.last_error_rc); } /* Thermal telemetry via the generation-agnostic GetThermalStatus * (previously Jaguar1-only): every family reads its RF 0x42 meter — @@ -990,9 +1018,16 @@ int main(int argc, char **argv) { * Jaguar3 [6:1] + efuse 0xd0 (E) / first-read cold reference (C). */ if (thermal_every > 0 && tx_count % thermal_every == 0) { auto t = rtlDevice->GetThermalStatus(); + if (t.valid || t.raw != 0) { + 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)); + } if (t.valid) { - printf("raw=%u baseline=%u delta=%+d status=%s\n", - t.raw, t.baseline, t.delta, devourer::ThermalBucket(t)); if (t.delta >= thermal_warn_delta && !thermal_warned) { logger->warn("thermal: chip running hot — raw={} baseline={} " "delta=+{} (>= {}); TX power tracking backing off, " @@ -1002,11 +1037,7 @@ int main(int argc, char **argv) { } else if (t.delta < thermal_warn_delta) { thermal_warned = false; } - } else if (t.raw != 0) { - printf("raw=%u baseline=none status=%s\n", - t.raw, devourer::ThermalBucket(t)); } - fflush(stdout); } if (rc) { consec_fail = 0; @@ -1028,8 +1059,9 @@ int main(int argc, char **argv) { /* Bounded hop mode (DEVOURER_HOP_ROUNDS>0) reaches here when its rounds * complete; the signal and back-off paths also fall through. */ if (!hop_channels.empty()) { - printf("frames=%ld dwells=%ld\n", tx_count, dwell_no + 1); - fflush(stdout); + devourer::Ev(*g_ev, "hop.done") + .f("frames", tx_count) + .f("dwells", dwell_no + 1); } /* Stop and join the libusb event thread BEFORE any teardown: a thread still diff --git a/examples/txpower/main.cpp b/examples/txpower/main.cpp index cc30f1e..3c2b08a 100644 --- a/examples/txpower/main.cpp +++ b/examples/txpower/main.cpp @@ -25,12 +25,12 @@ * — proves the hop path leaves TXAGC alone * --thermal print a thermal snapshot with each state line * - * Machine-readable output (one line per step, consumed by + * Machine-readable output (one JSONL event per step, consumed by * tests/txpwr_offset_regcheck.sh): * - * max=63 step_qdb=2 min=-126 max_qdb=126 - * flat=-1 offset_qdb=-24 steps=-12 satlo=0 sathi=0 \ - * cck=28 ofdm=34 mcs7=30 rb=1 + * {"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} */ #ifdef _WIN32 #define NOMINMAX @@ -60,6 +60,10 @@ 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, @@ -122,7 +126,8 @@ bool parse_args(int argc, char **argv, Args &a) { else if (k == "--thermal") a.thermal = true; else { - std::fprintf(stderr, "unknown/incomplete arg: %s\n", k.c_str()); + std::fprintf(stderr, "devourer [W] unknown/incomplete arg: %s\n", + k.c_str()); return false; } } @@ -144,17 +149,26 @@ ChannelWidth_t bw_enum(int bw) { void print_state(IRtlDevice *dev, bool with_thermal) { const devourer::TxPowerState s = dev->GetTxPowerState(); - std::printf("flat=%d offset_qdb=%d steps=%d satlo=%d " - "sathi=%d cck=%d ofdm=%d mcs7=%d rb=%d\n", - s.flat_index, s.offset_qdb, s.offset_steps, - s.saturated_low ? 1 : 0, s.saturated_high ? 1 : 0, s.cck_index, - s.ofdm_index, s.mcs7_index, s.hw_readback ? 1 : 0); + 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); if (with_thermal) { const devourer::ThermalStatus t = dev->GetThermalStatus(); - std::printf("raw=%u baseline=%u delta=%d status=%s\n", - t.raw, t.baseline, t.delta, devourer::ThermalBucket(t)); + 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)); } - std::fflush(stdout); } } // namespace @@ -165,6 +179,8 @@ int main(int argc, char **argv) { return 2; auto logger = std::make_shared(); + apply_logging_env(*logger); /* DEVOURER_LOG_LEVEL / DEVOURER_EVENTS / ... */ + g_ev = &logger->events(); install_devourer_signal_handlers(); libusb_context *ctx = nullptr; @@ -209,12 +225,13 @@ int main(int argc, char **argv) { } const devourer::TxPowerCaps caps = dev->GetTxPowerCaps(); - std::printf("supported=%d max=%u step_qdb=%u " - "step_measured=%d min_qdb=%d max_qdb=%d\n", - caps.supported ? 1 : 0, caps.index_max, caps.step_qdb, - caps.step_measured ? 1 : 0, caps.offset_min_qdb, - caps.offset_max_qdb); - std::fflush(stdout); + 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); if (!caps.supported) { logger->error("TX-power API not wired for this family yet"); dev->Stop(); @@ -245,8 +262,7 @@ int main(int argc, char **argv) { !g_devourer_should_stop; q += inc) { const int applied = dev->SetTxPowerOffsetQdb(q); - std::printf("requested=%d applied=%d\n", q, - applied); + devourer::Ev(*g_ev, "txpwr.offset").f("requested", q).f("applied", applied); print_state(dev.get(), a.thermal); std::this_thread::sleep_for(std::chrono::milliseconds(a.step_ms)); } diff --git a/src/DeviceConfig.h b/src/DeviceConfig.h index 707d38e..47f22e8 100644 --- a/src/DeviceConfig.h +++ b/src/DeviceConfig.h @@ -185,8 +185,8 @@ struct DeviceConfig { bool bb_dump = false; /* env: DEVOURER_EFUSE_DUMP — Jaguar2: dump the efuse logical map. */ bool efuse_dump = false; - /* env: DEVOURER_LOG_WRITES — emit every vendor register write as - * "0xADDR N 0xVAL" (matches tests/decode_wseq.py). */ + /* env: DEVOURER_LOG_WRITES — emit every vendor register write as a + * debug.wreg event (addr/width/val mirror tests/decode_wseq.py). */ bool log_writes = false; /* env: DEVOURER_LOG_TXPWR — Jaguar1: log the per-channel/per-rate TX-power * derivation. */ diff --git a/src/Event.h b/src/Event.h new file mode 100644 index 0000000..06b0009 --- /dev/null +++ b/src/Event.h @@ -0,0 +1,374 @@ +#ifndef DEVOURER_EVENT_H +#define DEVOURER_EVENT_H + +/* Machine event stream — JSON Lines on stdout (the data plane). + * + * One event = one line = one JSON object whose first field is always the + * event name, serialized exactly as + * + * {"ev":"", ...} + * + * That first-field guarantee is part of the contract: shell consumers may + * `grep -F '"ev":"rx.txhit"'` without a JSON parser; everything richer goes + * through json.loads (see tests/devourer_events.py). Schema: docs/logging.md. + * + * Emission is a single fwrite() of the complete line followed by fflush() + * (FlushPolicy::EveryLine, the default) — fwrite locks the FILE* on every + * supported libc, so concurrent threads interleave whole lines, never + * characters, and a python subprocess reading the pipe sees each event the + * moment it is emitted (stdout through a pipe is fully buffered otherwise). + * + * Usage: + * Ev(sink, "thermal").t().f("raw", raw).f("delta", d).f("status", s); + * // emits on destruction; or call .emit() explicitly. + * + * Dependency-free, no allocation for typical events (inline 768-byte buffer; + * oversized bodies spill to a reused thread-local scratch). Numbers are + * emitted via snprintf with long long/unsigned long long/double only — no + * %zu-style portability traps. NaN/Inf serialize as null. A line is hard- + * capped at 64 KiB: an overflowing field is dropped and "truncated":true is + * appended. */ + +#include +#include +#include +#include +#include +#include +#include + +namespace devourer { + +class EventSink { +public: + enum class FlushPolicy { EveryLine, Never }; + + /* Configure before any worker threads spawn (demo main() start). */ + void configure(std::FILE *out, FlushPolicy fp = FlushPolicy::EveryLine) { + _out = out; + _flush = fp; + _enabled = out != nullptr; + } + void disable() { _enabled = false; } + bool enabled() const { return _enabled; } + + /* p..p+n must be one complete line including the trailing '\n'. A single + * fwrite keeps the line atomic across threads. */ + void write_line(const char *p, size_t n) { + if (!_enabled) + return; + std::fwrite(p, 1, n, _out); + if (_flush == FlushPolicy::EveryLine) + std::fflush(_out); + } + +private: + std::FILE *_out = stdout; + FlushPolicy _flush = FlushPolicy::EveryLine; + bool _enabled = true; +}; + +/* Monotonic milliseconds since process start, for Ev::t(). The epoch is a + * function-local static, so "start" is the first event emission — close + * enough to main() for a telemetry timestamp, and safe under any static-init + * order. */ +inline long long event_uptime_ms() { + static const auto t0 = std::chrono::steady_clock::now(); + return std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0) + .count(); +} + +class Ev { +public: + static constexpr size_t kMaxLine = 64 * 1024; + + Ev(EventSink &sink, const char *name) : _sink(sink) { + _buf = _sbuf; + _cap = sizeof(_sbuf); + if (!_sink.enabled()) { + _dead = true; + return; + } + raw("{\"ev\":\"", 7); + raw(name, std::strlen(name)); /* event names are our literals: no escape */ + raw("\"", 1); + } + Ev(const Ev &) = delete; + Ev &operator=(const Ev &) = delete; + ~Ev() { emit(); } + + Ev &f(const char *k, long long v) { + if (begin_field(k)) { + char tmp[24]; + raw(tmp, (size_t)std::snprintf(tmp, sizeof(tmp), "%lld", v)); + } + return *this; + } + Ev &f(const char *k, unsigned long long v) { + if (begin_field(k)) { + char tmp[24]; + raw(tmp, (size_t)std::snprintf(tmp, sizeof(tmp), "%llu", v)); + } + return *this; + } + Ev &f(const char *k, int v) { return f(k, (long long)v); } + Ev &f(const char *k, long v) { return f(k, (long long)v); } + Ev &f(const char *k, unsigned v) { return f(k, (unsigned long long)v); } + Ev &f(const char *k, unsigned long v) { return f(k, (unsigned long long)v); } + Ev &f(const char *k, double v) { + if (begin_field(k)) { + if (std::isfinite(v)) { + char tmp[32]; + raw(tmp, (size_t)std::snprintf(tmp, sizeof(tmp), "%.10g", v)); + } else { + raw("null", 4); + } + } + return *this; + } + Ev &f(const char *k, bool v) { + if (begin_field(k)) + v ? raw("true", 4) : raw("false", 5); + return *this; + } + Ev &f(const char *k, std::string_view v) { + if (begin_field(k)) + str(v); + return *this; + } + Ev &f(const char *k, const char *v) { return f(k, std::string_view(v)); } + Ev &f(const char *k, std::nullptr_t) { + if (begin_field(k)) + raw("null", 4); + return *this; + } + + /* "k":"0x1c2" — register addresses/values/masks are hex strings by schema + * convention. digits=0 emits the minimal width. */ + Ev &hexf(const char *k, unsigned long long v, int digits = 0) { + if (begin_field(k)) { + char tmp[24]; + raw(tmp, (size_t)std::snprintf(tmp, sizeof(tmp), "\"0x%0*llx\"", + digits, v)); + } + return *this; + } + + /* "k":"aabbcc..." — lowercase hex string of a byte blob (frame bodies). */ + Ev &hex(const char *k, const uint8_t *p, size_t n) { + if (!begin_field(k)) + return *this; + if (!ensure(n * 2 + 2)) { /* field key already written: close valuelessly */ + raw("null", 4); + return *this; + } + static const char digits[] = "0123456789abcdef"; + _buf[_len++] = '"'; + for (size_t i = 0; i < n; i++) { + _buf[_len++] = digits[p[i] >> 4]; + _buf[_len++] = digits[p[i] & 0xf]; + } + _buf[_len++] = '"'; + return *this; + } + + /* "k":[1,-2,3] — per-chain metrics. */ + Ev &arr(const char *k, const int *v, size_t n) { + if (!begin_field(k)) + return *this; + raw("[", 1); + for (size_t i = 0; i < n; i++) { + if (i) + raw(",", 1); + char tmp[16]; + raw(tmp, (size_t)std::snprintf(tmp, sizeof(tmp), "%d", v[i])); + } + raw("]", 1); + return *this; + } + Ev &arr(const char *k, const double *v, size_t n) { + if (!begin_field(k)) + return *this; + raw("[", 1); + for (size_t i = 0; i < n; i++) { + if (i) + raw(",", 1); + if (std::isfinite(v[i])) { + char tmp[32]; + raw(tmp, (size_t)std::snprintf(tmp, sizeof(tmp), "%.10g", v[i])); + } else { + raw("null", 4); + } + } + raw("]", 1); + return *this; + } + + /* "t": — periodic/marker events carry it; + * per-frame events rely on seq/tsfl instead. */ + Ev &t() { return f("t", event_uptime_ms()); } + + void emit() { + if (_dead) + return; + /* The closer consumes the kSlack reserve, so it always fits. */ + if (_truncated) + closer(",\"truncated\":true", 17); + closer("}\n", 2); + _dead = true; + _sink.write_line(_buf, _len); + release_scratch(); + } + +private: + /* Reused per-thread spill buffer so oversized events (hex frame bodies) + * don't allocate per emission. A busy flag guards the rare nested-Ev case; + * the fallback then is a private heap vector. */ + struct Scratch { + std::vector buf; + bool busy = false; + }; + static Scratch &tls_scratch() { + thread_local Scratch s; + return s; + } + + bool begin_field(const char *k) { + if (_dead) + return false; + /* key is always one of our literals — no escaping needed */ + size_t klen = std::strlen(k); + if (!ensure(klen + 4)) + return false; + _buf[_len++] = ','; + _buf[_len++] = '"'; + std::memcpy(_buf + _len, k, klen); + _len += klen; + _buf[_len++] = '"'; + _buf[_len++] = ':'; + return true; + } + + void raw(const char *p, size_t n) { + if (_dead || !ensure(n)) + return; + std::memcpy(_buf + _len, p, n); + _len += n; + } + + /* emit()-only append that spends the kSlack reserve (every successful + * ensure() left _len <= kMaxLine - kSlack, so this cannot fail). */ + void closer(const char *p, size_t n) { + if (!ensure(n, 0)) + return; + std::memcpy(_buf + _len, p, n); + _len += n; + } + + void str(std::string_view v) { + /* worst case every char escapes to \u00XX (6 bytes) + quotes */ + if (!ensure(v.size() * 6 + 2)) { + raw("null", 4); + return; + } + _buf[_len++] = '"'; + for (unsigned char c : v) { + switch (c) { + case '"': + _buf[_len++] = '\\'; + _buf[_len++] = '"'; + break; + case '\\': + _buf[_len++] = '\\'; + _buf[_len++] = '\\'; + break; + case '\n': + _buf[_len++] = '\\'; + _buf[_len++] = 'n'; + break; + case '\t': + _buf[_len++] = '\\'; + _buf[_len++] = 't'; + break; + case '\r': + _buf[_len++] = '\\'; + _buf[_len++] = 'r'; + break; + default: + if (c < 0x20) { + _len += (size_t)std::snprintf(_buf + _len, 8, "\\u%04x", c); + } else { + _buf[_len++] = (char)c; + } + } + } + _buf[_len++] = '"'; + } + + /* Grow so that n more bytes fit, spilling from the inline buffer to the + * thread-local scratch. Returns false (and flags truncation) if the line + * would exceed kMaxLine minus slack for the truncated marker + "}\n". */ + static constexpr size_t kSlack = 32; /* reserved for the emit() closer */ + + bool ensure(size_t n, size_t slack = kSlack) { + if (_len + n + slack > kMaxLine) { + _truncated = true; + return false; + } + if (_len + n <= _cap) + return true; + size_t want = _len + n; + size_t newcap = _cap * 2 > want ? _cap * 2 : want; + if (newcap > kMaxLine) + newcap = kMaxLine; + if (_buf == _sbuf) { + Scratch &s = tls_scratch(); + if (!s.busy) { + s.busy = true; + _scratch = &s; + if (s.buf.size() < newcap) + s.buf.resize(newcap); + std::memcpy(s.buf.data(), _sbuf, _len); + _buf = s.buf.data(); + _cap = s.buf.size(); + return true; + } + _heap.resize(newcap); + std::memcpy(_heap.data(), _sbuf, _len); + _buf = _heap.data(); + _cap = _heap.size(); + return true; + } + if (_scratch) { + _scratch->buf.resize(newcap); + _buf = _scratch->buf.data(); + } else { + _heap.resize(newcap); + _buf = _heap.data(); + } + _cap = newcap; + return true; + } + + void release_scratch() { + if (_scratch) { + _scratch->busy = false; + _scratch = nullptr; + } + } + + EventSink &_sink; + char *_buf = nullptr; + size_t _cap = 0; + size_t _len = 0; + bool _dead = false; + bool _truncated = false; + Scratch *_scratch = nullptr; + std::vector _heap; + char _sbuf[768]; +}; + +} /* namespace devourer */ + +#endif /* DEVOURER_EVENT_H */ diff --git a/src/HopProf.h b/src/HopProf.h index 2dbc84a..c063adb 100644 --- a/src/HopProf.h +++ b/src/HopProf.h @@ -2,13 +2,15 @@ #define HOP_PROF_H #include -#include +#include + +#include "Event.h" /* Per-stage timing inside the fast_retune paths (DeviceConfig * debug.hop_prof), for driving the hop-latency work (FHSS wants every hop - * microsecond accounted for). Each fast hop emits one machine-parseable line: + * microsecond accounted for). Each fast hop emits one event: * - * gen= ch= _us= ... total_us= + * {"ev":"hop.prof","gen":"","ch":N,"_us":N,...,"total_us":N} * * Stages are generation-specific labels passed at mark() time. Zero overhead * when disabled beyond a branch. */ @@ -17,13 +19,13 @@ namespace devourer { class HopProf { public: - HopProf(bool enabled, const char *gen, unsigned channel) - : _enabled(enabled), _gen(gen), _ch(channel) { + HopProf(EventSink &sink, bool enabled, const char *gen, unsigned channel) + : _enabled(enabled) { if (!_enabled) return; _t0 = _last = std::chrono::steady_clock::now(); - _len = std::snprintf(_buf, sizeof(_buf), "gen=%s ch=%u", - _gen, _ch); + _ev.emplace(sink, "hop.prof"); + _ev->f("gen", gen).f("ch", channel); } /* Record the time since the previous mark under `stage`. */ @@ -35,28 +37,25 @@ class HopProf { std::chrono::duration_cast(now - _last) .count(); _last = now; - if (_len > 0 && _len < static_cast(sizeof(_buf))) - _len += std::snprintf(_buf + _len, sizeof(_buf) - _len, " %s_us=%lld", - stage, us); + char key[48]; + std::snprintf(key, sizeof(key), "%s_us", stage); + _ev->f(key, us); } ~HopProf() { - if (!_enabled || _len <= 0) + if (!_enabled) return; const long long total = std::chrono::duration_cast( std::chrono::steady_clock::now() - _t0) .count(); - std::fprintf(stderr, "%s total_us=%lld\n", _buf, total); + _ev->f("total_us", total); /* _ev emits on destruction */ } private: bool _enabled = false; - const char *_gen; - unsigned _ch; + std::optional _ev; std::chrono::steady_clock::time_point _t0{}, _last{}; - char _buf[512]; - int _len = 0; }; } /* namespace devourer */ diff --git a/src/InitTimer.h b/src/InitTimer.h index 0261c93..133c36b 100644 --- a/src/InitTimer.h +++ b/src/InitTimer.h @@ -2,18 +2,19 @@ #define INIT_TIMER_H #include +#include #include #include "logger.h" -/* Stage timer for init-path profiling. Emits one info line per checkpoint: +/* Stage timer for init-path profiling. Emits one event per checkpoint: * - * init-timing: . = ms + * {"ev":"init.timing","stage":".","ms":N} * * `stage()` reports time since the previous checkpoint (or construction); - * `total()` reports time since construction. Always-on: a handful of log - * lines per init, negligible next to the USB transfers being measured. - * tests/bench_init.py parses these lines. */ + * `total()` reports time since construction. Always-on: a handful of events + * per init, negligible next to the USB transfers being measured. + * tests/bench_init.py parses these events (docs/logging.md). */ class InitTimer { using clock = std::chrono::steady_clock; @@ -24,16 +25,21 @@ class InitTimer { void stage(const char *name) { const auto now = clock::now(); - _logger->info("init-timing: {}.{} = {} ms", _scope, name, ms(_last, now)); + emit(name, ms(_last, now)); _last = now; } - void total() { - _logger->info("init-timing: {}.total = {} ms", _scope, - ms(_start, clock::now())); - } + void total() { emit("total", ms(_start, clock::now())); } private: + void emit(const char *name, long long millis) { + char stage[96]; + std::snprintf(stage, sizeof(stage), "%s.%s", _scope, name); + devourer::Ev(_logger->events(), "init.timing") + .f("stage", stage) + .f("ms", millis); + } + static long long ms(clock::time_point from, clock::time_point to) { return std::chrono::duration_cast(to - from) .count(); diff --git a/src/RadiotapBuilder.cpp b/src/RadiotapBuilder.cpp index 5abc22f..f1421ab 100644 --- a/src/RadiotapBuilder.cpp +++ b/src/RadiotapBuilder.cpp @@ -228,8 +228,9 @@ TxMode parse_tx_mode_str(const std::string& spec) { start = slash + 1; } if (tokens.empty() || tokens[0].empty() || !parse_rate_token(tokens[0], &cfg)) { + /* free function, no Logger handle — hand-rolled diagnostic-plane line */ std::fprintf(stderr, - "warning: unrecognised TX rate '%s', " + "devourer [W] unrecognised TX rate '%s', " "falling back to 6M legacy\n", spec.c_str()); return TxMode{}; } @@ -243,7 +244,7 @@ TxMode parse_tx_mode_str(const std::string& spec) { cfg.bw_mhz = static_cast(std::atoi(t.c_str())); else if (!t.empty()) std::fprintf(stderr, - "warning: ignoring unrecognised TX-mode token " + "devourer [W] ignoring unrecognised TX-mode token " "'%s'\n", t.c_str()); } return cfg; diff --git a/src/RtlUsbAdapter.cpp b/src/RtlUsbAdapter.cpp index 90a9517..63713af 100644 --- a/src/RtlUsbAdapter.cpp +++ b/src/RtlUsbAdapter.cpp @@ -432,7 +432,7 @@ void RtlUsbAdapter::transfer_callback(struct libusb_transfer *transfer) { auto *self = static_cast(transfer->user_data); if (transfer->status == LIBUSB_TRANSFER_COMPLETED && transfer->actual_length == transfer->length) { - self->_logger->debug("Packet sent successfully, length: {}", + DVR_DEBUG(self->_logger, "Packet sent successfully, length: {}", transfer->length); } else { /* Flag the bulk-OUT as possibly halted so the next send_packet (on the TX @@ -448,6 +448,11 @@ void RtlUsbAdapter::transfer_callback(struct libusb_transfer *transfer) { std::memory_order_relaxed); self->_logger->error("Failed to send packet, status: {}, actual length: {}", transfer->status, transfer->actual_length); + /* machine-readable mirror of the failure (tests/regress.py keys on it) */ + devourer::Ev(self->_logger->events(), "tx.fail") + .f("status", (long long)transfer->status) + .f("actual_len", transfer->actual_length) + .f("timeout", transfer->status == LIBUSB_TRANSFER_TIMED_OUT); } libusb_free_transfer(transfer); } @@ -536,7 +541,7 @@ bool RtlUsbAdapter::send_packet(uint8_t *packet, size_t length) { auto end = std::chrono::high_resolution_clock::now(); std::chrono::duration elapsed = end - start; if (rc == LIBUSB_SUCCESS) { - _logger->debug("Packet sent successfully, length: {},used time {}ms", length, + DVR_DEBUG(_logger, "Packet sent successfully, length: {},used time {}ms", length, elapsed.count()); return true; } else { @@ -545,6 +550,9 @@ bool RtlUsbAdapter::send_packet(uint8_t *packet, size_t length) { _tx_stats->last_timeout.store(rc == LIBUSB_ERROR_TIMEOUT, std::memory_order_relaxed); _logger->error("Failed to send packet, error code: {}", rc); + devourer::Ev(_logger->events(), "tx.fail") + .f("rc", rc) + .f("timeout", rc == LIBUSB_ERROR_TIMEOUT); libusb_free_transfer(transfer); return false; } diff --git a/src/RtlUsbAdapter.h b/src/RtlUsbAdapter.h index 18a7886..ec23763 100644 --- a/src/RtlUsbAdapter.h +++ b/src/RtlUsbAdapter.h @@ -185,12 +185,14 @@ class RtlUsbAdapter { } template bool rtw_write(uint16_t reg_num, T value) { - /* debug.log_writes: emit every vendor reg write as "0xADDR N 0xVAL" - * (matches tests/decode_wseq.py output) so devourer's bring-up write set - * can be diffed against the kernel golden. */ + /* debug.log_writes: emit every vendor reg write as a debug.wreg event + * (addr/width/val mirror tests/decode_wseq.py's tuple) so devourer's + * bring-up write set can be diffed against the kernel golden. */ if (_log_writes) - fprintf(stderr, "0x%04x %zu 0x%0*x\n", reg_num, sizeof(T), - (int)(sizeof(T) * 2), (unsigned)value); + devourer::Ev(_logger->events(), "debug.wreg") + .hexf("addr", reg_num, 4) + .f("width", sizeof(T)) + .hexf("val", (unsigned long long)value, (int)(sizeof(T) * 2)); if (libusb_control_transfer(_dev_handle, REALTEK_USB_VENQT_WRITE, 5, reg_num, 0, (uint8_t *)&value, sizeof(T), USB_TIMEOUT) == sizeof(T)) { diff --git a/src/RxPacket.h b/src/RxPacket.h index 02583c4..44582c1 100644 --- a/src/RxPacket.h +++ b/src/RxPacket.h @@ -39,7 +39,7 @@ struct rx_pkt_attrib * for the frame. With the seq_num just above it, a downstream layer * can drop duplicates by seq and measure one-way latency by diffing * the chip's TSF against its own wall clock. Populated by - * FrameParser; surfaced through examples/rx/main.cpp's . */ + * FrameParser; surfaced through examples/rx/main.cpp's rx.frame event. */ uint32_t tsfl; uint8_t data_rate; uint8_t bw; diff --git a/src/jaguar1/FirmwareManager.cpp b/src/jaguar1/FirmwareManager.cpp index d1eb5f5..0fca4e4 100644 --- a/src/jaguar1/FirmwareManager.cpp +++ b/src/jaguar1/FirmwareManager.cpp @@ -895,7 +895,7 @@ void FirmwareManager::_SetDownLoadFwRsvdPagePkt_8814A( _logger->error("8814A RSVD chunk TX failed: sent={} of {} bytes", sent, (int)total); } else { - _logger->debug("8814A RSVD chunk TX OK: {} bytes", sent); + DVR_DEBUG(_logger, "8814A RSVD chunk TX OK: {} bytes", sent); } } diff --git a/src/jaguar1/FrameParser.cpp b/src/jaguar1/FrameParser.cpp index 11f7f4c..26951ce 100644 --- a/src/jaguar1/FrameParser.cpp +++ b/src/jaguar1/FrameParser.cpp @@ -133,7 +133,7 @@ static rx_pkt_attrib rtl8812_query_rx_desc_status(uint8_t *pdesc) { /* Offset 20 — chip-side TSF low (32 bits). Surfaced via RxAtrib.tsfl * for downstream latency measurement and dup-detection (see - * examples/rx/main.cpp's line). The macro reads bits 0..31 + * examples/rx/main.cpp's rx.frame event). The macro reads bits 0..31 * of pdesc+20 (full 4-byte u32), not a byte — the original commented * `(byte)` cast in master was a copy-paste from another field. */ pattrib.tsfl = GET_RX_STATUS_DESC_TSFL_8812(pdesc); @@ -190,7 +190,7 @@ std::vector FrameParser::recvbuf2recvframe(std::span ptr) { * corruption (corruption_analysis.py, FEC layers). The pkt_len check * above already guards the slice math against a corrupted descriptor. */ if ((pattrib.crc_err) || (pattrib.icv_err)) { - _logger->debug("RX corrupted frame surfaced: crc_err={} icv_err={} " + DVR_DEBUG(_logger, "RX corrupted frame surfaced: crc_err={} icv_err={} " "pkt_len={}", pattrib.crc_err, pattrib.icv_err, pattrib.pkt_len); } @@ -287,7 +287,7 @@ std::vector FrameParser::recvbuf2recvframe(std::span ptr) { * never decremented it — so a non-zero value here is the norm for every * aggregated transfer, not an error. */ if (pkt_cnt != 0) { - _logger->debug("RX aggregate carried {} packets (DMA_AGG_NUM)", pkt_cnt); + DVR_DEBUG(_logger, "RX aggregate carried {} packets (DMA_AGG_NUM)", pkt_cnt); } //_logger->info("{} received in frame", ret.size()); diff --git a/src/jaguar1/HalModule.cpp b/src/jaguar1/HalModule.cpp index 62a7b8f..4b858a1 100644 --- a/src/jaguar1/HalModule.cpp +++ b/src/jaguar1/HalModule.cpp @@ -2391,7 +2391,7 @@ void HalModule::odm_read_and_config_mp_8812a_phy_reg() { } } else { if (is_matched) { - //_logger->debug("SEND_TO {:04X}", v1); + //DVR_DEBUG(_logger, "SEND_TO {:04X}", v1); odm_config_bb_phy_8812a(v1, MASKDWORD, v2); } } diff --git a/src/jaguar1/Iqk8812a.cpp b/src/jaguar1/Iqk8812a.cpp index ba62bfa..438a5d6 100644 --- a/src/jaguar1/Iqk8812a.cpp +++ b/src/jaguar1/Iqk8812a.cpp @@ -370,7 +370,7 @@ void Iqk8812a::DoTxRxCalibration(uint8_t chnl_idx, BandType band) { } } - _logger->debug("Iqk8812a TX: A_done={} B_done={} A_retry={} B_retry={}", + DVR_DEBUG(_logger, "Iqk8812a TX: A_done={} B_done={} A_retry={} B_retry={}", unsigned(TX0_finish), unsigned(TX1_finish), unsigned(cal0_retry), unsigned(cal1_retry)); @@ -574,7 +574,7 @@ void Iqk8812a::DoTxRxCalibration(uint8_t chnl_idx, BandType band) { } } - _logger->debug("Iqk8812a RX: A_done={} B_done={} A_retry={} B_retry={}", + DVR_DEBUG(_logger, "Iqk8812a RX: A_done={} B_done={} A_retry={} B_retry={}", unsigned(RX0_finish), unsigned(RX1_finish), unsigned(cal0_retry), unsigned(cal1_retry)); diff --git a/src/jaguar1/PhydmWatchdog.cpp b/src/jaguar1/PhydmWatchdog.cpp index 64b3b7c..e9168f1 100644 --- a/src/jaguar1/PhydmWatchdog.cpp +++ b/src/jaguar1/PhydmWatchdog.cpp @@ -243,7 +243,7 @@ void PhydmWatchdog::DigTick(uint32_t fa_cnt) { } if (new_igi != _cur_ig_value) { - _logger->debug("PhydmWatchdog::DigTick fa={} igi 0x{:02x}->0x{:02x}", + DVR_DEBUG(_logger, "PhydmWatchdog::DigTick fa={} igi 0x{:02x}->0x{:02x}", fa_cnt, unsigned(_cur_ig_value), unsigned(new_igi)); DigWriteIgi(new_igi); _cur_ig_value = new_igi; diff --git a/src/jaguar1/PowerTracking8812a.cpp b/src/jaguar1/PowerTracking8812a.cpp index c87bbbd..9b09911 100644 --- a/src/jaguar1/PowerTracking8812a.cpp +++ b/src/jaguar1/PowerTracking8812a.cpp @@ -178,7 +178,7 @@ void PowerTracking8812a::ApplySwingToBb() { void PowerTracking8812a::TickThermalMeter(BandType band, uint8_t channel) { if (!_initialised) { - _logger->debug( + DVR_DEBUG(_logger, "PowerTracking8812a::TickThermalMeter called before Init — skipped"); return; } @@ -221,7 +221,7 @@ void PowerTracking8812a::TickThermalMeter(BandType band, uint8_t channel) { delta_abs = static_cast(_thermalValue - avg); } - _logger->debug( + DVR_DEBUG(_logger, "pwrtrk tick: ch={} band={} thermal_raw=0x{:x} avg=0x{:x} eeprom=0x{:x} " "delta_abs={} last_thermal=0x{:x}", unsigned(channel), unsigned(band), unsigned(thermal_value), unsigned(avg), @@ -239,7 +239,7 @@ void PowerTracking8812a::TickThermalMeter(BandType band, uint8_t channel) { } GetTrackingTable(band, channel, avg, delta); - _logger->debug( + DVR_DEBUG(_logger, "pwrtrk delta={} abs_ofdm_swing_idx=[A={}, B={}] delta_pwr=[{}, {}] " "default_ofdm={} -> final=[A={}, B={}]", unsigned(delta), int(_absoluteOfdmSwingIdx[0]), diff --git a/src/jaguar1/RadioManagementModule.cpp b/src/jaguar1/RadioManagementModule.cpp index f8992ed..0619845 100644 --- a/src/jaguar1/RadioManagementModule.cpp +++ b/src/jaguar1/RadioManagementModule.cpp @@ -620,7 +620,7 @@ void RadioManagementModule::DumpCanary() { void RadioManagementModule::phy_set_rf_reg(RfPath eRFPath, uint16_t RegAddr, uint32_t BitMask, uint32_t Data) { uint32_t data = Data; - //_logger->debug("RFREG;{};{:X};{:X};{:X}", (uint8_t)eRFPath, (uint)RegAddr, + //DVR_DEBUG(_logger, "RFREG;{};{:X};{:X};{:X}", (uint8_t)eRFPath, (uint)RegAddr, // BitMask, data); if (BitMask == 0) { return; @@ -2187,7 +2187,7 @@ void RadioManagementModule::PHY_SetTxPowerIndex_8812A(uint32_t powerIndex, RfPath rfPath, MGN_RATE rate) { - _logger->debug("PHY_SetTxPowerIndex {} {} {}", powerIndex, (int)rfPath, rate); + DVR_DEBUG(_logger, "PHY_SetTxPowerIndex {} {} {}", powerIndex, (int)rfPath, rate); /* 8814A: per-rate per-path power index is programmed via a single packed * BB-register write at 0x1998. Port of PHY_SetTxPowerIndex_8814A from @@ -2659,7 +2659,7 @@ void RadioManagementModule::PHY_SetTxPowerIndex_8812A(uint32_t powerIndex, void RadioManagementModule::phy_set_tx_power_index_by_rate_section( RfPath rfPath, uint8_t channel, RATE_SECTION rateSection) { - _logger->debug("SET_TX_POWER {} - {} - {}", (int)rfPath, (int)channel, + DVR_DEBUG(_logger, "SET_TX_POWER {} - {} - {}", (int)rfPath, (int)channel, (int)rateSection); if (rateSection >= RATE_SECTION::RATE_SECTION_NUM) { diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index e7834d2..2039f87 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -17,6 +17,19 @@ #include #include +/* comma-joined 0xNN hex dump for the DVR_TRACE TX-buffer dumps (argument is + * only evaluated when trace is enabled). */ +[[maybe_unused]] static std::string hex_join(const uint8_t *p, size_t n) { + std::string s; + s.reserve(n * 5); + char b[8]; + for (size_t i = 0; i < n; i++) { + std::snprintf(b, sizeof(b), "%s0x%02x", i ? "," : "", p[i]); + s += b; + } + return s; +} + /* Per-queue free-page registers (8814A only — 0x0230..0x0240 don't decode * the same way on 8812/8821). Cross-checked against hal/rtl8814a_spec.h * and src/HalModule.cpp's REG_FIFOPAGE_INFO_*_8814A constants. */ @@ -304,7 +317,7 @@ bool RtlJaguarDevice::send_packet(const uint8_t *packet, size_t length) { usb_frame_length = real_packet_length + TXDESC_SIZE; - _logger->debug("radiotap length is {}, 80211 length is {}, usb_frame length " + DVR_DEBUG(_logger, "radiotap length is {}, 80211 length is {}, usb_frame length " "should be {}", radiotap_length, real_packet_length, usb_frame_length); @@ -483,7 +496,7 @@ bool RtlJaguarDevice::send_packet(const uint8_t *packet, size_t length) { } stbc = 0; } - _logger->debug("fixed rate:{}, sgi:{}, radiotap_bwidth:{}, ldpc:{}, stbc:{}", + DVR_DEBUG(_logger, "fixed rate:{}, sgi:{}, radiotap_bwidth:{}, ldpc:{}, stbc:{}", (int)fixed_rate, (int)sgi, (int)bwidth, (int)ldpc, (int)stbc); uint8_t BWSettingOfDesc; @@ -494,7 +507,7 @@ bool RtlJaguarDevice::send_packet(const uint8_t *packet, size_t length) { } else { BWSettingOfDesc = 0; } - _logger->debug("TX DESC BW decision: _channel.ChannelWidth(RX)={}, radiotap_bwidth(TX)={}, BWSettingOfDesc(TX_DESC)={}", + DVR_DEBUG(_logger, "TX DESC BW decision: _channel.ChannelWidth(RX)={}, radiotap_bwidth(TX)={}, BWSettingOfDesc(TX_DESC)={}", (int)_channel.ChannelWidth, (int)bwidth, (int)BWSettingOfDesc); SET_TX_DESC_DATA_BW_8812(usb_frame, BWSettingOfDesc); @@ -602,32 +615,12 @@ bool RtlJaguarDevice::send_packet(const uint8_t *packet, size_t length) { } rtl8812a_cal_txdesc_chksum(usb_frame); - _logger->debug("tx desc formed"); -#ifdef DEBUG - for (size_t i = 0; i < usb_frame_length; ++i) { - std::cout << "0x" << std::hex << std::setw(2) << std::setfill('0') - << static_cast(usb_frame[i]); - - if (i < usb_frame_length - 1) { - std::cout << ","; - } - } - std::cout << std::dec << std::endl; -#endif + DVR_TRACE(_logger, "tx desc formed: {}", + hex_join(usb_frame, usb_frame_length)); uint8_t *addr = usb_frame + TXDESC_SIZE; memcpy(addr, packet + radiotap_length, real_packet_length); - _logger->debug("packet formed"); -#ifdef DEBUG - for (size_t i = 0; i < usb_frame_length; ++i) { - std::cout << "0x" << std::hex << std::setw(2) << std::setfill('0') - << static_cast(usb_frame[i]); - - if (i < usb_frame_length - 1) { - std::cout << ","; - } - } - std::cout << std::dec << std::endl; -#endif + DVR_TRACE(_logger, "packet formed: {}", + hex_join(usb_frame, usb_frame_length)); resp = _device.send_packet(usb_frame, usb_frame_length); delete[] usb_frame; @@ -674,7 +667,7 @@ void RtlJaguarDevice::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { * instead of setting it once — the stimulus for the mobile/fading combining * measurement: alternate a fixed single chain vs all-4 fast relative to the * operator's motion, so both configs sample the same fading process. Each - * switch prints a `0xNN` marker inline with the frame + * switch emits an `rx.path_mask` event inline with the frame * stream so the analyser can tag each frame with the active mask. A plain * `0xNN` applies once, as before. */ if (_cfg.rx.path_spec) { @@ -947,7 +940,7 @@ RtlJaguarDevice::~RtlJaguarDevice() { } /* Cycle the RX-path mask (0x808 byte 0) through `masks` every `interval_ms` on a - * background thread, printing a `mask` marker on each switch. + * background thread, emitting an `rx.path_mask` event on each switch. * The control-transfer write runs concurrently with the RX bulk-IN workers on a * different endpoint, which libusb permits (see the queue-depth poller). Used by * the mobile/fading combining measurement. */ @@ -962,8 +955,7 @@ void RtlJaguarDevice::start_rx_path_toggle(const std::vector &masks, while (!_rxmask_stop.load()) { uint8_t m = masks[i++ % masks.size()]; _device.rtw_write8(0x808, m); - std::printf("0x%02x\n", m); - std::fflush(stdout); + devourer::Ev(_logger->events(), "rx.path_mask").t().hexf("mask", m, 2); for (uint32_t slept = 0; slept < interval_ms && !_rxmask_stop.load(); slept += 25) std::this_thread::sleep_for(std::chrono::milliseconds(25)); diff --git a/src/jaguar2/HalJaguar2.cpp b/src/jaguar2/HalJaguar2.cpp index a06e250..10bb490 100644 --- a/src/jaguar2/HalJaguar2.cpp +++ b/src/jaguar2/HalJaguar2.cpp @@ -687,7 +687,8 @@ bool HalJaguar2::fast_retune(uint8_t channel, uint8_t bw, if (channel == _last_tuned_ch) return true; /* no-op hop */ - devourer::HopProf prof(_cfg.debug.hop_prof, "j2", channel); + devourer::HopProf prof(_logger->events(), _cfg.debug.hop_prof, "j2", + channel); const uint8_t cch = central_ch(channel, bw, primary_ch_idx); const bool g2 = cch <= 14; const bool c8821 = (_variant == ChipVariant::C8821C); @@ -833,7 +834,7 @@ bool HalJaguar2::fast_retune(uint8_t channel, uint8_t bw, * >80% of the hop's USB round-trips. */ _last_tuned_ch = channel; - _logger->debug("Jaguar2: fast retune -> ch {} (central {}, RF18=0x{:05x})", + DVR_DEBUG(_logger, "Jaguar2: fast retune -> ch {} (central {}, RF18=0x{:05x})", channel, cch, rf18); /* The fast path must emit the canary itself (it does not pass through the * full path) or the parity diff compares a stale full-set dump. */ diff --git a/src/jaguar3/RadioManagementJaguar3.cpp b/src/jaguar3/RadioManagementJaguar3.cpp index d716e4e..77c789b 100644 --- a/src/jaguar3/RadioManagementJaguar3.cpp +++ b/src/jaguar3/RadioManagementJaguar3.cpp @@ -447,7 +447,8 @@ bool RadioManagementJaguar3::fast_retune(uint8_t channel, if (channel == _last_channel) return true; /* no-op hop */ - devourer::HopProf prof(_cfg.debug.hop_prof, "j3", channel); + devourer::HopProf prof(_logger->events(), _cfg.debug.hop_prof, "j3", + channel); uint8_t central, pri; central_and_pri(channel, channel_offset, bwmode, central, pri); @@ -561,7 +562,7 @@ bool RadioManagementJaguar3::fast_retune(uint8_t channel, prof.mark("bbrst"); _last_channel = channel; - _logger->debug("Jaguar3: fast retune -> ch {} (central {}, RF18=0x{:05x})", + DVR_DEBUG(_logger, "Jaguar3: fast retune -> ch {} (central {}, RF18=0x{:05x})", channel, central, rf18); /* The J1 parity lesson: the fast path must emit the canary itself — it does * not pass through the full path, so without this the parity diff would diff --git a/src/logger.h b/src/logger.h index 4820368..d06f1e5 100644 --- a/src/logger.h +++ b/src/logger.h @@ -1,51 +1,61 @@ #ifndef LOGGER_H #define LOGGER_H +/* Human diagnostics — leveled text lines on stderr (the control plane). + * + * devourer [I] message\n (level letter: T/D/I/W/E) + * + * The machine event stream (JSON Lines on stdout) is the other plane; it + * lives in Event.h and is reached through this object as logger->events(). + * Contract + schema: docs/logging.md. + * + * Each line is assembled fully, then written with one fwrite + fflush — + * per-line atomicity across threads (RX loop / coex / main TX) and no + * pipe-buffering stalls for a subprocess supervisor. + * + * Verbosity has two gates: + * - runtime: set_level() (default Debug — everything but Trace). + * - compile time: DEVOURER_LOG_MAX_LEVEL (CMake option of the same name) + * removes trace/debug call bodies entirely. Hot-path trace/debug sites + * must go through the DVR_TRACE/DVR_DEBUG macros so the *arguments* are + * not evaluated either. Default: NDEBUG builds compile debug/trace out + * (matching the old NDEBUG behavior), debug builds keep everything. + * + * Configure (set_level / set_diag_stream / events().configure) before worker + * threads spawn; the fields are intentionally unsynchronized. */ + #include #include #include +#include #include -#include #include -#include #include #include +#include "Event.h" + #define ushort uint16_t -#define DEVOURER_LOG_TAG "devourer" -#ifdef __ANDROID__ - #include +/* Compile-time floor: calls below this level compile to nothing. */ +#define DEVOURER_LL_TRACE 0 +#define DEVOURER_LL_DEBUG 1 +#define DEVOURER_LL_INFO 2 +#define DEVOURER_LL_WARN 3 +#define DEVOURER_LL_ERROR 4 +#define DEVOURER_LL_SILENT 5 - #define DEVOURER_LOGV(...) __android_log_write(ANDROID_LOG_VERBOSE, DEVOURER_LOG_TAG, __VA_ARGS__) - #define DEVOURER_LOGD(...) __android_log_write(ANDROID_LOG_DEBUG, DEVOURER_LOG_TAG, __VA_ARGS__) - #define DEVOURER_LOGI(...) __android_log_write(ANDROID_LOG_INFO, DEVOURER_LOG_TAG, __VA_ARGS__) - #define DEVOURER_LOGW(...) __android_log_write(ANDROID_LOG_WARN, DEVOURER_LOG_TAG, __VA_ARGS__) - #define DEVOURER_LOGE(...) __android_log_write(ANDROID_LOG_ERROR, DEVOURER_LOG_TAG, __VA_ARGS__) +#ifndef DEVOURER_LOG_MAX_LEVEL +#ifdef NDEBUG +#define DEVOURER_LOG_MAX_LEVEL DEVOURER_LL_INFO #else - #include - - #define DEVOURER_LOGV(...) \ - printf("<%s>", DEVOURER_LOG_TAG); \ - printf(__VA_ARGS__); \ - printf("\n") - #define DEVOURER_LOGD(...) \ - printf("<%s>", DEVOURER_LOG_TAG); \ - printf(__VA_ARGS__); \ - printf("\n") - #define DEVOURER_LOGI(...) \ - printf("<%s>", DEVOURER_LOG_TAG); \ - printf(__VA_ARGS__); \ - printf("\n") - #define DEVOURER_LOGW(...) \ - printf("<%s>", DEVOURER_LOG_TAG); \ - printf(__VA_ARGS__); \ - printf("\n") - #define DEVOURER_LOGE(...) \ - printf("<%s>", DEVOURER_LOG_TAG); \ - printf(__VA_ARGS__); \ - printf("\n") +#define DEVOURER_LOG_MAX_LEVEL DEVOURER_LL_TRACE +#endif +#endif + +#ifdef __ANDROID__ +#include #endif /* Widen byte-sized integers before streaming: `oss << uint8_t` inserts a raw @@ -132,48 +142,126 @@ using format_string_t = std::string_view; class Logger { public: /* Verbosity threshold. A message at level L is emitted only when - * _level <= L, so Debug shows everything and Silent shows nothing. Defaults - * to Debug, so existing consumers' output is unchanged; a caller that wants - * a clean stdout (e.g. sense's live display) can quiet the library - * with set_level(Logger::Level::Warn). */ - enum class Level { Debug, Info, Warn, Error, Silent }; + * _level <= L, so Trace shows everything and Silent shows nothing. + * Defaults to Debug; a caller that wants a clean stderr (e.g. sense's + * live display) can quiet the library with set_level(Level::Warn). */ + enum class Level { Trace, Debug, Info, Warn, Error, Silent }; void set_level(Level l) { _level = l; } Level level() const { return _level; } + /* Diagnostics destination (default stderr). */ + void set_diag_stream(std::FILE* f) { _diag = f; } + + /* The machine event stream (JSON Lines, default stdout) — Event.h. */ + devourer::EventSink& events() { return _events; } + + template + void trace(format_string_t fmt, Args &&...args) { +#if DEVOURER_LOG_MAX_LEVEL <= DEVOURER_LL_TRACE + if (_level > Level::Trace) return; + emit('T', format(fmt, args...)); +#else + (void)fmt; ((void)args, ...); +#endif + } + template void debug(format_string_t fmt, Args &&...args) { -#if !defined(NDEBUG) +#if DEVOURER_LOG_MAX_LEVEL <= DEVOURER_LL_DEBUG if (_level > Level::Debug) return; - std::string txt = format(fmt, args...); - DEVOURER_LOGD(txt.c_str()); + emit('D', format(fmt, args...)); +#else + (void)fmt; ((void)args, ...); #endif } template void info(format_string_t fmt, Args &&...args) { +#if DEVOURER_LOG_MAX_LEVEL <= DEVOURER_LL_INFO if (_level > Level::Info) return; - std::string txt = format(fmt, args...); - DEVOURER_LOGI(txt.c_str()); + emit('I', format(fmt, args...)); +#else + (void)fmt; ((void)args, ...); +#endif } template void warn(format_string_t fmt, Args &&...args) { +#if DEVOURER_LOG_MAX_LEVEL <= DEVOURER_LL_WARN if (_level > Level::Warn) return; - std::string txt = format(fmt, args...); - DEVOURER_LOGW(txt.c_str()); + emit('W', format(fmt, args...)); +#else + (void)fmt; ((void)args, ...); +#endif } template void error(format_string_t fmt, Args &&...args) { +#if DEVOURER_LOG_MAX_LEVEL <= DEVOURER_LL_ERROR if (_level > Level::Error) return; - std::string txt = format(fmt, args...); - DEVOURER_LOGE(txt.c_str()); + emit('E', format(fmt, args...)); +#else + (void)fmt; ((void)args, ...); +#endif } private: + void emit(char lvl, const std::string& txt) { +#ifdef __ANDROID__ + static constexpr int prio[] = {ANDROID_LOG_VERBOSE, ANDROID_LOG_DEBUG, + ANDROID_LOG_INFO, ANDROID_LOG_WARN, + ANDROID_LOG_ERROR}; + const char* p = "TDIWE"; + const char* found = std::strchr(p, lvl); + __android_log_write(prio[found ? found - p : 2], "devourer", + txt.c_str()); +#else + /* One line, one fwrite (FILE* is stream-locked → per-line atomic + * across threads), then flush so a pipe reader never stalls. */ + std::string line; + line.reserve(txt.size() + 16); + line += "devourer ["; + line += lvl; + line += "] "; + line += txt; + line += '\n'; + std::fwrite(line.data(), 1, line.size(), _diag); + std::fflush(_diag); +#endif + } + Level _level = Level::Debug; + std::FILE* _diag = stderr; + devourer::EventSink _events; }; using Logger_t = std::shared_ptr; +/* Hot-path trace/debug: the if-guard elides argument evaluation at runtime; + * the #if removes the whole statement (arguments included) when compiled out + * via DEVOURER_LOG_MAX_LEVEL. */ +#if DEVOURER_LOG_MAX_LEVEL <= DEVOURER_LL_TRACE +#define DVR_TRACE(lg, ...) \ + do { \ + if ((lg) && (lg)->level() <= Logger::Level::Trace) \ + (lg)->trace(__VA_ARGS__); \ + } while (0) +#else +#define DVR_TRACE(lg, ...) \ + do { \ + } while (0) +#endif + +#if DEVOURER_LOG_MAX_LEVEL <= DEVOURER_LL_DEBUG +#define DVR_DEBUG(lg, ...) \ + do { \ + if ((lg) && (lg)->level() <= Logger::Level::Debug) \ + (lg)->debug(__VA_ARGS__); \ + } while (0) +#else +#define DVR_DEBUG(lg, ...) \ + do { \ + } while (0) +#endif + #endif /* LOGGER_H */ diff --git a/tests/adapter_doctor_cold.sh b/tests/adapter_doctor_cold.sh index 659b66d..a038017 100644 --- a/tests/adapter_doctor_cold.sh +++ b/tests/adapter_doctor_cold.sh @@ -78,7 +78,7 @@ sleep 6 kill -0 $! 2>/dev/null || { log "FATAL: flood txdemo died"; tail -5 "$LOG/flood.log"; exit 3; } timeout -k 5 -s INT 8 env $VERIFY_ARGS DEVOURER_CHANNEL="$CHANNEL" \ "$RXDEMO" > "$LOG/verify.log" 2>&1 -hits=$(grep -o 'hits=[0-9]*' "$LOG/verify.log" | tail -1 | cut -d= -f2) +hits=$(grep -o '"hits":[0-9]*' "$LOG/verify.log" | tail -1 | cut -d: -f2) [ "${hits:-0}" -ge 5 ] || { log "FATAL: flood not radiating (hits=${hits:-0})"; exit 3; } log "flood radiating (verify hits=$hits) — deaf verdicts are trustworthy" @@ -107,7 +107,7 @@ for i in $(seq 1 "$REPS"); do --expect-traffic > "$LOG/rep$i.log" 2>&1 rc=$? [ "$rc" -gt "$worst" ] && [ "$rc" -le 2 ] && worst=$rc - log "rep $i: $(grep -o '.*' "$LOG/rep$i.log" | head -1) (rc=$rc)" + log "rep $i: $(grep -F '"ev":"doctor.verdict"' "$LOG/rep$i.log" | head -1) (rc=$rc)" done log "=== worst verdict across $REPS cold reps: rc=$worst (0=healthy 1=suspect 2=failing) ===" diff --git a/tests/adaptive_onair.sh b/tests/adaptive_onair.sh index 9d005f8..40e2b69 100644 --- a/tests/adaptive_onair.sh +++ b/tests/adaptive_onair.sh @@ -6,8 +6,11 @@ # (USE_INTERFERER=1) drives the link into the corrupt regime so the controller # visibly drops to a more robust profile. # -# WITNESS (no extra instrumentation): the *peer's* rate= changes -# when a SET_RATE lands, and rssi= rises when a SET_PWR raises power. +# WITNESS (no extra instrumentation): the *peer's* rx.frame events change rate +# when a SET_RATE lands, and rssi rises when a SET_PWR raises power. duplex's +# stream.rx/stream.ctl events ride its stdout, which adaptive_link.py consumes +# for the controller — they never reach these logs; the applied state is +# witnessed via adaptive_link's own / stderr lines. # # sudo bash tests/adaptive_onair.sh # steady-state adaptation # USE_INTERFERER=1 IGAIN=75 sudo bash tests/adaptive_onair.sh @@ -64,8 +67,11 @@ sudo env DEVOURER_VID=$VTX_VID DEVOURER_PID=$VTX_PID PYTHONPATH="$PREC" \ sleep "$SECS"; KILL; sleep 1 echo "=== RESULT ===" -echo "[vrx] video frames heard from VTX (rx hits): $(grep -oP 'rx hits=\K\d+' "$VRX_LOG" | tail -1)" -echo "[vtx] RCF applied: SET_PWR=$(grep -c 'ctl op=1' "$VTX_LOG") SET_RATE=$(grep -c 'ctl op=2' "$VTX_LOG")" +echo "[vrx] last controller window (frames= is the scoring-window RX count):" +grep '' "$VRX_LOG" | tail -1 +SETPTS=$(grep -oP '.*txagc=\K\d+' "$VTX_LOG" | uniq | wc -l) +LADDERS=$(grep -oP '.*ladder=\K\S+' "$VTX_LOG" | uniq | wc -l) +echo "[vtx] applied operating points: txagc set-points=$SETPTS rate ladders=$LADDERS (>1 of either = RCF landed)" echo "[vrx] controller trajectory (1 Hz):" grep '' "$VRX_LOG" | tail -8 echo "[vtx] applied-state trajectory (1 Hz):" diff --git a/tests/antenna_decorrelation.py b/tests/antenna_decorrelation.py index acb4d06..a21de81 100644 --- a/tests/antenna_decorrelation.py +++ b/tests/antenna_decorrelation.py @@ -10,13 +10,13 @@ instead of assuming it. Input is the per-chain PHY link metric emitted by ``rxdemo`` under -``DEVOURER_RX_ALLPATHS=1`` — one ```` line per received frame: +``DEVOURER_RX_ALLPATHS=1`` — one ``rx.path`` JSONL event per received frame: - seq=N rssi=a,b,c,d snr=a,b,c,d evm=a,b,c,d + {"ev":"rx.path","seq":N,"rssi":[a,b,c,d],"snr":[a,b,c,d],"evm":[a,b,c,d]} Paths C/D are meaningful only on the 8814AU (4T4R); on 2T2R parts they read 0 -and are dropped automatically. The two-path ```` line is also -parsed as a fallback (rssi=a,b / snr=a,b) so a 2T2R capture works with no demo +and are dropped automatically. The two-path ``rx.frame`` event is also parsed +as a fallback (rssi/snr arrays of 2) so a 2T2R capture works with no demo flag. What it computes, per capture: @@ -46,19 +46,11 @@ from __future__ import annotations import argparse -import re import sys import numpy as np -# seq=12 rssi=40,38,0,0 snr=25,24,0,0 evm=10,11,0,0 -_RXPATH = re.compile( - r"seq=(\d+)\s+rssi=([\-\d,]+)\s+snr=([\-\d,]+)\s+evm=([\-\d,]+)" -) -# fallback: canonical two-path stream line -_STREAM = re.compile( - r".*?rssi=(-?\d+),(-?\d+)\s+evm=(-?\d+),(-?\d+)\s+snr=(-?\d+),(-?\d+)" -) +from devourer_events import parse_event # Correlation bands for the verdict (worst pairwise |rho|). GOOD, MODERATE = 0.3, 0.7 @@ -78,20 +70,15 @@ def parse(lines, metric: str = "rssi"): Trailing all-zero columns (idle paths C/D on a 2T2R part) are trimmed. """ - mi = {"rssi": 0, "snr": 1, "evm": 2}[metric] rows: list[list[float]] = [] for ln in lines: - m = _RXPATH.search(ln) - if m: - triplet = m.groups()[1:] # rssi, snr, evm CSV strings - vals = [int(x) for x in triplet[mi].split(",")] - rows.append([float(v) for v in vals]) + ev = parse_event(ln) + if ev is None: continue - m = _STREAM.search(ln) - if m: - g = [int(x) for x in m.groups()] - pick = {"rssi": g[0:2], "snr": g[4:6], "evm": g[2:4]}[metric] - rows.append([float(v) for v in pick]) + # per-chain event (DEVOURER_RX_ALLPATHS=1), else the canonical two-path + # frame event as a fallback — both carry rssi/snr/evm arrays. + if ev["ev"] in ("rx.path", "rx.frame") and metric in ev: + rows.append([float(v) for v in ev[metric]]) if not rows: return np.empty((0, 0)) width = max(len(r) for r in rows) @@ -305,12 +292,14 @@ def self_test() -> int: print(f"[{status}] 1%-outage MRC gain: independent={indep:.2f} dB > " f"correlated(rho=.9)={corr:.2f} dB") - # 4) parser round-trips both line formats + # 4) parser round-trips both event formats sample = [ - "seq=1 rssi=40,38,20,18 snr=25,24,10,9 evm=5,6,7,8", + '{"ev":"rx.path","seq":1,"rssi":[40,38,20,18],"snr":[25,24,10,9],' + '"evm":[5,6,7,8]}', "junk line", - "rate=4 len=100 crc_err=0 icv_err=0 rssi=41,39 " - "evm=5,6 snr=26,25 seq=2 tsfl=0 bw=0 stbc=0 ldpc=0 sgi=0 body=deadbeef", + '{"ev":"rx.frame","rate":4,"len":100,"crc":0,"icv":0,"rssi":[41,39],' + '"evm":[5,6],"snr":[26,25],"seq":2,"tsfl":0,"bw":0,"stbc":0,"ldpc":0,' + '"sgi":0,"body":"deadbeef"}', ] a = parse(sample, "rssi") status = "ok" if a.shape == (2, 4) else "FAIL" # stream row zero-padded to 4 @@ -347,7 +336,7 @@ def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("input", nargs="?", default="-", - help="capture file with lines, or '-' for stdin") + help="capture file with rx.path events, or '-' for stdin") ap.add_argument("--metric", choices=("rssi", "snr", "evm"), default="rssi", help="per-chain metric to analyse (default rssi)") ap.add_argument("--self-test", action="store_true", @@ -365,7 +354,7 @@ def main() -> int: arr = parse(lines, args.metric) if arr.size == 0: - print("no or lines found — did you " + print("no rx.path or rx.frame events found — did you " "run rxdemo with DEVOURER_RX_ALLPATHS=1 (and receive " "frames)?", file=sys.stderr) return 2 diff --git a/tests/bench_init.py b/tests/bench_init.py index cf2377f..491de5c 100644 --- a/tests/bench_init.py +++ b/tests/bench_init.py @@ -4,9 +4,10 @@ Measures, per plugged DUT, how long it takes from process start until the driver can actually move frames: - * devourer RX — `rxdemo` until `init-timing: demo.first_rx_frame` + * devourer RX — `rxdemo` until the `init.timing` event with + stage `demo.first_rx_frame` (exec → first 802.11 frame delivered). - * devourer TX — `txdemo` until `init-timing: txdemo.first_tx_submit` + * devourer TX — `txdemo` until stage `txdemo.first_tx_submit` (exec → first bulk-OUT submitted; includes txdemo's settle sleep, deliberately — that's the user-visible latency). * kernel — two flavours, same stage names: @@ -22,7 +23,7 @@ driver-behaviour comparisons only — virtualized USB adds latency to every stage, so don't cite its timings. -Per-stage breakdown comes from the `init-timing:` lines emitted by the +Per-stage breakdown comes from the `init.timing` JSONL events emitted by the devourer library (src/InitTimer.h); this script only parses and aggregates. A/B variants isolate extrinsic overheads (libusb log level, USB reset, @@ -42,7 +43,6 @@ import argparse import os -import re import statistics import subprocess import sys @@ -61,8 +61,21 @@ discover_duts, usb_port_power_cycle, ) +from devourer_events import iter_events # noqa: E402 -INIT_TIMING_RE = re.compile(r"init-timing: ([\w.]+) = (\d+) ms") + +def _read_timing_stages(log_path: Path) -> dict[str, int]: + """{stage: ms} from the demo's `init.timing` events. First occurrence + wins: the second channel_set (from mlme init) re-emits channel_set.* — + keep init-order stages stable. The end-marker only ever appears once.""" + stages: dict[str, int] = {} + lines = log_path.read_text(errors="replace").splitlines() + for ev in iter_events(lines, ev="init.timing"): + try: + stages.setdefault(ev["stage"], int(ev["ms"])) + except (KeyError, TypeError, ValueError): + pass + return stages # Variant name → env deltas applied on top of the demo defaults. # "quiet" (libusb WARNING) is the demo default; "debug" opts into libusb @@ -144,7 +157,7 @@ def run_devourer_cell( while time.monotonic() < deadline: if proc.poll() is not None: break # crashed / exited early - if end_marker in log_path.read_text(errors="replace"): + if end_marker in _read_timing_stages(log_path): break time.sleep(0.2) finally: @@ -152,12 +165,7 @@ def run_devourer_cell( regress._unregister_local_proc(proc) fh.close() - stages: dict[str, int] = {} - for m in INIT_TIMING_RE.finditer(log_path.read_text(errors="replace")): - # First occurrence wins: the second channel_set (from mlme init) - # re-emits channel_set.* — keep init-order stages stable. The - # end-marker only ever appears once. - stages.setdefault(m.group(1), int(m.group(2))) + stages = _read_timing_stages(log_path) if end_marker not in stages: return None return stages @@ -272,7 +280,7 @@ def start_traffic_source( while time.monotonic() < deadline: if proc.poll() is not None: sys.exit(f"traffic source died at startup — see {log_path}") - if "txdemo.first_tx_submit" in log_path.read_text(errors="replace"): + if "txdemo.first_tx_submit" in _read_timing_stages(log_path): print(f"[traffic] {src.chipset} beaconing on ch {channel}", flush=True) return proc diff --git a/tests/bf_report_sniff.sh b/tests/bf_report_sniff.sh index b824040..55b55ae 100755 --- a/tests/bf_report_sniff.sh +++ b/tests/bf_report_sniff.sh @@ -4,7 +4,7 @@ # - 8812AU : sounder (TX NDPA + descriptor NDPA bit + arm sounder engine) # - 8821AU : beamformee (armed, unassociated) # - 8814AU : passive monitor sniffer, DEVOURER_BF_DETECT_REPORT=1 -# A line on the sniffer with SA = 8821AU MAC is the +# A "ev":"bf.report" event on the sniffer with sa = 8821AU MAC is the # direct proof the unassociated responder emitted a VHT Compressed Beamforming # report. Run armed vs unarmed to show the report only appears when armed. # @@ -74,9 +74,9 @@ run_cell() { # $1=name $2=arm(0|1) wait "$bfee_pid" "$sniff_pid" 2>/dev/null local n - n=$(grep -c "" "$OUT/$name.sniff.log") + n=$(grep -Fc '"ev":"bf.report"' "$OUT/$name.sniff.log") echo " bf-report lines: $n" - grep "" "$OUT/$name.sniff.log" | head -3 + grep -F '"ev":"bf.report"' "$OUT/$name.sniff.log" | head -3 } run_cell unarmed 0 @@ -85,6 +85,6 @@ run_cell armed 1 echo echo "== verdict ==" -echo "unarmed reports: $(grep -c '' "$OUT/unarmed.sniff.log")" -echo "armed reports: $(grep -c '' "$OUT/armed.sniff.log")" +echo "unarmed reports: $(grep -Fc '"ev":"bf.report"' "$OUT/unarmed.sniff.log")" +echo "armed reports: $(grep -Fc '"ev":"bf.report"' "$OUT/armed.sniff.log")" echo "(expect armed reports with sa=$BFEE_MAC; logs in $OUT)" diff --git a/tests/bf_selfsound_jaguar2.sh b/tests/bf_selfsound_jaguar2.sh index dfc4bcd..65c6fa4 100755 --- a/tests/bf_selfsound_jaguar2.sh +++ b/tests/bf_selfsound_jaguar2.sh @@ -6,7 +6,7 @@ # Jaguar-1 sounder (default 8814AU — proven single-radio GS path) # 8822BU : beamformee (shared recipe, kBfeeJaguar23) — chip under test # Jaguar-1 sniffer (default 8821AU), DEVOURER_BF_DETECT_REPORT=4 -# PASS = with SA = the Jaguar-2 beamformee MAC +# PASS = a "ev":"bf.report" event with sa = the Jaguar-2 beamformee MAC # (00:e0:4c:88:22:bb, programmed at arm time) only when armed. # # cell C (sounder direction + single-radio ground station): @@ -14,7 +14,7 @@ # MAC learned from its init log) # 8822BU : sounder + self-capture (DEVOURER_TX_WITH_RX=thread) — under test # Jaguar-1 sniffer (default 8814AU) cross-check -# PASS = lines in the 8822BU's OWN log (self-capture). +# PASS = "ev":"bf.report" events in the 8822BU's OWN log (self-capture). # Sniffer>0 with self-capture==0 isolates an RX-side issue; both 0 = the # NDPA descriptor / HW NDP never fired. # @@ -89,9 +89,9 @@ run_bfee_cell() { # $1=name $2=arm(0|1) $3=mu(0|1) kill "$bfee_pid" "$sniff_pid" 2>/dev/null wait "$bfee_pid" "$sniff_pid" 2>/dev/null local n - n=$(grep -c "" "$OUT/$name.sniff.log") + n=$(grep -Fc '"ev":"bf.report"' "$OUT/$name.sniff.log") echo " sniffer bf-report lines: $n" - grep "" "$OUT/$name.sniff.log" | grep "$BFEE_MAC" | head -2 + grep -F '"ev":"bf.report"' "$OUT/$name.sniff.log" | grep "$BFEE_MAC" | head -2 } run_sounder_cell() { # cell C: Jaguar-2 as beamformer + single-radio self-capture @@ -129,10 +129,10 @@ run_sounder_cell() { # cell C: Jaguar-2 as beamformer + single-radio self-captur kill "$bfee_pid" "$sniff_pid" 2>/dev/null wait "$bfee_pid" "$sniff_pid" 2>/dev/null local self air - self=$(grep -c "" "$OUT/$name.gs.log") - air=$(grep -c "" "$OUT/$name.sniff.log") + self=$(grep -Fc '"ev":"bf.report"' "$OUT/$name.gs.log") + air=$(grep -Fc '"ev":"bf.report"' "$OUT/$name.sniff.log") echo " self-captured reports: $self sniffer cross-check: $air" - grep "" "$OUT/$name.gs.log" | head -2 + grep -F '"ev":"bf.report"' "$OUT/$name.gs.log" | head -2 } run_bfee_cell "unarmed" 0 @@ -144,9 +144,9 @@ run_sounder_cell echo echo "== verdict ($J2_VID:$J2_PID) ==" -echo "bfee direction — unarmed reports: $(grep '' "$OUT/unarmed.sniff.log" | grep -c "$BFEE_MAC")" -echo "bfee direction — armed reports: $(grep '' "$OUT/armed.sniff.log" | grep -c "$BFEE_MAC")" +echo "bfee direction — unarmed reports: $(grep -F '"ev":"bf.report"' "$OUT/unarmed.sniff.log" | grep -c "$BFEE_MAC")" +echo "bfee direction — armed reports: $(grep -F '"ev":"bf.report"' "$OUT/armed.sniff.log" | grep -c "$BFEE_MAC")" [ "${RUN_MU:-0}" = 1 ] && \ -echo "bfee direction — armed MU reports: $(grep '' "$OUT/armed_mu.sniff.log" | grep -c "$BFEE_MAC")" -echo "sounder direction — self-captured: $(grep -c '' "$OUT/sounder_gs.gs.log" 2>/dev/null)" +echo "bfee direction — armed MU reports: $(grep -F '"ev":"bf.report"' "$OUT/armed_mu.sniff.log" | grep -c "$BFEE_MAC")" +echo "sounder direction — self-captured: $(grep -Fc '"ev":"bf.report"' "$OUT/sounder_gs.gs.log" 2>/dev/null)" echo "(logs in $OUT)" diff --git a/tests/bf_selfsound_jaguar3.sh b/tests/bf_selfsound_jaguar3.sh index bc96c46..9647344 100755 --- a/tests/bf_selfsound_jaguar3.sh +++ b/tests/bf_selfsound_jaguar3.sh @@ -8,7 +8,7 @@ # 8822C/E : beamformee (shared recipe, kBfeeJaguar23) — the chip under test # 8814AU : passive monitor sniffer, DEVOURER_BF_DETECT_REPORT=1 # -# PASS = with SA = the Jaguar-3 beamformee MAC +# PASS = a "ev":"bf.report" event with sa = the Jaguar-3 beamformee MAC # (00:e0:4c:88:22:ce, programmed at arm time) only when armed. # # Usage: sudo tests/bf_selfsound_jaguar3.sh (c812 | a81a) @@ -70,9 +70,9 @@ run_cell() { # $1=name $2=arm(0|1) kill "$bfee_pid" "$sniff_pid" 2>/dev/null wait "$bfee_pid" "$sniff_pid" 2>/dev/null local n - n=$(grep -c "" "$OUT/$name.sniff.log") + n=$(grep -Fc '"ev":"bf.report"' "$OUT/$name.sniff.log") echo " bf-report lines: $n" - grep "" "$OUT/$name.sniff.log" | grep "$BFEE_MAC" | head -2 + grep -F '"ev":"bf.report"' "$OUT/$name.sniff.log" | grep "$BFEE_MAC" | head -2 } run_cell "${tag}_unarmed" 0 @@ -81,6 +81,6 @@ run_cell "${tag}_armed" 1 echo echo "== verdict ($BFEE_PID) ==" -echo "unarmed reports from bfee: $(grep '' "$OUT/${tag}_unarmed.sniff.log" | grep -c "$BFEE_MAC")" -echo "armed reports from bfee: $(grep '' "$OUT/${tag}_armed.sniff.log" | grep -c "$BFEE_MAC")" +echo "unarmed reports from bfee: $(grep -F '"ev":"bf.report"' "$OUT/${tag}_unarmed.sniff.log" | grep -c "$BFEE_MAC")" +echo "armed reports from bfee: $(grep -F '"ev":"bf.report"' "$OUT/${tag}_armed.sniff.log" | grep -c "$BFEE_MAC")" echo "(logs in $OUT)" diff --git a/tests/canary_diff.py b/tests/canary_diff.py index 7dcf1d6..29a5ccb 100755 --- a/tests/canary_diff.py +++ b/tests/canary_diff.py @@ -5,9 +5,14 @@ A clean way to compare: - `tools/canary_kernel_dump.sh [chip]` output (kernel side via iwpriv) - - `DEVOURER_DUMP_CANARY=1 ./build/rxdemo` block extracted - by `awk '/DEVOURER_DUMP_CANARY \\(post channel-set ch=N\\)/, - /END DEVOURER_DUMP_CANARY/' | sed 's/^//'` + - `DEVOURER_DUMP_CANARY=1 ./build/rxdemo` block. The dump is a + human diagnostic and goes to **stderr** as `devourer [I] `-prefixed + lines (stdout carries the JSONL event stream), so capture with + `2> rx.err` (or `2>&1` past the demo) and extract with + `awk '/DEVOURER_DUMP_CANARY \\(post channel-set ch=N\\)/, + /END DEVOURER_DUMP_CANARY/' | sed -E 's/^devourer \\[[A-Z]\\] //'` + — though the parser below strips the prefix itself, so raw + stderr files work unstripped too. Both files contain `KIND 0xADDR = 0xVALUE` lines in the same order (the kernel script mirrors devourer's emit order exactly per @@ -176,6 +181,10 @@ r"^(BB|MAC|RF\[[AB]\])\s+0x([0-9a-fA-F]+)\s*=\s*0x([0-9a-fA-F]+)\s*$" ) +# Devourer human-diagnostic prefix on stderr: `devourer [I] ` (level letter +# T/D/I/W/E). Stripped liberally so an unfiltered stderr capture parses. +DIAG_PREFIX_RE = re.compile(r"^devourer \[[A-Z]\] ") + @dataclass(frozen=True) class Reading: @@ -187,10 +196,13 @@ class Reading: def parse_canary(path: Path) -> dict[tuple[str, int], int]: """Parse a canary file into {(kind, addr): value}. Skips lines outside the `=== DEVOURER_DUMP_CANARY ===` envelope (header, - log noise, etc.).""" + log noise, etc.). Devourer-side lines carry the stderr + `devourer [I] ` diagnostic prefix — stripped before matching, so + both pre-stripped and raw stderr captures parse.""" readings: dict[tuple[str, int], int] = {} in_block = False for raw in path.read_text().splitlines(): + raw = DIAG_PREFIX_RE.sub("", raw.strip()) if "DEVOURER_DUMP_CANARY (post channel-set" in raw: in_block = True continue diff --git a/tests/cold_first_init_8812au.sh b/tests/cold_first_init_8812au.sh index bf83a2c..3da5586 100644 --- a/tests/cold_first_init_8812au.sh +++ b/tests/cold_first_init_8812au.sh @@ -119,7 +119,7 @@ verify_flood() { # $1 = tag ; returns 0 if radiating DEVOURER_USB_BUS=9 DEVOURER_USB_PORT=1.3 \ "$RXDEMO" > "$vlog" 2>&1 local hits - hits=$(grep -o 'hits=[0-9]*' "$vlog" | tail -1 | cut -d= -f2) + hits=$(grep -o '"hits":[0-9]*' "$vlog" | tail -1 | cut -d: -f2) hits="${hits:-0}" log "flood verify ($1): hits=$hits (need >=$MIN_VERIFY_HITS)" [ "$hits" -ge "$MIN_VERIFY_HITS" ] @@ -196,9 +196,9 @@ for i in $(seq 1 "$REPS"); do mac_state=$(grep -o "MAC has already power on\|MAC has not been powered on yet" "$RLOG" | tail -1) eeprom=$(grep -o "EEPROM ID=0x[0-9A-Fa-f]*" "$RLOG" | tail -1) fwrdy=$(grep -o "Polling FW ready OK\|Polling FW ready Fail" "$RLOG" | tail -1) - pon=$(grep "init-timing: hal_init.power_on" "$RLOG" | tail -1 | grep -o '= [0-9]* ms' | tr -dc 0-9) - tot=$(grep "init-timing: hal_init.total" "$RLOG" | tail -1 | grep -o '= [0-9]* ms' | tr -dc 0-9) - hits=$(grep -o 'hits=[0-9]*' "$RLOG" | tail -1 | cut -d= -f2); hits="${hits:-0}" + pon=$(grep -o '"ev":"init.timing","stage":"hal_init.power_on","ms":[0-9]*' "$RLOG" | tail -1 | grep -o '[0-9]*$') + tot=$(grep -o '"ev":"init.timing","stage":"hal_init.total","ms":[0-9]*' "$RLOG" | tail -1 | grep -o '[0-9]*$') + hits=$(grep -o '"hits":[0-9]*' "$RLOG" | tail -1 | cut -d: -f2); hits="${hits:-0}" fail=$(grep -c "InitLLTTable8812A failed\|InitPowerOn: run power on flow fail\|rtw_hal_init: fail" "$RLOG") if [ "$fail" -gt 0 ]; then v="INIT_FAIL(power_on=${pon:-?}ms)" diff --git a/tests/compare_8814_decorrelation.sh b/tests/compare_8814_decorrelation.sh index d3c0cb8..8525de2 100755 --- a/tests/compare_8814_decorrelation.sh +++ b/tests/compare_8814_decorrelation.sh @@ -64,10 +64,10 @@ stdbuf -oL -eL env DEVOURER_PID="$TX_PID" DEVOURER_CHANNEL="$CHANNEL" "$TXDEMO" # ~10s to init and occasionally fails to claim on the first try, which would # otherwise leave every device's capture empty. for _ in $(seq 1 30); do - grep -q 'TX #.* rc=1' "$TXLOG" 2>/dev/null && break + grep -q '"ev":"tx.frame".*"rc":1' "$TXLOG" 2>/dev/null && break sleep 1 done -grep -q 'TX #.* rc=1' "$TXLOG" 2>/dev/null || { +grep -q '"ev":"tx.frame".*"rc":1' "$TXLOG" 2>/dev/null || { echo "TX beacon did not confirm an inject within 30s — check $TXLOG" >&2; exit 3; } echo "== TX beacon confirmed injecting ==" @@ -87,7 +87,7 @@ for dev in "${DEVS[@]}"; do kill "$rxpid" 2>/dev/null || true wait "$rxpid" 2>/dev/null || true - frames="$(grep -c '' "$cap" || true)" + frames="$(grep -cF '"ev":"rx.path"' "$cap" || true)" echo "captured ${frames:-0} canonical-SA frames" if [ "${frames:-0}" -gt 0 ]; then "$PY" "$HERE/antenna_decorrelation.py" --metric "$METRIC" "$cap" diff --git a/tests/devourer_events.py b/tests/devourer_events.py new file mode 100644 index 0000000..780fb2e --- /dev/null +++ b/tests/devourer_events.py @@ -0,0 +1,55 @@ +"""devourer_events — shared parser for devourer's JSONL event stream. + +The demos emit machine events as JSON Lines on stdout (docs/logging.md): +one event per line, first field always the event name, serialized exactly as + + {"ev":"rx.txhit","hits":3,"total_rx":10,"len":247} + +Diagnostics are plain text on stderr and never look like events. Any consumer +should tolerate non-JSON lines interleaved (libusb chatter redirected, etc.), +which is what iter_events() does. + +Usage: + from devourer_events import iter_events, parse_event + + for ev in iter_events(proc.stdout, ev="rx.txhit"): + hits = ev["hits"] +""" + +import json + +EVENT_PREFIX = '{"ev":"' + + +def parse_event(line, ev=None): + """Parse one line; return the event dict, or None if the line is not an + event (or not the requested event name). Accepts str or bytes.""" + if isinstance(line, (bytes, bytearray)): + try: + line = line.decode("utf-8", "replace") + except Exception: + return None + line = line.strip() + if not line.startswith(EVENT_PREFIX): + return None + if ev is not None and not line.startswith(EVENT_PREFIX + ev + '"'): + return None + try: + obj = json.loads(line) + except ValueError: + return None + if not isinstance(obj, dict) or "ev" not in obj: + return None + if ev is not None and obj["ev"] != ev: + return None + return obj + + +def iter_events(lines, ev=None): + """Yield event dicts from an iterable of lines (file object, list, or a + whole-text .splitlines()). Non-event lines are skipped. `ev` filters to + one event name.""" + for line in lines: + obj = parse_event(line, ev) + if obj is not None: + yield obj diff --git a/tests/dis_cca_onair.sh b/tests/dis_cca_onair.sh index 0bea6ba..b3fda07 100755 --- a/tests/dis_cca_onair.sh +++ b/tests/dis_cca_onair.sh @@ -10,8 +10,8 @@ # # The test: ONE 8822EU DUT, ONE swept B210 AWGN interferer, ONE marginal beacon. # A/B is DEVOURER_DIS_CCA off vs on at each noise gain. delivery = the final -# hits (canonical-SA beacons decoded); we also log median IGI + -# OFDM false-alarm from . +# rx.txhit event's hits (canonical-SA beacons decoded); we also log median IGI + +# OFDM false-alarm from the rx.energy events. # # MEASURED RESULT (8822EU, 5-point AWGN sweep 46..76 dB): NULL. The MAC # EDCCA-disable (DEVOURER_DIS_CCA=1) leaves delivery within 1% at every noise @@ -82,31 +82,34 @@ run_cell() { # $1=gain $2=tag $3=discca(0|1) sudo -n pkill -f sdr_interferer 2>/dev/null sleep 3 python3 - "$OUT/$tag.log" <<'PYEOF' -import re, statistics, sys -log = open(sys.argv[1], errors="replace").read() +import json, statistics, sys hits = 0 -for m in re.finditer(r".*hits=(\d+)", log): - hits = max(hits, int(m.group(1))) igis, fas = [], [] -for m in re.finditer(r".*", log): - s = m.group(0) - gi = re.search(r"\bigi=(-?\d+)", s) - fo = re.search(r"\bfa_ofdm=(-?\d+|-)", s) - if gi and gi.group(1) != "-": - igis.append(int(gi.group(1))) - if fo and fo.group(1) not in ("-",): - fas.append(int(fo.group(1))) +for line in open(sys.argv[1], errors="replace"): + try: + ev = json.loads(line) + except ValueError: + continue + name = ev.get("ev") + if name == "rx.txhit": + hits = max(hits, int(ev.get("hits", 0))) + elif name == "rx.energy": + if ev.get("igi") is not None: + igis.append(int(ev["igi"])) + if ev.get("fa_ofdm") is not None: + fas.append(int(ev["fa_ofdm"])) im = int(statistics.median(igis)) if igis else -1 fm = int(statistics.median(fas)) if fas else -1 print(f"{hits} {im} {fm}") PYEOF } -# Confirm the knob actually lands its registers once (no interferer) before the sweep. +# Confirm the knob actually lands its registers once (no interferer) before the +# sweep. The "MAC EDCCA DISABLED" line is a human diagnostic on stderr. echo "== register-landing check (no interferer) ==" sudo -n env DEVOURER_PID="$DUT_PID" DEVOURER_VID="$DUT_VID" DEVOURER_CHANNEL="$CH" \ - DEVOURER_DIS_CCA=1 timeout 6 "$ROOT/build/rxdemo" 2>&1 \ - | grep -m1 "CCA/EDCCA" || echo " WARN: no CCA/EDCCA log line seen" + DEVOURER_DIS_CCA=1 timeout 6 "$ROOT/build/rxdemo" 2>&1 >/dev/null \ + | grep -m1 "MAC EDCCA" || echo " WARN: no MAC EDCCA diagnostic seen" printf "%-8s | %-22s | %-22s\n" "gain_dB" "dis_cca=0 (hits/igi/fa)" "dis_cca=1 (hits/igi/fa)" printf -- "---------+------------------------+------------------------\n" diff --git a/tests/eu_rx_validate.sh b/tests/eu_rx_validate.sh index 04322ec..ad8dc86 100755 --- a/tests/eu_rx_validate.sh +++ b/tests/eu_rx_validate.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Two-adapter RX validation for the RTL8812EU (0bda:a81a): the RTL8812AU # (Jaguar1, 9-2) floods canonical-SA beacons (57:42:75:05:d6:00) on a channel -# while the EU runs the RX demo. Success = the EU prints +# while the EU runs the RX demo. Success = the EU emits "ev":"rx.txhit" # (it decoded the injected SA), proving the 8822e RX datapath delivers frames. # # sudo tests/eu_rx_validate.sh [channel] [seconds] @@ -48,10 +48,10 @@ sleep 6 # let TX come up before RX starts listening echo "=== running RX DUT ($RXPID) on ch$CH ===" sudo env DEVOURER_VID=0x0bda DEVOURER_PID=0x$RXPID DEVOURER_CHANNEL=$CH \ stdbuf -oL -eL timeout -k 5 $((SECS-7)) "${RXBIN:-build/rxdemo}" 2>&1 \ - | tee /tmp/eu_rxval_rx.log | grep -iE "IQK done|RX pkt|tx-hit|entering RX" | sed 's/^/[rx] /' + | tee /tmp/eu_rxval_rx.log | grep -E 'IQK done|"ev":"rx\.(pkt|txhit)"|entering RX' | sed 's/^/[rx] /' echo "==================== RESULT ====================" -echo "EU RX pkt lines : $(grep -c 'RX pkt' /tmp/eu_rxval_rx.log)" -echo "EU canonical hit: $(grep -c 'tx-hit' /tmp/eu_rxval_rx.log)" +echo "EU RX pkt lines : $(grep -Fc '"ev":"rx.pkt"' /tmp/eu_rxval_rx.log)" +echo "EU canonical hit: $(grep -Fc '"ev":"rx.txhit"' /tmp/eu_rxval_rx.log)" echo "TX bulk sends : $(grep -c 'bulk_send' /tmp/eu_rxval_tx.log)" -grep -m1 "tx-hit" /tmp/eu_rxval_rx.log && echo ">>> EU RX DATAPATH CONFIRMED <<<" || echo ">>> no canonical-SA hit (see /tmp/eu_rxval_{tx,rx}.log) <<<" +grep -m1 -F '"ev":"rx.txhit"' /tmp/eu_rxval_rx.log && echo ">>> EU RX DATAPATH CONFIRMED <<<" || echo ">>> no canonical-SA hit (see /tmp/eu_rxval_{tx,rx}.log) <<<" diff --git a/tests/fused_fec_headtohead.sh b/tests/fused_fec_headtohead.sh index 18a7c27..ccca0b1 100644 --- a/tests/fused_fec_headtohead.sh +++ b/tests/fused_fec_headtohead.sh @@ -3,7 +3,7 @@ # # One devourer 8812 transmits fused-FEC bodies (HT MCS7, marginal power) once; # two receivers decode that SAME radiation simultaneously: -# * chip RX — devourer 8821 (DEVOURER_RX_KEEP_CORRUPTED), live +# * chip RX — devourer 8821 (DEVOURER_RX_KEEP_CORRUPTED), live rx.frame events # * SDR RX — USRP B210, IQ recorded to disk (no live-decode overflow), then # replayed offline through the gr-ieee802-11 fork HARD and SOFT. # Both feed the identical FusedFecReceiver (same FEC params) so "RS blocks / @@ -73,7 +73,7 @@ $PY "$FEC/fused_fec_tx.py" --input /tmp/h2h_src.bin --repeat 200 $FEC_ARGS \ > "$BODIES" 2>/dev/null echo "[h2h] TX bodies: $(wc -c <"$BODIES") bytes rate=$TX_RATE pwr=$TX_PWR_OVERRIDE" -# --- chip RX (8821): capture for the whole window --- +# --- chip RX (8821): capture the rx.frame events (stdout) for the whole window --- : > "$RAW" sudo env DEVOURER_VID=$RX_VID DEVOURER_PID=$RX_PID DEVOURER_CHANNEL=$CH \ DEVOURER_STREAM_OUT=1 DEVOURER_RX_KEEP_CORRUPTED=1 \ @@ -103,18 +103,18 @@ sleep 1 # ===================== offline analysis (same FEC params) ===================== echo echo "=== CHIP RX (8821) ===" -grep "" "$RAW" | $PY "$FEC/fused_fec_rx.py" $FEC_ARGS \ +grep -F '"ev":"rx.frame"' "$RAW" | $PY "$FEC/fused_fec_rx.py" $FEC_ARGS \ >/dev/null 2>/tmp/h2h_chip.out grep "fused_fec_rx" /tmp/h2h_chip.out || echo " (no chip frames — check rate/power/unbind)" # chip per-frame SNR split by FCS pass/fail $PY - "$RAW" <<'PYEOF' -import re,sys,statistics +import json,sys,statistics clean=[];corr=[] -rx=re.compile(r".*?crc_err=(\d+).*?snr=(-?\d+),") for line in open(sys.argv[1],errors="ignore"): - m=rx.search(line) - if not m: continue - (corr if int(m.group(1)) else clean).append(int(m.group(2))) + if not line.startswith('{"ev":"rx.frame"'): continue + try: ev=json.loads(line) + except ValueError: continue + (corr if ev.get("crc") else clean).append(int(ev["snr"][0])) def med(x): return f"{statistics.median(x):.0f}" if x else "-" print(f" chip SNR(dB) median: clean={med(clean)} (n={len(clean)}) " f"corrupt={med(corr)} (n={len(corr)})") diff --git a/tests/fused_fec_onair.sh b/tests/fused_fec_onair.sh index 522d251..ac75c50 100644 --- a/tests/fused_fec_onair.sh +++ b/tests/fused_fec_onair.sh @@ -109,6 +109,6 @@ wait "$RXBG" 2>/dev/null # Offline analysis: same FEC params, baseline vs SBI. echo "=== fused-FEC result (offline analysis of the on-air capture) ===" -grep "" "$RAW" | $PY "$FEC/fused_fec_rx.py" $FEC_ARGS >/dev/null 2>/tmp/fused_gain.log +grep -F '"ev":"rx.frame"' "$RAW" | $PY "$FEC/fused_fec_rx.py" $FEC_ARGS >/dev/null 2>/tmp/fused_gain.log grep "fused_fec_rx" /tmp/fused_gain.log || echo " no frames captured — check TX rate/power and that both chips are unbound" echo "=== raw capture at $RAW (re-analyse with other FEC params if desired) ===" diff --git a/tests/hop_parity_check.sh b/tests/hop_parity_check.sh index 034936f..ddfaf65 100755 --- a/tests/hop_parity_check.sh +++ b/tests/hop_parity_check.sh @@ -52,7 +52,9 @@ cmake --build "$ROOT/build" -j --target txdemo >/dev/null # Run the demo to: InitWrite(INIT@BW) [full], then hop to TARGET@BW via the # selected path. One full round of [INIT,TARGET] reaches TARGET last; the final -# canary block is TARGET's. DUMP_CANARY logs to the demo's stdout/err. +# canary block is TARGET's. DUMP_CANARY is a stderr diagnostic ("devourer [I] " +# prefixed "KIND 0xADDR = 0xVALUE" lines between the === markers) — captured +# below with 2>&1; the extraction grep is prefix-agnostic. run_path() { # $1 = HOP_FAST value, $2 = output file sudo -n env DEVOURER_PID="$TX_PID" DEVOURER_CHANNEL="$INIT" \ ${TX_VID:+DEVOURER_VID="$TX_VID"} \ diff --git a/tests/inject_beacon.py b/tests/inject_beacon.py index b8d1c56..6b5acb2 100755 --- a/tests/inject_beacon.py +++ b/tests/inject_beacon.py @@ -3,8 +3,8 @@ monitor interface. The frame mirrors examples/tx/main.cpp's hardcoded beacon: a probe request with -SA = 57:42:75:05:d6:00. rxdemo and txdemo both grep for -this SA on RX (the `` matcher) so the same beacon works +SA = 57:42:75:05:d6:00. rxdemo and txdemo both match +this SA on RX (the `rx.txhit` event matcher) so the same beacon works as the TX source whether the RX side is devourer or tcpdump. Run from tests/regress.py's kernel-TX cell: @@ -22,7 +22,7 @@ from scapy.all import RadioTap, Dot11, Raw, sendp # Source MAC matches the canonical beacon SA in examples/tx/main.cpp and the -# `` matcher in examples/rx/main.cpp. Don't change without +# `rx.txhit` event matcher in examples/rx/main.cpp. Don't change without # updating both sides. CANONICAL_SA = "57:42:75:05:d6:00" diff --git a/tests/j3_dig_penalty_sweep.sh b/tests/j3_dig_penalty_sweep.sh index 2e41afc..63d1cf5 100755 --- a/tests/j3_dig_penalty_sweep.sh +++ b/tests/j3_dig_penalty_sweep.sh @@ -9,9 +9,9 @@ # Same marginal TX beacon (8812AU, TX power dialled low via the runtime knob so # the noise sweep actually reaches a delivery cliff), same noise ladder. # -# Per (chip, noise-gain) cell: delivery = the final hits=N +# Per (chip, noise-gain) cell: delivery = the final "ev":"rx.txhit" "hits":N # (canonical-SA beacons the RX decoded), plus the median IGI + false-alarm rate -# from (DEVOURER_RX_ENERGY_MS). The story is in two numbers: +# from "ev":"rx.energy" (DEVOURER_RX_ENERGY_MS). The story is in two numbers: # - does the J3's IGI stay pinned while its FA climbs (confirming no DIG)? # - is the J3's 50%-delivery noise-gain WORSE than the J2's (the penalty DIG # would recover)? @@ -79,20 +79,22 @@ run_cell() { # $1=rx_pid $2=rx_vid $3=gain $4=tag sudo -n pkill -f sdr_interferer 2>/dev/null sleep 3 python3 - "$OUT/$tag.log" <<'PYEOF' -import re, statistics, sys -log = open(sys.argv[1], errors="replace").read() +import json, statistics, sys hits = 0 -for m in re.finditer(r".*hits=(\d+)", log): - hits = max(hits, int(m.group(1))) igis, fas = [], [] -for m in re.finditer(r".*", log): - s = m.group(0) - gi = re.search(r"\bigi=(-?\d+)", s) - fo = re.search(r"\bfa_ofdm=(-?\d+|-)", s) - if gi and gi.group(1) != "-": - igis.append(int(gi.group(1))) - if fo and fo.group(1) not in ("-",): - fas.append(int(fo.group(1))) +for line in open(sys.argv[1], errors="replace"): + try: + ev = json.loads(line) + except ValueError: + continue + name = ev.get("ev") + if name == "rx.txhit": + hits = max(hits, int(ev.get("hits", 0))) + elif name == "rx.energy": + if ev.get("igi") is not None: + igis.append(int(ev["igi"])) + if ev.get("fa_ofdm") is not None: + fas.append(int(ev["fa_ofdm"])) im = int(statistics.median(igis)) if igis else -1 fm = int(statistics.median(fas)) if fas else -1 # IGI spread: does it move at all across the cell? (max-min) diff --git a/tests/jaguar2_8821c_bringup_smoke.sh b/tests/jaguar2_8821c_bringup_smoke.sh index e489324..13fcd5d 100755 --- a/tests/jaguar2_8821c_bringup_smoke.sh +++ b/tests/jaguar2_8821c_bringup_smoke.sh @@ -36,7 +36,7 @@ echo "--- bring-up progress ---" grep -iE "Creating RtlJaguar2Device|power-on|chip.version|cut=|rf_2t2r|firmware booted|DLFW|MAC cfg|PHY tables|channel set|IQK|RX enabled|entering RX loop|RX: completion|error|throw|fail" "$LOG" | head -30 DEV=$(grep -c "Creating RtlJaguar2Device C8821C" "$LOG") -STREAM=$(grep -c "" "$LOG") +STREAM=$(grep -cF '"ev":"rx.frame"' "$LOG") COMPL=$(grep -c "RX: completion" "$LOG") echo "--- counts: C8821C-construct=$DEV stream=$STREAM rx-completions=$COMPL ---" if [ "$DEV" -ge 1 ]; then diff --git a/tests/jaguar2_8821c_bw_txhit.sh b/tests/jaguar2_8821c_bw_txhit.sh index 83f22d2..2e09a8d 100755 --- a/tests/jaguar2_8821c_bw_txhit.sh +++ b/tests/jaguar2_8821c_bw_txhit.sh @@ -2,8 +2,8 @@ # Wide-bandwidth (HT40 / VHT80) tx-hit validation for the RTL8821C (CF-811AC). # Exercises set_channel_bw_8821c() at bw=40/80 in BOTH directions, which is where # the 0x8ac ADC/DAC clock word is programmed: -# rx: T3U (8822B) TX an HT40/VHT80 frame; 8821C RX at the same bandwidth. A -# proves the 8821C wide RX (0x8ac clock) decodes on-air. +# rx: T3U (8822B) TX an HT40/VHT80 frame; 8821C RX at the same bandwidth. An +# rx.txhit event proves the 8821C wide RX (0x8ac clock) decodes on-air. # tx: 8821C TX an HT40/VHT80 frame; T2U-Plus (8821AU) RX. A tx-hit proves the # 8821C wide TX tune works on-air. # Uses controlled traffic (no reliable ambient wide 2.4G at this bench). Restores @@ -64,7 +64,7 @@ fi echo "--- 8821C side bring-up ---" grep -iE "channel set|bw=|error|throw|fail" "$([ "$DIR" = rx ] && echo "$RXLOG" || echo "$TXLOG")" | head -4 -HITS=$(grep -c "" "$RXLOG") +HITS=$(grep -cF '"ev":"rx.txhit"' "$RXLOG") echo "--- tx-hits: $HITS (dir=$DIR bw=$BW) ---" if [ "$HITS" -ge 1 ]; then echo "PASS: 8821C wide $BW MHz $DIR decoded on-air (rx-log $RXLOG)" diff --git a/tests/jaguar2_8821c_rx_txhit.sh b/tests/jaguar2_8821c_rx_txhit.sh index 0a078c5..659eb6d 100755 --- a/tests/jaguar2_8821c_rx_txhit.sh +++ b/tests/jaguar2_8821c_rx_txhit.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # RX validation: confirm the RTL8821C (CF-811AC) decodes a KNOWN over-the-air # frame. Inject the canonical txdemo beacon (SA 57:42:75:05:d6:00) from the T3U -# (RTL8822B, known-good TX) and RX on the 8821C; a proves the +# (RTL8822B, known-good TX) and RX on the 8821C; an rx.txhit event proves the # 8821C RX decoded it end-to-end. Uses controlled traffic because this bench has # no reliable ambient 2.4G. Restores kernel drivers on exit. # @@ -45,8 +45,8 @@ DEVOURER_VID=$RX_VID DEVOURER_PID=$RX_PID DEVOURER_CHANNEL=$CH \ echo "--- TX side ---"; grep -iE "ready for TX|Creating|error" /tmp/8821c-tx.log | head -3 echo "--- RX result ---" -HITS=$(grep -c "" "$RXLOG") -grep -E "" "$RXLOG" | head -3 +HITS=$(grep -cF '"ev":"rx.txhit"' "$RXLOG") +grep -F '"ev":"rx.txhit"' "$RXLOG" | head -3 echo "$(grep -oE "RX loop exited \([0-9]+ frames, [0-9]+ reads" "$RXLOG" | head -1)" echo "--- tx-hits: $HITS ---" if [ "$HITS" -ge 1 ]; then diff --git a/tests/jaguar2_8821c_tx_txhit.sh b/tests/jaguar2_8821c_tx_txhit.sh index 14d77e7..8369393 100755 --- a/tests/jaguar2_8821c_tx_txhit.sh +++ b/tests/jaguar2_8821c_tx_txhit.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # TX validation (functional): does the RTL8821C (CF-811AC) TRANSMIT on air? # Inject the canonical txdemo beacon FROM the 8821C and receive on the known-good -# Jaguar-1 8821AU (2357:0120); each on the RX side proves an +# Jaguar-1 8821AU (2357:0120); each rx.txhit event on the RX side proves an # 8821C-transmitted frame flew over the air. Restores kernel drivers on exit. # # Usage: sudo tests/jaguar2_8821c_tx_txhit.sh [channel] [seconds] @@ -45,8 +45,8 @@ DEVOURER_VID=$RX_VID DEVOURER_PID=$RX_PID DEVOURER_CHANNEL=$CH \ echo "--- TX side (8821C) ---" grep -iE "ready for TX|Creating|LCK|channel set|error|throw" /tmp/8821c-txside.log | head -5 echo "--- RX side (8821AU) ---" -HITS=$(grep -c "" "$RXLOG") -grep -E "" "$RXLOG" | head -3 +HITS=$(grep -cF '"ev":"rx.txhit"' "$RXLOG") +grep -F '"ev":"rx.txhit"' "$RXLOG" | head -3 echo "$(grep -oE "RX loop exited \([0-9]+ frames, [0-9]+ reads" "$RXLOG" | head -1)" echo "--- 8821C-TX tx-hits: $HITS ---" if [ "$HITS" -ge 1 ]; then diff --git a/tests/jaguar2_8822b_rx_smoke.sh b/tests/jaguar2_8822b_rx_smoke.sh index 624c0e6..a162c3d 100755 --- a/tests/jaguar2_8822b_rx_smoke.sh +++ b/tests/jaguar2_8822b_rx_smoke.sh @@ -29,8 +29,8 @@ echo "--- key log lines ---" grep -iE "Creating RtlJaguar2Device|firmware booted|PHY tables applied|entering RX loop|RX: completion" "$LOG" | head -12 DEV=$(grep -c "Creating RtlJaguar2Device" "$LOG") -STREAM=$(grep -c "" "$LOG") -BODY=$(grep -c "" "$LOG") +STREAM=$(grep -cF '"ev":"rx.frame"' "$LOG") +BODY=$(grep -cF '"ev":"rx.body"' "$LOG") COMPL=$(grep -c "RX: completion" "$LOG") echo "--- counts: RtlJaguar2Device=$DEV stream=$STREAM body=$BODY rx-completions=$COMPL ---" diff --git a/tests/jaguar3_tx_sniff.sh b/tests/jaguar3_tx_sniff.sh index 279763a..dfb08a9 100755 --- a/tests/jaguar3_tx_sniff.sh +++ b/tests/jaguar3_tx_sniff.sh @@ -4,8 +4,8 @@ # DUT: RTL8812CU (0bda:c812, 9-1.3) runs devourer's Jaguar-3 TX demo, # injecting the canonical beacon SA 57:42:75:05:d6:00 on ch36. # Witness: RTL8812AU (0bda:8812, 9-2) runs devourer's Jaguar-1 RX demo in -# monitor on ch36 — examples/rx/main.cpp already prints -# when it decodes that SA. Dogfoods devourer on both ends and avoids +# monitor on ch36 — examples/rx/main.cpp already emits "ev":"rx.txhit" +# events when it decodes that SA. Dogfoods devourer on both ends and avoids # the RTL8814AU host-capture path entirely. # Bonus: USRP B210 in-band power at 5180 MHz. # @@ -49,11 +49,11 @@ sleep 1 cleanup echo "=== verdict ===" -HITS=$(grep -c "devourer-tx-hit" "$SNIFF_LOG" 2>/dev/null || echo 0) -echo "DUT TX submit lines: $(grep -cE 'bulk_send EP|TX #' "$DUT_LOG")" -echo "sniffer total RX frames: $(grep -cE 'devourer.*hit|RX frame|' "$SNIFF_LOG")" -echo " count: $HITS" -grep "devourer-tx-hit" "$SNIFF_LOG" | head -3 +HITS=$(grep -Fc '"ev":"rx.txhit"' "$SNIFF_LOG" 2>/dev/null || echo 0) +echo "DUT TX submit lines: $(grep -cE 'bulk_send EP|"ev":"tx\.frame"' "$DUT_LOG")" +echo "sniffer total RX frames: $(grep -Fc '"ev":"rx.pkt"' "$SNIFF_LOG")" +echo "rx.txhit event count: $HITS" +grep -F '"ev":"rx.txhit"' "$SNIFF_LOG" | head -3 if [ "$HITS" -gt 0 ]; then echo "ON-AIR TX CONFIRMED BY DECODE (canonical SA received over the air)" else diff --git a/tests/link_health_onair.sh b/tests/link_health_onair.sh index 1e1279b..1ae8e2b 100755 --- a/tests/link_health_onair.sh +++ b/tests/link_health_onair.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Hardware validation for the link-health classifier (). +# Hardware validation for the link-health classifier (link.health events). # The classifier's core job is the near-field story from the brainstorm: tell # "strong but self-jamming, BACK OFF power" apart from a clean link. That is # the A/B this validates on-air, at a fixed short bench distance: @@ -49,7 +49,7 @@ echo "== building ==" cmake --build "$ROOT/build" -j --target rxdemo txdemo >/dev/null || exit 1 # Run a ground-RX window against a beacon at TX index $1 (+ optional interferer -# gain in $2); echo the MODAL verdict over the window. +# gain in $2); echo the MODAL link.health verdict over the window. run_regime() { # $1=tx_idx $2=jam_gain("" = none) $3=tag local idx="$1" jam="$2" tag="$3" jampid="" if [ -n "$jam" ]; then @@ -74,8 +74,9 @@ run_regime() { # $1=tx_idx $2=jam_gain("" = none) $3=tag sleep 2 # Modal verdict across the window's linkhealth lines (skip the first, it can # land during TX bring-up before frames arrive). - grep -oE "verdict=[A-Z_]+" "$OUT/$tag.log" \ - | sed 's/.*verdict=//' | tail -n +2 | sort | uniq -c | sort -rn | head -1 \ + grep -F '"ev":"link.health"' "$OUT/$tag.log" \ + | grep -o '"verdict":"[A-Z_]*"' | cut -d'"' -f4 \ + | tail -n +2 | sort | uniq -c | sort -rn | head -1 \ | awk '{print $2" ("$1" windows)"}' } diff --git a/tests/link_health_selftest.cpp b/tests/link_health_selftest.cpp index 491a847..6787315 100644 --- a/tests/link_health_selftest.cpp +++ b/tests/link_health_selftest.cpp @@ -1,5 +1,5 @@ /* Headless guard for the link-health classifier (src/LinkHealth.h) — the - * sensor-tuple -> verdict mapping behind . The cases are + * sensor-tuple -> verdict mapping behind the link.health event. The cases are * drawn from real on-air data: the saturation-knee sweep * (tests/saturation_knee_sweep.sh: clean EVM ~-56 at rssi 57, collapsed EVM * ~-27 at rssi 72) and the AWGN interference sweep (tests/j3_dig_penalty_sweep: diff --git a/tests/link_probe.py b/tests/link_probe.py index d07873c..9d8a092 100755 --- a/tests/link_probe.py +++ b/tests/link_probe.py @@ -4,9 +4,9 @@ point. The emitter (txdemo with DEVOURER_CONT_TX + a DEVOURER_TX_PWR ramp) -prints one `index=N` marker per step; the ground station -(rxdemo with DEVOURER_RX_ENERGY_MS) prints `` (per-frame -SNR aggregate) and `` (frame-free power histogram) lines. Both are +emits one `txpwr.set` event (index=N) per step; the ground station +(rxdemo with DEVOURER_RX_ENERGY_MS) emits `rx.energy` (per-frame +SNR aggregate) and `rx.nhm` (frame-free power histogram) events. Both are captured with a host-side arrival timestamp (prefixed ` `), so this aligns them by wall-clock — no cross-process clock sync — binning each ground sample into the emitter step window it falls in. @@ -16,7 +16,7 @@ --target-snr (the energy-min reflex: least power that holds the margin). The same analyzer serves the MCS-headroom axis: when the emitter steps its rate -(DEVOURER_TX_MCS_SWEEP) it prints mcs= markers instead, and +(DEVOURER_TX_MCS_SWEEP) it emits `tx.contx` events (mcs=) instead, and this reports per-MCS ground SNR + frame delivery and picks the *highest* rate that still clears the floor (the highest modulation the link holds). The axis is auto- detected from whichever marker the emitter log carries. @@ -30,13 +30,9 @@ import statistics import sys +from devourer_events import parse_event + TS = re.compile(r"^(\d+\.\d+)\s+(.*)$") -STEP_PWR = re.compile(r"index=(\d+)") -STEP_MCS = re.compile(r"mcs=(\S+)") -ENERGY = re.compile(r"(.*)") -NHM = re.compile(r".*peak=(\d+)") -THERMAL = re.compile(r"raw=(\d+)") -KV = re.compile(r"(\w+)=(-?\d+)") def read_stamped(path): @@ -67,14 +63,15 @@ def main() -> int: steps = [] # [(t_start, label), ...] axis = None for t, text in emit: - mp = STEP_PWR.search(text) - mm = STEP_MCS.search(text) - if mp: + ev = parse_event(text) + if ev is None: + continue + if ev["ev"] == "txpwr.set": axis = axis or "power" - steps.append((t, mp.group(1))) - elif mm: + steps.append((t, str(ev["index"]))) + elif ev["ev"] == "tx.contx": axis = axis or "mcs" - steps.append((t, mm.group(1))) + steps.append((t, str(ev["mcs"]))) if not steps: print("no step markers in emitter log — set DEVOURER_TX_PWR_START/STOP/STEP " "(power axis) or DEVOURER_TX_MCS_SWEEP (MCS axis)", file=sys.stderr) @@ -84,14 +81,15 @@ def main() -> int: samples = [] last_nhm = None for t, text in ground: - mn = NHM.search(text) - if mn: - last_nhm = int(mn.group(1)) + ev = parse_event(text) + if ev is None: + continue + if ev["ev"] == "rx.nhm": + last_nhm = ev.get("peak") continue - me = ENERGY.search(text) - if me: - kv = dict((k, int(v)) for k, v in KV.findall(me.group(1))) - samples.append((t, kv.get("snr_mean"), kv.get("frames", 0), last_nhm)) + if ev["ev"] == "rx.energy": + samples.append((t, ev.get("snr_mean"), + ev.get("frames") or 0, last_nhm)) # Assign each ground sample to the step window it falls in, accumulating by # label (the sweep may cycle through the levels more than once). @@ -136,8 +134,8 @@ def main() -> int: # (DEVOURER_THERMAL_POLL_MS on the emitter). Continuous stepping at full duty # is the worst-case heat; this reports how far the PA drifted, bounding the # power/duty a controller may sustain (the drone's local safety override). - raws = [int(m.group(1)) for _, text in emit - for m in [THERMAL.search(text)] if m] + raws = [int(ev["raw"]) for _, text in emit + for ev in [parse_event(text, "thermal")] if ev] if raws: print(f"\n# PA thermal (emitter raw meter): {raws[0]} -> {raws[-1]} " f"units over the sweep (range {min(raws)}..{max(raws)}, " diff --git a/tests/log_event_selftest.cpp b/tests/log_event_selftest.cpp new file mode 100644 index 0000000..b5264d6 --- /dev/null +++ b/tests/log_event_selftest.cpp @@ -0,0 +1,163 @@ +/* Headless guard for the JSONL event emitter (src/Event.h) — the machine + * event stream every test script parses (docs/logging.md). Checks the + * '{"ev":"name",' first-field serialization guarantee, JSON string escaping, + * number/bool/null/hex/array field forms, the inline->scratch spill path, + * and the 64 KiB truncation cap. A regression here silently corrupts every + * consumer, so it fails `ctest` instead. */ +#include +#include +#include + +#include "Event.h" + +using devourer::Ev; +using devourer::EventSink; + +static int g_fail = 0; + +/* Capture one emission through a tmpfile-backed sink. */ +struct Capture { + std::FILE *f; + EventSink sink; + Capture() : f(std::tmpfile()) { sink.configure(f); } + ~Capture() { std::fclose(f); } + std::string text() { + std::fflush(f); + long n = std::ftell(f); + std::rewind(f); + std::string s((size_t)n, '\0'); + if (std::fread(s.data(), 1, s.size(), f) != s.size()) + s.clear(); + return s; + } +}; + +static void expect_eq(const char *what, const std::string &got, + const std::string &want) { + if (got == want) + return; + ++g_fail; + std::printf("FAIL: %s\n got: %s\n want: %s\n", what, got.c_str(), + want.c_str()); +} + +static void expect_true(const char *what, bool ok) { + if (ok) + return; + ++g_fail; + std::printf("FAIL: %s\n", what); +} + +int main() { + /* First-field guarantee: exact '{"ev":"name"' prefix, one line, LF end. */ + { + Capture c; + Ev(c.sink, "rx.txhit").f("hits", 3).f("total_rx", 10); + expect_eq("basic event", c.text(), + "{\"ev\":\"rx.txhit\",\"hits\":3,\"total_rx\":10}\n"); + } + + /* No fields. */ + { + Capture c; + Ev(c.sink, "hop.done").emit(); + expect_eq("empty event", c.text(), "{\"ev\":\"hop.done\"}\n"); + } + + /* Numbers: negatives, unsigned 64-bit, double, NaN -> null, bool, null. */ + { + Capture c; + Ev(c.sink, "n") + .f("i", -42) + .f("u", 18446744073709551615ull) + .f("d", 2.5) + .f("nan", 0.0 / 0.0) + .f("b", true) + .f("z", nullptr); + expect_eq("numbers", c.text(), + "{\"ev\":\"n\",\"i\":-42,\"u\":18446744073709551615,\"d\":2.5," + "\"nan\":null,\"b\":true,\"z\":null}\n"); + } + + /* String escaping. */ + { + Capture c; + /* "\x01" split from "f" — otherwise C lexes the hex escape as \x1f */ + Ev(c.sink, "s").f("v", std::string_view("a\"b\\c\nd\te\x01" "f")); + expect_eq("escaping", c.text(), + "{\"ev\":\"s\",\"v\":\"a\\\"b\\\\c\\nd\\te\\u0001f\"}\n"); + } + + /* Hex register form + byte-blob hex + arrays. */ + { + Capture c; + const uint8_t blob[] = {0xde, 0xad, 0x0f}; + const int chains[] = {-52, -60, 0, 0}; + const double evm[] = {-28.5, -30.0}; + Ev(c.sink, "x") + .hexf("addr", 0x808, 4) + .hexf("val", 0x2a) + .hex("body", blob, sizeof(blob)) + .arr("rssi", chains, 4) + .arr("evm", evm, 2); + expect_eq("hex+arrays", c.text(), + "{\"ev\":\"x\",\"addr\":\"0x0808\",\"val\":\"0x2a\"," + "\"body\":\"dead0f\",\"rssi\":[-52,-60,0,0]," + "\"evm\":[-28.5,-30]}\n"); + } + + /* Spill path: a body larger than the inline buffer must still emit one + * well-formed line. */ + { + Capture c; + uint8_t big[3000]; + for (size_t i = 0; i < sizeof(big); i++) + big[i] = (uint8_t)i; + Ev(c.sink, "rx.frame").f("len", 3000).hex("body", big, sizeof(big)); + std::string out = c.text(); + expect_true("spill: single line", + out.size() > 6000 && out.back() == '\n' && + out.find('\n') == out.size() - 1); + expect_true("spill: prefix", + out.rfind("{\"ev\":\"rx.frame\",\"len\":3000,\"body\":\"", 0) == + 0); + expect_true("spill: closed", out.substr(out.size() - 3) == "\"}\n"); + /* Second event on the same thread reuses the scratch cleanly. */ + Ev(c.sink, "rx.frame").f("len", 1); + expect_true("scratch reuse", + c.text().find("{\"ev\":\"rx.frame\",\"len\":1}\n") != + std::string::npos); + } + + /* Truncation cap: an over-64KiB field is dropped (-> null) and the line + * carries "truncated":true, still valid JSON. */ + { + Capture c; + static uint8_t huge[70 * 1024]; + std::memset(huge, 0xab, sizeof(huge)); + Ev(c.sink, "big").f("pre", 1).hex("body", huge, sizeof(huge)).f("post", 2); + std::string out = c.text(); + expect_true("truncation: capped", out.size() < Ev::kMaxLine + 2); + expect_true("truncation: marker", + out.find("\"truncated\":true}") != std::string::npos); + expect_true("truncation: dropped field -> null", + out.find("\"body\":null") != std::string::npos); + expect_true("truncation: prefix survives", + out.rfind("{\"ev\":\"big\",\"pre\":1,", 0) == 0); + } + + /* Disabled sink emits nothing. */ + { + Capture c; + c.sink.disable(); + Ev(c.sink, "off").f("k", 1); + expect_eq("disabled sink", c.text(), ""); + } + + if (g_fail) { + std::printf("log_event_selftest: %d failure(s)\n", g_fail); + return 1; + } + std::printf("log_event_selftest: all checks passed\n"); + return 0; +} diff --git a/tests/mrc_mobility.py b/tests/mrc_mobility.py index ee7dff0..23c946d 100644 --- a/tests/mrc_mobility.py +++ b/tests/mrc_mobility.py @@ -10,15 +10,15 @@ It consumes a capture from `rxdemo` run with the RX-path *toggle*: DEVOURER_RX_PATHS=0x22:0xFF@300 # alternate chain-B-only and all-4 every 300ms - DEVOURER_RX_ALLPATHS=1 # emit per-chain lines + DEVOURER_RX_ALLPATHS=1 # emit per-chain rx.path events -The library prints a `mask` marker on every switch, inline with -the frame stream, so each frame is attributed to the mask active when it arrived: +The library emits an `rx.path_mask` event on every switch, inline with the +frame stream, so each frame is attributed to the mask active when it arrived: - 0x22 - seq=.. rssi=a,b,c,d snr=.. evm=.. + {"ev":"rx.path_mask","t":1234,"mask":"0x22"} + {"ev":"rx.path","seq":..,"rssi":[a,b,c,d],"snr":[...],"evm":[...]} ... - 0xff + {"ev":"rx.path_mask","t":1534,"mask":"0xff"} ... Because the toggle is fast relative to hand-motion, the two configs sample the @@ -46,13 +46,12 @@ from __future__ import annotations import argparse -import re import sys import numpy as np -_MARK = re.compile(r"(0x[0-9a-fA-F]+)") -_FRAME = re.compile(r"seq=\d+\s+rssi=([\-\d,]+)") +from devourer_events import parse_event + # How many frames to drop right after each switch. Toggling 0x808 causes a brief # RX/AGC transient that drops a few frames on the leading edge of a window; # dropping them de-biases the per-mask delivery. Tune with --settle if the @@ -68,16 +67,16 @@ def parse_windows(lines): cur_mask = None cur_rows = [] for ln in lines: - m = _MARK.search(ln) - if m: + ev = parse_event(ln) + if ev is None: + continue + if ev["ev"] == "rx.path_mask": if cur_mask is not None: windows.append((cur_mask, cur_rows)) - cur_mask = int(m.group(1), 16) + cur_mask = int(ev["mask"], 16) cur_rows = [] - continue - f = _FRAME.search(ln) - if f and cur_mask is not None: - cur_rows.append([int(x) for x in f.group(1).split(",")]) + elif ev["ev"] == "rx.path" and cur_mask is not None: + cur_rows.append([int(x) for x in ev["rssi"]]) if cur_mask is not None: windows.append((cur_mask, cur_rows)) return windows @@ -174,14 +173,16 @@ def self_test() -> int: lines = [] for w in range(40): # ALL window: steady ~40 frames, all chains ~70 - lines.append("0xff") + lines.append('{"ev":"rx.path_mask","t":0,"mask":"0xff"}') for _ in range(40): - lines.append("seq=1 rssi=70,71,69,72 snr=1,1,1,1 evm=0,0,0,0") + lines.append('{"ev":"rx.path","seq":1,"rssi":[70,71,69,72],' + '"snr":[1,1,1,1],"evm":[0,0,0,0]}') # B-only window: fades — half the windows deliver few frames - lines.append("0x22") + lines.append('{"ev":"rx.path_mask","t":0,"mask":"0x22"}') nb = 40 if rng.random() > 0.5 else int(rng.integers(0, 5)) for _ in range(nb): - lines.append("seq=1 rssi=0,70,0,0 snr=1,1,1,1 evm=0,0,0,0") + lines.append('{"ev":"rx.path","seq":1,"rssi":[0,70,0,0],' + '"snr":[1,1,1,1],"evm":[0,0,0,0]}') windows = parse_windows(lines) stats = per_mask_stats(windows) diff --git a/tests/per_mcs_power_ceiling.sh b/tests/per_mcs_power_ceiling.sh index 75a6ffb..ddf4f70 100755 --- a/tests/per_mcs_power_ceiling.sh +++ b/tests/per_mcs_power_ceiling.sh @@ -9,7 +9,7 @@ # # This measures it directly, reusing this repo's diagnostics: for each MCS it # sweeps the flat TXAGC index (DEVOURER_TX_PWR) while a second devourer adapter -# reports per-frame RSSI/EVM (). The ceiling per MCS is the +# reports per-frame RSSI/EVM (rx.frame events). The ceiling per MCS is the # highest power that still DELIVERS cleanly — i.e. the point just before frames # garble and delivery collapses (exactly the community's "raise power until # garbage" cliff). NB the EVM *knee* (where the PA compresses) is nearly the @@ -77,8 +77,8 @@ wait "$GJ" 2>/dev/null python3 - "$OUT/ground.log" "$OUT/cells.txt" <<'PYEOF' import re, statistics, sys frames = [] # (t, rssi0, snr0, evm0) -rx = re.compile(r"^([0-9.]+) .*.*\brssi=(-?\d+),-?\d+ " - r"evm=(-?\d+),-?\d+ snr=(-?\d+),-?\d+") +rx = re.compile(r'^([0-9.]+) .*"ev":"rx\.frame".*"rssi":\[(-?\d+),-?\d+\],' + r'"evm":\[(-?\d+),-?\d+\],"snr":\[(-?\d+),-?\d+\]') for line in open(sys.argv[1], errors="replace"): m = rx.match(line) if m: diff --git a/tests/pipe_latency_check.py b/tests/pipe_latency_check.py new file mode 100644 index 0000000..ea2b82b --- /dev/null +++ b/tests/pipe_latency_check.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""pipe_latency_check.py — prove the event stream doesn't stall in a pipe. + +The historical failure mode: a supervisor (test script, AI agent) reads a +demo's stdout through subprocess.PIPE; libc makes piped stdout fully +buffered, so output stalls until a 4 KiB boundary. The new logging system +flushes every event line at emission (src/Event.h), so a piped reader must +see each event within moments of the emitting condition — this script +asserts that end-to-end with a real adapter. + +Method: spawn rxdemo with stdout=PIPE, timestamp every event line as it +arrives, and check the gap between our first sight of `init.timing` +stage=demo.create_device and the moment the process emitted it (its `ms` +field vs the process start) stays under the threshold. Then confirm +periodic rx.energy events arrive at their cadence (each within 2 intervals), +which fails under full buffering (a quiet channel emits ~60 B/interval — +a 4 KiB buffer would hold ~30 s of them back). + +Usage: + sudo python3 tests/pipe_latency_check.py [--pid 0xNNNN] [--channel N] + +Exit 0 = no stall; 1 = stall or no events; 2 = setup failure. +""" + +import argparse +import os +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +from devourer_events import parse_event # noqa: E402 + +BUILD = os.path.join(HERE, "..", "build") +ENERGY_MS = 500 +RUN_S = 8 +SLOP_S = 2 * ENERGY_MS / 1000.0 # each periodic event must land within 2 ticks + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--pid", default=None, help="DEVOURER_PID filter") + ap.add_argument("--channel", default="6") + args = ap.parse_args() + + exe = os.path.join(BUILD, "rxdemo") + if not os.path.exists(exe): + print(f"pipe_latency_check: {exe} not built", file=sys.stderr) + return 2 + + env = dict(os.environ) + env["DEVOURER_CHANNEL"] = args.channel + env["DEVOURER_RX_ENERGY_MS"] = str(ENERGY_MS) + if args.pid: + env["DEVOURER_PID"] = args.pid + + t0 = time.monotonic() + proc = subprocess.Popen([exe], stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, env=env) + energy_seen = [] + first_event = None + try: + deadline = t0 + 60 + while time.monotonic() < deadline: + line = proc.stdout.readline() + if not line: + print("note: rxdemo exited early (rc pending) — bring-up " + "failure? rerun or check stderr without DEVNULL", + file=sys.stderr) + break + now = time.monotonic() + ev = parse_event(line) + if ev is None: + continue + if first_event is None: + first_event = (ev["ev"], now - t0) + if ev["ev"] == "rx.energy": + energy_seen.append(now) + if len(energy_seen) >= RUN_S * 1000 // ENERGY_MS: + break + finally: + proc.terminate() + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + if first_event is None: + print("FAIL: no events arrived through the pipe at all") + return 1 + print(f"first event: {first_event[0]} after {first_event[1]:.2f}s") + + if len(energy_seen) < 3: + print(f"FAIL: only {len(energy_seen)} rx.energy events " + f"(expected ~{RUN_S * 1000 // ENERGY_MS})") + return 1 + gaps = [b - a for a, b in zip(energy_seen, energy_seen[1:])] + worst = max(gaps) + print(f"rx.energy: {len(energy_seen)} events, worst inter-arrival " + f"{worst:.2f}s (cadence {ENERGY_MS / 1000.0:.1f}s, " + f"allowed {SLOP_S:.1f}s)") + if worst > SLOP_S: + print("FAIL: periodic events stalled in the pipe") + return 1 + print("PASS: piped consumer sees every event at emission time") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/precoder_roundtrip.py b/tests/precoder_roundtrip.py index c0fbb6d..3639498 100644 --- a/tests/precoder_roundtrip.py +++ b/tests/precoder_roundtrip.py @@ -37,7 +37,6 @@ import argparse import os -import re import subprocess import sys import threading @@ -48,13 +47,17 @@ REPO = HERE.parent PRECODER = REPO / "tools" / "precoder" +sys.path.insert(0, str(HERE)) +from devourer_events import iter_events, parse_event # noqa: E402 + DESC_RATE6M = 0x04 # legacy OFDM 6 Mbps; CCK is 0x00-0x03, HT/VHT MCS is 0x0c+ N_SD_LEGACY = 48 -_HIT_RE = re.compile(r"") -# Tolerant of the Tier-2 health fields inserted between rate= and len=. -_BODY_RE = re.compile(r"rate=(\d+).*? len=(\d+) body=([0-9a-fA-F]*)") -_HEALTH_RE = re.compile(r"rssi=([-\d,]+) evm=([-\d,]+) snr=([-\d,]+) crc=(\d+)") +# rxdemo emits machine events as JSON Lines on stdout: +# {"ev":"rx.txhit",...} — canonical-SA frame match +# {"ev":"rx.body",...} — DEVOURER_DUMP_BODY frame body + Tier-2 health +# Human diagnostics go to stderr as `devourer [I] ...` and never parse as +# events, so merging stderr into the reader below stays safe. def rate_name(idx: int) -> str: @@ -153,7 +156,7 @@ def run_test(args) -> int: deadline = time.monotonic() + args.duration try: while time.monotonic() < deadline: - if any(_BODY_RE.search(l) for l in rx_reader.lines): + if any(parse_event(l, "rx.body") for l in rx_reader.lines): time.sleep(1.0) # let a couple more land break time.sleep(0.5) @@ -168,9 +171,8 @@ def run_test(args) -> int: p.kill() # 5. verdict. - hits = sum(1 for l in rx_reader.lines if _HIT_RE.search(l)) - body_lines = [l for l in rx_reader.lines if _BODY_RE.search(l)] - bodies = [_BODY_RE.search(l) for l in body_lines] + hits = sum(1 for _ in iter_events(rx_reader.lines, ev="rx.txhit")) + bodies = list(iter_events(rx_reader.lines, ev="rx.body")) if args.keep: log = Path(args.workdir) / "rx.log" log.write_text("".join(rx_reader.lines)) @@ -184,23 +186,23 @@ def run_test(args) -> int: ok &= hits > 0 if not bodies: - print("[2/3] phy rate: no line — FAIL") + print("[2/3] phy rate: no rx.body event — FAIL") print("[3/3] bytes: n/a — FAIL") return 1 if not args.allow_fail else 0 - m = bodies[0] - rate = int(m.group(1)) - rx_body = bytes.fromhex(m.group(3)) + ev = bodies[0] + rate = int(ev["rate"]) + rx_body = bytes.fromhex(ev.get("body", "")) print(f"[2/3] phy rate: idx 0x{rate:02x} = {rate_name(rate)} — " + ("PASS" if rate == DESC_RATE6M else "FAIL")) ok &= rate == DESC_RATE6M # Tier-2 link-health diagnostics (info only — content-blind, never a # per-subcarrier or control signal; see the precoder README). - h = _HEALTH_RE.search(body_lines[0]) - if h: - print(f"[ -- ] link health (info, not control): rssi={h.group(1)} " - f"evm={h.group(2)} snr={h.group(3)} crc_err={h.group(4)}") + if "rssi" in ev: + fmt = lambda k: ",".join(str(v) for v in ev.get(k, [])) # noqa: E731 + print(f"[ -- ] link health (info, not control): rssi={fmt('rssi')} " + f"evm={fmt('evm')} snr={fmt('snr')} crc_err={ev.get('crc', 0)}") n = min(len(expected), len(rx_body)) match = n > 0 and rx_body[:n] == expected[:n] diff --git a/tests/precoder_stream_roundtrip.py b/tests/precoder_stream_roundtrip.py index ec25fd2..31dcd77 100644 --- a/tests/precoder_stream_roundtrip.py +++ b/tests/precoder_stream_roundtrip.py @@ -4,7 +4,8 @@ Streams `--bytes N` random bytes from one devourer adapter (streamtx, fed by tools/precoder/stream_tx.py) to a second devourer adapter (rxdemo with DEVOURER_STREAM_OUT=1), then decodes the received - lines via stream.decode_body and checks: +`{"ev":"rx.frame",...}` JSONL events (rxdemo stdout) via +stream.decode_body and checks: 1. TRANSPORT — at least `--min-frames` frames decoded. 2. RX RATE — every decoded frame's RX rate index == DESC_RATE6M (0x04), @@ -35,7 +36,6 @@ import argparse import os import random -import re import subprocess import sys import threading @@ -45,20 +45,20 @@ HERE = Path(__file__).resolve().parent REPO = HERE.parent PRECODER = REPO / "tools" / "precoder" +sys.path.insert(0, str(HERE)) sys.path.insert(0, str(PRECODER)) import stream # noqa: E402 import encode_subcarriers as enc # noqa: E402 +from devourer_events import iter_events # noqa: E402 DESC_RATE6M = 0x04 -_STREAM_RE = re.compile( - r"rate=(?P\d+)\s+len=(?P\d+)" - r"(?:\s+crc_err=(?P\d+))?" - r"(?:\s+icv_err=(?P\d+))?" - r"(?:\s+rssi=(?P-?\d+,-?\d+))?" - r"(?:\s+evm=(?P-?\d+,-?\d+))?" - r"(?:\s+snr=(?P-?\d+,-?\d+))?" - r"\s+body=(?P[0-9a-fA-F]*)" -) + +# rxdemo's DEVOURER_STREAM_OUT emits one `{"ev":"rx.frame",...}` JSON Line +# per frame on stdout (fields: rate, len, crc, icv, rssi/evm/snr, seq, tsfl, +# bw, stbc, ldpc, sgi, body-hex). Human diagnostics go to stderr as +# `devourer [I] ...` and never parse as events, so merging stderr into the +# reader below stays safe. (streamtx routes its own events to stderr — its +# stdin is the data path — but this harness DEVNULLs the TX side anyway.) def parse_shape(s: str | None): @@ -182,7 +182,7 @@ def run_test(args) -> int: deadline = time.monotonic() + args.duration try: while time.monotonic() < deadline: - hits = sum(1 for l in rx_reader.lines if _STREAM_RE.search(l)) + hits = sum(1 for _ in iter_events(rx_reader.lines, ev="rx.frame")) if hits >= expected_frames + 2: # small buffer for retransmits time.sleep(0.5) break @@ -219,14 +219,11 @@ def run_test(args) -> int: shape_checked = 0 bodies_for_shape: list[tuple[bytes, int]] = [] # (body, plen) - for l in rx_reader.lines: - m = _STREAM_RE.search(l) - if not m: - continue - rate = int(m.group("rate")) + for ev in iter_events(rx_reader.lines, ev="rx.frame"): + rate = int(ev["rate"]) if rate != DESC_RATE6M: rate_mismatch += 1 - body = bytes.fromhex(m.group("hex")) + body = bytes.fromhex(ev.get("body", "")) frame = stream.decode_body(body, shape=shape, seed=seed, offset=offset, entry_state=entry_state) if frame is None: diff --git a/tests/precoder_stream_smoke.py b/tests/precoder_stream_smoke.py index a248e96..da90368 100644 --- a/tests/precoder_stream_smoke.py +++ b/tests/precoder_stream_smoke.py @@ -6,8 +6,8 @@ on the system Python. It drives the same encode → simulated-on-wire → decode path the two-adapter -hardware harness uses, but synthesises the `` line that -rxdemo would print, so no USB is involved. +hardware harness uses, but synthesises the body bytes an rxdemo +`{"ev":"rx.frame",...}` event would carry, so no USB is involved. The smoke covers: * byte-mode round-trip — frame the input, encode, decode, reassemble; @@ -61,8 +61,8 @@ def test_byte_mode_repo_smoke(): bodies.append(body) # Simulate the wire: length-prefixed PSDU bodies in, the same bodies - # back out (streamtx emits them, demo's DEVOURER_STREAM_OUT dumps - # them — identity on the byte plane). + # back out (streamtx emits them, rxdemo's DEVOURER_STREAM_OUT carries + # them in rx.frame events — identity on the byte plane). wire = b"".join(_frame_psdu_for_wire(b) for b in bodies) rx_bodies = _parse_length_prefixed(wire) assert rx_bodies == bodies diff --git a/tests/regress.py b/tests/regress.py index 28755cd..abebe77 100755 --- a/tests/regress.py +++ b/tests/regress.py @@ -63,6 +63,9 @@ from pathlib import Path from typing import Optional +# Same-dir import: python puts the script's own directory on sys.path. +from devourer_events import iter_events + # --------------------------------------------------------------------------- # Process leak prevention. @@ -154,7 +157,7 @@ def _handler(signum, _frame): signal.signal(signal.SIGHUP, _handler) # Source MAC of the canonical beacon — must match examples/tx/main.cpp and the -# `` matcher in examples/rx/main.cpp. tcpdump filter and scapy +# `rx.txhit` event matcher in examples/rx/main.cpp. tcpdump filter and scapy # injector both use this. CANONICAL_SA = "57:42:75:05:d6:00" @@ -873,37 +876,35 @@ def _terminate(proc: subprocess.Popen, grace: float = 2.0) -> None: def _count_devourer_rx_hits(log_path: Path) -> int: last = 0 try: - for line in log_path.read_text(errors="replace").splitlines(): - if "" in line: - for tok in line.split(): - if tok.startswith("hits="): - try: - last = max(last, int(tok.split("=", 1)[1])) - except ValueError: - pass + lines = log_path.read_text(errors="replace").splitlines() except FileNotFoundError: - pass + return 0 + for ev in iter_events(lines, ev="rx.txhit"): + try: + last = max(last, int(ev["hits"])) + except (KeyError, TypeError, ValueError): + pass return last def _count_devourer_tx_attempts(log_path: Path) -> tuple[int, int]: - """Returns (max_tx_count_logged, send_failures). The print is rate-limited - so when sends fail often, max_tx_count_logged stays low — surface failure - count alongside so the picture is honest.""" + """Returns (max_tx_count_logged, send_failures). The tx.frame event is + rate-limited so when sends fail often, max_tx_count_logged stays low — + surface the tx.fail count alongside so the picture is honest.""" last = 0 failures = 0 try: - for line in log_path.read_text(errors="replace").splitlines(): - if line.startswith("TX #"): - tok = line.split("#", 1)[1].split()[0] - try: - last = max(last, int(tok)) - except ValueError: - pass - elif "Failed to send packet" in line: - failures += 1 + lines = log_path.read_text(errors="replace").splitlines() except FileNotFoundError: - pass + return 0, 0 + for ev in iter_events(lines): + if ev["ev"] == "tx.frame": + try: + last = max(last, int(ev["n"])) + except (KeyError, TypeError, ValueError): + pass + elif ev["ev"] == "tx.fail": + failures += 1 return last, failures diff --git a/tests/run_antenna_decorrelation.sh b/tests/run_antenna_decorrelation.sh index 96928f0..6857417 100755 --- a/tests/run_antenna_decorrelation.sh +++ b/tests/run_antenna_decorrelation.sh @@ -8,7 +8,7 @@ # (txdemo) on the same channel — the standard two-adapter bench setup. # # It builds devourer, runs rxdemo (RX) with DEVOURER_RX_ALLPATHS=1 for a -# fixed dwell capturing the lines, then feeds them to +# fixed dwell capturing the rx.path JSONL events, then feeds them to # antenna_decorrelation.py. Optionally starts the TX beacon too (set TX_PID). # # Env knobs (all optional): @@ -71,13 +71,13 @@ if [ -n "${TX_PID:-}" ]; then # Poll until the beacon actually injects — TX+RX each need ~7-10s to init # (efuse read + settle), and the 8812 TX occasionally fails to claim on the # first try when launched alongside the RX's USB reset. Waiting for a - # confirmed inject (a `TX #.. rc=1` line) instead of a fixed sleep is what - # makes a zero-frame capture (dead TX window) reliable to avoid. + # confirmed inject (a tx.frame event with rc=1) instead of a fixed sleep is + # what makes a zero-frame capture (dead TX window) reliable to avoid. for _ in $(seq 1 30); do - grep -q 'TX #.* rc=1' "$TXLOG" 2>/dev/null && break + grep -q '"ev":"tx.frame".*"rc":1' "$TXLOG" 2>/dev/null && break sleep 1 done - if ! grep -q 'TX #.* rc=1' "$TXLOG" 2>/dev/null; then + if ! grep -q '"ev":"tx.frame".*"rc":1' "$TXLOG" 2>/dev/null; then echo "TX beacon did not confirm an inject within 30s — check $TXLOG" >&2 exit 3 fi @@ -105,8 +105,8 @@ sleep "$DURATION" kill "$RX_PID_RUNNING" 2>/dev/null || true wait "$RX_PID_RUNNING" 2>/dev/null || true -RXLINES="$(grep -c '' "$CAP" || true)" -echo "== captured $RXLINES frames ==" +RXLINES="$(grep -cF '"ev":"rx.path"' "$CAP" || true)" +echo "== captured $RXLINES rx.path frames ==" if [ "${RXLINES:-0}" -eq 0 ]; then echo "No canonical-SA frames captured. Is the beacon TX flying on ch$CHANNEL," \ "and is RX_PID the right adapter?" >&2 diff --git a/tests/run_hop_validation.sh b/tests/run_hop_validation.sh index 51cfe19..2192205 100755 --- a/tests/run_hop_validation.sh +++ b/tests/run_hop_validation.sh @@ -89,12 +89,12 @@ sudo --preserve-env \ "$ROOT/build/txdemo" >"$TX_LOG" 2>&1 & TX_PID_PROC=$! -# Wait until the TX is actually hopping (first marker) before we +# Wait until the TX is actually hopping (first hop.dwell event) before we # start the SDR — chip bring-up takes a few seconds and we don't want to burn # the capture window on it. echo "== waiting for TX to begin hopping ==" for _ in $(seq 1 300); do - grep -q "" "$TX_LOG" 2>/dev/null && break + grep -qF '"ev":"hop.dwell"' "$TX_LOG" 2>/dev/null && break kill -0 "$TX_PID_PROC" 2>/dev/null || { echo "TX exited early"; break; } sleep 0.1 done @@ -112,8 +112,8 @@ echo "== TX finished; waiting for SDR capture to end ==" wait "$RX_PID" 2>/dev/null || true echo -echo "== TX hop markers (from $TX_LOG) ==" -grep -E "devourer-hop|devourer-hop-done" "$TX_LOG" | head -40 || true +echo "== TX hop events (from $TX_LOG) ==" +grep -F -e '"ev":"hop.dwell"' -e '"ev":"hop.done"' "$TX_LOG" | head -40 || true echo echo "== SDR verdict (from $RX_LOG) ==" sed -n '/=== hop_rx_probe verdict ===/,$p' "$RX_LOG" || true diff --git a/tests/run_stream_hop_validation.sh b/tests/run_stream_hop_validation.sh index a7507b1..7e57046 100755 --- a/tests/run_stream_hop_validation.sh +++ b/tests/run_stream_hop_validation.sh @@ -62,9 +62,11 @@ sudo --preserve-env \ >"$TX_LOG" 2>&1 & TX_PID_PROC=$! +# streamtx routes its events (stream.tx / stream.done) to STDERR — stdout is +# its data path — so they land in $TX_LOG via the merged 2>&1 capture. echo "== waiting for first TX ==" for _ in $(seq 1 300); do - grep -q "" "$TX_LOG" 2>/dev/null && break + grep -qF '"ev":"stream.tx"' "$TX_LOG" 2>/dev/null && break kill -0 "$TX_PID_PROC" 2>/dev/null || { echo "TX exited early"; break; } sleep 0.1 done @@ -78,6 +80,6 @@ RX_PID=$! wait "$RX_PID" 2>/dev/null || true cleanup -echo; echo "== TX summary =="; grep -E "stream-tx|HOP_CHANNELS" "$TX_LOG" | tail -5 || true +echo; echo "== TX summary =="; grep -F -e '"ev":"stream.tx"' -e '"ev":"stream.done"' -e 'HOP_CHANNELS' "$TX_LOG" | tail -5 || true echo; echo "== SDR verdict =="; sed -n '/=== hop_rx_probe verdict ===/,$p' "$RX_LOG" || true echo; echo "Logs: $TX_LOG $RX_LOG" diff --git a/tests/rx_energy_check.py b/tests/rx_energy_check.py index 915e24c..de9c26d 100755 --- a/tests/rx_energy_check.py +++ b/tests/rx_energy_check.py @@ -2,7 +2,7 @@ """Verdict for tests/rx_energy_probe.sh: is a CW interferer detectable in the frame-free RX energy telemetry? -Parses the sensor's lines from a baseline (tone off) capture +Parses the sensor's rx.energy events from a baseline (tone off) capture and a tone-on capture, and asserts the two are clearly separable in the CCA counter. A strong co-located CW pushes the sensor's cca_ofdm out of its ambient band in one of two ways, both a valid detection: @@ -19,21 +19,20 @@ python3 rx_energy_check.py baseline.log tone.log """ from __future__ import annotations -import re import sys import statistics -_CCA = re.compile(r"cca_ofdm=(\d+)") +from devourer_events import iter_events def cca_series(path: str) -> list[int]: out = [] try: with open(path) as f: - for ln in f: - m = _CCA.search(ln) - if m: - out.append(int(m.group(1))) + for ev in iter_events(f, ev="rx.energy"): + v = ev.get("cca_ofdm") + if v is not None: + out.append(int(v)) except FileNotFoundError: pass # drop the first sample (often the pre-bring-up 0) diff --git a/tests/rx_energy_probe.sh b/tests/rx_energy_probe.sh index f1708f7..1027e71 100755 --- a/tests/rx_energy_probe.sh +++ b/tests/rx_energy_probe.sh @@ -3,7 +3,7 @@ # # Runs a two-adapter, no-SDR test: one adapter emits a CW tone (DEVOURER_CW_TONE) # on a channel; a second adapter runs the RX demo with DEVOURER_RX_ENERGY_MS and -# reports the frame-free energy telemetry (). We capture the +# reports the frame-free energy telemetry (rx.energy events). We capture the # sensor's telemetry with the tone OFF (baseline) and ON, then assert the two are # clearly separable (rx_energy_check.py). A strong co-located CW pushes the # sensor's CCA counter far out of its ambient band — either a large spike (the @@ -57,7 +57,7 @@ sense() { # $1=logfile timeout -sINT "$((SECS + 3))" env DEVOURER_VID="$SENSOR_VID" \ DEVOURER_PID="$SENSOR_PID" DEVOURER_CHANNEL="$CHANNEL" \ DEVOURER_RX_ENERGY_MS="$ENERGY_MS" "$DEMO_RX" 2>/dev/null \ - | grep --line-buffered "devourer-energy" > "$1" || true + | grep --line-buffered -F '"ev":"rx.energy"' > "$1" || true } echo "== baseline (tone OFF) ==" diff --git a/tests/rx_noise_floor_onair.sh b/tests/rx_noise_floor_onair.sh index a7a4bbd..3b5f17e 100755 --- a/tests/rx_noise_floor_onair.sh +++ b/tests/rx_noise_floor_onair.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Validate the passive noise-floor + the GetRxQuality() feed (src/RxQuality.h, -# ) ON-AIR. The passive noise floor is nf_dbm = rssi_dbm - +# rx.quality events) ON-AIR. The passive noise floor is nf_dbm = rssi_dbm - # snr_db per decoded OFDM/HT frame — the effective noise+interference floor the # receiver sees, and the Fluke self-jamming signal ("decrease txpower until NF # returns to normal"): a raised floor drops SNR while RSSI holds, so nf rises. @@ -58,28 +58,27 @@ run_cell() { sleep 3 sudo -n env DEVOURER_PID="$RX_PID" DEVOURER_VID="$RX_VID" DEVOURER_CHANNEL="$CH" \ DEVOURER_RX_ENERGY_MS=1000 DEVOURER_RXQUALITY=1 \ - timeout "$DUR" "$ROOT/build/rxdemo" 2>&1 \ - | grep "devourer-rxquality" >"$log" || true + timeout "$DUR" "$ROOT/build/rxdemo" 2>/dev/null \ + | grep -F '"ev":"rx.quality"' >"$log" || true kill "$tx" 2>/dev/null; wait "$tx" 2>/dev/null sleep 2 python3 - "$log" <<'PYEOF' -import re, statistics, sys +import json, statistics, sys nf, rssi, snr, verdicts, frames = [], [], [], [], [] for line in open(sys.argv[1], errors="replace"): - fr = re.search(r"frames=(\d+)", line) - if not fr or int(fr.group(1)) == 0: + try: + ev = json.loads(line) + except ValueError: continue - def g(pat): - m = re.search(pat, line); return float(m.group(1)) if m else None - nfv = g(r"noise_floor_dbm=(-?\d+\.?\d*)") - rv = g(r"rssi_mean_dbm=(-?\d+\.?\d*)") - sv = g(r"snr_mean_db=(-?\d+\.?\d*)") - v = re.search(r"verdict=(\w+)", line) - frames.append(int(fr.group(1))) - if nfv is not None: nf.append(nfv) - if rv is not None: rssi.append(rv) - if sv is not None: snr.append(sv) - if v: verdicts.append(v.group(1)) + if ev.get("ev") != "rx.quality" or not ev.get("frames"): + continue + frames.append(int(ev["frames"])) + nfv, rv, sv = ev.get("noise_floor_dbm"), ev.get("rssi_mean_dbm"), ev.get("snr_mean_db") + v = ev.get("verdict") + if nfv is not None: nf.append(float(nfv)) + if rv is not None: rssi.append(float(rv)) + if sv is not None: snr.append(float(sv)) + if v: verdicts.append(v) def med(a): return statistics.median(a) if a else float("nan") tail = verdicts[len(verdicts)//2:] or verdicts vv = max(set(tail), key=tail.count) if tail else "NONE" diff --git a/tests/rx_path_mask_check.sh b/tests/rx_path_mask_check.sh index 94e5ade..67fadc1 100755 --- a/tests/rx_path_mask_check.sh +++ b/tests/rx_path_mask_check.sh @@ -2,7 +2,7 @@ # Hardware validation for the runtime RX-chain mask (SetRxPathMask, issue #189). # The 8814AU is the only plugged part with >2 chains to trade, so it is the RX; # an 8812AU beacon on the same channel is the source. DEVOURER_RX_ALLPATHS emits -# per-chain RSSI (A,B,C,D) on lines — masking a chain drops +# per-chain RSSI (A,B,C,D) on rx.path JSONL events — masking a chain drops # its RSSI to the noise floor, which is the functional truth the check keys on. # # Two things proven: @@ -43,20 +43,23 @@ if ! plugged "$TX_PID" "$TX_VID"; then echo "SKIP: 8812AU (TX) not plugged"; exi echo "== building ==" cmake --build "$ROOT/build" -j --target rxdemo txdemo >/dev/null || exit 1 -# Median per-chain RSSI (chains A B C D) over a run's lines, +# Median per-chain RSSI (chains A B C D) over a run's rx.path events, # restricted to a channel by cross-referencing the sweep's dwell markers is # overkill here: the beacon is on ONE channel, so only that channel's dwells -# catch frames — every line already belongs to it. +# catch frames — every rx.path event already belongs to it. medians() { # $1=log -> "A B C D" python3 - "$1" <<'PYEOF' -import re, statistics, sys +import json, statistics, sys ch = {0: [], 1: [], 2: [], 3: []} -rx = re.compile(r".*\brssi=(-?\d+),(-?\d+),(-?\d+),(-?\d+)") for line in open(sys.argv[1], errors="replace"): - m = rx.search(line) - if m: - for i in range(4): - ch[i].append(int(m.group(i + 1))) + if not line.startswith('{"ev":"rx.path"'): + continue + try: + rssi = json.loads(line)["rssi"] + except (ValueError, KeyError): + continue + for i in range(4): + ch[i].append(int(rssi[i])) out = [] for i in range(4): out.append(str(int(statistics.median(ch[i]))) if ch[i] else "nan") diff --git a/tests/rx_quality_selftest.cpp b/tests/rx_quality_selftest.cpp index bbd9e6f..c1d5520 100644 --- a/tests/rx_quality_selftest.cpp +++ b/tests/rx_quality_selftest.cpp @@ -1,6 +1,6 @@ /* Headless guard for the RxQuality accumulator + build_rx_quality fuse * (src/RxQuality.h) — the windowed RX-link-quality feed behind - * / IRtlDevice::GetRxQuality(). Verifies the aggregate + * rx.quality event / IRtlDevice::GetRxQuality(). Verifies the aggregate * math (mean/max/min, EVM present-gate), the PASSIVE noise-floor formula * (nf_dbm = (rssi_raw-110) - snr_raw/2), the unit conversions, and that the * fused verdict matches classify_link_health. A regression fails ctest instead diff --git a/tests/rx_spectrum_sweep.py b/tests/rx_spectrum_sweep.py index 185f13f..d517a8a 100755 --- a/tests/rx_spectrum_sweep.py +++ b/tests/rx_spectrum_sweep.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """Render a coarse RX spectrum map from a live DEVOURER_RX_SWEEP log. -Parses the `ch=N ...` lines the sensor emits (one per dwell), +Parses the `rx.energy` events (with a `ch` field) the sensor emits (one per +dwell), aggregates the frame-free CCA-OFDM energy per channel across sweep rounds, and draws an ASCII energy-vs-frequency bar chart. Flags the peak bin (a spike interferer) and, when the floor is clearly non-zero, the deepest dip (a 1T1R @@ -12,36 +13,23 @@ Jaguar3 with --nb-bw 5. """ import argparse -import re import statistics import sys -LINE = re.compile(r"(.*)") -KV = re.compile(r"(\w+)=(\S+)") +from devourer_events import iter_events def parse(path): """channel -> list of cca_ofdm samples (ints), in file order.""" per_ch = {} with open(path) as f: - for raw in f: - m = LINE.search(raw) - if not m: + for ev in iter_events(f, ev="rx.energy"): + if "ch" not in ev or ev["ch"] is None: continue - kv = dict(KV.findall(m.group(1))) - if "ch" not in kv: + v = ev.get("cca_ofdm") + if v is None: continue - try: - ch = int(kv["ch"]) - except ValueError: - continue - v = kv.get("cca_ofdm", "-") - if v == "-": - continue - try: - per_ch.setdefault(ch, []).append(int(v)) - except ValueError: - pass + per_ch.setdefault(int(ev["ch"]), []).append(int(v)) return per_ch @@ -53,7 +41,7 @@ def main(): per_ch = parse(args.log) if not per_ch: - print("no ch= samples in log", file=sys.stderr) + print("no rx.energy samples with ch= in log", file=sys.stderr) return 1 chans = sorted(per_ch) diff --git a/tests/rx_spectrum_sweep.sh b/tests/rx_spectrum_sweep.sh index 85865ba..ca4b1f7 100755 --- a/tests/rx_spectrum_sweep.sh +++ b/tests/rx_spectrum_sweep.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Coarse RX spectrum map — a single live sweep. One sensor process cycles the # listed channels (DEVOURER_RX_SWEEP), dwelling DEVOURER_RX_SWEEP_DWELL_MS on -# each and emitting one ch=N line per bin from the frame-free +# each and emitting one rx.energy event (with ch=N) per bin from the frame-free # energy sensor. The devourer-native spectrum analyzer: no per-tone CSI (the # silicon can't export it), just channel-wide energy per bin. # @@ -75,7 +75,7 @@ echo "== live sweep: $CHANNELS x $ROUNDS rounds, ${DWELL_MS}ms dwell (~${secs}s) timeout -sINT "$secs" env DEVOURER_VID="$SENSOR_VID" \ DEVOURER_PID="$SENSOR_PID" DEVOURER_RX_SWEEP="$CHANNELS" \ DEVOURER_RX_SWEEP_DWELL_MS="$DWELL_MS" ${NB_BW:+DEVOURER_NB_BW="$NB_BW"} \ - "$DEMO_RX" 2>/dev/null | grep --line-buffered "devourer-energy" \ + "$DEMO_RX" 2>/dev/null | grep --line-buffered -F '"ev":"rx.energy"' \ | tee "$OUT/sweep.log" || true cleanup diff --git a/tests/rx_tone_localize.py b/tests/rx_tone_localize.py index a6cad3e..29c1a44 100755 --- a/tests/rx_tone_localize.py +++ b/tests/rx_tone_localize.py @@ -10,7 +10,7 @@ script thresholds (robust median/MAD) to flag and locate the interferer to a subcarrier group (~312 kHz * Ng) — far finer than a channel. -Input: `HEX` lines (rxdemo DEVOURER_BF_DETECT_ +Input: `{"ev":"bf.report_raw",...}` event lines (rxdemo DEVOURER_BF_DETECT_ REPORT=4) on stdin or a file. Reuses tools/bf_report_decode.py's analyzer. Two modes: diff --git a/tests/saturation_knee_sweep.sh b/tests/saturation_knee_sweep.sh index 00746af..e0ff7b7 100755 --- a/tests/saturation_knee_sweep.sh +++ b/tests/saturation_knee_sweep.sh @@ -2,7 +2,7 @@ # Near-field saturation-knee sweep — the measurement behind the link-health # classifier and the bench-testing guidance. Walks the TX-power flat index over # its full range (the runtime knob DEVOURER_TX_PWR) while a ground adapter -# reports per-frame RSSI / SNR / EVM (). On a healthy link EVM +# reports per-frame RSSI / SNR / EVM (rx.frame events). On a healthy link EVM # improves monotonically as RSSI rises; at the front-end saturation knee it # STOPS improving and reverses — more signal made it worse. This finds that # knee and, as a side effect, calibrates the RSSI scale (does raw RSSI climb @@ -67,8 +67,8 @@ wait "$GJ" 2>/dev/null python3 - "$OUT/ground.log" "$OUT/cells.txt" <<'PYEOF' import re, statistics, sys frames = [] # (t, rssi0, snr0, evm0) -rx = re.compile(r"^([0-9.]+) .*.*\brssi=(-?\d+),-?\d+ " - r"evm=(-?\d+),-?\d+ snr=(-?\d+),-?\d+") +rx = re.compile(r'^([0-9.]+) .*"ev":"rx\.frame".*"rssi":\[(-?\d+),-?\d+\],' + r'"evm":\[(-?\d+),-?\d+\],"snr":\[(-?\d+),-?\d+\]') for line in open(sys.argv[1], errors="replace"): m = rx.match(line) if m: diff --git a/tests/sounding_map.py b/tests/sounding_map.py index 817a940..fbb4c86 100755 --- a/tests/sounding_map.py +++ b/tests/sounding_map.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Coarse H(f) map from a two-ended sounding sweep (issue #149). -Parses the RX side's per-dwell `ch=N ... frames=N -rssi_mean=.. rssi_max=.. snr_mean=.. evm_mean=..` lines (rxdemo with +Parses the RX side's per-dwell `rx.energy` events (with `ch`, `frames`, +`rssi_mean`, `rssi_max`, `snr_mean`, `evm_mean` fields; rxdemo with DEVOURER_RX_SWEEP + DEVOURER_RX_AGG_SA=canon, driven by tests/sounding_sweep.sh) and renders the recovered per-bin link map. @@ -26,11 +26,10 @@ from __future__ import annotations import argparse -import re import statistics import sys -KV = re.compile(r"(\w+)=(-?\d+)") +from devourer_events import iter_events def expand_spec(spec: str) -> list[int]: @@ -117,7 +116,7 @@ def main() -> int: ap = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - ap.add_argument("--log", help="RX sweep log (ch= lines)") + ap.add_argument("--log", help="RX sweep log (rx.energy events with ch=)") ap.add_argument("--expand", metavar="SPEC", help="print the channel list for a SweepSpec and exit") ap.add_argument("--notch-db", type=float, default=6.0, @@ -138,18 +137,17 @@ def main() -> int: # Per-bin dwell records; the first dwell per bin is dropped (partial dwell # while the loops phase-align + AGC settle — same convention as # rx_spectrum_sweep.py). - dwells: dict[int, list[dict[str, int]]] = {} - for line in open(args.log, errors="replace"): - if "" not in line or "ch=" not in line: - continue - kv = {k: int(v) for k, v in KV.findall(line)} + dwells: dict[int, list[dict[str, float]]] = {} + for ev in iter_events(open(args.log, errors="replace"), ev="rx.energy"): + # keep the numeric fields only (drop the ev name and any null sensors) + kv = {k: v for k, v in ev.items() if isinstance(v, (int, float))} if "ch" not in kv or "frames" not in kv: - continue # no per-dwell frame stats (older binary) or steady-state emitter - dwells.setdefault(kv["ch"], []).append(kv) + continue # no sweep bin / per-dwell frame stats (steady-state emitter) + dwells.setdefault(int(kv["ch"]), []).append(kv) dwells = {ch: d[1:] if len(d) > 1 else d for ch, d in dwells.items()} if not dwells: - print("no sounding samples in log (need ch= lines with frames=)", - file=sys.stderr) + print("no sounding samples in log (need rx.energy events with ch and " + "frames)", file=sys.stderr) return 1 rows = [] diff --git a/tests/stbc_1t1r_check.sh b/tests/stbc_1t1r_check.sh index 3c0580d..da2c340 100755 --- a/tests/stbc_1t1r_check.sh +++ b/tests/stbc_1t1r_check.sh @@ -11,7 +11,7 @@ # 2T2R (8812AU / 8822BU): inject MCS1/STBC -> NO warning (STBC honoured) and # the frame delivers. # -# Ground = a second devourer part counting from the canonical SA. +# Ground = a second devourer part counting rx.txhit events from the canonical SA. # # Usage: sudo -v && tests/stbc_1t1r_check.sh set -u @@ -49,7 +49,7 @@ for dut in "${DUTS[@]}"; do : >"$OUT/$tag-g.log" sudo -n env DEVOURER_PID="$GPID" DEVOURER_VID="$GVID" DEVOURER_CHANNEL="$CH" \ stdbuf -oL timeout 45 "$ROOT/build/rxdemo" 2>/dev/null \ - | grep --line-buffered "tx-hit" >"$OUT/$tag-g.log" & + | grep --line-buffered -F '"ev":"rx.txhit"' >"$OUT/$tag-g.log" & GJ=$! sleep 10 sudo -n env DEVOURER_PID="$PID" DEVOURER_VID="$VID" DEVOURER_CHANNEL="$CH" \ @@ -58,8 +58,10 @@ for dut in "${DUTS[@]}"; do sudo -n pkill -x rxdemo 2>/dev/null; wait "$GJ" 2>/dev/null sleep 2 + # The 1T1R guard warning is a stderr diagnostic (devourer [W]); the tx log + # captures 2>&1 so the grep still sees it. warned=$(grep -c "dropping the STBC flag" "$OUT/$tag-tx.log") - hits=$(grep -oE "hits=[0-9]+" "$OUT/$tag-g.log" | tail -1 | grep -oE "[0-9]+") + hits=$(grep -oE '"hits":[0-9]+' "$OUT/$tag-g.log" | tail -1 | grep -oE "[0-9]+") hits=${hits:-0} echo " warning fired: $warned, delivery hits: $hits" diff --git a/tests/stbc_sanity.sh b/tests/stbc_sanity.sh index 41b1f91..e8e55be 100755 --- a/tests/stbc_sanity.sh +++ b/tests/stbc_sanity.sh @@ -5,7 +5,7 @@ # Alamouti across two TX antennas internally, sidestepping the SDR 2-channel # problem), and receives on an RTL8812AU — the 8812/8821 RX descriptor exposes # the received HT-SIG STBC bit, which the 8814 RX does NOT (it reports defaults). -# A pass = frames arrive with stbc=1 in the line. +# A pass = frames arrive with "stbc":1 in the rx.frame event. # # Hardware (all on ONE host, adapters inches apart for a STRONG link — a clean # yes/no needs the frame to decode easily; range confounds a zero result): @@ -45,8 +45,9 @@ TX_ENV=(DEVOURER_PID=0x8813 DEVOURER_CHANNEL="$CHANNEL" DEVOURER_TX_RATE="${MCS} [ -n "${TX_BUS:-}" ] && TX_ENV+=(DEVOURER_USB_BUS="$TX_BUS") [ -n "${TX_PORT:-}" ] && TX_ENV+=(DEVOURER_USB_PORT="$TX_PORT") stdbuf -oL -eL env "${TX_ENV[@]}" "$TXDEMO" >"$TXLOG" 2>&1 & -for _ in $(seq 1 30); do grep -q 'TX #.* rc=1' "$TXLOG" 2>/dev/null && break; sleep 1; done -grep -q 'TX #.* rc=1' "$TXLOG" || { echo "TX beacon never injected — check $TXLOG" >&2; exit 3; } +tx_ok() { grep -F '"ev":"tx.frame"' "$TXLOG" 2>/dev/null | grep -q '"rc":1'; } +for _ in $(seq 1 30); do tx_ok && break; sleep 1; done +tx_ok || { echo "TX beacon never injected — check $TXLOG" >&2; exit 3; } echo " TX injecting ($(grep -m1 'fixed rate' "$TXLOG" 2>/dev/null || echo '?'))" echo "== RX: 8812 for ${DURATION}s, reading the STBC bit ==" @@ -57,10 +58,10 @@ sleep "$DURATION" kill "$rxpid" 2>/dev/null || true wait "$rxpid" 2>/dev/null || true # only the RX — the TX beacon runs on -TOTAL=$(grep -c '' "$RXLOG" || true) -STBC1=$(grep -c '.*stbc=1' "$RXLOG" || true) +TOTAL=$(grep -c -F '"ev":"rx.frame"' "$RXLOG" || true) +STBC1=$(grep -c '"ev":"rx.frame".*"stbc":1' "$RXLOG" || true) echo "== result: canonical frames=$TOTAL with stbc=1: $STBC1 ==" -grep -m3 '' "$RXLOG" | sed -E 's/body=.*//' +grep -m3 -F '"ev":"rx.frame"' "$RXLOG" | sed -E 's/"body":.*//' if [ "${STBC1:-0}" -gt 0 ]; then echo "PASS — the chip emits decodable STBC on-air (stbc=1 seen). Proceed to the" echo " TX-diversity mobility measurement (STBC vs single-stream, moving TX)." diff --git a/tests/svc_uep_onair.sh b/tests/svc_uep_onair.sh index d78813e..5585482 100644 --- a/tests/svc_uep_onair.sh +++ b/tests/svc_uep_onair.sh @@ -41,7 +41,7 @@ python3 "$ROOT/tests/gen_svc_nals.py" 12 | \ "$ROOT/build/svctx" --gap-us 600 >/tmp/svc.log 2>&1 & sleep 9 echo "=== svctx policy + per-TID counters ===" -grep -E "SVC UEP policy| T[0-9] ->|" /tmp/svc.log | tail -6 +grep -E 'SVC UEP policy| T[0-9] ->|"ev":"svc.stats"' /tmp/svc.log | tail -6 echo "=== witness decoded MCS histogram (expect MCS0:MCS1:MCS4:MCS7 ~ 1:4:8:16) ===" sudo timeout 6 tcpdump -i "$W" -e -nn "ether src $SA" 2>/dev/null | grep -oE "MCS [0-9]+" | sort -V | uniq -c KILL diff --git a/tests/test_canary_diff.py b/tests/test_canary_diff.py index b3565d1..4bce378 100644 --- a/tests/test_canary_diff.py +++ b/tests/test_canary_diff.py @@ -34,22 +34,31 @@ def run_diff(kernel: str, devourer: str, *extra_args: str, def wrap(body: str) -> str: + """Kernel-style capture: bare `KIND 0xADDR = 0xVALUE` lines.""" return f"{CANARY_HEADER}\n{textwrap.dedent(body).strip()}\n{CANARY_FOOTER}\n" +def dwrap(body: str) -> str: + """Devourer-style capture: raw stderr with the `devourer [I] ` + human-diagnostic prefix on every line (dump + envelope markers).""" + lines = [CANARY_HEADER, *textwrap.dedent(body).strip().splitlines(), + CANARY_FOOTER] + return "".join(f"devourer [I] {l.strip()}\n" for l in lines) + + def test_clean_diff_exits_zero(tmp_path: Path) -> None: - canary = wrap(""" + body = """ BB 0x808 = 0x3E028233 MAC 0x040 = 0x000C0000 RF[A] 0x00 = 0x33EA9 - """) - res = run_diff(canary, canary, tmp_path=tmp_path) + """ + res = run_diff(wrap(body), dwrap(body), tmp_path=tmp_path) assert res.returncode == 0, res.stdout + res.stderr def test_real_divergence_exits_one(tmp_path: Path) -> None: kernel = wrap("BB 0x808 = 0x3E028233") - devourer = wrap("BB 0x808 = 0x3E028299") + devourer = dwrap("BB 0x808 = 0x3E028299") res = run_diff(kernel, devourer, tmp_path=tmp_path) assert res.returncode == 1, res.stdout + res.stderr assert "BB 0x808" in res.stdout @@ -60,7 +69,7 @@ def test_ephemeral_mac_counter_masked(tmp_path: Path) -> None: """MAC 0x550 is a beacon-window counter; differences should be masked out and exit 0.""" kernel = wrap("MAC 0x550 = 0x00001019") - devourer = wrap("MAC 0x550 = 0x00001010") + devourer = dwrap("MAC 0x550 = 0x00001010") res = run_diff(kernel, devourer, tmp_path=tmp_path) assert res.returncode == 0, res.stdout + res.stderr @@ -68,7 +77,7 @@ def test_ephemeral_mac_counter_masked(tmp_path: Path) -> None: def test_ephemeral_rf_thermal_masked(tmp_path: Path) -> None: """RF[A] 0x42 is the thermal-meter sample register.""" kernel = wrap("RF[A] 0x42 = 0x0B160") - devourer = wrap("RF[A] 0x42 = 0x098F8") + devourer = dwrap("RF[A] 0x42 = 0x098F8") res = run_diff(kernel, devourer, tmp_path=tmp_path) assert res.returncode == 0, res.stdout + res.stderr @@ -238,22 +247,35 @@ def test_empty_file_exits_two(tmp_path: Path) -> None: def test_lines_outside_envelope_ignored(tmp_path: Path) -> None: - """Devourer logs `...` lines that the awk extractor - strips. If extra noise leaks in via a different path, the parser - should still only consider the envelope.""" + """A raw devourer stderr capture carries other `devourer [X] ` + diagnostic lines around the dump envelope. The parser should + strip the prefix but still only consider the envelope.""" kernel = wrap("BB 0x808 = 0x3E028233") # Extra junk before + after the envelope. devourer = ( - "spurious log line that should be ignored\n" - "BB 0x808 = 0xDEADBEEF\n" # outside envelope — ignored - "MAC 0x040 = 0xCAFEBABE\n" # outside envelope — ignored - + wrap("BB 0x808 = 0x3E028233") + "devourer [I] spurious diagnostic that should be ignored\n" + "devourer [W] BB 0x808 = 0xDEADBEEF\n" # outside envelope — ignored + "MAC 0x040 = 0xCAFEBABE\n" # outside envelope — ignored + + dwrap("BB 0x808 = 0x3E028233") + "trailing junk\n" ) res = run_diff(kernel, devourer, tmp_path=tmp_path) assert res.returncode == 0, res.stdout + res.stderr +def test_prefix_stripped_any_level_letter(tmp_path: Path) -> None: + """Prefix stripping is liberal: any `devourer [X] ` level letter + (T/D/I/W/E) parses, and pre-stripped devourer captures still work.""" + kernel = wrap("BB 0x808 = 0x3E028233") + devourer = ( + "devourer [D] === DEVOURER_DUMP_CANARY (post channel-set ch=6) ===\n" + "devourer [T] BB 0x808 = 0x3E028233\n" + "devourer [D] === END DEVOURER_DUMP_CANARY ===\n" + ) + res = run_diff(kernel, devourer, tmp_path=tmp_path) + assert res.returncode == 0, res.stdout + res.stderr + + def test_show_clean_emits_clean_line(tmp_path: Path) -> None: canary = wrap("BB 0x808 = 0x3E028233") res = run_diff(canary, canary, "--show-clean", tmp_path=tmp_path) diff --git a/tests/thermal_gain_sweep.py b/tests/thermal_gain_sweep.py index a16e843..867c98d 100755 --- a/tests/thermal_gain_sweep.py +++ b/tests/thermal_gain_sweep.py @@ -5,8 +5,8 @@ ramping the absolute TXAGC index up over time (DEVOURER_TX_PWR_* knobs), and captures three interleaved streams: - * `index=N` — the gain step the demo just applied - * `raw=.. delta=..` — the chip's own thermal meter + * `txpwr.set` events (index=N) — the gain step the demo just applied + * `thermal` events (raw/delta/status) — the chip's own thermal meter * `sdr-power dbfs=..` — an independent USRP receive-power probe Every line from both subprocesses is stamped with a host-side monotonic @@ -38,11 +38,8 @@ from pathlib import Path import regress # reuse DUT discovery + process-hygiene helpers +from devourer_events import parse_event -TXPWR_RE = re.compile(r"index=(\d+)") -THERMAL_RE = re.compile( - r"raw=(\d+) baseline=(\d+|none)(?: delta=([+-]?\d+))? status=(\w+)" -) SDR_RE = re.compile(r"sdr-power dbfs=([+-]?[\d.]+)") # UHD channel-center frequencies (MHz) for the channels we use. @@ -164,21 +161,21 @@ def reader(proc: subprocess.Popen, source: str): line = raw_line.rstrip("\n") t = now_ms() s = None - m = TXPWR_RE.search(line) - if m: + ev = parse_event(line) + if ev is not None and ev["ev"] == "txpwr.set": s = Sample(t, "txpwr") - s.index = int(m.group(1)) - if s is None: - m = THERMAL_RE.search(line) - if m: - s = Sample(t, "thermal") - s.raw = int(m.group(1)) - s.baseline = None if m.group(2) == "none" else int(m.group(2)) - s.delta = int(m.group(3)) if m.group(3) is not None else None - s.status = m.group(4) - if s.status == "critical": - print(f"\n!! thermal critical at t={t/1000:.1f}s — aborting TX") - abort.set() + s.index = int(ev["index"]) + elif ev is not None and ev["ev"] == "thermal": + s = Sample(t, "thermal") + s.raw = int(ev["raw"]) + baseline = ev.get("baseline") + s.baseline = None if baseline is None else int(baseline) + delta = ev.get("delta") # present only when baseline valid + s.delta = None if delta is None else int(delta) + s.status = ev.get("status") + if s.status == "critical": + print(f"\n!! thermal critical at t={t/1000:.1f}s — aborting TX") + abort.set() if s is None: m = SDR_RE.search(line) if m: @@ -280,7 +277,7 @@ def _summarize(rows: list[Sample]) -> None: if not per_index: print("no gain-index transitions captured (did the demo emit " - "?)") + "txpwr.set events?)") return print("\n=== per gain-index summary ===") diff --git a/tests/thermal_hwcheck.sh b/tests/thermal_hwcheck.sh index c0c047a..b49e933 100755 --- a/tests/thermal_hwcheck.sh +++ b/tests/thermal_hwcheck.sh @@ -2,9 +2,10 @@ # Hardware smoke-test for the thermal monitor probe. # # Runs txdemo against each plugged Jaguar adapter for a few seconds -# with DEVOURER_THERMAL_POLL_MS enabled, and prints the -# lines it emits. Read-only w.r.t. the probe — this just confirms the thermal -# meter reads back a live, plausible value per chip. +# with DEVOURER_THERMAL_POLL_MS enabled, and prints the thermal event +# lines it emits ({"ev":"thermal",...} on stdout). Read-only w.r.t. the +# probe — this just confirms the thermal meter reads back a live, +# plausible value per chip. # # Usage: sudo tests/thermal_hwcheck.sh set -u @@ -67,10 +68,12 @@ run_one() { CHILD_PID="" echo "--- thermal monitor lines ---" - grep -E "|thermal:|ThermalMeter|thermal monitor on" "$log" | head -20 + # thermal events land on stdout; the human diagnostics (devourer [I]/[W] + # "thermal: ...") on stderr — the log captures both (2>&1). + grep -E '"ev":"thermal"|thermal:|ThermalMeter|thermal monitor on' "$log" | head -20 local n - n="$(grep -c "" "$log")" - echo "--- ($n lines total) ---" + n="$(grep -c -F '"ev":"thermal"' "$log")" + echo "--- ($n thermal event lines total) ---" if [[ "$n" -eq 0 ]]; then echo " no thermal lines — tail of log for context:" tail -15 "$log" | sed 's/^/ /' diff --git a/tests/tx_stats_congestion.sh b/tests/tx_stats_congestion.sh index 57af169..dc4b56d 100755 --- a/tests/tx_stats_congestion.sh +++ b/tests/tx_stats_congestion.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Validate GetTxStats () — the driver-drop / congestion signal. +# Validate GetTxStats (the tx.stats event) — the driver-drop / congestion signal. # Back-to-back TX (DEVOURER_TX_GAP_US=0) fills the on-chip TX FIFO, so the USB # bulk-OUT starts NAKing (LIBUSB_ERROR_TIMEOUT / LIBUSB_TRANSFER_TIMED_OUT) and # `failed` climbs with was_timeout=1 — the recoverable back-pressure an adaptive @@ -42,8 +42,8 @@ run_cell() { # $1=pid $2=vid $3=gap $4=tag DEVOURER_TX_RATE=MCS3 DEVOURER_TX_GAP_US="$3" \ timeout "$DUR" "$ROOT/build/txdemo" >"$log" 2>&1 || true local line - line=$(grep "" "$log" | tail -1) - echo "$line" | sed -E 's/.*submitted=([0-9]+) failed=([0-9]+) was_timeout=([0-9]+) last_rc=(-?[0-9]+).*/\1 \2 \3 \4/' + line=$(grep -F '"ev":"tx.stats"' "$log" | tail -1) + echo "$line" | sed -E 's/.*"submitted":([0-9]+),"failed":([0-9]+),"was_timeout":([0-9]+),"last_rc":(-?[0-9]+).*/\1 \2 \3 \4/' } for dut in "${DUTS[@]}"; do diff --git a/tests/txbf_apply_onair.sh b/tests/txbf_apply_onair.sh index d90b56f..753843e 100755 --- a/tests/txbf_apply_onair.sh +++ b/tests/txbf_apply_onair.sh @@ -7,7 +7,7 @@ # from its RX loop the moment it ingests the peer's Compressed Beamforming # Report (the CBR gate; a blind apply with no V degrades the link). # B (beamformee + sensor, 8822CU): responds to the NDPA with a CBR and measures -# A's frames via GetRxQuality (). +# A's frames via GetRxQuality ("ev":"rx.quality" events). # A/B: apply OFF (no DEVOURER_BF_TXBF) vs ON. PASS = ON lifts B's snr/rssi and # A logs "TXBF apply ENABLED". Low TX power dodges the near-field RSSI ceiling. # @@ -50,7 +50,7 @@ run_cell() { sudo -n env DEVOURER_PID="$B_PID" DEVOURER_VID="$B_VID" DEVOURER_CHANNEL="$CH" \ DEVOURER_BF_ARM_BFEE="$BFER" DEVOURER_RXQUALITY=1 DEVOURER_RX_ENERGY_MS=1000 \ timeout $((DUR + 20)) "$ROOT/build/rxdemo" 2>&1 \ - | tee "$OUT/$tag-b.full.log" | grep --line-buffered devourer-rxquality \ + | tee "$OUT/$tag-b.full.log" | grep --line-buffered -F '"ev":"rx.quality"' \ >"$OUT/$tag-b.log" & local bpid=$! # Jaguar3 beamformee init is long (DLFW + cals ~10-15s) — wait for the RX @@ -72,15 +72,17 @@ run_cell() { sleep 2 # median rssi_mean / snr_mean over settled windows (frames>0) python3 - "$OUT/$tag-b.log" <<'PYEOF' -import re, statistics, sys +import json, statistics, sys r, s = [], [] for line in open(sys.argv[1], errors="replace"): - fr = re.search(r"frames=(\d+)", line) - if not fr or int(fr.group(1)) == 0: continue - m = re.search(r"rssi_mean_dbm=(-?\d+)", line) - n = re.search(r"snr_mean_db=(-?\d+\.?\d*)", line) - if m: r.append(int(m.group(1))) - if n: s.append(float(n.group(1))) + try: + ev = json.loads(line) + except ValueError: + continue + if ev.get("ev") != "rx.quality" or not ev.get("frames"): + continue + if ev.get("rssi_mean_dbm") is not None: r.append(float(ev["rssi_mean_dbm"])) + if ev.get("snr_mean_db") is not None: s.append(float(ev["snr_mean_db"])) def med(a): return statistics.median(a) if a else float("nan") print(f"{med(r):.1f} {med(s):.1f}") PYEOF diff --git a/tests/txpkt_pwr_ofset_onair.sh b/tests/txpkt_pwr_ofset_onair.sh index 3662aa0..08c9166 100755 --- a/tests/txpkt_pwr_ofset_onair.sh +++ b/tests/txpkt_pwr_ofset_onair.sh @@ -83,7 +83,7 @@ sudo -n pkill -x rxdemo 2>/dev/null; wait "$GJ2" 2>/dev/null python3 - "$OUT/ground.log" "$OUT/cells.txt" <<'PYEOF' import re, statistics, sys frames = [] -rx = re.compile(r"^([0-9.]+) .*.*\brssi=(-?\d+),") +rx = re.compile(r'^([0-9.]+) .*"ev":"rx\.frame".*"rssi":\[(-?\d+),') for line in open(sys.argv[1], errors="replace"): m = rx.match(line) if m: diff --git a/tests/txpwr_offset_onair.sh b/tests/txpwr_offset_onair.sh index 9aa4bd2..b03220f 100755 --- a/tests/txpwr_offset_onair.sh +++ b/tests/txpwr_offset_onair.sh @@ -120,14 +120,14 @@ for dut in "${DUTS[@]}"; do wait "$GROUND_JOB" 2>/dev/null # Slope fit: per cell, median ground RSSI (chain A) of the canonical-SA - # stream lines in [t0+6, t1-1] (bring-up transmits nothing at first). + # rx.frame events in [t0+6, t1-1] (bring-up transmits nothing at first). python3 - "$OUT/$tag-ground.log" "$OUT/$tag-cells.txt" "$NOMINAL" >"$OUT/$tag-fit.txt" 2>&1 <<'PYEOF' import re, statistics, sys ground_log, cells_txt = sys.argv[1], sys.argv[2] tssi = sys.argv[3] == "tssi" nominal = None if tssi else float(sys.argv[3]) frames = [] -rx = re.compile(r"^([0-9.]+) .*.*\brssi=(-?\d+),(-?\d+)") +rx = re.compile(r'^([0-9.]+) .*"ev":"rx\.frame".*"rssi":\[(-?\d+),(-?\d+)\]') for line in open(ground_log, errors="replace"): m = rx.match(line) if m: diff --git a/tests/txpwr_offset_regcheck.sh b/tests/txpwr_offset_regcheck.sh index 5cf4f40..ff517f2 100755 --- a/tests/txpwr_offset_regcheck.sh +++ b/tests/txpwr_offset_regcheck.sh @@ -58,17 +58,14 @@ DUTS=( plugged() { lsusb -d "$(printf '%04x:%04x' "$2" "$1")" >/dev/null 2>&1; } # --- helpers --------------------------------------------------------------- -# Extract field F from the Nth line of a log. +# Extract numeric field F from the Nth txpwr.state event line of a log. state_field() { # $1=log $2=line-index(1-based) $3=field - awk -v n="$2" -v f="$3" '// { - c++; if (c==n) { sub(/^.*/,""); - for (i=1;i<=NF;i++) { split($i,kv,"="); - if (kv[1]==f) print kv[2]; } } }' "$1" | tr -d '\r' + grep -F '"ev":"txpwr.state"' "$1" | sed -n "$2p" \ + | grep -o "\"$3\":-\?[0-9]*" | cut -d: -f2 | tr -d '\r' } caps_field() { # $1=log $2=field - awk -v f="$2" '// { sub(/^.*/,""); - for (i=1;i<=NF;i++) { split($i,kv,"="); - if (kv[1]==f) { print kv[2]; exit } } }' "$1" | tr -d '\r' + grep -F '"ev":"txpwr.caps"' "$1" | head -1 \ + | grep -o "\"$2\":-\?[0-9]*" | cut -d: -f2 | tr -d '\r' } run_step_demo() { # $1=outfile, rest = args @@ -76,6 +73,10 @@ run_step_demo() { # $1=outfile, rest = args sudo -n timeout 90 "$STEP_DEMO" "$@" >"$out" 2>&1 } +# The canary dump stays "KIND 0xADDR = 0xVALUE" text, but now arrives as +# stderr diagnostics ("devourer [I] " prefix; master's build still emits the +# old "" stdout form). The grep -oE extraction is prefix-agnostic, +# so one extractor serves both builds — run_canary captures 2>&1. last_canary() { # $1 = log file (same extractor as hop_parity_check.sh) awk '/=== DEVOURER_DUMP_CANARY/{buf=""} {buf=buf $0 "\n"} /=== END DEVOURER_DUMP_CANARY/{last=buf} END{printf "%s", last}' "$1" \ @@ -199,10 +200,10 @@ for dut in "${DUTS[@]}"; do # -- thermal plausibility -------------------------------------------------- th="$OUT/$tag-thermal.log" run_step_demo "$th" --vid "$VID" --pid "$PID" --channel "$CH_A" --thermal - raw="$(awk '// { sub(/^.*/,""); - for (i=1;i<=NF;i++) { split($i,kv,"="); if (kv[1]=="raw") { print kv[2]; exit } } }' "$th" | tr -d '\r')" + raw="$(grep -F '"ev":"thermal"' "$th" | head -1 \ + | grep -o '"raw":[0-9-]*' | cut -d: -f2 | tr -d '\r')" if [ -n "$raw" ] && [ "$raw" -ge 1 ] 2>/dev/null && [ "$raw" -le 63 ]; then - pass "$name thermal: raw=$raw plausible (baseline=$(awk '// { sub(/^.*/,""); for (i=1;i<=NF;i++) { split($i,kv,"="); if (kv[1]=="baseline") { print kv[2]; exit } } }' "$th"))" + pass "$name thermal: raw=$raw plausible (baseline=$(grep -F '"ev":"thermal"' "$th" | head -1 | grep -o '"baseline":[^,}]*' | cut -d: -f2))" else fail "$name thermal: raw='$raw' implausible/missing" fi diff --git a/tests/validate_jaguar3_physts.sh b/tests/validate_jaguar3_physts.sh index c3b17ec..52c65ed 100755 --- a/tests/validate_jaguar3_physts.sh +++ b/tests/validate_jaguar3_physts.sh @@ -8,8 +8,8 @@ # # Rig: one known-good TX adapter (default 8812AU) beacons the canonical SA # (57:42:75:05:d6:00) at HT MCS1 on ch6; each Jaguar3 RX adapter runs -# rxdemo with DEVOURER_DUMP_BODY=1 and we assert the -# lines (canonical-SA hits) show non-zero rssi AND snr (and evm for MCS/HT). +# rxdemo with DEVOURER_DUMP_BODY=1 and we assert the "ev":"rx.body" +# events (canonical-SA hits) show non-zero rssi AND snr (and evm for MCS/HT). # # sudo ./tests/validate_jaguar3_physts.sh # TX_PID=0x8812 CH=6 RATE=MCS1 ./tests/validate_jaguar3_physts.sh @@ -50,20 +50,20 @@ for pid in ${RX_PIDS[@]}; do DEVOURER_DUMP_BODY=1 \ timeout "$SECS" "$ROOT/build/rxdemo" >"$log" 2>&1 || true - hits=$(grep -c "" "$log" || true) - body=$(grep "" "$log" || true) - echo " tx-hits=$hits body-lines=$(printf '%s\n' "$body" | grep -c body= || true)" + hits=$(grep -Fc '"ev":"rx.txhit"' "$log" || true) + body=$(grep -F '"ev":"rx.body"' "$log" || true) + echo " tx-hits=$hits body-lines=$(printf '%s\n' "$body" | grep -c '"body":' || true)" if [ -z "$body" ]; then - echo " FAIL: no canonical-SA frames decoded (link/TX?)" + echo " FAIL: no canonical-SA rx.body frames decoded (link/TX?)" overall=1 continue fi - printf '%s\n' "$body" | head -3 | sed 's/body=.*/body=.../; s/^/ /' + printf '%s\n' "$body" | head -3 | sed 's/"body":.*/"body":.../; s/^/ /' # Assert at least one body frame has non-zero rssi AND non-zero snr. ok=$(printf '%s\n' "$body" | awk ' - match($0, /rssi=(-?[0-9]+),(-?[0-9]+)/, r) && - match($0, /snr=(-?[0-9]+),(-?[0-9]+)/, s) { + match($0, /"rssi":\[(-?[0-9]+),(-?[0-9]+)/, r) && + match($0, /"snr":\[(-?[0-9]+),(-?[0-9]+)/, s) { if ((r[1]+0 != 0 || r[2]+0 != 0) && (s[1]+0 != 0 || s[2]+0 != 0)) n++ } END { print n+0 }') echo " frames with non-zero rssi & snr: $ok" diff --git a/tests/verify_stream_fields.sh b/tests/verify_stream_fields.sh index 1b688ef..ac4e9aa 100644 --- a/tests/verify_stream_fields.sh +++ b/tests/verify_stream_fields.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# M0 verification: confirm the RX line now carries the decoded +# M0 verification: confirm the rx.frame RX event now carries the decoded # PHY descriptor fields (bw/stbc/ldpc/sgi) added in examples/rx/main.cpp. # # Two-adapter loopback, NO SDR: an 8821AU transmits the canonical-SA beacon # (txdemo) while an 8812AU receives with DEVOURER_STREAM_OUT=1. The -# stream line only fires on canonical-SA frames, so a devourer TX is required. +# rx.frame event only fires on canonical-SA frames, so a devourer TX is required. # # TX: RTL8821AU (TP-Link Archer T2U Plus, 2357:0120) --ch6--> air # RX: RTL8812AU (0bda:8812), DEVOURER_STREAM_OUT=1 @@ -35,12 +35,12 @@ echo "[verify] starting 8812AU RX (STREAM_OUT) for 30s ..." timeout 30 env DEVOURER_PID=$RXPID DEVOURER_CHANNEL=$CH DEVOURER_STREAM_OUT=1 \ "$BIN/rxdemo" 2>/dev/null >"$OUT" -N=$(grep -c devourer-stream "$OUT") -echo "[verify] lines: $N" +N=$(grep -cF '"ev":"rx.frame"' "$OUT") +echo "[verify] rx.frame events: $N" if [ "$N" -gt 0 ]; then echo "[verify] sample (body truncated):" - grep -m2 devourer-stream "$OUT" | sed 's/body=.*/body=.../' - if grep -q "bw=.*stbc=.*ldpc=.*sgi=" "$OUT"; then + grep -m2 -F '"ev":"rx.frame"' "$OUT" | sed 's/"body":.*/"body":"..."}/' + if grep -q '"bw":.*"stbc":.*"ldpc":.*"sgi":' "$OUT"; then echo "[verify] RESULT: PASS — bw/stbc/ldpc/sgi present in stream line" exit 0 fi diff --git a/tools/bf_report_decode.py b/tools/bf_report_decode.py index 4fe828a..7efb4bf 100644 --- a/tools/bf_report_decode.py +++ b/tools/bf_report_decode.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 """Decode 802.11ac VHT Compressed Beamforming reports into per-subcarrier CSI. -Input: `HEX` lines (from rxdemo with -DEVOURER_BF_DETECT_REPORT=4) on stdin or a file, or bare hex frames. +Input: `{"ev":"bf.report_raw","frame":"HEX"}` JSONL events (from rxdemo with +DEVOURER_BF_DETECT_REPORT=4; the sense demo emits them on stderr) on stdin or +a file, or bare hex frames. Non-event lines that aren't hex are skipped, so +feeding a whole mixed capture works. For the two-adapter self-sounding path a 2-TX beamformer sounds a 1-RX beamformee, so each report carries a per-subcarrier 2x1 steering vector @@ -24,10 +26,12 @@ Usage: rxdemo ... DEVOURER_BF_DETECT_REPORT=4 | tools/bf_report_decode.py tools/bf_report_decode.py captured_frames.txt --csv out.csv + grep -F '"ev":"bf.report_raw"' capture.txt | tools/bf_report_decode.py """ from __future__ import annotations import argparse +import json import math import sys @@ -179,14 +183,32 @@ def decode_angles(angle_bytes: bytes, ns: int, na: int, bphi: int, bpsi: int, return out +def report_hex(line: str): + """Hex payload of one input line: a `bf.report_raw` event's `frame` field, + or the line itself when it's bare hex. Returns None for any other event + line (other-event JSON must not fall through to the hex parser).""" + line = line.strip() + if line.startswith('{"ev":"'): + if not line.startswith('{"ev":"bf.report_raw"'): + return None + try: + obj = json.loads(line) + except ValueError: + return None + if not isinstance(obj, dict) or obj.get("ev") != "bf.report_raw": + return None + return obj.get("frame") + return line + + def read_frames(src, max_frames=200): - """Parse `` (or bare hex) lines into frame dicts.""" + """Parse `bf.report_raw` event (or bare hex) lines into frame dicts.""" frames = [] for line in src: - line = line.strip() - if "" in line: - line = line.split("", 1)[1] - f = parse_frame(line) + h = report_hex(line) + if h is None: + continue + f = parse_frame(h) if f: frames.append(f) if len(frames) >= max_frames: diff --git a/tools/bf_waterfall.py b/tools/bf_waterfall.py index af62d47..8ce9820 100644 --- a/tools/bf_waterfall.py +++ b/tools/bf_waterfall.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """Live per-subcarrier waterfall from beamforming self-sounding reports. -Pipe rxdemo's raw report stream into this for a scrolling, truecolor -per-tone spectrogram in the terminal — the channel across frequency (X) and -time (Y): +Pipe rxdemo's event stream (`{"ev":"bf.report_raw",...}` JSONL) into this for +a scrolling, truecolor per-tone spectrogram in the terminal — the channel +across frequency (X) and time (Y). Non-event lines are skipped: rxdemo ... DEVOURER_BF_DETECT_REPORT=4 | tools/bf_waterfall.py @@ -141,9 +141,10 @@ def flush_row(): try: for line in sys.stdin: - if "" in line: - line = line.split("", 1)[1] - f = bf.parse_frame(line.strip()) + h = bf.report_hex(line) + if h is None: + continue + f = bf.parse_frame(h) if not f: continue if ns is None: @@ -153,7 +154,6 @@ def flush_row(): vbytes = (ns * 10 + 7) // 8 # extract this report's per-tone series if mode == "MU": - f["raw"] = bytes.fromhex(line.strip()) if "raw" not in f else f["raw"] s = bf.parse_mu_snr(f, ns, vbytes) else: # SU: reuse the decoder's fixed compact split (bphi=6,bpsi=4) diff --git a/tools/bf_waterfall_svg.py b/tools/bf_waterfall_svg.py index ab769b4..69d5772 100644 --- a/tools/bf_waterfall_svg.py +++ b/tools/bf_waterfall_svg.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Render a per-subcarrier SNR waterfall from captured MU beamforming reports -to a standalone SVG (for docs — GitHub renders it, and it stays diff-able). +(`{"ev":"bf.report_raw",...}` JSONL events, or bare hex lines) to a standalone +SVG (for docs — GitHub renders it, and it stays diff-able). tools/bf_waterfall_svg.py capture.txt -o docs/img/bf_waterfall.svg \ --operating-snr 28 @@ -32,9 +33,10 @@ def main() -> int: frames = [] for line in open(args.infile): - if "" in line: - line = line.split("", 1)[1] - f = bf.parse_frame(line.strip()) + h = bf.report_hex(line) + if h is None: + continue + f = bf.parse_frame(h) if f and f["feedback"]: frames.append(f) if not frames: diff --git a/tools/canary_kernel_dump.sh b/tools/canary_kernel_dump.sh index 8f7e667..ac215ce 100755 --- a/tools/canary_kernel_dump.sh +++ b/tools/canary_kernel_dump.sh @@ -14,14 +14,16 @@ # # Usage: # sudo tools/canary_kernel_dump.sh > /tmp/krn.canary -# # then on devourer side: +# # then on devourer side (the canary is a stderr diagnostic, prefixed +# # "devourer [I] " — hence the 2>&1 and the prefix strip): # sudo env DEVOURER_VID=... DEVOURER_PID=... DEVOURER_CHANNEL= \ # DEVOURER_DUMP_CANARY=1 ./build/txdemo 2>&1 \ # | awk '/DEVOURER_DUMP_CANARY \(post channel-set ch=\)/, # /END DEVOURER_DUMP_CANARY/' \ -# | sed 's/^//' \ +# | sed -E 's/^devourer \[[A-Z]\] //' \ # > /tmp/dev.canary # diff /tmp/krn.canary /tmp/dev.canary +# # (tests/canary_diff.py strips the prefix itself — raw captures are fine.) # # Iface is the wlx... or wlan? name the kernel driver enumerated. Run # `iw dev` to find it after `modprobe 88XXau`. diff --git a/tools/precoder/README.md b/tools/precoder/README.md index 6aab5de..9c089cd 100644 --- a/tools/precoder/README.md +++ b/tools/precoder/README.md @@ -204,9 +204,9 @@ the save→write→read→restore dance for each selector on the first 8 canonical-SA frames and emits ``` -hit=1 selector=0x0000001a value=0x???????? -hit=1 selector=0x00000020 value=0x???????? -hit=1 selector=0x00000040 value=0x???????? +{"ev":"csi.hit","hit":1,"selector":"0x0000001a","value":"0x????????"} +{"ev":"csi.hit","hit":1,"selector":"0x00000020","value":"0x????????"} +{"ev":"csi.hit","hit":1,"selector":"0x00000040","value":"0x????????"} ... ``` @@ -218,15 +218,15 @@ in a single u32, with selector encoding the subcarrier index in its low bits — that's the upstream phydm convention, but unverified here. -`tools/precoder/csi_dump.py` parses `` lines and reports +`tools/precoder/csi_dump.py` parses `csi.hit` events and reports which selectors changed across frames, which stayed constant, and the per-bit toggle ratio (high = bus, low = status flag). #### Brick recovery if the chip stalls after a sweep -Symptoms: RX stops producing `RX pkt` lines, control transfers +Symptoms: RX stops producing `rx.pkt` events, control transfers still succeed. The `BbDbgportReader` auto-detects this via a SYS_CFG -sanity read after every selector and self-wedges — `` +sanity read after every selector and self-wedges — `csi.wedged` on stdout means subsequent selectors were skipped. Recovery ladder (each step is a fresh process invocation): diff --git a/tools/precoder/adaptive_link.py b/tools/precoder/adaptive_link.py index a46a7aa..7cbfe46 100644 --- a/tools/precoder/adaptive_link.py +++ b/tools/precoder/adaptive_link.py @@ -4,8 +4,8 @@ This is the policy plane (the mechanical knobs live in the C++ duplex binary). The two roles: - AdaptiveVrx (ground): consumes the VTX's video frames ( RSSI/ - SNR/crc/seq), scores the link, runs the energy-min controller to pick the + AdaptiveVrx (ground): consumes the VTX's video frames (`rx.frame` events: + RSSI/SNR/crc/seq), scores the link, runs the energy-min controller to pick the operating point, and emits RCF feedback (or DISC beacons when the VTX is lost) ~10 Hz. AdaptiveVtx (drone): consumes RCF/DISC, applies the operating point to the live @@ -251,13 +251,12 @@ def selftest(verbose: bool = False): # Live I/O: drive a real duplex subprocess (exercised on-air by # tests/adaptive_onair.sh). The classes above hold all the policy; this is plumbing. # --------------------------------------------------------------------------- # -import re import struct +import sys +from pathlib import Path -_STREAM_RE = re.compile( - rb"rate=(?P\d+).*?crc_err=(?P\d+).*?" - rb"rssi=(?P-?\d+),(?P-?\d+).*?snr=(?P-?\d+),(?P-?\d+).*?" - rb"seq=(?P\d+).*?body=(?P[0-9a-fA-F]*)") +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tests")) +from devourer_events import parse_event # noqa: E402 def ctl_frame(op: int, payload: bytes = b"") -> bytes: @@ -287,23 +286,22 @@ def run_vrx(proc, link, calib, vtx_id, channel, feedback_period_ms=100): def reader(): for line in proc.stdout: - m = _STREAM_RE.search(line) - if not m: + ev = parse_event(line, ev="rx.frame") + if ev is None: continue - body = bytes.fromhex(m.group("body").decode()) + body = bytes.fromhex(ev.get("body") or "") if rp.frame_type(body) == rp.T_DISC_ACK: vrx.on_disc_ack(body, _now_ms()) continue - snr = max(int(m.group("snr")), int(m.group("snr2"))) - rssi = max(int(m.group("rssi")), int(m.group("rssi2"))) + snr = max(ev["snr"]) + rssi = max(ev["rssi"]) # App-level sequence: the VTX prefixes a 12-bit per-video-frame counter, - # so gap-based loss is EXACT. (The 802.11 seq=... advances per transmitted + # so gap-based loss is EXACT. (The 802.11 seq advances per transmitted # frame incl. retries, so it over-counts loss — see score.seq_gap_loss.) seq = (int.from_bytes(body[:2], "little") & 0xFFF - if len(body) >= 2 else int(m.group("seq"))) - vrx.on_video(rssi, snr, int(m.group("crc")) != 0, seq, _now_ms()) + if len(body) >= 2 else ev["seq"]) + vrx.on_video(rssi, snr, ev["crc"] != 0, seq, _now_ms()) threading.Thread(target=reader, daemon=True).start() - import sys import time last_log = 0.0 while True: @@ -331,10 +329,10 @@ def run_vtx(proc, vtx_id, video_path, channel): def reader(): for line in proc.stdout: - m = _STREAM_RE.search(line) - if not m: + ev = parse_event(line, ev="rx.frame") + if ev is None: continue - body = bytes.fromhex(m.group("body").decode()) + body = bytes.fromhex(ev.get("body") or "") t = rp.frame_type(body) if t not in (rp.T_RCF, rp.T_DISC): continue diff --git a/tools/precoder/corruption_analysis.py b/tools/precoder/corruption_analysis.py index 746d485..82ab2ed 100644 --- a/tools/precoder/corruption_analysis.py +++ b/tools/precoder/corruption_analysis.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Corruption analysis for the precoder stream link. -Reads `` lines on stdin (typically piped from +Reads `rx.frame` JSONL events on stdin (typically piped from `rxdemo` with both `DEVOURER_STREAM_OUT=1` and `DEVOURER_RX_KEEP_CORRUPTED=1`), reconstructs what each received body *should* have been from a known source file, and reports byte/bit-level @@ -17,7 +17,7 @@ The TX side encodes `source.bin` deterministically into N body frames. RX captures every body matching the canonical SA, including those the chip -flagged with crc_err / icv_err (without `KEEP_CORRUPTED` the parser drops +flagged with crc / icv (without `KEEP_CORRUPTED` the parser drops them; with it on they reach us with the descriptor flags set). For each captured body we: @@ -47,7 +47,6 @@ import argparse import collections import os -import re import sys from pathlib import Path from typing import Optional @@ -55,25 +54,10 @@ _HERE = Path(__file__).resolve().parent if str(_HERE) not in sys.path: sys.path.insert(0, str(_HERE)) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tests")) import stream # noqa: E402 - -_STREAM_RE = re.compile( - r"rate=(?P\d+)\s+len=(?P\d+)" - r"(?:\s+crc_err=(?P\d+))?" - r"(?:\s+icv_err=(?P\d+))?" - r"(?:\s+rssi=(?P-?\d+,-?\d+))?" - r"(?:\s+evm=(?P-?\d+,-?\d+))?" - r"(?:\s+snr=(?P-?\d+,-?\d+))?" - r"\s+body=(?P[0-9a-fA-F]*)" -) - - -def _parse_pair(s: Optional[str]) -> Optional[tuple[int, int]]: - if not s: - return None - a, b = s.split(",") - return int(a), int(b) +from devourer_events import parse_event # noqa: E402 def _effective_snr(snr: Optional[tuple[int, int]]) -> Optional[int]: @@ -160,13 +144,13 @@ def main(argv: Optional[list[str]] = None) -> int: bucket_bits_compared: collections.Counter = collections.Counter() for line in sys.stdin: - m = _STREAM_RE.search(line) - if not m: + ev = parse_event(line, ev="rx.frame") + if ev is None: continue total_captured += 1 - crc_err = int(m.group("crc_err") or 0) - icv_err = int(m.group("icv_err") or 0) - snr = _parse_pair(m.group("snr")) + crc_err = int(ev.get("crc") or 0) + icv_err = int(ev.get("icv") or 0) + snr = tuple(ev["snr"]) if ev.get("snr") else None eff = _effective_snr(snr) if eff is not None: (snr_corrupt if crc_err or icv_err else snr_clean).append(eff) @@ -174,7 +158,7 @@ def main(argv: Optional[list[str]] = None) -> int: total_corrupted += 1 else: total_clean += 1 - body = bytes.fromhex(m.group("hex")) + body = bytes.fromhex(ev.get("body") or "") if len(body) < stream.HEADER_LEN: unmatched_seq += 1 continue @@ -219,7 +203,7 @@ def main(argv: Optional[list[str]] = None) -> int: f"{total_captured} captured) ===") print(f"captured : {total_captured}") print(f" chip-clean : {total_clean}") - print(f" chip-corrupt : {total_corrupted} (crc_err or icv_err set)") + print(f" chip-corrupt : {total_corrupted} (crc or icv set)") print(f"matched seq : {matched_seq}") print(f"unmatched seq : {unmatched_seq} (likely lost, foreign, or " f"seq-bytes corrupted)") diff --git a/tools/precoder/corruption_survey.py b/tools/precoder/corruption_survey.py index b85c93e..4e6b6d6 100644 --- a/tools/precoder/corruption_survey.py +++ b/tools/precoder/corruption_survey.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Corruption-pattern survey for FEC design. -Reads `` lines (emitted by `rxdemo` with +Reads `rx.corrupt` JSONL events (emitted by `rxdemo` with `DEVOURER_RX_DUMP_ALL=1` + `DEVOURER_RX_KEEP_CORRUPTED=1`) and aggregates descriptive statistics about the corruption rate, frame-size distribution, and link-quality distribution — the empirical inputs for choosing the @@ -45,21 +45,24 @@ import argparse import collections -import re +import json import select import statistics import sys import time from typing import Optional -_CORRUPT_ANY_RE = re.compile( - r"len=(?P\d+)\s+" - r"crc_err=(?P\d+)\s+icv_err=(?P\d+)\s+" - r"rate=(?P\d+)\s+" - r"rssi=(?P-?\d+),(?P-?\d+)\s+" - r"evm=(?P-?\d+),(?P-?\d+)\s+" - r"snr=(?P-?\d+),(?P-?\d+)" -) + +def _parse_corrupt(line: str) -> Optional[dict]: + """Parse one `rx.corrupt` event line; None for anything else.""" + line = line.strip() + if not line.startswith('{"ev":"rx.corrupt"'): + return None + try: + ev = json.loads(line) + except ValueError: + return None + return ev if isinstance(ev, dict) and ev.get("ev") == "rx.corrupt" else None def _len_bucket(n: int) -> str: @@ -130,21 +133,21 @@ def main(argv: Optional[list[str]] = None) -> int: no_snr = 0 for line in sys.stdin: - m = _CORRUPT_ANY_RE.search(line) - if not m: + ev = _parse_corrupt(line) + if ev is None: continue now = time.monotonic() if deadline is not None and now > deadline: break total += 1 - crc_err = int(m.group("crc_err")) - icv_err = int(m.group("icv_err")) + crc_err = int(ev["crc"]) + icv_err = int(ev["icv"]) corrupted = bool(crc_err or icv_err) - plen = int(m.group("len")) - rate = int(m.group("rate")) - rssi_a = int(m.group("rssi_a")); rssi_b = int(m.group("rssi_b")) - evm_a = int(m.group("evm_a")); evm_b = int(m.group("evm_b")) - snr_a = int(m.group("snr_a")); snr_b = int(m.group("snr_b")) + plen = int(ev["len"]) + rate = int(ev["rate"]) + rssi_a, rssi_b = int(ev["rssi"][0]), int(ev["rssi"][1]) + evm_a, evm_b = int(ev["evm"][0]), int(ev["evm"][1]) + snr_a, snr_b = int(ev["snr"][0]), int(ev["snr"][1]) # The chip only populates evm/snr for OFDM data frames; for CCK ACKs # and short mgmt frames both paths read 0. Treat (0,0) as "no # measurement" rather than "0 dB" so we don't artificially fill the @@ -187,7 +190,7 @@ def main(argv: Optional[list[str]] = None) -> int: elapsed = max(1e-9, time.monotonic() - start) if total == 0: - sys.stderr.write("survey: no lines parsed; " + sys.stderr.write("survey: no rx.corrupt events parsed; " "is DEVOURER_RX_DUMP_ALL set?\n") return 1 diff --git a/tools/precoder/csi_dump.py b/tools/precoder/csi_dump.py index e61240e..5bbb880 100644 --- a/tools/precoder/csi_dump.py +++ b/tools/precoder/csi_dump.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Parse lines emitted by rxdemo with +"""Parse `csi.hit` / `csi.wedged` JSONL events emitted by rxdemo with DEVOURER_RX_DUMP_CSI=hex,hex,... set and report which selectors look like a real BB bus vs static status flags. @@ -25,16 +25,22 @@ from __future__ import annotations import argparse -import re +import json import sys from collections import defaultdict from pathlib import Path -CSI_RE = re.compile( - r"hit=(?P\d+)\s+selector=0x(?P[0-9a-fA-F]+)" - r"\s+value=0x(?P[0-9a-fA-F]+)" -) -WEDGE_RE = re.compile(r"after selector=0x([0-9a-fA-F]+)") + +def _event(line: str) -> dict | None: + """Parse one JSONL event line; None for anything else.""" + line = line.strip() + if not line.startswith('{"ev":"'): + return None + try: + ev = json.loads(line) + except ValueError: + return None + return ev if isinstance(ev, dict) and "ev" in ev else None def main() -> int: @@ -51,18 +57,16 @@ def main() -> int: wedged_after: int | None = None for line in args.log.read_text().splitlines(): - m = CSI_RE.search(line) - if m: - sel = int(m.group("sel"), 16) - val = int(m.group("val"), 16) - per_selector[sel].append(val) + ev = _event(line) + if ev is None: continue - w = WEDGE_RE.search(line) - if w: - wedged_after = int(w.group(1), 16) + if ev["ev"] == "csi.hit": + per_selector[int(ev["selector"], 16)].append(int(ev["value"], 16)) + elif ev["ev"] == "csi.wedged": + wedged_after = int(ev["selector"], 16) if not per_selector: - print("no lines found", file=sys.stderr) + print("no csi.hit events found", file=sys.stderr) return 2 print(f"# parsed {sum(len(v) for v in per_selector.values())} samples " diff --git a/tools/precoder/fused_fec_link.py b/tools/precoder/fused_fec_link.py index 1cbf6e9..23186ba 100644 --- a/tools/precoder/fused_fec_link.py +++ b/tools/precoder/fused_fec_link.py @@ -3,7 +3,7 @@ This is the chip-path (scenario 1) sibling of the SDR capstone (`~/git/sdr2wifi/fused_fec_rung1.py`): the same SBI-over-RS framing, but the -receiver consumes `` lines (with DEVOURER_RX_KEEP_CORRUPTED) +receiver consumes `rx.frame` JSONL events (with DEVOURER_RX_KEEP_CORRUPTED) instead of gr-ieee802-11 PDUs. The chip gives only hard corrupted bytes (no LLRs), so erasure localization is purely the per-sub-block CRC. @@ -17,7 +17,7 @@ * sbi — keeps it and salvages the CRC-valid sub-blocks. CLI wrappers: `fused_fec_tx.py` (bytes→bodies→streamtx) and -`fused_fec_rx.py` (``→recovered bytes + gain report). +`fused_fec_rx.py` (`rx.frame` events→recovered bytes + gain report). """ from __future__ import annotations diff --git a/tools/precoder/fused_fec_rx.py b/tools/precoder/fused_fec_rx.py index d6776cc..eaab817 100644 --- a/tools/precoder/fused_fec_rx.py +++ b/tools/precoder/fused_fec_rx.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Fused-FEC RX driver — → SBI salvage → RS decode. +"""Fused-FEC RX driver — `rx.frame` events → SBI salvage → RS decode. Reads rxdemo's stdout (run it with DEVOURER_STREAM_OUT=1 AND DEVOURER_RX_KEEP_CORRUPTED=1 so FCS-failed bodies are surfaced), salvages each @@ -15,23 +15,18 @@ import argparse import os -import re import sys import time _HERE = os.path.dirname(os.path.abspath(__file__)) if _HERE not in sys.path: sys.path.insert(0, _HERE) +sys.path.insert(0, os.path.abspath(os.path.join(_HERE, "..", "..", "tests"))) from fused_fec_link import FusedFecReceiver # noqa: E402 from fused_fec_tx import add_fec_args # noqa: E402 (shared FEC arg set) from stream_fec import FecConfig # noqa: E402 - -_STREAM_RE = re.compile( - r"rate=(?P\d+)\s+len=(?P\d+)" - r"(?:\s+crc_err=(?P\d+))?" - r"(?:\s+icv_err=(?P\d+))?" - r".*?\s+body=(?P[0-9a-fA-F]*)") +from devourer_events import parse_event # noqa: E402 def main(argv=None) -> int: @@ -49,14 +44,13 @@ def main(argv=None) -> int: out = sys.stdout.buffer last = time.monotonic() for line in sys.stdin: - m = _STREAM_RE.search(line) - if not m: + ev = parse_event(line, ev="rx.frame") + if ev is None: if args.idle_timeout and (time.monotonic() - last) > args.idle_timeout: break continue - body = bytes.fromhex(m.group("hex")) - crc_err = bool(int(m.group("crc_err") or 0)) or \ - bool(int(m.group("icv_err") or 0)) + body = bytes.fromhex(ev.get("body") or "") + crc_err = bool(ev.get("crc") or 0) or bool(ev.get("icv") or 0) for pkt in rcv.add_frame(body, crc_err): out.write(pkt) out.flush() diff --git a/tools/precoder/score.py b/tools/precoder/score.py index e028cb0..36cde20 100644 --- a/tools/precoder/score.py +++ b/tools/precoder/score.py @@ -1,4 +1,4 @@ -"""VRX link-quality scoring over a sliding window of frames. +"""VRX link-quality scoring over a sliding window of `rx.frame` events. Feeds the controller an SNR estimate and produces the alink-compatible 1000..2000 score carried in the RCF (telemetry + a future drone-decides mode). The score diff --git a/tools/precoder/seed_probe.py b/tools/precoder/seed_probe.py index 64209a0..882ef7d 100644 --- a/tools/precoder/seed_probe.py +++ b/tools/precoder/seed_probe.py @@ -9,8 +9,8 @@ --mode rx (primary intent) Read the descrambler seed the chip recovers from frames it *receives*. Run `rxdemo` with DEVOURER_DUMP_SCRAMBLER=1 on a second adapter - pointed at the precoder TX; it prints `seed=0xNN` - lines. This script parses them and reports whether the seed is CONSTANT + pointed at the precoder TX; it emits `{"ev":"rx.scrambler","seed":"0xNN",...}` + events. This script parses them and reports whether the seed is CONSTANT (one shaped PSDU works) or VARYING per frame (brute-force needed). CAVEAT: the seed field is only trustworthy when the RX adapter is an @@ -39,30 +39,29 @@ import argparse import os -import re import subprocess import sys from collections import Counter +from pathlib import Path -import encode_subcarriers as enc +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tests")) -_SEED_RE = re.compile(rb"seed=0x([0-9a-fA-F]{2})") +import encode_subcarriers as enc +from devourer_events import parse_event # noqa: E402 def _iter_seed_lines(stream) -> "list[int]": seeds = [] for raw in stream: - if isinstance(raw, str): - raw = raw.encode("utf-8", "replace") - m = _SEED_RE.search(raw) - if m: - seeds.append(int(m.group(1), 16)) + ev = parse_event(raw, ev="rx.scrambler") + if ev is not None: + seeds.append(int(ev["seed"], 16)) return seeds def _report(seeds: "list[int]") -> int: if not seeds: - print("seed_probe: no lines seen. Is the RX demo " + print("seed_probe: no rx.scrambler events seen. Is the RX demo " "running with DEVOURER_DUMP_SCRAMBLER=1, pointed at the precoder " "TX on the same channel?", file=sys.stderr) return 1 @@ -115,9 +114,9 @@ def mode_rx(args) -> int: line = proc.stdout.readline() if not line: break - m = _SEED_RE.search(line) - if m: - seeds.append(int(m.group(1), 16)) + ev = parse_event(line, ev="rx.scrambler") + if ev is not None: + seeds.append(int(ev["seed"], 16)) finally: proc.terminate() try: diff --git a/tools/precoder/stream_rx.py b/tools/precoder/stream_rx.py index d5329eb..d2f579c 100644 --- a/tools/precoder/stream_rx.py +++ b/tools/precoder/stream_rx.py @@ -2,7 +2,7 @@ """Stream RX driver — decodes stream frames out of rxdemo's stdout. Reads rxdemo's stdout on this process's stdin, picks up the -`` lines emitted when DEVOURER_STREAM_OUT=1 is set, decodes +`rx.frame` JSONL events emitted when DEVOURER_STREAM_OUT=1 is set, decodes each body via `stream.decode_body` (optionally with a shape constraint), re-orders by sequence number, and writes the decoded payload bytes to stdout. Gaps and duplicates are logged to stderr. @@ -30,7 +30,6 @@ import argparse import os -import re import sys import time from typing import Optional @@ -38,19 +37,10 @@ _HERE = os.path.dirname(os.path.abspath(__file__)) if _HERE not in sys.path: sys.path.insert(0, _HERE) +sys.path.insert(0, os.path.abspath(os.path.join(_HERE, "..", "..", "tests"))) import stream # noqa: E402 - -# Mirrors stream_tx.py's parser without re-importing it (kept side-effect free). -_STREAM_RE = re.compile( - r"rate=(?P\d+)\s+len=(?P\d+)" - r"(?:\s+crc_err=(?P\d+))?" - r"(?:\s+icv_err=(?P\d+))?" - r"(?:\s+rssi=(?P-?\d+,-?\d+))?" - r"(?:\s+evm=(?P-?\d+,-?\d+))?" - r"(?:\s+snr=(?P-?\d+,-?\d+))?" - r"\s+body=(?P[0-9a-fA-F]*)" -) +from devourer_events import parse_event # noqa: E402 def parse_shape_env(s: str) -> Optional[dict]: @@ -129,16 +119,16 @@ def main(argv: Optional[list[str]] = None) -> int: out_bytes = sys.stdout.buffer for line in sys.stdin: - m = _STREAM_RE.search(line) - if not m: + ev = parse_event(line, ev="rx.frame") + if ev is None: continue - rate = int(m.group("rate")) + rate = int(ev["rate"]) if rate != 0x04: # Tier-1 sanity: every shaped frame must fly as legacy 6M OFDM. A # 0x00 here means the chip downgraded us to 1M CCK and OFDM # subcarriers don't exist at all (see precoder README). rate_mismatch += 1 - body = bytes.fromhex(m.group("hex")) + body = bytes.fromhex(ev.get("body") or "") last_event = time.monotonic() frame = stream.decode_body( diff --git a/tools/precoder/tun_p2p.py b/tools/precoder/tun_p2p.py index ee0e985..5d659cb 100644 --- a/tools/precoder/tun_p2p.py +++ b/tools/precoder/tun_p2p.py @@ -6,7 +6,7 @@ two threads: tun fd ──read──► encode_body ─length-prefix─► streamtx (libusb TX) - rxdemo (libusb RX) ──► decode_body ──write──► tun fd + rxdemo (libusb RX) ─rx.frame events─► decode_body ──write──► tun fd ONE IP PACKET = ONE STREAM FRAME (seq increments per packet, total=0 = unbounded stream). body_bytes defaults to 1500 so a 1490-byte tun MTU fits cleanly with @@ -86,7 +86,6 @@ import collections import fcntl import os -import re import shutil import signal import struct @@ -99,8 +98,10 @@ _HERE = Path(__file__).resolve().parent if str(_HERE) not in sys.path: sys.path.insert(0, str(_HERE)) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tests")) import stream # noqa: E402 +from devourer_events import parse_event # noqa: E402 # stream_fec pulls in `raptorq` from PyPI. Defer the import so byte-mode # users running on a python without the wheel installed don't get @@ -118,16 +119,6 @@ def _import_stream_fec(): # noqa: E302 IFF_TUN = 0x0001 IFF_NO_PI = 0x1000 -_STREAM_RE = re.compile( - r"rate=(?P\d+)\s+len=(?P\d+)" - r"(?:\s+crc_err=(?P\d+))?" - r"(?:\s+icv_err=(?P\d+))?" - r"(?:\s+rssi=(?P-?\d+,-?\d+))?" - r"(?:\s+evm=(?P-?\d+,-?\d+))?" - r"(?:\s+snr=(?P-?\d+,-?\d+))?" - r"\s+body=(?P[0-9a-fA-F]*)" -) - # --------------------------------------------------------------------------- # # Shape spec — same parser as stream_tx.py / stream_rx.py @@ -196,7 +187,7 @@ def launch_rx(args) -> subprocess.Popen: def launch_duplex(args) -> subprocess.Popen: """Single binary, one chip, both directions. stdin = length-prefixed - PSDU bodies (TX side), stdout = `` lines (RX side).""" + PSDU bodies (TX side), stdout = `rx.frame` JSONL events (RX side).""" env = dict(os.environ, DEVOURER_PID=args.duplex_pid, DEVOURER_VID=args.duplex_vid, DEVOURER_CHANNEL=str(args.channel), DEVOURER_USB_QUIET="1") @@ -361,7 +352,7 @@ def rx_thread(stop: StopBit, rx_stdout, tun_fd: int, dedup: Optional[SeqWindow], counters: dict, fec_dec: "Optional[stream_fec.FecDecoder]" = None, fec_block_expire_ms: int = 500) -> None: - """Read `` lines off the rxdemo, decode the + """Read `rx.frame` events off the rxdemo, decode the stream envelope, then either: * byte mode: write the StreamFrame payload to the TUN as one IP packet, * FEC mode: feed the payload to the FecDecoder; when a block decodes, @@ -376,12 +367,12 @@ def rx_thread(stop: StopBit, rx_stdout, tun_fd: int, for line in rx_stdout: if stop.stop: return - m = _STREAM_RE.search(line) - if not m: + ev = parse_event(line, ev="rx.frame") + if ev is None: continue - if int(m.group("rate")) != 0x04: + if int(ev["rate"]) != 0x04: counters["rate_mismatch"] += 1 - body = bytes.fromhex(m.group("hex")) + body = bytes.fromhex(ev.get("body") or "") frame = stream.decode_body( body, shape=shape, seed=seed, offset=offset, entry_state=entry_state, From 98d3c27bf43da85cba79a46d58c4d8e5d2e20311 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:40:11 +0300 Subject: [PATCH 2/3] CI: fix log_event_selftest on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MSVC rejects the constant 0.0/0.0 NaN outright (C2124 hard error) — use std::numeric_limits::quiet_NaN(). The mingw job builds an explicit target list, so every registered ctest binary must be named there; add LogEventSelftest (it was failing as "Not Run"). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/cmake-multi-platform.yml | 2 +- tests/log_event_selftest.cpp | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml index 1db859d..8c54f36 100644 --- a/.github/workflows/cmake-multi-platform.yml +++ b/.github/workflows/cmake-multi-platform.yml @@ -148,7 +148,7 @@ jobs: devourer streamtx duplex StreamStdinSelftest ToneMaskSelftest BfReportDecodeSelftest SweepSpecSelftest TxPowerQuantSelftest LinkHealthSelftest TxCapsSelftest TxPktPwrSelftest - RxQualitySelftest AdapterHealthSelftest + RxQualitySelftest AdapterHealthSelftest LogEventSelftest - name: Test working-directory: build diff --git a/tests/log_event_selftest.cpp b/tests/log_event_selftest.cpp index b5264d6..9c2dcd3 100644 --- a/tests/log_event_selftest.cpp +++ b/tests/log_event_selftest.cpp @@ -6,6 +6,7 @@ * consumer, so it fails `ctest` instead. */ #include #include +#include #include #include "Event.h" @@ -71,7 +72,7 @@ int main() { .f("i", -42) .f("u", 18446744073709551615ull) .f("d", 2.5) - .f("nan", 0.0 / 0.0) + .f("nan", std::numeric_limits::quiet_NaN()) .f("b", true) .f("z", nullptr); expect_eq("numbers", c.text(), From e7c8620efd230a6479252edd96d175861576bf79 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:45:23 +0300 Subject: [PATCH 3/3] CI: fix selftest read-then-write UB on msvcrt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture::text() ended with an fread; the next event's fwrite to the same update stream without an intervening reposition is undefined (C11 7.21.5.3) — glibc tolerates it, msvcrt drops the write, failing the scratch-reuse check on mingw. fseek to EOF after reading. Co-Authored-By: Claude Opus 4.8 --- tests/log_event_selftest.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/log_event_selftest.cpp b/tests/log_event_selftest.cpp index 9c2dcd3..93dfc01 100644 --- a/tests/log_event_selftest.cpp +++ b/tests/log_event_selftest.cpp @@ -29,6 +29,9 @@ struct Capture { std::string s((size_t)n, '\0'); if (std::fread(s.data(), 1, s.size(), f) != s.size()) s.clear(); + /* C requires a reposition between a read and a subsequent write on an + * update stream — without this the next emit is UB (msvcrt drops it). */ + std::fseek(f, 0, SEEK_END); return s; } };