Files
car-key-killer/src/main.cpp
2026-03-24 23:59:19 -07:00

2206 lines
85 KiB
C++

/**
* Dual CC1101 sub-GHz lab instrument (fixed carriers + optional capture/replay).
* ESP32-S3 DevKitC-1: two CC1101 on shared SPI.
* Radio 1: 315.0 MHz narrow FM + LFSR on GDO0; Radio 2: 433.92 MHz wide FM + LFSR.
* Intended for authorized RF research (e.g. sealed anechoic / shielded chamber).
* CC1101 reset and SPI sequencing follow TI SWRS061 (CHIP_RDYn, SRES Fig. 27).
*/
#include <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 <string.h>
#include <Wire.h>
#include <U8g2lib.h>
#include <esp_now.h>
#include <esp_wifi.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 — TX power fixed at CC1101 max (+10 dBm); no PA in hardware path.
static bool jammingEnabled = false;
static uint8_t jamPowerIdx = DEFAULT_JAM_POWER_IDX;
static int8_t jamPower = kPowerTable[DEFAULT_JAM_POWER_IDX];
// Auto-start jamming on power-up (NVS autoStartJam); independent of session toggle.
static bool autoStartJam = DEFAULT_AUTO_START_JAM;
// Individual radio status tracking
static int8_t radio1Status = -1; // 0=init, 1=standby/idle OK, 2=transmitting, -1=error
static int8_t radio2Status = -1;
static String radio1Error = "Disabled / not initialized";
static String radio2Error = "Disabled / not initialized";
// Telemetry
static uint32_t uptimeStart = 0;
static float currentRssi1 = NAN;
static float currentRssi2 = NAN;
// Locked jam carriers (UI / OLED)
static float jamFreq1 = JAM_LOCK_FREQ_1_MHZ;
static float jamFreq2 = JAM_LOCK_FREQ_2_MHZ;
// Gate LFSR ISR so GDO0 is only toggled for radios in direct async TX (avoids driving idle CC1101).
static volatile bool s_noiseEn1 = false;
static volatile bool s_noiseEn2 = false;
// Auto-reinit watchdog
static uint32_t lastReInitCheck = 0;
// 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 uint32_t in = REG_READ(GPIO_IN_REG);
const uint8_t clk = (in >> ENC_CLK_PIN) & 1u;
if (clk == encLastClk) return; // filter glitch
encLastClk = clk;
if (clk == LOW) { // falling edge = one detent
encDelta += ((in >> ENC_DT_PIN) & 1u) ? +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;
}
// ─── ESP-NOW peer mesh (auto-discover same firmware, no MAC entry) ───────────
// All units must use the same WiFi AP channel (softAP uses ch 1). Beacons go to
// broadcast; packets with magic "KLNK" mark another board running this build.
static constexpr uint8_t kEspNowMagic[4] = { 'K', 'L', 'N', 'K' };
struct EspNowPeerEntry {
uint8_t mac[6];
uint32_t lastSeenMs;
};
static EspNowPeerEntry s_espNowPeers[ESPNOW_MAX_PEERS];
static uint8_t s_espNowPeerCount = 0;
static uint32_t s_espNowBootToken = 0;
static bool s_espNowReady = false;
static uint32_t s_espNowLastTxMs = 0;
static uint8_t s_espNowSelfMac[6];
static uint8_t espNowActivePeerCount() {
const uint32_t now = millis();
uint8_t n = 0;
for (uint8_t i = 0; i < s_espNowPeerCount; i++) {
if (now - s_espNowPeers[i].lastSeenMs < ESPNOW_PEER_STALE_MS) n++;
}
return n;
}
static void espNowTouchPeer(const uint8_t mac[6]) {
if (memcmp(mac, s_espNowSelfMac, 6) == 0) return;
for (uint8_t i = 0; i < s_espNowPeerCount; i++) {
if (memcmp(s_espNowPeers[i].mac, mac, 6) == 0) {
s_espNowPeers[i].lastSeenMs = millis();
return;
}
}
if (s_espNowPeerCount < ESPNOW_MAX_PEERS) {
memcpy(s_espNowPeers[s_espNowPeerCount].mac, mac, 6);
s_espNowPeers[s_espNowPeerCount].lastSeenMs = millis();
s_espNowPeerCount++;
char m[24];
snprintf(m, sizeof(m), "%02X:%02X:%02X:%02X:%02X:%02X",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
logLine("[ESPNOW] node " + String(m));
return;
}
uint8_t bi = 0;
uint32_t oldest = s_espNowPeers[0].lastSeenMs;
for (uint8_t i = 1; i < ESPNOW_MAX_PEERS; i++) {
if (s_espNowPeers[i].lastSeenMs < oldest) {
oldest = s_espNowPeers[i].lastSeenMs;
bi = i;
}
}
memcpy(s_espNowPeers[bi].mac, mac, 6);
s_espNowPeers[bi].lastSeenMs = millis();
}
static void espNowOnRecv(const uint8_t* mac, const uint8_t* data, int len) {
if (len < 12 || mac == nullptr || data == nullptr) return;
if (memcmp(data, kEspNowMagic, 4) != 0) return;
espNowTouchPeer(mac);
}
static void espNowInit() {
s_espNowBootToken = esp_random();
if (s_espNowBootToken == 0) s_espNowBootToken = 0xC0FFEE01u;
if (esp_read_mac(s_espNowSelfMac, ESP_MAC_WIFI_SOFTAP) != ESP_OK) {
memset(s_espNowSelfMac, 0, 6);
}
if (esp_now_init() != ESP_OK) {
logLine("[ESPNOW] esp_now_init failed");
return;
}
esp_now_register_recv_cb(espNowOnRecv);
uint8_t bcast[6] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
esp_now_peer_info_t peer = {};
memcpy(peer.peer_addr, bcast, 6);
peer.channel = 0;
peer.encrypt = false;
peer.ifidx = WIFI_IF_AP;
esp_err_t e = esp_now_add_peer(&peer);
if (e != ESP_OK) {
logLine("[ESPNOW] add_peer(broadcast) failed: " + String((int)e));
esp_now_deinit();
return;
}
s_espNowReady = true;
logLine("[ESPNOW] mesh listening; beacons every " + String(ESPNOW_BEACON_MS) + " ms (same AP channel)");
}
static void espNowTick() {
if (!s_espNowReady) return;
const uint32_t now = millis();
if (now - s_espNowLastTxMs < ESPNOW_BEACON_MS) return;
s_espNowLastTxMs = now;
uint8_t pkt[12];
memcpy(pkt, kEspNowMagic, 4);
memcpy(pkt + 4, &s_espNowBootToken, 4);
uint32_t up = now - uptimeStart;
memcpy(pkt + 8, &up, 4);
uint8_t bcast[6] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
(void)esp_now_send(bcast, pkt, sizeof(pkt));
}
// Forward declarations
static void noiseGenStart();
static void startJamming();
static void stopJamming();
static void probeRadiosStandby();
static void oledNotify(const char* l1, const char* l2, uint32_t dur);
static void spiWriteReg(uint8_t csPin, uint8_t reg, uint8_t val);
// ─── 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
struct CapHistoryEntry {
uint32_t uptime_ms;
float freq_mhz;
uint32_t bits;
uint8_t radio_num;
uint8_t is_ook;
};
static CapHistoryEntry capHistory[CAP_HISTORY_MAX];
static uint8_t capHistoryCount = 0;
static void capHistoryPersist() {
preferences.putUChar("capHistN", capHistoryCount);
if (capHistoryCount > 0) {
preferences.putBytes("capHist", capHistory, capHistoryCount * sizeof(CapHistoryEntry));
}
}
static void capHistoryLoad() {
capHistoryCount = preferences.getUChar("capHistN", 0);
if (capHistoryCount > CAP_HISTORY_MAX) capHistoryCount = CAP_HISTORY_MAX;
if (capHistoryCount == 0) return;
const size_t expect = capHistoryCount * sizeof(CapHistoryEntry);
const size_t rd = preferences.getBytes("capHist", capHistory, sizeof(capHistory));
if (rd < expect) {
capHistoryCount = (uint8_t)(rd / sizeof(CapHistoryEntry));
}
}
static void capHistoryAppend(uint32_t bits, float freqMhz, uint8_t radioNum, bool isOOK) {
if (bits < 100) return;
if (capHistoryCount < CAP_HISTORY_MAX) {
memmove(&capHistory[1], &capHistory[0], capHistoryCount * sizeof(CapHistoryEntry));
capHistoryCount++;
} else {
memmove(&capHistory[1], &capHistory[0], (CAP_HISTORY_MAX - 1) * sizeof(CapHistoryEntry));
}
capHistory[0].uptime_ms = millis();
capHistory[0].freq_mhz = freqMhz;
capHistory[0].bits = bits;
capHistory[0].radio_num = radioNum;
capHistory[0].is_ook = isOOK ? 1u : 0u;
capHistoryPersist();
}
// ─── Capture/replay ISRs ──────────────────────────────────────────────────────
static void IRAM_ATTR capRecordISR() {
const uint32_t i = capIdx;
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);
if (capRecBits >= 100) {
capHistoryAppend(capRecBits, capFreq, capRadioNum, capIsOOK);
}
} 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;
if (prev) ones++; // count bit 0
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 (PATABLE burst for OOK replay) ──────────────────────────────────
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();
}
// TI CC1101 SWRS061 §10.1 / §19.1.2 Figure 27 — CHIP_RDYn on SO after CSn low; SRES with SCLK=1, SI=0 before sequence.
static bool cc1101WaitMisoLow(uint32_t timeoutUs) {
const uint32_t t0 = micros();
while (digitalRead(SPI_MISO_PIN) == HIGH) {
if ((uint32_t)(micros() - t0) > timeoutUs) return false;
}
return true;
}
static bool cc1101ManualReset(uint8_t csPin) {
digitalWrite(CC1101_1_CS, HIGH);
digitalWrite(CC1101_2_CS, HIGH);
pinMode(SPI_SCK_PIN, OUTPUT);
pinMode(SPI_MOSI_PIN, OUTPUT);
digitalWrite(SPI_SCK_PIN, HIGH);
digitalWrite(SPI_MOSI_PIN, LOW);
pinMode(csPin, OUTPUT);
digitalWrite(csPin, HIGH);
delayMicroseconds(20);
digitalWrite(csPin, LOW);
delayMicroseconds(200);
digitalWrite(csPin, HIGH);
delayMicroseconds(50);
digitalWrite(csPin, LOW);
if (!cc1101WaitMisoLow(10000)) {
digitalWrite(csPin, HIGH);
return false;
}
spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
spi.transfer(0x30);
{
const uint32_t t0 = micros();
while (digitalRead(SPI_MISO_PIN) == HIGH) {
if ((uint32_t)(micros() - t0) > 50000) {
digitalWrite(csPin, HIGH);
spi.endTransaction();
return false;
}
}
}
digitalWrite(csPin, HIGH);
spi.endTransaction();
// XOSC / digital core settle (SWRS061 §19.1); margin beyond 150 µs tsp,pd for lab repeatability
delayMicroseconds(800);
return true;
}
static uint8_t cc1101ReadRegister(uint8_t csPin, uint8_t regAddr6) {
digitalWrite(CC1101_1_CS, HIGH);
digitalWrite(CC1101_2_CS, HIGH);
spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
digitalWrite(csPin, LOW);
delayMicroseconds(10);
spi.transfer((uint8_t)(0x80u | (regAddr6 & 0x3Fu)));
uint8_t v = spi.transfer(0x00);
digitalWrite(csPin, HIGH);
spi.endTransaction();
return v;
}
// VERSION (0x31): only 0xFF indicates dead SPI / wrong CS; other values are valid silicon revs (SWRS061).
static bool cc1101VerifyVersion(uint8_t csPin, String* errOut) {
const uint8_t ver = cc1101ReadRegister(csPin, 0x31);
if (ver == 0xFF) {
if (errOut) *errOut = "VERSION 0xFF (MISO open or CS conflict)";
return false;
}
logLine("[CC1101] CS" + String((int)csPin) + " VERSION=0x" + String(ver, HEX));
return true;
}
static void probeOneRadio(CC1101& radio, uint8_t cs, float nomMhz, float lockMhz, float devKhz,
int8_t& stOut, String& errOut) {
stOut = -1;
if (!cc1101ManualReset(cs)) {
errOut = "SRES/CHIP_RDYn failed (see SWRS061 Fig.27)";
return;
}
delay(5);
int st = RADIOLIB_ERR_CHIP_NOT_FOUND;
for (int attempt = 0; attempt < 3 && st != RADIOLIB_ERR_NONE; attempt++) {
if (attempt > 0) delay(30);
st = radio.begin(nomMhz, JAM_BITRATE_KBPS, JAM_FREQ_DEV_KHZ, JAM_RX_BW_KHZ, jamPower, 16);
}
if (st != RADIOLIB_ERR_NONE) {
errOut = "Init failed: " + String(st);
return;
}
String verr;
if (!cc1101VerifyVersion(cs, &verr)) {
(void)radio.standby();
errOut = verr;
return;
}
radio.setFrequency(lockMhz);
radio.setFrequencyDeviation(devKhz);
(void)radio.standby();
stOut = 1;
errOut = "";
}
static void probeRadiosStandby() {
radio1Error = "";
radio2Error = "";
jamFreq1 = JAM_LOCK_FREQ_1_MHZ;
jamFreq2 = JAM_LOCK_FREQ_2_MHZ;
int8_t s1 = -1, s2 = -1;
probeOneRadio(radio1, CC1101_1_CS, CC1101_1_FREQ_MHZ, JAM_LOCK_FREQ_1_MHZ, JAM_DEV_KHZ_R1_NARROW, s1, radio1Error);
radio1Status = s1;
probeOneRadio(radio2, CC1101_2_CS, CC1101_2_FREQ_MHZ, JAM_LOCK_FREQ_2_MHZ, JAM_DEV_KHZ_R2_WIDE, s2, radio2Error);
radio2Status = s2;
if (radio1Status == 1) logLine("[R1] probe OK (standby)");
else logLine("[R1] probe FAIL: " + radio1Error);
if (radio2Status == 1) logLine("[R2] probe OK (standby)");
else logLine("[R2] probe FAIL: " + radio2Error);
}
// Start simultaneous jamming on both radios (SRES before each begin; VERSION check; noise after TX entry).
static void startJamming() {
logLine("[JAM] Starting simultaneous jamming system");
jamPowerIdx = DEFAULT_JAM_POWER_IDX;
jamPower = kPowerTable[jamPowerIdx];
preferences.putInt("jamPowerIdx", (int)jamPowerIdx);
logLine("[JAM] TX power fixed: " + String(jamPower) + " dBm (max)");
s_noiseEn1 = false;
s_noiseEn2 = false;
if (s_noiseTimer) {
timerAlarmDisable(s_noiseTimer);
timerDetachInterrupt(s_noiseTimer);
timerEnd(s_noiseTimer);
s_noiseTimer = nullptr;
}
gpio_set_level((gpio_num_t)CC1101_1_GDO0, 0);
gpio_set_level((gpio_num_t)CC1101_2_GDO0, 0);
radio1Status = 0;
radio2Status = 0;
radio1Error = "";
radio2Error = "";
int st1 = RADIOLIB_ERR_CHIP_NOT_FOUND;
for (int attempt = 0; attempt < 3 && st1 != RADIOLIB_ERR_NONE; attempt++) {
if (attempt > 0) delay(50);
if (!cc1101ManualReset(CC1101_1_CS)) {
st1 = RADIOLIB_ERR_CHIP_NOT_FOUND;
radio1Error = "SRES/CHIP_RDYn failed";
continue;
}
delay(5);
st1 = radio1.begin(CC1101_1_FREQ_MHZ, JAM_BITRATE_KBPS, JAM_FREQ_DEV_KHZ, JAM_RX_BW_KHZ, jamPower, 16);
}
if (st1 != RADIOLIB_ERR_NONE) {
radio1Status = -1;
if (radio1Error.length() == 0) radio1Error = "Init failed: " + String(st1);
logLine("[R1] init failed: " + radio1Error);
} else {
String verr;
if (!cc1101VerifyVersion(CC1101_1_CS, &verr)) {
(void)radio1.standby();
radio1Status = -1;
radio1Error = verr;
logLine("[R1] VERSION check failed: " + verr);
} else {
radio1Status = 1;
radio1.setFrequency(JAM_LOCK_FREQ_1_MHZ);
radio1.setFrequencyDeviation(JAM_DEV_KHZ_R1_NARROW);
}
}
int st2 = RADIOLIB_ERR_CHIP_NOT_FOUND;
for (int attempt = 0; attempt < 3 && st2 != RADIOLIB_ERR_NONE; attempt++) {
if (attempt > 0) delay(50);
if (!cc1101ManualReset(CC1101_2_CS)) {
st2 = RADIOLIB_ERR_CHIP_NOT_FOUND;
radio2Error = "SRES/CHIP_RDYn failed";
continue;
}
delay(5);
st2 = radio2.begin(CC1101_2_FREQ_MHZ, JAM_BITRATE_KBPS, JAM_FREQ_DEV_KHZ, JAM_RX_BW_KHZ, jamPower, 16);
}
if (st2 != RADIOLIB_ERR_NONE) {
radio2Status = -1;
if (radio2Error.length() == 0) radio2Error = "Init failed: " + String(st2);
logLine("[R2] init failed: " + radio2Error);
} else {
String verr;
if (!cc1101VerifyVersion(CC1101_2_CS, &verr)) {
(void)radio2.standby();
radio2Status = -1;
radio2Error = verr;
logLine("[R2] VERSION check failed: " + verr);
} else {
radio2Status = 1;
radio2.setFrequency(JAM_LOCK_FREQ_2_MHZ);
radio2.setFrequencyDeviation(JAM_DEV_KHZ_R2_WIDE);
}
}
jamFreq1 = JAM_LOCK_FREQ_1_MHZ;
jamFreq2 = JAM_LOCK_FREQ_2_MHZ;
int stTx1 = RADIOLIB_ERR_NONE;
int stTx2 = RADIOLIB_ERR_NONE;
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;
}
}
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;
}
}
s_noiseEn1 = (radio1Status == 2);
s_noiseEn2 = (radio2Status == 2);
if (s_noiseEn1 || s_noiseEn2) {
noiseGenStart();
}
if (radio1Status == 2 || radio2Status == 2) {
logLine("[JAM] Fixed carriers:");
logLine("[JAM] R1: " + String(JAM_LOCK_FREQ_1_MHZ, 2) + " MHz @ " + String(jamPower) + " dBm (" + String(radio1Status == 2 ? "TX" : "off") + ")");
logLine("[JAM] R2: " + String(JAM_LOCK_FREQ_2_MHZ, 2) + " MHz @ " + String(jamPower) + " dBm (" + String(radio2Status == 2 ? "TX" : "off") + ")");
} else {
logLine("[JAM] Both radios failed — check SPI, power, antenna");
logLine("[JAM] R1: " + radio1Error);
logLine("[JAM] R2: " + radio2Error);
}
}
// 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;
s_noiseEn1 = false;
s_noiseEn2 = false;
// 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, ">> LOCKED JAM <<");
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) {
uint32_t currentIdx, currentTransitions;
noInterrupts();
currentIdx = capIdx;
currentTransitions = capTransitions;
interrupts();
const uint32_t pct = currentIdx * 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)(currentIdx / CAP_SAMPLE_HZ),
(unsigned)CAP_DURATION_S,
(unsigned long)currentTransitions);
} 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)jamFreq1);
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)jamFreq2);
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 dBm max", (int)jamPower);
u8g2.drawStr(0, 44, buf);
}
// Row 4: temp + heap OR FULL TX badge
if (jam && r1 && r2) {
u8g2.drawStr(0, 55, "[ 315 + 433.92 LOCK ]");
} 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)jamFreq1);
u8g2.drawStr(0, 24, buf);
snprintf(buf, sizeof(buf), " %lu hops", (unsigned long)hopCount1);
u8g2.drawStr(0, 33, buf);
snprintf(buf, sizeof(buf), "R2 %.4f MHz", (double)jamFreq2);
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;
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 ESPNOW %u",
WiFi.softAPgetStationNum(), (unsigned)espNowActivePeerCount());
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 modulated bandwidth.
// R2 (433.92 MHz) uses max CC1101 deviation (~810 kHz FM noise). R1 (315 MHz)
// uses narrow deviation so most energy stays on-channel.
//
// 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;
if (s_noiseEn1) gpio_set_level((gpio_num_t)CC1101_1_GDO0, (s >> 0) & 1u);
else gpio_set_level((gpio_num_t)CC1101_1_GDO0, 0);
if (s_noiseEn2) gpio_set_level((gpio_num_t)CC1101_2_GDO0, (s >> 7) & 1u);
else gpio_set_level((gpio_num_t)CC1101_2_GDO0, 0);
}
static void noiseGenStart() {
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.
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);
}
// 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>
<div style="display:flex;align-items:center;gap:12px">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#86f28a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="filter:drop-shadow(0 0 6px rgba(134,242,138,0.5))"><path d="M2 12h4l3-9 5 18 3-9h5"/></svg>
<div>
<h1>CC1101 JAMMER</h1>
<div style="font-size:11px;color:#a0f5a4;font-style:italic;margin-top:1px;letter-spacing:0.08em;text-shadow:0 0 4px rgba(134,242,138,0.3)">"only tryan make a dollar"</div>
</div>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#86f28a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="filter:drop-shadow(0 0 6px rgba(134,242,138,0.5))"><path d="M2 12h4l3-9 5 18 3-9h5"/></svg>
</div>
<div class="sub" style="margin-top:5px">ESP32-S3 &bull; LOCK 315 MHz + LOCK 433.92 MHz &bull; Dual-carrier LFSR jam</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 (max)</div><div class="sv" id="mPow">—<span class="su">dBm</span></div></div>
<div class="s"><div class="sl">ERP (chip only)</div><div class="sv" id="mEff">—<span class="su">dBm</span></div></div>
<div class="s"><div class="sl">ERP Watts</div><div class="sv" id="mW">—<span class="su">mW</span></div></div>
<div class="s"><div class="sl">R1 hardware</div><div class="sv" id="mR1h">—</div></div>
<div class="s"><div class="sl">R2 hardware</div><div class="sv" id="mR2h">—</div></div>
<div class="s"><div class="sl">Jam on boot</div><div class="sv" id="mBoot">—</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">AP Clients</div><div class="sv" id="mCl">—</div></div>
<div class="s"><div class="sl">Nodes (ESP-NOW)</div><div class="sv" id="mEn">—</div></div>
</div>
</div>
<div class="card">
<h2>Locked jam carriers</h2>
<div class="band">
<div class="bl">
<span><span class="dot on" id="d1"></span>&nbsp;Radio 1 — 315.000 MHz (NA)&nbsp;<small style="color:#2a6a2e">narrow FM + LFSR</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 — 433.920 MHz (EU/global)&nbsp;<small style="color:#2a6a2e">max FM noise + LFSR</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 — 315 MHz LOCK</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 — 433.92 MHz LOCK</strong></div>
<div id="r2s" class="sub">—</div><div id="r2e" class="err"></div>
</div>
</div>
</div>
<div class="card">
<h2>Controls</h2>
<p style="font-size:10px;color:#3a7a3e;margin-bottom:8px">TX power is fixed at +10 dBm (CC1101 max, TI SWRS061). Radiated level includes your antenna gain only no external PA.</p>
<div class="row" style="margin-bottom:10px">
<div class="col" style="display:flex;flex-direction:column;gap:6px">
<button id="tog">Start Jamming</button>
<button id="bootJamOff" class="d">Boot: jam OFF next power-up</button>
<button id="bootJamOn">Boot: jam ON next power-up</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>
<h2 style="margin-top:12px;margin-bottom:6px">Capture history (NVS)</h2>
<div style="overflow-x:auto;border:1px solid #122814">
<table id="capHist" style="width:100%;font-size:10px;border-collapse:collapse">
<thead><tr style="color:#4fbf59;text-align:left"><th style="padding:4px">#</th><th style="padding:4px">Time (uptime)</th><th style="padding:4px">MHz</th><th style="padding:4px">Bits</th><th style="padding:4px">Radio</th><th style="padding:4px">Mod</th></tr></thead>
<tbody id="capHistBody"></tbody>
</table>
</div>
<div style="font-size:9px;color:#2a6a2e;margin-top:5px">
REC pauses jamming, RX via CC1101 GDO0 (SWRS061). STOP saves to history if 100 bits. REPLAY loops async TX. If boot jam is OFF, capture works without fighting auto-TX.
</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>
<div style="text-align:center; padding: 20px 0; color: #3a7a3e; font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase;">
rusian marks atm
</div>
</main><script>
let T={},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;
lastP=Date.now();
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)+'%)';
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 ok1=t.radio1_hw_ok===true||t.radio1_hw_ok==='true';
const ok2=t.radio2_hw_ok===true||t.radio2_hw_ok==='true';
const tx1=t.radio1_active===true||t.radio1_active==='true';
const tx2=t.radio2_active===true||t.radio2_active==='true';
const parts=[];
if(!ok1)parts.push('R1 FAULT');
if(!ok2)parts.push('R2 FAULT');
if(jam&&(tx1||tx2)){
const b=[];if(tx1)b.push('315 TX');if(tx2)b.push('433.92 TX');
parts.push(b.join(' + '));
}
document.getElementById('bs').textContent=parts.length?parts.join(' | ')+' | '+t.jam_power+' dBm chip (ERP + antenna only)'
:(jam?'Jamming on but no TX':'Standby use REC when boot jam is OFF');
document.getElementById('bs').style.color=(jam&&(tx1||tx2))?'#86f28a':(!ok1||!ok2||jam)?'#f28a86':'#4fbf59';
document.getElementById('tog').textContent=jam?'Stop Jamming':'Start Jamming';
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 el1=document.getElementById('mR1h');el1.textContent=ok1?'OK':'FAIL';el1.className='sv '+(ok1?'':'crit');
const el2=document.getElementById('mR2h');el2.textContent=ok2?'OK':'FAIL';el2.className='sv '+(ok2?'':'crit');
const bootOn=t.auto_start_jam===true||t.auto_start_jam==='true';
const bootEl=document.getElementById('mBoot');bootEl.textContent=bootOn?'ON':'OFF';bootEl.className='sv '+(bootOn?'':'lo');
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('mCl').textContent=t.ap_clients??'';
const en=document.getElementById('mEn');
if(en)en.textContent=(typeof t.espnow_ok==='boolean')?(t.espnow_ok?String(t.espnow_peers??0):'off'):'';
const jf1=t.jam_freq1??315,sp1=4;
const jf2=t.jam_freq2??433.92,sp2=8;
updTr(tr1,0.5);updTr(tr2,0.5);
drawSw('c1',jf1,jf1,sp1,tx1,tr1,MK1);
drawSw('c2',jf2,jf2,sp2,tx2,tr2,MK2);
document.getElementById('f1c').textContent=ff(jf1);
document.getElementById('f2c').textContent=ff(jf2);
const sd=(id,on)=>{const d=document.getElementById(id);d.className='dot '+(on?'on':'off');};
sd('d1',tx1);sd('d2',tx2);sd('r1d',ok1);sd('r2d',ok2);
const rs=s=>s===2?'TRANSMITTING':s===1?'IDLE/OK':s===0?'INIT':'ERROR';
document.getElementById('r1s').textContent=rs(t.radio1_status)+' '+ff(t.jam_freq1);
document.getElementById('r2s').textContent=rs(t.radio2_status)+' '+ff(t.jam_freq2);
document.getElementById('r1e').textContent=t.radio1_error||'';
document.getElementById('r2e').textContent=t.radio2_error||'';
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 capRefreshHist(){
try{
const r=await fetch('/api/capture/history');
const arr=await r.json();
const tb=document.getElementById('capHistBody');
if(!tb||!Array.isArray(arr))return;
tb.innerHTML=arr.map((row,i)=>'<tr style="border-top:1px solid #122814"><td style="padding:4px">'+(i+1)+'</td><td style="padding:4px">'+fu(row.uptime_ms)+'</td><td style="padding:4px">'+(+row.freq).toFixed(3)+'</td><td style="padding:4px">'+(+row.bits).toLocaleString()+'</td><td style="padding:4px">'+row.radio+'</td><td style="padding:4px">'+row.mod+'</td></tr>').join('');
}catch(e){}
}
async function poll(){
try{
const tr=await fetch('/api/telemetry');applyTelemetry(await tr.json());
if(++logTick%3===0)capRefreshHist();
if(logTick%5===0){
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('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('bootJamOff').addEventListener('click',async()=>{try{await fetch('/api/auto_start_jam',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({auto_start_jam:false})});applyTelemetry(await(await fetch('/api/telemetry')).json());}catch(e){}});
document.getElementById('bootJamOn').addEventListener('click',async()=>{try{await fetch('/api/auto_start_jam',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({auto_start_jam:true})});applyTelemetry(await(await fetch('/api/telemetry')).json());}catch(e){}});
document.getElementById('dl').addEventListener('click',()=>{const a=document.createElement('a');a.href='/api/log';a.download='jammer-log.txt';a.click();});
poll();
capRefreshHist();
// ── 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();
const int8_t effDbm = jamPower;
const float effWatts = powf(10.0f, effDbm / 10.0f) / 1000.0f;
String err1 = jsonEscape(radio1Error);
String err2 = jsonEscape(radio2Error);
static char jsonBuf[1400];
snprintf(jsonBuf, sizeof(jsonBuf),
"{"
"\"uptime_ms\":%lu,"
"\"free_heap\":%lu,"
"\"temp_c\":%.1f,"
"\"jamming_enabled\":%s,"
"\"auto_start_jam\":%s,"
"\"jam_power\":%d,"
"\"jam_power_idx\":%d,"
"\"eff_power_dbm\":%d,"
"\"eff_power_w\":%.3f,"
"\"jam_freq1\":%.4f,"
"\"jam_freq2\":%.4f,"
"\"jam_fixed\":true,"
"\"radio1_status\":%d,"
"\"radio1_error\":\"%s\","
"\"radio1_freq\":%.4f,"
"\"radio1_active\":%s,"
"\"radio1_hw_ok\":%s,"
"\"radio2_status\":%d,"
"\"radio2_error\":\"%s\","
"\"radio2_freq\":%.4f,"
"\"radio2_active\":%s,"
"\"radio2_hw_ok\":%s,"
"\"hop_count1\":%lu,"
"\"hop_count2\":%lu,"
"\"min_heap\":%lu,"
"\"ap_clients\":%d,"
"\"espnow_ok\":%s,"
"\"espnow_peers\":%u"
"}",
(unsigned long)(millis() - uptimeStart),
(unsigned long)ESP.getFreeHeap(),
(double)tempC,
jammingEnabled ? "true" : "false",
autoStartJam ? "true" : "false",
(int)jamPower,
(int)jamPowerIdx,
(int)effDbm,
(double)effWatts,
(double)jamFreq1,
(double)jamFreq2,
(int)radio1Status,
err1.c_str(),
(double)jamFreq1,
radio1Status == 2 ? "true" : "false",
radio1Status != -1 ? "true" : "false",
(int)radio2Status,
err2.c_str(),
(double)jamFreq2,
radio2Status == 2 ? "true" : "false",
radio2Status != -1 ? "true" : "false",
(unsigned long)hopCount1,
(unsigned long)hopCount2,
(unsigned long)(minFreeHeap == 0xFFFFFFFF ? ESP.getFreeHeap() : minFreeHeap),
(int)WiFi.softAPgetStationNum(),
s_espNowReady ? "true" : "false",
(unsigned)espNowActivePeerCount()
);
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);
}
// POST {"auto_start_jam":true|false} — jam on AC power-up (TI init path unchanged).
static void handleAutoStartJam() {
if (server.hasArg("plain")) {
String body = server.arg("plain");
const int p = body.indexOf("\"auto_start_jam\"");
if (p >= 0) {
int c = body.indexOf(':', p) + 1;
while (c < (int)body.length() && (body.charAt(c) == ' ' || body.charAt(c) == '\t')) c++;
autoStartJam = body.substring(c, c + 4) == "true";
preferences.putBool("autoStartJam", autoStartJam);
logLine("[NVS] autoStartJam=" + String(autoStartJam ? "true" : "false"));
oledNotify("BOOT JAM", autoStartJam ? "ON next power-up" : "OFF next power-up");
}
}
server.send(200, "application/json; charset=utf-8",
String("{\"success\":true,\"auto_start_jam\":") + (autoStartJam ? "true" : "false") + "}");
}
static void handleCaptureHistory() {
String j = "[";
for (uint8_t i = 0; i < capHistoryCount; i++) {
const CapHistoryEntry& e = capHistory[i];
if (i) j += ",";
j += "{\"uptime_ms\":" + String(e.uptime_ms) +
",\"freq\":" + String(e.freq_mhz, 3) +
",\"bits\":" + String(e.bits) +
",\"radio\":" + String((unsigned)e.radio_num) +
",\"mod\":\"" + String(e.is_ook ? "ook" : "fsk") + "\"}";
}
j += "]";
server.send(200, "application/json; charset=utf-8", j);
}
static void handleHealth() {
static char buf[160];
snprintf(buf, sizeof(buf),
"{\"ok\":true,\"uptime_ms\":%lu,\"heap\":%lu,\"ap_clients\":%d,"
"\"espnow_ok\":%s,\"espnow_peers\":%u}",
(unsigned long)(millis() - uptimeStart),
(unsigned long)ESP.getFreeHeap(),
(int)WiFi.softAPgetStationNum(),
s_espNowReady ? "true" : "false",
(unsigned)espNowActivePeerCount());
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;
uint32_t currentIdx;
noInterrupts(); currentIdx = capIdx; interrupts();
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)currentIdx,
(unsigned long)(CAP_BUF_BYTES * 8),
(unsigned long)capRecBits,
(unsigned long)(currentIdx * 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 (TX power always max; no PA gain; no sweep)
preferences.begin("jammer4", false);
autoStartJam = preferences.getBool("autoStartJam", DEFAULT_AUTO_START_JAM);
jamPowerIdx = DEFAULT_JAM_POWER_IDX;
jamPower = kPowerTable[jamPowerIdx];
preferences.putInt("jamPowerIdx", (int)jamPowerIdx);
capHistoryLoad();
logLine("[NVS] autoStartJam=" + String(autoStartJam ? "true" : "false") +
" jamPower=" + String(jamPower) + " dBm (fixed max, TI PATABLE)");
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 (apOk) {
espNowInit();
} else {
logLine("[ESPNOW] skipped (AP not up — need WiFi channel for ESP-NOW)");
}
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/auto_start_jam", HTTP_POST, handleAutoStartJam);
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.on("/api/capture/history", HTTP_GET, handleCaptureHistory);
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();
if (autoStartJam) {
jammingEnabled = true;
oledBootMsg("Radio init...");
startJamming();
if (radio1Status == 2 || radio2Status == 2) {
oledBootMsg("JAMMING - ACTIVE!");
} else {
oledBootMsg("RADIO INIT FAILED");
}
} else {
jammingEnabled = false;
oledBootMsg("Probe CC1101...");
probeRadiosStandby();
oledBootMsg("Standby — boot jam OFF");
logLine("[JAM] Boot jam disabled; radios probed (standby). Use web or toggle to TX.");
}
delay(800); // hold boot result on display briefly before switching to live pages
}
void loop() {
espNowTick();
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.
uint32_t currentLongRuns;
noInterrupts();
currentLongRuns = capLongRuns;
interrupts();
if (capMode == CapMode::RECORDING && !capSigNotified && currentLongRuns > 10) {
capSigNotified = true;
uint32_t currentIdx;
noInterrupts(); currentIdx = capIdx; interrupts();
oledNotify("SIGNAL!", "CAUGHT -- PRESS STOP", 3000);
logLine("[CAP] Signal detected: " + String(currentLongRuns) + " valid symbols @ bit " + String(currentIdx));
}
if (capMode == CapMode::RECORDING && capBufFull) {
capTimerStop();
noInterrupts();
capRecBits = capIdx;
interrupts();
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 (capRecBits >= 100) {
capHistoryAppend(capRecBits, capFreq, capRadioNum, capIsOOK);
}
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();
}
// Handle serial input for debugging
if (Serial.available()) {
String cmd = Serial.readStringUntil('\n');
cmd.trim();
if (cmd == "start") {
jammingEnabled = true;
startJamming();
} else if (cmd == "stop") {
stopJamming();
} else if (cmd == "status") {
Serial.println("Jamming: " + String(jammingEnabled ? "ON" : "OFF"));
Serial.println("Boot auto-jam: " + String(autoStartJam ? "ON" : "OFF"));
Serial.println("Power: " + String(jamPower) + " dBm (fixed max)");
Serial.println("R1 hw_ok=" + String(radio1Status != -1) + " st=" + String((int)radio1Status));
Serial.println("R2 hw_ok=" + String(radio2Status != -1) + " st=" + String((int)radio2Status));
}
}
}