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:
@@ -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
|
||||
|
||||
343
src/main.cpp
343
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
|
||||
</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 <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;
|
||||
|
||||
Reference in New Issue
Block a user