Files
car-key-killer/src/main.cpp
drjones 2143981110 Add jam strategies (direct/precision/flood), web UI modes and SmartRF register export/import
- JamMode: DIRECT, PRECISION, FLOOD with NVS persistence and flood tick path
- HTTP: POST /api/jam_mode, GET/POST /api/cc1101/registers
- Telemetry: jam_mode, jam_mode_str
- Web UI: mode buttons, toast on switch, CC1101 hex export/apply card
- config.h: precision/flood tuning, CC1101_CFG_REG_LAST
- platformio.ini: explicit USB serial upload port for macOS

Made-with: Cursor
2026-03-25 10:18:55 -07:00

2738 lines
107 KiB
C++

/**
* Dual CC1101 sub-GHz lab instrument (fixed carriers + optional capture/replay).
* ESP32-S3 DevKitC-1: two CC1101 on shared SPI.
* Radio 1: 315.0 MHz narrow FM + LFSR on GDO0; Radio 2: 433.92 MHz wide FM + LFSR.
* Intended for authorized RF research (e.g. sealed anechoic / shielded chamber).
* CC1101 reset and SPI sequencing follow TI SWRS061 (CHIP_RDYn, SRES Fig. 27).
*/
#include <Arduino.h>
#include <RadioLib.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <ArduinoOTA.h>
#include "driver/gpio.h"
#include <Preferences.h>
#include <math.h>
#include <string.h>
#include <Wire.h>
#include <U8g2lib.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include "config.h"
// Shared SPI; each Module uses its own CS.
// Must pass SPIClass explicitly so RadioLib uses our configured pins.
static SPIClass spi(FSPI);
static ArduinoHal hal(spi, SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
static Module mod1(&hal, CC1101_1_CS, CC1101_1_GDO0, RADIOLIB_NC, RADIOLIB_NC);
static Module mod2(&hal, CC1101_2_CS, CC1101_2_GDO0, RADIOLIB_NC, RADIOLIB_NC);
CC1101 radio1(&mod1);
CC1101 radio2(&mod2);
static WebServer server(WEB_PORT);
static Preferences preferences;
// CC1101 valid discrete power levels in dBm (RadioLib only accepts these exact values)
static const int8_t kPowerTable[JAM_POWER_LEVELS] = { -30, -20, -15, -10, 0, 5, 7, 10 };
// Jamming state — TX power fixed at CC1101 max (+10 dBm); no PA in hardware path.
static bool jammingEnabled = false;
static uint8_t jamPowerIdx = DEFAULT_JAM_POWER_IDX;
static int8_t jamPower = kPowerTable[DEFAULT_JAM_POWER_IDX];
// Policy: jam always starts after boot. NVS autoStartJam is forced true on each boot (no “boot jam off”).
static bool autoStartJam = true;
enum class JamMode : uint8_t { DIRECT = 0, PRECISION = 1, FLOOD = 2 };
static JamMode jamMode = JamMode::DIRECT;
static bool s_floodJamActive = false;
static uint32_t s_jamLfsrKeyHz = JAM_LFSR_KEY_HZ;
static const char* jamModeToCstr(JamMode m) {
switch (m) {
case JamMode::PRECISION: return "precision";
case JamMode::FLOOD: return "flood";
default: return "direct";
}
}
static JamMode jamModeFromU8(uint8_t u) {
if (u <= (uint8_t)JamMode::FLOOD) return (JamMode)u;
return JamMode::DIRECT;
}
// Individual radio status tracking
static int8_t radio1Status = -1; // 0=init, 1=standby/idle OK, 2=transmitting, -1=error
static int8_t radio2Status = -1;
static String radio1Error = "Disabled / not initialized";
static String radio2Error = "Disabled / not initialized";
// Telemetry
static uint32_t uptimeStart = 0;
static float currentRssi1 = NAN;
static float currentRssi2 = NAN;
// Locked jam carriers (UI / OLED)
static float jamFreq1 = JAM_LOCK_FREQ_1_MHZ;
static float jamFreq2 = JAM_LOCK_FREQ_2_MHZ;
// Gate LFSR ISR so GDO0 is only toggled for radios in direct async TX (avoids driving idle CC1101).
static volatile bool s_noiseEn1 = false;
static volatile bool s_noiseEn2 = false;
// Auto-reinit watchdog
static uint32_t lastReInitCheck = 0;
// 24-hour operation health tracking
static uint32_t hopCount1 = 0; // total frequency hops since boot
static uint32_t hopCount2 = 0;
static uint32_t minFreeHeap = 0xFFFFFFFF; // lowest heap ever observed
static uint32_t lastTempWarnMs = 0; // rate-limit temperature warnings
// Boot self-test snapshot (SRES + RadioLib begin + VERSION); unchanged until next reboot
static bool bootSelftestR1Pass = false;
static bool bootSelftestR2Pass = false;
static uint32_t bootSelftestDurationMs = 0;
static String bootSelftestR1Detail = "not run";
static String bootSelftestR2Detail = "not run";
// Last on-demand self-test (POST /api/selftest); does not overwrite boot snapshot
static bool lastSelftestRan = false;
static bool lastSelftestR1Pass = false;
static bool lastSelftestR2Pass = false;
static uint32_t lastSelftestDurationMs = 0;
static uint32_t lastSelftestAtMs = 0;
static String lastSelftestR1Detail = "never";
static String lastSelftestR2Detail = "never";
// ─── OLED (0.96" SSD1306 128x64) ─────────────────────────────────────────────
// SW_I2C: bit-bangs GPIO directly — no Wire library involved, always works
// if the pins are physically correct. SDA=GPIO17, SCL=GPIO18.
static U8G2_SSD1306_128X64_NONAME_F_SW_I2C
u8g2(U8G2_R0, OLED_SCL_PIN, OLED_SDA_PIN, U8X8_PIN_NONE);
static bool oledOk = false;
static uint8_t oledPage = 0; // 0=status, 1=freq/hops, 2=health
static uint32_t oledPageMs = 0;
static uint32_t oledTickMs = 0;
static uint8_t waveFrame = 0; // 0-3 animated arc count
static uint32_t waveMs = 0;
static uint32_t notifEnd = 0; // millis() when current notification expires
static char notifL1[22] = {};
static char notifL2[22] = {};
// ─── Rotary encoder ──────────────────────────────────────────────────────────
static volatile int8_t encDelta = 0; // +1 CW / -1 CCW per detent
static uint8_t encLastClk = HIGH;
void IRAM_ATTR encISR() {
const uint32_t in = REG_READ(GPIO_IN_REG);
const uint8_t clk = (in >> ENC_CLK_PIN) & 1u;
if (clk == encLastClk) return; // filter glitch
encLastClk = clk;
if (clk == LOW) { // falling edge = one detent
encDelta += ((in >> ENC_DT_PIN) & 1u) ? +1 : -1;
}
}
// Log ring buffer
static constexpr size_t LOG_LINES = 100;
static String logRing[LOG_LINES];
static size_t logHead = 0;
static size_t logCount = 0;
static void logLine(const String& s) {
uint32_t ms = millis();
uint32_t ss = ms / 1000;
uint32_t mm = ss / 60; ss %= 60;
uint32_t hh = mm / 60; mm %= 60;
char ts[12];
snprintf(ts, sizeof(ts), "[%02u:%02u:%02u] ", hh, mm, ss);
const String line = String(ts) + s;
logRing[logHead] = line;
logHead = (logHead + 1) % LOG_LINES;
if (logCount < LOG_LINES) logCount++;
Serial.println(line);
}
static String getLogsText() {
String out;
out.reserve(4096);
const size_t start = (logCount == LOG_LINES) ? logHead : 0;
for (size_t i = 0; i < logCount; i++) {
const size_t idx = (start + i) % LOG_LINES;
out += logRing[idx];
out += '\n';
}
return out;
}
static String jsonEscape(const String& in) {
String out;
out.reserve(in.length() + 8);
for (size_t i = 0; i < in.length(); ++i) {
const char c = in.charAt(i);
if (c == '\\') out += "\\\\";
else if (c == '\"') out += "\\\"";
else if (c == '\n') out += "\\n";
else if (c == '\r') out += "\\r";
else if (c == '\t') out += "\\t";
else out += c;
}
return out;
}
// ─── ESP-NOW peer mesh (auto-discover same firmware, no MAC entry) ───────────
// All units must use the same WiFi AP channel (softAP uses ch 1). Beacons go to
// broadcast; packets with magic "KLNK" mark another board running this build.
static constexpr uint8_t kEspNowMagic[4] = { 'K', 'L', 'N', 'K' };
struct EspNowPeerEntry {
uint8_t mac[6];
uint32_t lastSeenMs;
};
static EspNowPeerEntry s_espNowPeers[ESPNOW_MAX_PEERS];
static uint8_t s_espNowPeerCount = 0;
static uint32_t s_espNowBootToken = 0;
static bool s_espNowReady = false;
static uint32_t s_espNowLastTxMs = 0;
static uint8_t s_espNowSelfMac[6];
static uint8_t espNowActivePeerCount() {
const uint32_t now = millis();
uint8_t n = 0;
for (uint8_t i = 0; i < s_espNowPeerCount; i++) {
if (now - s_espNowPeers[i].lastSeenMs < ESPNOW_PEER_STALE_MS) n++;
}
return n;
}
static void espNowTouchPeer(const uint8_t mac[6]) {
if (memcmp(mac, s_espNowSelfMac, 6) == 0) return;
for (uint8_t i = 0; i < s_espNowPeerCount; i++) {
if (memcmp(s_espNowPeers[i].mac, mac, 6) == 0) {
s_espNowPeers[i].lastSeenMs = millis();
return;
}
}
if (s_espNowPeerCount < ESPNOW_MAX_PEERS) {
memcpy(s_espNowPeers[s_espNowPeerCount].mac, mac, 6);
s_espNowPeers[s_espNowPeerCount].lastSeenMs = millis();
s_espNowPeerCount++;
char m[24];
snprintf(m, sizeof(m), "%02X:%02X:%02X:%02X:%02X:%02X",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
logLine("[ESPNOW] node " + String(m));
return;
}
uint8_t bi = 0;
uint32_t oldest = s_espNowPeers[0].lastSeenMs;
for (uint8_t i = 1; i < ESPNOW_MAX_PEERS; i++) {
if (s_espNowPeers[i].lastSeenMs < oldest) {
oldest = s_espNowPeers[i].lastSeenMs;
bi = i;
}
}
memcpy(s_espNowPeers[bi].mac, mac, 6);
s_espNowPeers[bi].lastSeenMs = millis();
}
static void espNowOnRecv(const uint8_t* mac, const uint8_t* data, int len) {
if (len < 12 || mac == nullptr || data == nullptr) return;
if (memcmp(data, kEspNowMagic, 4) != 0) return;
espNowTouchPeer(mac);
}
static void espNowInit() {
s_espNowBootToken = esp_random();
if (s_espNowBootToken == 0) s_espNowBootToken = 0xC0FFEE01u;
if (esp_read_mac(s_espNowSelfMac, ESP_MAC_WIFI_SOFTAP) != ESP_OK) {
memset(s_espNowSelfMac, 0, 6);
}
if (esp_now_init() != ESP_OK) {
logLine("[ESPNOW] esp_now_init failed");
return;
}
esp_now_register_recv_cb(espNowOnRecv);
uint8_t bcast[6] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
esp_now_peer_info_t peer = {};
memcpy(peer.peer_addr, bcast, 6);
peer.channel = 0;
peer.encrypt = false;
peer.ifidx = WIFI_IF_AP;
esp_err_t e = esp_now_add_peer(&peer);
if (e != ESP_OK) {
logLine("[ESPNOW] add_peer(broadcast) failed: " + String((int)e));
esp_now_deinit();
return;
}
s_espNowReady = true;
logLine("[ESPNOW] mesh listening; beacons every " + String(ESPNOW_BEACON_MS) + " ms (same AP channel)");
}
static void espNowTick() {
if (!s_espNowReady) return;
const uint32_t now = millis();
if (now - s_espNowLastTxMs < ESPNOW_BEACON_MS) return;
s_espNowLastTxMs = now;
uint8_t pkt[12];
memcpy(pkt, kEspNowMagic, 4);
memcpy(pkt + 4, &s_espNowBootToken, 4);
uint32_t up = now - uptimeStart;
memcpy(pkt + 8, &up, 4);
uint8_t bcast[6] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
(void)esp_now_send(bcast, pkt, sizeof(pkt));
}
// Forward declarations
static void noiseGenStart();
static void startJamming();
static void stopJamming();
static void probeRadiosStandby();
static void runBootSelfTest();
static void runManualSelfTest();
static void oledNotify(const char* l1, const char* l2, uint32_t dur);
static void spiWriteReg(uint8_t csPin, uint8_t reg, uint8_t val);
static void jamFloodTick();
static void handleJamMode();
static void handleCc1101RegsGet();
static void handleCc1101RegsPost();
static void handleCc1101RegsRoute();
// ─── 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 volatile uint32_t capRecBits = 0; // replay ISR + main; keep consistent with capIdx
static float capFreq = 315.0f; // frequency at capture time
static uint8_t capRadioNum = 1; // 1 or 2
static bool capIsOOK = true; // modulation: true=OOK, false=2-FSK
static gpio_num_t capGdoPin = (gpio_num_t)CC1101_1_GDO0;
static hw_timer_t* capTimer = nullptr;
static volatile uint32_t capTransitions = 0; // edge count — used for bitrate estimation
static volatile uint32_t capLongRuns = 0; // counts stable runs (>15 samples) to filter out thermal noise
static uint32_t capCurrentRun = 0; // current stable run length
static bool capSigNotified = false; // fire OLED notification only once per session
static bool capPrevJamming = false; // jammingEnabled state saved before capture pauses it
struct CapHistoryEntry {
uint32_t uptime_ms;
float freq_mhz;
uint32_t bits;
uint8_t radio_num;
uint8_t is_ook;
};
static CapHistoryEntry capHistory[CAP_HISTORY_MAX];
static uint8_t capHistoryCount = 0;
static void capHistoryPersist() {
preferences.putUChar("capHistN", capHistoryCount);
if (capHistoryCount > 0) {
preferences.putBytes("capHist", capHistory, capHistoryCount * sizeof(CapHistoryEntry));
}
}
static void capHistoryLoad() {
capHistoryCount = preferences.getUChar("capHistN", 0);
if (capHistoryCount > CAP_HISTORY_MAX) capHistoryCount = CAP_HISTORY_MAX;
if (capHistoryCount == 0) return;
const size_t expect = capHistoryCount * sizeof(CapHistoryEntry);
const size_t rd = preferences.getBytes("capHist", capHistory, sizeof(capHistory));
if (rd < expect) {
capHistoryCount = (uint8_t)(rd / sizeof(CapHistoryEntry));
}
}
static void capHistoryAppend(uint32_t bits, float freqMhz, uint8_t radioNum, bool isOOK) {
if (bits < 100) return;
if (capHistoryCount < CAP_HISTORY_MAX) {
memmove(&capHistory[1], &capHistory[0], capHistoryCount * sizeof(CapHistoryEntry));
capHistoryCount++;
} else {
memmove(&capHistory[1], &capHistory[0], (CAP_HISTORY_MAX - 1) * sizeof(CapHistoryEntry));
}
capHistory[0].uptime_ms = millis();
capHistory[0].freq_mhz = freqMhz;
capHistory[0].bits = bits;
capHistory[0].radio_num = radioNum;
capHistory[0].is_ook = isOOK ? 1u : 0u;
capHistoryPersist();
}
// ─── Capture/replay ISRs ──────────────────────────────────────────────────────
static void IRAM_ATTR capRecordISR() {
const uint32_t i = capIdx;
if (i >= (uint32_t)(CAP_BUF_BYTES * 8)) { capBufFull = true; return; }
const uint8_t bit = (uint8_t)((REG_READ(GPIO_IN_REG) >> capGdoPin) & 1u);
// Count transitions and long stable runs (software squelch)
if (i > 0) {
const uint8_t prev = (capBuf[(i-1) >> 3] >> ((i-1) & 7)) & 1u;
if (bit == prev) {
capCurrentRun++;
} else {
capTransitions++;
if (capCurrentRun > 15) capLongRuns++;
capCurrentRun = 0;
}
}
if (bit) capBuf[i >> 3] |= (1u << (i & 7));
else capBuf[i >> 3] &= ~(1u << (i & 7));
capIdx = i + 1;
}
static void IRAM_ATTR capReplayISR() {
uint32_t i = capIdx;
if (i >= capRecBits) { i = 0; } // loop seamlessly
const uint8_t bit = (capBuf[i >> 3] >> (i & 7)) & 1u;
gpio_set_level(capGdoPin, bit);
capIdx = i + 1;
}
// ─── Capture/replay management ───────────────────────────────────────────────
static void capTimerStop() {
if (capTimer) {
timerAlarmDisable(capTimer);
timerDetachInterrupt(capTimer);
timerEnd(capTimer);
capTimer = nullptr;
}
}
static void startCapture(float freq, uint8_t radioNum, bool isOOK) {
capPrevJamming = jammingEnabled; // save before stopJamming() clears it
stopJamming();
capFreq = freq;
capRadioNum = radioNum;
capIsOOK = isOOK;
capGdoPin = (radioNum == 1) ? (gpio_num_t)CC1101_1_GDO0 : (gpio_num_t)CC1101_2_GDO0;
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();
noInterrupts();
capIdx = 0;
capBufFull = false;
capRecBits = 0;
capMode = CapMode::RECORDING;
interrupts();
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;
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();
noInterrupts();
capIdx = 0;
capMode = CapMode::REPLAYING;
interrupts();
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();
noInterrupts();
const CapMode was = capMode;
if (was == CapMode::RECORDING) {
capRecBits = capIdx;
capMode = (capRecBits > 0) ? CapMode::RECORDED : CapMode::IDLE;
} else if (was == CapMode::REPLAYING) {
capMode = CapMode::RECORDED;
}
interrupts();
if (was == CapMode::RECORDING) {
logLine("[CAP] Stopped: " + String(capRecBits) + " bits saved");
oledNotify("CAPTURED", (String(capRecBits / 1000) + "k bits").c_str(), 2500);
if (capRecBits >= 100) {
capHistoryAppend(capRecBits, capFreq, capRadioNum, capIsOOK);
}
} else if (was == CapMode::REPLAYING) {
logLine("[CAP] Replay stopped");
oledNotify("REPLAY", "STOPPED", 2500);
}
// Restore pin direction then restart jamming if it was active before capture
gpio_set_direction(capGdoPin, GPIO_MODE_OUTPUT);
gpio_set_level(capGdoPin, 0);
if (capPrevJamming) {
capPrevJamming = false;
jammingEnabled = true;
startJamming();
}
}
// Simple signal analysis — counts transitions to estimate original bitrate
// and measures duty cycle (fraction of 1s = carrier-on time).
static String capAnalyze() {
if (capRecBits < 100) return "{\"err\":\"no data\"}";
uint32_t ones = 0, transitions = 0;
uint8_t prev = (capBuf[0] >> 0) & 1u;
if (prev) ones++; // count bit 0
for (uint32_t i = 1; i < capRecBits; i++) {
const uint8_t b = (capBuf[i >> 3] >> (i & 7)) & 1u;
if (b) ones++;
if (b != prev) { transitions++; prev = b; }
}
// Approximate original bitrate: each symbol averages capRecBits/transitions samples
const uint32_t avgRunLen = (transitions > 0) ? (capRecBits / transitions) : capRecBits;
const uint32_t estBps = (avgRunLen > 0) ? (CAP_SAMPLE_HZ / avgRunLen) : 0;
const uint32_t dutyPct = (uint32_t)(ones * 100UL / capRecBits);
const uint32_t durMs = capRecBits * 1000 / CAP_SAMPLE_HZ;
char buf[200];
snprintf(buf, sizeof(buf),
"{\"bits\":%lu,\"dur_ms\":%lu,\"transitions\":%lu,"
"\"est_bps\":%lu,\"duty_pct\":%lu,\"freq\":%.3f}",
(unsigned long)capRecBits, (unsigned long)durMs,
(unsigned long)transitions, (unsigned long)estBps,
(unsigned long)dutyPct, (double)capFreq);
return String(buf);
}
// ─── Raw SPI (PATABLE burst for OOK replay) ──────────────────────────────────
static void spiWriteReg(uint8_t csPin, uint8_t reg, uint8_t val) {
spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
digitalWrite(csPin, LOW);
spi.transfer(reg);
spi.transfer(val);
digitalWrite(csPin, HIGH);
spi.endTransaction();
}
// TI CC1101 SWRS061 §10.1 / §19.1.2 Figure 27 — CHIP_RDYn on SO after CSn low; SRES with SCLK=1, SI=0 before sequence.
static bool cc1101WaitMisoLow(uint32_t timeoutUs) {
const uint32_t t0 = micros();
while (digitalRead(SPI_MISO_PIN) == HIGH) {
if ((uint32_t)(micros() - t0) > timeoutUs) return false;
}
return true;
}
static bool cc1101ManualReset(uint8_t csPin) {
digitalWrite(CC1101_1_CS, HIGH);
digitalWrite(CC1101_2_CS, HIGH);
pinMode(SPI_SCK_PIN, OUTPUT);
pinMode(SPI_MOSI_PIN, OUTPUT);
digitalWrite(SPI_SCK_PIN, HIGH);
digitalWrite(SPI_MOSI_PIN, LOW);
pinMode(csPin, OUTPUT);
digitalWrite(csPin, HIGH);
delayMicroseconds(20);
digitalWrite(csPin, LOW);
delayMicroseconds(200);
digitalWrite(csPin, HIGH);
delayMicroseconds(50);
digitalWrite(csPin, LOW);
if (!cc1101WaitMisoLow(10000)) {
digitalWrite(csPin, HIGH);
return false;
}
spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
spi.transfer(0x30);
{
const uint32_t t0 = micros();
while (digitalRead(SPI_MISO_PIN) == HIGH) {
if ((uint32_t)(micros() - t0) > 50000) {
digitalWrite(csPin, HIGH);
spi.endTransaction();
return false;
}
}
}
digitalWrite(csPin, HIGH);
spi.endTransaction();
// XOSC / digital core settle (SWRS061 §19.1); margin beyond 150 µs tsp,pd for lab repeatability
delayMicroseconds(800);
return true;
}
static uint8_t cc1101ReadRegister(uint8_t csPin, uint8_t regAddr6) {
digitalWrite(CC1101_1_CS, HIGH);
digitalWrite(CC1101_2_CS, HIGH);
spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
digitalWrite(csPin, LOW);
delayMicroseconds(10);
spi.transfer((uint8_t)(0x80u | (regAddr6 & 0x3Fu)));
uint8_t v = spi.transfer(0x00);
digitalWrite(csPin, HIGH);
spi.endTransaction();
return v;
}
// VERSION (0x31): only 0xFF indicates dead SPI / wrong CS; other values are valid silicon revs (SWRS061).
static bool cc1101VerifyVersion(uint8_t csPin, String* errOut) {
const uint8_t ver = cc1101ReadRegister(csPin, 0x31);
if (ver == 0xFF) {
if (errOut) *errOut = "VERSION 0xFF (MISO open or CS conflict)";
return false;
}
logLine("[CC1101] CS" + String((int)csPin) + " VERSION=0x" + String(ver, HEX));
return true;
}
static void probeOneRadio(CC1101& radio, uint8_t cs, float nomMhz, float lockMhz, float devKhz,
int8_t& stOut, String& errOut) {
stOut = -1;
if (!cc1101ManualReset(cs)) {
errOut = "SRES/CHIP_RDYn failed (see SWRS061 Fig.27)";
return;
}
delay(5);
int st = RADIOLIB_ERR_CHIP_NOT_FOUND;
for (int attempt = 0; attempt < 3 && st != RADIOLIB_ERR_NONE; attempt++) {
if (attempt > 0) delay(30);
st = radio.begin(nomMhz, JAM_BITRATE_KBPS, JAM_FREQ_DEV_KHZ, JAM_RX_BW_KHZ, jamPower, 16);
}
if (st != RADIOLIB_ERR_NONE) {
errOut = "Init failed: " + String(st);
return;
}
String verr;
if (!cc1101VerifyVersion(cs, &verr)) {
(void)radio.standby();
errOut = verr;
return;
}
radio.setFrequency(lockMhz);
radio.setFrequencyDeviation(devKhz);
(void)radio.standby();
stOut = 1;
errOut = "";
}
static void probeRadiosStandby() {
radio1Error = "";
radio2Error = "";
jamFreq1 = JAM_LOCK_FREQ_1_MHZ;
jamFreq2 = JAM_LOCK_FREQ_2_MHZ;
int8_t s1 = -1, s2 = -1;
probeOneRadio(radio1, CC1101_1_CS, CC1101_1_FREQ_MHZ, JAM_LOCK_FREQ_1_MHZ, JAM_DEV_KHZ_R1_NARROW, s1, radio1Error);
radio1Status = s1;
probeOneRadio(radio2, CC1101_2_CS, CC1101_2_FREQ_MHZ, JAM_LOCK_FREQ_2_MHZ, JAM_DEV_KHZ_R2_WIDE, s2, radio2Error);
radio2Status = s2;
if (radio1Status == 1) logLine("[R1] probe OK (standby)");
else logLine("[R1] probe FAIL: " + radio1Error);
if (radio2Status == 1) logLine("[R2] probe OK (standby)");
else logLine("[R2] probe FAIL: " + radio2Error);
}
static String selftestTruncateDetail(const String& s) {
const size_t maxLen = 72;
if ((size_t)s.length() <= maxLen) return s;
return s.substring(0, (unsigned)maxLen) + "...";
}
// Runs on every power-up after SPI is ready: same path as chamber probe (TI SWRS061 + VERSION).
static void runBootSelfTest() {
const uint32_t t0 = millis();
logLine("[SELFTEST] Boot radio check (every power-up)...");
probeRadiosStandby();
bootSelftestR1Pass = (radio1Status == 1);
bootSelftestR2Pass = (radio2Status == 1);
bootSelftestR1Detail = selftestTruncateDetail(bootSelftestR1Pass ? String("PASS") : radio1Error);
bootSelftestR2Detail = selftestTruncateDetail(bootSelftestR2Pass ? String("PASS") : radio2Error);
bootSelftestDurationMs = millis() - t0;
logLine("[SELFTEST] R1 " + bootSelftestR1Detail + " | R2 " + bootSelftestR2Detail +
" | " + String(bootSelftestDurationMs) + " ms");
if (bootSelftestR1Pass && bootSelftestR2Pass) {
logLine("[SELFTEST] Result: ALL PASS");
} else {
logLine("[SELFTEST] Result: FAIL — fix wiring/power before trusting TX");
}
}
// On-demand: no reboot; ends in standby or restores jam if it was on. Rejects while capture active (HTTP).
static void runManualSelfTest() {
const bool wasJam = jammingEnabled;
if (wasJam) stopJamming();
const uint32_t t0 = millis();
logLine("[SELFTEST] Manual radio check...");
probeRadiosStandby();
lastSelftestRan = true;
lastSelftestR1Pass = (radio1Status == 1);
lastSelftestR2Pass = (radio2Status == 1);
lastSelftestDurationMs = millis() - t0;
lastSelftestAtMs = millis() - uptimeStart;
lastSelftestR1Detail = selftestTruncateDetail(lastSelftestR1Pass ? String("PASS") : radio1Error);
lastSelftestR2Detail = selftestTruncateDetail(lastSelftestR2Pass ? String("PASS") : radio2Error);
logLine("[SELFTEST] Manual R1 " + lastSelftestR1Detail + " | R2 " + lastSelftestR2Detail +
" | " + String(lastSelftestDurationMs) + " ms");
if (wasJam) {
jammingEnabled = true;
startJamming();
}
}
// Start simultaneous jamming on both radios (SRES before each begin; VERSION check; noise after TX entry).
static void startJamming() {
logLine("[JAM] Starting simultaneous jamming system");
jamPowerIdx = DEFAULT_JAM_POWER_IDX;
jamPower = kPowerTable[jamPowerIdx];
preferences.putInt("jamPowerIdx", (int)jamPowerIdx);
logLine("[JAM] TX power fixed: " + String(jamPower) + " dBm (max)");
s_noiseEn1 = false;
s_noiseEn2 = false;
if (s_noiseTimer) {
timerAlarmDisable(s_noiseTimer);
timerDetachInterrupt(s_noiseTimer);
timerEnd(s_noiseTimer);
s_noiseTimer = nullptr;
}
gpio_set_level((gpio_num_t)CC1101_1_GDO0, 0);
gpio_set_level((gpio_num_t)CC1101_2_GDO0, 0);
radio1Status = 0;
radio2Status = 0;
radio1Error = "";
radio2Error = "";
int st1 = RADIOLIB_ERR_CHIP_NOT_FOUND;
for (int attempt = 0; attempt < 3 && st1 != RADIOLIB_ERR_NONE; attempt++) {
if (attempt > 0) delay(50);
if (!cc1101ManualReset(CC1101_1_CS)) {
st1 = RADIOLIB_ERR_CHIP_NOT_FOUND;
radio1Error = "SRES/CHIP_RDYn failed";
continue;
}
delay(5);
st1 = radio1.begin(CC1101_1_FREQ_MHZ, JAM_BITRATE_KBPS, JAM_FREQ_DEV_KHZ, JAM_RX_BW_KHZ, jamPower, 16);
}
if (st1 != RADIOLIB_ERR_NONE) {
radio1Status = -1;
if (radio1Error.length() == 0) radio1Error = "Init failed: " + String(st1);
logLine("[R1] init failed: " + radio1Error);
} else {
String verr;
if (!cc1101VerifyVersion(CC1101_1_CS, &verr)) {
(void)radio1.standby();
radio1Status = -1;
radio1Error = verr;
logLine("[R1] VERSION check failed: " + verr);
} else {
radio1Status = 1;
radio1.setFrequency(JAM_LOCK_FREQ_1_MHZ);
}
}
int st2 = RADIOLIB_ERR_CHIP_NOT_FOUND;
for (int attempt = 0; attempt < 3 && st2 != RADIOLIB_ERR_NONE; attempt++) {
if (attempt > 0) delay(50);
if (!cc1101ManualReset(CC1101_2_CS)) {
st2 = RADIOLIB_ERR_CHIP_NOT_FOUND;
radio2Error = "SRES/CHIP_RDYn failed";
continue;
}
delay(5);
st2 = radio2.begin(CC1101_2_FREQ_MHZ, JAM_BITRATE_KBPS, JAM_FREQ_DEV_KHZ, JAM_RX_BW_KHZ, jamPower, 16);
}
if (st2 != RADIOLIB_ERR_NONE) {
radio2Status = -1;
if (radio2Error.length() == 0) radio2Error = "Init failed: " + String(st2);
logLine("[R2] init failed: " + radio2Error);
} else {
String verr;
if (!cc1101VerifyVersion(CC1101_2_CS, &verr)) {
(void)radio2.standby();
radio2Status = -1;
radio2Error = verr;
logLine("[R2] VERSION check failed: " + verr);
} else {
radio2Status = 1;
radio2.setFrequency(JAM_LOCK_FREQ_2_MHZ);
}
}
jamFreq1 = JAM_LOCK_FREQ_1_MHZ;
jamFreq2 = JAM_LOCK_FREQ_2_MHZ;
// Per-mode modulation (freq already set)
if (radio1Status == 1) {
switch (jamMode) {
case JamMode::PRECISION:
(void)radio1.setOOK(false);
radio1.setFrequencyDeviation(JAM_PRECISION_DEV_R1_KHZ);
break;
case JamMode::FLOOD:
(void)radio1.setOOK(false);
radio1.setFrequencyDeviation(JAM_FLOOD_DEV_R1_KHZ);
break;
case JamMode::DIRECT:
default:
#if JAM_R1_USE_OOK
if (radio1.setOOK(true) != RADIOLIB_ERR_NONE) {
logLine("[R1] setOOK failed — falling back to wide 2-FSK jam");
(void)radio1.setOOK(false);
radio1.setFrequencyDeviation(JAM_DEV_KHZ_R1_WIDE);
} else {
logLine("[JAM] R1 ASK/OOK + LFSR (direct mode)");
}
#else
(void)radio1.setOOK(false);
radio1.setFrequencyDeviation(JAM_DEV_KHZ_R1_WIDE);
#endif
break;
}
}
if (radio2Status == 1) {
switch (jamMode) {
case JamMode::PRECISION:
radio2.setFrequencyDeviation(JAM_PRECISION_DEV_R2_KHZ);
break;
case JamMode::FLOOD:
radio2.setFrequencyDeviation(JAM_FLOOD_DEV_R2_KHZ);
break;
case JamMode::DIRECT:
default:
radio2.setFrequencyDeviation(JAM_DEV_KHZ_R2_WIDE);
break;
}
}
s_floodJamActive = false;
int stTx1 = RADIOLIB_ERR_NONE;
int stTx2 = RADIOLIB_ERR_NONE;
if (jamMode == JamMode::FLOOD) {
logLine(String("[JAM] Mode: flood (packet PRNG, ") + String(JAM_FLOOD_PKT_BYTES) + " B) @ " +
String(JAM_LOCK_FREQ_1_MHZ, 2) + " / " + String(JAM_LOCK_FREQ_2_MHZ, 2) + " MHz");
if (radio1Status == 1) radio1Status = 2;
if (radio2Status == 1) radio2Status = 2;
s_floodJamActive = (radio1Status == 2 || radio2Status == 2);
s_noiseEn1 = false;
s_noiseEn2 = false;
} else {
s_jamLfsrKeyHz = (jamMode == JamMode::PRECISION) ? (uint32_t)JAM_PRECISION_LFSR_HZ : (uint32_t)JAM_LFSR_KEY_HZ;
logLine(String("[JAM] Mode: ") + jamModeToCstr(jamMode) + " | LFSR " + String(s_jamLfsrKeyHz) + " Hz on GDO0");
if (radio1Status == 1) {
stTx1 = radio1.transmitDirectAsync();
if (stTx1 != RADIOLIB_ERR_NONE) {
radio1Status = -1;
radio1Error = "Transmit failed: " + String(stTx1);
logLine("[R1] transmitDirectAsync failed: " + String(stTx1));
} else {
radio1Status = 2;
}
}
if (radio2Status == 1) {
stTx2 = radio2.transmitDirectAsync();
if (stTx2 != RADIOLIB_ERR_NONE) {
radio2Status = -1;
radio2Error = "Transmit failed: " + String(stTx2);
logLine("[R2] transmitDirectAsync failed: " + String(stTx2));
} else {
radio2Status = 2;
}
}
s_noiseEn1 = (radio1Status == 2);
s_noiseEn2 = (radio2Status == 2);
if (s_noiseEn1 || s_noiseEn2) {
noiseGenStart();
}
}
if (radio1Status == 2 || radio2Status == 2) {
logLine("[JAM] Fixed carriers:");
logLine("[JAM] R1: " + String(JAM_LOCK_FREQ_1_MHZ, 2) + " MHz @ " + String(jamPower) + " dBm (" + String(radio1Status == 2 ? "TX" : "off") + ")");
logLine("[JAM] R2: " + String(JAM_LOCK_FREQ_2_MHZ, 2) + " MHz @ " + String(jamPower) + " dBm (" + String(radio2Status == 2 ? "TX" : "off") + ")");
} else {
logLine("[JAM] Both radios failed — check SPI, power, antenna");
logLine("[JAM] R1: " + radio1Error);
logLine("[JAM] R2: " + radio2Error);
}
}
// Stop jamming — idempotent, safe to call at any time including from capture code.
// Always brings GDO0 pins and noise timer to a known-safe state regardless of
// whether jammingEnabled was true. Only logs if something was actually active.
static void stopJamming() {
const bool wasActive = jammingEnabled;
s_floodJamActive = false;
s_noiseEn1 = false;
s_noiseEn2 = false;
// Always stop noise timer first — prevents ISR touching GDO0 during standby
if (s_noiseTimer) {
timerAlarmDisable(s_noiseTimer);
timerDetachInterrupt(s_noiseTimer);
timerEnd(s_noiseTimer);
s_noiseTimer = nullptr;
}
gpio_set_level((gpio_num_t)CC1101_1_GDO0, 0);
gpio_set_level((gpio_num_t)CC1101_2_GDO0, 0);
if (radio1Status == 2) {
int st1 = radio1.standby();
if (st1 != RADIOLIB_ERR_NONE) {
radio1Error = "Standby failed: " + String(st1);
logLine("[R1] standby failed: " + String(st1));
} else {
radio1Status = 1;
radio1Error = "";
}
}
if (radio2Status == 2) {
int st2 = radio2.standby();
if (st2 != RADIOLIB_ERR_NONE) {
radio2Error = "Standby failed: " + String(st2);
logLine("[R2] standby failed: " + String(st2));
} else {
radio2Status = 1;
radio2Error = "";
}
}
jammingEnabled = false;
if (wasActive) logLine("[JAM] Jamming stopped");
}
// Packet-flood jam: PRNG frames on both radios (shared SPI — sequential).
static void jamFloodTick() {
if (!s_floodJamActive || !jammingEnabled) return;
static uint8_t pkt[JAM_FLOOD_PKT_BYTES];
static uint32_t lastMs;
const uint32_t now = millis();
if ((uint32_t)(now - lastMs) < 2u) return;
lastMs = now;
if (radio1Status == 2) {
esp_fill_random(pkt, sizeof(pkt));
(void)radio1.transmit(pkt, sizeof(pkt));
}
if (radio2Status == 2) {
esp_fill_random(pkt, sizeof(pkt));
(void)radio2.transmit(pkt, sizeof(pkt));
}
}
static int hexNibble(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return 10 + (c - 'a');
if (c >= 'A' && c <= 'F') return 10 + (c - 'A');
return -1;
}
static void handleJamMode() {
if (!server.hasArg("plain")) {
server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"need_json\"}");
return;
}
const String body = server.arg("plain");
JamMode newM = JamMode::DIRECT;
if (body.indexOf("\"mode\":\"flood\"") >= 0) {
newM = JamMode::FLOOD;
} else if (body.indexOf("\"mode\":\"precision\"") >= 0) {
newM = JamMode::PRECISION;
} else if (body.indexOf("\"mode\":\"direct\"") >= 0) {
newM = JamMode::DIRECT;
} else {
server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"bad_mode\"}");
return;
}
jamMode = newM;
preferences.putUChar("jamMode", (uint8_t)jamMode);
logLine(String("[JAM] UI mode -> ") + jamModeToCstr(jamMode));
const bool wasJam = jammingEnabled;
if (wasJam) {
stopJamming();
jammingEnabled = true;
startJamming();
}
String j = String("{\"ok\":true,\"mode\":\"") + jamModeToCstr(jamMode) + "\"}";
server.send(200, "application/json; charset=utf-8", j);
}
static void handleCc1101RegsGet() {
if (!server.hasArg("radio")) {
server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"radio\"}");
return;
}
const int r = server.arg("radio").toInt();
if (r != 1 && r != 2) {
server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"radio\"}");
return;
}
const uint8_t cs = (r == 1) ? CC1101_1_CS : CC1101_2_CS;
String hex;
hex.reserve((CC1101_CFG_REG_LAST + 1u) * 2u + 4u);
for (uint8_t a = 0; a <= CC1101_CFG_REG_LAST; a++) {
const uint8_t v = cc1101ReadRegister(cs, a);
char pair[4];
snprintf(pair, sizeof(pair), "%02X", (unsigned)v);
hex += pair;
}
server.send(200, "application/json; charset=utf-8",
String("{\"ok\":true,\"radio\":") + r + ",\"hex\":\"" + hex + "\"}");
}
static void handleCc1101RegsPost() {
int r = 0;
String hx;
if (server.hasArg("radio") && server.hasArg("hex")) {
r = server.arg("radio").toInt();
hx = server.arg("hex");
} else if (server.hasArg("plain")) {
const String p = server.arg("plain");
if (p.indexOf("\"radio\":1") >= 0 || p.indexOf("\"radio\": 1") >= 0) r = 1;
else if (p.indexOf("\"radio\":2") >= 0 || p.indexOf("\"radio\": 2") >= 0) r = 2;
const int iq = p.indexOf("\"hex\"");
if (iq < 0) {
server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"missing_args\"}");
return;
}
int q1 = p.indexOf('"', iq + 5);
q1 = p.indexOf('"', q1 + 1);
const int q2 = p.indexOf('"', q1 + 1);
if (q1 < 0 || q2 < 0) {
server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"hex_parse\"}");
return;
}
hx = p.substring(q1 + 1, q2);
} else {
server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"missing_args\"}");
return;
}
if (r != 1 && r != 2) {
server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"radio\"}");
return;
}
hx.trim();
hx.replace(" ", "");
const size_t need = (size_t)(CC1101_CFG_REG_LAST + 1) * 2u;
if (hx.length() < need) {
server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"hex_len\"}");
return;
}
uint8_t buf[64];
for (size_t i = 0; i <= (size_t)CC1101_CFG_REG_LAST; i++) {
const int hi = hexNibble(hx[(unsigned)(i * 2)]);
const int lo = hexNibble(hx[(unsigned)(i * 2 + 1)]);
if (hi < 0 || lo < 0) {
server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"hex_parse\"}");
return;
}
buf[i] = (uint8_t)((unsigned)hi << 4 | (unsigned)lo);
}
const bool wasJam = jammingEnabled;
if (wasJam) stopJamming();
const uint8_t cs = (r == 1) ? CC1101_1_CS : CC1101_2_CS;
for (uint8_t a = 0; a <= CC1101_CFG_REG_LAST; a++) {
spiWriteReg(cs, a, buf[a]);
}
logLine(String("[REG] SmartRF-style cfg import R") + String(r) + " (" + String(CC1101_CFG_REG_LAST + 1) + " bytes)");
if (wasJam) {
jammingEnabled = true;
startJamming();
}
server.send(200, "application/json; charset=utf-8", "{\"ok\":true}");
}
static void handleCc1101RegsRoute() {
if (server.method() == HTTP_GET) {
handleCc1101RegsGet();
} else if (server.method() == HTTP_POST) {
handleCc1101RegsPost();
} else {
server.send(405, "text/plain", "Method Not Allowed");
}
}
// ─── 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 r1Tx = (radio1Status == 2);
const bool r2Tx = (radio2Status == 2);
const bool r1Ok = (radio1Status != -1);
const bool r2Ok = (radio2Status != -1);
CapMode cm;
noInterrupts();
cm = capMode;
interrupts();
// Yellow zone header
u8g2.setFont(u8g2_font_6x10_tf);
if (cm == CapMode::RECORDING) {
// Flashing border effect — blink every ~500 ms using bit 9 of millis()
if (millis() & 512) {
u8g2.drawBox(0, 0, 128, 13);
u8g2.setDrawColor(0);
}
u8g2.drawStr(2, 10, ">> RECORDING <<");
u8g2.setDrawColor(1);
} else if (cm == CapMode::REPLAYING) {
u8g2.drawBox(0, 0, 128, 13);
u8g2.setDrawColor(0);
u8g2.drawStr(2, 10, ">> REPLAYING <<");
u8g2.setDrawColor(1);
} else if (jam) {
u8g2.drawBox(0, 0, 110, 13);
u8g2.setDrawColor(0);
u8g2.drawStr(2, 10, ">> LOCKED JAM <<");
u8g2.setDrawColor(1);
} else {
u8g2.drawStr(2, 10, "-- STANDBY --");
}
oledPageDots(0);
// Blue zone — 5x7 font
u8g2.setFont(u8g2_font_5x7_tf);
const uint8_t nW = waveFrame;
if (cm == CapMode::RECORDING || cm == CapMode::REPLAYING) {
// Show capture/replay status instead of sweep info
char buf[24];
snprintf(buf, sizeof(buf), "%.3f MHz R%u",
(double)capFreq, (unsigned)capRadioNum);
u8g2.drawStr(0, 24, buf);
// Progress bar for recording
if (cm == CapMode::RECORDING) {
uint32_t currentIdx, currentTransitions;
noInterrupts();
currentIdx = capIdx;
currentTransitions = capTransitions;
interrupts();
const uint32_t pct = currentIdx * 100 / (CAP_BUF_BYTES * 8);
u8g2.drawFrame(0, 26, 128, 5);
u8g2.drawBox(0, 26, (uint8_t)(pct * 128 / 100), 5);
snprintf(buf, sizeof(buf), "%lus / %us %lu tr",
(unsigned long)(currentIdx / CAP_SAMPLE_HZ),
(unsigned)CAP_DURATION_S,
(unsigned long)currentTransitions);
} else {
// Replaying — show loop position (atomic idx + recBits vs ISR)
uint32_t cidx, crec;
noInterrupts();
cidx = capIdx;
crec = capRecBits;
interrupts();
const uint32_t pct = crec ? cidx * 100 / crec : 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: OK = probed CC1101; TX = actively jamming (status 2).
// Do not label idle/standby as OFFLINE — that was only for status -1 (hardware fault).
char buf[24];
if (r1Ok) {
if (r1Tx) {
snprintf(buf, sizeof(buf), "1: %.3f MHz", (double)jamFreq1);
u8g2.drawStr(0, 24, buf);
oledDrawWaves(101, 19, nW);
} else {
snprintf(buf, sizeof(buf), "1: %.3f idle", (double)jamFreq1);
u8g2.drawStr(0, 24, buf);
}
} else {
u8g2.drawStr(0, 24, "1: [OFFLINE]");
}
if (r2Ok) {
if (r2Tx) {
snprintf(buf, sizeof(buf), "2: %.3f MHz", (double)jamFreq2);
u8g2.drawStr(0, 33, buf);
oledDrawWaves(101, 28, nW);
} else {
snprintf(buf, sizeof(buf), "2: %.3f idle", (double)jamFreq2);
u8g2.drawStr(0, 33, buf);
}
} else {
u8g2.drawStr(0, 33, "2: [OFFLINE]");
}
// Row 3: power
{
char buf[28];
snprintf(buf, sizeof(buf), "TX %d dBm max", (int)jamPower);
u8g2.drawStr(0, 44, buf);
}
// Row 4: temp + heap OR FULL TX badge
if (jam && r1Tx && r2Tx) {
u8g2.drawStr(0, 55, "[ 315 + 433.92 LOCK ]");
} else {
char buf[28];
snprintf(buf, sizeof(buf), "%.1fC %lukB",
(double)temperatureRead(), (unsigned long)(ESP.getFreeHeap() / 1024));
u8g2.drawStr(0, 55, buf);
}
}
// Row 5: uptime small
{
const uint32_t up = millis() - uptimeStart;
char buf[20];
snprintf(buf, sizeof(buf), "up %uh%um%us",
(unsigned)(up/3600000), (unsigned)((up/60000)%60), (unsigned)((up/1000)%60));
u8g2.drawStr(0, 63, buf);
}
}
// Page 1 — Frequency + Hops
static void oledDrawFreq() {
// Yellow zone header
u8g2.setFont(u8g2_font_6x10_tf);
u8g2.drawStr(2, 10, "FREQ & HOPS");
oledPageDots(1);
u8g2.setFont(u8g2_font_5x7_tf);
char buf[24];
snprintf(buf, sizeof(buf), "R1 %.4f MHz", (double)jamFreq1);
u8g2.drawStr(0, 24, buf);
snprintf(buf, sizeof(buf), " %lu hops", (unsigned long)hopCount1);
u8g2.drawStr(0, 33, buf);
snprintf(buf, sizeof(buf), "R2 %.4f MHz", (double)jamFreq2);
u8g2.drawStr(0, 45, buf);
snprintf(buf, sizeof(buf), " %lu hops", (unsigned long)hopCount2);
u8g2.drawStr(0, 54, buf);
// Total hops per second (approx from 5s heartbeat window)
const uint32_t up = (millis() - uptimeStart) / 1000;
if (up > 0) {
snprintf(buf, sizeof(buf), "~%lu h/s total",
(unsigned long)((hopCount1 + hopCount2) / up));
u8g2.drawStr(0, 63, buf);
}
}
// Page 2 — System Health
static void oledDrawHealth() {
// Yellow zone header
u8g2.setFont(u8g2_font_6x10_tf);
u8g2.drawStr(2, 10, "SYS HEALTH");
oledPageDots(2);
u8g2.setFont(u8g2_font_5x7_tf);
char buf[24];
snprintf(buf, sizeof(buf), "TEMP %.1f C", (double)temperatureRead());
u8g2.drawStr(0, 24, buf);
const uint32_t freeK = ESP.getFreeHeap() / 1024;
const uint32_t minK = (minFreeHeap == 0xFFFFFFFF ? ESP.getFreeHeap() : minFreeHeap) / 1024;
snprintf(buf, sizeof(buf), "HEAP %lukB min%lukB", freeK, minK);
u8g2.drawStr(0, 33, buf);
const uint32_t up = millis() - uptimeStart;
snprintf(buf, sizeof(buf), "UP %uh %um %us",
(unsigned)(up/3600000), (unsigned)((up/60000)%60), (unsigned)((up/1000)%60));
u8g2.drawStr(0, 44, buf);
const int effDbm = (int)jamPower;
const uint32_t effMw = (uint32_t)roundf(powf(10.0f, effDbm / 10.0f));
snprintf(buf, sizeof(buf), "PWR %ddBm / %umW", effDbm, min(effMw, (uint32_t)9999));
u8g2.drawStr(0, 55, buf);
snprintf(buf, sizeof(buf), "WIFI %d ESPNOW %u",
WiFi.softAPgetStationNum(), (unsigned)espNowActivePeerCount());
u8g2.drawStr(0, 63, buf);
}
// Full-screen inverted notification overlay
static void oledDrawNotif() {
u8g2.drawBox(0, 0, 128, 64);
u8g2.setDrawColor(0);
u8g2.setFont(u8g2_font_7x13_tf);
int16_t x1 = (128 - (int16_t)strlen(notifL1) * 7) / 2;
u8g2.drawStr((uint8_t)max((int16_t)0, x1), 26, notifL1);
u8g2.setFont(u8g2_font_6x10_tf);
int16_t x2 = (128 - (int16_t)strlen(notifL2) * 6) / 2;
u8g2.drawStr((uint8_t)max((int16_t)0, x2), 44, notifL2);
u8g2.setDrawColor(1);
}
// Main OLED update — call from loop() every pass; self-throttles to 100ms.
static void oledTick() {
if (!oledOk) return;
const uint32_t now = millis();
if (now - oledTickMs < 100) return;
oledTickMs = now;
// Advance wave animation every 220ms (4 frames → ~1.1s full cycle)
if (now - waveMs >= 220) {
waveMs = now;
waveFrame = (waveFrame + 1) & 3;
}
// Consume encoder — manual page change resets the auto-cycle timer
if (encDelta != 0) {
noInterrupts();
const int8_t d = encDelta;
encDelta = 0;
interrupts();
oledPage = (uint8_t)((oledPage + 3 + (d > 0 ? 1 : -1)) % 3);
oledPageMs = now; // reset auto-advance so page stays visible
}
// Auto page-advance every 8s (not during notification, not if encoder just moved)
if (now > notifEnd && now - oledPageMs >= 8000) {
oledPageMs = now;
oledPage = (oledPage + 1) % 3;
}
u8g2.clearBuffer();
if (now < notifEnd) {
oledDrawNotif();
} else if (oledPage == 0) {
oledDrawStatus();
} else if (oledPage == 1) {
oledDrawFreq();
} else {
oledDrawHealth();
}
u8g2.sendBuffer();
}
// ─── Galois LFSR broadband noise generator ───────────────────────────────────
//
// Replaces LEDC fixed-frequency PWM which produced strong predictable sidebands
// at ±120 kHz, ±240 kHz etc — a pattern car receivers can filter out.
//
// A 32-bit Galois LFSR clocked at JAM_LFSR_KEY_HZ generates a maximal-length pseudo-
// random bit sequence (period 2^32-1). The output
// is spectrally flat: power spreads uniformly across the modulated bandwidth.
// R2 (433.92 MHz) uses max CC1101 deviation (~810 kHz FM noise). R1 (315 MHz)
// uses narrow deviation so most energy stays on-channel.
//
// Polynomial 0xB4BCD35C: taps at bits 0,2,6,7,16,18,19,21 — proven maximal.
// Both radios use different bit positions of the same sequence for uncorrelated
// but equally flat noise on each band.
static inline IRAM_ATTR uint32_t lfsrStep(uint32_t s) {
return (s >> 1) ^ (-(s & 1u) & 0xB4BCD35Cu);
}
static void IRAM_ATTR noiseISR() {
const uint32_t s = lfsrStep(s_lfsr);
s_lfsr = s;
if (s_noiseEn1) gpio_set_level((gpio_num_t)CC1101_1_GDO0, (s >> 0) & 1u);
else gpio_set_level((gpio_num_t)CC1101_1_GDO0, 0);
if (s_noiseEn2) gpio_set_level((gpio_num_t)CC1101_2_GDO0, (s >> 7) & 1u);
else gpio_set_level((gpio_num_t)CC1101_2_GDO0, 0);
}
static void noiseGenStart() {
if (s_noiseTimer) {
timerAlarmDisable(s_noiseTimer);
timerDetachInterrupt(s_noiseTimer);
timerEnd(s_noiseTimer);
s_noiseTimer = nullptr;
}
s_lfsr = esp_random();
if (s_lfsr == 0) s_lfsr = 0xDEADBEEFu; // LFSR must never be zero
gpio_set_direction((gpio_num_t)CC1101_1_GDO0, GPIO_MODE_OUTPUT);
gpio_set_direction((gpio_num_t)CC1101_2_GDO0, GPIO_MODE_OUTPUT);
// Hardware timer at JAM_LFSR_KEY_HZ — true ISR, no jitter, no FreeRTOS overhead.
// prescaler 80 → 1 MHz tick; alarm period = 1e6 / JAM_LFSR_KEY_HZ µs.
const uint32_t noisePeriodUs = (1000000u + (s_jamLfsrKeyHz / 2u)) / s_jamLfsrKeyHz;
s_noiseTimer = timerBegin(2, 80, true); // timer 2, 1 MHz, count up
timerAttachInterrupt(s_noiseTimer, &noiseISR, true); // edge triggered
timerAlarmWrite(s_noiseTimer, noisePeriodUs, true);
timerAlarmEnable(s_noiseTimer);
}
// Web server handlers
const char kHtml[] = R"HTML(
<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>CC1101 JAMMER</title><style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:#020504;color:#86f28a;font-family:'Courier New',monospace;font-size:12px;line-height:1.4}
body::after{content:'';position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9999;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,.08) 2px,rgba(0,0,0,.08) 4px);opacity:.6}
header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid #1a3a1e;background:linear-gradient(180deg,#040a06 0%,#030705 100%)}
h1{font-size:17px;letter-spacing:.12em;color:#a0f5a4;text-shadow:0 0 8px rgba(134,242,138,.4)}
.sub{font-size:10px;color:#4fbf59;margin-top:2px}
#progWrap{height:3px;background:#060e07}
#progBar{height:3px;background:linear-gradient(90deg,#2a8a2e,#86f28a,#2a8a2e);background-size:200% 100%;width:0;transition:width .8s linear;animation:progShimmer 3s linear infinite}
@keyframes progShimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
main{padding:10px;max-width:920px;margin:0 auto;display:flex;flex-direction:column;gap:8px}
.card{background:#030705;border:1px solid #1a3a1e;padding:10px;transition:border-color .3s}
.card:hover{border-color:#2a5a2e}
.card h2{font-size:10px;color:#4fbf59;letter-spacing:.12em;text-transform:uppercase;border-bottom:1px solid #122814;padding-bottom:5px;margin-bottom:8px}
.banner{padding:14px;text-align:center;border:2px solid #1a3a1e;transition:all .4s ease}
.bt{font-size:22px;font-weight:bold;letter-spacing:.18em}
.bs{font-size:11px;margin-top:5px}
.ban-on{border-color:#4fbf59;background:radial-gradient(ellipse at center,#0a1a0c 0%,#060f07 70%)}
.ban-off{border-color:#3a1218;background:#030504}
@keyframes pulse{0%,100%{box-shadow:0 0 6px rgba(134,242,138,.15)}50%{box-shadow:0 0 24px rgba(134,242,138,.5),0 0 60px rgba(134,242,138,.1)}}
.ban-on{animation:pulse 2.2s ease-in-out infinite}
.ban-on .bt{text-shadow:0 0 12px rgba(134,242,138,.6)}
.sg{display:grid;grid-template-columns:repeat(auto-fill,minmax(105px,1fr));gap:5px}
.s{background:#020504;border:1px solid #1a3a1e;padding:6px 8px;transition:border-color .2s,background .2s}
.s:hover{border-color:#2a5a2e;background:#040a06}
.sl{font-size:8px;color:#4fbf59;text-transform:uppercase;letter-spacing:.1em;white-space:nowrap}
.sv{font-size:15px;font-weight:bold;margin-top:2px;transition:color .3s}
.su{font-size:8px;color:#4fbf59;margin-left:1px}
.warm{color:#f5d87c!important}.hot{color:#f28a86!important}.lo{color:#f5d87c!important}.crit{color:#f28a86!important}
.band{margin-bottom:10px}
.bl{font-size:10px;color:#4fbf59;display:flex;justify-content:space-between;align-items:center;margin-bottom:3px}
canvas{display:block;width:100%}
canvas.sw{height:92px;border:1px solid #122814;background:#020504}
canvas.sp{height:44px;border:1px solid #122814;background:#020504}
.row{display:flex;flex-wrap:wrap;gap:10px}
.col{flex:1;min-width:140px}
.rrow{display:flex;gap:7px;align-items:center;margin-bottom:4px}
.dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;transition:all .3s}
.on{background:#86f28a;box-shadow:0 0 6px rgba(134,242,138,.7)}
@keyframes dotPulse{0%,100%{box-shadow:0 0 4px rgba(134,242,138,.5)}50%{box-shadow:0 0 10px rgba(134,242,138,.9)}}
.on{animation:dotPulse 1.5s ease-in-out infinite}
.off{background:#f28a86;box-shadow:0 0 4px rgba(242,138,134,.4)}
hr{border:none;border-top:1px solid #122814;margin:8px 0}
label{font-size:10px;color:#4fbf59;display:block;margin-bottom:3px}
input[type=range]{width:100%;accent-color:#86f28a;margin:2px 0}
input[type=number]{background:#020504;border:1px solid #1a3a1e;color:#86f28a;padding:3px 6px;font-family:inherit;font-size:11px;width:100%;transition:border-color .2s}
input[type=number]:focus{border-color:#4fbf59;outline:none}
select:focus{border-color:#4fbf59;outline:none}
button{padding:6px 11px;background:#0a1e0c;color:#86f28a;border:1px solid #2a5a2e;cursor:pointer;font-family:inherit;font-size:11px;letter-spacing:.04em;transition:all .15s}
button:hover{background:#142a16;box-shadow:0 0 8px rgba(134,242,138,.15)}
button:active{transform:scale(.97)}
button.jm{font-size:10px;padding:5px 10px}
button.jm-active{outline:2px solid #86f28a;box-shadow:0 0 10px rgba(134,242,138,.28)}
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}
.toast-wrap{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:10000;pointer-events:none}
#toast{background:#0a1e0c;border:1px solid #4fbf59;color:#a0f5a4;padding:7px 16px;font-size:11px;letter-spacing:.06em;opacity:0;transition:opacity .22s,transform .22s;transform:translateY(10px);box-shadow:0 4px 22px rgba(0,0,0,.55);max-width:90vw;text-align:center}
#toast.show{opacity:1;transform:translateY(0)}
textarea{background:#020504;border:1px solid #1a3a1e;color:#86f28a;padding:6px;font-family:inherit;font-size:10px;resize:vertical}
textarea:focus{border-color:#4fbf59;outline:none}
</style></head><body>
<div class="toast-wrap"><div id="toast" role="status" aria-live="polite"></div></div>
<header>
<div>
<div style="display:flex;align-items:center;gap:12px">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#86f28a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="filter:drop-shadow(0 0 6px rgba(134,242,138,0.5))"><path d="M2 12h4l3-9 5 18 3-9h5"/></svg>
<div>
<h1>CC1101 JAMMER</h1>
<div style="font-size:11px;color:#a0f5a4;font-style:italic;margin-top:1px;letter-spacing:0.08em;text-shadow:0 0 4px rgba(134,242,138,0.3)">"only tryan make a dollar"</div>
</div>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#86f28a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="filter:drop-shadow(0 0 6px rgba(134,242,138,0.5))"><path d="M2 12h4l3-9 5 18 3-9h5"/></svg>
</div>
<div class="sub" style="margin-top:5px">ESP32-S3 &bull; LOCK 315 MHz + LOCK 433.92 MHz &bull; Dual-carrier LFSR jam</div>
</div>
<div style="text-align:right;display:flex;align-items:center;gap:10px">
<div>
<div id="mission" style="font-size:9px;color:#4fbf59;margin-bottom:3px"></div>
<div style="font-size:8px;color:#1a3a1e;letter-spacing:.08em">v2.0 // LFSR+VCO</div>
</div>
<div id="connDot"></div>
</div>
</header>
<div id="progWrap"><div id="progBar"></div></div>
<main>
<div class="banner ban-off" id="banner">
<div class="bt" id="bt">INITIALIZING</div>
<div class="bs" id="bs"></div>
</div>
<div class="card">
<h2>System Metrics</h2>
<div class="sg">
<div class="s"><div class="sl">Uptime</div><div class="sv" id="mUp">—</div></div>
<div class="s"><div class="sl">CC1101 TX (max)</div><div class="sv" id="mPow">—<span class="su">dBm</span></div></div>
<div class="s"><div class="sl">ERP (chip only)</div><div class="sv" id="mEff">—<span class="su">dBm</span></div></div>
<div class="s"><div class="sl">ERP Watts</div><div class="sv" id="mW">—<span class="su">mW</span></div></div>
<div class="s"><div class="sl">R1 hardware</div><div class="sv" id="mR1h">—</div></div>
<div class="s"><div class="sl">R2 hardware</div><div class="sv" id="mR2h">—</div></div>
<div class="s"><div class="sl">Jam at boot</div><div class="sv" id="mBoot">always</div></div>
<div class="s"><div class="sl">Boot test R1</div><div class="sv" id="mSt1" title="">—</div></div>
<div class="s"><div class="sl">Boot test R2</div><div class="sv" id="mSt2" title="">—</div></div>
<div class="s"><div class="sl">Boot test all</div><div class="sv" id="mStAll">—</div></div>
<div class="s"><div class="sl">Boot test ms</div><div class="sv" id="mStMs">—</div></div>
<div class="s"><div class="sl">Last test R1</div><div class="sv" id="mLst1" title="">—</div></div>
<div class="s"><div class="sl">Last test R2</div><div class="sv" id="mLst2" title="">—</div></div>
<div class="s"><div class="sl">Last test all</div><div class="sv" id="mLstAll">—</div></div>
<div class="s"><div class="sl">Last test ms</div><div class="sv" id="mLstMs">—</div></div>
<div class="s"><div class="sl">Last test @ up</div><div class="sv" id="mLstAt">—</div></div>
<div class="s"><div class="sl">Temp</div><div class="sv" id="mTmp">—<span class="su">°C</span></div></div>
<div class="s"><div class="sl">Free Heap</div><div class="sv" id="mH">—<span class="su">kB</span></div></div>
<div class="s"><div class="sl">Min Heap</div><div class="sv" id="mMH">—<span class="su">kB</span></div></div>
<div class="s"><div class="sl">AP Clients</div><div class="sv" id="mCl">—</div></div>
<div class="s"><div class="sl">Nodes (ESP-NOW)</div><div class="sv" id="mEn">—</div></div>
</div>
</div>
<div class="card">
<h2>Locked jam carriers</h2>
<div class="band">
<div class="bl">
<span><span class="dot on" id="d1"></span>&nbsp;Radio 1 — 315.000 MHz (NA)&nbsp;<small style="color:#2a6a2e">narrow FM + LFSR</small></span>
<span id="f1c" style="color:#86f28a;font-weight:bold"></span>
</div>
<canvas class="sw" id="c1"></canvas>
</div>
<div class="band">
<div class="bl">
<span><span class="dot on" id="d2"></span>&nbsp;Radio 2 — 433.920 MHz (EU/global)&nbsp;<small style="color:#2a6a2e">max FM noise + LFSR</small></span>
<span id="f2c" style="color:#86f28a;font-weight:bold"></span>
</div>
<canvas class="sw" id="c2"></canvas>
</div>
</div>
<div class="card">
<h2>2-Minute History</h2>
<div class="row">
<div class="col">
<div class="bl"><span style="color:#4fbf59">Temperature (°C)</span><span id="tNow" style="font-weight:bold">—</span></div>
<canvas class="sp" id="cT"></canvas>
</div>
<div class="col">
<div class="bl"><span style="color:#4fbf59">Free Heap (kB)</span><span id="hNow" style="font-weight:bold">—</span></div>
<canvas class="sp" id="cH"></canvas>
</div>
</div>
</div>
<div class="card">
<h2>Radio Status</h2>
<p class="sub" style="margin-bottom:8px">Boot: once per power-up before WiFi (SRES, RadioLib, VERSION). Last: most recent POST self-test (no reboot).</p>
<div class="row">
<div class="col">
<div class="rrow"><span class="dot off" id="r1d"></span><strong>Radio 1 — 315 MHz LOCK</strong></div>
<div id="r1boot" class="sub" style="color:#4fbf59">—</div>
<div id="r1last" class="sub" style="color:#3a7a3e">—</div>
<div id="r1s" class="sub">—</div><div id="r1e" class="err"></div>
</div>
<div class="col">
<div class="rrow"><span class="dot off" id="r2d"></span><strong>Radio 2 — 433.92 MHz LOCK</strong></div>
<div id="r2boot" class="sub" style="color:#4fbf59">—</div>
<div id="r2last" class="sub" style="color:#3a7a3e">—</div>
<div id="r2s" class="sub">—</div><div id="r2e" class="err"></div>
</div>
</div>
</div>
<div class="card">
<h2>Controls</h2>
<p style="font-size:10px;color:#3a7a3e;margin-bottom:8px">TX power is fixed at +10 dBm (CC1101 max, TI SWRS061). Radiated level includes your antenna gain only no external PA.</p>
<p style="font-size:10px;color:#4fbf59;margin-bottom:8px">Jamming starts automatically on every power-up. Press <strong>Stop Jamming</strong> to go standby for capture (REC), then start jam again when done.</p>
<p style="font-size:10px;color:#3a7a3e;margin-bottom:6px"><strong>Jam strategy</strong> Direct: wide LFSR + async TX. Precision: narrower deviation / key rate. Flood: bursty random packets (hypothesis for burst-sensitive links; verify in your chamber).</p>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">
<button type="button" class="jm" id="jmDirect">Direct</button>
<button type="button" class="jm" id="jmPrecision">Precision</button>
<button type="button" class="jm" id="jmFlood">Flood</button>
</div>
<div class="row" style="margin-bottom:10px">
<div class="col" style="display:flex;flex-direction:column;gap:6px">
<button id="tog">Start Jamming</button>
<button id="runSelftest">Run radio self-test</button>
</div>
</div>
</div>
<div class="card">
<h2>Signal Capture / Replay</h2>
<div class="row" style="margin-bottom:8px">
<div class="col">
<label>Target Frequency (MHz)</label>
<input type="number" id="capFreq" min="290" max="450" step="0.001" value="315.000" style="width:110px">
</div>
<div class="col">
<label>Modulation</label>
<select id="capMod" style="background:#020504;border:1px solid #1a3a1e;color:#86f28a;padding:3px 6px;font-family:inherit;font-size:11px">
<option value="ook">OOK / ASK (90% of fobs)</option>
<option value="fsk">2-FSK</option>
</select>
</div>
<div class="col">
<label>Radio</label>
<select id="capRadio" style="background:#020504;border:1px solid #1a3a1e;color:#86f28a;padding:3px 6px;font-family:inherit;font-size:11px">
<option value="1">Radio 1 (300-320 MHz)</option>
<option value="2">Radio 2 (390-436 MHz)</option>
</select>
</div>
</div>
<div style="display:flex;gap:7px;flex-wrap:wrap;margin-bottom:8px">
<button onclick="capStartRec()">REC</button>
<button onclick="capStop()">STOP</button>
<button onclick="capReplay()">REPLAY</button>
</div>
<div style="height:3px;background:#060e07;margin-bottom:8px"><div id="capProg" style="height:3px;background:#4fbf59;width:0;transition:width .3s linear"></div></div>
<div class="sg" style="margin-bottom:8px">
<div class="s"><div class="sl">State</div><div class="sv" id="capStat">IDLE</div></div>
<div class="s"><div class="sl">Bits</div><div class="sv" id="capBits">—</div></div>
<div class="s"><div class="sl">Duration</div><div class="sv" id="capDur">—</div></div>
<div class="s"><div class="sl">Est Bitrate</div><div class="sv" id="capBps">—</div></div>
<div class="s"><div class="sl">Duty Cycle</div><div class="sv" id="capDuty">—</div></div>
<div class="s"><div class="sl">Cap Freq</div><div class="sv" id="capRecFreq">—</div></div>
</div>
<canvas id="capWave" class="sw" height="60" style="height:60px"></canvas>
<h2 style="margin-top:12px;margin-bottom:6px">Capture history (NVS)</h2>
<div style="overflow-x:auto;border:1px solid #122814">
<table id="capHist" style="width:100%;font-size:10px;border-collapse:collapse">
<thead><tr style="color:#4fbf59;text-align:left"><th style="padding:4px">#</th><th style="padding:4px">Time (uptime)</th><th style="padding:4px">MHz</th><th style="padding:4px">Bits</th><th style="padding:4px">Radio</th><th style="padding:4px">Mod</th></tr></thead>
<tbody id="capHistBody"></tbody>
</table>
</div>
<div style="font-size:9px;color:#2a6a2e;margin-top:5px">
REC pauses jamming, RX via CC1101 GDO0 (SWRS061). STOP saves to history if 100 bits. REPLAY loops async TX. If jam is on, use Stop Jamming first or REC will pause it automatically.
</div>
</div>
<div class="card">
<h2>CC1101 registers (SmartRF-style)</h2>
<p style="font-size:10px;color:#3a7a3e;margin-bottom:8px">Export configuration bytes for one radio, edit offline, paste hex and apply. TX pauses briefly during SPI write.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<button type="button" id="regExp1">Export R1</button>
<button type="button" id="regExp2">Export R2</button>
</div>
<label>Apply to</label>
<select id="regRadio" style="background:#020504;border:1px solid #1a3a1e;color:#86f28a;padding:3px 6px;font-family:inherit;font-size:11px;width:100%;max-width:220px;margin-bottom:6px">
<option value="1">Radio 1</option>
<option value="2">Radio 2</option>
</select>
<label>Hex (contiguous)</label>
<textarea id="regHex" rows="3" spellcheck="false" placeholder="paste hex from export…"></textarea>
<button type="button" id="regApply" style="margin-top:8px">Apply to radio</button>
</div>
<div class="card">
<h2>System Log &nbsp;<button id="dl" style="font-size:9px;padding:2px 7px">Download</button></h2>
<pre id="log"></pre>
</div>
<div style="text-align:center; padding: 20px 0; color: #3a7a3e; font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase;">
rusian marks atm
</div>
</main><script>
let T={},lastP=Date.now();
const HS=120,hT=new Array(HS).fill(null),hH=new Array(HS).fill(null);
let hi=0;
const TL=50,tr1=[],tr2=[];
const MK1=[{f:303.825,l:'Honda'},{f:310,l:'Chmb'},{f:314.98,l:'Toyot'},{f:315,l:'Ford'},{f:318,l:'Line'}];
const MK2=[{f:390,l:'Lift'},{f:418,l:'Holt'},{f:433.42,l:'Somfy'},{f:433.92,l:'EU'},{f:434.42,l:'Nero'}];
function ff(f){return f?(+f).toFixed(4)+' MHz':''}
function fu(ms){const s=Math.floor(ms/1000),m=Math.floor(s/60),h=Math.floor(m/60),d=Math.floor(h/24);
return d?`${d}d ${h%24}h ${m%60}m`:h?`${h}h ${m%60}m ${s%60}s`:`${m}m ${s%60}s`}
function ct(v){return v>80?'hot':v>65?'warm':''}
function ch(kb){return kb<30?'crit':kb<60?'lo':''}
function updTr(arr,norm){
arr.forEach(t=>t.age++);
while(arr.length&&arr[0].age>=TL)arr.shift();
arr.push({x:Math.max(0,Math.min(1,norm)),age:0});
}
function drawSw(id,freq,ctr,span,active,trail,marks){
const cv=document.getElementById(id);if(!cv)return;
const W=cv.offsetWidth||400,H=92;cv.width=W;cv.height=H;
const ctx=cv.getContext('2d');
const lo=ctr-span/2,sp=Math.max(span,0.001);
const tx=f=>Math.max(0,Math.min(W,(f-lo)/sp*W));
ctx.fillStyle='#020504';ctx.fillRect(0,0,W,H);
// Subtle grid
ctx.strokeStyle='#0a180c';ctx.lineWidth=1;ctx.setLineDash([2,10]);
for(let i=1;i<10;i++){const x=i/10*W;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H-14);ctx.stroke();}
ctx.setLineDash([]);
// Known freq markers
marks.forEach(({f,l})=>{
if(f<lo||f>lo+sp)return;
const x=tx(f);
ctx.strokeStyle='rgba(74,180,84,0.4)';ctx.lineWidth=1;ctx.setLineDash([3,4]);
ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H-15);ctx.stroke();ctx.setLineDash([]);
ctx.fillStyle='rgba(74,180,84,0.7)';ctx.font='8px monospace';ctx.textAlign='center';ctx.fillText(l,x,H-16);
});
// Heat trail
trail.forEach(t=>{
const a=(1-t.age/TL)*0.5,bw=55,bx=t.x*W;
const g=ctx.createLinearGradient(bx-bw,0,bx+bw,0);
g.addColorStop(0,'rgba(80,240,110,0)');
g.addColorStop(.5,`rgba(80,240,110,${a})`);
g.addColorStop(1,'rgba(80,240,110,0)');
ctx.fillStyle=g;ctx.fillRect(bx-bw,0,bw*2,H-14);
});
if(active&&freq){
const cx=tx(freq);
// Wide glow
const g=ctx.createLinearGradient(cx-80,0,cx+80,0);
g.addColorStop(0,'rgba(134,242,138,0)');g.addColorStop(.5,'rgba(134,242,138,0.22)');g.addColorStop(1,'rgba(134,242,138,0)');
ctx.fillStyle=g;ctx.fillRect(cx-80,0,160,H-14);
// Cursor line with glow
ctx.save();ctx.shadowColor='rgba(134,242,138,.6)';ctx.shadowBlur=6;
ctx.strokeStyle='#86f28a';ctx.lineWidth=2;
ctx.beginPath();ctx.moveTo(cx,0);ctx.lineTo(cx,H-14);ctx.stroke();
ctx.restore();
// Cursor dot with glow
ctx.save();ctx.shadowColor='rgba(134,242,138,.8)';ctx.shadowBlur=10;
ctx.fillStyle='#86f28a';ctx.beginPath();ctx.arc(cx,(H-14)/2,4,0,Math.PI*2);ctx.fill();
ctx.restore();
// Top notch
ctx.fillStyle='#86f28a';ctx.fillRect(Math.max(0,cx-2),0,4,4);
} else if(!active){
ctx.strokeStyle='#2a1216';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(W/2,0);ctx.lineTo(W/2,H-14);ctx.stroke();
}
// Axis bar
ctx.fillStyle='#0a180c';ctx.fillRect(0,H-14,W,14);
ctx.fillStyle='#3a7a3e';ctx.font='9px monospace';
ctx.textAlign='left';ctx.fillText(lo.toFixed(1)+' MHz',3,H-3);
ctx.textAlign='right';ctx.fillText((lo+sp).toFixed(1)+' MHz',W-3,H-3);
if(active&&freq){ctx.fillStyle='#86f28a';ctx.textAlign='center';ctx.fillText((+freq).toFixed(4)+' MHz',tx(freq),H-3);}
}
function drawSp(id,data,color,minH,maxH){
const cv=document.getElementById(id);if(!cv)return;
const W=cv.offsetWidth||280,H=44;cv.width=W;cv.height=H;
const ctx=cv.getContext('2d');
ctx.fillStyle='#020504';ctx.fillRect(0,0,W,H);
const vd=data.filter(v=>v!==null);if(vd.length<2)return;
const mn=minH??Math.min(...vd),mx=maxH??Math.max(...vd),rng=Math.max(mx-mn,0.5);
const toY=v=>H-4-((v-mn)/rng*(H-8));
// Fill
ctx.beginPath();let fs=true;
data.forEach((v,i)=>{if(v===null){fs=true;return;}const x=i/(HS-1)*W,y=toY(v);if(fs){ctx.moveTo(x,H-4);ctx.lineTo(x,y);fs=false;}else ctx.lineTo(x,y);});
ctx.lineTo(W,H-4);ctx.closePath();
const gf=ctx.createLinearGradient(0,0,0,H);gf.addColorStop(0,color+'30');gf.addColorStop(1,color+'05');
ctx.fillStyle=gf;ctx.fill();
// Line with glow
ctx.save();ctx.shadowColor=color;ctx.shadowBlur=4;
ctx.strokeStyle=color;ctx.lineWidth=1.5;ctx.beginPath();fs=true;
data.forEach((v,i)=>{if(v===null){fs=true;return;}const x=i/(HS-1)*W,y=toY(v);if(fs){ctx.moveTo(x,y);fs=false;}else ctx.lineTo(x,y);});
ctx.stroke();ctx.restore();
// Range labels
ctx.fillStyle=color+'90';ctx.font='8px monospace';
ctx.textAlign='left';ctx.fillText(mn.toFixed(0),2,H-3);
ctx.textAlign='right';ctx.fillText(mx.toFixed(0),W-2,H-3);
}
function applyTelemetry(t){
T=t;
lastP=Date.now();
const pct=Math.min(100,t.uptime_ms/864000);
document.getElementById('progBar').style.width=pct+'%';
document.getElementById('mission').textContent=fu(t.uptime_ms)+' / 24h ('+pct.toFixed(1)+'%)';
const jam=t.jamming_enabled;
document.getElementById('banner').className='banner '+(jam?'ban-on':'ban-off');
document.getElementById('bt').textContent=jam?' JAMMING ACTIVE ':'STANDBY';
document.getElementById('bt').style.color=jam?'#86f28a':'#f28a86';
const ok1=t.radio1_hw_ok===true||t.radio1_hw_ok==='true';
const ok2=t.radio2_hw_ok===true||t.radio2_hw_ok==='true';
const tx1=t.radio1_active===true||t.radio1_active==='true';
const tx2=t.radio2_active===true||t.radio2_active==='true';
const bst1=t.boot_st_r1_pass===true||t.boot_st_r1_pass==='true';
const bst2=t.boot_st_r2_pass===true||t.boot_st_r2_pass==='true';
const bstall=t.boot_st_all_pass===true||t.boot_st_all_pass==='true';
const lran=t.last_st_ran===true||t.last_st_ran==='true';
const lstall=t.last_st_all_pass===true||t.last_st_all_pass==='true';
const parts=[];
if(!bstall)parts.push('BOOT SELF-TEST FAIL');
if(lran&&!lstall)parts.push('last self-test FAIL');
if(!bst1)parts.push('R1 dead @boot');
if(!bst2)parts.push('R2 dead @boot');
if(!ok1)parts.push('R1 FAULT now');
if(!ok2)parts.push('R2 FAULT now');
if(jam&&(tx1||tx2)){
const b=[];if(tx1)b.push('315 TX');if(tx2)b.push('433.92 TX');
const jm=t.jam_mode_str||({0:'direct',1:'precision',2:'flood'}[t.jam_mode])||'direct';
parts.push(jm+' | '+b.join(' + '));
}
document.getElementById('bs').textContent=parts.length?parts.join(' | ')+' | '+t.jam_power+' dBm chip (ERP + antenna only)'
:(jam?'Jamming on but no TX':'Standby Stop Jamming to capture');
document.getElementById('bs').style.color=(jam&&(tx1||tx2))?'#86f28a':(!bstall||!ok1||!ok2||jam||(lran&&!lstall))?'#f28a86':'#4fbf59';
document.getElementById('tog').textContent=jam?'Stop Jamming':'Start Jamming';
document.getElementById('mUp').textContent=fu(t.uptime_ms);
document.getElementById('mPow').innerHTML=t.jam_power+'<span class="su">dBm</span>';
document.getElementById('mEff').innerHTML=t.eff_power_dbm+'<span class="su">dBm</span>';
document.getElementById('mW').innerHTML=(t.eff_power_w*1000).toFixed(0)+'<span class="su">mW</span>';
const el1=document.getElementById('mR1h');el1.textContent=ok1?'OK':'FAIL';el1.className='sv '+(ok1?'':'crit');
const el2=document.getElementById('mR2h');el2.textContent=ok2?'OK':'FAIL';el2.className='sv '+(ok2?'':'crit');
const bootEl=document.getElementById('mBoot');if(bootEl){bootEl.textContent='always';bootEl.className='sv';}
const s1=document.getElementById('mSt1');s1.textContent=bst1?'PASS':'FAIL';s1.className='sv '+(bst1?'':'crit');s1.title=t.boot_st_r1||'';
const s2=document.getElementById('mSt2');s2.textContent=bst2?'PASS':'FAIL';s2.className='sv '+(bst2?'':'crit');s2.title=t.boot_st_r2||'';
const sa=document.getElementById('mStAll');sa.textContent=bstall?'PASS':'FAIL';sa.className='sv '+(bstall?'':'crit');
document.getElementById('mStMs').textContent=(t.boot_st_ms!=null)?String(t.boot_st_ms):'';
const ls1=document.getElementById('mLst1'),ls2=document.getElementById('mLst2'),lsa=document.getElementById('mLstAll');
if(ls1){ls1.textContent=lran?(t.last_st_r1_pass?'PASS':'FAIL'):'';ls1.className='sv '+(!lran?'':(t.last_st_r1_pass?'':'crit'));ls1.title=t.last_st_r1||'';}
if(ls2){ls2.textContent=lran?(t.last_st_r2_pass?'PASS':'FAIL'):'';ls2.className='sv '+(!lran?'':(t.last_st_r2_pass?'':'crit'));ls2.title=t.last_st_r2||'';}
if(lsa){lsa.textContent=lran?(lstall?'PASS':'FAIL'):'';lsa.className='sv '+(!lran?'':(lstall?'':'crit'));}
document.getElementById('mLstMs').textContent=lran&&t.last_st_ms!=null?String(t.last_st_ms):'';
document.getElementById('mLstAt').textContent=lran&&t.last_st_at_ms!=null?fu(t.last_st_at_ms):'';
const tEl=document.getElementById('mTmp');tEl.innerHTML=t.temp_c+'<span class="su">°C</span>';tEl.className='sv '+ct(+t.temp_c);
const hkb=t.free_heap/1024;const hEl=document.getElementById('mH');hEl.innerHTML=hkb.toFixed(0)+'<span class="su">kB</span>';hEl.className='sv '+ch(hkb);
const mhkb=t.min_heap/1024;document.getElementById('mMH').innerHTML=mhkb.toFixed(0)+'<span class="su">kB</span>';
document.getElementById('mCl').textContent=t.ap_clients??'';
const en=document.getElementById('mEn');
if(en)en.textContent=(typeof t.espnow_ok==='boolean')?(t.espnow_ok?String(t.espnow_peers??0):'off'):'';
const jf1=t.jam_freq1??315,sp1=4;
const jf2=t.jam_freq2??433.92,sp2=8;
updTr(tr1,0.5);updTr(tr2,0.5);
drawSw('c1',jf1,jf1,sp1,tx1,tr1,MK1);
drawSw('c2',jf2,jf2,sp2,tx2,tr2,MK2);
document.getElementById('f1c').textContent=ff(jf1);
document.getElementById('f2c').textContent=ff(jf2);
const sd=(id,on)=>{const d=document.getElementById(id);d.className='dot '+(on?'on':'off');};
// Band row dots: green when CC1101 probed OK (not only when TX — red was misleading in standby).
sd('d1',ok1);sd('d2',ok2);sd('r1d',ok1);sd('r2d',ok2);
const rs=s=>s===2?'TRANSMITTING':s===1?'IDLE/OK':s===0?'INIT':'ERROR';
document.getElementById('r1boot').textContent='Boot: '+(t.boot_st_r1||'');
document.getElementById('r2boot').textContent='Boot: '+(t.boot_st_r2||'');
document.getElementById('r1last').textContent=lran?('Last: '+(t.last_st_r1||'')):'Last: (not run)';
document.getElementById('r2last').textContent=lran?('Last: '+(t.last_st_r2||'')):'Last: (not run)';
document.getElementById('r1s').textContent=rs(t.radio1_status)+' '+ff(t.jam_freq1);
document.getElementById('r2s').textContent=rs(t.radio2_status)+' '+ff(t.jam_freq2);
document.getElementById('r1e').textContent=t.radio1_error||'';
document.getElementById('r2e').textContent=t.radio2_error||'';
hT[hi]=+t.temp_c;hH[hi]=t.free_heap/1024;hi=(hi+1)%HS;
document.getElementById('tNow').textContent=(+t.temp_c).toFixed(1)+'°C';
document.getElementById('tNow').className=ct(+t.temp_c);
document.getElementById('hNow').textContent=(t.free_heap/1024).toFixed(0)+' kB';
document.getElementById('hNow').className=ch(t.free_heap/1024);
drawSp('cT',hT,'#86f28a',20,90);
drawSp('cH',hH,'#4fbf59',0,320);
const cd=document.getElementById('connDot');cd.style.background='#86f28a';cd.style.boxShadow='0 0 8px rgba(134,242,138,.7)';
const jmStr=(t.jam_mode_str||({0:'direct',1:'precision',2:'flood'}[t.jam_mode])||'direct').toLowerCase();
[['jmDirect','direct'],['jmPrecision','precision'],['jmFlood','flood']].forEach(([id,exp])=>{
const el=document.getElementById(id);if(!el)return;
el.classList.toggle('jm-active',jmStr===exp);
});
}
function showToast(msg){
const el=document.getElementById('toast');if(!el)return;
el.textContent=msg;
el.classList.add('show');
clearTimeout(showToast._t);
showToast._t=setTimeout(()=>el.classList.remove('show'),2200);
}
async function setJamMode(mode){
try{
const r=await fetch('/api/jam_mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode})});
const j=await r.json();
if(!j||!j.ok){showToast(j&&j.err?String(j.err):'Mode change failed');return;}
showToast('Mode: '+j.mode);
const tr=await fetch('/api/telemetry');
applyTelemetry(await tr.json());
}catch(e){showToast('Offline / error');}
}
let logTick=0;
async function capRefreshHist(){
try{
const r=await fetch('/api/capture/history');
const arr=await r.json();
const tb=document.getElementById('capHistBody');
if(!tb||!Array.isArray(arr))return;
tb.innerHTML=arr.map((row,i)=>'<tr style="border-top:1px solid #122814"><td style="padding:4px">'+(i+1)+'</td><td style="padding:4px">'+fu(row.uptime_ms)+'</td><td style="padding:4px">'+(+row.freq).toFixed(3)+'</td><td style="padding:4px">'+(+row.bits).toLocaleString()+'</td><td style="padding:4px">'+row.radio+'</td><td style="padding:4px">'+row.mod+'</td></tr>').join('');
}catch(e){}
}
async function poll(){
try{
const tr=await fetch('/api/telemetry');applyTelemetry(await tr.json());
if(++logTick%3===0)capRefreshHist();
if(logTick%5===0){
const lr=await fetch('/api/log');
const log=document.getElementById('log');
const atBot=log.scrollHeight-log.scrollTop<=log.clientHeight+40;
log.textContent=await lr.text();
if(atBot)log.scrollTop=log.scrollHeight;
}
}catch(e){
const cd=document.getElementById('connDot');cd.style.background='#f28a86';cd.style.boxShadow='0 0 8px rgba(242,138,134,.6)';
document.getElementById('bt').textContent='OFFLINE';
}
setTimeout(poll, 1000);
}
document.getElementById('tog').addEventListener('click',async()=>{try{const r=await fetch('/api/toggle',{method:'POST'});applyTelemetry({...T,jamming_enabled:(await r.json()).enabled});}catch(e){}});
document.getElementById('runSelftest').addEventListener('click',async()=>{try{const r=await fetch('/api/selftest',{method:'POST'});const j=await r.json();if(j&&j.ok===false&&j.err==='capture_active'){alert('Stop capture or replay first.');return;}applyTelemetry(await(await fetch('/api/telemetry')).json());}catch(e){}});
document.getElementById('dl').addEventListener('click',()=>{const a=document.createElement('a');a.href='/api/log';a.download='jammer-log.txt';a.click();});
document.getElementById('jmDirect').addEventListener('click',()=>setJamMode('direct'));
document.getElementById('jmPrecision').addEventListener('click',()=>setJamMode('precision'));
document.getElementById('jmFlood').addEventListener('click',()=>setJamMode('flood'));
async function regExport(radio){
try{
const res=await fetch('/api/cc1101/registers?radio='+radio);
const j=await res.json();
if(j&&j.ok){document.getElementById('regHex').value=j.hex;document.getElementById('regRadio').value=String(radio);showToast('Exported R'+radio);}
else showToast(j&&j.err?String(j.err):'Export failed');
}catch(e){showToast('Export error');}
}
document.getElementById('regExp1').addEventListener('click',()=>regExport(1));
document.getElementById('regExp2').addEventListener('click',()=>regExport(2));
document.getElementById('regApply').addEventListener('click',async()=>{
const r=+document.getElementById('regRadio').value;
const hex=document.getElementById('regHex').value.replace(/\s+/g,'');
try{
const res=await fetch('/api/cc1101/registers',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({radio:r,hex})});
const j=await res.json();
if(j&&j.ok){showToast('Registers applied');const tr=await fetch('/api/telemetry');applyTelemetry(await tr.json());}
else showToast(j&&j.err?String(j.err):'Apply failed');
}catch(e){showToast('Apply error');}
});
poll();
capRefreshHist();
// ── Capture / Replay ─────────────────────────────────────────────────────────
let capPolling=false;
let capWaveData=[];
async function capFetch(url){try{return await(await fetch(url)).json();}catch(e){return null;}}
async function capStartRec(){
const freq=+document.getElementById('capFreq').value;
const radio=+document.getElementById('capRadio').value;
const mod=document.getElementById('capMod').value;
if(isNaN(freq)||freq<290||freq>450){alert('Frequency must be 290-450 MHz');return;}
const d=await capFetch('/api/capture/start?freq='+freq+'&radio='+radio+'&mod='+mod);
if(d){capSetStatus('RECORDING',d);capPollStart();}
}
async function capStop(){
const d=await capFetch('/api/capture/stop');
if(d){capSetStatus('STOPPED',d);capPollStop();}
await capLoadWave();
}
async function capReplay(){
const radio=+document.getElementById('capRadio').value;
const d=await capFetch('/api/capture/replay?radio='+radio);
if(d){capSetStatus('REPLAYING',d);}
}
async function capLoadWave(){
const w=await capFetch('/api/capture/wave');
if(!w||!w.length)return;
capWaveData=w;
capDrawWave();
}
function capDrawWave(){
const cv=document.getElementById('capWave');
if(!cv||!capWaveData.length)return;
const W=cv.width,H=cv.height;
const ctx=cv.getContext('2d');
ctx.clearRect(0,0,W,H);
ctx.fillStyle='#020504';ctx.fillRect(0,0,W,H);
const n=capWaveData.length;
const bw=W/n;
ctx.fillStyle='#4fbf59';
for(let i=0;i<n;i++){
const h=Math.round(capWaveData[i]/100*(H-2));
ctx.fillRect(Math.round(i*bw),H-h,Math.max(1,Math.ceil(bw)),h);
}
}
function capSetStatus(label,d){
document.getElementById('capStat').textContent=label;
const dispBits=d?(d.rec_bits||d.bits):0;if(dispBits!==undefined)document.getElementById('capBits').textContent=dispBits.toLocaleString()+' bits';
if(d&&d.dur_ms)document.getElementById('capDur').textContent=(d.dur_ms/1000).toFixed(2)+'s';
if(d&&d.est_bps)document.getElementById('capBps').textContent=d.est_bps.toLocaleString()+' bps';
if(d&&d.duty_pct!==undefined)document.getElementById('capDuty').textContent=d.duty_pct+'%';
if(d&&d.freq)document.getElementById('capRecFreq').textContent=d.freq.toFixed(3)+' MHz';
const pct=d?(d.mode===1&&d.buf_bits?Math.round(d.bits*100/d.buf_bits):(d.mode===3&&d.rec_bits?Math.round(d.bits*100/d.rec_bits):100)):0;
document.getElementById('capProg').style.width=(d&&(d.mode===2||d.mode===3)?100:pct)+'%';
}
let capPollTimer=null;
function capPollStart(){if(!capPollTimer)capPollTimer=setInterval(capPollStatus,400);}
function capPollStop(){clearInterval(capPollTimer);capPollTimer=null;}
async function capPollStatus(){
const d=await capFetch('/api/capture/status');
if(!d)return;
const modes=['IDLE','RECORDING','CAPTURED','REPLAYING'];
capSetStatus(modes[d.mode]||'?',d);
if(d.mode===2||d.mode===0){capPollStop();if(d.mode===2)capLoadWave();}
}
window.addEventListener('resize',capDrawWave);
</script></body></html>
)HTML";
static void handleRoot() {
// ETag based on compile timestamp — browser caches until next flash
server.sendHeader("ETag", "\"" __DATE__ __TIME__ "\"");
server.sendHeader("Cache-Control", "no-cache"); // revalidate via ETag, don't re-download
if (server.hasHeader("If-None-Match") &&
server.header("If-None-Match") == "\"" __DATE__ __TIME__ "\"") {
server.send(304); // Not Modified — browser uses cached copy, saves ~15 KB
return;
}
server.setContentLength(sizeof(kHtml) - 1);
server.send(200, "text/html; charset=utf-8", "");
server.sendContent(kHtml);
}
static void handleLog() {
server.send(200, "text/plain; charset=utf-8", getLogsText());
}
static void handleTelemetry() {
float tempC = temperatureRead();
const int8_t effDbm = jamPower;
const float effWatts = powf(10.0f, effDbm / 10.0f) / 1000.0f;
String err1 = jsonEscape(radio1Error);
String err2 = jsonEscape(radio2Error);
String st1 = jsonEscape(bootSelftestR1Detail);
String st2 = jsonEscape(bootSelftestR2Detail);
String lst1 = jsonEscape(lastSelftestR1Detail);
String lst2 = jsonEscape(lastSelftestR2Detail);
static char jsonBuf[2624];
snprintf(jsonBuf, sizeof(jsonBuf),
"{"
"\"uptime_ms\":%lu,"
"\"free_heap\":%lu,"
"\"temp_c\":%.1f,"
"\"jamming_enabled\":%s,"
"\"jam_mode\":%u,"
"\"jam_mode_str\":\"%s\","
"\"auto_start_jam\":%s,"
"\"jam_power\":%d,"
"\"jam_power_idx\":%d,"
"\"eff_power_dbm\":%d,"
"\"eff_power_w\":%.3f,"
"\"jam_freq1\":%.4f,"
"\"jam_freq2\":%.4f,"
"\"jam_fixed\":true,"
"\"boot_st_r1_pass\":%s,"
"\"boot_st_r2_pass\":%s,"
"\"boot_st_all_pass\":%s,"
"\"boot_st_ms\":%lu,"
"\"boot_st_r1\":\"%s\","
"\"boot_st_r2\":\"%s\","
"\"last_st_ran\":%s,"
"\"last_st_r1_pass\":%s,"
"\"last_st_r2_pass\":%s,"
"\"last_st_all_pass\":%s,"
"\"last_st_ms\":%lu,"
"\"last_st_at_ms\":%lu,"
"\"last_st_r1\":\"%s\","
"\"last_st_r2\":\"%s\","
"\"radio1_status\":%d,"
"\"radio1_error\":\"%s\","
"\"radio1_freq\":%.4f,"
"\"radio1_active\":%s,"
"\"radio1_hw_ok\":%s,"
"\"radio2_status\":%d,"
"\"radio2_error\":\"%s\","
"\"radio2_freq\":%.4f,"
"\"radio2_active\":%s,"
"\"radio2_hw_ok\":%s,"
"\"hop_count1\":%lu,"
"\"hop_count2\":%lu,"
"\"min_heap\":%lu,"
"\"ap_clients\":%d,"
"\"espnow_ok\":%s,"
"\"espnow_peers\":%u"
"}",
(unsigned long)(millis() - uptimeStart),
(unsigned long)ESP.getFreeHeap(),
(double)tempC,
jammingEnabled ? "true" : "false",
(unsigned)(uint8_t)jamMode,
jamModeToCstr(jamMode),
autoStartJam ? "true" : "false",
(int)jamPower,
(int)jamPowerIdx,
(int)effDbm,
(double)effWatts,
(double)jamFreq1,
(double)jamFreq2,
bootSelftestR1Pass ? "true" : "false",
bootSelftestR2Pass ? "true" : "false",
(bootSelftestR1Pass && bootSelftestR2Pass) ? "true" : "false",
(unsigned long)bootSelftestDurationMs,
st1.c_str(),
st2.c_str(),
lastSelftestRan ? "true" : "false",
lastSelftestR1Pass ? "true" : "false",
lastSelftestR2Pass ? "true" : "false",
(lastSelftestR1Pass && lastSelftestR2Pass) ? "true" : "false",
(unsigned long)lastSelftestDurationMs,
(unsigned long)lastSelftestAtMs,
lst1.c_str(),
lst2.c_str(),
(int)radio1Status,
err1.c_str(),
(double)jamFreq1,
radio1Status == 2 ? "true" : "false",
radio1Status != -1 ? "true" : "false",
(int)radio2Status,
err2.c_str(),
(double)jamFreq2,
radio2Status == 2 ? "true" : "false",
radio2Status != -1 ? "true" : "false",
(unsigned long)hopCount1,
(unsigned long)hopCount2,
(unsigned long)(minFreeHeap == 0xFFFFFFFF ? ESP.getFreeHeap() : minFreeHeap),
(int)WiFi.softAPgetStationNum(),
s_espNowReady ? "true" : "false",
(unsigned)espNowActivePeerCount()
);
server.send(200, "application/json; charset=utf-8", jsonBuf);
}
static void handleToggle() {
if (jammingEnabled) {
stopJamming();
oledNotify("STANDBY", "Jamming stopped");
} else {
jammingEnabled = true; // must be set before startJamming so sweep loop and watchdog see it
startJamming();
if (radio1Status != 2 && radio2Status != 2) {
jammingEnabled = false; // both radios failed — don't pretend we're jamming
oledNotify("RADIO FAIL", "Check connections");
} else {
oledNotify("JAMMING", "STARTED");
}
}
// Save new state
preferences.putBool("jamEnabled", jammingEnabled);
String json = "{\"enabled\":" + String(jammingEnabled ? "true" : "false") + "}";
server.send(200, "application/json; charset=utf-8", json);
}
// Boot jam is always ON; endpoint kept for old clients — ignores false, rewrites NVS true.
static void handleAutoStartJam() {
autoStartJam = true;
preferences.putBool("autoStartJam", true);
if (server.hasArg("plain")) {
String body = server.arg("plain");
if (body.indexOf("\"auto_start_jam\"") >= 0 && body.indexOf("false") >= 0) {
logLine("[HTTP] auto_start_jam=false ignored — boot jam is always ON");
}
}
server.send(200, "application/json; charset=utf-8",
"{\"success\":true,\"auto_start_jam\":true,\"note\":\"boot_jam_always_on\"}");
}
static void handleCaptureHistory() {
String j = "[";
for (uint8_t i = 0; i < capHistoryCount; i++) {
const CapHistoryEntry& e = capHistory[i];
if (i) j += ",";
j += "{\"uptime_ms\":" + String(e.uptime_ms) +
",\"freq\":" + String(e.freq_mhz, 3) +
",\"bits\":" + String(e.bits) +
",\"radio\":" + String((unsigned)e.radio_num) +
",\"mod\":\"" + String(e.is_ook ? "ook" : "fsk") + "\"}";
}
j += "]";
server.send(200, "application/json; charset=utf-8", j);
}
static void handleHealth() {
static char buf[160];
snprintf(buf, sizeof(buf),
"{\"ok\":true,\"uptime_ms\":%lu,\"heap\":%lu,\"ap_clients\":%d,"
"\"espnow_ok\":%s,\"espnow_peers\":%u}",
(unsigned long)(millis() - uptimeStart),
(unsigned long)ESP.getFreeHeap(),
(int)WiFi.softAPgetStationNum(),
s_espNowReady ? "true" : "false",
(unsigned)espNowActivePeerCount());
server.send(200, "application/json; charset=utf-8", buf);
}
// ─── Capture / replay HTTP handlers ──────────────────────────────────────────
static void handleCaptureStart() {
noInterrupts();
const CapMode cm = capMode;
interrupts();
if (cm == CapMode::RECORDING || cm == CapMode::REPLAYING) {
server.send(409, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"capture_busy\"}");
return;
}
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() {
noInterrupts();
const CapMode cm = capMode;
interrupts();
if (cm == CapMode::RECORDING || cm == CapMode::REPLAYING) {
server.send(409, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"capture_busy\"}");
return;
}
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"};
uint8_t m;
uint32_t currentIdx, recBitsSnap;
noInterrupts();
m = (uint8_t)capMode;
currentIdx = capIdx;
recBitsSnap = capRecBits;
interrupts();
static char buf[512];
int n = snprintf(buf, sizeof(buf),
"{\"mode\":%u,\"mode_str\":\"%s\","
"\"bits\":%lu,\"buf_bits\":%lu,\"rec_bits\":%lu,"
"\"pct\":%lu,\"freq\":%.3f",
(unsigned)m,
modeStr[m < 4 ? m : 0],
(unsigned long)currentIdx,
(unsigned long)(CAP_BUF_BYTES * 8),
(unsigned long)recBitsSnap,
(unsigned long)(currentIdx * 100UL / (CAP_BUF_BYTES * 8)),
(double)capFreq);
if (m == (uint8_t)CapMode::RECORDED || m == (uint8_t)CapMode::REPLAYING) {
if (recBitsSnap >= 100) {
uint32_t ones = 0, transitions = 0;
uint8_t prev = (capBuf[0] >> 0) & 1u;
for (uint32_t i = 1; i < recBitsSnap; 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) ? (recBitsSnap / transitions) : recBitsSnap;
n += snprintf(buf + n, sizeof(buf) - n,
",\"dur_ms\":%lu,\"transitions\":%lu,\"est_bps\":%lu,\"duty_pct\":%lu",
(unsigned long)(recBitsSnap * 1000 / CAP_SAMPLE_HZ),
(unsigned long)transitions,
(unsigned long)(avgRL > 0 ? CAP_SAMPLE_HZ / avgRL : 0),
(unsigned long)(ones * 100UL / recBitsSnap));
}
}
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() {
uint32_t nbits;
noInterrupts();
nbits = capRecBits;
interrupts();
if (!nbits) { server.send(200, "application/json", "[]"); return; }
static char waveBuf[1280];
const uint32_t N = 256;
const uint32_t bpp = max(1u, nbits / 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, nbits);
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");
}
static void handleSelfTest() {
noInterrupts();
const CapMode cm = capMode;
interrupts();
if (cm == CapMode::RECORDING || cm == CapMode::REPLAYING) {
server.send(409, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"capture_active\"}");
return;
}
runManualSelfTest();
const bool all = lastSelftestR1Pass && lastSelftestR2Pass;
String j = String("{\"ok\":true,\"r1_pass\":") + (lastSelftestR1Pass ? "true" : "false") +
",\"r2_pass\":" + (lastSelftestR2Pass ? "true" : "false") +
",\"all_pass\":" + (all ? "true" : "false") +
",\"ms\":" + String(lastSelftestDurationMs) + "}";
server.send(200, "application/json; charset=utf-8", j);
}
void setup() {
// Shorter delay for Serial to initialize on ESP32-S3 in production
Serial.begin(115200);
// OLED init — SW_I2C bit-bangs GPIO17/18 directly; no Wire needed.
// begin() always returns true for SW_I2C so just call it and force oledOk.
u8g2.begin();
u8g2.setContrast(255); // max brightness — some panels boot dim
oledOk = true;
Serial.println("[OLED] SW_I2C init done (GPIO17=SDA GPIO18=SCL)");
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_7x13_tf);
u8g2.drawStr(18, 22, "CC1101");
u8g2.drawStr(12, 38, "JAMMER");
u8g2.setFont(u8g2_font_5x7_tf);
u8g2.drawStr(14, 54, "ESP32-S3 BOOTING...");
u8g2.sendBuffer();
// Wait for Serial to be ready (timeout after 500ms for production)
unsigned long start = millis();
while (!Serial && (millis() - start) < 500) {
delay(10);
}
// Immediate debug output to verify boot
Serial.println("=== CAR-KEY-KILLER BOOT START ===");
Serial.flush();
uptimeStart = millis();
// Load preferences (TX power always max; no PA gain; no sweep)
preferences.begin("jammer4", false);
autoStartJam = true;
preferences.putBool("autoStartJam", true);
jamPowerIdx = DEFAULT_JAM_POWER_IDX;
jamPower = kPowerTable[jamPowerIdx];
preferences.putInt("jamPowerIdx", (int)jamPowerIdx);
capHistoryLoad();
jamMode = jamModeFromU8(preferences.getUChar("jamMode", 0));
logLine("[NVS] jam at boot: always ON | jamPower=" + String(jamPower) + " dBm | jam_mode=" +
String(jamModeToCstr(jamMode)));
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("Boot self-test...");
runBootSelfTest();
oledBootMsg("WiFi AP start...");
// Start WiFi AP
Serial.println("[DEBUG] Starting WiFi AP...");
Serial.flush();
WiFi.persistent(false);
WiFi.setSleep(false);
WiFi.mode(WIFI_MODE_AP);
WiFi.softAPdisconnect(true);
delay(100);
WiFi.softAPConfig(IPAddress(192, 168, 4, 1), IPAddress(192, 168, 4, 1), IPAddress(255, 255, 255, 0));
bool apOk = false;
for (int attempt = 1; attempt <= 5 && !apOk; ++attempt) {
if (strlen(WIFI_AP_PASS) == 0) {
apOk = WiFi.softAP(WIFI_AP_SSID, nullptr, 1, 0, 4);
} else {
apOk = WiFi.softAP(WIFI_AP_SSID, WIFI_AP_PASS, 1, 0, 4);
}
Serial.println("[DEBUG] WiFi.softAP attempt " + String(attempt) + ": " + (apOk ? "OK" : "FAILED"));
Serial.flush();
if (!apOk) {
delay(300);
}
}
Serial.println(String("[DEBUG] WiFi.softAP result: ") + (apOk ? "OK" : "FAILED"));
Serial.flush();
if (!apOk) {
logLine("[WIFI] softAP failed");
Serial.println("[ERROR] WiFi softAP failed");
Serial.flush();
}
delay(250);
IPAddress ip = WiFi.softAPIP();
logLine("[WIFI] AP started: " + String(WIFI_AP_SSID) + " IP: " + ip.toString());
Serial.println("[WIFI] AP SSID: " + String(WIFI_AP_SSID));
Serial.println("[WIFI] AP IP: " + ip.toString());
Serial.flush();
if (apOk) {
espNowInit();
} else {
logLine("[ESPNOW] skipped (AP not up — need WiFi channel for ESP-NOW)");
}
if (MDNS.begin("killer")) {
MDNS.addService("http", "tcp", WEB_PORT);
Serial.println("[MDNS] Started: http://killer.local");
} else {
Serial.println("[MDNS] Failed");
}
Serial.flush();
// Setup web server routes
server.on("/", handleRoot);
server.on("/api/log", handleLog);
server.on("/api/telemetry", handleTelemetry);
server.on("/api/health", handleHealth);
server.on("/api/toggle", HTTP_POST, handleToggle);
server.on("/api/auto_start_jam", HTTP_POST, handleAutoStartJam);
server.on("/api/capture/start", handleCaptureStart);
server.on("/api/capture/stop", handleCaptureStop);
server.on("/api/capture/replay", handleCaptureReplay);
server.on("/api/capture/status", handleCaptureStatus);
server.on("/api/capture/wave", handleCaptureWave);
server.on("/api/capture/history", HTTP_GET, handleCaptureHistory);
server.on("/api/selftest", HTTP_POST, handleSelfTest);
server.on("/api/jam_mode", HTTP_POST, handleJamMode);
server.on("/api/cc1101/registers", handleCc1101RegsRoute);
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();
jammingEnabled = true;
oledBootMsg("Radio init...");
startJamming();
if (radio1Status == 2 || radio2Status == 2) {
oledBootMsg("JAMMING - ACTIVE!");
} else {
oledBootMsg("RADIO INIT FAILED");
}
logLine("[JAM] Boot policy: jam ON after init (Stop Jamming in UI for capture)");
delay(800); // hold boot result on display briefly before switching to live pages
}
void loop() {
espNowTick();
ArduinoOTA.handle();
server.handleClient();
jamFloodTick();
oledTick();
yield();
const uint32_t now = millis();
// Capture state machine — runs in main loop (ISR sets flags, loop acts on them)
// Signal-present detection: fire OLED "SIGNAL!" once per session when
// the ISR has seen enough stable bits to indicate a real RF burst.
// capLongRuns > 10 filters out thermal noise which transitions almost constantly.
uint32_t currentLongRuns;
noInterrupts();
currentLongRuns = capLongRuns;
interrupts();
noInterrupts();
const CapMode capLoopMode = capMode;
interrupts();
if (capLoopMode == CapMode::RECORDING && !capSigNotified && currentLongRuns > 10) {
capSigNotified = true;
uint32_t currentIdx;
noInterrupts(); currentIdx = capIdx; interrupts();
oledNotify("SIGNAL!", "CAUGHT -- PRESS STOP", 3000);
logLine("[CAP] Signal detected: " + String(currentLongRuns) + " valid symbols @ bit " + String(currentIdx));
}
bool capFull = false;
noInterrupts();
if (capLoopMode == CapMode::RECORDING) capFull = capBufFull;
interrupts();
if (capLoopMode == CapMode::RECORDING && capFull) {
capTimerStop();
noInterrupts();
capRecBits = capIdx;
capBufFull = false;
capMode = CapMode::RECORDED;
interrupts();
gpio_set_direction(capGdoPin, GPIO_MODE_OUTPUT);
gpio_set_level(capGdoPin, 0);
logLine("[CAP] Buffer full: " + String(capRecBits) + " bits (" +
String(capRecBits * 1000 / CAP_SAMPLE_HZ) + " ms) captured");
oledNotify("CAPTURED", (String(capRecBits * 1000 / CAP_SAMPLE_HZ) + "ms").c_str());
if (capRecBits >= 100) {
capHistoryAppend(capRecBits, capFreq, capRadioNum, capIsOOK);
}
if (capPrevJamming) {
capPrevJamming = false;
jammingEnabled = true;
startJamming();
}
}
// Auto-reinit watchdog: if jamming should be active but a radio failed, retry every 30s
if (jammingEnabled && now - lastReInitCheck >= 30000) {
lastReInitCheck = now;
bool needReinit = (radio1Status != 2 || radio2Status != 2);
if (needReinit) {
logLine("[WDT] Radio failure detected, attempting reinit...");
oledNotify("RADIO REINIT", "R1 + R2...");
startJamming();
}
}
static uint32_t lastHeartbeat = 0;
if (now - lastHeartbeat >= 5000) {
lastHeartbeat = now;
const uint32_t freeHeap = ESP.getFreeHeap();
if (freeHeap < minFreeHeap) minFreeHeap = freeHeap;
// Low-heap protection: heap below 15 KB risks crash — reboot cleanly
if (freeHeap < 15360) {
logLine("[CRIT] Heap critical: " + String(freeHeap) + "B — rebooting");
delay(500);
ESP.restart();
}
// Temperature alarm: log once per minute if over threshold
const float tempC = temperatureRead();
if (tempC > 75.0f && now - lastTempWarnMs > 60000) {
lastTempWarnMs = now;
logLine("[WARN] High temp: " + String(tempC, 1) + "°C");
}
Serial.println("[HEARTBEAT] up=" + String(now - uptimeStart) + "ms heap=" +
String(freeHeap) + " minHeap=" + String(minFreeHeap) +
" temp=" + String(tempC, 1) + "°C" +
" hops=" + String(hopCount1) + "/" + String(hopCount2));
Serial.flush();
}
// Handle serial input for debugging
if (Serial.available()) {
String cmd = Serial.readStringUntil('\n');
cmd.trim();
if (cmd == "start") {
jammingEnabled = true;
startJamming();
} else if (cmd == "stop") {
stopJamming();
} else if (cmd == "status") {
Serial.println("Jamming: " + String(jammingEnabled ? "ON" : "OFF"));
Serial.println("Boot auto-jam: " + String(autoStartJam ? "ON" : "OFF"));
Serial.println("Power: " + String(jamPower) + " dBm (fixed max)");
Serial.println("R1 hw_ok=" + String(radio1Status != -1) + " st=" + String((int)radio1Status));
Serial.println("R2 hw_ok=" + String(radio2Status != -1) + " st=" + String((int)radio2Status));
}
}
}