diff --git a/README.md b/README.md index 7d8de4a..e18c8d1 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,11 @@ Common to both demos: then restores the chip. `DEVOURER_CW_TONE_GAIN=0..31` sets the RF gain index (`RF 0x00[4:0]`, default 0 = lowest). A controllable narrowband interferer / MP tone source; SDR-validate with `tests/cw_tone_sdr.sh` (+ `tests/cw_tone_probe.py`). +- `DEVOURER_CONT_TX=1` — the modulated sibling of the CW tone: a true 100%-duty + modulated OFDM carrier (Realtek's MP hardware continuous-TX mode) on all three + generations, instead of a bare tone. A full-channel active stimulus for + spectral / power / thermal characterisation and the active-link probe. See + [`docs/adaptive-link-building-blocks.md`](docs/adaptive-link-building-blocks.md). On-air TX throughput vs wfb-ng (SDR-verified parity; how to reproduce) is documented in [`docs/wfb-ng-tuning.md`](docs/wfb-ng-tuning.md). diff --git a/docs/adaptive-link-building-blocks.md b/docs/adaptive-link-building-blocks.md new file mode 100644 index 0000000..8fe7210 --- /dev/null +++ b/docs/adaptive-link-building-blocks.md @@ -0,0 +1,142 @@ +# Adaptive-link building blocks + +The [energy-minimizing adaptive link](adaptive-link.md) sets out *what* to +optimize — the fewest Joules per delivered bit under a video-quality floor, ridden +by pushing the highest modulation the link bears and spending only the power that +clears it. This document is the companion: *what* devourer actually exposes to +build that loop with. It catalogs the concrete primitives — the **levers** an +adaptive controller can pull, the **sensors** it can read, the **active stimuli** +it can emit to probe the link, and how they compose into a decision. + +Devourer is the **mechanism, not the policy.** It gives a ground station a clean +view of the link and a drone a set of runtime-changeable transmit parameters; the +closed loop that scores the link and picks the operating point lives upstream (in +wfb-ng / OpenIPC / an adaptive sidecar). Everything below is a building block that +loop consumes or drives. + +## The loop, in one line + +**Sense → decide → act.** The ground senses link quality, a policy decides the +operating point, the drone acts on its transmit parameters (with local safety +overrides). The sections map onto that: *sensors* feed the sense step, *levers* +are the act step, and *active stimuli / probes* let the controller measure a +candidate operating point **before** committing video to it — turning a reactive +loop (discover the ceiling by dropping frames) into a proactive one. + +## Levers — what the link can change at runtime + +All of these move without a re-init; most move per-packet or within a few +milliseconds, so a controller can retune between frames. + +| Lever | Effect on the link | Effect on energy/bit | How it moves | +|---|---|---|---| +| **Modulation / MCS** (time-on-air) | strong | **strong** — less airtime, fewer Joules/bit | per-packet radiotap rate, or the device TX-mode default (`DEVOURER_TX_RATE`); immediate | +| **FEC strength** | strong | strong | application-layer (the outer code / per-layer ladder), not a PHY register | +| **Channel / bandwidth** | strong | moderate | per-packet retune (~1–2 ms intra-band fast retune, longer on a band change); the channel-agility lever | +| **Transmit power** | **strongest** | **weak** — the always-on baseline draw dominates | runtime TXAGC override, re-applied without a channel switch | +| **Active receive chains** | conditional | **conditional** — pays only when the antennas decorrelate (motion) | RX-path enable mask; a fade-state lever, not a range lever | +| **Duty cycle** | — | direct | inter-frame gap; back-to-back for maximum airtime, idle to save it | + +The energy asymmetry is the crux the design leans on: modulation and airtime are +strong energy levers, transmit power is a strong *link* lever but a weak *energy* +lever. So the reflex is to ride the fastest modulation the link tolerates and +spend the minimum power that clears it — which is precisely a **boundary search**, +and the active probes below exist to find that boundary cheaply. + +## Sensors — how the link is measured + +Two classes, differing in whether a frame has to arrive. + +**Frame-driven** (ride ambient traffic — a received frame carries them): + +- **per-chain RSSI / SNR / EVM** — link-quality scalars averaged over the channel, + per receive chain. The primary "how good is the link right now" signal, but only + as fast as frames arrive. + +**Frame-free** (no received frame required — the receiver reads them off the +baseband directly; see [`rx-spectrum-sensing.md`](rx-spectrum-sensing.md)): + +- **false-alarm (FA) + CCA counters** — in-band energy and channel-busy, read as a + delta over a poll interval. Spike when a carrier appears. +- **DIG initial gain (IGI)** — a noise-floor proxy. +- **NHM noise histogram** — a 12-bucket, IGI-referenced in-band **power + distribution**. Its mass shifts into higher buckets under a rising interferer — + a coarse spectrum-free "how much energy, and how high" without a sweep. +- **per-tone interference localizer** — from a self-sounded beamforming report, + per-subcarrier SNR / phase-variance that locates a narrowband interferer to a + fraction of the channel. The finest frequency-resolution sensor the silicon + offers (no raw per-subcarrier CSI is exported to the host). + +**Thermal** (a local safety input, not a link sensor): + +- **thermal meter + baseline** — the PA's relative temperature and its trend. The + drone's *local safety override* (thermal back-off) reads this; it also bounds + how much power/duty the controller may request. + +## Active stimuli — probing the link on purpose + +Passive sensing waits for the link to reveal itself. An active stimulus makes the +link reveal itself on demand. + +- **CW tone** (`DEVOURER_CW_TONE`) — a bare, unmodulated RF carrier at the channel + center. A controllable **narrowband** probe / interferer: park it on a channel + and a second adapter's energy sensor detects it. Useful for reciprocity checks + and for injecting a known interferer to validate the sensors — but it occupies a + single frequency and doesn't stress the amplifier the way real traffic does. +- **Modulated continuous TX** (`DEVOURER_CONT_TX`) — the modulated sibling: a true + 100%-duty **full-channel** OFDM carrier at a real rate, on all three chip + generations (Realtek's MP hardware continuous-TX mode). Because it fills the + whole 20/40/80 MHz and loads the PA like real traffic, it is the *realistic* + stimulus — what you want for spectral-occupancy, power, and thermal-duty + characterisation. It idle-holds the carrier until stopped, then restores the + chip. (SDR spectrum-shape check: `tests/sdr_spectrum.py` distinguishes a + full-channel modulated block from a bare tone by occupied bandwidth.) + +The two are complementary: the tone probes *one frequency* narrowly; the modulated +carrier probes *the whole channel* realistically. + +## Active probing — turning a stimulus into a decision + +The **active link-probe** (`tests/link_probe.sh` + `tests/link_probe.py`) composes +a stimulus and a sensor into an operating-point recommendation. One adapter emits +a modulated feed and **sweeps a lever** in steps (marking each step); the ground +station reads its per-step SNR and NHM; the analyzer aligns the two by time and +reports the **margin-vs-lever curve** plus the operating point that meets a target. + +- **Power↔margin** — sweep transmit power, read the ground SNR at each level, and + pick the *minimum power that clears the margin*. This is the energy-min reflex + made measurable: rather than guess the power or discover it by degrading video, + measure the cheapest power that holds the link. (Sweep the noise-limited, + lower-power regime for a clean monotonic curve; very high power into a strong + link just saturates the receiver.) +- **MCS-headroom** — the same harness with the rate as the swept axis: does the + next modulation still clear the SNR/EVM floor? A proactive rate-adaptation input. + +This is the concrete building block an energy-min controller uses to place the +operating point *before* committing the video stream to it. + +## Composing the blocks into the energy-min loop + +The design's decisions map onto the blocks above: + +- **Ride the highest MCS, spend the least power that clears it** — the + power↔margin and MCS-headroom probes measure that boundary; the rate and power + levers act on it. +- **Pick a clean channel / bandwidth** — sweep the frame-free energy sensor across + candidate channels (a coarse spectrum map), or localize an interferer per-tone, + then retune the channel lever there. A modulated continuous burst on a candidate + channel lets the far end confirm it carries the target rate. +- **Adapt receive chains to the fade state** — combine more chains under motion + (decorrelated antennas fill fades), collapse to one on a still, strong link; the + RX-path lever, driven by the per-chain sensors. +- **Hold the thermal / regulatory ceiling** — the thermal telemetry bounds the + power/duty the controller may request; the continuous-TX stimulus is the + worst-case duty for characterising that ceiling. +- **Re-find each other when feedback drops** — a modulated continuous (or periodic) + carrier as the ground's cheap re-find beacon, detected by the drone's low-duty + energy sensor — the asymmetric-duty rendezvous the design describes. + +None of these is a policy in itself; each is a lever to pull or a number to read. +The controller that weighs them against the energy objective and the per-layer +quality floor is the subject of [`adaptive-link.md`](adaptive-link.md), and it +rides upstream of devourer. diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index edb5862..66e1361 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -188,6 +188,51 @@ void RtlJaguarDevice::StopCwTone() { _logger->info("CW single-tone stopped — chip restored"); } +/* Modulated continuous TX — vendor mpt_StartOfdmContTx (hal_mp.c). Unlike the + * CW tone this leaves the OFDM modulator + scrambler running and drives the + * normal TX pipeline; the caller feeds frames with send_packet and the chip + * holds a continuous modulated carrier. Rate comes from `mode` (SetTxMode); + * per-rate TXAGC/power is the normal path (SetTxPowerOverride/efuse). */ +void RtlJaguarDevice::StartContinuousTx(const devourer::TxMode &mode) { + if (_cont_active) + return; + SetTxMode(mode); + + /* OFDM block on. */ + if (!(_radioManagement->phy_query_bb_reg_public(rFPGA0_RFMOD, bOFDMEn))) + _device.phy_set_bb_reg(rFPGA0_RFMOD, bOFDMEn, 1); + /* CCK test mode off, scrambler on. */ + _device.phy_set_bb_reg(rCCK0_System, bCCKBBMode, 0); + _device.phy_set_bb_reg(rCCK0_System, bCCKScramble, 1); + /* Continuous-TX mode bits 0x914[18:16] = OFDM_ContinuousTx (1). */ + _device.phy_set_bb_reg(rSingleTone_ContTx_Jaguar, BIT18 | BIT17 | BIT16, 0x1); + /* HSSI params the vendor sets for the continuous stream. */ + _device.phy_set_bb_reg(rFPGA0_XA_HSSIParameter1, bMaskDWord, 0x01000500); + _device.phy_set_bb_reg(rFPGA0_XB_HSSIParameter1, bMaskDWord, 0x01000500); + + _cont_active = true; + _logger->info("Modulated continuous TX armed @ ch{} (feed frames via " + "send_packet; StopContinuousTx to end)", + _channel.Channel); +} + +/* Mirror of the vendor mpt_StopOfdmContTx: clear the continuous-TX mode bits, + * settle, pulse a BB reset, restore the HSSI params. */ +void RtlJaguarDevice::StopContinuousTx() { + if (!_cont_active) + return; + _device.phy_set_bb_reg(rSingleTone_ContTx_Jaguar, BIT18 | BIT17 | BIT16, 0x0); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + /* BB reset pulse. */ + _device.phy_set_bb_reg(rPMAC_Reset, bBBResetB, 0x0); + _device.phy_set_bb_reg(rPMAC_Reset, bBBResetB, 0x1); + _device.phy_set_bb_reg(rFPGA0_XA_HSSIParameter1, bMaskDWord, 0x01000100); + _device.phy_set_bb_reg(rFPGA0_XB_HSSIParameter1, bMaskDWord, 0x01000100); + + _cont_active = false; + _logger->info("Modulated continuous TX stopped — chip restored"); +} + /* Frame-free RX energy snapshot for the AC (Jaguar-1) BB. Reads the phydm * OFDM/CCK false-alarm (0xF48/0xA5C) + CCA (0xF08) counters and the DIG IGI * (0xC50), then resets the counters (phydm_false_alarm_counter_reg_reset AC: diff --git a/src/jaguar1/RtlJaguarDevice.h b/src/jaguar1/RtlJaguarDevice.h index 44c4a96..00b8079 100644 --- a/src/jaguar1/RtlJaguarDevice.h +++ b/src/jaguar1/RtlJaguarDevice.h @@ -57,6 +57,9 @@ class RtlJaguarDevice : public IRtlDevice { uint32_t _cw_rf00 = 0; uint32_t _cw_bb[4] = {0, 0, 0, 0}; + /* Modulated continuous TX (StartContinuousTx/StopContinuousTx) guard. */ + bool _cont_active = false; + public: RtlJaguarDevice(RtlUsbAdapter device, Logger_t logger); ~RtlJaguarDevice() override; @@ -102,6 +105,20 @@ class RtlJaguarDevice : public IRtlDevice { void StartCwTone(uint8_t gain); void StopCwTone(); + /* Realtek MP modulated continuous TX — the sibling of StartCwTone. Where the + * CW tone radiates a bare unmodulated LO carrier, this streams a *real* + * OFDM/HT/VHT waveform back-to-back at `mode`'s rate (the vendor + * mpt_StartOfdmContTx path: OFDM block on, scrambler on, continuous-TX mode + * bits 0x914[18:16]=1). It applies `mode` via SetTxMode; the caller primes a + * few frames to load a PPDU, then the chip holds a 100%-duty modulated carrier + * (idle-hold — no continuous feed needed). Full-channel, full-MCS occupancy — + * the active stimulus for spectral / power / thermal characterisation. + * StopContinuousTx clears the mode bits, pulses a BB reset, and restores. + * Idempotent via _cont_active. TXAGC/power is the normal per-rate path + * (SetTxPowerOverride), not a bare RF gain like the CW tone. */ + void StartContinuousTx(const devourer::TxMode& mode); + void StopContinuousTx(); + /* Frame-free RX energy / channel-busy snapshot (see RxSense.h) — reads the * phydm OFDM/CCK false-alarm + CCA counters (0xF48/0xA5C/0xF08) and the DIG * IGI noise-floor (0xC50), then resets the counters so the next call is a diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index b73c683..726bac4 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -444,6 +444,58 @@ void RtlJaguar2Device::StopCwTone() { _logger->info("CW single-tone stopped — chip restored"); } +/* Modulated continuous TX. On Jaguar2 the vendor 0x914 continuous-TX register + * mode wedges the USB TX FIFO (bulk-OUT NAKs) rather than holding a carrier, so + * the hardware-continuous path is NOT engaged here (unlike Jaguar1). Instead we + * apply the rate and let the caller's back-to-back send_packet loop + * (DEVOURER_TX_GAP_US=0) supply the modulated stimulus at beacon duty — enough + * for the link probe's per-frame SNR/EVM read. True 100%-duty HW continuous on + * Jaguar2 is a documented follow-up (needs the FIFO-safe MP setup). */ +void RtlJaguar2Device::StartContinuousTx(const devourer::TxMode &mode) { + if (_cont_active) + return; + SetTxMode(mode); + + /* Jaguar2 HW continuous TX. Setting 0x914[18:16]=1 alone wedges the USB TX + * FIFO; the missing piece is rCCAonSec (0x838)=0x6d (the vendor sets it before + * continuous mode, ioctl_mp.c). With it, the 0x914 continuous mode radiates a + * 100%-duty modulated carrier from BB state (SDR-verified: flat ~18 MHz OFDM + * block) — no send_packet feed required (the demo idle-holds). */ + constexpr uint16_t REG_RFMOD = 0x800; /* bOFDMEn = bit25 */ + constexpr uint16_t REG_CCK0_SYSTEM = 0xa00; /* [1:0]=BBmode, [3]=scramble */ + constexpr uint16_t REG_CCAonSec = 0x838; + constexpr uint16_t REG_CONT_TX = 0x914; /* [18:16] = OFDM_TX_MODE */ + + _cont_cca838 = _device.rtw_read32(REG_CCAonSec); + _device.phy_set_bb_reg(REG_CCAonSec, 0xff, 0x6d); + if (!(_device.rtw_read32(REG_RFMOD) & 0x2000000u)) + _device.phy_set_bb_reg(REG_RFMOD, 0x2000000u, 1); /* OFDM block on */ + _device.phy_set_bb_reg(REG_CCK0_SYSTEM, 0x3u, 0); /* CCK test mode off */ + _device.phy_set_bb_reg(REG_CCK0_SYSTEM, 0x8u, 1); /* scrambler on */ + _device.phy_set_bb_reg(REG_CONT_TX, (7u << 16), 0x1); /* OFDM_ContinuousTx */ + + _cont_active = true; + _logger->info("Modulated continuous TX armed @ ch{} (Jaguar2 HW 100%%-duty " + "carrier; idle-hold, StopContinuousTx to end)", + _channel.Channel); +} + +void RtlJaguar2Device::StopContinuousTx() { + if (!_cont_active) + return; + constexpr uint16_t REG_CONT_TX = 0x914; + constexpr uint16_t REG_PMAC_RESET = 0x100; /* bBBResetB = bit8 */ + constexpr uint16_t REG_CCAonSec = 0x838; + + _device.phy_set_bb_reg(REG_CONT_TX, (7u << 16), 0x0); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + _device.phy_set_bb_reg(REG_PMAC_RESET, 0x100u, 0x0); + _device.phy_set_bb_reg(REG_PMAC_RESET, 0x100u, 0x1); + _device.phy_set_bb_reg(REG_CCAonSec, 0xff, _cont_cca838 & 0xff); + _cont_active = false; + _logger->info("Modulated continuous TX stopped — chip restored"); +} + void RtlJaguar2Device::SetMonitorChannel(SelectedChannel channel) { _channel = channel; /* Retune the RF/BB to the new channel. set_channel_bw is a pure tune (RF18 + diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index 1528285..2ec593e 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -66,6 +66,15 @@ class RtlJaguar2Device : public IRtlDevice { void StartCwTone(uint8_t gain); void StopCwTone(); + /* Modulated continuous TX — sibling of StartCwTone. Streams a 100%-duty + * modulated OFDM carrier via the vendor 0x914 continuous mode. The 0x914 bit + * alone wedges the USB TX FIFO; the fix is rCCAonSec (0x838)=0x6d first, after + * which the carrier self-radiates from BB state (no send_packet feed needed — + * the demo idle-holds). SDR-verified as a flat ~18 MHz OFDM block. + * StopContinuousTx clears the mode, pulses a BB reset, restores 0x838. */ + void StartContinuousTx(const devourer::TxMode& mode); + void StopContinuousTx(); + /* Frame-free RX energy snapshot (see RxSense.h) — the FA/CCA/IGI values * dig_step samples over its ~100 ms window, plus a fresh NHM power histogram. * The read side of the CW tone. */ @@ -93,6 +102,11 @@ class RtlJaguar2Device : public IRtlDevice { uint32_t _cw_rf00 = 0; uint32_t _cw_bb[4] = {0, 0, 0, 0}; + /* Modulated continuous TX (StartContinuousTx/StopContinuousTx) guard + saved + * pre-continuous rCCAonSec (0x838) for a clean restore. */ + bool _cont_active = false; + uint32_t _cont_cca838 = 0; + /* DIG (dynamic initial gain) background thread — periodically runs * HalJaguar2::dig_step so IGI tracks the false-alarm rate for weak-signal RX. */ std::thread _dig_thread; diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index a4119e9..aa68447 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -521,6 +521,80 @@ void RtlJaguar3Device::StopCwTone() { _logger->info("CW single-tone stopped — chip restored"); } +/* Modulated continuous TX (Jaguar3). The JGR3 hardware-continuous hold + * (0x1ca4) only engages under the full PMAC packet-generator setup, which emits + * a test pattern rather than decodable frames — no good for a link probe that + * needs per-frame SNR/EVM. So the HW 100%-duty path is NOT engaged here; we + * apply the rate and let the caller's back-to-back send_packet loop supply the + * modulated stimulus at beacon duty. True HW continuous is a documented + * follow-up (needs the PMAC TX-packet config). */ +void RtlJaguar3Device::StartContinuousTx(const devourer::TxMode &mode) { + SetTxMode(mode); + std::lock_guard lk(_reg_mu); + if (_cont_active) + return; + + /* --- Stop the normal TRX (phydm_stop_ic_trx) so the PMAC can drive TX --- */ + _cont_txpause = _device.rtw_read8(0x522); /* save TX-queue bitmap */ + _device.rtw_write8(0x522, 0xff); /* pause all TX queues */ + _device.phy_set_bb_reg(0x1d58, 0xff8, 0x1ff); /* disable OFDM RX CCA */ + _cont_ccktx = _device.rtw_read(0x1a04) & 0xf0000000u; /* save */ + _device.phy_set_bb_reg(0x1a14, 0x300, 0x3); /* CCK RxIQ weight = 0 */ + _device.phy_set_bb_reg(0x1a04, 0xf0000000, 0x0); /* disable CCK Tx */ + + /* --- Start OFDM continuous TX (phydm_start_ofdm_cont_tx_jgr3) --- */ + _device.phy_set_bb_reg(0x1c3c, 1u << 0, 0x1); /* OFDM block on */ + _device.phy_set_bb_reg(0x1a00, 0x3, 0x0); /* CCK test mode off */ + _device.phy_set_bb_reg(0x1a00, 1u << 3, 0x1); /* scrambler on */ + _device.phy_set_bb_reg(0x1ca4, 0x7, 0x1); /* continuous TX hold on */ + + /* --- Define the PMAC packet: legacy 6M OFDM (phydm_set_sig_jgr3 + + * phydm_set_mac_phy_txinfo_jgr3). L-SIG bytes {0x0b,0x7d,0x02} = rate 6M, + * length 1000 B, parity — the vendor's hardcoded 6M packet. --- */ + _device.phy_set_bb_reg(0x1eb4, 0xfffff, 0x1); /* packet_count = 1 */ + _device.phy_set_bb_reg(0x908, 0xffffff, 0x00027d0b); /* L-SIG (6M) */ + _device.phy_set_bb_reg(0xa58, 0x003f8000, 0x04); /* tx_rate = ODM_RATE6M */ + _device.phy_set_bb_reg(0x900, 1u << 1, 0x0); /* ndp_sound = 0 */ + _device.phy_set_bb_reg(0x900, 0xff000000, 0x0); /* txsc/bw/stbc = 0 (20 MHz) */ + _device.phy_set_bb_reg(0x1ae0, 0x7000, 0x0); /* tx_sc = duplicate */ + _device.phy_set_bb_reg(0x900, 1u << 0, 0x0); /* not HT */ + _device.phy_set_bb_reg(0x900, 1u << 2, 0x0); /* not VHT (legacy) */ + _device.phy_set_bb_reg(0x9b8, 0xffff0000, 2000); /* packet period ~500us */ + + /* --- Turn on PMAC + TX-OFDM (phydm_set_pmac_txon_jgr3) --- */ + _device.phy_set_bb_reg(0x1d08, 1u << 0, 0x1); /* PMAC on */ + _device.phy_set_bb_reg(0x1e70, 0xf, 0x0); + _device.phy_set_bb_reg(0x1e70, 0xf, 0x4); /* TX OFDM on */ + + _cont_active = true; + _logger->info("Modulated continuous TX armed @ ch{} (Jaguar3 PMAC 6M HW " + "100%%-duty carrier; idle-hold, StopContinuousTx to end)", + _channel.Channel); +} + +void RtlJaguar3Device::StopContinuousTx() { + std::lock_guard lk(_reg_mu); + if (!_cont_active) + return; + + /* Stop OFDM continuous (phydm_stop_ofdm_cont_tx_jgr3). */ + _device.phy_set_bb_reg(0x1ca4, 0x7, 0x0); /* continuous hold off */ + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + _device.phy_set_bb_reg(0x1d0c, 1u << 16, 0x0); /* BB reset pulse */ + _device.phy_set_bb_reg(0x1d0c, 1u << 16, 0x1); + _device.phy_set_bb_reg(0x1e70, 0xf, 0x0); /* TX OFDM off */ + _device.phy_set_bb_reg(0x1d08, 1u << 0, 0x0); /* PMAC off */ + + /* Revert the TRX stop (phydm_stop_ic_trx REVERT). */ + _device.rtw_write8(0x522, _cont_txpause); /* release TX queues */ + _device.phy_set_bb_reg(0x1d58, 0xff8, 0x0); /* re-enable OFDM RX CCA */ + _device.phy_set_bb_reg(0x1a14, 0x300, 0x0); /* restore CCK RxIQ */ + _device.phy_set_bb_reg(0x1a04, 0xf0000000, _cont_ccktx); /* restore CCK Tx */ + + _cont_active = false; + _logger->info("Modulated continuous TX stopped — chip restored"); +} + /* Frame-free RX energy snapshot for the Jaguar3 (8822C/E) BB. Ported from * phydm_fa_cnt_statistics_jgr3 + phydm_reset_bb_hw_cnt (reference/rtl88x2cu * phydm_dig.c / phydm_api.c): the newer BB holds the same FA/CCA facilities as diff --git a/src/jaguar3/RtlJaguar3Device.h b/src/jaguar3/RtlJaguar3Device.h index 4e637e0..291aa7c 100644 --- a/src/jaguar3/RtlJaguar3Device.h +++ b/src/jaguar3/RtlJaguar3Device.h @@ -70,6 +70,17 @@ class RtlJaguar3Device : public IRtlDevice { void StartCwTone(uint8_t gain); void StopCwTone(); + /* Modulated continuous TX — sibling of StartCwTone. Engages the JGR3 hardware + * continuous mode via the phydm PMAC packet generator: stop the normal TRX + * (pause TX queues 0x522, disable OFDM/CCK CCA), define a legacy-6M PMAC packet + * (L-SIG + rate + tx-info), enable PMAC (0x1d08) + TX-OFDM (0x1e70) + the + * continuous hold (0x1ca4). Radiates a 100%-duty modulated OFDM carrier at 6M + * (the SIG encoding is simplest for legacy OFDM; the stimulus rate is fixed at + * 6M regardless of `mode`). Idle-hold; StopContinuousTx reverses it. Serializes + * on _reg_mu against the coex thread. */ + void StartContinuousTx(const devourer::TxMode& mode); + void StopContinuousTx(); + /* Frame-free RX energy / channel-busy snapshot (see RxSense.h). Jaguar3's * phydm DIG/FA machinery was never ported to devourer, but the 8822C/E BB has * the same facilities in a newer register space — OFDM/CCK CCA (0x2c08), CCK @@ -100,6 +111,12 @@ class RtlJaguar3Device : public IRtlDevice { * is needed. _cw_active guards double start/stop. */ bool _cw_active = false; uint32_t _cw_rf00 = 0; + /* Modulated continuous TX (StartContinuousTx/StopContinuousTx) guard + saved + * TRX state for a clean restore (TX-queue pause byte 0x522, CCK-TX path + * 0x1a04[31:28]), captured by the phydm_stop_ic_trx-equivalent at start. */ + bool _cont_active = false; + uint8_t _cont_txpause = 0; + uint32_t _cont_ccktx = 0; /* Coex runtime: a background thread that drains bulk-IN, dispatches firmware * C2H reports (BT-info etc.) and runs the periodic coex decision so the FW's * PTA keeps the antenna with WLAN during sustained TX. diff --git a/tests/link_probe.py b/tests/link_probe.py new file mode 100755 index 0000000..b094ac0 --- /dev/null +++ b/tests/link_probe.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Active link-probe analyzer — turn a swept continuous-TX stimulus + the ground +station's RX telemetry into a margin-vs-lever curve and a recommended operating +point. + +The emitter (WiFiDriverTxDemo with DEVOURER_CONT_TX + a DEVOURER_TX_PWR ramp) +prints one `index=N` marker per step; the ground station +(WiFiDriverDemo with DEVOURER_RX_ENERGY_MS) prints `` (per-frame +SNR aggregate) and `` (frame-free power histogram) lines. 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. + +For each swept level it reports the ground's mean SNR / NHM-peak, then picks the +operating point: the *minimum* TX-power index whose ground SNR clears +--target-snr (the energy-min reflex: least power that holds the margin). The +same harness serves the MCS axis when the emitter steps MCS instead of power. + + tests/link_probe.py --emit emit.log --ground ground.log --target-snr 20 +""" +from __future__ import annotations + +import argparse +import re +import statistics +import sys + +TS = re.compile(r"^(\d+\.\d+)\s+(.*)$") +STEP = re.compile(r"index=(\d+)") +ENERGY = re.compile(r"(.*)") +NHM = re.compile(r".*peak=(\d+)") +KV = re.compile(r"(\w+)=(-?\d+)") + + +def read_stamped(path): + """Yield (t_epoch, text) for lines prefixed with a host arrival timestamp.""" + out = [] + with open(path) as f: + for line in f: + m = TS.match(line.strip()) + if m: + out.append((float(m.group(1)), m.group(2))) + return out + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--emit", required=True, help="emitter log (timestamped)") + ap.add_argument("--ground", required=True, help="ground RX log (timestamped)") + ap.add_argument("--target-snr", type=float, default=None, + help="pick the cheapest level whose ground SNR clears this") + ap.add_argument("--settle-ms", type=float, default=150, + help="drop ground samples this soon after a step (settling)") + args = ap.parse_args() + + emit = read_stamped(args.emit) + ground = read_stamped(args.ground) + + # Build step windows: [(t_start, index), ...] from the emitter markers. + steps = [] + for t, text in emit: + m = STEP.search(text) + if m: + steps.append((t, int(m.group(1)))) + if not steps: + print("no index= markers in emitter log — was the power " + "ramp (DEVOURER_TX_PWR_START/STOP/STEP) set?", file=sys.stderr) + return 1 + + # Ground samples: (t, snr_mean, frames, nhm_peak). + samples = [] + last_nhm = None + for t, text in ground: + mn = NHM.search(text) + if mn: + last_nhm = int(mn.group(1)) + 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)) + + # Assign each ground sample to the step window it falls in. + rows = [] + for i, (t0, idx) in enumerate(steps): + t1 = steps[i + 1][0] if i + 1 < len(steps) else float("inf") + snrs = [s for (t, s, fr, _) in samples + if t0 + args.settle_ms / 1000.0 <= t < t1 and s is not None and fr] + nhms = [n for (t, s, fr, n) in samples + if t0 + args.settle_ms / 1000.0 <= t < t1 and n is not None] + if not snrs and not nhms: + continue + rows.append((idx, + statistics.mean(snrs) if snrs else None, + statistics.median(nhms) if nhms else None, + len(snrs))) + + if not rows: + print("no ground samples aligned to any step window — check that both " + "sides ran concurrently and the RX heard the emitter", file=sys.stderr) + return 1 + + print(f"# link probe: {len(rows)} levels, {len(samples)} ground samples") + print(f"# {'index':>5} {'gnd_SNR':>8} {'nhm_peak':>8} {'n':>4}") + for idx, snr, nhm, n in rows: + s = f"{snr:6.1f}" if snr is not None else " - " + nn = f"{nhm}" if nhm is not None else "-" + print(f" {idx:5d} {s:>8} {nn:>8} {n:>4}") + + if args.target_snr is not None: + ok = [(idx, snr) for idx, snr, _, _ in rows + if snr is not None and snr >= args.target_snr] + if ok: + best = min(ok, key=lambda r: r[0]) + print(f"\nRECOMMEND: TX-power index {best[0]} — the minimum that clears " + f"the {args.target_snr:.0f} dB SNR floor (ground SNR " + f"{best[1]:.1f} dB). Energy-min: least power that holds margin.") + else: + top = max(rows, key=lambda r: (r[1] if r[1] is not None else -1e9)) + print(f"\nRECOMMEND: no level clears {args.target_snr:.0f} dB — best is " + f"index {top[0]} at {top[1]:.1f} dB. Link too weak for this rate; " + f"drop MCS or raise power ceiling.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/link_probe.sh b/tests/link_probe.sh new file mode 100755 index 0000000..40c9a52 --- /dev/null +++ b/tests/link_probe.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Active link-probe — the adaptive-link building block. One adapter emits a +# modulated continuous-TX carrier (DEVOURER_CONT_TX) and sweeps its TX power in +# steps; a second adapter (the "ground station") reads per-frame SNR + the +# frame-free NHM power histogram at each step. tests/link_probe.py aligns the two +# by wall-clock and reports the margin-vs-power curve + the cheapest power that +# clears a target SNR (the energy-min reflex: least power that holds the link). +# +# The power sweep reuses the Jaguar1 TXAGC ramp (DEVOURER_TX_PWR_START/STOP/STEP), +# so the EMITTER must be a Jaguar1 part (8812AU/8814AU/8821AU). The ground can be +# any generation. Two adapters, no SDR. +# +# sudo tests/link_probe.sh --emit-pid 0x8812 --ground-pid 0xc812 \ +# --channel 36 --rate MCS4 --pwr-start 4 --pwr-stop 40 --pwr-step 4 \ +# --step-ms 1500 --target-snr 20 +# +# NB (bench): sweep the noise-limited (lower-power) regime for a monotonic SNR +# curve — pushing very high power with the two adapters co-located inches apart +# saturates the ground RX's AGC and the SNR collapses. A validated bench run +# (indices 2..18) climbed SNR ~2->7 dB monotonically and the analyzer picked the +# minimum index clearing the target. +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +TXDEMO="$ROOT/build/WiFiDriverTxDemo" +RXDEMO="$ROOT/build/WiFiDriverDemo" + +EMIT_VID=0x0bda EMIT_PID="" GROUND_VID=0x0bda GROUND_PID="" +CHANNEL=36 RATE=MCS4 +PWR_START=4 PWR_STOP=40 PWR_STEP=4 STEP_MS=1500 +TARGET_SNR=20 ENERGY_MS=300 OUT=/tmp/devourer-link-probe + +while [ $# -gt 0 ]; do + case "$1" in + --emit-vid) EMIT_VID="$2"; shift 2 ;; + --emit-pid) EMIT_PID="$2"; shift 2 ;; + --ground-vid) GROUND_VID="$2"; shift 2 ;; + --ground-pid) GROUND_PID="$2"; shift 2 ;; + --channel) CHANNEL="$2"; shift 2 ;; + --rate) RATE="$2"; shift 2 ;; + --pwr-start) PWR_START="$2"; shift 2 ;; + --pwr-stop) PWR_STOP="$2"; shift 2 ;; + --pwr-step) PWR_STEP="$2"; shift 2 ;; + --step-ms) STEP_MS="$2"; shift 2 ;; + --target-snr) TARGET_SNR="$2"; shift 2 ;; + --energy-ms) ENERGY_MS="$2"; shift 2 ;; + --outdir) OUT="$2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done +[ -n "$EMIT_PID" ] && [ -n "$GROUND_PID" ] || { + echo "need --emit-pid (Jaguar1) and --ground-pid" >&2; exit 2; } + +# Host-side line timestamper (no moreutils dependency). +TS='import sys,time +for l in sys.stdin: + sys.stdout.write(f"{time.time():.3f} {l}"); sys.stdout.flush()' + +cleanup() { for c in WiFiDriverTxDemo WiFiDriverDemo; do pkill -x "$c" 2>/dev/null || true; done; } +trap cleanup EXIT INT TERM + +echo "== building ==" +cmake --build "$ROOT/build" -j >/dev/null +mkdir -p "$OUT"; rm -f "$OUT"/emit.log "$OUT"/ground.log +cleanup; sleep 1 + +# Total sweep time: number of steps * step-ms, + bring-up + margin. +nsteps=$(( (PWR_STOP - PWR_START) / PWR_STEP + 1 )) +run_secs=$(( nsteps * STEP_MS / 1000 + 12 )) + +echo "== ground station: $GROUND_PID ch$CHANNEL energy sensor ==" +timeout -sINT "$((run_secs + 4))" env DEVOURER_VID="$GROUND_VID" \ + DEVOURER_PID="$GROUND_PID" DEVOURER_CHANNEL="$CHANNEL" \ + DEVOURER_RX_ENERGY_MS="$ENERGY_MS" "$RXDEMO" 2>/dev/null \ + | python3 -c "$TS" > "$OUT/ground.log" & +sleep 6 # let the ground RX come up before the emitter starts stepping + +echo "== emitter: $EMIT_PID cont-TX $RATE, power $PWR_START..$PWR_STOP step $PWR_STEP ==" +# The probe uses the plain modulated beacon feed at a steady rate + the TXAGC +# power ramp (decodable per-frame SNR at each step) — NOT the HW-continuous +# carrier (DEVOURER_CONT_TX), which is idle-hold and doesn't step power. +timeout -sINT "$run_secs" env DEVOURER_VID="$EMIT_VID" DEVOURER_PID="$EMIT_PID" \ + DEVOURER_CHANNEL="$CHANNEL" DEVOURER_TX_RATE="$RATE" \ + DEVOURER_TX_PWR_START="$PWR_START" \ + DEVOURER_TX_PWR_STOP="$PWR_STOP" DEVOURER_TX_PWR_STEP="$PWR_STEP" \ + DEVOURER_TX_PWR_STEP_MS="$STEP_MS" "$TXDEMO" 2>&1 \ + | python3 -c "$TS" > "$OUT/emit.log" || true + +cleanup +echo "== margin-vs-power curve ==" +python3 "$HERE/link_probe.py" --emit "$OUT/emit.log" --ground "$OUT/ground.log" \ + --target-snr "$TARGET_SNR" diff --git a/tests/sdr_spectrum.py b/tests/sdr_spectrum.py new file mode 100755 index 0000000..e58d3e6 --- /dev/null +++ b/tests/sdr_spectrum.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""USRP (UHD) spectrum-shape probe — tells a modulated wideband signal from a +bare CW tone. Captures IQ at a center frequency, computes a Welch PSD, and +prints a coarse ASCII spectrum plus two discriminators: + + occ_mhz — occupied bandwidth (span of bins within 10 dB of the peak) + peakiness — peak_bin_power / median_bin_power (dB); a single CW tone is a + tall narrow spike (high peakiness, occ_mhz ~ 0); a modulated + OFDM waveform fills the channel (low peakiness, occ_mhz ~ BW). + +Uncalibrated relative dBFS, like sdr_power_probe.py. Pair with a fixed geometry. + + python3 sdr_spectrum.py --freq 5180e6 --rate 25e6 --gain 40 --duration 2 +""" +from __future__ import annotations + +import argparse +import sys + +import numpy as np + +try: + import uhd +except ImportError: + sys.stderr.write("sdr_spectrum: `import uhd` failed — use the " + "--system-site-packages venv (see tests/pyproject.toml).\n") + raise + + +def capture(freq, rate, gain, antenna, dev_args, nsamp): + usrp = uhd.usrp.MultiUSRP(dev_args) + usrp.set_rx_rate(rate) + usrp.set_rx_freq(uhd.types.TuneRequest(freq)) + usrp.set_rx_gain(gain) + usrp.set_rx_antenna(antenna) + st_args = uhd.usrp.StreamArgs("fc32", "sc16") + st_args.channels = [0] + rx = usrp.get_rx_stream(st_args) + md = uhd.types.RXMetadata() + buf = np.zeros((1, rx.get_max_num_samps()), dtype=np.complex64) + out = np.empty(nsamp, dtype=np.complex64) + got = 0 + sc = uhd.types.StreamCMD(uhd.types.StreamMode.start_cont) + sc.stream_now = True + rx.issue_stream_cmd(sc) + while got < nsamp: + n = rx.recv(buf, md) + if md.error_code != uhd.types.RXMetadataErrorCode.none or n == 0: + continue + take = min(n, nsamp - got) + out[got:got + take] = buf[0, :take] + got += take + rx.issue_stream_cmd(uhd.types.StreamCMD(uhd.types.StreamMode.stop_cont)) + return out + + +def welch_psd(x, rate, nfft=1024): + win = np.hanning(nfft) + step = nfft // 2 + acc = np.zeros(nfft) + k = 0 + for i in range(0, len(x) - nfft, step): + seg = x[i:i + nfft] * win + acc += np.abs(np.fft.fftshift(np.fft.fft(seg))) ** 2 + k += 1 + psd = acc / max(k, 1) + # Null the DC bins — the B210 has a fixed LO-leakage / DC-offset spike at the + # tuned center that would masquerade as a tone. Replace the center +-2 bins + # with the local median so occupancy/peakiness reflect the real signal. + c = nfft // 2 + psd[c - 2:c + 3] = np.median(psd) + psd_db = 10 * np.log10(psd + 1e-12) + freqs = np.fft.fftshift(np.fft.fftfreq(nfft, 1.0 / rate)) + return freqs, psd_db + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--freq", type=float, default=5180e6) + ap.add_argument("--rate", type=float, default=25e6) + ap.add_argument("--gain", type=float, default=40.0) + ap.add_argument("--antenna", default="RX2") + ap.add_argument("--args", default="type=b200") + ap.add_argument("--duration", type=float, default=1.5) + ap.add_argument("--label", default="") + args = ap.parse_args() + + nsamp = max(1 << 16, int(args.rate * args.duration)) + x = capture(args.freq, args.rate, args.gain, args.antenna, args.args, nsamp) + freqs, psd = welch_psd(x, args.rate) + + peak = psd.max() + med = np.median(psd) + peakiness = peak - med + # Occupied bandwidth: contiguous-ish span of bins within 10 dB of peak. + mask = psd >= peak - 10.0 + occ_hz = (freqs[mask].max() - freqs[mask].min()) if mask.any() else 0.0 + occ_mhz = occ_hz / 1e6 + + # Coarse ASCII spectrum (downsample to ~60 columns). + cols = 60 + binw = len(psd) // cols + ds = np.array([psd[i * binw:(i + 1) * binw].max() for i in range(cols)]) + lo, hi = ds.min(), ds.max() + span = (hi - lo) or 1.0 + ramp = " .:-=+*#%@" + line = "".join(ramp[min(len(ramp) - 1, int((v - lo) / span * (len(ramp) - 1)))] + for v in ds) + lbl = f"[{args.label}] " if args.label else "" + print(f"{lbl}{args.freq/1e6:.0f} MHz, {args.rate/1e6:.0f} MS/s") + print(f" {line}") + print(f" peakiness={peakiness:.1f} dB occ_bw={occ_mhz:.1f} MHz " + f"(tone: high peakiness/low occ; modulated: low peakiness/wide occ)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/txdemo/main.cpp b/txdemo/main.cpp index 8c31eff..4e33773 100644 --- a/txdemo/main.cpp +++ b/txdemo/main.cpp @@ -536,6 +536,32 @@ int main(int argc, char **argv) { const devourer::TxMode tx_mode_base = devourer::parse_tx_mode_env(); rtlDevice->SetTxMode(tx_mode_base); + /* DEVOURER_CONT_TX: arm modulated continuous TX (the sibling of the CW tone). + * All three generations engage a true 100%-duty hardware modulated carrier the + * demo idle-holds (cont_hw): Jaguar1 via the 0x914 continuous mode, Jaguar2 via + * the same once rCCAonSec (0x838) is set, Jaguar3 via the JGR3 PMAC packet + * generator (a fixed 6M OFDM carrier). Rate from DEVOURER_TX_RATE (Jaguar1/2). */ + const bool cont_tx = std::getenv("DEVOURER_CONT_TX") != nullptr; + bool cont_hw = false; /* true = HW continuous engaged (idle-hold); false = feed */ + if (cont_tx) { + bool armed = false; +#if defined(DEVOURER_HAVE_JAGUAR1) + if (jag) { jag->StartContinuousTx(tx_mode_base); armed = true; cont_hw = true; } +#endif +#if defined(DEVOURER_HAVE_JAGUAR2) + if (jag2) { jag2->StartContinuousTx(tx_mode_base); armed = true; cont_hw = true; } +#endif +#if defined(DEVOURER_HAVE_JAGUAR3) + if (jag3) { jag3->StartContinuousTx(tx_mode_base); armed = true; cont_hw = true; } +#endif + if (armed) + logger->info("DEVOURER_CONT_TX: armed ({})", + cont_hw ? "HW 100%-duty carrier, idle-hold" + : "rate applied, beacon feed"); + else + logger->error("DEVOURER_CONT_TX set but device is not a supported chip"); + } + /* DEVOURER_TX_STBC_TOGGLE=1 alternates the STBC bit every frame (keeping the * rate from DEVOURER_TX_RATE, which must be HT/VHT for STBC to apply). The RX * reports the received STBC bit per frame, so an alternating TX lets one @@ -807,6 +833,22 @@ int main(int argc, char **argv) { } } + /* DEVOURER_CONT_TX HW-continuous idle-hold (all generations): the continuous + * mode holds a 100%-duty modulated carrier once armed. Prime a few frames so + * the Jaguar1/2 engine has a PPDU to repeat (harmless on Jaguar3, whose PMAC + * self-generates), then idle-hold — the carrier self-radiates. SIGINT falls + * through to StopContinuousTx in the de-init below. NB: this is the + * spectral/thermal stimulus; the link probe uses the plain feed + power ramp + * (no DEVOURER_CONT_TX) for decodable per-frame SNR. */ + if (cont_tx && cont_hw) { + for (int i = 0; i < 64 && !g_devourer_should_stop; i++) + rtlDevice->send_packet(tx_buf.data(), tx_buf.size()); /* prime (NAKs ok) */ + logger->info("DEVOURER_CONT_TX hold — 100%%-duty modulated carrier up, " + "idling until SIGINT (Ctrl-C to stop)"); + while (!g_devourer_should_stop) + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + while (!g_devourer_should_stop) { if (tx_count == 0) { logger->info("init-timing: txdemo.first_tx_submit = {} ms", @@ -926,6 +968,21 @@ int main(int argc, char **argv) { * (core dump). Setting the flag also covers the back-off exit path, where no * signal was delivered. */ g_devourer_should_stop = true; + + /* Undo modulated continuous TX before de-init (clears the 0x914/0x1ca4 hold + + * BB reset), so the chip returns to normal TX/RX and re-enumerates cleanly. */ + if (cont_tx) { +#if defined(DEVOURER_HAVE_JAGUAR1) + if (jag) jag->StopContinuousTx(); +#endif +#if defined(DEVOURER_HAVE_JAGUAR2) + if (jag2) jag2->StopContinuousTx(); +#endif +#if defined(DEVOURER_HAVE_JAGUAR3) + if (jag3) jag3->StopContinuousTx(); +#endif + } + /* Join the RX loop BEFORE Stop(): the chip de-init must not race in-flight * RX URBs (and StartRxLoop's event pump must exit before libusb_exit). */ rtlDevice->StopRxLoop();