An open GPS jamming detector that costs about as much as a takeaway.
GNSS interference over the Baltic has stopped being an occasional curiosity and become a standing condition. Aircraft report it, ships report it, and the reports are largely anecdotal because almost nobody on the ground is measuring. Savethesat is a small device that measures — and enough of them measure at once to say where.
Flash a device from your browser →
A Seeed XIAO ESP32-C5 with one or two u-blox GNSS receivers. It watches the receivers' own interference telemetry, scores it against a baseline it learns for itself, and serves a dashboard over its own Wi-Fi. Power it from a USB bank and put it on a windowsill, a mast or a dashboard.
- Detects interference from five independent signals, and shows its working
- Self-baselining — no site survey, no calibration, no magic numbers
- Phone dashboard over its own AP, with your mobile data still working
- OLED status screen when one is fitted, headless when it is not
- ESP-NOW mesh so several nodes can be worked as a network
- Browser flasher — no toolchain, no IDE, no Arduino install
- Receive only. It never transmits in a GNSS band.
- Flash a board from the web flasher (desktop Chrome or Edge — Web Serial doesn't exist on phones).
- Wire a u-blox module to GPIO 12/11. Wiring →
- Power it up, join Wi-Fi
Savethesat-XXXX/savethesat. - Open
http://192.168.4.1/. - Leave it alone for a minute while it learns what quiet looks like.
The access point offers no internet uplink and runs no captive portal, and this is deliberate rather than a missing feature. Your phone tests the network, finds no uplink, keeps mobile data as its default route for everything else — and still routes the AP's own subnet over Wi-Fi. Dashboard and Instagram at the same time.
Any attempt to be clever here — a fake portal, an offered default route — is precisely what would break your cellular connection.
| Signal | Why it matters |
|---|---|
| u-blox jamming indicator | The receiver's own interference estimate |
| C/N0 collapse | Satellites still visible, carrier-to-noise gone |
| AGC level | Falls as input power rises — something loud arrived |
| Noise per ms | Broadband noise floor at the front end |
| Fix lost, sats visible | Separates jamming from simply having no sky |
Weighted into a 0–100 score: CLEAR → ELEVATED → JAMMED → DENIED. Every
contribution is shown separately on the dashboard, because a verdict you cannot
audit is a verdict nobody should act on.
Full detail, including what this cannot do: docs/DETECTION.md
| Part | Notes |
|---|---|
| Seeed XIAO ESP32-C5 | Any ESP32-C5 board |
| u-blox GNSS ×1–2 | Must be u-blox — M10/M9N ideal, M8N fine |
| USB power bank | Runs all day |
The one thing that will bite you: cheap non-u-blox modules (ATGM336H / AT6558, generic MTK) emit NMEA only and have no jamming indicator, no AGC and no noise figure — three of the five signals. Savethesat runs on them from C/N0 collapse and fix loss, renormalising the score so the upper levels stay reachable, but it warns later and false-alarms more. What you give up →
If one is already soldered down, adding a u-blox on the second port is a better move than replacing it: full signals from one, cross-check from both.
No external libraries — the NMEA and UBX parsing is in-tree, so there is nothing to install beyond the ESP32 core.
arduino-cli core install esp32:[email protected]
arduino-cli compile --fqbn "esp32:esp32:esp32c5:CDCOnBoot=cdc" firmware/savethesat_c5
arduino-cli upload -p /dev/ttyACM0 --fqbn "esp32:esp32:esp32c5:CDCOnBoot=cdc" firmware/savethesat_c5Pushing to main builds the firmware, merges the image and redeploys the
flasher automatically.
The USB console emits one complete JSON object per line, once a second —
the verdict, both receivers' detection state and link plumbing, per-constellation
counts, the full sky view, position, access point state, free heap and any mesh
peers. The same object is served at /api/all.
# watch it live
python3 -c "import serial;p=serial.Serial('/dev/ttyACM0',115200);[print(p.readline().decode().strip()) for _ in iter(int,1)]"
# or straight into jq
cat /dev/ttyACM0 | jq -c '{t,level,score,a:.a.link.bytes,b:.b.link.bytes}'
# log a session to file
cat /dev/ttyACM0 > session.ndjsonEach line also carries a hw block: chip model and revision, CPU clock, SDK,
MAC, flash and PSRAM size, reset reason, die temperature, heap free/min/
largest-block, the I2C bus contents found at boot, and the live logic level of
every GNSS UART pin.
Read
rxLevelcarefully. The UART driver pulls its receive pin up, so1is the resting default and a disconnected pin reads exactly like a healthy idle one. Only0is informative — something is actively holding that line down. To tell "attached" from "not attached", usebytes.
Newline-delimited JSON, so it appends cleanly, greps usefully and replays into
anything. The interval is SERIAL_JSON_MS in config.h; set it to 0 to go
quiet.
One caveat: the ESP-ROM bootloader prints a few plain-text lines at power-on before the firmware runs, and a panic would too. Skip lines that do not parse rather than assuming every line is ours.
- One device detects; it does not locate. Finding a jammer needs motion or several nodes. The ESP-NOW link is there to make the second possible.
- Two receivers do not give a bearing. Two antennas centimetres apart cannot resolve direction at L1. What the second one buys you is a reference channel and a cross-check.
- No signature analysis. Telling a chirp jammer from broadband noise needs real spectrum, which needs an SDR, which needs a host — a Raspberry Pi base station, one per network, not per sensor. See docs/ROADMAP.md.
- Spoofing is a different problem and is not detected. A spoofer raises no noise floor; the receiver tracks a convincing lie.
Savethesat listens. It never transmits in a GNSS band, and that is a permanent non-goal rather than an unimplemented feature. Detecting and mapping interference is lawful nearly everywhere. Transmitting in these bands is not, anywhere. If you find something, the next move belongs to people with legal authority — report it.
MIT. See LICENSE.