diff --git a/include/config.h b/include/config.h index 0df8049..5d46c74 100644 --- a/include/config.h +++ b/include/config.h @@ -25,7 +25,8 @@ #define WEB_PORT 80 // Jamming: max CC1101 TX (+10 dBm). External PA removed — radiated power is chip + antenna gain only. -#define DEFAULT_AUTO_START_JAM false // NVS key autoStartJam; do not jam until user enables or saves auto-start +// Jam always starts after boot self-test; use web “Stop Jamming” (or capture REC) to pause for RX/capture. +#define DEFAULT_AUTO_START_JAM true // CC1101 only accepts 8 discrete power levels (index 0-7); jam path always uses max (10 dBm). #define JAM_POWER_LEVELS 8 #define DEFAULT_JAM_POWER_IDX 7 // 10 dBm — full device output (see TI SWRS061 PATABLE / output power) @@ -34,12 +35,32 @@ #define JAM_BITRATE_KBPS 250.0f // baseband / channel filter context for RadioLib begin() #define JAM_FREQ_DEV_KHZ 380.0f // default passed to begin(); per-radio deviation applied after init #define JAM_RX_BW_KHZ 812.0f // wide RX BW for begin() +// GDO0 toggle rate during jam (1 MHz timer tick). Higher → wider AM/FM splatter on both carriers; 100 kHz typical. +#define JAM_LFSR_KEY_HZ 100000 // Fixed dual-carrier jamming: each radio holds one frequency at full TX power (TI CC1101 freq + deviation). +// Many NA ~315 MHz RKE remotes are ASK/OOK (see TI CC1101 datasheet MDMCFG2.MOD_FORMAT). Jam path uses OOK on +// R1 so LFSR on GDO0 keys the PA (broad AM sidebands). Set JAM_R1_USE_OOK 0 for 2-FSK only at JAM_DEV_KHZ_R1_WIDE. +// Fobs may sit on 314.8–315.2 MHz — measure with an SDR and retune JAM_LOCK_FREQ_1_MHZ if needed. #define JAM_LOCK_FREQ_1_MHZ 315.0f #define JAM_LOCK_FREQ_2_MHZ 433.92f #define JAM_DEV_KHZ_R2_WIDE 380.0f -#define JAM_DEV_KHZ_R1_NARROW 25.0f +#define JAM_DEV_KHZ_R1_NARROW 25.0f // probe / RadioLib begin() only +#define JAM_DEV_KHZ_R1_WIDE 200.0f // FSK jam spread when JAM_R1_USE_OOK is 0 (or OOK setup fails) +#define JAM_R1_USE_OOK 1 // 1 = R1 jam ASK/OOK (typical NA); 0 = wide 2-FSK on R1 + +// Precision jam: narrow 2-FSK on both + slower LFSR (energy in a smaller RF slice). +#define JAM_PRECISION_DEV_R1_KHZ 28.0f +#define JAM_PRECISION_DEV_R2_KHZ 55.0f +#define JAM_PRECISION_LFSR_HZ 40000 + +// Flood jam: packet-mode random payloads (mcore1976-style bursty TX); deviation for symbol spread. +#define JAM_FLOOD_PKT_BYTES 64 +#define JAM_FLOOD_DEV_R1_KHZ 140.0f +#define JAM_FLOOD_DEV_R2_KHZ 200.0f + +// SmartRF-style dump: config space only (TI SWRS061); PATABLE/ strobes not included. +#define CC1101_CFG_REG_LAST 0x2E // 0.96" SSD1306 OLED display — I2C via SW_I2C (any free GPIO) #define OLED_SDA_PIN 17 diff --git a/platformio.ini b/platformio.ini index c101a26..0d81407 100644 --- a/platformio.ini +++ b/platformio.ini @@ -27,3 +27,5 @@ board_build.extra_flags = -DBOARD_HAS_PSRAM monitor_speed = 115200 +; Avoid macOS picking Bluetooth “serial” instead of the ESP32 USB-UART +upload_port = /dev/cu.usbserial-A5069RR4 diff --git a/src/main.cpp b/src/main.cpp index b835a80..e2b8304 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -43,8 +43,26 @@ static bool jammingEnabled = false; static uint8_t jamPowerIdx = DEFAULT_JAM_POWER_IDX; static int8_t jamPower = kPowerTable[DEFAULT_JAM_POWER_IDX]; -// Auto-start jamming on power-up (NVS autoStartJam); independent of session toggle. -static bool autoStartJam = DEFAULT_AUTO_START_JAM; +// Policy: jam always starts after boot. NVS autoStartJam is forced true on each boot (no “boot jam off”). +static bool autoStartJam = true; + +enum class JamMode : uint8_t { DIRECT = 0, PRECISION = 1, FLOOD = 2 }; +static JamMode jamMode = JamMode::DIRECT; +static bool s_floodJamActive = false; +static uint32_t s_jamLfsrKeyHz = JAM_LFSR_KEY_HZ; + +static const char* jamModeToCstr(JamMode m) { + switch (m) { + case JamMode::PRECISION: return "precision"; + case JamMode::FLOOD: return "flood"; + default: return "direct"; + } +} + +static JamMode jamModeFromU8(uint8_t u) { + if (u <= (uint8_t)JamMode::FLOOD) return (JamMode)u; + return JamMode::DIRECT; +} // Individual radio status tracking static int8_t radio1Status = -1; // 0=init, 1=standby/idle OK, 2=transmitting, -1=error @@ -74,6 +92,22 @@ 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. @@ -268,8 +302,15 @@ 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; @@ -283,7 +324,7 @@ 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 uint32_t capRecBits = 0; // bits stored after recording +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 @@ -387,9 +428,6 @@ static void startCapture(float freq, uint8_t radioNum, bool isOOK) { capRadioNum = radioNum; capIsOOK = isOOK; capGdoPin = (radioNum == 1) ? (gpio_num_t)CC1101_1_GDO0 : (gpio_num_t)CC1101_2_GDO0; - capIdx = 0; - capBufFull = false; - capRecBits = 0; capTransitions = 0; capLongRuns = 0; capCurrentRun = 0; @@ -407,7 +445,12 @@ static void startCapture(float freq, uint8_t radioNum, bool isOOK) { gpio_set_direction(capGdoPin, GPIO_MODE_INPUT); capTimerStop(); - capMode = CapMode::RECORDING; + 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 @@ -426,7 +469,6 @@ static void startReplay(uint8_t radioNum) { capRadioNum = radioNum; capGdoPin = (radioNum == 1) ? (gpio_num_t)CC1101_1_GDO0 : (gpio_num_t)CC1101_2_GDO0; - capIdx = 0; CC1101& radio = (radioNum == 1) ? radio1 : radio2; const uint8_t csPin = (radioNum == 1) ? CC1101_1_CS : CC1101_2_CS; @@ -455,7 +497,10 @@ static void startReplay(uint8_t radioNum) { gpio_set_direction(capGdoPin, GPIO_MODE_OUTPUT); capTimerStop(); - capMode = CapMode::REPLAYING; + noInterrupts(); + capIdx = 0; + capMode = CapMode::REPLAYING; + interrupts(); capTimer = timerBegin(3, 80, true); timerAttachInterrupt(capTimer, &capReplayISR, true); timerAlarmWrite(capTimer, 1000000 / CAP_SAMPLE_HZ, true); @@ -469,16 +514,22 @@ static void startReplay(uint8_t radioNum) { static void stopCapture() { capTimerStop(); - if (capMode == CapMode::RECORDING) { + 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 (capMode == CapMode::REPLAYING) { - capMode = CapMode::RECORDED; + } else if (was == CapMode::REPLAYING) { logLine("[CAP] Replay stopped"); oledNotify("REPLAY", "STOPPED", 2500); } @@ -654,6 +705,57 @@ static void probeRadiosStandby() { 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"); @@ -703,7 +805,6 @@ static void startJamming() { } else { radio1Status = 1; radio1.setFrequency(JAM_LOCK_FREQ_1_MHZ); - radio1.setFrequencyDeviation(JAM_DEV_KHZ_R1_NARROW); } } @@ -732,42 +833,99 @@ static void startJamming() { } else { radio2Status = 1; radio2.setFrequency(JAM_LOCK_FREQ_2_MHZ); - radio2.setFrequencyDeviation(JAM_DEV_KHZ_R2_WIDE); } } jamFreq1 = JAM_LOCK_FREQ_1_MHZ; jamFreq2 = JAM_LOCK_FREQ_2_MHZ; - int stTx1 = RADIOLIB_ERR_NONE; - int stTx2 = RADIOLIB_ERR_NONE; - + // Per-mode modulation (freq already set) 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; + switch (jamMode) { + case JamMode::PRECISION: + (void)radio1.setOOK(false); + radio1.setFrequencyDeviation(JAM_PRECISION_DEV_R1_KHZ); + break; + case JamMode::FLOOD: + (void)radio1.setOOK(false); + radio1.setFrequencyDeviation(JAM_FLOOD_DEV_R1_KHZ); + break; + case JamMode::DIRECT: + default: +#if JAM_R1_USE_OOK + if (radio1.setOOK(true) != RADIOLIB_ERR_NONE) { + logLine("[R1] setOOK failed — falling back to wide 2-FSK jam"); + (void)radio1.setOOK(false); + radio1.setFrequencyDeviation(JAM_DEV_KHZ_R1_WIDE); + } else { + logLine("[JAM] R1 ASK/OOK + LFSR (direct mode)"); + } +#else + (void)radio1.setOOK(false); + radio1.setFrequencyDeviation(JAM_DEV_KHZ_R1_WIDE); +#endif + break; } } if (radio2Status == 1) { - stTx2 = radio2.transmitDirectAsync(); - if (stTx2 != RADIOLIB_ERR_NONE) { - radio2Status = -1; - radio2Error = "Transmit failed: " + String(stTx2); - logLine("[R2] transmitDirectAsync failed: " + String(stTx2)); - } else { - radio2Status = 2; + switch (jamMode) { + case JamMode::PRECISION: + radio2.setFrequencyDeviation(JAM_PRECISION_DEV_R2_KHZ); + break; + case JamMode::FLOOD: + radio2.setFrequencyDeviation(JAM_FLOOD_DEV_R2_KHZ); + break; + case JamMode::DIRECT: + default: + radio2.setFrequencyDeviation(JAM_DEV_KHZ_R2_WIDE); + break; } } - s_noiseEn1 = (radio1Status == 2); - s_noiseEn2 = (radio2Status == 2); - if (s_noiseEn1 || s_noiseEn2) { - noiseGenStart(); + s_floodJamActive = false; + int stTx1 = RADIOLIB_ERR_NONE; + int stTx2 = RADIOLIB_ERR_NONE; + + if (jamMode == JamMode::FLOOD) { + logLine(String("[JAM] Mode: flood (packet PRNG, ") + String(JAM_FLOOD_PKT_BYTES) + " B) @ " + + String(JAM_LOCK_FREQ_1_MHZ, 2) + " / " + String(JAM_LOCK_FREQ_2_MHZ, 2) + " MHz"); + if (radio1Status == 1) radio1Status = 2; + if (radio2Status == 1) radio2Status = 2; + s_floodJamActive = (radio1Status == 2 || radio2Status == 2); + s_noiseEn1 = false; + s_noiseEn2 = false; + } else { + s_jamLfsrKeyHz = (jamMode == JamMode::PRECISION) ? (uint32_t)JAM_PRECISION_LFSR_HZ : (uint32_t)JAM_LFSR_KEY_HZ; + logLine(String("[JAM] Mode: ") + jamModeToCstr(jamMode) + " | LFSR " + String(s_jamLfsrKeyHz) + " Hz on GDO0"); + + if (radio1Status == 1) { + stTx1 = radio1.transmitDirectAsync(); + if (stTx1 != RADIOLIB_ERR_NONE) { + radio1Status = -1; + radio1Error = "Transmit failed: " + String(stTx1); + logLine("[R1] transmitDirectAsync failed: " + String(stTx1)); + } else { + radio1Status = 2; + } + } + + if (radio2Status == 1) { + stTx2 = radio2.transmitDirectAsync(); + if (stTx2 != RADIOLIB_ERR_NONE) { + radio2Status = -1; + radio2Error = "Transmit failed: " + String(stTx2); + logLine("[R2] transmitDirectAsync failed: " + String(stTx2)); + } else { + radio2Status = 2; + } + } + + s_noiseEn1 = (radio1Status == 2); + s_noiseEn2 = (radio2Status == 2); + if (s_noiseEn1 || s_noiseEn2) { + noiseGenStart(); + } } if (radio1Status == 2 || radio2Status == 2) { @@ -787,6 +945,7 @@ static void startJamming() { static void stopJamming() { const bool wasActive = jammingEnabled; + s_floodJamActive = false; s_noiseEn1 = false; s_noiseEn2 = false; @@ -826,6 +985,163 @@ static void stopJamming() { if (wasActive) logLine("[JAM] Jamming stopped"); } +// Packet-flood jam: PRNG frames on both radios (shared SPI — sequential). +static void jamFloodTick() { + if (!s_floodJamActive || !jammingEnabled) return; + static uint8_t pkt[JAM_FLOOD_PKT_BYTES]; + static uint32_t lastMs; + const uint32_t now = millis(); + if ((uint32_t)(now - lastMs) < 2u) return; + lastMs = now; + + if (radio1Status == 2) { + esp_fill_random(pkt, sizeof(pkt)); + (void)radio1.transmit(pkt, sizeof(pkt)); + } + if (radio2Status == 2) { + esp_fill_random(pkt, sizeof(pkt)); + (void)radio2.transmit(pkt, sizeof(pkt)); + } +} + +static int hexNibble(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); + if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); + return -1; +} + +static void handleJamMode() { + if (!server.hasArg("plain")) { + server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"need_json\"}"); + return; + } + const String body = server.arg("plain"); + JamMode newM = JamMode::DIRECT; + if (body.indexOf("\"mode\":\"flood\"") >= 0) { + newM = JamMode::FLOOD; + } else if (body.indexOf("\"mode\":\"precision\"") >= 0) { + newM = JamMode::PRECISION; + } else if (body.indexOf("\"mode\":\"direct\"") >= 0) { + newM = JamMode::DIRECT; + } else { + server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"bad_mode\"}"); + return; + } + + jamMode = newM; + preferences.putUChar("jamMode", (uint8_t)jamMode); + logLine(String("[JAM] UI mode -> ") + jamModeToCstr(jamMode)); + + const bool wasJam = jammingEnabled; + if (wasJam) { + stopJamming(); + jammingEnabled = true; + startJamming(); + } + + String j = String("{\"ok\":true,\"mode\":\"") + jamModeToCstr(jamMode) + "\"}"; + server.send(200, "application/json; charset=utf-8", j); +} + +static void handleCc1101RegsGet() { + if (!server.hasArg("radio")) { + server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"radio\"}"); + return; + } + const int r = server.arg("radio").toInt(); + if (r != 1 && r != 2) { + server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"radio\"}"); + return; + } + const uint8_t cs = (r == 1) ? CC1101_1_CS : CC1101_2_CS; + String hex; + hex.reserve((CC1101_CFG_REG_LAST + 1u) * 2u + 4u); + for (uint8_t a = 0; a <= CC1101_CFG_REG_LAST; a++) { + const uint8_t v = cc1101ReadRegister(cs, a); + char pair[4]; + snprintf(pair, sizeof(pair), "%02X", (unsigned)v); + hex += pair; + } + server.send(200, "application/json; charset=utf-8", + String("{\"ok\":true,\"radio\":") + r + ",\"hex\":\"" + hex + "\"}"); +} + +static void handleCc1101RegsPost() { + int r = 0; + String hx; + if (server.hasArg("radio") && server.hasArg("hex")) { + r = server.arg("radio").toInt(); + hx = server.arg("hex"); + } else if (server.hasArg("plain")) { + const String p = server.arg("plain"); + if (p.indexOf("\"radio\":1") >= 0 || p.indexOf("\"radio\": 1") >= 0) r = 1; + else if (p.indexOf("\"radio\":2") >= 0 || p.indexOf("\"radio\": 2") >= 0) r = 2; + const int iq = p.indexOf("\"hex\""); + if (iq < 0) { + server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"missing_args\"}"); + return; + } + int q1 = p.indexOf('"', iq + 5); + q1 = p.indexOf('"', q1 + 1); + const int q2 = p.indexOf('"', q1 + 1); + if (q1 < 0 || q2 < 0) { + server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"hex_parse\"}"); + return; + } + hx = p.substring(q1 + 1, q2); + } else { + server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"missing_args\"}"); + return; + } + if (r != 1 && r != 2) { + server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"radio\"}"); + return; + } + hx.trim(); + hx.replace(" ", ""); + const size_t need = (size_t)(CC1101_CFG_REG_LAST + 1) * 2u; + if (hx.length() < need) { + server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"hex_len\"}"); + return; + } + uint8_t buf[64]; + for (size_t i = 0; i <= (size_t)CC1101_CFG_REG_LAST; i++) { + const int hi = hexNibble(hx[(unsigned)(i * 2)]); + const int lo = hexNibble(hx[(unsigned)(i * 2 + 1)]); + if (hi < 0 || lo < 0) { + server.send(400, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"hex_parse\"}"); + return; + } + buf[i] = (uint8_t)((unsigned)hi << 4 | (unsigned)lo); + } + + const bool wasJam = jammingEnabled; + if (wasJam) stopJamming(); + + const uint8_t cs = (r == 1) ? CC1101_1_CS : CC1101_2_CS; + for (uint8_t a = 0; a <= CC1101_CFG_REG_LAST; a++) { + spiWriteReg(cs, a, buf[a]); + } + logLine(String("[REG] SmartRF-style cfg import R") + String(r) + " (" + String(CC1101_CFG_REG_LAST + 1) + " bytes)"); + + if (wasJam) { + jammingEnabled = true; + startJamming(); + } + server.send(200, "application/json; charset=utf-8", "{\"ok\":true}"); +} + +static void handleCc1101RegsRoute() { + if (server.method() == HTTP_GET) { + handleCc1101RegsGet(); + } else if (server.method() == HTTP_POST) { + handleCc1101RegsPost(); + } else { + server.send(405, "text/plain", "Method Not Allowed"); + } +} + // ─── OLED functions ────────────────────────────────────────────────────────── // Queue a full-screen notification overlay for dur ms. @@ -873,9 +1189,14 @@ static void oledPageDots(uint8_t page) { // Blue zone (y16-63): 4 data lines with 5x7 font static void oledDrawStatus() { const bool jam = jammingEnabled; - const bool r1 = (radio1Status == 2); - const bool r2 = (radio2Status == 2); - const CapMode cm = capMode; + 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); @@ -928,8 +1249,13 @@ static void oledDrawStatus() { (unsigned)CAP_DURATION_S, (unsigned long)currentTransitions); } else { - // Replaying — show loop position - const uint32_t pct = capRecBits ? capIdx * 100 / capRecBits : 0; + // 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", @@ -941,23 +1267,31 @@ static void oledDrawStatus() { (double)temperatureRead(), (unsigned long)(ESP.getFreeHeap() / 1024)); u8g2.drawStr(0, 55, buf); } else { - // Normal jamming / standby display - // Row 1: ANT1 - if (r1) { - char buf[20]; - snprintf(buf, sizeof(buf), "1: %.3f MHz", (double)jamFreq1); - u8g2.drawStr(0, 24, buf); - oledDrawWaves(101, 19, nW); + // 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]"); } - // Row 2: ANT2 - if (r2) { - char buf[20]; - snprintf(buf, sizeof(buf), "2: %.3f MHz", (double)jamFreq2); - u8g2.drawStr(0, 33, buf); - oledDrawWaves(101, 28, nW); + 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]"); } @@ -970,7 +1304,7 @@ static void oledDrawStatus() { } // Row 4: temp + heap OR FULL TX badge - if (jam && r1 && r2) { + if (jam && r1Tx && r2Tx) { u8g2.drawStr(0, 55, "[ 315 + 433.92 LOCK ]"); } else { char buf[28]; @@ -1115,8 +1449,8 @@ static void oledTick() { // 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 50 kHz generates a maximal-length pseudo- -// random bit sequence (period 2^32-1 = ~23.8 hours at 50 kbps). The output +// 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. @@ -1152,11 +1486,12 @@ static void noiseGenStart() { gpio_set_direction((gpio_num_t)CC1101_1_GDO0, GPIO_MODE_OUTPUT); gpio_set_direction((gpio_num_t)CC1101_2_GDO0, GPIO_MODE_OUTPUT); - // Hardware timer at 50 kHz — true ISR, no jitter, no FreeRTOS overhead. - // prescaler 80 → 1 MHz tick, alarm at 20 = 20 µs period = 50 kHz. + // 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, 20, true); // 20 µs auto-reload + timerAlarmWrite(s_noiseTimer, noisePeriodUs, true); timerAlarmEnable(s_noiseTimer); } @@ -1215,6 +1550,8 @@ 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} @@ -1228,7 +1565,13 @@ 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} +
@@ -1266,7 +1609,16 @@ h1{animation:flicker .4s ease-out}
ERP Watts
mW
R1 hardware
R2 hardware
-
Jam on boot
+
Jam at boot
always
+
Boot test R1
+
Boot test R2
+
Boot test all
+
Boot test ms
+
Last test R1
+
Last test R2
+
Last test all
+
Last test ms
+
Last test @ up
Temp
°C
Free Heap
kB
Min Heap
kB
@@ -1309,13 +1661,18 @@ h1{animation:flicker .4s ease-out}

