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
This commit is contained in:
drjones
2026-03-11 08:05:37 -07:00
parent eb94d611c9
commit 055073fc9d
2 changed files with 350 additions and 0 deletions

View File

@@ -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

View File

@@ -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
</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>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>
<div style="font-size:9px;color:#2a6a2e;margin-top:5px">
REC pauses jamming and records raw demodulated signal for 4s. REPLAY transmits the capture on loop at the original frequency. STOP resumes jamming.
</div>
</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>
@@ -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<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;
if(d&&d.bits!==undefined)document.getElementById('capBits').textContent=d.bits.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.buf_bits?Math.round(d.bits*100/d.buf_bits):0;
document.getElementById('capProg').style.width=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";
@@ -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;