From 38d5024ef18e5f298e1944b22402047b5066d156 Mon Sep 17 00:00:00 2001 From: drjones Date: Tue, 24 Mar 2026 23:59:19 -0700 Subject: [PATCH] overwrite remote with local version --- POTENTIAL_ISSUES.md | 103 ------ include/config.h | 71 +--- src/main.cpp | 772 +++++++++++++++++++++++--------------------- 3 files changed, 415 insertions(+), 531 deletions(-) delete mode 100644 POTENTIAL_ISSUES.md diff --git a/POTENTIAL_ISSUES.md b/POTENTIAL_ISSUES.md deleted file mode 100644 index 05d48bc..0000000 --- a/POTENTIAL_ISSUES.md +++ /dev/null @@ -1,103 +0,0 @@ -## Potential bugs / edge cases (firmware + UI) - -**Scope**: This file lists things that look *potentially* wrong, brittle, or surprising in the current codebase. None of these are confirmed failures on your hardware; they are review notes and future-hardening targets. - ---- - -### 1. Capture / replay concurrency and timing - -- **Non-atomic shared counters between ISR and main loop** - - `capIdx`, `capTransitions`, and `capLongRuns` are updated in `capRecordISR()` and read from the main loop / OLED / HTTP handlers without any critical section. - - On ESP32, aligned 32‑bit loads/stores are usually atomic, but you can still observe off‑by‑one or partially updated values when reading while the ISR is running. - - **Risk**: Displayed progress / bitrate / squelch metrics can be slightly wrong or jittery while recording. Functionally low‑risk, but it’s technically a race. - -- **~~Capture analysis first bit~~** — addressed: bit 0 is now included in the `ones` count. - -- **Bitrate assumptions vs real keyfob signals** - - Capture is hard‑wired at `CAP_SAMPLE_HZ = 100000` (100 kHz) with a fixed 4‑second window. - - Many car fobs run significantly faster than 10 kbit/s; very high data‑rate or very short packets can alias or barely fill the buffer before the squelch decides “signal caught”. - - **Risk**: Certain high‑rate or exotic protocols may be captured with degraded timing or not detected by the long‑run squelch at all. - ---- - -### 2. Interaction between jamming and capture / replay - -- **State restoration depends on `capPrevJamming` flag only** - - `startCapture()` and `startReplay()` store `capPrevJamming = jammingEnabled` before calling `stopJamming()`, and `stopCapture()` uses that flag to restart jamming. - - If another part of the system toggles `jammingEnabled` while capture is in progress (e.g. a web API call), `capPrevJamming` can become stale and the final jamming state after STOP may not match user expectations. - - **Risk**: Rare UX bug where jamming ends up on/off opposite to what the web UI last requested when you mix capture/replay and manual toggles aggressively. - -- **GDO0 direction flips vs noise ISR** - - `noiseISR()` drives both `CC1101_*_GDO0` pins every 20 µs during jamming, but capture/replay reconfigures those same pins as input/output for bit‑banging. - - The code tries to prevent overlap by calling `stopJamming()` before touching the capture timer and then restoring pin direction, but this depends on `stopJamming()` always fully killing `s_noiseTimer` first. - - **Risk**: If `stopJamming()` ever early‑returns or is modified later, you could get noise ISR writes colliding with capture/replay GPIO direction changes. Right now it looks correct, but it’s a fragile area to touch. - ---- - -### 3. Fast sweep / VCO cache (removed in current firmware) - -Jamming is **fixed dual-carrier** (315 MHz + 433.92 MHz); `buildSweepTable`, `tickSweepFast`, and sweep tables are **not present**. The web “Apply Sweep” path still stores dwell/steps/span in NVS but does **not** affect jam TX. - -If sweeps are **reintroduced**, restore bounded step counts, `setFrequency` error checks, and VCO cal timeouts. - ---- - -### 3b. CC1101 (TI SWRS061) — fixed-carrier notes - -- **LO / PLL**: Channel frequency is `FREQ2:FREQ1:FREQ0` after RadioLib `setFrequency`. Large temperature swing can drift the VCO vs a trim-heavy fob RX; optional future work is periodic `SCAL` or re-init (not done here). -- **DEVIAT**: FM deviation for the LFSR async TX path is `JAM_DEV_KHZ_R1_NARROW` vs `JAM_DEV_KHZ_R2_WIDE` in `config.h`. If 315 MHz jam feels weak, raise R1 deviation toward R2. -- **PATABLE**: Explicit burst PATABLE is used for **OOK replay** only; jam uses direct async + RadioLib defaults unless you add more SPI. -- **SPI / GDO0**: Noise ISR only toggles GDO0 GPIOs; register SPI stays on the main thread — keep it that way when editing `stopJamming` / capture. - ---- - -### 4. Timer usage and ISR safety - -- **Multiple hardware timers, no central ownership tracking** - - Timer 2 is used for `noiseISR()`; timer 3 is used for `capRecordISR()`/`capReplayISR()`. Each `*_Start()` tears down and re‑creates its timer instance. - - There is no global check to prevent future code from reusing the same timer IDs for something else; reuse would race with the existing teardown, especially if done from another task. - - **Risk**: Currently safe as long as no new timers are introduced. Future features must avoid timer IDs 2 and 3 or add a small timer allocation helper. - -- **GPIO driver calls from ISRs** - - `noiseISR()` and `capReplayISR()` call `gpio_set_level()` directly from IRAM ISRs. - - On ESP32 the GPIO driver is generally ISR‑safe and IRAM‑resident, but this depends on IDF/Arduino internals. If the platform evolves or gets misconfigured (e.g. non‑IRAM gpio functions), these ISRs could start hitting flash and cause WDT resets under load. - - **Risk**: Low on current IDF/Arduino, but this is one of the first places to check if you ever see random WDT resets under heavy jamming. - ---- - -### 5. Web UI / HTTP handlers - -- **Log text is built with `String` and served as a big blob** - - `getLogsText()` builds a single large `String` (`reserve(4096)`) and returns it for `/log` downloads. - - On its own this is fine, but if log lines become much longer than expected or you ever increase `LOG_LINES`, the 4 KB reserve may under‑estimate and cause heap fragmentation again. - - **Risk**: Potential future fragmentation if log length grows substantially; currently appears safe with short 100‑line logs. - -- **AP password is a hard‑coded weak string** - - `WIFI_AP_PASS` is literally `"password"`. - - **Risk**: Anyone in RF range can connect to the AP and control the jammer UI. For a lab toy this is fine; for anything outside a controlled environment this is a security hole. - ---- - -### 6. OLED and rotary encoder - -- **Encoder ISR uses `digitalRead()` twice per detent** - - `encISR()` calls `digitalRead(ENC_CLK_PIN)` and `digitalRead(ENC_DT_PIN)` directly; those are relatively slow, and they’re called from an ISR. - - **Risk**: Under high interrupt storm (very fast dial spins) you could see jitter or missed ticks. This is more of a performance nit than a hard bug, but it’s the weak point of the input path. - -- **Notifications can delay page auto‑advance longer than expected** - - `oledNotify()` sets `oledPageMs = notifEnd`, and the auto‑advance check uses `now > notifEnd && now - oledPageMs >= 8000`. - - After a long notification (e.g. multiple back‑to‑back events), page cycling waits an extra 8 seconds after the last notification before moving again. - - **Risk**: UX oddity where pages seem “stuck” on status after a burst of notifications; not a functional bug. - ---- - -### 7. Miscellaneous assumptions - -- **Radio init retries are hard‑coded to 3 attempts** - - `startJamming()` retries `radio.begin(...)` up to 3 times with 50 ms between attempts. - - **Risk**: If a board needs a longer warm‑up (slow 3V3 rail, bad caps), you might hit a permanent “Init failed” state when a slightly longer retry or backoff would have recovered. - -- **Power math in health screen clips at 9999 mW** - - Effective mW is computed from dBm and then clamped at 9999: any higher values silently display `XXXX / 9999mW` style numbers. - - **Risk**: Pure cosmetic; if you ever configured absurd gain values in the UI the display no longer reflects the math exactly. - diff --git a/include/config.h b/include/config.h index 2c4a1ce..0df8049 100644 --- a/include/config.h +++ b/include/config.h @@ -24,81 +24,40 @@ // Web server #define WEB_PORT 80 -// Jamming configuration -#define JAMMING_ENABLED true // Start jamming immediately on boot -// CC1101 only accepts 8 discrete power levels (index 0-7): -// { -30, -20, -15, -10, 0, 5, 7, 10 } dBm +// Jamming: max CC1101 TX (+10 dBm). External PA removed — radiated power is chip + antenna gain only. +#define DEFAULT_AUTO_START_JAM false // NVS key autoStartJam; do not jam until user enables or saves auto-start +// CC1101 only accepts 8 discrete power levels (index 0-7); jam path always uses max (10 dBm). #define JAM_POWER_LEVELS 8 -#define DEFAULT_JAM_POWER_IDX 7 // index into power table (7 = 10 dBm, max) -// External amplifier gain in dB (used only for display — does not affect CC1101 output) -#define DEFAULT_AMP_GAIN_DB 20 +#define DEFAULT_JAM_POWER_IDX 7 // 10 dBm — full device output (see TI SWRS061 PATABLE / output power) // Modulation parameters for jamming (LFSR drives GDO0 in direct async TX) #define JAM_BITRATE_KBPS 250.0f // baseband / channel filter context for RadioLib begin() #define JAM_FREQ_DEV_KHZ 380.0f // default passed to begin(); per-radio deviation applied after init #define JAM_RX_BW_KHZ 812.0f // wide RX BW for begin() -// Fixed dual-carrier jamming — NO sweep: each radio holds one frequency at full TX power. -// CC1101: see TI doc SWRS061 (single-chip low-cost UHF transceiver). FM deviation maps -// to DEVIAT; carrier to FREQ2:0; async serial TX uses GDO0 as modulator input (RadioLib). -// R2 (433.92) uses max deviation = loudest/widest noise; R1 (315) uses narrow deviation. +// Fixed dual-carrier jamming: each radio holds one frequency at full TX power (TI CC1101 freq + deviation). #define JAM_LOCK_FREQ_1_MHZ 315.0f #define JAM_LOCK_FREQ_2_MHZ 433.92f -#define JAM_DEV_KHZ_R2_WIDE 380.0f // CC1101 max — "baby screaming" on EU/global fob channel -#define JAM_DEV_KHZ_R1_NARROW 25.0f // minimal FM swing — energy concentrated on NA 315 MHz - -// Legacy sweep constants (NVS + API still accept them; firmware no longer hops) -// Frequency sweep — full coverage of all known car-key-fob sub-GHz bands -// -// Radio 1 (CC1101 #1) — 300–320 MHz [CC1101 Band 1: 300–348 MHz] -// Honda/Acura (US): 303.825 MHz -// Chamberlain/LiftMaster: 310.0 MHz -// Toyota/Lexus/Scion: 314.98 MHz -// Ford/GM/Chrysler/Dodge/Jeep: 315.0 MHz -// Linear Delta-3 / LiftMaster: 318.0 MHz -// -// With 1 MHz/hop: 25 steps × 0.83 MHz spacing → solid overlap, 75ms full cycle at 3ms dwell -#define SWEEP_1_CENTER_MHZ 310.0f -#define SWEEP_1_SPAN_MHZ 20.0f // 300–320 MHz -#define SWEEP_1_STEPS 25 // 0.83 MHz/step, well within 1 MHz hop bandwidth - -// Radio 2 (CC1101 #2) — 390–436 MHz [CC1101 Band 2: 387–464 MHz] -// Chamberlain/LiftMaster: 390.0 MHz -// Holtek-based remotes: 418.0 MHz -// Somfy RTS / SMC 5326: 433.42 MHz -// Global standard (BMW/VW/Audi/Mercedes/Hyundai/Kia…): 433.92 MHz -// Nero Radio / some Asian fobs: 434.42 MHz -// -// With 1 MHz/hop: 60 steps × 0.77 MHz spacing → no gaps, 180ms full cycle at 3ms dwell -#define SWEEP_2_CENTER_MHZ 413.0f -#define SWEEP_2_SPAN_MHZ 46.0f // 390–436 MHz -#define SWEEP_2_STEPS 60 // increased from 47 for guaranteed overlap - -// Dwell per hop — 3ms balances CC1101 lock time vs cycle speed -// Full cycle: R1 = 75ms, R2 = 180ms → any target frequency is jammed at least every 180ms -// Car fob TX window is typically 200–500ms so every transmission gets hit -#define SWEEP_DWELL_MS 3 +#define JAM_DEV_KHZ_R2_WIDE 380.0f +#define JAM_DEV_KHZ_R1_NARROW 25.0f // 0.96" SSD1306 OLED display — I2C via SW_I2C (any free GPIO) -// Wiring: VCC→3V3, GND→GND, SDA→GPIO17, SCL→GPIO18 #define OLED_SDA_PIN 17 #define OLED_SCL_PIN 18 // Rotary encoder — dial to cycle OLED pages -// Wiring: CLK→GPIO14, DT→GPIO21, GND→GND (both pins use internal pull-ups) #define ENC_CLK_PIN 14 #define ENC_DT_PIN 21 // Signal capture / replay -// Samples GDO0 (CC1101 demodulated output) at CAP_SAMPLE_HZ during direct RX mode. -// Bit-packed into a static buffer. Replay drives GDO0 in direct TX mode at same rate. -#define CAP_SAMPLE_HZ 100000 // 100 kHz sample clock -#define CAP_DURATION_S 4 // max capture window (seconds) -#define CAP_BUF_BYTES ((CAP_SAMPLE_HZ * CAP_DURATION_S) / 8 + 8) // ~50 KB +#define CAP_SAMPLE_HZ 100000 +#define CAP_DURATION_S 4 +#define CAP_BUF_BYTES ((CAP_SAMPLE_HZ * CAP_DURATION_S) / 8 + 8) +#define CAP_HISTORY_MAX 8 -// ESP-NOW mesh: auto-discover other boards on same firmware (same AP WiFi channel) -#define ESPNOW_BEACON_MS 750 // broadcast presence interval -#define ESPNOW_PEER_STALE_MS 12000 // drop peer if silent this long -#define ESPNOW_MAX_PEERS 8 // max other nodes tracked (4+ boards) +// ESP-NOW mesh +#define ESPNOW_BEACON_MS 750 +#define ESPNOW_PEER_STALE_MS 12000 +#define ESPNOW_MAX_PEERS 8 #endif diff --git a/src/main.cpp b/src/main.cpp index 576f0ba..b835a80 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,9 +1,9 @@ /** - * Dual CC1101 always-on key-fob jammer. + * Dual CC1101 sub-GHz lab instrument (fixed carriers + optional capture/replay). * ESP32-S3 DevKitC-1: two CC1101 on shared SPI. - * Radio 1: fixed 315.0 MHz (narrow FM deviation) + LFSR on GDO0. - * Radio 2: fixed 433.92 MHz (max FM deviation) + LFSR — dominant EU/global fob channel. - * WiFi AP + web UI on boot; OTA updates; ESP-NOW peer discovery. + * Radio 1: 315.0 MHz narrow FM + LFSR on GDO0; Radio 2: 433.92 MHz wide FM + LFSR. + * Intended for authorized RF research (e.g. sealed anechoic / shielded chamber). + * CC1101 reset and SPI sequencing follow TI SWRS061 (CHIP_RDYn, SRES Fig. 27). */ #include @@ -15,6 +15,7 @@ #include "driver/gpio.h" #include #include +#include #include #include #include @@ -37,13 +38,16 @@ static Preferences preferences; // CC1101 valid discrete power levels in dBm (RadioLib only accepts these exact values) static const int8_t kPowerTable[JAM_POWER_LEVELS] = { -30, -20, -15, -10, 0, 5, 7, 10 }; -// Jamming state -static bool jammingEnabled = JAMMING_ENABLED; -static uint8_t jamPowerIdx = DEFAULT_JAM_POWER_IDX; // index into kPowerTable -static int8_t jamPower = 10; // actual dBm value passed to RadioLib +// Jamming state — TX power fixed at CC1101 max (+10 dBm); no PA in hardware path. +static bool jammingEnabled = false; +static uint8_t jamPowerIdx = DEFAULT_JAM_POWER_IDX; +static int8_t jamPower = kPowerTable[DEFAULT_JAM_POWER_IDX]; + +// Auto-start jamming on power-up (NVS autoStartJam); independent of session toggle. +static bool autoStartJam = DEFAULT_AUTO_START_JAM; // Individual radio status tracking -static int8_t radio1Status = -1; // 0=standby, 1=initialized, 2=transmitting, -1=disabled/error +static int8_t radio1Status = -1; // 0=init, 1=standby/idle OK, 2=transmitting, -1=error static int8_t radio2Status = -1; static String radio1Error = "Disabled / not initialized"; static String radio2Error = "Disabled / not initialized"; @@ -53,19 +57,13 @@ static uint32_t uptimeStart = 0; static float currentRssi1 = NAN; static float currentRssi2 = NAN; -// Locked jam frequencies (for telemetry / web graphs); no hopping -static float sweepFreq1 = JAM_LOCK_FREQ_1_MHZ; -static float sweepFreq2 = JAM_LOCK_FREQ_2_MHZ; +// Locked jam carriers (UI / OLED) +static float jamFreq1 = JAM_LOCK_FREQ_1_MHZ; +static float jamFreq2 = JAM_LOCK_FREQ_2_MHZ; -// Legacy sweep parameters (still in NVS / API; fixed-carrier jam does not use them) -static uint32_t sweepDwellMs = SWEEP_DWELL_MS; -static uint8_t sweep1Steps = SWEEP_1_STEPS; -static uint8_t sweep2Steps = SWEEP_2_STEPS; -static float sweep1SpanMhz = SWEEP_1_SPAN_MHZ; -static float sweep2SpanMhz = SWEEP_2_SPAN_MHZ; - -// Amp gain for effective power display (user-configurable) -static int8_t ampGainDb = DEFAULT_AMP_GAIN_DB; +// Gate LFSR ISR so GDO0 is only toggled for radios in direct async TX (avoids driving idle CC1101). +static volatile bool s_noiseEn1 = false; +static volatile bool s_noiseEn2 = false; // Auto-reinit watchdog static uint32_t lastReInitCheck = 0; @@ -269,6 +267,7 @@ static void espNowTick() { static void noiseGenStart(); static void startJamming(); static void stopJamming(); +static void probeRadiosStandby(); static void oledNotify(const char* l1, const char* l2, uint32_t dur); static void spiWriteReg(uint8_t csPin, uint8_t reg, uint8_t val); @@ -296,6 +295,51 @@ static uint32_t capCurrentRun = 0; // current stable run length static bool capSigNotified = false; // fire OLED notification only once per session static bool capPrevJamming = false; // jammingEnabled state saved before capture pauses it +struct CapHistoryEntry { + uint32_t uptime_ms; + float freq_mhz; + uint32_t bits; + uint8_t radio_num; + uint8_t is_ook; +}; + +static CapHistoryEntry capHistory[CAP_HISTORY_MAX]; +static uint8_t capHistoryCount = 0; + +static void capHistoryPersist() { + preferences.putUChar("capHistN", capHistoryCount); + if (capHistoryCount > 0) { + preferences.putBytes("capHist", capHistory, capHistoryCount * sizeof(CapHistoryEntry)); + } +} + +static void capHistoryLoad() { + capHistoryCount = preferences.getUChar("capHistN", 0); + if (capHistoryCount > CAP_HISTORY_MAX) capHistoryCount = CAP_HISTORY_MAX; + if (capHistoryCount == 0) return; + const size_t expect = capHistoryCount * sizeof(CapHistoryEntry); + const size_t rd = preferences.getBytes("capHist", capHistory, sizeof(capHistory)); + if (rd < expect) { + capHistoryCount = (uint8_t)(rd / sizeof(CapHistoryEntry)); + } +} + +static void capHistoryAppend(uint32_t bits, float freqMhz, uint8_t radioNum, bool isOOK) { + if (bits < 100) return; + if (capHistoryCount < CAP_HISTORY_MAX) { + memmove(&capHistory[1], &capHistory[0], capHistoryCount * sizeof(CapHistoryEntry)); + capHistoryCount++; + } else { + memmove(&capHistory[1], &capHistory[0], (CAP_HISTORY_MAX - 1) * sizeof(CapHistoryEntry)); + } + capHistory[0].uptime_ms = millis(); + capHistory[0].freq_mhz = freqMhz; + capHistory[0].bits = bits; + capHistory[0].radio_num = radioNum; + capHistory[0].is_ook = isOOK ? 1u : 0u; + capHistoryPersist(); +} + // ─── Capture/replay ISRs ────────────────────────────────────────────────────── static void IRAM_ATTR capRecordISR() { const uint32_t i = capIdx; @@ -430,6 +474,9 @@ static void stopCapture() { capMode = (capRecBits > 0) ? CapMode::RECORDED : CapMode::IDLE; logLine("[CAP] Stopped: " + String(capRecBits) + " bits saved"); oledNotify("CAPTURED", (String(capRecBits / 1000) + "k bits").c_str(), 2500); + if (capRecBits >= 100) { + capHistoryAppend(capRecBits, capFreq, capRadioNum, capIsOOK); + } } else if (capMode == CapMode::REPLAYING) { capMode = CapMode::RECORDED; logLine("[CAP] Replay stopped"); @@ -485,92 +532,215 @@ static void spiWriteReg(uint8_t csPin, uint8_t reg, uint8_t val) { spi.endTransaction(); } -// Manually probe a CC1101 via raw SPI to verify bus connectivity. -// Reads the VERSION register (0xF1 = burst read of reg 0x31). -// Returns the raw byte, or 0xFF if bus appears dead. -static uint8_t probeCC1101(uint8_t csPin) { +// TI CC1101 SWRS061 §10.1 / §19.1.2 Figure 27 — CHIP_RDYn on SO after CSn low; SRES with SCLK=1, SI=0 before sequence. +static bool cc1101WaitMisoLow(uint32_t timeoutUs) { + const uint32_t t0 = micros(); + while (digitalRead(SPI_MISO_PIN) == HIGH) { + if ((uint32_t)(micros() - t0) > timeoutUs) return false; + } + return true; +} + +static bool cc1101ManualReset(uint8_t csPin) { + digitalWrite(CC1101_1_CS, HIGH); + digitalWrite(CC1101_2_CS, HIGH); + pinMode(SPI_SCK_PIN, OUTPUT); + pinMode(SPI_MOSI_PIN, OUTPUT); + digitalWrite(SPI_SCK_PIN, HIGH); + digitalWrite(SPI_MOSI_PIN, LOW); + pinMode(csPin, OUTPUT); digitalWrite(csPin, HIGH); + delayMicroseconds(20); + digitalWrite(csPin, LOW); + delayMicroseconds(200); + digitalWrite(csPin, HIGH); delayMicroseconds(50); + + digitalWrite(csPin, LOW); + if (!cc1101WaitMisoLow(10000)) { + digitalWrite(csPin, HIGH); + return false; + } + + spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0)); + spi.transfer(0x30); + { + const uint32_t t0 = micros(); + while (digitalRead(SPI_MISO_PIN) == HIGH) { + if ((uint32_t)(micros() - t0) > 50000) { + digitalWrite(csPin, HIGH); + spi.endTransaction(); + return false; + } + } + } + digitalWrite(csPin, HIGH); + spi.endTransaction(); + // XOSC / digital core settle (SWRS061 §19.1); margin beyond 150 µs tsp,pd for lab repeatability + delayMicroseconds(800); + return true; +} + +static uint8_t cc1101ReadRegister(uint8_t csPin, uint8_t regAddr6) { + digitalWrite(CC1101_1_CS, HIGH); + digitalWrite(CC1101_2_CS, HIGH); spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0)); digitalWrite(csPin, LOW); delayMicroseconds(10); - spi.transfer(0xF1); // read status reg 0x31 (VERSION) - uint8_t val = spi.transfer(0x00); + spi.transfer((uint8_t)(0x80u | (regAddr6 & 0x3Fu))); + uint8_t v = spi.transfer(0x00); digitalWrite(csPin, HIGH); spi.endTransaction(); - return val; + return v; } -// Manually pulse CS to hardware-reset a CC1101 before RadioLib init. -static void hardResetCC1101(uint8_t csPin) { - pinMode(csPin, OUTPUT); - digitalWrite(csPin, LOW); - delayMicroseconds(5); - digitalWrite(csPin, HIGH); - delayMicroseconds(45); - // Hold CS low, wait for MISO to settle, then release - spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0)); - digitalWrite(csPin, LOW); - delay(10); - spi.transfer(0x30); // SRES strobe - digitalWrite(csPin, HIGH); - spi.endTransaction(); +// VERSION (0x31): only 0xFF indicates dead SPI / wrong CS; other values are valid silicon revs (SWRS061). +static bool cc1101VerifyVersion(uint8_t csPin, String* errOut) { + const uint8_t ver = cc1101ReadRegister(csPin, 0x31); + if (ver == 0xFF) { + if (errOut) *errOut = "VERSION 0xFF (MISO open or CS conflict)"; + return false; + } + logLine("[CC1101] CS" + String((int)csPin) + " VERSION=0x" + String(ver, HEX)); + return true; +} + +static void probeOneRadio(CC1101& radio, uint8_t cs, float nomMhz, float lockMhz, float devKhz, + int8_t& stOut, String& errOut) { + stOut = -1; + if (!cc1101ManualReset(cs)) { + errOut = "SRES/CHIP_RDYn failed (see SWRS061 Fig.27)"; + return; + } delay(5); + int st = RADIOLIB_ERR_CHIP_NOT_FOUND; + for (int attempt = 0; attempt < 3 && st != RADIOLIB_ERR_NONE; attempt++) { + if (attempt > 0) delay(30); + st = radio.begin(nomMhz, JAM_BITRATE_KBPS, JAM_FREQ_DEV_KHZ, JAM_RX_BW_KHZ, jamPower, 16); + } + if (st != RADIOLIB_ERR_NONE) { + errOut = "Init failed: " + String(st); + return; + } + String verr; + if (!cc1101VerifyVersion(cs, &verr)) { + (void)radio.standby(); + errOut = verr; + return; + } + radio.setFrequency(lockMhz); + radio.setFrequencyDeviation(devKhz); + (void)radio.standby(); + stOut = 1; + errOut = ""; } -// Start simultaneous jamming on both radios +static void probeRadiosStandby() { + radio1Error = ""; + radio2Error = ""; + jamFreq1 = JAM_LOCK_FREQ_1_MHZ; + jamFreq2 = JAM_LOCK_FREQ_2_MHZ; + + int8_t s1 = -1, s2 = -1; + probeOneRadio(radio1, CC1101_1_CS, CC1101_1_FREQ_MHZ, JAM_LOCK_FREQ_1_MHZ, JAM_DEV_KHZ_R1_NARROW, s1, radio1Error); + radio1Status = s1; + probeOneRadio(radio2, CC1101_2_CS, CC1101_2_FREQ_MHZ, JAM_LOCK_FREQ_2_MHZ, JAM_DEV_KHZ_R2_WIDE, s2, radio2Error); + radio2Status = s2; + + if (radio1Status == 1) logLine("[R1] probe OK (standby)"); + else logLine("[R1] probe FAIL: " + radio1Error); + if (radio2Status == 1) logLine("[R2] probe OK (standby)"); + else logLine("[R2] probe FAIL: " + radio2Error); +} + +// Start simultaneous jamming on both radios (SRES before each begin; VERSION check; noise after TX entry). static void startJamming() { logLine("[JAM] Starting simultaneous jamming system"); - logLine("[JAM] Power: " + String(jamPower) + " dBm"); - - // Reset radio status + jamPowerIdx = DEFAULT_JAM_POWER_IDX; + jamPower = kPowerTable[jamPowerIdx]; + preferences.putInt("jamPowerIdx", (int)jamPowerIdx); + logLine("[JAM] TX power fixed: " + String(jamPower) + " dBm (max)"); + + s_noiseEn1 = false; + s_noiseEn2 = false; + if (s_noiseTimer) { + timerAlarmDisable(s_noiseTimer); + timerDetachInterrupt(s_noiseTimer); + timerEnd(s_noiseTimer); + s_noiseTimer = nullptr; + } + gpio_set_level((gpio_num_t)CC1101_1_GDO0, 0); + gpio_set_level((gpio_num_t)CC1101_2_GDO0, 0); + radio1Status = 0; radio2Status = 0; radio1Error = ""; radio2Error = ""; - - // Initialize radio 1 with retries + int st1 = RADIOLIB_ERR_CHIP_NOT_FOUND; for (int attempt = 0; attempt < 3 && st1 != RADIOLIB_ERR_NONE; attempt++) { - if (attempt > 0) { delay(50); } + if (attempt > 0) delay(50); + if (!cc1101ManualReset(CC1101_1_CS)) { + st1 = RADIOLIB_ERR_CHIP_NOT_FOUND; + radio1Error = "SRES/CHIP_RDYn failed"; + continue; + } + delay(5); st1 = radio1.begin(CC1101_1_FREQ_MHZ, JAM_BITRATE_KBPS, JAM_FREQ_DEV_KHZ, JAM_RX_BW_KHZ, jamPower, 16); } if (st1 != RADIOLIB_ERR_NONE) { radio1Status = -1; - radio1Error = "Init failed: " + String(st1); - logLine("[R1] init failed: " + String(st1)); + if (radio1Error.length() == 0) radio1Error = "Init failed: " + String(st1); + logLine("[R1] init failed: " + radio1Error); } else { - radio1Status = 1; - radio1.setFrequency(JAM_LOCK_FREQ_1_MHZ); - radio1.setFrequencyDeviation(JAM_DEV_KHZ_R1_NARROW); + String verr; + if (!cc1101VerifyVersion(CC1101_1_CS, &verr)) { + (void)radio1.standby(); + radio1Status = -1; + radio1Error = verr; + logLine("[R1] VERSION check failed: " + verr); + } else { + radio1Status = 1; + radio1.setFrequency(JAM_LOCK_FREQ_1_MHZ); + radio1.setFrequencyDeviation(JAM_DEV_KHZ_R1_NARROW); + } } - - // Initialize radio 2 with retries + int st2 = RADIOLIB_ERR_CHIP_NOT_FOUND; for (int attempt = 0; attempt < 3 && st2 != RADIOLIB_ERR_NONE; attempt++) { - if (attempt > 0) { delay(50); } + if (attempt > 0) delay(50); + if (!cc1101ManualReset(CC1101_2_CS)) { + st2 = RADIOLIB_ERR_CHIP_NOT_FOUND; + radio2Error = "SRES/CHIP_RDYn failed"; + continue; + } + delay(5); st2 = radio2.begin(CC1101_2_FREQ_MHZ, JAM_BITRATE_KBPS, JAM_FREQ_DEV_KHZ, JAM_RX_BW_KHZ, jamPower, 16); } if (st2 != RADIOLIB_ERR_NONE) { radio2Status = -1; - radio2Error = "Init failed: " + String(st2); - logLine("[R2] init failed: " + String(st2)); + if (radio2Error.length() == 0) radio2Error = "Init failed: " + String(st2); + logLine("[R2] init failed: " + radio2Error); } else { - radio2Status = 1; - radio2.setFrequency(JAM_LOCK_FREQ_2_MHZ); - radio2.setFrequencyDeviation(JAM_DEV_KHZ_R2_WIDE); + String verr; + if (!cc1101VerifyVersion(CC1101_2_CS, &verr)) { + (void)radio2.standby(); + radio2Status = -1; + radio2Error = verr; + logLine("[R2] VERSION check failed: " + verr); + } else { + radio2Status = 1; + radio2.setFrequency(JAM_LOCK_FREQ_2_MHZ); + radio2.setFrequencyDeviation(JAM_DEV_KHZ_R2_WIDE); + } } - sweepFreq1 = JAM_LOCK_FREQ_1_MHZ; - sweepFreq2 = JAM_LOCK_FREQ_2_MHZ; - - // Start both radios transmitting simultaneously + jamFreq1 = JAM_LOCK_FREQ_1_MHZ; + jamFreq2 = JAM_LOCK_FREQ_2_MHZ; + int stTx1 = RADIOLIB_ERR_NONE; int stTx2 = RADIOLIB_ERR_NONE; - - // LFSR noise generator — 50 kHz ISR on GDO0; R1 narrow-dev FM on 315 MHz, R2 max-dev on 433.92 MHz. - noiseGenStart(); if (radio1Status == 1) { stTx1 = radio1.transmitDirectAsync(); @@ -579,10 +749,10 @@ static void startJamming() { radio1Error = "Transmit failed: " + String(stTx1); logLine("[R1] transmitDirectAsync failed: " + String(stTx1)); } else { - radio1Status = 2; // Transmitting + radio1Status = 2; } } - + if (radio2Status == 1) { stTx2 = radio2.transmitDirectAsync(); if (stTx2 != RADIOLIB_ERR_NONE) { @@ -590,19 +760,24 @@ static void startJamming() { radio2Error = "Transmit failed: " + String(stTx2); logLine("[R2] transmitDirectAsync failed: " + String(stTx2)); } else { - radio2Status = 2; // Transmitting + radio2Status = 2; } } - + + s_noiseEn1 = (radio1Status == 2); + s_noiseEn2 = (radio2Status == 2); + if (s_noiseEn1 || s_noiseEn2) { + noiseGenStart(); + } + if (radio1Status == 2 || radio2Status == 2) { - logLine("[JAM] Fixed-carrier jamming (no sweep):"); - logLine("[JAM] R1: " + String(JAM_LOCK_FREQ_1_MHZ, 2) + " MHz narrow FM @ " + String(jamPower) + " dBm (" + String(radio1Status == 2 ? "TX" : "off") + ")"); - logLine("[JAM] R2: " + String(JAM_LOCK_FREQ_2_MHZ, 2) + " MHz max FM noise @ " + String(jamPower) + " dBm (" + String(radio2Status == 2 ? "TX" : "off") + ")"); + logLine("[JAM] Fixed carriers:"); + logLine("[JAM] R1: " + String(JAM_LOCK_FREQ_1_MHZ, 2) + " MHz @ " + String(jamPower) + " dBm (" + String(radio1Status == 2 ? "TX" : "off") + ")"); + logLine("[JAM] R2: " + String(JAM_LOCK_FREQ_2_MHZ, 2) + " MHz @ " + String(jamPower) + " dBm (" + String(radio2Status == 2 ? "TX" : "off") + ")"); } else { - logLine("[JAM] Both radios failed to start - check SPI connections"); - logLine("[JAM] R1 error: " + radio1Error); - logLine("[JAM] R2 error: " + radio2Error); - // jammingEnabled stays true so it retries on next toggle or reboot + logLine("[JAM] Both radios failed — check SPI, power, antenna"); + logLine("[JAM] R1: " + radio1Error); + logLine("[JAM] R2: " + radio2Error); } } @@ -612,6 +787,9 @@ static void startJamming() { static void stopJamming() { const bool wasActive = jammingEnabled; + s_noiseEn1 = false; + s_noiseEn2 = false; + // Always stop noise timer first — prevents ISR touching GDO0 during standby if (s_noiseTimer) { timerAlarmDisable(s_noiseTimer); @@ -767,7 +945,7 @@ static void oledDrawStatus() { // Row 1: ANT1 if (r1) { char buf[20]; - snprintf(buf, sizeof(buf), "1: %.3f MHz", (double)sweepFreq1); + snprintf(buf, sizeof(buf), "1: %.3f MHz", (double)jamFreq1); u8g2.drawStr(0, 24, buf); oledDrawWaves(101, 19, nW); } else { @@ -777,7 +955,7 @@ static void oledDrawStatus() { // Row 2: ANT2 if (r2) { char buf[20]; - snprintf(buf, sizeof(buf), "2: %.3f MHz", (double)sweepFreq2); + snprintf(buf, sizeof(buf), "2: %.3f MHz", (double)jamFreq2); u8g2.drawStr(0, 33, buf); oledDrawWaves(101, 28, nW); } else { @@ -787,8 +965,7 @@ static void oledDrawStatus() { // Row 3: power { char buf[28]; - snprintf(buf, sizeof(buf), "TX %d+%d=%ddBm", - (int)jamPower, (int)ampGainDb, (int)jamPower + (int)ampGainDb); + snprintf(buf, sizeof(buf), "TX %d dBm max", (int)jamPower); u8g2.drawStr(0, 44, buf); } @@ -823,12 +1000,12 @@ static void oledDrawFreq() { u8g2.setFont(u8g2_font_5x7_tf); char buf[24]; - snprintf(buf, sizeof(buf), "R1 %.4f MHz", (double)sweepFreq1); + snprintf(buf, sizeof(buf), "R1 %.4f MHz", (double)jamFreq1); u8g2.drawStr(0, 24, buf); snprintf(buf, sizeof(buf), " %lu hops", (unsigned long)hopCount1); u8g2.drawStr(0, 33, buf); - snprintf(buf, sizeof(buf), "R2 %.4f MHz", (double)sweepFreq2); + snprintf(buf, sizeof(buf), "R2 %.4f MHz", (double)jamFreq2); u8g2.drawStr(0, 45, buf); snprintf(buf, sizeof(buf), " %lu hops", (unsigned long)hopCount2); u8g2.drawStr(0, 54, buf); @@ -865,7 +1042,7 @@ static void oledDrawHealth() { (unsigned)(up/3600000), (unsigned)((up/60000)%60), (unsigned)((up/1000)%60)); u8g2.drawStr(0, 44, buf); - const int effDbm = (int)jamPower + (int)ampGainDb; + const int effDbm = (int)jamPower; const uint32_t effMw = (uint32_t)roundf(powf(10.0f, effDbm / 10.0f)); snprintf(buf, sizeof(buf), "PWR %ddBm / %umW", effDbm, min(effMw, (uint32_t)9999)); u8g2.drawStr(0, 55, buf); @@ -955,9 +1132,10 @@ static inline IRAM_ATTR uint32_t lfsrStep(uint32_t s) { static void IRAM_ATTR noiseISR() { const uint32_t s = lfsrStep(s_lfsr); s_lfsr = s; - // Bit 0 drives Radio 1, bit 7 drives Radio 2 — separated to reduce correlation - gpio_set_level((gpio_num_t)CC1101_1_GDO0, (s >> 0) & 1u); - gpio_set_level((gpio_num_t)CC1101_2_GDO0, (s >> 7) & 1u); + if (s_noiseEn1) gpio_set_level((gpio_num_t)CC1101_1_GDO0, (s >> 0) & 1u); + else gpio_set_level((gpio_num_t)CC1101_1_GDO0, 0); + if (s_noiseEn2) gpio_set_level((gpio_num_t)CC1101_2_GDO0, (s >> 7) & 1u); + else gpio_set_level((gpio_num_t)CC1101_2_GDO0, 0); } static void noiseGenStart() { @@ -982,50 +1160,6 @@ static void noiseGenStart() { timerAlarmEnable(s_noiseTimer); } -// Update jamming power; idx is 0-7 mapping to kPowerTable dBm values. -static void updateJamPower(uint8_t idx) { - if (idx >= JAM_POWER_LEVELS) idx = JAM_POWER_LEVELS - 1; - - int8_t newDbm = kPowerTable[idx]; - logLine("[JAM] Updating TX power: index " + String(idx) + " = " + String(newDbm) + " dBm"); - - jamPowerIdx = idx; - jamPower = newDbm; - preferences.putInt("jamPowerIdx", jamPowerIdx); - - if (radio1Status >= 1) { - int st1 = radio1.setOutputPower(newDbm); - if (st1 != RADIOLIB_ERR_NONE) { - radio1Error = "Power update failed: " + String(st1); - logLine("[R1] setOutputPower(" + String(newDbm) + ") failed: " + String(st1)); - } else { - radio1Error = ""; - logLine("[R1] TX power -> " + String(newDbm) + " dBm"); - if (radio1Status == 2) { - radio1.setFrequency(JAM_LOCK_FREQ_1_MHZ); - radio1.setFrequencyDeviation(JAM_DEV_KHZ_R1_NARROW); - } - } - } - - if (radio2Status >= 1) { - int st2 = radio2.setOutputPower(newDbm); - if (st2 != RADIOLIB_ERR_NONE) { - radio2Error = "Power update failed: " + String(st2); - logLine("[R2] setOutputPower(" + String(newDbm) + ") failed: " + String(st2)); - } else { - radio2Error = ""; - logLine("[R2] TX power -> " + String(newDbm) + " dBm"); - if (radio2Status == 2) { - radio2.setFrequency(JAM_LOCK_FREQ_2_MHZ); - radio2.setFrequencyDeviation(JAM_DEV_KHZ_R2_WIDE); - } - } - } - - logLine("[JAM] Power update complete"); -} - // Web server handlers const char kHtml[] = R"HTML( @@ -1127,16 +1261,15 @@ h1{animation:flicker .4s ease-out}