Radio Status

+

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

Radio 1 — 315 MHz LOCK
+
+
Radio 2 — 433.92 MHz LOCK
+
+
@@ -1324,11 +1681,17 @@ h1{animation:flicker .4s ease-out}

Controls

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

+

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

+

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

+
+ + + +
- - +
@@ -1378,10 +1741,27 @@ h1{animation:flicker .4s ease-out}
- REC pauses jamming, RX via CC1101 GDO0 (SWRS061). STOP saves to history if ≥100 bits. REPLAY loops async TX. If boot jam is OFF, capture works without fighting auto-TX. + REC pauses jamming, RX via CC1101 GDO0 (SWRS061). STOP saves to history if ≥100 bits. REPLAY loops async TX. If jam is on, use Stop Jamming first or REC will pause it automatically.
+
+

CC1101 registers (SmartRF-style)

+

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

+
+ + +
+ + + + + +
+

System Log  


@@ -1507,16 +1887,26 @@ function applyTelemetry(t){
   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(!ok1)parts.push('R1 FAULT');
-  if(!ok2)parts.push('R2 FAULT');
+  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');
-    parts.push(b.join(' + '));
+    const jm=t.jam_mode_str||({0:'direct',1:'precision',2:'flood'}[t.jam_mode])||'direct';
+    parts.push(jm+' | '+b.join(' + '));
   }
   document.getElementById('bs').textContent=parts.length?parts.join(' | ')+' | '+t.jam_power+' dBm chip (ERP + antenna only)'
