/**
 * 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 <DNSServer.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.
// Fifth arg (gpio) must be the wired GDO0: CC1101::transmit() waits on getGpio() for packet
// timing; RADIOLIB_NC makes digitalRead always 0 → TX timeout → flood jam silently did nothing.
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, CC1101_1_GDO0);
static Module mod2(&hal, CC1101_2_CS, CC1101_2_GDO0, RADIOLIB_NC, CC1101_2_GDO0);

CC1101 radio1(&mod1);
CC1101 radio2(&mod2);

static WebServer server(WEB_PORT);
static DNSServer dnsServer;
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, CW = 3, PULSE = 4, SPECIAL = 5 };
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";
    case JamMode::CW:       return "cw";
    case JamMode::PULSE:    return "pulse";
    case JamMode::SPECIAL:  return "special";
    default:                return "direct";
  }
}

static JamMode jamModeFromU8(uint8_t u) {
  if (u <= (uint8_t)JamMode::SPECIAL) 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;
// ISR must not read jamMode (non-atomic); noiseGenStart sets this for PULSE vs PRNG.
static volatile bool s_noiseIsPulse = 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
  pinMode(capGdoPin, 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();
  }

  // Drive GDO0 output low before switching CC1101 to async TX to avoid glitching
  pinMode(capGdoPin, OUTPUT);
  digitalWrite(capGdoPin, LOW);

  radio.transmitDirectAsync();  // GDO0 becomes data input to CC1101

  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
  pinMode(capGdoPin, OUTPUT);
  digitalWrite(capGdoPin, LOW);
  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);
  // SWRS061 §10.2: config regs (0x00-0x2E) use 0x80|addr (read, no burst).
  // Status regs (0x30-0x3D): burst bit MUST be set (0xC0|addr); burst=0 in this range
  // selects a command strobe instead of a register read.
  const uint8_t hdr = (regAddr6 >= 0x30u)
                      ? (uint8_t)(0xC0u | (regAddr6 & 0x3Fu))
                      : (uint8_t)(0x80u | (regAddr6 & 0x3Fu));
  spi.transfer(hdr);
  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;
  }
  // Default: inputs so begin() matches RadioLib; flood/packet TX will keep INPUT, direct jam sets OUTPUT later.
  pinMode(CC1101_1_GDO0, INPUT);
  pinMode(CC1101_2_GDO0, INPUT);

  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:
      case JamMode::SPECIAL:
        (void)radio1.setOOK(false);
        radio1.setFrequencyDeviation(JAM_FLOOD_DEV_R1_KHZ);
        break;
      case JamMode::CW:
        (void)radio1.setOOK(true);
        break;
      case JamMode::PULSE:
        (void)radio1.setOOK(false);
        radio1.setFrequencyDeviation(JAM_DEV_KHZ_R1_WIDE);
        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:
        (void)radio2.setOOK(false);
        radio2.setFrequencyDeviation(JAM_PRECISION_DEV_R2_KHZ);
        break;
      case JamMode::FLOOD:
      case JamMode::SPECIAL:
        (void)radio2.setOOK(false);
        radio2.setFrequencyDeviation(JAM_FLOOD_DEV_R2_KHZ);
        break;
      case JamMode::CW:
        (void)radio2.setOOK(true);
        break;
      case JamMode::PULSE:
      case JamMode::DIRECT:
      default:
        (void)radio2.setOOK(false);
        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 || jamMode == JamMode::SPECIAL) {
    logLine(String("[JAM] Mode: ") + jamModeToCstr(jamMode) + " (packet PRNG) @ " +
            String(JAM_LOCK_FREQ_1_MHZ, 2) + " / " + String(JAM_LOCK_FREQ_2_MHZ, 2) + " MHz");
    // Packet TX: RadioLib polls GDO0 for timing — must be MCU input (not OUTPUT from LFSR path).
    pinMode(CC1101_1_GDO0, INPUT);
    pinMode(CC1101_2_GDO0, INPUT);
    if (radio1Status == 1) radio1Status = 2;
    if (radio2Status == 1) radio2Status = 2;
    s_floodJamActive = (radio1Status == 2 || radio2Status == 2);
    s_noiseEn1 = false;
    s_noiseEn2 = false;
  } else if (jamMode == JamMode::CW) {
    logLine(String("[JAM] Mode: cw (unmodulated carrier) @ ") +
            String(JAM_LOCK_FREQ_1_MHZ, 2) + " / " + String(JAM_LOCK_FREQ_2_MHZ, 2) + " MHz");
    
    // Async TX with GDO0 held HIGH to generate a continuous wave in OOK mode
    pinMode(CC1101_1_GDO0, OUTPUT);
    pinMode(CC1101_2_GDO0, OUTPUT);
    digitalWrite(CC1101_1_GDO0, HIGH);
    digitalWrite(CC1101_2_GDO0, HIGH);

    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 = false;
    s_noiseEn2 = false;
  } else {
    if (jamMode == JamMode::PULSE) {
      s_jamLfsrKeyHz = JAM_PULSE_HZ;
    } 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");

    // Async TX: CC1101 samples modulator data from GDO0 — drive the pin before CMD_TX.
    pinMode(CC1101_1_GDO0, OUTPUT);
    pinMode(CC1101_2_GDO0, OUTPUT);
    digitalWrite(CC1101_1_GDO0, LOW);
    digitalWrite(CC1101_2_GDO0, LOW);

    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;
  }
  pinMode(CC1101_1_GDO0, INPUT);
  pinMode(CC1101_2_GDO0, INPUT);

  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;
  
  if (jamMode == JamMode::SPECIAL) {
    // Exact cypher-pulse clone: 60-byte payload, 10ms wait, blocking transmit.
    if ((uint32_t)(millis() - lastMs) < 10u) return;

    if (radio1Status == 2) {
      esp_fill_random(pkt, 60);
      (void)radio1.transmit(pkt, 60);
    }
    if (radio2Status == 2) {
      esp_fill_random(pkt, 60);
      (void)radio2.transmit(pkt, 60);
    }
  } else {
    // Flood: full transmit() — startTransmit() without finishTransmit() was aborting
    // each packet on the next tick (standby() at start of TX), so almost no on-air energy.
    if ((uint32_t)(millis() - lastMs) < 2u) return;

    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));
    }
  }
  
  lastMs = millis(); // record time AFTER transmit completes
}

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\":\"cw\"") >= 0) {
    newM = JamMode::CW;
  } else if (body.indexOf("\"mode\":\"pulse\"") >= 0) {
    newM = JamMode::PULSE;
  } else if (body.indexOf("\"mode\":\"special\"") >= 0) {
    newM = JamMode::SPECIAL;
  } 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() {
  if (s_noiseIsPulse) {
    static uint32_t pulseToggle = 0;
    pulseToggle ^= 1;
    if (s_noiseEn1) gpio_set_level((gpio_num_t)CC1101_1_GDO0, pulseToggle);
    else gpio_set_level((gpio_num_t)CC1101_1_GDO0, 0);
    if (s_noiseEn2) gpio_set_level((gpio_num_t)CC1101_2_GDO0, pulseToggle);
    else gpio_set_level((gpio_num_t)CC1101_2_GDO0, 0);
  } else {
    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

  s_noiseIsPulse = (jamMode == JamMode::PULSE);

  pinMode(CC1101_1_GDO0, OUTPUT);
  pinMode(CC1101_2_GDO0, OUTPUT);

  // Hardware timer at JAM_LFSR_KEY_HZ — true ISR, no jitter, no FreeRTOS overhead.
  // prescaler 80 → 1 MHz tick; alarm period = 1e6 / JAM_LFSR_KEY_HZ µs.
  const uint32_t noisePeriodUs = (1000000u + (s_jamLfsrKeyHz / 2u)) / s_jamLfsrKeyHz;
  s_noiseTimer = timerBegin(2, 80, true);              // timer 2, 1 MHz, count up
  timerAttachInterrupt(s_noiseTimer, &noiseISR, true); // edge triggered
  timerAlarmWrite(s_noiseTimer, noisePeriodUs, true);
  timerAlarmEnable(s_noiseTimer);
}

// Web server handlers
const char kHtml[] = R"HTML(
<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>CC1101 JAMMER</title><style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:#020504;color:#86f28a;font-family:'Courier New',monospace;font-size:12px;line-height:1.4}
body::after{content:'';position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9999;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,.08) 2px,rgba(0,0,0,.08) 4px);opacity:.6}
header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid #1a3a1e;background:linear-gradient(180deg,#040a06 0%,#030705 100%)}
h1{font-size:17px;letter-spacing:.12em;color:#a0f5a4;text-shadow:0 0 8px rgba(134,242,138,.4)}
.sub{font-size:10px;color:#4fbf59;margin-top:2px}
#progWrap{height:3px;background:#060e07}
#progBar{height:3px;background:linear-gradient(90deg,#2a8a2e,#86f28a,#2a8a2e);background-size:200% 100%;width:0;transition:width .8s linear;animation:progShimmer 3s linear infinite}
@keyframes progShimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
main{padding:10px;max-width:920px;margin:0 auto;display:flex;flex-direction:column;gap:8px}
.card{background:#030705;border:1px solid #1a3a1e;padding:10px;transition:border-color .3s}
.card:hover{border-color:#2a5a2e}
.card h2{font-size:10px;color:#4fbf59;letter-spacing:.12em;text-transform:uppercase;border-bottom:1px solid #122814;padding-bottom:5px;margin-bottom:8px}
.banner{padding:14px;text-align:center;border:2px solid #1a3a1e;transition:all .4s ease}
.bt{font-size:22px;font-weight:bold;letter-spacing:.18em}
.bs{font-size:11px;margin-top:5px}
.ban-on{border-color:#4fbf59;background:radial-gradient(ellipse at center,#0a1a0c 0%,#060f07 70%)}
.ban-off{border-color:#3a1218;background:#030504}
@keyframes pulse{0%,100%{box-shadow:0 0 6px rgba(134,242,138,.15)}50%{box-shadow:0 0 24px rgba(134,242,138,.5),0 0 60px rgba(134,242,138,.1)}}
.ban-on{animation:pulse 2.2s ease-in-out infinite}
.ban-on .bt{text-shadow:0 0 12px rgba(134,242,138,.6)}
.sg{display:grid;grid-template-columns:repeat(auto-fill,minmax(105px,1fr));gap:5px}
.s{background:#020504;border:1px solid #1a3a1e;padding:6px 8px;transition:border-color .2s,background .2s}
.s:hover{border-color:#2a5a2e;background:#040a06}
.sl{font-size:8px;color:#4fbf59;text-transform:uppercase;letter-spacing:.1em;white-space:nowrap}
.sv{font-size:15px;font-weight:bold;margin-top:2px;transition:color .3s}
.su{font-size:8px;color:#4fbf59;margin-left:1px}
.warm{color:#f5d87c!important}.hot{color:#f28a86!important}.lo{color:#f5d87c!important}.crit{color:#f28a86!important}
.band{margin-bottom:10px}
.bl{font-size:10px;color:#4fbf59;display:flex;justify-content:space-between;align-items:center;margin-bottom:3px}
canvas{display:block;width:100%}
canvas.sw{height:92px;border:1px solid #122814;background:#020504}
canvas.sp{height:44px;border:1px solid #122814;background:#020504}
.row{display:flex;flex-wrap:wrap;gap:10px}
.col{flex:1;min-width:140px}
.rrow{display:flex;gap:7px;align-items:center;margin-bottom:4px}
.dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;transition:all .3s}
.on{background:#86f28a;box-shadow:0 0 6px rgba(134,242,138,.7)}
@keyframes dotPulse{0%,100%{box-shadow:0 0 4px rgba(134,242,138,.5)}50%{box-shadow:0 0 10px rgba(134,242,138,.9)}}
.on{animation:dotPulse 1.5s ease-in-out infinite}
.off{background:#f28a86;box-shadow:0 0 4px rgba(242,138,134,.4)}
hr{border:none;border-top:1px solid #122814;margin:8px 0}
label{font-size:10px;color:#4fbf59;display:block;margin-bottom:3px}
input[type=range]{width:100%;accent-color:#86f28a;margin:2px 0}
input[type=number]{background:#020504;border:1px solid #1a3a1e;color:#86f28a;padding:3px 6px;font-family:inherit;font-size:11px;width:100%;transition:border-color .2s}
input[type=number]:focus{border-color:#4fbf59;outline:none}
select:focus{border-color:#4fbf59;outline:none}
button{padding:6px 11px;background:#0a1e0c;color:#86f28a;border:1px solid #2a5a2e;cursor:pointer;font-family:inherit;font-size:11px;letter-spacing:.04em;transition:all .15s}
button:hover{background:#142a16;box-shadow:0 0 8px rgba(134,242,138,.15)}
button:active{transform:scale(.97)}
button.jm{font-size:10px;padding:5px 10px}
button.jm-active{outline:2px solid #86f28a;box-shadow:0 0 10px rgba(134,242,138,.28)}
button.d{background:#140608;border-color:#4a1820;color:#f28a86}
button.d:hover{background:#200a10;box-shadow:0 0 8px rgba(242,138,134,.15)}
.err{color:#f28a86;font-size:10px;margin-top:2px}
pre{margin:0;padding:8px;background:#020504;border:1px solid #122814;height:28vh;overflow-y:auto;font-size:10px;line-height:1.6;color:#5fbf69}
pre::-webkit-scrollbar{width:4px}
pre::-webkit-scrollbar-track{background:#020504}
pre::-webkit-scrollbar-thumb{background:#1a3a1e;border-radius:2px}
#connDot{width:9px;height:9px;border-radius:50%;background:#f28a86;transition:background .3s,box-shadow .3s}
@keyframes bootIn{0%{opacity:0;filter:brightness(2) blur(2px)}100%{opacity:1;filter:brightness(1) blur(0)}}
body{animation:bootIn .6s ease-out}
@keyframes flicker{0%{opacity:1}3%{opacity:.4}6%{opacity:1}7%{opacity:.6}9%{opacity:1}100%{opacity:1}}
h1{animation:flicker .4s ease-out}
.sv{font-variant-numeric:tabular-nums}
.toast-wrap{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:10000;pointer-events:none}
#toast{background:#0a1e0c;border:1px solid #4fbf59;color:#a0f5a4;padding:7px 16px;font-size:11px;letter-spacing:.06em;opacity:0;transition:opacity .22s,transform .22s;transform:translateY(10px);box-shadow:0 4px 22px rgba(0,0,0,.55);max-width:90vw;text-align:center}
#toast.show{opacity:1;transform:translateY(0)}
textarea{background:#020504;border:1px solid #1a3a1e;color:#86f28a;padding:6px;font-family:inherit;font-size:10px;resize:vertical}
textarea:focus{border-color:#4fbf59;outline:none}
</style></head><body>
<div class="toast-wrap"><div id="toast" role="status" aria-live="polite"></div></div>
<header>
  <div>
    <div style="display:flex;align-items:center;gap:12px">
      <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#86f28a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="filter:drop-shadow(0 0 6px rgba(134,242,138,0.5))"><path d="M2 12h4l3-9 5 18 3-9h5"/></svg>
      <div>
        <h1>CC1101 JAMMER</h1>
        <div style="font-size:11px;color:#a0f5a4;font-style:italic;margin-top:1px;letter-spacing:0.08em;text-shadow:0 0 4px rgba(134,242,138,0.3)">"only tryan make a dollar"</div>
      </div>
      <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#86f28a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="filter:drop-shadow(0 0 6px rgba(134,242,138,0.5))"><path d="M2 12h4l3-9 5 18 3-9h5"/></svg>
    </div>
    <div class="sub" style="margin-top:5px">ESP32-S3 &bull; LOCK 315 MHz + LOCK 433.92 MHz &bull; Dual-carrier LFSR jam</div>
  </div>
  <div style="text-align:right;display:flex;align-items:center;gap:10px">
    <div>
      <div id="mission" style="font-size:9px;color:#4fbf59;margin-bottom:3px">—</div>
      <div style="font-size:8px;color:#1a3a1e;letter-spacing:.08em">v2.0 // LFSR+VCO</div>
    </div>
    <div id="connDot"></div>
  </div>
</header>
<div id="progWrap"><div id="progBar"></div></div>
<main>

<div class="banner ban-off" id="banner">
  <div class="bt" id="bt">INITIALIZING</div>
  <div class="bs" id="bs"></div>
</div>

<div class="card">
  <h2>System Metrics</h2>
  <div class="sg">
    <div class="s"><div class="sl">Uptime</div><div class="sv" id="mUp">—</div></div>
    <div class="s"><div class="sl">CC1101 TX (max)</div><div class="sv" id="mPow">—<span class="su">dBm</span></div></div>
    <div class="s"><div class="sl">ERP (chip only)</div><div class="sv" id="mEff">—<span class="su">dBm</span></div></div>
    <div class="s"><div class="sl">ERP Watts</div><div class="sv" id="mW">—<span class="su">mW</span></div></div>
    <div class="s"><div class="sl">R1 hardware</div><div class="sv" id="mR1h">—</div></div>
    <div class="s"><div class="sl">R2 hardware</div><div class="sv" id="mR2h">—</div></div>
    <div class="s"><div class="sl">Jam at boot</div><div class="sv" id="mBoot">always</div></div>
    <div class="s"><div class="sl">Boot test R1</div><div class="sv" id="mSt1" title="">—</div></div>
    <div class="s"><div class="sl">Boot test R2</div><div class="sv" id="mSt2" title="">—</div></div>
    <div class="s"><div class="sl">Boot test all</div><div class="sv" id="mStAll">—</div></div>
    <div class="s"><div class="sl">Boot test ms</div><div class="sv" id="mStMs">—</div></div>
    <div class="s"><div class="sl">Last test R1</div><div class="sv" id="mLst1" title="">—</div></div>
    <div class="s"><div class="sl">Last test R2</div><div class="sv" id="mLst2" title="">—</div></div>
    <div class="s"><div class="sl">Last test all</div><div class="sv" id="mLstAll">—</div></div>
    <div class="s"><div class="sl">Last test ms</div><div class="sv" id="mLstMs">—</div></div>
    <div class="s"><div class="sl">Last test @ up</div><div class="sv" id="mLstAt">—</div></div>
    <div class="s"><div class="sl">Temp</div><div class="sv" id="mTmp">—<span class="su">°C</span></div></div>
    <div class="s"><div class="sl">Free Heap</div><div class="sv" id="mH">—<span class="su">kB</span></div></div>
    <div class="s"><div class="sl">Min Heap</div><div class="sv" id="mMH">—<span class="su">kB</span></div></div>
    <div class="s"><div class="sl">AP Clients</div><div class="sv" id="mCl">—</div></div>
    <div class="s"><div class="sl">Nodes (ESP-NOW)</div><div class="sv" id="mEn">—</div></div>
  </div>
</div>

<div class="card">
  <h2>Locked jam carriers</h2>
  <div class="band">
    <div class="bl">
      <span><span class="dot on" id="d1"></span>&nbsp;Radio 1 — 315.000 MHz (NA)&nbsp;<small style="color:#2a6a2e">narrow FM + LFSR</small></span>
      <span id="f1c" style="color:#86f28a;font-weight:bold">—</span>
    </div>
    <canvas class="sw" id="c1"></canvas>
  </div>
  <div class="band">
    <div class="bl">
      <span><span class="dot on" id="d2"></span>&nbsp;Radio 2 — 433.920 MHz (EU/global)&nbsp;<small style="color:#2a6a2e">max FM noise + LFSR</small></span>
      <span id="f2c" style="color:#86f28a;font-weight:bold">—</span>
    </div>
    <canvas class="sw" id="c2"></canvas>
  </div>
</div>

<div class="card">
  <h2>2-Minute History</h2>
  <div class="row">
    <div class="col">
      <div class="bl"><span style="color:#4fbf59">Temperature (°C)</span><span id="tNow" style="font-weight:bold">—</span></div>
      <canvas class="sp" id="cT"></canvas>
    </div>
    <div class="col">
      <div class="bl"><span style="color:#4fbf59">Free Heap (kB)</span><span id="hNow" style="font-weight:bold">—</span></div>
      <canvas class="sp" id="cH"></canvas>
    </div>
  </div>
</div>

<div class="card">
  <h2>Radio Status</h2>
  <p class="sub" style="margin-bottom:8px">Boot: once per power-up before WiFi (SRES, RadioLib, VERSION). Last: most recent POST self-test (no reboot).</p>
  <div class="row">
    <div class="col">
      <div class="rrow"><span class="dot off" id="r1d"></span><strong>Radio 1 — 315 MHz LOCK</strong></div>
      <div id="r1boot" class="sub" style="color:#4fbf59">—</div>
      <div id="r1last" class="sub" style="color:#3a7a3e">—</div>
      <div id="r1s" class="sub">—</div><div id="r1e" class="err"></div>
    </div>
    <div class="col">
      <div class="rrow"><span class="dot off" id="r2d"></span><strong>Radio 2 — 433.92 MHz LOCK</strong></div>
      <div id="r2boot" class="sub" style="color:#4fbf59">—</div>
      <div id="r2last" class="sub" style="color:#3a7a3e">—</div>
      <div id="r2s" class="sub">—</div><div id="r2e" class="err"></div>
    </div>
  </div>
</div>

<div class="card">
  <h2>Controls</h2>
  <p style="font-size:10px;color:#3a7a3e;margin-bottom:8px">TX power is fixed at +10 dBm (CC1101 max, TI SWRS061). Radiated level includes your antenna gain only — no external PA.</p>
  <p style="font-size:10px;color:#4fbf59;margin-bottom:8px">Jamming starts automatically on every power-up. Press <strong>Stop Jamming</strong> to go standby for capture (REC), then start jam again when done.</p>
  <p style="font-size:10px;color:#3a7a3e;margin-bottom:6px"><strong>Jam strategy</strong> — Direct: wide LFSR + async TX. Precision: narrower deviation / key rate. Flood: bursty random packets (hypothesis for burst-sensitive links; verify in your chamber). CW: unmodulated carrier. Pulse: slow square wave (spoofs preamble AGC). Special: 60-byte blocking packet TX with 10ms delay (cypher-pulse clone).</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>
    <button type="button" class="jm" id="jmCw">CW</button>
    <button type="button" class="jm" id="jmPulse">Pulse</button>
    <button type="button" class="jm" id="jmSpecial">Special</button>
  </div>
  <div class="row" style="margin-bottom:10px">
    <div class="col" style="display:flex;flex-direction:column;gap:6px">
      <button id="tog">Start Jamming</button>
      <button id="runSelftest">Run radio self-test</button>
    </div>
  </div>
</div>

<div class="card">
  <h2>Signal Capture / Replay</h2>
  <div class="row" style="margin-bottom:8px">
    <div class="col">
      <label>Target Frequency (MHz)</label>
      <input type="number" id="capFreq" min="290" max="450" step="0.001" value="315.000" style="width:110px">
    </div>
    <div class="col">
      <label>Modulation</label>
      <select id="capMod" style="background:#020504;border:1px solid #1a3a1e;color:#86f28a;padding:3px 6px;font-family:inherit;font-size:11px">
        <option value="ook">OOK / ASK (90% of fobs)</option>
        <option value="fsk">2-FSK</option>
      </select>
    </div>
    <div class="col">
      <label>Radio</label>
      <select id="capRadio" style="background:#020504;border:1px solid #1a3a1e;color:#86f28a;padding:3px 6px;font-family:inherit;font-size:11px">
        <option value="1">Radio 1 (300-320 MHz)</option>
        <option value="2">Radio 2 (390-436 MHz)</option>
      </select>
    </div>
  </div>
  <div style="display:flex;gap:7px;flex-wrap:wrap;margin-bottom:8px">
    <button onclick="capStartRec()">REC</button>
    <button onclick="capStop()">STOP</button>
    <button onclick="capReplay()">REPLAY</button>
  </div>
  <div style="height:3px;background:#060e07;margin-bottom:8px"><div id="capProg" style="height:3px;background:#4fbf59;width:0;transition:width .3s linear"></div></div>
  <div class="sg" style="margin-bottom:8px">
    <div class="s"><div class="sl">State</div><div class="sv" id="capStat">IDLE</div></div>
    <div class="s"><div class="sl">Bits</div><div class="sv" id="capBits">—</div></div>
    <div class="s"><div class="sl">Duration</div><div class="sv" id="capDur">—</div></div>
    <div class="s"><div class="sl">Est Bitrate</div><div class="sv" id="capBps">—</div></div>
    <div class="s"><div class="sl">Duty Cycle</div><div class="sv" id="capDuty">—</div></div>
    <div class="s"><div class="sl">Cap Freq</div><div class="sv" id="capRecFreq">—</div></div>
  </div>
  <canvas id="capWave" class="sw" height="60" style="height:60px"></canvas>
  <h2 style="margin-top:12px;margin-bottom:6px">Capture history (NVS)</h2>
  <div style="overflow-x:auto;border:1px solid #122814">
    <table id="capHist" style="width:100%;font-size:10px;border-collapse:collapse">
      <thead><tr style="color:#4fbf59;text-align:left"><th style="padding:4px">#</th><th style="padding:4px">Time (uptime)</th><th style="padding:4px">MHz</th><th style="padding:4px">Bits</th><th style="padding:4px">Radio</th><th style="padding:4px">Mod</th></tr></thead>
      <tbody id="capHistBody"></tbody>
    </table>
  </div>
  <div style="font-size:9px;color:#2a6a2e;margin-top:5px">
    REC pauses jamming, RX via CC1101 GDO0 (SWRS061). STOP saves to history if ≥100 bits. REPLAY loops async TX. If jam is on, use Stop Jamming first or REC will pause it automatically.
  </div>
</div>

<div class="card">
  <h2>CC1101 registers (SmartRF-style)</h2>
  <p style="font-size:10px;color:#3a7a3e;margin-bottom:8px">Export configuration bytes for one radio, edit offline, paste hex and apply. TX pauses briefly during SPI write.</p>
  <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
    <button type="button" id="regExp1">Export R1</button>
    <button type="button" id="regExp2">Export R2</button>
  </div>
  <label>Apply to</label>
  <select id="regRadio" style="background:#020504;border:1px solid #1a3a1e;color:#86f28a;padding:3px 6px;font-family:inherit;font-size:11px;width:100%;max-width:220px;margin-bottom:6px">
    <option value="1">Radio 1</option>
    <option value="2">Radio 2</option>
  </select>
  <label>Hex (contiguous)</label>
  <textarea id="regHex" rows="3" spellcheck="false" placeholder="paste hex from export…"></textarea>
  <button type="button" id="regApply" style="margin-top:8px">Apply to radio</button>
</div>

<div class="card">
  <h2>System Log &nbsp;<button id="dl" style="font-size:9px;padding:2px 7px">Download</button></h2>
  <pre id="log"></pre>
</div>

<div style="text-align:center; padding: 20px 0; color: #3a7a3e; font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase;">
  rusian marks atm
</div>

</main><script>
let T={},lastP=Date.now();
const HS=120,hT=new Array(HS).fill(null),hH=new Array(HS).fill(null);
let hi=0;
const TL=50,tr1=[],tr2=[];
const MK1=[{f:303.825,l:'Honda'},{f:310,l:'Chmb'},{f:314.98,l:'Toyot'},{f:315,l:'Ford'},{f:318,l:'Line'}];
const MK2=[{f:390,l:'Lift'},{f:418,l:'Holt'},{f:433.42,l:'Somfy'},{f:433.92,l:'EU'},{f:434.42,l:'Nero'}];

function ff(f){return f?(+f).toFixed(4)+' MHz':'—'}
function fu(ms){const s=Math.floor(ms/1000),m=Math.floor(s/60),h=Math.floor(m/60),d=Math.floor(h/24);
  return d?`${d}d ${h%24}h ${m%60}m`:h?`${h}h ${m%60}m ${s%60}s`:`${m}m ${s%60}s`}
function ct(v){return v>80?'hot':v>65?'warm':''}
function ch(kb){return kb<30?'crit':kb<60?'lo':''}

function updTr(arr,norm){
  arr.forEach(t=>t.age++);
  while(arr.length&&arr[0].age>=TL)arr.shift();
  arr.push({x:Math.max(0,Math.min(1,norm)),age:0});
}

function drawSw(id,freq,ctr,span,active,trail,marks){
  const cv=document.getElementById(id);if(!cv)return;
  const W=cv.offsetWidth||400,H=92;cv.width=W;cv.height=H;
  const ctx=cv.getContext('2d');
  const lo=ctr-span/2,sp=Math.max(span,0.001);
  const tx=f=>Math.max(0,Math.min(W,(f-lo)/sp*W));
  ctx.fillStyle='#020504';ctx.fillRect(0,0,W,H);
  // Subtle grid
  ctx.strokeStyle='#0a180c';ctx.lineWidth=1;ctx.setLineDash([2,10]);
  for(let i=1;i<10;i++){const x=i/10*W;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H-14);ctx.stroke();}
  ctx.setLineDash([]);
  // Known freq markers
  marks.forEach(({f,l})=>{
    if(f<lo||f>lo+sp)return;
    const x=tx(f);
    ctx.strokeStyle='rgba(74,180,84,0.4)';ctx.lineWidth=1;ctx.setLineDash([3,4]);
    ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H-15);ctx.stroke();ctx.setLineDash([]);
    ctx.fillStyle='rgba(74,180,84,0.7)';ctx.font='8px monospace';ctx.textAlign='center';ctx.fillText(l,x,H-16);
  });
  // Heat trail
  trail.forEach(t=>{
    const a=(1-t.age/TL)*0.5,bw=55,bx=t.x*W;
    const g=ctx.createLinearGradient(bx-bw,0,bx+bw,0);
    g.addColorStop(0,'rgba(80,240,110,0)');
    g.addColorStop(.5,`rgba(80,240,110,${a})`);
    g.addColorStop(1,'rgba(80,240,110,0)');
    ctx.fillStyle=g;ctx.fillRect(bx-bw,0,bw*2,H-14);
  });
  if(active&&freq){
    const cx=tx(freq);
    // Wide glow
    const g=ctx.createLinearGradient(cx-80,0,cx+80,0);
    g.addColorStop(0,'rgba(134,242,138,0)');g.addColorStop(.5,'rgba(134,242,138,0.22)');g.addColorStop(1,'rgba(134,242,138,0)');
    ctx.fillStyle=g;ctx.fillRect(cx-80,0,160,H-14);
    // Cursor line with glow
    ctx.save();ctx.shadowColor='rgba(134,242,138,.6)';ctx.shadowBlur=6;
    ctx.strokeStyle='#86f28a';ctx.lineWidth=2;
    ctx.beginPath();ctx.moveTo(cx,0);ctx.lineTo(cx,H-14);ctx.stroke();
    ctx.restore();
    // Cursor dot with glow
    ctx.save();ctx.shadowColor='rgba(134,242,138,.8)';ctx.shadowBlur=10;
    ctx.fillStyle='#86f28a';ctx.beginPath();ctx.arc(cx,(H-14)/2,4,0,Math.PI*2);ctx.fill();
    ctx.restore();
    // Top notch
    ctx.fillStyle='#86f28a';ctx.fillRect(Math.max(0,cx-2),0,4,4);
  } else if(!active){
    ctx.strokeStyle='#2a1216';ctx.lineWidth=1;
    ctx.beginPath();ctx.moveTo(W/2,0);ctx.lineTo(W/2,H-14);ctx.stroke();
  }
  // Axis bar
  ctx.fillStyle='#0a180c';ctx.fillRect(0,H-14,W,14);
  ctx.fillStyle='#3a7a3e';ctx.font='9px monospace';
  ctx.textAlign='left';ctx.fillText(lo.toFixed(1)+' MHz',3,H-3);
  ctx.textAlign='right';ctx.fillText((lo+sp).toFixed(1)+' MHz',W-3,H-3);
  if(active&&freq){ctx.fillStyle='#86f28a';ctx.textAlign='center';ctx.fillText((+freq).toFixed(4)+' MHz',tx(freq),H-3);}
}

function drawSp(id,data,color,minH,maxH){
  const cv=document.getElementById(id);if(!cv)return;
  const W=cv.offsetWidth||280,H=44;cv.width=W;cv.height=H;
  const ctx=cv.getContext('2d');
  ctx.fillStyle='#020504';ctx.fillRect(0,0,W,H);
  const vd=data.filter(v=>v!==null);if(vd.length<2)return;
  const mn=minH??Math.min(...vd),mx=maxH??Math.max(...vd),rng=Math.max(mx-mn,0.5);
  const toY=v=>H-4-((v-mn)/rng*(H-8));
  // Fill
  ctx.beginPath();let fs=true;
  data.forEach((v,i)=>{if(v===null){fs=true;return;}const x=i/(HS-1)*W,y=toY(v);if(fs){ctx.moveTo(x,H-4);ctx.lineTo(x,y);fs=false;}else ctx.lineTo(x,y);});
  ctx.lineTo(W,H-4);ctx.closePath();
  const gf=ctx.createLinearGradient(0,0,0,H);gf.addColorStop(0,color+'30');gf.addColorStop(1,color+'05');
  ctx.fillStyle=gf;ctx.fill();
  // Line with glow
  ctx.save();ctx.shadowColor=color;ctx.shadowBlur=4;
  ctx.strokeStyle=color;ctx.lineWidth=1.5;ctx.beginPath();fs=true;
  data.forEach((v,i)=>{if(v===null){fs=true;return;}const x=i/(HS-1)*W,y=toY(v);if(fs){ctx.moveTo(x,y);fs=false;}else ctx.lineTo(x,y);});
  ctx.stroke();ctx.restore();
  // Range labels
  ctx.fillStyle=color+'90';ctx.font='8px monospace';
  ctx.textAlign='left';ctx.fillText(mn.toFixed(0),2,H-3);
  ctx.textAlign='right';ctx.fillText(mx.toFixed(0),W-2,H-3);
}

function applyTelemetry(t){
  T=t;
  lastP=Date.now();
  const pct=Math.min(100,t.uptime_ms/864000);
  document.getElementById('progBar').style.width=pct+'%';
  document.getElementById('mission').textContent=fu(t.uptime_ms)+' / 24h  ('+pct.toFixed(1)+'%)';
  const jam=t.jamming_enabled;
  document.getElementById('banner').className='banner '+(jam?'ban-on':'ban-off');
  document.getElementById('bt').textContent=jam?'◉ JAMMING ACTIVE ◉':'STANDBY';
  document.getElementById('bt').style.color=jam?'#86f28a':'#f28a86';
  const ok1=t.radio1_hw_ok===true||t.radio1_hw_ok==='true';
  const ok2=t.radio2_hw_ok===true||t.radio2_hw_ok==='true';
  const tx1=t.radio1_active===true||t.radio1_active==='true';
  const tx2=t.radio2_active===true||t.radio2_active==='true';
  const bst1=t.boot_st_r1_pass===true||t.boot_st_r1_pass==='true';
  const bst2=t.boot_st_r2_pass===true||t.boot_st_r2_pass==='true';
  const bstall=t.boot_st_all_pass===true||t.boot_st_all_pass==='true';
  const lran=t.last_st_ran===true||t.last_st_ran==='true';
  const lstall=t.last_st_all_pass===true||t.last_st_all_pass==='true';
  const parts=[];
  if(!bstall)parts.push('BOOT SELF-TEST FAIL');
  if(lran&&!lstall)parts.push('last self-test FAIL');
  if(!bst1)parts.push('R1 dead @boot');
  if(!bst2)parts.push('R2 dead @boot');
  if(!ok1)parts.push('R1 FAULT now');
  if(!ok2)parts.push('R2 FAULT now');
  if(jam&&(tx1||tx2)){
    const b=[];if(tx1)b.push('315 TX');if(tx2)b.push('433.92 TX');
    const jm=t.jam_mode_str||({0:'direct',1:'precision',2:'flood',3:'cw',4:'pulse',5:'special'}[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',3:'cw',4:'pulse',5:'special'}[t.jam_mode])||'direct').toLowerCase();
  [['jmDirect','direct'],['jmPrecision','precision'],['jmFlood','flood'],['jmCw','cw'],['jmPulse','pulse'],['jmSpecial','special']].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'));
document.getElementById('jmCw').addEventListener('click',()=>setJamMode('cw'));
document.getElementById('jmPulse').addEventListener('click',()=>setJamMode('pulse'));
document.getElementById('jmSpecial').addEventListener('click',()=>setJamMode('special'));

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() {
  server.setContentLength(CONTENT_LENGTH_UNKNOWN);
  server.send(200, "text/html; charset=utf-8", "");
  const size_t total = sizeof(kHtml) - 1;
  size_t off = 0;
  while (off < total) {
    size_t n = total - off;
    if (n > 512) n = 512;
    server.sendContent_P(kHtml + off, n);
    off += n;
    yield();
  }
  server.sendContent("");
}

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() {
  server.sendHeader("Location", "http://192.168.4.1/");
  server.send(302, "text/plain", "Redirect");
}

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();
  // Boot policy: CW — unmodulated carrier on both radios at 315 MHz, all TX power at exactly 315 MHz.
  jamMode = JamMode::CW;
  preferences.putUChar("jamMode", (uint8_t)jamMode);
  logLine("[NVS] jam at boot: always ON | jamPower=" + String(jamPower) + " dBm | jam_mode=" +
          String(jamModeToCstr(jamMode)) + " (default on power-up)");
  
  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();

  dnsServer.start(53, "*", WiFi.softAPIP());
  logLine("[DNS] Captive portal DNS on *:53 → " + WiFi.softAPIP().toString());

  // 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() {
  dnsServer.processNextRequest();
  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();
    pinMode(capGdoPin, OUTPUT);
    digitalWrite(capGdoPin, LOW);
    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));
    }
  }
}
