Every Cheap Radio Is an SDR Now. Baseline Your Bench Before You Believe One.
A TinySA sweep logger, a CC1101 RSSI cross-check, and a diff script that tells you which spikes belong to your own room. Two days ago Hackaday ran a write-up of Logan Zarov turning an LG D325 Android phone into a wideba
A TinySA sweep logger, a CC1101 RSSI cross-check, and a diff script that tells you which spikes belong to your own room.
Two days ago Hackaday ran a write-up of Logan Zarov turning an LG D325 Android phone into a wideband receiver. Root, LineageOS, a modem firmware change that exposes the baseband stream, an antenna wire, and the Bluetooth radio pressed into service as a local oscillator so the thing can hear down into shortwave. A month earlier it was the ESP32's Wi-Fi block coughing up I/Q samples, then an ESP32-C5 pulling analog drone video out of 5 GHz.
The pattern is clear. Every radio chip in the junk drawer has an SDR hiding inside it, waiting for someone patient enough to find the debug path.
The part nobody posts is the boring half. A hacked receiver with an unknown front end, unknown filtering and an oscillator borrowed from a different radio will show you signals. Some of them will be real. Plenty will be its own spurs, the USB hub's switching noise, your monitor's backlight, and the LED driver in the ceiling. Before you trust anything a clever receiver tells you, you need a reference picture of your own bench, taken with an instrument whose behavior is documented.
That reference is cheap to build. Here is the one I would set up before plugging in anything exotic.
The Kit and Why Each Piece Is There
Three pieces of hardware, each with a job.
The reference instrument is a tinySA Ultra [AFFILIATE: tinySA Ultra]. It is a small swept spectrum analyzer with a USB serial console, a documented command set and a calibrated input. Calibrated matters more than sensitive here. You want a tool whose lies are written down.
The second opinion is a CC1101 module [AFFILIATE: CC1101 433 MHz SPI module] wired to an ordinary ESP32-WROOM dev board [AFFILIATE: ESP32-WROOM-32 DevKit]. The CC1101 is a sub-GHz transceiver from TI with a readable RSSI register. It has nothing in common with the tinySA's front end, which is exactly the point. When two unrelated receivers agree a signal exists, it probably does.
The third piece is a 20 dB SMA attenuator [AFFILIATE: SMA 20dB attenuator 2W]. Every spectrum analyzer has a maximum input level, and the cheapest way to kill one is a strong nearby transmitter coupled straight into the port. The attenuator also gives you a trick for spotting fake signals, covered below.
Everything here is receive-only. The CC1101 can transmit; nothing in this build asks it to.
Sweep the Room With the TinySA Over Serial
The tinySA speaks a NanoVNA-style serial console. Plug it in, and on Linux it enumerates as a CDC device, usually /dev/ttyACM0. Commands end with a carriage return, the device echoes the command, prints its output, and finishes with a ch> prompt.
The command that does the work is scan:
scan 430000000 440000000 450 2
That sweeps 430 to 440 MHz in 450 points. The final argument is an output mask; a value of 2 asks for the measured level of each point, one value per line, in dBm. The community TinySA_Python library uses the same call and parses the first whitespace-separated token of each line as the level. It also documents a firmware quirk worth guarding against: some early-2024 builds emit malformed numbers like -:.000000e+01, which the library patches before parsing.
Frequencies for each point are derived from start, stop and point count rather than read back, because the frequencies command reports the last screen sweep and follows the RBW setting, not the point count you asked for.
The logger below takes a burst of sweeps, keeps the per-bin median and per-bin maximum, and writes a baseline CSV. Install pyserial first.
#!/usr/bin/env python3
"""tsa_baseline.py: record or compare a tinySA spectrum baseline."""
import argparse
import csv
import statistics
import time
import serial
def command(port, line, timeout=30.0):
port.reset_input_buffer()
port.write((line + "\r").encode())
buf = b""
deadline = time.time() + timeout
while not buf.endswith(b"ch> ") and time.time() < deadline:
buf += port.read(port.in_waiting or 1)
lines = buf.decode(errors="replace").splitlines()
return [l.strip() for l in lines[1:] if l.strip() and not l.startswith("ch>")]
def parse_level(token):
token = token.replace("-:.0", "-10.0").replace(":.0", "10.0")
return float(token)
def sweep(port, start, stop, points):
rows = command(port, f"scan {start} {stop} {points} 2")
levels = [parse_level(r.split()[0]) for r in rows]
step = (stop - start) / (points - 1)
return [(start + i * step, lvl) for i, lvl in enumerate(levels)]
def record(args, port):
runs = []
for n in range(args.sweeps):
runs.append(sweep(port, args.start, args.stop, args.points))
print(f"sweep {n + 1}/{args.sweeps} done")
time.sleep(args.gap)
with open(args.file, "w", newline="") as fh:
out = csv.writer(fh)
out.writerow(["freq_hz", "median_dbm", "max_dbm"])
for i in range(len(runs[0])):
levels = [r[i][1] for r in runs]
out.writerow([int(runs[0][i][0]),
round(statistics.median(levels), 1),
round(max(levels), 1)])
print(f"baseline written to {args.file}")
def compare(args, port):
with open(args.file) as fh:
base = [(float(r["freq_hz"]), float(r["max_dbm"]))
for r in csv.DictReader(fh)]
now = sweep(port, args.start, args.stop, args.points)
hot = [(f, lvl, lvl - b) for (f, lvl), (_, b) in zip(now, base)
if lvl - b >= args.margin]
for f, lvl, delta in hot:
print(f"{f / 1e6:10.4f} MHz {lvl:6.1f} dBm +{delta:4.1f} dB over baseline max")
if not hot:
print("nothing above baseline max + margin")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("mode", choices=["record", "compare"])
ap.add_argument("--port", default="/dev/ttyACM0")
ap.add_argument("--start", type=int, default=430_000_000)
ap.add_argument("--stop", type=int, default=440_000_000)
ap.add_argument("--points", type=int, default=450)
ap.add_argument("--sweeps", type=int, default=30)
ap.add_argument("--gap", type=float, default=2.0)
ap.add_argument("--margin", type=float, default=6.0)
ap.add_argument("--file", default="baseline.csv")
args = ap.parse_args()
with serial.Serial(args.port, 115200, timeout=0.5) as port:
print(command(port, "version"))
record(args, port) if args.mode == "record" else compare(args, port)
if __name__ == "__main__":
main()
Run it like this:
$ pip install pyserial
$ python3 tsa_baseline.py record --sweeps 30 --file bench_433_night.csv
$ python3 tsa_baseline.py compare --file bench_433_night.csv --margin 6
The design choice that matters is comparing against the baseline maximum, not the median. Your room is not quiet. It breathes. A switching supply that wanders in and out every few seconds will show up in some sweeps and not others, and a median-based comparison flags it every time it reappears. Comparing against the worst the room did during the baseline means the script only speaks up when something exceeds the room's own bad behavior by your margin.
Record more than one baseline. Night and day differ. So does "laptop on charger" versus "laptop on battery." Label the files honestly and keep them.
The Attenuator Test for Signals That Aren't There
Swept analyzers built from mixers generate images and spurs, and cheap ones generate more. The tinySA documentation spends real time on this, and the firmware has settings aimed at it. You can also catch a fake signal with nothing but the attenuator and arithmetic.
Take a sweep with the antenna connected directly. Insert the 20 dB attenuator and sweep again. A real external signal drops by 20 dB, give or take the attenuator's tolerance. A spur generated inside the instrument by a strong signal overloading the mixer does something different: intermodulation products fall faster than the signals that create them, so a third-order product drops by roughly three times the attenuation. Internal spurs that come from the analyzer's own oscillators barely move at all.
That gives you a sorting rule:
drops ~20 dB -> external signal, keep it
drops far more -> overload product, your front end is lying under load
barely moves -> internal spur, belongs to the instrument
Run compare against an attenuated baseline and an unattenuated one, and the differences tell you which entries in your hot list are worth chasing. This same test works on the rooted phone, the ESP32 SDR, or any receiver whose filtering you can't see. It's the cheapest calibration move in radio.
A Second Opinion From the CC1101 Registers
A spectrum analyzer gives you the shape. The CC1101 gives you a vote from a completely different signal chain. The goal is a slow RSSI sweep across the same band you baselined, printed as CSV, so you can line it up with the tinySA results.
The register facts come straight from the CC1101 definitions ESPHome ships: the frequency word lives in FREQ2/FREQ1/FREQ0 at 0x0D to 0x0F, RSSI is status register 0x34, VERSION is 0x31, the strobes are SRES 0x30, SCAL 0x33, SRX 0x34, SIDLE 0x36, the read bit is 0x80, burst is 0x40, the crystal is 26 MHz, and the RSSI step is 0.5 dB with a typical offset of 74.
Status registers share addresses with strobes, which trips up everybody once. Reading 0x34 with only the read bit set sends the SRX strobe. You must set read and burst together, 0x34 | 0xC0, to get the RSSI value back.
#include <SPI.h>
const int PIN_CS = 5; // VSPI defaults on ESP32-WROOM: SCK 18, MISO 19, MOSI 23
const int PIN_MISO = 19;
const double XTAL_HZ = 26e6;
void select() {
digitalWrite(PIN_CS, LOW);
while (digitalRead(PIN_MISO)) {} // CC1101 pulls SO low when its crystal is stable
}
void release() { digitalWrite(PIN_CS, HIGH); }
void strobe(uint8_t cmd) { select(); SPI.transfer(cmd); release(); }
void writeReg(uint8_t addr, uint8_t val) {
select(); SPI.transfer(addr); SPI.transfer(val); release();
}
uint8_t readStatus(uint8_t addr) {
select(); SPI.transfer(addr | 0xC0); uint8_t v = SPI.transfer(0); release();
return v;
}
void tune(double hz) {
uint32_t word = (uint32_t)((hz * 65536.0) / XTAL_HZ);
strobe(0x36); // SIDLE
writeReg(0x0D, (word >> 16) & 0xFF);
writeReg(0x0E, (word >> 8) & 0xFF);
writeReg(0x0F, word & 0xFF);
strobe(0x33); // SCAL: calibrate synthesizer at the new frequency
delay(1);
strobe(0x34); // SRX
delay(3); // let RSSI settle
}
float rssiDbm() {
uint8_t raw = readStatus(0x34);
float v = (raw >= 128) ? (raw - 256) / 2.0 : raw / 2.0;
return v - 74.0;
}
void setup() {
Serial.begin(115200);
pinMode(PIN_CS, OUTPUT);
release();
SPI.begin();
SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
strobe(0x30); // SRES
delay(10);
uint8_t ver = readStatus(0x31);
Serial.printf("# CC1101 VERSION=0x%02X\n", ver);
if (ver == 0x00 || ver == 0xFF) Serial.println("# check wiring, SPI is not talking");
Serial.println("freq_hz,rssi_dbm");
}
void loop() {
for (double f = 433.05e6; f <= 434.79e6; f += 25e3) {
tune(f);
Serial.printf("%.0f,%.1f\n", f, rssiDbm());
}
delay(2000);
}
At 433.92 MHz the frequency word works out to 0x10B071, which you will recognize from half the CC1101 configs on GitHub. If your output shows that tuning landing somewhere else, your crystal is not 26 MHz or your arithmetic is off.
Three honest limitations. The reset-default channel filter is roughly 200 kHz wide, so stepping at 25 kHz oversamples and smears narrow carriers across several bins. The 74 dB offset is a typical figure; TI's datasheet lists offsets that shift with data rate, so treat absolute dBm values as approximate and trust the relative shape. And the spin-wait on MISO is how TI's own timing diagram says to wait for chip-ready, but a dead module will hang the loop there forever. Add a timeout before you leave it unattended.
What you want from the CC1101 run is agreement in position and relative height with the tinySA hot list. A carrier the tinySA reports at 433.92 that the CC1101 also sees rising above its floor is real. A tinySA spike the CC1101 never notices, which also failed the attenuator test, belongs to the analyzer.
Reading the Room Like Evidence
Once you have the logs, the work becomes bookkeeping, and bookkeeping is where most RF hobbyists quit. Write down the time, the antenna, the attenuator state, which devices were powered, and where you were standing. A baseline without that metadata is a picture of nothing in particular.
The questions you can answer with this setup are narrower than the internet would like. You can say a signal was present at a frequency, at roughly a level, at a time, seen by two unrelated receivers. You cannot say what device made it or who owns that device. Detection is where an investigation starts, and every honest RF guide says so in some form.
If the measurement side is where you want to go deeper, the RF Inspection for Beginners: TinySA Spectrum Baseline Guide ($45) is the next step after the script above. It covers instrument setup and calibration before interpretation, picking RBW, sweep speed and max-hold for the specific question you are asking, comparing repeated observations, and documenting time, location, equipment and uncertainty so a sweep turns into a record. That last part is exactly what the Python skips.
If the CC1101 half grabbed you, the registers go a long way past FREQ and RSSI. Bandwidth, deviation, data rate and the AGC all change what that RSSI number means. The CC1101 Deep Dive: Register-Level RF Lab Guide ($35) works through registers, modulation, timing, deviation and noise-floor behavior on ESP32 hardware, receive-only, with a bench workflow for comparing presets against measured parameters. It's the natural follow-on once you want the second-opinion receiver tuned for the signal instead of left on reset defaults.
Then Go Play With the Clever Receivers
Now the phone hacks and ESP32 SDRs get interesting, because you have something to measure them against. Point the hacked receiver at the same band, run the same sweep schedule, and compare its hot list to your two references. Every peak it reports that your references can't confirm is a fact about the hacked receiver, its oscillator, its filtering, its firmware. That's a better result than a pretty waterfall. It is a characterization, and characterizations are what turn a clever hack into a usable instrument.
The receiver that impresses you on day one is rarely the one you trust on day thirty. The one you trust is the one whose lies you have already written down.
Sources: Hackaday, "From Phone, To SDR, No Extra Hardware" (Nov 9, 2026); Hackaday ESP32 tag (ESP32-C5 5 GHz drone video, Oct 20, 2026; ESP32 SDR frequency limits, Oct 15, 2026); LC-Linkous/TinySA_Python README; ESPHome cc1101defs.h; TI CC1101 product page.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.