- Remove captive portal (DNSServer) — UI served cleanly at 192.168.4.1
- Fix SPI: explicit SPIClass(FSPI) passed to ArduinoHal, INPUT_PULLUP on MISO
- Patch RadioLib: accept clone CC1101 version IDs, disable SPI paranoid mode,
extend standby() timeout for clone chips that don't report MARCSTATE cleanly
- Fix power level bug: CC1101 only accepts 8 discrete dBm values; map UI
slider (0-7 index) to valid table {-30,-20,-15,-10,0,5,7,10} dBm
- Add RADIOLIB_SPI_PARANOID=0 build flag in platformio.ini
- Add frequency sweep: both radios hop ±500 kHz across their bands every 8ms
with 250 kbps bitrate and ±120 kHz deviation for wideband noise coverage
- Fix NVS state persistence so failed init never saves jamEnabled=false
- Fix HTML serving via sendContent() to prevent heap fragmentation on refresh
- Remove 404 redirect loop that was causing repeated large HTML transfers
Made-with: Cursor
862 lines
29 KiB
C++
862 lines
29 KiB
C++
/**
|
||
* Dual CC1101 always-on key-fob jammer (315 MHz + 433.92 MHz).
|
||
* ESP32-S3 DevKitC-1: two CC1101 on shared SPI.
|
||
* On power-up, WiFi AP + web UI starts immediately; both radios begin jamming.
|
||
* Simultaneous jamming on both 315 MHz and 433.92 MHz frequencies with adjustable power.
|
||
*/
|
||
|
||
#include <Arduino.h>
|
||
#include <RadioLib.h>
|
||
#include <WiFi.h>
|
||
#include <WebServer.h>
|
||
#include <ESPmDNS.h>
|
||
#include <Preferences.h>
|
||
#include <math.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;
|
||
|
||
// 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) {
|
||
logRing[logHead] = s;
|
||
logHead = (logHead + 1) % LOG_LINES;
|
||
if (logCount < LOG_LINES) logCount++;
|
||
Serial.println(s);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Start both radios transmitting simultaneously
|
||
int stTx1 = RADIOLIB_ERR_NONE;
|
||
int stTx2 = RADIOLIB_ERR_NONE;
|
||
|
||
if (radio1Status == 1) {
|
||
stTx1 = radio1.transmitDirect();
|
||
if (stTx1 != RADIOLIB_ERR_NONE) {
|
||
radio1Status = -1;
|
||
radio1Error = "Transmit failed: " + String(stTx1);
|
||
logLine("[R1] transmitDirect failed: " + String(stTx1));
|
||
} else {
|
||
radio1Status = 2; // Transmitting
|
||
}
|
||
}
|
||
|
||
if (radio2Status == 1) {
|
||
stTx2 = radio2.transmitDirect();
|
||
if (stTx2 != RADIOLIB_ERR_NONE) {
|
||
radio2Status = -1;
|
||
radio2Error = "Transmit failed: " + String(stTx2);
|
||
logLine("[R2] transmitDirect failed: " + String(stTx2));
|
||
} else {
|
||
radio2Status = 2; // Transmitting
|
||
}
|
||
}
|
||
|
||
if (radio1Status == 2 || radio2Status == 2) {
|
||
logLine("[JAM] Jamming active:");
|
||
logLine("[JAM] Radio 1: 315 MHz at " + String(jamPower) + " dBm (status: " + String(radio1Status == 2 ? "TX" : "FAIL") + ")");
|
||
logLine("[JAM] Radio 2: 433.92 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
|
||
static void stopJamming() {
|
||
if (!jammingEnabled) {
|
||
logLine("[JAM] Not currently jamming, ignoring stop request");
|
||
return;
|
||
}
|
||
|
||
logLine("[JAM] Stopping jamming system");
|
||
|
||
// Stop transmission on both radios
|
||
if (radio1Status == 2) { // Transmitting
|
||
int st1 = radio1.standby();
|
||
if (st1 != RADIOLIB_ERR_NONE) {
|
||
radio1Error = "Standby failed: " + String(st1);
|
||
logLine("[R1] standby failed: " + String(st1));
|
||
} else {
|
||
radio1Status = 1; // Initialized but not transmitting
|
||
radio1Error = "";
|
||
}
|
||
}
|
||
|
||
if (radio2Status == 2) { // Transmitting
|
||
int st2 = radio2.standby();
|
||
if (st2 != RADIOLIB_ERR_NONE) {
|
||
radio2Error = "Standby failed: " + String(st2);
|
||
logLine("[R2] standby failed: " + String(st2));
|
||
} else {
|
||
radio2Status = 1; // Initialized but not transmitting
|
||
radio2Error = "";
|
||
}
|
||
}
|
||
|
||
jammingEnabled = false;
|
||
logLine("[JAM] Jamming stopped");
|
||
}
|
||
|
||
// 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>
|
||
<head>
|
||
<meta charset="utf-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||
<title>CC1101 Key-Fob Jammer</title>
|
||
<style>
|
||
:root { color-scheme: dark; }
|
||
body { margin: 0; background: #050607; color: #86f28a; font-family: ui-monospace, monospace; }
|
||
header { padding: 12px 16px; border-bottom: 1px solid #123a16; }
|
||
.muted { color:#4fbf59; opacity: 0.9; }
|
||
main { padding: 16px; }
|
||
.card { padding: 16px; background:#030404; border: 1px solid #123a16; margin-bottom: 16px; }
|
||
.controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
|
||
.control-group { display: flex; flex-direction: column; gap: 8px; }
|
||
label { font-size: 14px; }
|
||
input[type="range"] { width: 100%; }
|
||
.value { font-weight: bold; }
|
||
button { padding: 8px 16px; background: #123a16; color: #86f28a; border: 1px solid #4fbf59; cursor: pointer; }
|
||
button:hover { background: #1a4d1f; }
|
||
button.danger { background: #3a1216; border-color: #bf4f59; }
|
||
pre { margin:0; padding:12px; background:#030404; border: 1px solid #123a16; height: 40vh; overflow:auto; font-size: 12px; }
|
||
.status { display: inline-block; padding: 4px 8px; border-radius: 4px; }
|
||
.status-active { background: #123a16; }
|
||
.status-inactive { background: #3a1216; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>CC1101 Key-Fob Jammer</h1>
|
||
<div class="muted">Dual-frequency (315 MHz + 433.92 MHz) simultaneous jamming</div>
|
||
</header>
|
||
<main>
|
||
<!-- Large Jamming Status Banner -->
|
||
<div id="jammingBanner" style="margin: 16px 0; padding: 20px; text-align: center; border: 3px solid #123a16; background: #030404; font-size: 24px; font-weight: bold;">
|
||
<div id="jammingStatusText">JAMMING STATUS: LOADING...</div>
|
||
<div id="jammingDetails" style="font-size: 16px; margin-top: 8px; color: #4fbf59;"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>System Status</h2>
|
||
<div id="statusDisplay">Loading...</div>
|
||
<div id="radioStatus" style="margin-top: 16px;"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Radio Status</h2>
|
||
<div class="controls">
|
||
<div class="control-group">
|
||
<h3 style="margin: 0 0 8px 0;">Radio 1 (315 MHz)</h3>
|
||
<div id="radio1Status" class="muted">Status: Unknown</div>
|
||
<div id="radio1Error" class="muted" style="color: #bf4f59; font-size: 12px;"></div>
|
||
</div>
|
||
<div class="control-group">
|
||
<h3 style="margin: 0 0 8px 0;">Radio 2 (433.92 MHz)</h3>
|
||
<div id="radio2Status" class="muted">Status: Unknown</div>
|
||
<div id="radio2Error" class="muted" style="color: #bf4f59; font-size: 12px;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Controls</h2>
|
||
<div class="controls">
|
||
<div class="control-group">
|
||
<label for="jamPower">TX Power (Both Radios): <span id="powerValue">10</span> dBm</label>
|
||
<input type="range" id="jamPower" min="0" max="7" value="7" step="1">
|
||
<div class="muted" style="font-size: 12px;">Valid levels: -30, -20, -15, -10, 0, 5, 7, 10 dBm</div>
|
||
</div>
|
||
<div class="control-group">
|
||
<div style="display: flex; gap: 16px;">
|
||
<button id="toggleJam">Toggle Jamming</button>
|
||
<button id="updateSettings" class="danger">Update Power</button>
|
||
</div>
|
||
<div class="muted" style="font-size: 12px;">
|
||
Both radios transmit simultaneously at configured power
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>System Log</h2>
|
||
<pre id="log"></pre>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
const logEl = document.getElementById('log');
|
||
const statusEl = document.getElementById('statusDisplay');
|
||
const radioStatusEl = document.getElementById('radioStatus');
|
||
const radio1StatusEl = document.getElementById('radio1Status');
|
||
const radio1ErrorEl = document.getElementById('radio1Error');
|
||
const radio2StatusEl = document.getElementById('radio2Status');
|
||
const radio2ErrorEl = document.getElementById('radio2Error');
|
||
const powerSlider = document.getElementById('jamPower');
|
||
const powerValue = document.getElementById('powerValue');
|
||
const toggleBtn = document.getElementById('toggleJam');
|
||
const updateBtn = document.getElementById('updateSettings');
|
||
|
||
const powerTable = [-30, -20, -15, -10, 0, 5, 7, 10];
|
||
let jammingActive = true;
|
||
|
||
// Update slider value display — show actual dBm from table
|
||
powerSlider.addEventListener('input', () => {
|
||
const idx = parseInt(powerSlider.value);
|
||
powerValue.textContent = powerTable[idx];
|
||
});
|
||
|
||
// Toggle jamming
|
||
toggleBtn.addEventListener('click', async () => {
|
||
try {
|
||
const res = await fetch('/api/toggle', { method: 'POST' });
|
||
const data = await res.json();
|
||
jammingActive = data.enabled;
|
||
updateStatus();
|
||
} catch (e) {
|
||
console.error('Toggle failed:', e);
|
||
}
|
||
});
|
||
|
||
// Update power settings
|
||
updateBtn.addEventListener('click', async () => {
|
||
const idx = parseInt(powerSlider.value);
|
||
try {
|
||
const res = await fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ power_idx: idx })
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
powerValue.textContent = data.jam_power;
|
||
}
|
||
} catch (e) {
|
||
console.error('Update failed:', e);
|
||
}
|
||
});
|
||
|
||
// Update status display
|
||
function updateStatus() {
|
||
const statusClass = jammingActive ? 'status status-active' : 'status status-inactive';
|
||
const statusText = jammingActive ? 'ACTIVE' : 'INACTIVE';
|
||
statusEl.innerHTML = `<span class="${statusClass}">JAMMING: ${statusText}</span>`;
|
||
toggleBtn.textContent = jammingActive ? 'Stop Jamming' : 'Start Jamming';
|
||
}
|
||
|
||
// Update radio status display
|
||
function updateRadioStatus(telemetry) {
|
||
// Radio 1 status
|
||
let radio1StatusText = 'Unknown';
|
||
let radio1StatusClass = 'muted';
|
||
|
||
if (telemetry.radio1_status === 2) {
|
||
radio1StatusText = 'ACTIVE JAMMING';
|
||
radio1StatusClass = 'status status-active';
|
||
} else if (telemetry.radio1_status === 1) {
|
||
radio1StatusText = 'Initialized';
|
||
radio1StatusClass = 'muted';
|
||
} else if (telemetry.radio1_status === -1) {
|
||
radio1StatusText = 'ERROR';
|
||
radio1StatusClass = 'status status-inactive';
|
||
} else if (telemetry.radio1_status === 0) {
|
||
radio1StatusText = 'Standby';
|
||
radio1StatusClass = 'muted';
|
||
}
|
||
|
||
radio1StatusEl.innerHTML = `<span class="${radio1StatusClass}">${radio1StatusText}</span>`;
|
||
|
||
if (telemetry.radio1_error) {
|
||
radio1ErrorEl.textContent = `Error: ${telemetry.radio1_error}`;
|
||
radio1ErrorEl.style.display = 'block';
|
||
} else {
|
||
radio1ErrorEl.textContent = '';
|
||
radio1ErrorEl.style.display = 'none';
|
||
}
|
||
|
||
// Radio 2 status
|
||
let radio2StatusText = 'Unknown';
|
||
let radio2StatusClass = 'muted';
|
||
|
||
if (telemetry.radio2_status === 2) {
|
||
radio2StatusText = 'ACTIVE JAMMING';
|
||
radio2StatusClass = 'status status-active';
|
||
} else if (telemetry.radio2_status === 1) {
|
||
radio2StatusText = 'Initialized';
|
||
radio2StatusClass = 'muted';
|
||
} else if (telemetry.radio2_status === -1) {
|
||
radio2StatusText = 'ERROR';
|
||
radio2StatusClass = 'status status-inactive';
|
||
} else if (telemetry.radio2_status === 0) {
|
||
radio2StatusText = 'Standby';
|
||
radio2StatusClass = 'muted';
|
||
}
|
||
|
||
radio2StatusEl.innerHTML = `<span class="${radio2StatusClass}">${radio2StatusText}</span>`;
|
||
|
||
if (telemetry.radio2_error) {
|
||
radio2ErrorEl.textContent = `Error: ${telemetry.radio2_error}`;
|
||
radio2ErrorEl.style.display = 'block';
|
||
} else {
|
||
radio2ErrorEl.textContent = '';
|
||
radio2ErrorEl.style.display = 'none';
|
||
}
|
||
|
||
// Update radio status summary
|
||
const activeRadios = (telemetry.radio1_active ? 1 : 0) + (telemetry.radio2_active ? 1 : 0);
|
||
radioStatusEl.innerHTML = `<div class="muted" style="margin-top: 8px;">
|
||
Active Radios: ${activeRadios}/2 |
|
||
Radio 1: ${telemetry.radio1_freq} MHz |
|
||
Radio 2: ${telemetry.radio2_freq} MHz
|
||
</div>`;
|
||
}
|
||
|
||
// Update jamming banner (large prominent display)
|
||
function updateJammingBanner(telemetry) {
|
||
const banner = document.getElementById('jammingBanner');
|
||
const statusText = document.getElementById('jammingStatusText');
|
||
const details = document.getElementById('jammingDetails');
|
||
|
||
if (telemetry.jamming_enabled) {
|
||
// Jamming is active
|
||
banner.style.borderColor = '#4fbf59';
|
||
banner.style.background = '#123a16';
|
||
statusText.textContent = '⚠️ JAMMING ACTIVE ⚠️';
|
||
statusText.style.color = '#86f28a';
|
||
|
||
// Show which radios are active
|
||
const activeRadios = [];
|
||
if (telemetry.radio1_active) activeRadios.push('315 MHz');
|
||
if (telemetry.radio2_active) activeRadios.push('433.92 MHz');
|
||
|
||
if (activeRadios.length > 0) {
|
||
details.textContent = `Transmitting on: ${activeRadios.join(' + ')} | Power: ${telemetry.jam_power} dBm`;
|
||
details.style.color = '#86f28a';
|
||
} else {
|
||
details.textContent = 'No radios transmitting (check errors)';
|
||
details.style.color = '#bf4f59';
|
||
}
|
||
} else {
|
||
// Jamming is inactive
|
||
banner.style.borderColor = '#3a1216';
|
||
banner.style.background = '#030404';
|
||
statusText.textContent = 'JAMMING INACTIVE';
|
||
statusText.style.color = '#bf4f59';
|
||
details.textContent = 'System ready - click "Start Jamming" to begin';
|
||
details.style.color = '#4fbf59';
|
||
}
|
||
}
|
||
|
||
// Fetch telemetry and logs
|
||
async function updateDisplay() {
|
||
try {
|
||
const [teleRes, logRes] = await Promise.all([
|
||
fetch('/api/telemetry'),
|
||
fetch('/api/log')
|
||
]);
|
||
|
||
const telemetry = await teleRes.json();
|
||
const logs = await logRes.text();
|
||
|
||
// Update status
|
||
jammingActive = telemetry.jamming_enabled;
|
||
updateStatus();
|
||
|
||
// Update jamming banner (prominent display)
|
||
updateJammingBanner(telemetry);
|
||
|
||
// Update slider to match current index and show actual dBm
|
||
if (telemetry.jam_power_idx !== undefined) {
|
||
powerSlider.value = telemetry.jam_power_idx;
|
||
}
|
||
powerValue.textContent = telemetry.jam_power;
|
||
|
||
// Update telemetry display
|
||
statusEl.innerHTML += `<br><div class="muted">
|
||
Uptime: ${Math.floor(telemetry.uptime_ms / 1000)}s |
|
||
Heap: ${telemetry.free_heap} bytes |
|
||
Power: ${telemetry.jam_power} dBm
|
||
</div>`;
|
||
|
||
// Update radio status display
|
||
updateRadioStatus(telemetry);
|
||
|
||
// Update logs
|
||
logEl.textContent = logs;
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
|
||
} catch (e) {
|
||
statusEl.innerHTML = `<span class="status status-inactive">OFFLINE</span>`;
|
||
}
|
||
}
|
||
|
||
// Initial update
|
||
updateDisplay();
|
||
setInterval(updateDisplay, 1000);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
)HTML";
|
||
|
||
static void handleRoot() {
|
||
|
||
logLine("[HTTP] GET /");
|
||
server.sendHeader("Cache-Control", "no-store, max-age=0");
|
||
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() {
|
||
String json = "{";
|
||
json += "\"uptime_ms\":" + String(millis() - uptimeStart) + ",";
|
||
json += "\"free_heap\":" + String(ESP.getFreeHeap()) + ",";
|
||
json += "\"jamming_enabled\":" + String(jammingEnabled ? "true" : "false") + ",";
|
||
json += "\"jam_power\":" + String(jamPower) + ",";
|
||
json += "\"jam_power_idx\":" + String(jamPowerIdx) + ",";
|
||
json += "\"rssi1\":" + (isnan(currentRssi1) ? "null" : String(currentRssi1, 1)) + ",";
|
||
json += "\"rssi2\":" + (isnan(currentRssi2) ? "null" : String(currentRssi2, 1)) + ",";
|
||
|
||
// Radio 1 status
|
||
json += "\"radio1_status\":" + String(radio1Status) + ",";
|
||
json += "\"radio1_error\":\"" + jsonEscape(radio1Error) + "\",";
|
||
json += "\"radio1_freq\":315.0,";
|
||
json += "\"radio1_active\":" + String(radio1Status == 2 ? "true" : "false") + ",";
|
||
|
||
// Radio 2 status
|
||
json += "\"radio2_status\":" + String(radio2Status) + ",";
|
||
json += "\"radio2_error\":\"" + jsonEscape(radio2Error) + "\",";
|
||
json += "\"radio2_freq\":433.92,";
|
||
json += "\"radio2_active\":" + String(radio2Status == 2 ? "true" : "false");
|
||
|
||
json += "}";
|
||
server.send(200, "application/json; charset=utf-8", json);
|
||
}
|
||
|
||
static void handleToggle() {
|
||
if (jammingEnabled) {
|
||
stopJamming();
|
||
} else {
|
||
startJamming();
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
} 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 handleHealth() {
|
||
String json = "{";
|
||
json += "\"ok\":true,";
|
||
json += "\"uptime_ms\":" + String(millis() - uptimeStart) + ",";
|
||
json += "\"heap\":" + String(ESP.getFreeHeap()) + ",";
|
||
json += "\"ap_clients\":" + String(WiFi.softAPgetStationNum());
|
||
json += "}";
|
||
server.send(200, "application/json; charset=utf-8", json);
|
||
}
|
||
|
||
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);
|
||
|
||
// 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("jammer3", false);
|
||
jammingEnabled = preferences.getBool("jamEnabled", JAMMING_ENABLED);
|
||
jamPowerIdx = (uint8_t)preferences.getInt("jamPowerIdx", DEFAULT_JAM_POWER_IDX);
|
||
if (jamPowerIdx >= JAM_POWER_LEVELS) jamPowerIdx = DEFAULT_JAM_POWER_IDX;
|
||
jamPower = kPowerTable[jamPowerIdx];
|
||
logLine("[NVS] jamEnabled=" + String(jammingEnabled) + " jamPowerIdx=" + String(jamPowerIdx) + " (" + String(jamPower) + " dBm)");
|
||
|
||
logLine("[BOOT] CC1101 Key-Fob Jammer starting");
|
||
logLine("[BOOT] ESP32-S3 DevKitC-1");
|
||
Serial.flush();
|
||
|
||
// 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
|
||
|
||
// 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.onNotFound(handleNotFound);
|
||
server.begin();
|
||
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) {
|
||
startJamming();
|
||
} else {
|
||
radio1Status = -1;
|
||
radio2Status = -1;
|
||
radio1Error = "Disabled / not initialized";
|
||
radio2Error = "Disabled / not initialized";
|
||
logLine("[JAM] Jamming disabled on boot");
|
||
}
|
||
}
|
||
|
||
// Advance one radio to the next sweep frequency.
|
||
// Hops evenly across [center - span/2 .. center + span/2].
|
||
static void tickSweep(CC1101& radio, uint8_t& step, uint8_t steps,
|
||
float center, float span, uint32_t& lastMs, float& curFreq) {
|
||
const uint32_t now = millis();
|
||
if (now - lastMs < SWEEP_DWELL_MS) return;
|
||
lastMs = now;
|
||
|
||
// Calculate next frequency
|
||
float freq = center - (span / 2.0f) + (span / (steps - 1)) * step;
|
||
if (freq != curFreq) {
|
||
// Put radio back to standby, retune, resume direct TX
|
||
radio.standby();
|
||
if (radio.setFrequency(freq) == RADIOLIB_ERR_NONE) {
|
||
radio.transmitDirect();
|
||
curFreq = freq;
|
||
}
|
||
}
|
||
step = (step + 1) % steps;
|
||
}
|
||
|
||
void loop() {
|
||
server.handleClient();
|
||
yield();
|
||
|
||
static uint32_t lastHeartbeat = 0;
|
||
const uint32_t now = millis();
|
||
if (now - lastHeartbeat >= 5000) {
|
||
lastHeartbeat = now;
|
||
Serial.println("[HEARTBEAT] up=" + String(now - uptimeStart) + "ms ip=" + WiFi.softAPIP().toString() +
|
||
" clients=" + String(WiFi.softAPgetStationNum()) + " heap=" + String(ESP.getFreeHeap()));
|
||
Serial.flush();
|
||
}
|
||
|
||
// Frequency sweep — hop both radios across their bands while jamming
|
||
if (jammingEnabled) {
|
||
if (radio1Status == 2) {
|
||
tickSweep(radio1, sweepStep1, SWEEP_1_STEPS,
|
||
SWEEP_1_CENTER_MHZ, SWEEP_1_SPAN_MHZ, lastSweep1Ms, sweepFreq1);
|
||
}
|
||
if (radio2Status == 2) {
|
||
tickSweep(radio2, sweepStep2, SWEEP_2_STEPS,
|
||
SWEEP_2_CENTER_MHZ, SWEEP_2_SPAN_MHZ, lastSweep2Ms, sweepFreq2);
|
||
}
|
||
}
|
||
|
||
// 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 (315 MHz): " + String(jammingEnabled ? "TRANSMITTING" : "STANDBY"));
|
||
Serial.println("Radio 2 (433.92 MHz): " + String(jammingEnabled ? "TRANSMITTING" : "STANDBY"));
|
||
}
|
||
}
|
||
}
|