/** * Dual CC1101 always-on key-fob jammer. * ESP32-S3 DevKitC-1: two CC1101 on shared SPI. * Radio 1: sweeps 300–320 MHz (US band — Honda 303.825, Toyota 315, Ford/GM/Chrysler 315, Linear 318 MHz) * Radio 2: sweeps 390–436 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 #include #include #include #include #include #include "driver/gpio.h" #include #include #include #include #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( CC1101 JAMMER

CC1101 JAMMER

ESP32-S3 • 300–320 MHz + 390–436 MHz • Dual-band FM noise sweep
v2.0 // LFSR+VCO

System Metrics

Uptime
CC1101 TX
dBm
Eff. Power
dBm
Eff. Watts
mW
Temp
°C
Free Heap
kB
Min Heap
kB
Dwell
ms
Hops R1
Hops R2
Hops/sec
AP Clients

Live Frequency Sweep

 Radio 1 — 300–320 MHz (Honda 303.825 · Toyota 314.98 · Ford/GM 315 · Linear 318)
 Radio 2 — 390–436 MHz (LiftMaster 390 · Holtek 418 · Somfy 433.42 · EU 433.92 · Nero 434.42)

2-Minute History

Temperature (°C)
Free Heap (kB)

Radio Status

Radio 1 — 300–320 MHz
Radio 2 — 390–436 MHz

Controls

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


Sweep Tuning

Signal Capture / Replay

State
IDLE
Bits
Duration
Est Bitrate
Duty Cycle
Cap Freq
REC pauses jamming and records raw demodulated signal for 4s. REPLAY transmits the capture on loop at the original frequency. STOP resumes jamming.

System Log  


)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")); } } }