Passive surveillance detector for the M5Stack Atom Lite (ESP32-PICO-D4).
Scans BLE and WiFi for body cameras, ALPR systems, AirTags, trackers, drones, and hidden cameras β LED turns red when something is watching.
Continuously monitors Bluetooth LE and WiFi for recording devices, surveillance cameras, ALPR (license-plate reader) systems, drones, and personal trackers. A confidence-score system drives a single RGB LED indicator so you get an at-a-glance privacy threat level at all times.
Printable guides for non-technical users: 4x6 pocket card Β· 8.5x11 sheet
Support: customer replies β ready-made answers to the questions that come back most often (sound, buzz patterns, what the event counter means, how to identify a detected device). Β· Changelog Β· Design decisions
| Color | Meaning | Score |
|---|---|---|
| π΅ Blue pulse | Startup / first scan | β |
| π’ Green solid | Clear β nothing detected | 0β2 |
| π‘ Yellow solid | Caution β possible recording device nearby | 3β5 |
| π΄ Red flashing | Alert β definite surveillance / tracking device detected | 6+ |
Score decays β1 point every 60 seconds. Each detection type has a 120-second re-score cooldown so a persistent device can't infinitely stack points.
| # | Target | What's Detected | Score |
|---|---|---|---|
| 1 | Axon body camera | BLE MAC OUI 00:25:df (Axon β body cams, tasers, LE equipment) |
+5 π΄ |
| 2 | Ray-Ban Meta smart glasses | BLE service UUID 0xFD5F |
+5 π΄ |
| 3 | Flock Safety BLE | BLE device name containing "Flock", "Raven", "Penguin", "Pigvision", "FS Ext Battery" or "DfuTarg", or matching a firmware-derived name shape: Penguin- + 10 digits, a bare 10-digit serial, FS Ext Battery, DfuTarg |
+5 π΄ |
| 4 | Flock accessory GATT (battery pack) | Advertised service UUID = Flock accessory service e8ccbb38-9532-46a8-9fe5-1814df172e6f or Nordic legacy DFU service 00001530-1212-efde-1523-785feabcd123 |
+5 π΄ |
| 5 | Raven surveillance device | Advertised service UUID matches the named vendor Raven GATT services or falls anywhere in the Raven 16-bit range 0x3100β0x3500 β the range catches 0x3101/0x3102, which expose GPS unauthenticated. Standard Bluetooth SIG services (0x180A/0x1809/0x1819) are deliberately excluded β they are on essentially every BLE device ever made |
+5 π΄ |
| 6 | Card skimmer (HC-03/05/06) | BLE device name exact match β Bluetooth modules commonly found in payment-terminal skimmers | +5 π΄ |
| 7 | Apple AirTag | Manufacturer data 0x004C subtype 0x12/0x1E, or raw payload 1E FF 4C 00 / 4C 00 12. Only scores after ~30 minutes of continuous presence β see the tracker gate below |
+6 π΄ (once following) |
| 8 | Drone (OpenDroneID BLE) | BLE service UUID 0xFFFA, or raw AD service-data payload with app code 0x0D |
+4 π΄ |
| 9 | Samsung SmartTag | BLE service UUID 0xFD5A β same 30-minute following gate as the AirTag |
+6 π΄ (once following) |
| 10 | Tile tracker | BLE service UUID 0xFEED or 0xFEEC β same 30-minute following gate as the AirTag |
+6 π΄ (once following) |
| 11 | MeshCore node | BLE device name prefix MeshCore- |
+2 π‘ |
| 12 | iBeacon (retail/venue tracking) | Manufacturer data 0x004C 0x02 0x15 β deployed in stores, airports, stadiums to track movement |
+2 π‘ |
| 13 | Unknown persistent device | Any unclassified BLE MAC seen β₯3Γ over β₯5 minutes (device scout / follower detection) | +2 π‘ |
Tracker gate (AirTag / SmartTag / Tile). These three engines do not score
on a sighting. A tracker must first be shown to be following β continuously in
range for ~30 minutes (TRACKER_FOLLOW_MS) β before it contributes anything
to the score. Until then the sighting is still printed (AirTag RSSI=-71 #4 watching (2min/30min)) and shown on screen, but it adds 0 points, so a
passer-by's AirTag cannot push the device toward an ALERT.
- Why: trackers are the one BLE class where a single sighting means nothing β they are carried by ordinary people, left in bags, shipped inside boxes. An alert every time one goes past trains the user to ignore the alert that matters.
- Continuity: a gap longer than 5 minutes
(
TRACKER_CONTINUITY_MAX_GAP_MS) ends the follow and restarts the clock, so three unrelated trackers passing at 20-minute intervals never add up to a follow. - Keyed on the tracker class, not the MAC address: AirTags rotate their Bluetooth address roughly every 15 minutes while separated from their owner, so a 30-minute window keyed on the address could never be satisfied by a real follow. Class + continuity survives that rotation.
- Scored once when the follow is established, so a tracker merely sitting on a desk cannot ratchet the score upward; the sticky-seen refresh keeps the alert up while it genuinely stays with you.
- 30 minutes is the order of magnitude Apple and Samsung use before warning a user about an unknown tracker travelling with them.
| # | Target | What's Detected | Score |
|---|---|---|---|
| 14 | Flock Safety camera (OUI) | BSSID matches the 35-entry Flock Safety OUI table (b4:1e:52, 82:6b:f2, 70:c9:4e, FS-Ext-Battery prefixes, β¦) |
+5 π΄ |
| 15 | Unprovisioned Flock camera (firmware-default MAC) | BSSID is exactly 00:03:7f:50:00:01 or 00:03:7f:4f:00:16 β the QCA9377 factory-default radio addresses from the camera firmware image. Full 6-byte match, and the only WiFi signal that reaches the alert threshold on its own (see below) |
+6 π΄ |
| 16 | ALPR / LPR camera (OUI) | BSSID matches Motorola Solutions / Vigilant Solutions OUI 00:0e:58 |
+5 π΄ |
| 17 | Flock keyword SSID | SSID contains: flock, flocksafety, fs ext, penguin, pigvision, raven, flck (the truncated test_flck spelling from CVE-2025-59409 β Flock's leaked development Wi-Fi credential string) |
+5 π΄ |
| 18 | ALPR keyword SSID | SSID contains: alpr, lpr, vigilant, plateread, licenseplat, motorola, automate |
+4 π΄ |
| 19 | Surveillance camera vendor (OUI) | BSSID matches 31-entry camera OUI table β Hikvision, Dahua, Axis, Ring, Nest, Arlo, Wyze, Reolink, FLIR, Amcrest, Vivotek, Hanwha, Mobotix, Ubiquiti UniFi | +3 π‘ |
| 20 | Camera keyword SSID | SSID contains: cam, ipcam, cctv, nvr, dvr, doorbell, surv, blink, lorex, protect, genetec, and more |
+2 π‘ |
| 21 | Flock contract-mfr OUI | BSSID matches the 8-entry contract-manufacturer table (Liteon/USI β incl. 14:b5:cd β plus 00:03:7f Qualcomm Atheros, the camera's QCA9377 radio block, shared with unrelated Atheros gear) |
+2 π‘ |
| # | Target | What's Detected | Score |
|---|---|---|---|
| 22 | Drone (OpenDroneID WiFi NaN) | 802.11 Management frame to destination 51:6f:9a:01:00:00 β ASTM F3411 Remote ID broadcast |
+4 π΄ |
- Score 0β2 β π’ GREEN β no significant detections
- Score 3β5 β π‘ YELLOW β possible device nearby, worth being aware
- Score 6+ β π΄ RED β definite surveillance / tracking equipment detected
A single hit from a definite device (Axon, Flock, ALPR OUI, AirTag) immediately reaches the alert threshold in one detection. Multiple independent caution-level signals combine to raise the alert level. The one deliberate exception is the firmware-default MAC engine (+6): a full 6-byte match on a factory-default radio address only exists on a never-provisioned camera, so it is allowed to alert alone β no other WiFi engine is.
Decay: score drops β1 per 60 s of inactivity. If you leave the area, the device returns to clear within a few minutes.
Re-score cooldown: the same detection type won't re-add points for 120 seconds, preventing runaway scoring from one persistent device.
Every board signals the same three-level severity through whichever outputs it physically has:
| Board | Audio | Visual | Haptic |
|---|---|---|---|
| Atom Lite | β (no speaker/buzzer) | NeoPixel LED | β |
| Atom Echo | passive buzzer (G25) | β | β |
| Atom Voice | I2S speaker | LED | β |
| Core2 For AWS | I2S speaker (1 W) | screen | vibration motor |
| M5Stack Basic | I2S speaker (1 W) | screen | β (no motor) |
| StickC Plus SE | passive buzzer (G2) | screen | β |
| T-Dongle C5 | β | screen + RGB LED | β |
Note the first row: the Atom Lite β the cheapest, most common build β has no audio hardware at all, so it can only ever light its LED. If you are waiting for a beep from an Atom Lite, you will wait forever; that is the hardware, not a fault.
- Alert (score 6+) β a rising two-tone chime, G6 (1568 Hz) β C7 (2093 Hz), 130 ms then 240 ms with a 70 ms gap. The rising interval is what reads as "attention".
- Caution (score 3β5) β a single lower tone, A5 (880 Hz), 130 ms.
This deliberately mirrors the vibration patterns (2 pulses = alert, 1 pulse = caution) so severity can be judged by ear, by feel, or by eye. The chime is non-blocking: it is armed by the detector and stepped by the UI task between redraws, so a chime never freezes the screen or delays a detection.
Boards with a display show, top to bottom:
- Severity β
CLEAR/CAUTION/ALERT, header colour matching the LED. - The detection type β e.g.
Flock-cam-OUI,AirTag,ALPR-OUI. - The source MAC address β directly beneath the type, in the solid severity colour.
The address is the actionable part: it identifies which device caused the alert, so it can be written down, reported, or cross-referenced against your own records.
MAC --means the trigger carried no address β an SSID-only match, where the identifier is the SSID itself.- It is shown as
--rather than reusing the previous detection's address on purpose: a stale address would misidentify an innocent device, which is precisely the confusion this line exists to remove. - On the T-Dongle C5 the screen is only 80 px wide, so the address wraps across two lines (
aa:bb:cc/dd:ee:ff).
Below the detection details the panel shows two counts, each coloured to match its level:
ALERTS: nin red β how many times the device has reached the alert level.CAUTIONS: nin amber β how many times it has reached the caution level.
Both count episodes, not time: a device parked at ALERT for an hour is one alert, not thousands. Both also decay, losing one point every two minutes (ACTIVITY_DECAY_MS), so they describe the recent past rather than everything since boot β and a quiet spell winds them back to zero on their own.
They replace the old Total events line, which counted every detection the firmware logged and only ever grew. That figure was widely misread as an alert count (three customers reported "530 alerts" from a single session), so it now appears in the serial status line as events=, where support can read it but nobody can mistake it for alerts.
A critical detection is usually momentary. On a live dashboard it would be replaced β severity and address together β before either could be read. So when severity reaches ALERT the panel is held for 15 seconds (EA_HOLD_MS), showing severity, detection type and source MAC, with a countdown so it is obvious the device is still running rather than hung.
- Nothing on the held panel blinks. The LED is the blinking element; a flashing address cannot be copied down.
- A second critical device arriving during a hold gets its own full 15-second window, instead of inheriting a nearly-expired one.
- Cautions never hold. They are common, and pinning the screen for each would make the device unusable.
- When the hold expires the live dashboard returns. If the same threat is still present the live panel already shows the same severity and address, so nothing is lost.
BLE passive (9 s) β WiFi scan (~3 s) β Promiscuous sniff (5 s) β repeat
BLE is stopped before any WiFi operations to respect the shared ESP32 radio. It restarts cleanly at the beginning of each cycle.
During the promiscuous phase, the radio channel-hops across {1, 6, 11, 3, 8, 13} every 400 ms to maximize drone NaN frame coverage.
| Board | M5Stack Atom Lite |
| MCU | ESP32-PICO-D4 |
| LED | SK6812 NeoPixel on GPIO27 |
| Button | GPIO39 (input only) |
| Flash | 4 MB |
| Board | LILYGO T-Dongle C5 |
| MCU | ESP32-C5 RISC-V, dual-band WiFi 6 + BT 5 |
| Display | ST7735S 80Γ160 colour TFT |
| RGB LED | WS2812B on GPIO11 |
| BOOT button | GPIO9 |
| Flash | 4 MB |
T-Dongle C5 TFT display layout:
| Score | Background | Large score # | Status |
|---|---|---|---|
| 0β2 | Black | π’ Green | CLEAR |
| 3β5 | Dark amber | π‘ Yellow | CAUTION |
| 6+ | Dark red | π΄ Red | ALERT |
Current scan phase (BLE / WIFI / PROMISC) is shown at the bottom of the screen. The WS2812B RGB LED mirrors the score level (green / amber / red) for at-a-glance visibility without looking at the screen.
# Compile for Atom Lite
pio run -e atom-lite
# Flash
pio run -e atom-lite -t upload
# Serial monitor (115200 baud)
pio device monitor -b 115200
# Generic ESP32 devkit (LED on GPIO2)
pio run -e esp32dev
# LILYGO T-Dongle C5 (experimental β ESP32-C5 dual-band WiFi 6 + BT 5)
pio run -e lilygo-t-dongle-c5 -t uploadDependencies (installed automatically by PlatformIO):
adafruit/Adafruit NeoPixel @ ^1.15.1h2zero/NimBLE-Arduino @ ^1.4.3adafruit/Adafruit ST7735 and ST7789 Library @ ^1.10.4(T-Dongle C5 only)adafruit/Adafruit GFX Library @ ^1.11.9(T-Dongle C5 only)
All output is prefixed with [eyespy]:
[eyespy] boot: reset=POWERON (cold boot / power cycle)
[eyespy] boot: heap=214880 min_heap=201336
[eyespy] Eye Spy v1.3 starting
[eyespy] slog ready: 511 slots, 0 stored, 0 lost
[eyespy] log schema=1
[eyespy] init OK flock_ouis=35 mfr_ouis=7 st_ouis=1
[eyespy] BLE scan start
[eyespy] Flock-cam OUI d4:bb:e6 "Flock_CAM_0032"
[eyespy] +5 (Flock-cam-OUI) score=5
[eyespy] Axon-cam RSSI=-62 #1
[eyespy] +5 (Axon-cam) score=10
[eyespy] status score=10 ALERT phase=BLE tracked=12 events=4 heap=213912 min_heap=201336 log=3 lost=0
[eyespy] decay score=9
reset= is why the device last restarted (anything other than a cold boot is
suffixed <-- investigate), and heap=/min_heap= are the leak tell β a floor
that keeps falling across a run is the usual cause of a reboot that looks like it
arrives on a timer. log=/lost= report the on-device detection log below.
The firmware keeps the last ~511 scored detections in a 64 KB eslog partition
that survives a reboot β so a unit that crashed, or that ran unattended, can
still be diagnosed afterwards. Two plain-text commands (press ENTER after each)
read and clear it:
dumplog # stream the stored records, oldest first
clearlog # empty the ring
A dump looks like this β each record is preceded by the device uptime when it was
written, and the records themselves are byte-identical to the lines printed live,
so the output can be piped straight into api/eyespy.py as a replay:
[eyespy] slog dump begin count=3 total=3 lost=0 dropped=0
[eyespy] slog t=12s
[eyespy] slog boot reset=POWERON (cold boot / power cycle)
[eyespy] slog t=184s
[eyespy] +5 (Flock-cam-OUI) score=5
[eyespy] slog t=190s
[eyespy] +5 (Axon-cam) score=10
[eyespy] slog dump end
Writes are buffered and flushed from the main loop, so a crash or power cut costs
at most the last second or two; lost= counts records overwritten once the ring
wraps, and dropped= counts records refused (a line too long for a slot is
rejected rather than truncated, so nothing in a dump can be a half-written line
that no longer parses).
The 35-entry FLOCK_OUIS table (src/es_detect.h) covers:
d4:bb:e6,3c:61:05β Flock Safety (IEEE registered, legacy entries)b4:1e:52β Flock Safety direct IEEE assignment82:6b:f2β DeFlockJoplin field-verified camera (locally-administered MAC)- 6 FS Ext Battery device-series prefixes
- 25 additional MAC prefixes from the @NitekryDPaul promiscuous-mode dataset
These are checked separately from the general ALPR table, so both Flock cameras and Vigilant LPR cameras can score independently in the same scan cycle. A separate low-confidence FLOCK_MFR_OUIS table (Liteon/USI contract-manufacturer prefixes shared with non-Flock hardware) scores +2 instead of +5.
The detection pattern library (src/es_detect.h) is fully tested via a
host-side Unity test suite β no ESP32 hardware needed. This mirrors the
equivalent test setup in the sibling
flock-you-esp32
project:
cd eye-spy
pio test -e native # run all 128 tests
pio test -e native -f test_oui_matching # OUI table + firmware-default MAC matching (20)
pio test -e native -f test_ssid_ble_matching # SSID / BLE-name / GATT / Raven-range matching (32)
pio test -e native -f test_tracker_follow # AirTag / SmartTag / Tile following gate (15)
pio test -e native -f test_activity_counts # decaying alert/caution tallies (12)
pio test -e native -f test_alert_hold # 15-second critical-alert hold (15)
pio test -e native -f test_config # web-flasher config blob codec (19)
pio test -e native -f test_es_log # on-device log framing + ring (15)All 67 tests pass against the current es_detect.h. The test suite covers:
- All 35
FLOCK_OUIS, 8FLOCK_MFR_OUIS(incl.00:03:7fand14:b5:cd), and 31CAM_OUISprefixes - SoundThinking and ALPR OUI isolation (not present in any other table)
- Cross-table mutual-exclusion (no OUI prefix appears in more than one table)
FLOCK_SSID_KW/ALPR_SSID_KW/CAM_SSID_KWkeyword matching (case-insensitive)FLOCK_BLE_NAMESsubstring matching viastrContainsCI()- Firmware-default MACs must match all six bytes β near-misses inside the
same
00:03:7fblock (β¦:50:00:02) must not, and a random00:03:7fdevice must be mfr-tier only, never the strong signal - BLE name shapes: bare 10-digit serial,
Penguin-+ 10 digits,FS Ext Battery,DfuTarg, plus rejection of wrong digit counts / trailing junk FLOCK_GATT_UUIDScontents, and that the Flock accessory service is not presented as a Raven UUID- Raven service range
0x3100β0x3500(including0x3101/0x3102, the GPS-leaking services the named table alone missed) and out-of-range rejection - 16-bit service parsing from both UUID shapes (canonical 128-bit and
0x3101) SKIMMER_NAMEStable contents/counts, andRAVEN_UUIDSvendor-only count (the standard SIG services0x180A/0x1809/0x1819must never match, whether via the table or the range parser β three tests enforce this)FLOCK_SSID_KWmatching for the CVE-2025-59409test_flckspellingFLOCK_BLE_MFR_IDS/flockBleMfrIdMatch()(incl. rejection of the old incorrect0x05A7)- Tracker "following" gate β 29 minutes is not yet a follow, 30 is; a gap over
5 minutes restarts the clock; sightings scattered over hours never add up to a
follow; the ~49-day
millis()wrap does not break the window; andhits==1marks a new window (the contractCHECK_TRACKERrelies on to re-arm its once-per-follow score) - nullptr-termination sanity for every pattern array
- BLE scanning is passive β no scan requests are transmitted. The device is not detectable by the equipment it is scanning for.
- RSSI threshold: devices weaker than β90 dBm are ignored to reduce false positives in dense environments.
- The device persistence tracker watches up to 50 unknown BLE MACs simultaneously. Entries are purged after 30 minutes of absence.
- The iBeacon detector fires on standard Apple iBeacon format (
0x4C 0x00 0x02 0x15) regardless of UUID, targeting retail-tracking deployments.