-    :(jam?'Jamming on but no TX':'Standby — use REC when boot jam is OFF');
-  document.getElementById('bs').style.color=(jam&&(tx1||tx2))?'#86f28a':(!ok1||!ok2||jam)?'#f28a86':'#4fbf59';
+    :(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+'dBm';
@@ -1524,8 +1914,17 @@ function applyTelemetry(t){
   document.getElementById('mW').innerHTML=(t.eff_power_w*1000).toFixed(0)+'mW';
   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 bootOn=t.auto_start_jam===true||t.auto_start_jam==='true';
-  const bootEl=document.getElementById('mBoot');bootEl.textContent=bootOn?'ON':'OFF';bootEl.className='sv '+(bootOn?'':'lo');
+  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+'°C';tEl.className='sv '+ct(+t.temp_c);
   const hkb=t.free_heap/1024;const hEl=document.getElementById('mH');hEl.innerHTML=hkb.toFixed(0)+'kB';hEl.className='sv '+ch(hkb);
   const mhkb=t.min_heap/1024;document.getElementById('mMH').innerHTML=mhkb.toFixed(0)+'kB';
@@ -1540,8 +1939,13 @@ function applyTelemetry(t){
   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');};
-  sd('d1',tx1);sd('d2',tx2);sd('r1d',ok1);sd('r2d',ok2);
+  // 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||'';
@@ -1554,6 +1958,30 @@ function applyTelemetry(t){
   drawSp('cT',hT,'#86f28a',20,90);
   drawSp('cH',hH,'#4fbf59',0,320);
   const cd=document.getElementById('connDot');cd.style.background='#86f28a';cd.style.boxShadow='0 0 8px rgba(134,242,138,.7)';
+  const jmStr=(t.jam_mode_str||({0:'direct',1:'precision',2:'flood'}[t.jam_mode])||'direct').toLowerCase();
+  [['jmDirect','direct'],['jmPrecision','precision'],['jmFlood','flood']].forEach(([id,exp])=>{
+    const el=document.getElementById(id);if(!el)return;
+    el.classList.toggle('jm-active',jmStr===exp);
+  });
+}
+
+function showToast(msg){
+  const el=document.getElementById('toast');if(!el)return;
+  el.textContent=msg;
+  el.classList.add('show');
+  clearTimeout(showToast._t);
+  showToast._t=setTimeout(()=>el.classList.remove('show'),2200);
+}
+
+async function setJamMode(mode){
+  try{
+    const r=await fetch('/api/jam_mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode})});
+    const j=await r.json();
+    if(!j||!j.ok){showToast(j&&j.err?String(j.err):'Mode change failed');return;}
+    showToast('Mode: '+j.mode);
+    const tr=await fetch('/api/telemetry');
+    applyTelemetry(await tr.json());
+  }catch(e){showToast('Offline / error');}
 }
 
 let logTick=0;
@@ -1585,9 +2013,32 @@ async function poll(){
 }
 
 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('bootJamOff').addEventListener('click',async()=>{try{await fetch('/api/auto_start_jam',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({auto_start_jam:false})});applyTelemetry(await(await fetch('/api/telemetry')).json());}catch(e){}});
-document.getElementById('bootJamOn').addEventListener('click',async()=>{try{await fetch('/api/auto_start_jam',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({auto_start_jam:true})});applyTelemetry(await(await fetch('/api/telemetry')).json());}catch(e){}});
+document.getElementById('runSelftest').addEventListener('click',async()=>{try{const r=await fetch('/api/selftest',{method:'POST'});const j=await r.json();if(j&&j.ok===false&&j.err==='capture_active'){alert('Stop capture or replay first.');return;}applyTelemetry(await(await fetch('/api/telemetry')).json());}catch(e){}});
 document.getElementById('dl').addEventListener('click',()=>{const a=document.createElement('a');a.href='/api/log';a.download='jammer-log.txt';a.click();});
+document.getElementById('jmDirect').addEventListener('click',()=>setJamMode('direct'));
+document.getElementById('jmPrecision').addEventListener('click',()=>setJamMode('precision'));
+document.getElementById('jmFlood').addEventListener('click',()=>setJamMode('flood'));
+
+async function regExport(radio){
+  try{
+    const res=await fetch('/api/cc1101/registers?radio='+radio);
+    const j=await res.json();
+    if(j&&j.ok){document.getElementById('regHex').value=j.hex;document.getElementById('regRadio').value=String(radio);showToast('Exported R'+radio);}
+    else showToast(j&&j.err?String(j.err):'Export failed');
+  }catch(e){showToast('Export error');}
+}
+document.getElementById('regExp1').addEventListener('click',()=>regExport(1));
+document.getElementById('regExp2').addEventListener('click',()=>regExport(2));
+document.getElementById('regApply').addEventListener('click',async()=>{
+  const r=+document.getElementById('regRadio').value;
+  const hex=document.getElementById('regHex').value.replace(/\s+/g,'');
+  try{
+    const res=await fetch('/api/cc1101/registers',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({radio:r,hex})});
+    const j=await res.json();
+    if(j&&j.ok){showToast('Registers applied');const tr=await fetch('/api/telemetry');applyTelemetry(await tr.json());}
+    else showToast(j&&j.err?String(j.err):'Apply failed');
+  }catch(e){showToast('Apply error');}
+});
 
 poll();
 capRefreshHist();
@@ -1694,14 +2145,20 @@ static void handleTelemetry() {
 
   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[1400];
+  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,"
@@ -1710,6 +2167,20 @@ static void handleTelemetry() {
     "\"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,"
@@ -1731,6 +2202,8 @@ static void handleTelemetry() {
     (unsigned long)ESP.getFreeHeap(),
     (double)tempC,
     jammingEnabled ? "true" : "false",
+    (unsigned)(uint8_t)jamMode,
+    jamModeToCstr(jamMode),
     autoStartJam ? "true" : "false",
     (int)jamPower,
     (int)jamPowerIdx,
@@ -1738,6 +2211,20 @@ static void handleTelemetry() {
     (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,
@@ -1781,22 +2268,18 @@ static void handleToggle() {
   server.send(200, "application/json; charset=utf-8", json);
 }
 
-// POST {"auto_start_jam":true|false} — jam on AC power-up (TI init path unchanged).
+// 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");
-    const int p = body.indexOf("\"auto_start_jam\"");
-    if (p >= 0) {
-      int c = body.indexOf(':', p) + 1;
-      while (c < (int)body.length() && (body.charAt(c) == ' ' || body.charAt(c) == '\t')) c++;
-      autoStartJam = body.substring(c, c + 4) == "true";
-      preferences.putBool("autoStartJam", autoStartJam);
-      logLine("[NVS] autoStartJam=" + String(autoStartJam ? "true" : "false"));
-      oledNotify("BOOT JAM", autoStartJam ? "ON next power-up" : "OFF next power-up");
+    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",
-               String("{\"success\":true,\"auto_start_jam\":") + (autoStartJam ? "true" : "false") + "}");
+               "{\"success\":true,\"auto_start_jam\":true,\"note\":\"boot_jam_always_on\"}");
 }
 
 static void handleCaptureHistory() {
@@ -1829,6 +2312,13 @@ static void handleHealth() {
 
 // ─── 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;
@@ -1844,6 +2334,13 @@ static void handleCaptureStop() {
 }
 
 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);
@@ -1853,9 +2350,13 @@ static void handleCaptureReplay() {
 
 static void handleCaptureStatus() {
   static const char* const modeStr[] = {"idle","recording","recorded","replaying"};
-  const uint8_t m = (uint8_t)capMode;
-  uint32_t currentIdx;
-  noInterrupts(); currentIdx = capIdx; interrupts();
+  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\","
@@ -1865,26 +2366,26 @@ static void handleCaptureStatus() {
     modeStr[m < 4 ? m : 0],
     (unsigned long)currentIdx,
     (unsigned long)(CAP_BUF_BYTES * 8),
-    (unsigned long)capRecBits,
+    (unsigned long)recBitsSnap,
     (unsigned long)(currentIdx * 100UL / (CAP_BUF_BYTES * 8)),
     (double)capFreq);
 
-  if (capMode == CapMode::RECORDED || capMode == CapMode::REPLAYING) {
-    if (capRecBits >= 100) {
+  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 < capRecBits; i++) {
+      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) ? (capRecBits / transitions) : capRecBits;
+      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)(capRecBits * 1000 / CAP_SAMPLE_HZ),
+        (unsigned long)(recBitsSnap * 1000 / CAP_SAMPLE_HZ),
         (unsigned long)transitions,
         (unsigned long)(avgRL > 0 ? CAP_SAMPLE_HZ / avgRL : 0),
-        (unsigned long)(ones * 100UL / capRecBits));
+        (unsigned long)(ones * 100UL / recBitsSnap));
     }
   }
   snprintf(buf + n, sizeof(buf) - n, "}");
@@ -1894,16 +2395,20 @@ static void handleCaptureStatus() {
 // 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() {
-  if (!capRecBits) { server.send(200, "application/json", "[]"); return; }
+  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, capRecBits / N);
+  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, capRecBits);
+    const uint32_t end   = min(start + bpp, nbits);
     for (uint32_t b = start; b < end; b++) {
       ones += (capBuf[b >> 3] >> (b & 7)) & 1u;
     }
@@ -1922,6 +2427,23 @@ static void handleNotFound() {
   server.send(404, "text/plain", "404: Not found");
 }
 
+static void handleSelfTest() {
+  noInterrupts();
+  const CapMode cm = capMode;
+  interrupts();
+  if (cm == CapMode::RECORDING || cm == CapMode::REPLAYING) {
+    server.send(409, "application/json; charset=utf-8", "{\"ok\":false,\"err\":\"capture_active\"}");
+    return;
+  }
+  runManualSelfTest();
+  const bool all = lastSelftestR1Pass && lastSelftestR2Pass;
+  String j = String("{\"ok\":true,\"r1_pass\":") + (lastSelftestR1Pass ? "true" : "false") +
+             ",\"r2_pass\":" + (lastSelftestR2Pass ? "true" : "false") +
+             ",\"all_pass\":" + (all ? "true" : "false") +
+             ",\"ms\":" + String(lastSelftestDurationMs) + "}";
+  server.send(200, "application/json; charset=utf-8", j);
+}
+
 void setup() {
   // Shorter delay for Serial to initialize on ESP32-S3 in production
   Serial.begin(115200);
@@ -1955,13 +2477,15 @@ void setup() {
   
   // Load preferences (TX power always max; no PA gain; no sweep)
   preferences.begin("jammer4", false);
-  autoStartJam = preferences.getBool("autoStartJam", DEFAULT_AUTO_START_JAM);
+  autoStartJam = true;
+  preferences.putBool("autoStartJam", true);
   jamPowerIdx  = DEFAULT_JAM_POWER_IDX;
   jamPower     = kPowerTable[jamPowerIdx];
   preferences.putInt("jamPowerIdx", (int)jamPowerIdx);
   capHistoryLoad();
-  logLine("[NVS] autoStartJam=" + String(autoStartJam ? "true" : "false") +
-          " jamPower=" + String(jamPower) + " dBm (fixed max, TI PATABLE)");
+  jamMode = jamModeFromU8(preferences.getUChar("jamMode", 0));
+  logLine("[NVS] jam at boot: always ON | jamPower=" + String(jamPower) + " dBm | jam_mode=" +
+          String(jamModeToCstr(jamMode)));
   
   logLine("[BOOT] CC1101 Key-Fob Jammer starting");
   logLine("[BOOT] ESP32-S3 DevKitC-1");
@@ -1995,7 +2519,9 @@ void setup() {
           " 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
@@ -2063,6 +2589,9 @@ void setup() {
   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();
 
@@ -2078,22 +2607,15 @@ void setup() {
   Serial.println("[HTTP] Server started on port " + String(WEB_PORT));
   Serial.flush();
   
-  if (autoStartJam) {
-    jammingEnabled = true;
-    oledBootMsg("Radio init...");
-    startJamming();
-    if (radio1Status == 2 || radio2Status == 2) {
-      oledBootMsg("JAMMING - ACTIVE!");
-    } else {
-      oledBootMsg("RADIO INIT FAILED");
-    }
+  jammingEnabled = true;
+  oledBootMsg("Radio init...");
+  startJamming();
+  if (radio1Status == 2 || radio2Status == 2) {
+    oledBootMsg("JAMMING - ACTIVE!");
   } else {
-    jammingEnabled = false;
-    oledBootMsg("Probe CC1101...");
-    probeRadiosStandby();
-    oledBootMsg("Standby — boot jam OFF");
-    logLine("[JAM] Boot jam disabled; radios probed (standby). Use web or toggle to TX.");
+    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
 }
 
@@ -2101,6 +2623,7 @@ void loop() {
   espNowTick();
   ArduinoOTA.handle();
   server.handleClient();
+  jamFloodTick();
   oledTick();
   yield();
 
@@ -2116,7 +2639,11 @@ void loop() {
   currentLongRuns = capLongRuns;
   interrupts();
   
-  if (capMode == CapMode::RECORDING && !capSigNotified && currentLongRuns > 10) {
+  noInterrupts();
+  const CapMode capLoopMode = capMode;
+  interrupts();
+
+  if (capLoopMode == CapMode::RECORDING && !capSigNotified && currentLongRuns > 10) {
     capSigNotified = true;
     uint32_t currentIdx;
     noInterrupts(); currentIdx = capIdx; interrupts();
@@ -2124,13 +2651,18 @@ void loop() {
     logLine("[CAP] Signal detected: " + String(currentLongRuns) + " valid symbols @ bit " + String(currentIdx));
   }
 
-  if (capMode == CapMode::RECORDING && capBufFull) {
+  bool capFull = false;
+  noInterrupts();
+  if (capLoopMode == CapMode::RECORDING) capFull = capBufFull;
+  interrupts();
+
+  if (capLoopMode == CapMode::RECORDING && capFull) {
     capTimerStop();
     noInterrupts();
     capRecBits = capIdx;
-    interrupts();
     capBufFull = false;
     capMode    = CapMode::RECORDED;
+    interrupts();
     gpio_set_direction(capGdoPin, GPIO_MODE_OUTPUT);
     gpio_set_level(capGdoPin, 0);
     logLine("[CAP] Buffer full: " + String(capRecBits) + " bits (" +