From 055073fc9d810cf251b0c7eb369d887ed3039365 Mon Sep 17 00:00:00 2001 From: drjones Date: Wed, 11 Mar 2026 08:05:37 -0700 Subject: [PATCH] Add signal capture and replay feature Records raw demodulated CC1101 GDO0 output at 100 kHz into a 50 KB bit-packed static buffer (up to 4 seconds). Replay drives GDO0 in direct TX mode at the same sample rate, looping until stopped. - config.h: CAP_SAMPLE_HZ / CAP_DURATION_S / CAP_BUF_BYTES defines - main.cpp: capRecordISR / capReplayISR using hw_timer_t on timer 3 - main.cpp: startCapture / startReplay / stopCapture management functions - main.cpp: capAnalyze() estimates bitrate and duty cycle from transitions - main.cpp: five HTTP endpoints under /api/capture/* - main.cpp: loop() state machine auto-finalises buffer-full capture - kHtml: Capture / Replay card with freq input, radio selector, REC/STOP/REPLAY buttons, progress bar, stats grid, waveform canvas Made-with: Cursor --- include/config.h | 7 + src/main.cpp | 343 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 350 insertions(+) diff --git a/include/config.h b/include/config.h index 5754460..92493c5 100644 --- a/include/config.h +++ b/include/config.h @@ -84,4 +84,11 @@ #define ENC_CLK_PIN 14 #define ENC_DT_PIN 21 +// Signal capture / replay +// Samples GDO0 (CC1101 demodulated output) at CAP_SAMPLE_HZ during direct RX mode. +// Bit-packed into a static buffer. Replay drives GDO0 in direct TX mode at same rate. +#define CAP_SAMPLE_HZ 100000 // 100 kHz sample clock +#define CAP_DURATION_S 4 // max capture window (seconds) +#define CAP_BUF_BYTES ((CAP_SAMPLE_HZ * CAP_DURATION_S) / 8 + 8) // ~50 KB + #endif diff --git a/src/main.cpp b/src/main.cpp index bd449d0..c568028 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -156,11 +156,166 @@ static String jsonEscape(const String& in) { // Forward declarations static void noiseGenStart(); +static void startJamming(); +static void stopJamming(); +static void oledNotify(const char* l1, const char* l2, uint32_t dur); // ─── 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 uint32_t capRecBits = 0; // bits stored after recording +static float capFreq = 315.0f; // frequency at capture time +static uint8_t capRadioNum = 1; // 1 or 2 +static gpio_num_t capGdoPin = (gpio_num_t)CC1101_1_GDO0; +static hw_timer_t* capTimer = nullptr; + +// ─── Capture/replay ISRs ────────────────────────────────────────────────────── +static void IRAM_ATTR capRecordISR() { + const uint32_t i = capIdx; + if (i >= (uint32_t)(CAP_BUF_BYTES * 8)) { capBufFull = true; return; } + // Direct register read — 1-2 CPU cycles, safe from ISR + const uint8_t bit = (uint8_t)((REG_READ(GPIO_IN_REG) >> capGdoPin) & 1u); + 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) { + stopJamming(); + + capFreq = freq; + capRadioNum = radioNum; + capGdoPin = (radioNum == 1) ? (gpio_num_t)CC1101_1_GDO0 : (gpio_num_t)CC1101_2_GDO0; + capIdx = 0; + capBufFull = false; + capRecBits = 0; + memset(capBuf, 0, sizeof(capBuf)); + + CC1101& radio = (radioNum == 1) ? radio1 : radio2; + radio.standby(); + radio.setFrequency(freq); + radio.setFrequencyDeviation(JAM_FREQ_DEV_KHZ); + radio.receiveDirect(); // GDO0 becomes demodulated-data output from CC1101 + + // After receiveDirect, CC1101 drives GDO0 — set ESP32 pin as input to read it + gpio_set_direction(capGdoPin, GPIO_MODE_INPUT); + + capTimerStop(); + capMode = CapMode::RECORDING; + 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; } + + stopJamming(); + + 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; + radio.standby(); + radio.setFrequency(capFreq); + radio.setFrequencyDeviation(JAM_FREQ_DEV_KHZ); + radio.transmitDirectAsync(); // GDO0 becomes data input to CC1101 + + gpio_set_direction(capGdoPin, GPIO_MODE_OUTPUT); + + capTimerStop(); + capMode = CapMode::REPLAYING; + 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(); + if (capMode == CapMode::RECORDING) { + capRecBits = capIdx; + capMode = (capRecBits > 0) ? CapMode::RECORDED : CapMode::IDLE; + logLine("[CAP] Stopped: " + String(capRecBits) + " bits saved"); + oledNotify("CAPTURED", (String(capRecBits / 1000) + "k bits").c_str(), 2500); + } else if (capMode == CapMode::REPLAYING) { + capMode = CapMode::RECORDED; + logLine("[CAP] Replay stopped"); + oledNotify("REPLAY", "STOPPED", 2500); + } + // Restore pin directions then restart jamming + gpio_set_direction(capGdoPin, GPIO_MODE_OUTPUT); + gpio_set_level(capGdoPin, 0); + if (jammingEnabled) 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; + 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); +} + // Manually probe a CC1101 via raw SPI to verify bus connectivity. // Reads the VERSION register (0xF1 = burst read of reg 0x31). // Returns the raw byte, or 0xFF if bus appears dead. @@ -801,6 +956,41 @@ pre{margin:0;padding:8px;background:#020504;border:1px solid #122814;height:28vh +
+

Signal Capture / Replay

+
+
+ + +
+
+ + +
+
+
+ + + +
+
+
+
State
IDLE
+
Bits
+
Duration
+
Est Bitrate
+
Duty Cycle
+
Cap Freq
+
+ +
+ REC pauses jamming and records raw demodulated signal for 4s. REPLAY transmits the capture on loop at the original frequency. STOP resumes jamming. +
+
+

System Log  


@@ -999,6 +1189,80 @@ document.getElementById('asw').addEventListener('click',async()=>{try{await fetc
 document.getElementById('dl').addEventListener('click',()=>{const a=document.createElement('a');a.href='/api/log';a.download='jammer-log.txt';a.click();});
 
 poll();setInterval(poll,1000);
+
+// ── 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;
+  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);
+  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
 )HTML";
 
@@ -1191,6 +1455,66 @@ static void handleHealth() {
   server.send(200, "application/json; charset=utf-8", json);
 }
 
+// ─── Capture / replay HTTP handlers ──────────────────────────────────────────
+static void handleCaptureStart() {
+  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;
+  startCapture(freq, radio);
+  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() {
+  const uint8_t radio = server.hasArg("radio") ? (uint8_t)server.arg("radio").toInt() : 1;
+  startReplay(radio);
+  server.send(200, "application/json", "{\"status\":\"replaying\",\"bits\":" +
+              String(capRecBits) + ",\"freq\":" + String(capFreq, 3) + "}");
+}
+
+static void handleCaptureStatus() {
+  const String modeStr[] = {"idle","recording","recorded","replaying"};
+  const uint8_t m = (uint8_t)capMode;
+  String json = "{";
+  json += "\"mode\":" + String(m) + ",";
+  json += "\"mode_str\":\"" + modeStr[m < 4 ? m : 0] + "\",";
+  json += "\"bits\":" + String(capIdx) + ",";
+  json += "\"buf_bits\":" + String(CAP_BUF_BYTES * 8) + ",";
+  json += "\"rec_bits\":" + String(capRecBits) + ",";
+  json += "\"pct\":" + String((uint32_t)(capIdx * 100UL / (CAP_BUF_BYTES * 8))) + ",";
+  json += "\"freq\":" + String(capFreq, 3);
+  if (capMode == CapMode::RECORDED || capMode == CapMode::REPLAYING) {
+    json += "," + capAnalyze().substring(1, capAnalyze().length() - 1); // merge JSON fields
+  }
+  json += "}";
+  server.send(200, "application/json; charset=utf-8", json);
+}
+
+// Returns 256 data points (0-100 = % carrier-on) for waveform canvas rendering.
+static void handleCaptureWave() {
+  if (!capRecBits) { server.send(200, "application/json", "[]"); return; }
+  const uint32_t N = 256;
+  const uint32_t bpp = max(1u, capRecBits / N);  // bits per point
+  String json = "[";
+  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);
+    for (uint32_t b = start; b < end; b++) {
+      ones += (capBuf[b >> 3] >> (b & 7)) & 1u;
+    }
+    json += String(bpp > 0 ? ones * 100 / bpp : 0);
+    if (p < N - 1) json += ',';
+  }
+  json += ']';
+  server.send(200, "application/json", json);
+}
+
 static void handleNotFound() {
   const String uri = server.uri();
   logLine("[HTTP] 404 " + uri);
@@ -1337,6 +1661,11 @@ void setup() {
   server.on("/api/settings",      HTTP_POST, handleSettings);
   server.on("/api/sweep",         HTTP_POST, handleSweepSettings);
   server.on("/api/amp",           HTTP_POST, handleAmpSettings);
+  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.onNotFound(handleNotFound);
   server.begin();
 
@@ -1402,6 +1731,20 @@ void loop() {
 
   const uint32_t now = millis();
 
+  // Capture state machine — runs in main loop (ISR sets flags, loop acts on them)
+  if (capMode == CapMode::RECORDING && capBufFull) {
+    capTimerStop();
+    capRecBits = capIdx;
+    capBufFull = false;
+    capMode    = CapMode::RECORDED;
+    gpio_set_direction(capGdoPin, GPIO_MODE_OUTPUT);
+    gpio_set_level(capGdoPin, 0);
+    logLine("[CAP] Buffer full: " + String(capRecBits) + " bits (" +
+            String(capRecBits * 1000 / CAP_SAMPLE_HZ) + " ms) captured");
+    oledNotify("CAPTURED", (String(capRecBits * 1000 / CAP_SAMPLE_HZ) + "ms").c_str());
+    if (jammingEnabled) startJamming();
+  }
+
   // Auto-reinit watchdog: if jamming should be active but a radio failed, retry every 30s
   if (jammingEnabled && now - lastReInitCheck >= 30000) {
     lastReInitCheck = now;