Files
car-key-killer/src/main.cpp
drjones f26cc60b53 UI polish: CRT scanlines, glow effects, boot animation
Pure CSS visual upgrades — zero JS overhead, no feature changes:
- CRT scanline overlay via body::after repeating gradient
- Boot-in animation (brightness flash + blur fade)
- Title flicker on load, persistent text-shadow glow
- Pulsing glow on active radio dots and connection indicator
- Shimmer gradient on progress bar
- Canvas sweep cursor and sparkline glow via shadowBlur
- Gradient fills on sparkline charts
- Hover states on cards, metric tiles, buttons (glow + scale)
- Custom thin scrollbar on log panel
- Tabular-nums for jitter-free metric updates
- Version badge in header

Made-with: Cursor
2026-03-12 13:36:01 -07:00

2128 lines
86 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Dual CC1101 always-on key-fob jammer.
* ESP32-S3 DevKitC-1: two CC1101 on shared SPI.
* Radio 1: sweeps 300320 MHz (US band — Honda 303.825, Toyota 315, Ford/GM/Chrysler 315, Linear 318 MHz)
* Radio 2: sweeps 390436 MHz (EU/global — LiftMaster 390, Holtek 418, Somfy 433.42, EU 433.92, Nero 434.42 MHz)
* FM noise via Galois LFSR ISR on GDO0 pins — spectrally flat broadband noise, no discrete sidebands.
* WiFi AP + web UI on boot; OTA updates via ArduinoOTA.
*/
#include <Arduino.h>
#include <RadioLib.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <ArduinoOTA.h>
#include "driver/gpio.h"
#include <Preferences.h>
#include <math.h>
#include <Wire.h>
#include <U8g2lib.h>
#include "config.h"
// Shared SPI; each Module uses its own CS.
// Must pass SPIClass explicitly so RadioLib uses our configured pins.
static SPIClass spi(FSPI);
static ArduinoHal hal(spi, SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
static Module mod1(&hal, CC1101_1_CS, CC1101_1_GDO0, RADIOLIB_NC, RADIOLIB_NC);
static Module mod2(&hal, CC1101_2_CS, CC1101_2_GDO0, RADIOLIB_NC, RADIOLIB_NC);
CC1101 radio1(&mod1);
CC1101 radio2(&mod2);
static WebServer server(WEB_PORT);
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
// Individual radio status tracking
static int8_t radio1Status = -1; // 0=standby, 1=initialized, 2=transmitting, -1=disabled/error
static int8_t radio2Status = -1;
static String radio1Error = "Disabled / not initialized";
static String radio2Error = "Disabled / not initialized";
// Telemetry
static uint32_t uptimeStart = 0;
static float currentRssi1 = NAN;
static float currentRssi2 = NAN;
// Frequency sweep state
static uint8_t sweepStep1 = 0;
static uint8_t sweepStep2 = 0;
static uint32_t lastSweep1Ms = 0;
static uint32_t lastSweep2Ms = 0;
static float sweepFreq1 = SWEEP_1_CENTER_MHZ;
static float sweepFreq2 = SWEEP_2_CENTER_MHZ;
// Fast Frequency Hopping / VCO Calibration Caching
// By caching the CC1101 PLL calibration registers for each sweep frequency,
// we bypass the 720µs auto-calibration during the sweep, reducing hop dead-time
// from ~750µs down to ~40µs (SPI transaction time). This increases jamming efficiency
// from ~76% to >98% at a 3ms dwell time.
struct SweepStepCache {
float freqMhz;
uint8_t freqRegs[3]; // FREQ2, FREQ1, FREQ0
uint8_t fscalRegs[3]; // FSCAL3, FSCAL2, FSCAL1
};
static SweepStepCache sweepTable1[100];
static SweepStepCache sweepTable2[100];
// Runtime-adjustable sweep parameters (loaded from NVS)
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;
// Auto-reinit watchdog
static uint32_t lastReInitCheck = 0;
// 24-hour operation health tracking
static uint32_t hopCount1 = 0; // total frequency hops since boot
static uint32_t hopCount2 = 0;
static uint32_t minFreeHeap = 0xFFFFFFFF; // lowest heap ever observed
static uint32_t lastTempWarnMs = 0; // rate-limit temperature warnings
// ─── OLED (0.96" SSD1306 128x64) ─────────────────────────────────────────────
// SW_I2C: bit-bangs GPIO directly — no Wire library involved, always works
// if the pins are physically correct. SDA=GPIO17, SCL=GPIO18.
static U8G2_SSD1306_128X64_NONAME_F_SW_I2C
u8g2(U8G2_R0, OLED_SCL_PIN, OLED_SDA_PIN, U8X8_PIN_NONE);
static bool oledOk = false;
static uint8_t oledPage = 0; // 0=status, 1=freq/hops, 2=health
static uint32_t oledPageMs = 0;
static uint32_t oledTickMs = 0;
static uint8_t waveFrame = 0; // 0-3 animated arc count
static uint32_t waveMs = 0;
static uint32_t notifEnd = 0; // millis() when current notification expires
static char notifL1[22] = {};
static char notifL2[22] = {};
// ─── Rotary encoder ──────────────────────────────────────────────────────────
static volatile int8_t encDelta = 0; // +1 CW / -1 CCW per detent
static uint8_t encLastClk = HIGH;
void IRAM_ATTR encISR() {
const uint8_t clk = digitalRead(ENC_CLK_PIN);
if (clk == encLastClk) return; // filter glitch
encLastClk = clk;
if (clk == LOW) { // falling edge = one detent
encDelta += (digitalRead(ENC_DT_PIN) == HIGH) ? +1 : -1;
}
}
// Log ring buffer
static constexpr size_t LOG_LINES = 100;
static String logRing[LOG_LINES];
static size_t logHead = 0;
static size_t logCount = 0;
static void logLine(const String& s) {
uint32_t ms = millis();
uint32_t ss = ms / 1000;
uint32_t mm = ss / 60; ss %= 60;
uint32_t hh = mm / 60; mm %= 60;
char ts[12];
snprintf(ts, sizeof(ts), "[%02u:%02u:%02u] ", hh, mm, ss);
const String line = String(ts) + s;
logRing[logHead] = line;
logHead = (logHead + 1) % LOG_LINES;
if (logCount < LOG_LINES) logCount++;
Serial.println(line);
}
static String getLogsText() {
String out;
out.reserve(4096);
const size_t start = (logCount == LOG_LINES) ? logHead : 0;
for (size_t i = 0; i < logCount; i++) {
const size_t idx = (start + i) % LOG_LINES;
out += logRing[idx];
out += '\n';
}
return out;
}
static String jsonEscape(const String& in) {
String out;
out.reserve(in.length() + 8);
for (size_t i = 0; i < in.length(); ++i) {
const char c = in.charAt(i);
if (c == '\\') out += "\\\\";
else if (c == '\"') out += "\\\"";
else if (c == '\n') out += "\\n";
else if (c == '\r') out += "\\r";
else if (c == '\t') out += "\\t";
else out += c;
}
return out;
}
// Forward declarations
static void noiseGenStart();
static void startJamming();
static void stopJamming();
static void oledNotify(const char* l1, const char* l2, uint32_t dur);
static void spiWriteReg(uint8_t csPin, uint8_t reg, uint8_t val);
// ─── Noise generator globals (used by stopJamming before definition) ─────────
static volatile uint32_t s_lfsr = 0xDEADBEEFu;
static hw_timer_t* s_noiseTimer = nullptr;
// ─── Signal capture / replay globals ─────────────────────────────────────────
// Buffer lives in BSS (static) — 50 KB, no heap fragmentation.
static uint8_t capBuf[CAP_BUF_BYTES];
enum class CapMode : uint8_t { IDLE=0, RECORDING=1, RECORDED=2, REPLAYING=3 };
static volatile CapMode capMode = CapMode::IDLE;
static volatile uint32_t capIdx = 0; // current bit index
static volatile bool capBufFull = false; // set by ISR when buffer fills
static uint32_t capRecBits = 0; // bits stored after recording
static float capFreq = 315.0f; // frequency at capture time
static uint8_t capRadioNum = 1; // 1 or 2
static bool capIsOOK = true; // modulation: true=OOK, false=2-FSK
static gpio_num_t capGdoPin = (gpio_num_t)CC1101_1_GDO0;
static hw_timer_t* capTimer = nullptr;
static volatile uint32_t capTransitions = 0; // edge count — used for bitrate estimation
static volatile uint32_t capLongRuns = 0; // counts stable runs (>15 samples) to filter out thermal noise
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
// ─── Capture/replay ISRs ──────────────────────────────────────────────────────
static void IRAM_ATTR capRecordISR() {
const uint32_t i = capIdx;
if (i >= (uint32_t)(CAP_BUF_BYTES * 8)) { capBufFull = true; return; }
const uint8_t bit = (uint8_t)((REG_READ(GPIO_IN_REG) >> capGdoPin) & 1u);
// Count transitions and long stable runs (software squelch)
if (i > 0) {
const uint8_t prev = (capBuf[(i-1) >> 3] >> ((i-1) & 7)) & 1u;
if (bit == prev) {
capCurrentRun++;
} else {
capTransitions++;
if (capCurrentRun > 15) capLongRuns++;
capCurrentRun = 0;
}
}
if (bit) capBuf[i >> 3] |= (1u << (i & 7));
else capBuf[i >> 3] &= ~(1u << (i & 7));
capIdx = i + 1;
}
static void IRAM_ATTR capReplayISR() {
uint32_t i = capIdx;
if (i >= capRecBits) { i = 0; } // loop seamlessly
const uint8_t bit = (capBuf[i >> 3] >> (i & 7)) & 1u;
gpio_set_level(capGdoPin, bit);
capIdx = i + 1;
}
// ─── Capture/replay management ───────────────────────────────────────────────
static void capTimerStop() {
if (capTimer) {
timerAlarmDisable(capTimer);
timerDetachInterrupt(capTimer);
timerEnd(capTimer);
capTimer = nullptr;
}
}
static void startCapture(float freq, uint8_t radioNum, bool isOOK) {
capPrevJamming = jammingEnabled; // save before stopJamming() clears it
stopJamming();
capFreq = freq;
capRadioNum = radioNum;
capIsOOK = isOOK;
capGdoPin = (radioNum == 1) ? (gpio_num_t)CC1101_1_GDO0 : (gpio_num_t)CC1101_2_GDO0;
capIdx = 0;
capBufFull = false;
capRecBits = 0;
capTransitions = 0;
capLongRuns = 0;
capCurrentRun = 0;
capSigNotified = false;
memset(capBuf, 0, sizeof(capBuf));
CC1101& radio = (radioNum == 1) ? radio1 : radio2;
radio.standby();
radio.setOOK(isOOK);
radio.setFrequency(freq);
radio.setFrequencyDeviation(JAM_FREQ_DEV_KHZ);
radio.receiveDirect(); // GDO0 becomes demodulated-data output from CC1101
// After receiveDirect, CC1101 drives GDO0 — set ESP32 pin as input to read it
gpio_set_direction(capGdoPin, GPIO_MODE_INPUT);
capTimerStop();
capMode = CapMode::RECORDING;
capTimer = timerBegin(3, 80, true); // timer 3, 1 MHz tick
timerAttachInterrupt(capTimer, &capRecordISR, true);
timerAlarmWrite(capTimer, 1000000 / CAP_SAMPLE_HZ, true); // period in µs
timerAlarmEnable(capTimer);
logLine("[CAP] Recording " + String(freq, 3) + " MHz via radio " +
String(radioNum) + " @ " + String(CAP_SAMPLE_HZ/1000) + " kHz");
oledNotify("RECORDING", (String(freq, 2) + " MHz").c_str(), 2500);
}
static void startReplay(uint8_t radioNum) {
if (capRecBits == 0) { logLine("[CAP] Nothing captured to replay"); return; }
capPrevJamming = jammingEnabled; // save before stopJamming() clears it
stopJamming();
capRadioNum = radioNum;
capGdoPin = (radioNum == 1) ? (gpio_num_t)CC1101_1_GDO0 : (gpio_num_t)CC1101_2_GDO0;
capIdx = 0;
CC1101& radio = (radioNum == 1) ? radio1 : radio2;
const uint8_t csPin = (radioNum == 1) ? CC1101_1_CS : CC1101_2_CS;
radio.standby();
radio.setOOK(capIsOOK);
radio.setFrequency(capFreq);
radio.setFrequencyDeviation(JAM_FREQ_DEV_KHZ);
// Configure PATABLE for maximum OOK contrast and TX power.
// In OOK mode the CC1101 uses PATABLE[0] for "0" bits and PATABLE[1] for "1" bits.
// 0x00 = full off, 0xC0 = max power (+10 dBm). This gives the sharpest on/off
// keying and maximizes replay range by eliminating residual carrier leakage during OFF.
if (capIsOOK) {
spiWriteReg(csPin, 0x3E, 0x00); // PATABLE[0] = off
spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
digitalWrite(csPin, LOW);
spi.transfer(0x7E); // Burst write PATABLE
spi.transfer(0x00); // index 0: OFF
spi.transfer(0xC0); // index 1: max power (+10 dBm)
digitalWrite(csPin, HIGH);
spi.endTransaction();
}
radio.transmitDirectAsync(); // GDO0 becomes data input to CC1101
gpio_set_direction(capGdoPin, GPIO_MODE_OUTPUT);
capTimerStop();
capMode = CapMode::REPLAYING;
capTimer = timerBegin(3, 80, true);
timerAttachInterrupt(capTimer, &capReplayISR, true);
timerAlarmWrite(capTimer, 1000000 / CAP_SAMPLE_HZ, true);
timerAlarmEnable(capTimer);
logLine("[CAP] Replaying " + String(capFreq, 3) + " MHz, " +
String(capRecBits) + " bits (" +
String(capRecBits * 1000 / CAP_SAMPLE_HZ) + " ms), looping");
oledNotify("REPLAYING", (String(capFreq, 2) + " MHz").c_str(), 2500);
}
static void stopCapture() {
capTimerStop();
if (capMode == CapMode::RECORDING) {
capRecBits = capIdx;
capMode = (capRecBits > 0) ? CapMode::RECORDED : CapMode::IDLE;
logLine("[CAP] Stopped: " + String(capRecBits) + " bits saved");
oledNotify("CAPTURED", (String(capRecBits / 1000) + "k bits").c_str(), 2500);
} else if (capMode == CapMode::REPLAYING) {
capMode = CapMode::RECORDED;
logLine("[CAP] Replay stopped");
oledNotify("REPLAY", "STOPPED", 2500);
}
// Restore pin direction then restart jamming if it was active before capture
gpio_set_direction(capGdoPin, GPIO_MODE_OUTPUT);
gpio_set_level(capGdoPin, 0);
if (capPrevJamming) {
capPrevJamming = false;
jammingEnabled = true;
startJamming();
}
}
// Simple signal analysis — counts transitions to estimate original bitrate
// and measures duty cycle (fraction of 1s = carrier-on time).
static String capAnalyze() {
if (capRecBits < 100) return "{\"err\":\"no data\"}";
uint32_t ones = 0, transitions = 0;
uint8_t prev = (capBuf[0] >> 0) & 1u;
for (uint32_t i = 1; i < capRecBits; i++) {
const uint8_t b = (capBuf[i >> 3] >> (i & 7)) & 1u;
if (b) ones++;
if (b != prev) { transitions++; prev = b; }
}
// Approximate original bitrate: each symbol averages capRecBits/transitions samples
const uint32_t avgRunLen = (transitions > 0) ? (capRecBits / transitions) : capRecBits;
const uint32_t estBps = (avgRunLen > 0) ? (CAP_SAMPLE_HZ / avgRunLen) : 0;
const uint32_t dutyPct = (uint32_t)(ones * 100UL / capRecBits);
const uint32_t durMs = capRecBits * 1000 / CAP_SAMPLE_HZ;
char buf[200];
snprintf(buf, sizeof(buf),
"{\"bits\":%lu,\"dur_ms\":%lu,\"transitions\":%lu,"
"\"est_bps\":%lu,\"duty_pct\":%lu,\"freq\":%.3f}",
(unsigned long)capRecBits, (unsigned long)durMs,
(unsigned long)transitions, (unsigned long)estBps,
(unsigned long)dutyPct, (double)capFreq);
return String(buf);
}
// ─── Raw SPI Helpers for Fast Sweep ────────────────────────────────────────────
static void spiStrobe(uint8_t csPin, uint8_t strobe) {
spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
digitalWrite(csPin, LOW);
spi.transfer(strobe);
digitalWrite(csPin, HIGH);
spi.endTransaction();
}
static void spiWriteReg(uint8_t csPin, uint8_t reg, uint8_t val) {
spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
digitalWrite(csPin, LOW);
spi.transfer(reg);
spi.transfer(val);
digitalWrite(csPin, HIGH);
spi.endTransaction();
}
static uint8_t spiReadReg(uint8_t csPin, uint8_t reg) {
spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
digitalWrite(csPin, LOW);
spi.transfer(reg | 0x80); // Read bit
uint8_t val = spi.transfer(0x00);
digitalWrite(csPin, HIGH);
spi.endTransaction();
return val;
}
static uint8_t spiReadStatusReg(uint8_t csPin, uint8_t reg) {
spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
digitalWrite(csPin, LOW);
spi.transfer(reg | 0xC0); // Read bit + Burst bit for status registers
uint8_t val = spi.transfer(0x00);
digitalWrite(csPin, HIGH);
spi.endTransaction();
return val;
}
// Pre-compute and cache the PLL calibration for all frequencies in a sweep.
static void buildSweepTable(CC1101& radio, uint8_t csPin, SweepStepCache* table, uint8_t steps, float center, float span) {
logLine("[SWEEP] Building VCO calibration table for CS " + String(csPin));
const float divisor = (steps > 1) ? (float)(steps - 1) : 1.0f;
for (uint8_t i = 0; i < steps; i++) {
float freq = center - (span / 2.0f) + (span / divisor) * (float)i;
table[i].freqMhz = freq;
radio.standby();
radio.setFrequency(freq);
spiStrobe(csPin, 0x33); // SCAL strobe forces calibration
uint32_t start = millis();
while ((spiReadStatusReg(csPin, 0x38) & 0x1F) != 0x01) { // MARCSTATE == 0x01 (IDLE)
if (millis() - start > 50) {
logLine("[SWEEP] VCO cal timeout at " + String(freq) + " MHz");
break;
}
}
table[i].freqRegs[0] = spiReadReg(csPin, 0x0D); // FREQ2
table[i].freqRegs[1] = spiReadReg(csPin, 0x0E); // FREQ1
table[i].freqRegs[2] = spiReadReg(csPin, 0x0F); // FREQ0
table[i].fscalRegs[0] = spiReadReg(csPin, 0x23); // FSCAL3
table[i].fscalRegs[1] = spiReadReg(csPin, 0x24); // FSCAL2
table[i].fscalRegs[2] = spiReadReg(csPin, 0x25); // FSCAL1
}
}
// 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) {
pinMode(csPin, OUTPUT);
digitalWrite(csPin, HIGH);
delayMicroseconds(50);
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);
digitalWrite(csPin, HIGH);
spi.endTransaction();
return val;
}
// 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();
delay(5);
}
// Start simultaneous jamming on both radios
static void startJamming() {
logLine("[JAM] Starting simultaneous jamming system");
logLine("[JAM] Power: " + String(jamPower) + " dBm");
// Reset radio status
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); }
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));
} else {
radio1Status = 1;
buildSweepTable(radio1, CC1101_1_CS, sweepTable1, sweep1Steps, SWEEP_1_CENTER_MHZ, sweep1SpanMhz);
}
// 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); }
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));
} else {
radio2Status = 1;
buildSweepTable(radio2, CC1101_2_CS, sweepTable2, sweep2Steps, SWEEP_2_CENTER_MHZ, sweep2SpanMhz);
}
// Start both radios transmitting simultaneously
int stTx1 = RADIOLIB_ERR_NONE;
int stTx2 = RADIOLIB_ERR_NONE;
// Start LFSR noise generator — drives GDO0 pins from a 50 kHz hardware timer ISR,
// producing spectrally flat pseudo-random broadband FM noise (~810 kHz per hop).
noiseGenStart();
if (radio1Status == 1) {
stTx1 = radio1.transmitDirectAsync();
if (stTx1 != RADIOLIB_ERR_NONE) {
radio1Status = -1;
radio1Error = "Transmit failed: " + String(stTx1);
logLine("[R1] transmitDirectAsync failed: " + String(stTx1));
} else {
radio1Status = 2; // Transmitting
}
}
if (radio2Status == 1) {
stTx2 = radio2.transmitDirectAsync();
if (stTx2 != RADIOLIB_ERR_NONE) {
radio2Status = -1;
radio2Error = "Transmit failed: " + String(stTx2);
logLine("[R2] transmitDirectAsync failed: " + String(stTx2));
} else {
radio2Status = 2; // Transmitting
}
}
if (radio1Status == 2 || radio2Status == 2) {
logLine("[JAM] Jamming active (async FM noise mode):");
logLine("[JAM] Radio 1: sweep 300-320 MHz at " + String(jamPower) + " dBm (status: " + String(radio1Status == 2 ? "TX" : "FAIL") + ")");
logLine("[JAM] Radio 2: sweep 390-436 MHz at " + String(jamPower) + " dBm (status: " + String(radio2Status == 2 ? "TX" : "FAIL") + ")");
} 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
}
}
// Stop jamming — idempotent, safe to call at any time including from capture code.
// Always brings GDO0 pins and noise timer to a known-safe state regardless of
// whether jammingEnabled was true. Only logs if something was actually active.
static void stopJamming() {
const bool wasActive = jammingEnabled;
// Always stop noise timer first — prevents ISR touching GDO0 during standby
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);
if (radio1Status == 2) {
int st1 = radio1.standby();
if (st1 != RADIOLIB_ERR_NONE) {
radio1Error = "Standby failed: " + String(st1);
logLine("[R1] standby failed: " + String(st1));
} else {
radio1Status = 1;
radio1Error = "";
}
}
if (radio2Status == 2) {
int st2 = radio2.standby();
if (st2 != RADIOLIB_ERR_NONE) {
radio2Error = "Standby failed: " + String(st2);
logLine("[R2] standby failed: " + String(st2));
} else {
radio2Status = 1;
radio2Error = "";
}
}
jammingEnabled = false;
if (wasActive) logLine("[JAM] Jamming stopped");
}
// ─── OLED functions ──────────────────────────────────────────────────────────
// Queue a full-screen notification overlay for dur ms.
static void oledNotify(const char* l1, const char* l2, uint32_t dur = 2500) {
if (!oledOk) return;
strlcpy(notifL1, l1, sizeof(notifL1));
strlcpy(notifL2, l2, sizeof(notifL2));
notifEnd = millis() + dur;
oledPageMs = notifEnd; // reset page timer after notification clears
}
// Show a synchronous one-shot boot status message (called during setup).
static void oledBootMsg(const char* line) {
if (!oledOk) return;
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_7x13_tf);
u8g2.drawStr(0, 14, "CC1101 JAMMER");
u8g2.setFont(u8g2_font_6x10_tf);
u8g2.drawStr(0, 27, "ESP32-S3 INIT");
u8g2.drawHLine(0, 30, 128);
u8g2.drawStr(0, 46, line);
u8g2.sendBuffer();
}
// Draw animated right-half radio-wave arcs at (cx, cy), n arcs (0-3).
static void oledDrawWaves(uint8_t cx, uint8_t cy, uint8_t n) {
for (uint8_t i = 0; i < n; i++) {
u8g2.drawCircle(cx, cy, (i + 1) * 3,
U8G2_DRAW_UPPER_RIGHT | U8G2_DRAW_LOWER_RIGHT);
}
}
// Draw page indicator dots in the yellow zone (top-right corner)
// page = current page (0-2)
static void oledPageDots(uint8_t page) {
for (uint8_t i = 0; i < 3; i++) {
const uint8_t x = 116 + i * 5;
if (i == page) u8g2.drawBox(x, 4, 3, 3); // filled = active
else u8g2.drawFrame(x, 4, 3, 3); // outline = inactive
}
}
// Page 0 — Live Status
// Yellow zone (y 0-15): status header
// Blue zone (y16-63): 4 data lines with 5x7 font
static void oledDrawStatus() {
const bool jam = jammingEnabled;
const bool r1 = (radio1Status == 2);
const bool r2 = (radio2Status == 2);
const CapMode cm = capMode;
// Yellow zone header
u8g2.setFont(u8g2_font_6x10_tf);
if (cm == CapMode::RECORDING) {
// Flashing border effect — blink every ~500 ms using bit 9 of millis()
if (millis() & 512) {
u8g2.drawBox(0, 0, 128, 13);
u8g2.setDrawColor(0);
}
u8g2.drawStr(2, 10, ">> RECORDING <<");
u8g2.setDrawColor(1);
} else if (cm == CapMode::REPLAYING) {
u8g2.drawBox(0, 0, 128, 13);
u8g2.setDrawColor(0);
u8g2.drawStr(2, 10, ">> REPLAYING <<");
u8g2.setDrawColor(1);
} else if (jam) {
u8g2.drawBox(0, 0, 110, 13);
u8g2.setDrawColor(0);
u8g2.drawStr(2, 10, ">> JAMMING ACTIVE <<");
u8g2.setDrawColor(1);
} else {
u8g2.drawStr(2, 10, "-- STANDBY --");
}
oledPageDots(0);
// Blue zone — 5x7 font
u8g2.setFont(u8g2_font_5x7_tf);
const uint8_t nW = waveFrame;
if (cm == CapMode::RECORDING || cm == CapMode::REPLAYING) {
// Show capture/replay status instead of sweep info
char buf[24];
snprintf(buf, sizeof(buf), "%.3f MHz R%u",
(double)capFreq, (unsigned)capRadioNum);
u8g2.drawStr(0, 24, buf);
// Progress bar for recording
if (cm == CapMode::RECORDING) {
const uint32_t pct = capIdx * 100 / (CAP_BUF_BYTES * 8);
u8g2.drawFrame(0, 26, 128, 5);
u8g2.drawBox(0, 26, (uint8_t)(pct * 128 / 100), 5);
snprintf(buf, sizeof(buf), "%lus / %us %lu tr",
(unsigned long)(capIdx / CAP_SAMPLE_HZ),
(unsigned)CAP_DURATION_S,
(unsigned long)capTransitions);
} else {
// Replaying — show loop position
const uint32_t pct = capRecBits ? capIdx * 100 / capRecBits : 0;
u8g2.drawFrame(0, 26, 128, 5);
u8g2.drawBox(0, 26, (uint8_t)(pct * 128 / 100), 5);
snprintf(buf, sizeof(buf), "%lu bits looping",
(unsigned long)capRecBits);
}
u8g2.drawStr(0, 40, buf);
snprintf(buf, sizeof(buf), "%.1fC %lukB",
(double)temperatureRead(), (unsigned long)(ESP.getFreeHeap() / 1024));
u8g2.drawStr(0, 55, buf);
} else {
// Normal jamming / standby display
// Row 1: ANT1
if (r1) {
char buf[20];
snprintf(buf, sizeof(buf), "1: %.3f MHz", (double)sweepFreq1);
u8g2.drawStr(0, 24, buf);
oledDrawWaves(101, 19, nW);
} else {
u8g2.drawStr(0, 24, "1: [OFFLINE]");
}
// Row 2: ANT2
if (r2) {
char buf[20];
snprintf(buf, sizeof(buf), "2: %.3f MHz", (double)sweepFreq2);
u8g2.drawStr(0, 33, buf);
oledDrawWaves(101, 28, nW);
} else {
u8g2.drawStr(0, 33, "2: [OFFLINE]");
}
// Row 3: power
{
char buf[28];
snprintf(buf, sizeof(buf), "TX %d+%d=%ddBm",
(int)jamPower, (int)ampGainDb, (int)jamPower + (int)ampGainDb);
u8g2.drawStr(0, 44, buf);
}
// Row 4: temp + heap OR FULL TX badge
if (jam && r1 && r2) {
u8g2.drawStr(0, 55, "[ FULL DUAL-BAND TX ]");
} else {
char buf[28];
snprintf(buf, sizeof(buf), "%.1fC %lukB",
(double)temperatureRead(), (unsigned long)(ESP.getFreeHeap() / 1024));
u8g2.drawStr(0, 55, buf);
}
}
// Row 5: uptime small
{
const uint32_t up = millis() - uptimeStart;
char buf[20];
snprintf(buf, sizeof(buf), "up %uh%um%us",
(unsigned)(up/3600000), (unsigned)((up/60000)%60), (unsigned)((up/1000)%60));
u8g2.drawStr(0, 63, buf);
}
}
// Page 1 — Frequency + Hops
static void oledDrawFreq() {
// Yellow zone header
u8g2.setFont(u8g2_font_6x10_tf);
u8g2.drawStr(2, 10, "FREQ & HOPS");
oledPageDots(1);
u8g2.setFont(u8g2_font_5x7_tf);
char buf[24];
snprintf(buf, sizeof(buf), "R1 %.4f MHz", (double)sweepFreq1);
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);
u8g2.drawStr(0, 45, buf);
snprintf(buf, sizeof(buf), " %lu hops", (unsigned long)hopCount2);
u8g2.drawStr(0, 54, buf);
// Total hops per second (approx from 5s heartbeat window)
const uint32_t up = (millis() - uptimeStart) / 1000;
if (up > 0) {
snprintf(buf, sizeof(buf), "~%lu h/s total",
(unsigned long)((hopCount1 + hopCount2) / up));
u8g2.drawStr(0, 63, buf);
}
}
// Page 2 — System Health
static void oledDrawHealth() {
// Yellow zone header
u8g2.setFont(u8g2_font_6x10_tf);
u8g2.drawStr(2, 10, "SYS HEALTH");
oledPageDots(2);
u8g2.setFont(u8g2_font_5x7_tf);
char buf[24];
snprintf(buf, sizeof(buf), "TEMP %.1f C", (double)temperatureRead());
u8g2.drawStr(0, 24, buf);
const uint32_t freeK = ESP.getFreeHeap() / 1024;
const uint32_t minK = (minFreeHeap == 0xFFFFFFFF ? ESP.getFreeHeap() : minFreeHeap) / 1024;
snprintf(buf, sizeof(buf), "HEAP %lukB min%lukB", freeK, minK);
u8g2.drawStr(0, 33, buf);
const uint32_t up = millis() - uptimeStart;
snprintf(buf, sizeof(buf), "UP %uh %um %us",
(unsigned)(up/3600000), (unsigned)((up/60000)%60), (unsigned)((up/1000)%60));
u8g2.drawStr(0, 44, buf);
const int effDbm = (int)jamPower + (int)ampGainDb;
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);
snprintf(buf, sizeof(buf), "WIFI %d client(s)", WiFi.softAPgetStationNum());
u8g2.drawStr(0, 63, buf);
}
// Full-screen inverted notification overlay
static void oledDrawNotif() {
u8g2.drawBox(0, 0, 128, 64);
u8g2.setDrawColor(0);
u8g2.setFont(u8g2_font_7x13_tf);
int16_t x1 = (128 - (int16_t)strlen(notifL1) * 7) / 2;
u8g2.drawStr((uint8_t)max((int16_t)0, x1), 26, notifL1);
u8g2.setFont(u8g2_font_6x10_tf);
int16_t x2 = (128 - (int16_t)strlen(notifL2) * 6) / 2;
u8g2.drawStr((uint8_t)max((int16_t)0, x2), 44, notifL2);
u8g2.setDrawColor(1);
}
// Main OLED update — call from loop() every pass; self-throttles to 100ms.
static void oledTick() {
if (!oledOk) return;
const uint32_t now = millis();
if (now - oledTickMs < 100) return;
oledTickMs = now;
// Advance wave animation every 220ms (4 frames → ~1.1s full cycle)
if (now - waveMs >= 220) {
waveMs = now;
waveFrame = (waveFrame + 1) & 3;
}
// Consume encoder — manual page change resets the auto-cycle timer
if (encDelta != 0) {
noInterrupts();
const int8_t d = encDelta;
encDelta = 0;
interrupts();
oledPage = (uint8_t)((oledPage + 3 + (d > 0 ? 1 : -1)) % 3);
oledPageMs = now; // reset auto-advance so page stays visible
}
// Auto page-advance every 8s (not during notification, not if encoder just moved)
if (now > notifEnd && now - oledPageMs >= 8000) {
oledPageMs = now;
oledPage = (oledPage + 1) % 3;
}
u8g2.clearBuffer();
if (now < notifEnd) {
oledDrawNotif();
} else if (oledPage == 0) {
oledDrawStatus();
} else if (oledPage == 1) {
oledDrawFreq();
} else {
oledDrawHealth();
}
u8g2.sendBuffer();
}
// ─── Galois LFSR broadband noise generator ───────────────────────────────────
//
// Replaces LEDC fixed-frequency PWM which produced strong predictable sidebands
// at ±120 kHz, ±240 kHz etc — a pattern car receivers can filter out.
//
// A 32-bit Galois LFSR clocked at 50 kHz generates a maximal-length pseudo-
// random bit sequence (period 2^32-1 = ~23.8 hours at 50 kbps). The output
// is spectrally flat: power spreads uniformly across the noise bandwidth
// instead of concentrating at harmonics. Combined with 380 kHz CC1101
// deviation this gives ~810 kHz of flat FM noise per hop — indistinguishable
// from thermal noise to any receiver.
//
// Polynomial 0xB4BCD35C: taps at bits 0,2,6,7,16,18,19,21 — proven maximal.
// Both radios use different bit positions of the same sequence for uncorrelated
// but equally flat noise on each band.
static inline IRAM_ATTR uint32_t lfsrStep(uint32_t s) {
return (s >> 1) ^ (-(s & 1u) & 0xB4BCD35Cu);
}
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);
}
static void noiseGenStart() {
if (s_noiseTimer) {
timerAlarmDisable(s_noiseTimer);
timerDetachInterrupt(s_noiseTimer);
timerEnd(s_noiseTimer);
s_noiseTimer = nullptr;
}
s_lfsr = esp_random();
if (s_lfsr == 0) s_lfsr = 0xDEADBEEFu; // LFSR must never be zero
gpio_set_direction((gpio_num_t)CC1101_1_GDO0, GPIO_MODE_OUTPUT);
gpio_set_direction((gpio_num_t)CC1101_2_GDO0, GPIO_MODE_OUTPUT);
// Hardware timer at 50 kHz — true ISR, no jitter, no FreeRTOS overhead.
// prescaler 80 → 1 MHz tick, alarm at 20 = 20 µs period = 50 kHz.
// Noise BW: 2*(380 kHz dev + 25 kHz baseband) = 810 kHz — solid coverage.
s_noiseTimer = timerBegin(2, 80, true); // timer 2, 1 MHz, count up
timerAttachInterrupt(s_noiseTimer, &noiseISR, true); // edge triggered
timerAlarmWrite(s_noiseTimer, 20, true); // 20 µs auto-reload
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 (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");
}
}
logLine("[JAM] Power update complete");
}
// Web server handlers
const char kHtml[] = R"HTML(
<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>CC1101 JAMMER</title><style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:#020504;color:#86f28a;font-family:'Courier New',monospace;font-size:12px;line-height:1.4}
body::after{content:'';position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9999;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,.08) 2px,rgba(0,0,0,.08) 4px);opacity:.6}
header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid #1a3a1e;background:linear-gradient(180deg,#040a06 0%,#030705 100%)}
h1{font-size:17px;letter-spacing:.12em;color:#a0f5a4;text-shadow:0 0 8px rgba(134,242,138,.4)}
.sub{font-size:10px;color:#4fbf59;margin-top:2px}
#progWrap{height:3px;background:#060e07}
#progBar{height:3px;background:linear-gradient(90deg,#2a8a2e,#86f28a,#2a8a2e);background-size:200% 100%;width:0;transition:width .8s linear;animation:progShimmer 3s linear infinite}
@keyframes progShimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
main{padding:10px;max-width:920px;margin:0 auto;display:flex;flex-direction:column;gap:8px}
.card{background:#030705;border:1px solid #1a3a1e;padding:10px;transition:border-color .3s}
.card:hover{border-color:#2a5a2e}
.card h2{font-size:10px;color:#4fbf59;letter-spacing:.12em;text-transform:uppercase;border-bottom:1px solid #122814;padding-bottom:5px;margin-bottom:8px}
.banner{padding:14px;text-align:center;border:2px solid #1a3a1e;transition:all .4s ease}
.bt{font-size:22px;font-weight:bold;letter-spacing:.18em}
.bs{font-size:11px;margin-top:5px}
.ban-on{border-color:#4fbf59;background:radial-gradient(ellipse at center,#0a1a0c 0%,#060f07 70%)}
.ban-off{border-color:#3a1218;background:#030504}
@keyframes pulse{0%,100%{box-shadow:0 0 6px rgba(134,242,138,.15)}50%{box-shadow:0 0 24px rgba(134,242,138,.5),0 0 60px rgba(134,242,138,.1)}}
.ban-on{animation:pulse 2.2s ease-in-out infinite}
.ban-on .bt{text-shadow:0 0 12px rgba(134,242,138,.6)}
.sg{display:grid;grid-template-columns:repeat(auto-fill,minmax(105px,1fr));gap:5px}
.s{background:#020504;border:1px solid #1a3a1e;padding:6px 8px;transition:border-color .2s,background .2s}
.s:hover{border-color:#2a5a2e;background:#040a06}
.sl{font-size:8px;color:#4fbf59;text-transform:uppercase;letter-spacing:.1em;white-space:nowrap}
.sv{font-size:15px;font-weight:bold;margin-top:2px;transition:color .3s}
.su{font-size:8px;color:#4fbf59;margin-left:1px}
.warm{color:#f5d87c!important}.hot{color:#f28a86!important}.lo{color:#f5d87c!important}.crit{color:#f28a86!important}
.band{margin-bottom:10px}
.bl{font-size:10px;color:#4fbf59;display:flex;justify-content:space-between;align-items:center;margin-bottom:3px}
canvas{display:block;width:100%}
canvas.sw{height:92px;border:1px solid #122814;background:#020504}
canvas.sp{height:44px;border:1px solid #122814;background:#020504}
.row{display:flex;flex-wrap:wrap;gap:10px}
.col{flex:1;min-width:140px}
.rrow{display:flex;gap:7px;align-items:center;margin-bottom:4px}
.dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;transition:all .3s}
.on{background:#86f28a;box-shadow:0 0 6px rgba(134,242,138,.7)}
@keyframes dotPulse{0%,100%{box-shadow:0 0 4px rgba(134,242,138,.5)}50%{box-shadow:0 0 10px rgba(134,242,138,.9)}}
.on{animation:dotPulse 1.5s ease-in-out infinite}
.off{background:#f28a86;box-shadow:0 0 4px rgba(242,138,134,.4)}
hr{border:none;border-top:1px solid #122814;margin:8px 0}
label{font-size:10px;color:#4fbf59;display:block;margin-bottom:3px}
input[type=range]{width:100%;accent-color:#86f28a;margin:2px 0}
input[type=number]{background:#020504;border:1px solid #1a3a1e;color:#86f28a;padding:3px 6px;font-family:inherit;font-size:11px;width:100%;transition:border-color .2s}
input[type=number]:focus{border-color:#4fbf59;outline:none}
select:focus{border-color:#4fbf59;outline:none}
button{padding:6px 11px;background:#0a1e0c;color:#86f28a;border:1px solid #2a5a2e;cursor:pointer;font-family:inherit;font-size:11px;letter-spacing:.04em;transition:all .15s}
button:hover{background:#142a16;box-shadow:0 0 8px rgba(134,242,138,.15)}
button:active{transform:scale(.97)}
button.d{background:#140608;border-color:#4a1820;color:#f28a86}
button.d:hover{background:#200a10;box-shadow:0 0 8px rgba(242,138,134,.15)}
.err{color:#f28a86;font-size:10px;margin-top:2px}
pre{margin:0;padding:8px;background:#020504;border:1px solid #122814;height:28vh;overflow-y:auto;font-size:10px;line-height:1.6;color:#5fbf69}
pre::-webkit-scrollbar{width:4px}
pre::-webkit-scrollbar-track{background:#020504}
pre::-webkit-scrollbar-thumb{background:#1a3a1e;border-radius:2px}
#connDot{width:9px;height:9px;border-radius:50%;background:#f28a86;transition:background .3s,box-shadow .3s}
@keyframes bootIn{0%{opacity:0;filter:brightness(2) blur(2px)}100%{opacity:1;filter:brightness(1) blur(0)}}
body{animation:bootIn .6s ease-out}
@keyframes flicker{0%{opacity:1}3%{opacity:.4}6%{opacity:1}7%{opacity:.6}9%{opacity:1}100%{opacity:1}}
h1{animation:flicker .4s ease-out}
.sv{font-variant-numeric:tabular-nums}
</style></head><body>
<header>
<div><h1>CC1101 JAMMER</h1>
<div class="sub">ESP32-S3 &bull; 300320 MHz + 390436 MHz &bull; Dual-band FM noise sweep</div></div>
<div style="text-align:right;display:flex;align-items:center;gap:10px">
<div>
<div id="mission" style="font-size:9px;color:#4fbf59;margin-bottom:3px"></div>
<div style="font-size:8px;color:#1a3a1e;letter-spacing:.08em">v2.0 // LFSR+VCO</div>
</div>
<div id="connDot"></div>
</div>
</header>
<div id="progWrap"><div id="progBar"></div></div>
<main>
<div class="banner ban-off" id="banner">
<div class="bt" id="bt">INITIALIZING</div>
<div class="bs" id="bs"></div>
</div>
<div class="card">
<h2>System Metrics</h2>
<div class="sg">
<div class="s"><div class="sl">Uptime</div><div class="sv" id="mUp">—</div></div>
<div class="s"><div class="sl">CC1101 TX</div><div class="sv" id="mPow">—<span class="su">dBm</span></div></div>
<div class="s"><div class="sl">Eff. Power</div><div class="sv" id="mEff">—<span class="su">dBm</span></div></div>
<div class="s"><div class="sl">Eff. Watts</div><div class="sv" id="mW">—<span class="su">mW</span></div></div>
<div class="s"><div class="sl">Temp</div><div class="sv" id="mTmp">—<span class="su">°C</span></div></div>
<div class="s"><div class="sl">Free Heap</div><div class="sv" id="mH">—<span class="su">kB</span></div></div>
<div class="s"><div class="sl">Min Heap</div><div class="sv" id="mMH">—<span class="su">kB</span></div></div>
<div class="s"><div class="sl">Dwell</div><div class="sv" id="mDw">—<span class="su">ms</span></div></div>
<div class="s"><div class="sl">Hops R1</div><div class="sv" id="mH1">—</div></div>
<div class="s"><div class="sl">Hops R2</div><div class="sv" id="mH2">—</div></div>
<div class="s"><div class="sl">Hops/sec</div><div class="sv" id="mHR">—</div></div>
<div class="s"><div class="sl">AP Clients</div><div class="sv" id="mCl">—</div></div>
</div>
</div>
<div class="card">
<h2>Live Frequency Sweep</h2>
<div class="band">
<div class="bl">
<span><span class="dot on" id="d1"></span>&nbsp;Radio 1 — 300320 MHz&nbsp;<small style="color:#2a6a2e">(Honda 303.825 · Toyota 314.98 · Ford/GM 315 · Linear 318)</small></span>
<span id="f1c" style="color:#86f28a;font-weight:bold"></span>
</div>
<canvas class="sw" id="c1"></canvas>
</div>
<div class="band">
<div class="bl">
<span><span class="dot on" id="d2"></span>&nbsp;Radio 2 — 390436 MHz&nbsp;<small style="color:#2a6a2e">(LiftMaster 390 · Holtek 418 · Somfy 433.42 · EU 433.92 · Nero 434.42)</small></span>
<span id="f2c" style="color:#86f28a;font-weight:bold"></span>
</div>
<canvas class="sw" id="c2"></canvas>
</div>
</div>
<div class="card">
<h2>2-Minute History</h2>
<div class="row">
<div class="col">
<div class="bl"><span style="color:#4fbf59">Temperature (°C)</span><span id="tNow" style="font-weight:bold">—</span></div>
<canvas class="sp" id="cT"></canvas>
</div>
<div class="col">
<div class="bl"><span style="color:#4fbf59">Free Heap (kB)</span><span id="hNow" style="font-weight:bold">—</span></div>
<canvas class="sp" id="cH"></canvas>
</div>
</div>
</div>
<div class="card">
<h2>Radio Status</h2>
<div class="row">
<div class="col">
<div class="rrow"><span class="dot off" id="r1d"></span><strong>Radio 1 — 300320 MHz</strong></div>
<div id="r1s" class="sub">—</div><div id="r1e" class="err"></div>
</div>
<div class="col">
<div class="rrow"><span class="dot off" id="r2d"></span><strong>Radio 2 — 390436 MHz</strong></div>
<div id="r2s" class="sub">—</div><div id="r2e" class="err"></div>
</div>
</div>
</div>
<div class="card">
<h2>Controls</h2>
<div class="row" style="margin-bottom:10px">
<div class="col">
<label>TX Power: <strong id="pv">10</strong> dBm</label>
<input type="range" id="jp" min="0" max="7" value="7" step="1">
<div style="font-size:9px;color:#3a7a3e;margin-top:2px">30 20 15 10 0 +5 +7 +10 dBm</div>
</div>
<div class="col" style="display:flex;flex-direction:column;gap:6px;justify-content:flex-end">
<button id="tog">Start Jamming</button>
<button id="apow" class="d">Apply Power</button>
</div>
</div>
<hr>
<div class="row" style="margin-bottom:10px">
<div class="col"><label>External Amp Gain (dB)</label><input type="number" id="ag" min="0" max="60" value="20" style="width:85px"></div>
<div class="col" style="display:flex;align-items:flex-end"><button id="aamp">Apply Amp</button></div>
</div>
<hr>
<h2 style="margin-bottom:8px">Sweep Tuning</h2>
<div class="row">
<div class="col"><label>Dwell / hop (ms)</label><input type="number" id="sd" min="1" max="500" value="5" style="width:75px"></div>
<div class="col">
<label>Steps (R1 / R2)</label>
<input type="number" id="ss1" min="2" max="100" value="25" style="width:60px">
<input type="number" id="ss2" min="2" max="100" value="47" style="width:60px;margin-top:4px">
</div>
<div class="col">
<label>Span MHz (R1 / R2)</label>
<input type="number" id="sp1" min="0.1" max="50" step="0.5" value="20.0" style="width:65px">
<input type="number" id="sp2" min="0.1" max="80" step="0.5" value="46.0" style="width:65px;margin-top:4px">
</div>
<div class="col" style="display:flex;align-items:flex-end"><button id="asw">Apply Sweep</button></div>
</div>
</div>
<div class="card">
<h2>Signal Capture / Replay</h2>
<div class="row" style="margin-bottom:8px">
<div class="col">
<label>Target Frequency (MHz)</label>
<input type="number" id="capFreq" min="290" max="450" step="0.001" value="315.000" style="width:110px">
</div>
<div class="col">
<label>Modulation</label>
<select id="capMod" style="background:#020504;border:1px solid #1a3a1e;color:#86f28a;padding:3px 6px;font-family:inherit;font-size:11px">
<option value="ook">OOK / ASK (90% of fobs)</option>
<option value="fsk">2-FSK</option>
</select>
</div>
<div class="col">
<label>Radio</label>
<select id="capRadio" style="background:#020504;border:1px solid #1a3a1e;color:#86f28a;padding:3px 6px;font-family:inherit;font-size:11px">
<option value="1">Radio 1 (300-320 MHz)</option>
<option value="2">Radio 2 (390-436 MHz)</option>
</select>
</div>
</div>
<div style="display:flex;gap:7px;flex-wrap:wrap;margin-bottom:8px">
<button onclick="capStartRec()">REC</button>
<button onclick="capStop()">STOP</button>
<button onclick="capReplay()">REPLAY</button>
</div>
<div style="height:3px;background:#060e07;margin-bottom:8px"><div id="capProg" style="height:3px;background:#4fbf59;width:0;transition:width .3s linear"></div></div>
<div class="sg" style="margin-bottom:8px">
<div class="s"><div class="sl">State</div><div class="sv" id="capStat">IDLE</div></div>
<div class="s"><div class="sl">Bits</div><div class="sv" id="capBits">—</div></div>
<div class="s"><div class="sl">Duration</div><div class="sv" id="capDur">—</div></div>
<div class="s"><div class="sl">Est Bitrate</div><div class="sv" id="capBps">—</div></div>
<div class="s"><div class="sl">Duty Cycle</div><div class="sv" id="capDuty">—</div></div>
<div class="s"><div class="sl">Cap Freq</div><div class="sv" id="capRecFreq">—</div></div>
</div>
<canvas id="capWave" class="sw" height="60" style="height:60px"></canvas>
<div style="font-size:9px;color:#2a6a2e;margin-top:5px">
REC pauses jamming and records raw demodulated signal for 4s. REPLAY transmits the capture on loop at the original frequency. STOP resumes jamming.
</div>
</div>
<div class="card">
<h2>System Log &nbsp;<button id="dl" style="font-size:9px;padding:2px 7px">Download</button></h2>
<pre id="log"></pre>
</div>
</main><script>
const PT=[-30,-20,-15,-10,0,5,7,10];
let T={},ph1=0,ph2=0,lastP=Date.now();
const HS=120,hT=new Array(HS).fill(null),hH=new Array(HS).fill(null);
let hi=0;
const TL=50,tr1=[],tr2=[];
const MK1=[{f:303.825,l:'Honda'},{f:310,l:'Chmb'},{f:314.98,l:'Toyot'},{f:315,l:'Ford'},{f:318,l:'Line'}];
const MK2=[{f:390,l:'Lift'},{f:418,l:'Holt'},{f:433.42,l:'Somfy'},{f:433.92,l:'EU'},{f:434.42,l:'Nero'}];
function ff(f){return f?(+f).toFixed(4)+' MHz':''}
function fu(ms){const s=Math.floor(ms/1000),m=Math.floor(s/60),h=Math.floor(m/60),d=Math.floor(h/24);
return d?`${d}d ${h%24}h ${m%60}m`:h?`${h}h ${m%60}m ${s%60}s`:`${m}m ${s%60}s`}
function ct(v){return v>80?'hot':v>65?'warm':''}
function ch(kb){return kb<30?'crit':kb<60?'lo':''}
function updTr(arr,norm){
arr.forEach(t=>t.age++);
while(arr.length&&arr[0].age>=TL)arr.shift();
arr.push({x:Math.max(0,Math.min(1,norm)),age:0});
}
function drawSw(id,freq,ctr,span,active,trail,marks){
const cv=document.getElementById(id);if(!cv)return;
const W=cv.offsetWidth||400,H=92;cv.width=W;cv.height=H;
const ctx=cv.getContext('2d');
const lo=ctr-span/2,sp=Math.max(span,0.001);
const tx=f=>Math.max(0,Math.min(W,(f-lo)/sp*W));
ctx.fillStyle='#020504';ctx.fillRect(0,0,W,H);
// Subtle grid
ctx.strokeStyle='#0a180c';ctx.lineWidth=1;ctx.setLineDash([2,10]);
for(let i=1;i<10;i++){const x=i/10*W;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H-14);ctx.stroke();}
ctx.setLineDash([]);
// Known freq markers
marks.forEach(({f,l})=>{
if(f<lo||f>lo+sp)return;
const x=tx(f);
ctx.strokeStyle='rgba(74,180,84,0.4)';ctx.lineWidth=1;ctx.setLineDash([3,4]);
ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H-15);ctx.stroke();ctx.setLineDash([]);
ctx.fillStyle='rgba(74,180,84,0.7)';ctx.font='8px monospace';ctx.textAlign='center';ctx.fillText(l,x,H-16);
});
// Heat trail
trail.forEach(t=>{
const a=(1-t.age/TL)*0.5,bw=55,bx=t.x*W;
const g=ctx.createLinearGradient(bx-bw,0,bx+bw,0);
g.addColorStop(0,'rgba(80,240,110,0)');
g.addColorStop(.5,`rgba(80,240,110,${a})`);
g.addColorStop(1,'rgba(80,240,110,0)');
ctx.fillStyle=g;ctx.fillRect(bx-bw,0,bw*2,H-14);
});
if(active&&freq){
const cx=tx(freq);
// Wide glow
const g=ctx.createLinearGradient(cx-80,0,cx+80,0);
g.addColorStop(0,'rgba(134,242,138,0)');g.addColorStop(.5,'rgba(134,242,138,0.22)');g.addColorStop(1,'rgba(134,242,138,0)');
ctx.fillStyle=g;ctx.fillRect(cx-80,0,160,H-14);
// Cursor line with glow
ctx.save();ctx.shadowColor='rgba(134,242,138,.6)';ctx.shadowBlur=6;
ctx.strokeStyle='#86f28a';ctx.lineWidth=2;
ctx.beginPath();ctx.moveTo(cx,0);ctx.lineTo(cx,H-14);ctx.stroke();
ctx.restore();
// Cursor dot with glow
ctx.save();ctx.shadowColor='rgba(134,242,138,.8)';ctx.shadowBlur=10;
ctx.fillStyle='#86f28a';ctx.beginPath();ctx.arc(cx,(H-14)/2,4,0,Math.PI*2);ctx.fill();
ctx.restore();
// Top notch
ctx.fillStyle='#86f28a';ctx.fillRect(Math.max(0,cx-2),0,4,4);
} else if(!active){
ctx.strokeStyle='#2a1216';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(W/2,0);ctx.lineTo(W/2,H-14);ctx.stroke();
}
// Axis bar
ctx.fillStyle='#0a180c';ctx.fillRect(0,H-14,W,14);
ctx.fillStyle='#3a7a3e';ctx.font='9px monospace';
ctx.textAlign='left';ctx.fillText(lo.toFixed(1)+' MHz',3,H-3);
ctx.textAlign='right';ctx.fillText((lo+sp).toFixed(1)+' MHz',W-3,H-3);
if(active&&freq){ctx.fillStyle='#86f28a';ctx.textAlign='center';ctx.fillText((+freq).toFixed(4)+' MHz',tx(freq),H-3);}
}
function drawSp(id,data,color,minH,maxH){
const cv=document.getElementById(id);if(!cv)return;
const W=cv.offsetWidth||280,H=44;cv.width=W;cv.height=H;
const ctx=cv.getContext('2d');
ctx.fillStyle='#020504';ctx.fillRect(0,0,W,H);
const vd=data.filter(v=>v!==null);if(vd.length<2)return;
const mn=minH??Math.min(...vd),mx=maxH??Math.max(...vd),rng=Math.max(mx-mn,0.5);
const toY=v=>H-4-((v-mn)/rng*(H-8));
// Fill
ctx.beginPath();let fs=true;
data.forEach((v,i)=>{if(v===null){fs=true;return;}const x=i/(HS-1)*W,y=toY(v);if(fs){ctx.moveTo(x,H-4);ctx.lineTo(x,y);fs=false;}else ctx.lineTo(x,y);});
ctx.lineTo(W,H-4);ctx.closePath();
const gf=ctx.createLinearGradient(0,0,0,H);gf.addColorStop(0,color+'30');gf.addColorStop(1,color+'05');
ctx.fillStyle=gf;ctx.fill();
// Line with glow
ctx.save();ctx.shadowColor=color;ctx.shadowBlur=4;
ctx.strokeStyle=color;ctx.lineWidth=1.5;ctx.beginPath();fs=true;
data.forEach((v,i)=>{if(v===null){fs=true;return;}const x=i/(HS-1)*W,y=toY(v);if(fs){ctx.moveTo(x,y);fs=false;}else ctx.lineTo(x,y);});
ctx.stroke();ctx.restore();
// Range labels
ctx.fillStyle=color+'90';ctx.font='8px monospace';
ctx.textAlign='left';ctx.fillText(mn.toFixed(0),2,H-3);
ctx.textAlign='right';ctx.fillText(mx.toFixed(0),W-2,H-3);
}
function applyTelemetry(t){
T=t;
const now=Date.now(),dt=(now-lastP)/1000;lastP=now;
// 24h progress
const pct=Math.min(100,t.uptime_ms/864000);
document.getElementById('progBar').style.width=pct+'%';
document.getElementById('mission').textContent=fu(t.uptime_ms)+' / 24h ('+pct.toFixed(1)+'%)';
// Banner
const jam=t.jamming_enabled;
document.getElementById('banner').className='banner '+(jam?'ban-on':'ban-off');
document.getElementById('bt').textContent=jam?' JAMMING ACTIVE ':'STANDBY';
document.getElementById('bt').style.color=jam?'#86f28a':'#f28a86';
const bands=[];if(t.radio1_active)bands.push('300320 MHz');if(t.radio2_active)bands.push('390436 MHz');
document.getElementById('bs').textContent=jam&&bands.length
?`${bands.join(' + ')} | ${t.jam_power}dBm + ${t.amp_gain_db}dB amp = ${t.eff_power_dbm}dBm (${(t.eff_power_w*1000).toFixed(0)}mW)`
:(jam?'No radios active':'Ready press Start Jamming');
document.getElementById('bs').style.color=jam&&bands.length?'#86f28a':jam?'#f28a86':'#4fbf59';
document.getElementById('tog').textContent=jam?'Stop Jamming':'Start Jamming';
// Metrics
document.getElementById('mUp').textContent=fu(t.uptime_ms);
document.getElementById('mPow').innerHTML=t.jam_power+'<span class="su">dBm</span>';
document.getElementById('mEff').innerHTML=t.eff_power_dbm+'<span class="su">dBm</span>';
document.getElementById('mW').innerHTML=(t.eff_power_w*1000).toFixed(0)+'<span class="su">mW</span>';
const tEl=document.getElementById('mTmp');tEl.innerHTML=t.temp_c+'<span class="su">°C</span>';tEl.className='sv '+ct(+t.temp_c);
const hkb=t.free_heap/1024;const hEl=document.getElementById('mH');hEl.innerHTML=hkb.toFixed(0)+'<span class="su">kB</span>';hEl.className='sv '+ch(hkb);
const mhkb=t.min_heap/1024;document.getElementById('mMH').innerHTML=mhkb.toFixed(0)+'<span class="su">kB</span>';
document.getElementById('mDw').innerHTML=t.sweep_dwell_ms+'<span class="su">ms</span>';
const h1=t.hop_count1||0,h2=t.hop_count2||0,dh=(h1-ph1+h2-ph2),rate=dt>0?(dh/dt).toFixed(0):0;
ph1=h1;ph2=h2;
document.getElementById('mH1').textContent=h1.toLocaleString();
document.getElementById('mH2').textContent=h2.toLocaleString();
document.getElementById('mHR').innerHTML=rate+'<span class="su">/s</span>';
document.getElementById('mCl').textContent=t.ap_clients??'';
// Trails + sweep canvases
const sp1=Math.max(t.sweep_span1||20,0.001),sp2=Math.max(t.sweep_span2||46,0.001);
updTr(tr1,(t.sweep_freq1-(t.sweep_center1-sp1/2))/sp1);
updTr(tr2,(t.sweep_freq2-(t.sweep_center2-sp2/2))/sp2);
drawSw('c1',t.sweep_freq1,t.sweep_center1||310,sp1,t.radio1_active,tr1,MK1);
drawSw('c2',t.sweep_freq2,t.sweep_center2||413,sp2,t.radio2_active,tr2,MK2);
document.getElementById('f1c').textContent=ff(t.sweep_freq1);
document.getElementById('f2c').textContent=ff(t.sweep_freq2);
// Dots
const sd=(id,ok)=>{const d=document.getElementById(id);d.className='dot '+(ok?'on':'off');};
sd('d1',t.radio1_active);sd('d2',t.radio2_active);sd('r1d',t.radio1_active);sd('r2d',t.radio2_active);
// Radio status
const rs=s=>s===2?'TRANSMITTING':s===1?'STANDBY':s===0?'INIT':'ERROR';
document.getElementById('r1s').textContent=rs(t.radio1_status)+' '+ff(t.sweep_freq1);
document.getElementById('r2s').textContent=rs(t.radio2_status)+' '+ff(t.sweep_freq2);
document.getElementById('r1e').textContent=t.radio1_error||'';
document.getElementById('r2e').textContent=t.radio2_error||'';
// Controls sync
if(t.jam_power_idx!==undefined){document.getElementById('jp').value=t.jam_power_idx;document.getElementById('pv').textContent=t.jam_power;}
if(!document.activeElement.id.startsWith('s')){
document.getElementById('sd').value=t.sweep_dwell_ms||5;
document.getElementById('ss1').value=t.sweep_steps1||25;
document.getElementById('ss2').value=t.sweep_steps2||47;
document.getElementById('sp1').value=t.sweep_span1||20;
document.getElementById('sp2').value=t.sweep_span2||46;
}
if(!document.getElementById('ag').matches(':focus'))document.getElementById('ag').value=t.amp_gain_db||20;
// History
hT[hi]=+t.temp_c;hH[hi]=t.free_heap/1024;hi=(hi+1)%HS;
document.getElementById('tNow').textContent=(+t.temp_c).toFixed(1)+'°C';
document.getElementById('tNow').className=ct(+t.temp_c);
document.getElementById('hNow').textContent=(t.free_heap/1024).toFixed(0)+' kB';
document.getElementById('hNow').className=ch(t.free_heap/1024);
drawSp('cT',hT,'#86f28a',20,90);
drawSp('cH',hH,'#4fbf59',0,320);
const cd=document.getElementById('connDot');cd.style.background='#86f28a';cd.style.boxShadow='0 0 8px rgba(134,242,138,.7)';
}
let logTick=0;
async function poll(){
try{
const tr=await fetch('/api/telemetry');applyTelemetry(await tr.json());
if(++logTick%5===0){ // fetch log every 5s instead of every second
const lr=await fetch('/api/log');
const log=document.getElementById('log');
const atBot=log.scrollHeight-log.scrollTop<=log.clientHeight+40;
log.textContent=await lr.text();
if(atBot)log.scrollTop=log.scrollHeight;
}
}catch(e){
const cd=document.getElementById('connDot');cd.style.background='#f28a86';cd.style.boxShadow='0 0 8px rgba(242,138,134,.6)';
document.getElementById('bt').textContent='OFFLINE';
}
setTimeout(poll, 1000);
}
document.getElementById('jp').addEventListener('input',e=>document.getElementById('pv').textContent=PT[+e.target.value]);
document.getElementById('tog').addEventListener('click',async()=>{try{const r=await fetch('/api/toggle',{method:'POST'});applyTelemetry({...T,jamming_enabled:(await r.json()).enabled});}catch(e){}});
document.getElementById('apow').addEventListener('click',async()=>{try{const i=+document.getElementById('jp').value;const r=await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({power_idx:i})});const d=await r.json();if(d.success){T.jam_power=d.jam_power;T.jam_power_idx=i;}}catch(e){}});
document.getElementById('aamp').addEventListener('click',async()=>{try{await fetch('/api/amp',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({gain_db:+document.getElementById('ag').value})});}catch(e){}});
document.getElementById('asw').addEventListener('click',async()=>{try{await fetch('/api/sweep',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dwell_ms:+document.getElementById('sd').value,steps1:+document.getElementById('ss1').value,steps2:+document.getElementById('ss2').value,span1_mhz:+document.getElementById('sp1').value,span2_mhz:+document.getElementById('sp2').value})});}catch(e){}});
document.getElementById('dl').addEventListener('click',()=>{const a=document.createElement('a');a.href='/api/log';a.download='jammer-log.txt';a.click();});
poll();
// ── Capture / Replay ─────────────────────────────────────────────────────────
let capPolling=false;
let capWaveData=[];
async function capFetch(url){try{return await(await fetch(url)).json();}catch(e){return null;}}
async function capStartRec(){
const freq=+document.getElementById('capFreq').value;
const radio=+document.getElementById('capRadio').value;
const mod=document.getElementById('capMod').value;
if(isNaN(freq)||freq<290||freq>450){alert('Frequency must be 290-450 MHz');return;}
const d=await capFetch('/api/capture/start?freq='+freq+'&radio='+radio+'&mod='+mod);
if(d){capSetStatus('RECORDING',d);capPollStart();}
}
async function capStop(){
const d=await capFetch('/api/capture/stop');
if(d){capSetStatus('STOPPED',d);capPollStop();}
await capLoadWave();
}
async function capReplay(){
const radio=+document.getElementById('capRadio').value;
const d=await capFetch('/api/capture/replay?radio='+radio);
if(d){capSetStatus('REPLAYING',d);}
}
async function capLoadWave(){
const w=await capFetch('/api/capture/wave');
if(!w||!w.length)return;
capWaveData=w;
capDrawWave();
}
function capDrawWave(){
const cv=document.getElementById('capWave');
if(!cv||!capWaveData.length)return;
const W=cv.width,H=cv.height;
const ctx=cv.getContext('2d');
ctx.clearRect(0,0,W,H);
ctx.fillStyle='#020504';ctx.fillRect(0,0,W,H);
const n=capWaveData.length;
const bw=W/n;
ctx.fillStyle='#4fbf59';
for(let i=0;i<n;i++){
const h=Math.round(capWaveData[i]/100*(H-2));
ctx.fillRect(Math.round(i*bw),H-h,Math.max(1,Math.ceil(bw)),h);
}
}
function capSetStatus(label,d){
document.getElementById('capStat').textContent=label;
const dispBits=d?(d.rec_bits||d.bits):0;if(dispBits!==undefined)document.getElementById('capBits').textContent=dispBits.toLocaleString()+' bits';
if(d&&d.dur_ms)document.getElementById('capDur').textContent=(d.dur_ms/1000).toFixed(2)+'s';
if(d&&d.est_bps)document.getElementById('capBps').textContent=d.est_bps.toLocaleString()+' bps';
if(d&&d.duty_pct!==undefined)document.getElementById('capDuty').textContent=d.duty_pct+'%';
if(d&&d.freq)document.getElementById('capRecFreq').textContent=d.freq.toFixed(3)+' MHz';
const pct=d?(d.mode===1&&d.buf_bits?Math.round(d.bits*100/d.buf_bits):(d.mode===3&&d.rec_bits?Math.round(d.bits*100/d.rec_bits):100)):0;
document.getElementById('capProg').style.width=(d&&(d.mode===2||d.mode===3)?100:pct)+'%';
}
let capPollTimer=null;
function capPollStart(){if(!capPollTimer)capPollTimer=setInterval(capPollStatus,400);}
function capPollStop(){clearInterval(capPollTimer);capPollTimer=null;}
async function capPollStatus(){
const d=await capFetch('/api/capture/status');
if(!d)return;
const modes=['IDLE','RECORDING','CAPTURED','REPLAYING'];
capSetStatus(modes[d.mode]||'?',d);
if(d.mode===2||d.mode===0){capPollStop();if(d.mode===2)capLoadWave();}
}
window.addEventListener('resize',capDrawWave);
</script></body></html>
)HTML";
static void handleRoot() {
// ETag based on compile timestamp — browser caches until next flash
server.sendHeader("ETag", "\"" __DATE__ __TIME__ "\"");
server.sendHeader("Cache-Control", "no-cache"); // revalidate via ETag, don't re-download
if (server.hasHeader("If-None-Match") &&
server.header("If-None-Match") == "\"" __DATE__ __TIME__ "\"") {
server.send(304); // Not Modified — browser uses cached copy, saves ~15 KB
return;
}
server.setContentLength(sizeof(kHtml) - 1);
server.send(200, "text/html; charset=utf-8", "");
server.sendContent(kHtml);
}
static void handleLog() {
server.send(200, "text/plain; charset=utf-8", getLogsText());
}
static void handleTelemetry() {
float tempC = temperatureRead();
int8_t effDbm = jamPower + ampGainDb;
float effWatts = powf(10.0f, effDbm / 10.0f) / 1000.0f; // dBm -> watts
String err1 = jsonEscape(radio1Error);
String err2 = jsonEscape(radio2Error);
static char jsonBuf[1024]; // Generously sized to avoid fragmentation
snprintf(jsonBuf, sizeof(jsonBuf),
"{"
"\"uptime_ms\":%lu,"
"\"free_heap\":%lu,"
"\"temp_c\":%.1f,"
"\"jamming_enabled\":%s,"
"\"jam_power\":%d,"
"\"jam_power_idx\":%d,"
"\"amp_gain_db\":%d,"
"\"eff_power_dbm\":%d,"
"\"eff_power_w\":%.3f,"
"\"sweep_freq1\":%.4f,"
"\"sweep_center1\":%.2f,"
"\"sweep_span1\":%.2f,"
"\"sweep_steps1\":%u,"
"\"sweep_freq2\":%.4f,"
"\"sweep_center2\":%.2f,"
"\"sweep_span2\":%.2f,"
"\"sweep_steps2\":%u,"
"\"sweep_dwell_ms\":%lu,"
"\"radio1_status\":%d,"
"\"radio1_error\":\"%s\","
"\"radio1_freq\":%.4f,"
"\"radio1_active\":%s,"
"\"radio2_status\":%d,"
"\"radio2_error\":\"%s\","
"\"radio2_freq\":%.4f,"
"\"radio2_active\":%s,"
"\"hop_count1\":%lu,"
"\"hop_count2\":%lu,"
"\"min_heap\":%lu,"
"\"ap_clients\":%d"
"}",
(unsigned long)(millis() - uptimeStart),
(unsigned long)ESP.getFreeHeap(),
(double)tempC,
jammingEnabled ? "true" : "false",
(int)jamPower,
(int)jamPowerIdx,
(int)ampGainDb,
(int)effDbm,
(double)effWatts,
(double)sweepFreq1,
(double)SWEEP_1_CENTER_MHZ,
(double)sweep1SpanMhz,
(unsigned)sweep1Steps,
(double)sweepFreq2,
(double)SWEEP_2_CENTER_MHZ,
(double)sweep2SpanMhz,
(unsigned)sweep2Steps,
(unsigned long)sweepDwellMs,
(int)radio1Status,
err1.c_str(),
(double)sweepFreq1,
radio1Status == 2 ? "true" : "false",
(int)radio2Status,
err2.c_str(),
(double)sweepFreq2,
radio2Status == 2 ? "true" : "false",
(unsigned long)hopCount1,
(unsigned long)hopCount2,
(unsigned long)(minFreeHeap == 0xFFFFFFFF ? ESP.getFreeHeap() : minFreeHeap),
(int)WiFi.softAPgetStationNum()
);
server.send(200, "application/json; charset=utf-8", jsonBuf);
}
static void handleToggle() {
if (jammingEnabled) {
stopJamming();
oledNotify("STANDBY", "Jamming stopped");
} else {
jammingEnabled = true; // must be set before startJamming so sweep loop and watchdog see it
startJamming();
if (radio1Status != 2 && radio2Status != 2) {
jammingEnabled = false; // both radios failed — don't pretend we're jamming
oledNotify("RADIO FAIL", "Check connections");
} else {
oledNotify("JAMMING", "STARTED");
}
}
// Save new state
preferences.putBool("jamEnabled", jammingEnabled);
String json = "{\"enabled\":" + String(jammingEnabled ? "true" : "false") + "}";
server.send(200, "application/json; charset=utf-8", json);
}
static void handleSettings() {
if (server.hasArg("plain")) {
String body = server.arg("plain");
body.trim();
// Expected format: {"power_idx":7}
int keyPos = body.indexOf("\"power_idx\":");
if (keyPos >= 0) {
int colonPos = keyPos + 12;
int endPos = body.indexOf(",", colonPos);
if (endPos == -1) endPos = body.indexOf("}", colonPos);
if (endPos > colonPos) {
String valStr = body.substring(colonPos, endPos);
valStr.trim();
uint8_t idx = (uint8_t)constrain(valStr.toInt(), 0, JAM_POWER_LEVELS - 1);
updateJamPower(idx);
{
char l2[22];
snprintf(l2, sizeof(l2), "%d dBm (eff %d dBm)",
(int)kPowerTable[idx], (int)kPowerTable[idx] + (int)ampGainDb);
oledNotify("POWER SET", l2);
}
}
} else {
logLine("[HTTP] No power_idx in JSON body");
}
} else {
logLine("[HTTP] No JSON body received");
}
// Return current state
String json = "{\"success\":true,\"power_idx\":" + String(jamPowerIdx) + ",\"jam_power\":" + String(jamPower) + "}";
server.send(200, "application/json; charset=utf-8", json);
}
static void handleSweepSettings() {
if (server.hasArg("plain")) {
String body = server.arg("plain");
auto extractFloat = [&](const char* key, float& val, float mn, float mx) {
int p = body.indexOf(key);
if (p < 0) return;
int c = p + strlen(key);
int e = body.indexOf(",", c); if (e < 0) e = body.indexOf("}", c);
if (e > c) { float v = body.substring(c, e).toFloat(); val = constrain(v, mn, mx); }
};
auto extractInt = [&](const char* key, uint32_t& val, uint32_t mn, uint32_t mx) {
int p = body.indexOf(key);
if (p < 0) return;
int c = p + strlen(key);
int e = body.indexOf(",", c); if (e < 0) e = body.indexOf("}", c);
if (e > c) { uint32_t v = (uint32_t)body.substring(c, e).toInt(); val = constrain(v, mn, mx); }
};
extractInt( "\"dwell_ms\":", sweepDwellMs, 1, 500);
uint32_t s1 = sweep1Steps, s2 = sweep2Steps;
extractInt( "\"steps1\":", s1, 2, 100); sweep1Steps = (uint8_t)s1;
extractInt( "\"steps2\":", s2, 2, 100); sweep2Steps = (uint8_t)s2;
extractFloat("\"span1_mhz\":", sweep1SpanMhz, 0.1f, 50.0f);
extractFloat("\"span2_mhz\":", sweep2SpanMhz, 0.1f, 80.0f);
preferences.putInt("sweepDwell", (int)sweepDwellMs);
preferences.putInt("sweep1Steps", sweep1Steps);
preferences.putInt("sweep2Steps", sweep2Steps);
preferences.putFloat("sweep1Span", sweep1SpanMhz);
preferences.putFloat("sweep2Span", sweep2SpanMhz);
if (radio1Status >= 1) {
sweepStep1 = 0;
buildSweepTable(radio1, CC1101_1_CS, sweepTable1, sweep1Steps, SWEEP_1_CENTER_MHZ, sweep1SpanMhz);
}
if (radio2Status >= 1) {
sweepStep2 = 0;
buildSweepTable(radio2, CC1101_2_CS, sweepTable2, sweep2Steps, SWEEP_2_CENTER_MHZ, sweep2SpanMhz);
}
logLine("[SWEEP] dwell=" + String(sweepDwellMs) + "ms steps=" +
String(sweep1Steps) + "/" + String(sweep2Steps) +
" span=" + String(sweep1SpanMhz,2) + "/" + String(sweep2SpanMhz,2) + "MHz");
}
server.send(200, "application/json; charset=utf-8",
"{\"success\":true,\"dwell_ms\":" + String(sweepDwellMs) +
",\"steps1\":" + String(sweep1Steps) +
",\"steps2\":" + String(sweep2Steps) +
",\"span1_mhz\":" + String(sweep1SpanMhz, 2) +
",\"span2_mhz\":" + String(sweep2SpanMhz, 2) + "}");
}
static void handleAmpSettings() {
if (server.hasArg("plain")) {
String body = server.arg("plain");
int p = body.indexOf("\"gain_db\":");
if (p >= 0) {
int c = p + 10;
int e = body.indexOf(",", c); if (e < 0) e = body.indexOf("}", c);
if (e > c) {
ampGainDb = (int8_t)constrain(body.substring(c, e).toInt(), 0, 60);
preferences.putInt("ampGainDb", ampGainDb);
logLine("[AMP] Gain set to " + String(ampGainDb) + " dB");
}
}
}
server.send(200, "application/json; charset=utf-8",
"{\"success\":true,\"gain_db\":" + String(ampGainDb) + "}");
}
static void handleHealth() {
static char buf[128];
snprintf(buf, sizeof(buf),
"{\"ok\":true,\"uptime_ms\":%lu,\"heap\":%lu,\"ap_clients\":%d}",
(unsigned long)(millis() - uptimeStart),
(unsigned long)ESP.getFreeHeap(),
(int)WiFi.softAPgetStationNum());
server.send(200, "application/json; charset=utf-8", buf);
}
// ─── Capture / replay HTTP handlers ──────────────────────────────────────────
static void handleCaptureStart() {
const float freq = server.hasArg("freq") ? server.arg("freq").toFloat() : 315.0f;
const uint8_t radio = server.hasArg("radio") ? (uint8_t)server.arg("radio").toInt() : 1;
const bool isOOK = server.hasArg("mod") ? (server.arg("mod") == "ook") : true;
startCapture(freq, radio, isOOK);
server.send(200, "application/json", "{\"status\":\"recording\",\"freq\":" +
String(freq, 3) + ",\"duration_ms\":" + String(CAP_DURATION_S * 1000) + "}");
}
static void handleCaptureStop() {
stopCapture();
server.send(200, "application/json", "{\"status\":\"stopped\",\"bits\":" +
String(capRecBits) + "}");
}
static void handleCaptureReplay() {
const uint8_t radio = server.hasArg("radio") ? (uint8_t)server.arg("radio").toInt() : 1;
// startReplay doesn't need isOOK from UI because it uses capIsOOK saved during capture
startReplay(radio);
server.send(200, "application/json", "{\"status\":\"replaying\",\"bits\":" +
String(capRecBits) + ",\"freq\":" + String(capFreq, 3) + "}");
}
static void handleCaptureStatus() {
static const char* const modeStr[] = {"idle","recording","recorded","replaying"};
const uint8_t m = (uint8_t)capMode;
static char buf[512];
int n = snprintf(buf, sizeof(buf),
"{\"mode\":%u,\"mode_str\":\"%s\","
"\"bits\":%lu,\"buf_bits\":%lu,\"rec_bits\":%lu,"
"\"pct\":%lu,\"freq\":%.3f",
(unsigned)m,
modeStr[m < 4 ? m : 0],
(unsigned long)capIdx,
(unsigned long)(CAP_BUF_BYTES * 8),
(unsigned long)capRecBits,
(unsigned long)(capIdx * 100UL / (CAP_BUF_BYTES * 8)),
(double)capFreq);
if (capMode == CapMode::RECORDED || capMode == CapMode::REPLAYING) {
if (capRecBits >= 100) {
uint32_t ones = 0, transitions = 0;
uint8_t prev = (capBuf[0] >> 0) & 1u;
for (uint32_t i = 1; i < capRecBits; i++) {
const uint8_t b = (capBuf[i >> 3] >> (i & 7)) & 1u;
if (b) ones++;
if (b != prev) { transitions++; prev = b; }
}
const uint32_t avgRL = (transitions > 0) ? (capRecBits / transitions) : capRecBits;
n += snprintf(buf + n, sizeof(buf) - n,
",\"dur_ms\":%lu,\"transitions\":%lu,\"est_bps\":%lu,\"duty_pct\":%lu",
(unsigned long)(capRecBits * 1000 / CAP_SAMPLE_HZ),
(unsigned long)transitions,
(unsigned long)(avgRL > 0 ? CAP_SAMPLE_HZ / avgRL : 0),
(unsigned long)(ones * 100UL / capRecBits));
}
}
snprintf(buf + n, sizeof(buf) - n, "}");
server.send(200, "application/json; charset=utf-8", buf);
}
// Returns 256 data points (0-100 = % carrier-on) for waveform canvas rendering.
// Static buffer: worst case = 256 * 4 chars ("100,") + 2 brackets + nul = 1026 bytes.
static void handleCaptureWave() {
if (!capRecBits) { server.send(200, "application/json", "[]"); return; }
static char waveBuf[1280];
const uint32_t N = 256;
const uint32_t bpp = max(1u, capRecBits / N);
int pos = 0;
waveBuf[pos++] = '[';
for (uint32_t p = 0; p < N; p++) {
uint32_t ones = 0;
const uint32_t start = p * bpp;
const uint32_t end = min(start + bpp, capRecBits);
for (uint32_t b = start; b < end; b++) {
ones += (capBuf[b >> 3] >> (b & 7)) & 1u;
}
pos += snprintf(waveBuf + pos, sizeof(waveBuf) - pos, "%lu%s",
(unsigned long)(bpp > 0 ? ones * 100 / bpp : 0),
(p < N - 1) ? "," : "");
}
waveBuf[pos++] = ']';
waveBuf[pos] = '\0';
server.send(200, "application/json", waveBuf);
}
static void handleNotFound() {
const String uri = server.uri();
logLine("[HTTP] 404 " + uri);
server.send(404, "text/plain", "404: Not found");
}
void setup() {
// Shorter delay for Serial to initialize on ESP32-S3 in production
Serial.begin(115200);
// OLED init — SW_I2C bit-bangs GPIO17/18 directly; no Wire needed.
// begin() always returns true for SW_I2C so just call it and force oledOk.
u8g2.begin();
u8g2.setContrast(255); // max brightness — some panels boot dim
oledOk = true;
Serial.println("[OLED] SW_I2C init done (GPIO17=SDA GPIO18=SCL)");
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_7x13_tf);
u8g2.drawStr(18, 22, "CC1101");
u8g2.drawStr(12, 38, "JAMMER");
u8g2.setFont(u8g2_font_5x7_tf);
u8g2.drawStr(14, 54, "ESP32-S3 BOOTING...");
u8g2.sendBuffer();
// Wait for Serial to be ready (timeout after 500ms for production)
unsigned long start = millis();
while (!Serial && (millis() - start) < 500) {
delay(10);
}
// Immediate debug output to verify boot
Serial.println("=== CAR-KEY-KILLER BOOT START ===");
Serial.flush();
uptimeStart = millis();
// Load preferences
preferences.begin("jammer4", false);
jammingEnabled = preferences.getBool("jamEnabled", JAMMING_ENABLED);
jamPowerIdx = (uint8_t)preferences.getInt("jamPowerIdx", DEFAULT_JAM_POWER_IDX);
ampGainDb = (int8_t) preferences.getInt("ampGainDb", DEFAULT_AMP_GAIN_DB);
sweepDwellMs = (uint32_t)preferences.getInt("sweepDwell", SWEEP_DWELL_MS);
sweep1Steps = (uint8_t)preferences.getInt("sweep1Steps", SWEEP_1_STEPS);
sweep2Steps = (uint8_t)preferences.getInt("sweep2Steps", SWEEP_2_STEPS);
sweep1SpanMhz = preferences.getFloat("sweep1Span", SWEEP_1_SPAN_MHZ);
sweep2SpanMhz = preferences.getFloat("sweep2Span", SWEEP_2_SPAN_MHZ);
if (jamPowerIdx >= JAM_POWER_LEVELS) jamPowerIdx = DEFAULT_JAM_POWER_IDX;
if (sweepDwellMs < 1) sweepDwellMs = 1;
if (sweep1Steps < 2) sweep1Steps = 2;
if (sweep2Steps < 2) sweep2Steps = 2;
jamPower = kPowerTable[jamPowerIdx];
logLine("[NVS] jamEnabled=" + String(jammingEnabled) + " jamPowerIdx=" + String(jamPowerIdx) +
" (" + String(jamPower) + " dBm) ampGain=" + String(ampGainDb) +
"dB sweepDwell=" + String(sweepDwellMs) + "ms");
logLine("[BOOT] CC1101 Key-Fob Jammer starting");
logLine("[BOOT] ESP32-S3 DevKitC-1");
Serial.flush();
// Rotary encoder — interrupt on CLK falling edge
pinMode(ENC_CLK_PIN, INPUT_PULLUP);
pinMode(ENC_DT_PIN, INPUT_PULLUP);
encLastClk = digitalRead(ENC_CLK_PIN);
attachInterrupt(digitalPinToInterrupt(ENC_CLK_PIN), encISR, CHANGE);
logLine("[ENC] Rotary encoder ready GPIO14=CLK GPIO21=DT");
oledBootMsg("SPI init...");
// Initialize SPI (required for CC1101 communication)
Serial.println("[SPI] Initializing SPI bus...");
Serial.flush();
// Drive CS pins HIGH before SPI init to prevent bus collisions
pinMode(CC1101_1_CS, OUTPUT);
digitalWrite(CC1101_1_CS, HIGH);
pinMode(CC1101_2_CS, OUTPUT);
digitalWrite(CC1101_2_CS, HIGH);
delay(10);
// Initialize the FSPI bus on the explicit ESP32-S3 pins
spi.begin(SPI_SCK_PIN, SPI_MISO_PIN, SPI_MOSI_PIN, -1);
// Pull MISO high to prevent floating bus reads from returning garbage
pinMode(SPI_MISO_PIN, INPUT_PULLUP);
logLine("[SPI] SPI bus initialized on SCK=" + String(SPI_SCK_PIN) +
" MISO=" + String(SPI_MISO_PIN) + " MOSI=" + String(SPI_MOSI_PIN) +
" speed=" + String(SPI_SPEED_HZ));
delay(150); // Allow CC1101 VCC to stabilize
oledBootMsg("WiFi AP start...");
// Start WiFi AP
Serial.println("[DEBUG] Starting WiFi AP...");
Serial.flush();
WiFi.persistent(false);
WiFi.setSleep(false);
WiFi.mode(WIFI_MODE_AP);
WiFi.softAPdisconnect(true);
delay(100);
WiFi.softAPConfig(IPAddress(192, 168, 4, 1), IPAddress(192, 168, 4, 1), IPAddress(255, 255, 255, 0));
bool apOk = false;
for (int attempt = 1; attempt <= 5 && !apOk; ++attempt) {
if (strlen(WIFI_AP_PASS) == 0) {
apOk = WiFi.softAP(WIFI_AP_SSID, nullptr, 1, 0, 4);
} else {
apOk = WiFi.softAP(WIFI_AP_SSID, WIFI_AP_PASS, 1, 0, 4);
}
Serial.println("[DEBUG] WiFi.softAP attempt " + String(attempt) + ": " + (apOk ? "OK" : "FAILED"));
Serial.flush();
if (!apOk) {
delay(300);
}
}
Serial.println(String("[DEBUG] WiFi.softAP result: ") + (apOk ? "OK" : "FAILED"));
Serial.flush();
if (!apOk) {
logLine("[WIFI] softAP failed");
Serial.println("[ERROR] WiFi softAP failed");
Serial.flush();
}
delay(250);
IPAddress ip = WiFi.softAPIP();
logLine("[WIFI] AP started: " + String(WIFI_AP_SSID) + " IP: " + ip.toString());
Serial.println("[WIFI] AP SSID: " + String(WIFI_AP_SSID));
Serial.println("[WIFI] AP IP: " + ip.toString());
Serial.flush();
if (MDNS.begin("killer")) {
MDNS.addService("http", "tcp", WEB_PORT);
Serial.println("[MDNS] Started: http://killer.local");
} else {
Serial.println("[MDNS] Failed");
}
Serial.flush();
// Setup web server routes
server.on("/", handleRoot);
server.on("/api/log", handleLog);
server.on("/api/telemetry", handleTelemetry);
server.on("/api/health", handleHealth);
server.on("/api/toggle", HTTP_POST, handleToggle);
server.on("/api/settings", HTTP_POST, handleSettings);
server.on("/api/sweep", HTTP_POST, handleSweepSettings);
server.on("/api/amp", HTTP_POST, handleAmpSettings);
server.on("/api/capture/start", handleCaptureStart);
server.on("/api/capture/stop", handleCaptureStop);
server.on("/api/capture/replay", handleCaptureReplay);
server.on("/api/capture/status", handleCaptureStatus);
server.on("/api/capture/wave", handleCaptureWave);
server.onNotFound(handleNotFound);
server.begin();
// OTA firmware updates over WiFi (connect to 'killer' AP, upload via PlatformIO OTA)
ArduinoOTA.setHostname("killer");
ArduinoOTA.setPassword("killerpw");
ArduinoOTA.onStart([]() { logLine("[OTA] Update starting..."); });
ArduinoOTA.onEnd([]() { logLine("[OTA] Update complete, rebooting"); });
ArduinoOTA.onError([](ota_error_t e) { logLine("[OTA] Error: " + String(e)); });
ArduinoOTA.begin();
logLine("[OTA] Ready — hostname: killer, port: 3232");
logLine("[HTTP] Server started on port " + String(WEB_PORT));
Serial.println("[HTTP] Server started on port " + String(WEB_PORT));
Serial.flush();
// Start jamming immediately if enabled
if (jammingEnabled) {
oledBootMsg("Radio 1 init...");
// (radio 2 init happens inside startJamming immediately after radio 1)
startJamming();
if (radio1Status == 2 || radio2Status == 2) {
oledBootMsg("JAMMING - ACTIVE!");
} else {
oledBootMsg("RADIO INIT FAILED");
}
} else {
radio1Status = -1;
radio2Status = -1;
radio1Error = "Disabled / not initialized";
radio2Error = "Disabled / not initialized";
logLine("[JAM] Jamming disabled on boot");
oledBootMsg("Standby. Press START.");
}
delay(800); // hold boot result on display briefly before switching to live pages
}
// Advance one radio to the next sweep frequency using cached VCO calibration.
// Bypasses the ~720µs auto-calibration dead time on every hop.
static void tickSweepFast(uint8_t csPin, uint8_t& step, uint8_t steps,
SweepStepCache* table, uint32_t& lastMs, float& curFreq,
uint32_t& hopCnt) {
const uint32_t now = millis();
if (now - lastMs < sweepDwellMs) return;
lastMs = now;
// Jump to IDLE to safely change registers
spiStrobe(csPin, 0x36); // SIDLE
// Write cached FREQ registers (0x0D, 0x0E, 0x0F)
spiWriteReg(csPin, 0x0D, table[step].freqRegs[0]);
spiWriteReg(csPin, 0x0E, table[step].freqRegs[1]);
spiWriteReg(csPin, 0x0F, table[step].freqRegs[2]);
// Write cached FSCAL registers (0x23, 0x24, 0x25)
spiWriteReg(csPin, 0x23, table[step].fscalRegs[0]);
spiWriteReg(csPin, 0x24, table[step].fscalRegs[1]);
spiWriteReg(csPin, 0x25, table[step].fscalRegs[2]);
// Disable auto-calibration before transmitting (MCSM0 register 0x18, bits 5:4 = 00)
// RadioLib defaults this to 0x18 (0001 1000) which is 01 (calibrate from IDLE to TX).
// We overwrite it to 0x08 (0000 1000) to never auto-calibrate.
spiWriteReg(csPin, 0x18, 0x08);
// Jump straight to TX without auto-cal
spiStrobe(csPin, 0x35); // STX
curFreq = table[step].freqMhz;
hopCnt++;
step = (step + 1) % steps;
}
void loop() {
ArduinoOTA.handle();
server.handleClient();
oledTick();
yield();
const uint32_t now = millis();
// Capture state machine — runs in main loop (ISR sets flags, loop acts on them)
// Signal-present detection: fire OLED "SIGNAL!" once per session when
// the ISR has seen enough stable bits to indicate a real RF burst.
// capLongRuns > 10 filters out thermal noise which transitions almost constantly.
if (capMode == CapMode::RECORDING && !capSigNotified && capLongRuns > 10) {
capSigNotified = true;
oledNotify("SIGNAL!", "CAUGHT -- PRESS STOP", 3000);
logLine("[CAP] Signal detected: " + String(capLongRuns) + " valid symbols @ bit " + String(capIdx));
}
if (capMode == CapMode::RECORDING && capBufFull) {
capTimerStop();
capRecBits = capIdx;
capBufFull = false;
capMode = CapMode::RECORDED;
gpio_set_direction(capGdoPin, GPIO_MODE_OUTPUT);
gpio_set_level(capGdoPin, 0);
logLine("[CAP] Buffer full: " + String(capRecBits) + " bits (" +
String(capRecBits * 1000 / CAP_SAMPLE_HZ) + " ms) captured");
oledNotify("CAPTURED", (String(capRecBits * 1000 / CAP_SAMPLE_HZ) + "ms").c_str());
if (capPrevJamming) {
capPrevJamming = false;
jammingEnabled = true;
startJamming();
}
}
// Auto-reinit watchdog: if jamming should be active but a radio failed, retry every 30s
if (jammingEnabled && now - lastReInitCheck >= 30000) {
lastReInitCheck = now;
bool needReinit = (radio1Status != 2 || radio2Status != 2);
if (needReinit) {
logLine("[WDT] Radio failure detected, attempting reinit...");
oledNotify("RADIO REINIT", "R1 + R2...");
startJamming();
}
}
static uint32_t lastHeartbeat = 0;
if (now - lastHeartbeat >= 5000) {
lastHeartbeat = now;
const uint32_t freeHeap = ESP.getFreeHeap();
if (freeHeap < minFreeHeap) minFreeHeap = freeHeap;
// Low-heap protection: heap below 15 KB risks crash — reboot cleanly
if (freeHeap < 15360) {
logLine("[CRIT] Heap critical: " + String(freeHeap) + "B — rebooting");
delay(500);
ESP.restart();
}
// Temperature alarm: log once per minute if over threshold
const float tempC = temperatureRead();
if (tempC > 75.0f && now - lastTempWarnMs > 60000) {
lastTempWarnMs = now;
logLine("[WARN] High temp: " + String(tempC, 1) + "°C");
}
Serial.println("[HEARTBEAT] up=" + String(now - uptimeStart) + "ms heap=" +
String(freeHeap) + " minHeap=" + String(minFreeHeap) +
" temp=" + String(tempC, 1) + "°C" +
" hops=" + String(hopCount1) + "/" + String(hopCount2));
Serial.flush();
}
// Frequency sweep — hop both radios across their bands while jamming
if (jammingEnabled) {
if (radio1Status == 2)
tickSweepFast(CC1101_1_CS, sweepStep1, sweep1Steps, sweepTable1, lastSweep1Ms, sweepFreq1, hopCount1);
if (radio2Status == 2)
tickSweepFast(CC1101_2_CS, sweepStep2, sweep2Steps, sweepTable2, lastSweep2Ms, sweepFreq2, hopCount2);
}
// Handle serial input for debugging
if (Serial.available()) {
String cmd = Serial.readStringUntil('\n');
cmd.trim();
if (cmd == "start") {
startJamming();
} else if (cmd == "stop") {
stopJamming();
} else if (cmd == "status") {
Serial.println("Jamming: " + String(jammingEnabled ? "ON" : "OFF"));
Serial.println("Power: " + String(jamPower) + " dBm");
Serial.println("Radio 1 (300-320 MHz): " + String(jammingEnabled ? "TRANSMITTING" : "STANDBY"));
Serial.println("Radio 2 (390-436 MHz): " + String(jammingEnabled ? "TRANSMITTING" : "STANDBY"));
}
}
}