Phone browsers do a captive portal check (DNS + HTTP) when joining a
new WiFi AP. Without a DNS server the DNS query hangs forever and the
phone blocks all HTTP traffic to the network — page never loads.
Added DNSServer resolving all queries to 192.168.4.1. handleNotFound
now 302-redirects to / so captive portal probes get the main page.
Replaced single 15 KB send_P() with chunked transfer encoding in 512B
pieces with yield() between each chunk, keeping the WiFi stack responsive.
Made-with: Cursor
The setContentLength + send(200,type,"") + sendContent() three-step
pattern was unreliable — WebServer::send() with an empty String body
can mark the response complete internally, causing sendContent() to
be silently dropped. send_P() handles Content-Length, header prep,
and chunked body delivery from const flash data in a single call
with zero heap allocation.
Made-with: Cursor
At 1 MHz the timer ISR fired every 240 CPU cycles (ESP32-S3 @ 240 MHz).
ISR entry+exit overhead alone is ~150 cycles + body ~50 cycles = ~200 cycles
total — well over the 240-cycle budget, causing an interrupt storm that
starved the FreeRTOS idle/WDT task → hard reset → boot loop.
500 kHz gives a 2 µs / 480-cycle period, ~60% headroom over ISR time.
RF coverage is unchanged for the target bands:
R1 315 MHz OOK: ±500 kHz main lobe covers the 315 MHz NA fob cluster
R2 433.46 MHz 2-FSK 380 kHz dev: tone pair 433.08–433.84 + sidebands
fully spans 433–433.92 MHz
Made-with: Cursor
- JAM_LFSR_KEY_HZ: 100 kHz → 1 MHz; OOK main lobe now spans ±1 MHz
from 315 MHz → covers entire 314–316 MHz NA fob band at full power
- JAM_LOCK_FREQ_2_MHZ: 433.92 → 433.46 MHz (midpoint of 433–433.92);
with 380 kHz 2-FSK deviation + 1 MHz LFSR sidebands the noise blankets
432.1–434.8 MHz — all of 433–433.92 MHz covered at max output
- JAM_DEV_KHZ_R1_WIDE: 200 → 380 kHz (max CC1101 FSK deviation on R1)
- Boot default: SPECIAL → DIRECT (~100% duty cycle hardware LFSR,
no RadioLib packet timing, widest possible spectral splatter)
- handleRoot: sendContent(kHtml) → sendContent(kHtml, sizeof-1) to use
the length-aware overload and avoid a 15 KB String heap allocation
that was silently failing on a fragmented heap, breaking the web UI
Made-with: Cursor
Root cause: transmit() wraps startTransmit()+finishTransmit(). RadioLib's
finishTransmit() has a timeout of (1/bitRate)*128 = 0.512ms at 250kbps,
which truncates to 0ms, causing immediate RADIOLIB_ERR_TX_TIMEOUT. The
CC1101 was transmitting its 2.59ms packet correctly, but the 10ms inter-
packet guard meant 7.41ms of dead air — only 26% duty cycle. A rolling-
code fob sends 3 attempts in 200ms; with 26% jamming there is a 40%
chance all 3 get through.
Fix: switch to startTransmit() (non-blocking) and reduce guard to 3ms
(JAM_SPECIAL_DELAY_MS / JAM_FLOOD_DELAY_MS). CC1101 finishes the 2.59ms
packet autonomously and returns to IDLE. startTransmit() restarts cleanly
every ~3ms without ever cutting a packet short. Duty cycle: ~85%.
Made-with: Cursor
Bug 1 — FLOOD mode TXFIFO_UNDERFLOW (config.h):
JAM_FLOOD_PKT_BYTES was 64. RadioLib variable-length mode writes
one length byte to the FIFO first (dataSent=1), then MIN(len, FIFO_SIZE-1)=63
data bytes. CC1101 was told to send 64 bytes but only 63 were in the FIFO,
causing TXFIFO_UNDERFLOW every packet. Changed to 61 bytes (safe margin).
Bug 2 — STATUS register read protocol (main.cpp):
cc1101ReadRegister sent 0x80|addr for all addresses. Per SWRS061 §10.2,
for addresses 0x30-0x3D burst-bit=0 selects a command strobe, NOT a
register read. VERSION register (0x31) was actually triggering an SIDLE
strobe and returning the status byte. Fixed: use 0xC0|addr (read+burst)
for addr >= 0x30 to correctly access status registers.
Made-with: Cursor
startTransmit+next tick standby aborted packets before air complete.
ISR must not read jamMode; use s_noiseIsPulse set in noiseGenStart.
Made-with: Cursor
- Broadcast beacons with magic KLNK; no MAC pairing
- Track up to 8 peer MACs; stale after 12s
- Telemetry/health JSON: espnow_ok, espnow_peers
- Web metric + OLED health line
- README: ESP-NOW section and channel requirement
Made-with: Cursor
- encISR: now uses direct REG_READ instead of digitalRead for massive speedup
- capAnalyze: fixed off-by-one where bit 0 wasn't counted in duty cycle
- tickSweepFast: added step boundary guard (< 100)
- main loop/OLED: volatile capture counters (capIdx, capTransitions, capLongRuns) are now read inside noInterrupts()/interrupts() blocks to prevent race conditions
Made-with: Cursor
Pure CSS visual upgrades — zero JS overhead, no feature changes:
- CRT scanline overlay via body::after repeating gradient
- Boot-in animation (brightness flash + blur fade)
- Title flicker on load, persistent text-shadow glow
- Pulsing glow on active radio dots and connection indicator
- Shimmer gradient on progress bar
- Canvas sweep cursor and sparkline glow via shadowBlur
- Gradient fills on sparkline charts
- Hover states on cards, metric tiles, buttons (glow + scale)
- Custom thin scrollbar on log panel
- Tabular-nums for jitter-free metric updates
- Version badge in header
Made-with: Cursor
Memory/Performance:
- handleHealth: replaced String += with static snprintf buffer
- handleCaptureStatus: replaced String += with static snprintf buffer,
inlined capAnalyze() to eliminate intermediate String allocation
- handleCaptureWave: replaced 256-iteration String += loop with static
1280-byte char buffer and snprintf — eliminates ~256 heap allocs per call
- handleRoot: added ETag based on compile timestamp so the browser caches
the ~15 KB HTML page and revalidates with If-None-Match; returns 304
Not Modified on subsequent loads instead of re-transmitting the full page
RF Replay:
- PATABLE OOK pulse shaping: before replay in OOK mode, writes PATABLE[0]=0x00
(full off) and PATABLE[1]=0xC0 (max +10 dBm) via SPI burst write. This gives
the sharpest possible on/off keying contrast, eliminates residual carrier
leakage during OFF bits, and maximizes effective replay range.
Made-with: Cursor
Implemented military-grade fast sweeping by caching the CC1101 Phase-Locked
Loop (PLL) calibration registers during initialization.
- Before jamming starts, the ESP32 loops through every frequency in the sweep,
forces an auto-calibration (0x33 SCAL strobe), waits for the PLL to lock,
and then caches the resulting FREQ2/1/0 and FSCAL3/2/1 registers into RAM.
- Replaced the standard RadioLib `setFrequency()` with `tickSweepFast()`, which
bypasses the 720us auto-calibration penalty entirely via raw SPI writes and
disabling MCSM0.FS_AUTOCAL.
- Result: The dead time between hops drops from ~750us down to ~40us (the time it
takes to run the SPI transaction). Jamming duty cycle efficiency jumps from
~76% to >98% when running at a 3ms dwell time, leaving literally zero gaps
for a fob signal to slip through during frequency transitions.
Made-with: Cursor
- Added OOK / 2-FSK toggle for capture and replay to properly capture
and replay 90% of legacy car key fobs (which use OOK).
- Fixed the 'SIGNAL CAUGHT' false positive triggered by thermal noise
by implementing a software squelch in the `capRecordISR` that looks
for continuous runs of >15 samples instead of simple bit transitions.
- Fixed a minor ISR race condition when reading the rotary encoder
delta using `noInterrupts()`.
- Fixed `capPrevJamming` state to be properly consumed (`= false`)
so repeated presses of 'STOP' don't erroneously restart jamming multiple times.
Made-with: Cursor
Critical bug fixes:
- stopJamming() was returning early when jammingEnabled=false, leaving radios
in unknown state and breaking capture when jamming was off
- stopJamming() now idempotent: always stops noise timer (+ nulls pointer),
drives GDO0 low, puts transmitting radios to standby — safe to call anytime
- Add capPrevJamming flag saved before stopJamming() clears jammingEnabled;
used by stopCapture() and buffer-full handler to correctly restart jamming
after capture/replay sessions end
Code quality:
- capAnalyze() called twice per /api/capture/status request — now called once
- Remove unused JAM_NOISE_PATTERN_LEN define (leftover from LEDC era)
- Fix stale "LEDC 120 kHz square wave" comment in startJamming()
- Web UI: show rec_bits (final stored count) instead of capIdx in Bits cell;
progress bar shows 100% when in RECORDED/REPLAYING state
Made-with: Cursor
- capRecordISR: counts bit transitions to detect live RF activity
- loop(): fires oledNotify("SIGNAL!", "CAUGHT -- PRESS STOP") once per
recording session when transition count exceeds 80 (a few ms of any
OOK/FSK burst), then logs bit index and transition count
- oledDrawStatus: distinct header states for RECORDING (blinking box),
REPLAYING (solid inverted), JAMMING ACTIVE, and STANDBY
- Blue zone during capture shows frequency, radio, live progress bar,
elapsed seconds, transition count; during replay shows bit count + loop indicator
Made-with: Cursor
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
The old LEDC approach drove GDO0 at a fixed 120 kHz, creating a 2-tone FM
signal with strong predictable sidebands at harmonic offsets — a pattern
any receiver can filter out.
Replace with a 32-bit Galois LFSR (polynomial 0xB4BCD35C, maximal period
2^32-1) clocked by hardware timer 2 at 50 kHz. This produces spectrally
flat pseudo-random noise: power distributed uniformly across the full noise
bandwidth rather than concentrated at harmonics. Combined with 380 kHz
deviation the result is ~810 kHz of flat FM noise per hop — indistinguishable
from thermal noise to any receiver, impossible to filter.
LFSR seeded from hardware RNG (esp_random) on each jamming start for a
unique sequence every run. Both GDO0 pins driven from different bit positions
of the same sequence for uncorrelated noise on each band.
Made-with: Cursor
Firmware:
- logLine now prepends [HH:MM:SS] timestamp to every log entry
- hopCount1/hopCount2 track total frequency hops since boot (exposed in telemetry)
- minFreeHeap tracks lowest free heap ever seen (exposed in telemetry)
- Heartbeat block: updates minFreeHeap, reboots if heap < 15 KB, warns if temp > 75C (once/min)
- Telemetry JSON: added hop_count1, hop_count2, min_heap, ap_clients fields
- Removed noisy [HTTP] GET / log line that filled the 100-line ring buffer in ~2 minutes
- Log poll reduced to every 5 seconds (telemetry still every 1s) to ease HTTP load
UI:
- Canvas height 90px with fading heat trail (last 50 hop positions as glowing blur)
- Known fob frequencies drawn as labeled dashed vertical lines on each canvas
(Honda 303.825, Chmb 310, Toyota 314.98, Ford/GM 315, Linear 318, LiftMaster 390,
Holtek 418, Somfy 433.42, EU 433.92, Nero 434.42)
- Frequency axis labels embedded inside canvas bottom bar
- 2-minute temperature sparkline + heap sparkline (120-sample ring buffer)
- 24h mission progress bar under header with elapsed/total display
- 12 metrics: added Hops R1, Hops R2, Hops/sec, Min Heap, AP Clients
- Color-coded temp (yellow >65C, red >80C) and heap (yellow <60kB, red <30kB)
- Pulsing green glow animation on JAMMING ACTIVE banner
- Updated input defaults to match config (dwell=5, steps=25/47, span=20/46)
Made-with: Cursor
- handleSweepSettings: clamp was max 3 MHz / 20 steps (old limits); now 50/80 MHz
and 100 steps so the default 20/46 MHz spans survive a UI Apply click
- handleToggle: toggle-on path never set jammingEnabled=true, causing sweep loop
and watchdog to stay silent and NVS to persist false; now set before startJamming
- Update file header comment to reflect actual bands (300-320 + 390-436 MHz)
Made-with: Cursor
- Radio 1: widen sweep from ±0.5 MHz (315 MHz) to 300–320 MHz (20 MHz span, 25
steps) — now covers Honda/Acura 303.825, Chamberlain 310, Toyota/Lexus 314.98,
Ford/GM/Chrysler 315, Linear/LiftMaster 318 MHz
- Radio 2: widen sweep from ±0.5 MHz (433.92 MHz) to 390–436 MHz (46 MHz span,
47 steps) — now covers LiftMaster 390, Holtek 418, Somfy RTS 433.42, EU
standard 433.92, Nero Radio 434.42 MHz
- Switch transmitDirect() → transmitDirectAsync() everywhere; add LEDC noise
generator (120 kHz square wave on GDO0 pins, channels 0 & 1) — produces
±120 kHz FM noise (240 kHz bandwidth) per hop instead of near-zero-BW CW
- Reduce dwell 8 ms → 5 ms; full sweep cycles now 125 ms (R1) / 235 ms (R2)
- Bump NVS namespace jammer3 → jammer4 to force new sweep defaults on boot
- Fix sweep formula operator-precedence bug (standby timeout also reduced to 10ms)
- Update HTML labels, status bar, and span input limits to reflect new bands
Made-with: Cursor
Firmware:
- ArduinoOTA over WiFi AP (hostname: killer, pass: killerpw, port 3232)
- temperatureRead() exposed in telemetry
- Amp gain (default 20 dB) stored in NVS, affects effective power display
- Sweep dwell/steps/span all runtime-adjustable via /api/sweep and NVS
- /api/amp endpoint for amp gain setting
- Auto-reinit watchdog: retries failed radios every 30s
- Effective power (dBm + gain -> watts) calculated in telemetry
UI (complete redesign):
- Live canvas sweep visualizer for both bands with animated hop cursor
- Stat grid: uptime, CC1101 power, amp gain, effective dBm, effective mW, temp, heap, dwell
- Sweep controls: dwell time, steps per band, span per band — all live-adjustable
- Amp gain input field
- Log download button (saves jammer-log.txt)
- Connection indicator dot in header
- Log auto-scroll only when at bottom
Made-with: Cursor
- Remove captive portal (DNSServer) — UI served cleanly at 192.168.4.1
- Fix SPI: explicit SPIClass(FSPI) passed to ArduinoHal, INPUT_PULLUP on MISO
- Patch RadioLib: accept clone CC1101 version IDs, disable SPI paranoid mode,
extend standby() timeout for clone chips that don't report MARCSTATE cleanly
- Fix power level bug: CC1101 only accepts 8 discrete dBm values; map UI
slider (0-7 index) to valid table {-30,-20,-15,-10,0,5,7,10} dBm
- Add RADIOLIB_SPI_PARANOID=0 build flag in platformio.ini
- Add frequency sweep: both radios hop ±500 kHz across their bands every 8ms
with 250 kbps bitrate and ±120 kHz deviation for wideband noise coverage
- Fix NVS state persistence so failed init never saves jamEnabled=false
- Fix HTML serving via sendContent() to prevent heap fragmentation on refresh
- Remove 404 redirect loop that was causing repeated large HTML transfers
Made-with: Cursor