- 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
2738 lines
107 KiB
C++
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 • LOCK 315 MHz + LOCK 433.92 MHz • 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> Radio 1 — 315.000 MHz (NA) <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> Radio 2 — 433.920 MHz (EU/global) <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 <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));
|
|
}
|
|
}
|
|
}
|