System Metrics

Uptime
-
CC1101 TX
dBm
-
Eff. Power
dBm
-
Eff. Watts
mW
+
CC1101 TX (max)
dBm
+
ERP (chip only)
dBm
+
ERP Watts
mW
+
R1 hardware
+
R2 hardware
+
Jam on boot
Temp
°C
Free Heap
kB
Min Heap
kB
-
Jam mode
-
Hops R1
-
Hops R2
-
Hops/sec
AP Clients
Nodes (ESP-NOW)
@@ -1190,38 +1323,14 @@ h1{animation:flicker .4s ease-out}

Controls

+

TX power is fixed at +10 dBm (CC1101 max, TI SWRS061). Radiated level includes your antenna gain only — no external PA.

-
- - -
−30 −20 −15 −10 0 +5 +7 +10 dBm
-
-
+
- + +
-
-
-
-
-
-
-

Sweep Tuning (legacy — fixed jam ignores)

-
-
-
- - - -
-
- - - -
-
-
@@ -1261,8 +1370,15 @@ h1{animation:flicker .4s ease-out}
Cap Freq
+

Capture history (NVS)

+
+ + + +
#Time (uptime)MHzBitsRadioMod
+
- REC pauses jamming and records raw demodulated signal for 4s. REPLAY transmits the capture on loop at the original frequency. STOP resumes jamming. + REC pauses jamming, RX via CC1101 GDO0 (SWRS061). STOP saves to history if ≥100 bits. REPLAY loops async TX. If boot jam is OFF, capture works without fighting auto-TX.
@@ -1276,8 +1392,7 @@ h1{animation:flicker .4s ease-out}