Skip to content
simeononsecurityPublic

About

Passive surveillance detector for the M5Stack Atom Lite. Scans BLE and WiFi for body cameras, ALPR systems, AirTags, trackers, drones, and hidden cameras. LED turns red when something is watching.

Topics

Resources

Stars

13 stars

Watchers

1 watching

Forks

Latest commit

Β 

History

57 Commits

Folders and files

Repository files navigation

Eye Spy

License Author

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


LED Indicators

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.


Detection Engines

BLE β€” passive (NimBLE, no scan requests transmitted)

# 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.

WiFi scan β€” active channel scan

# 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 🟑

WiFi promiscuous β€” passive sniff, channel-hopping

# 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 πŸ”΄

How Scoring Works

  • 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.


What the Device Tells You

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.

Audio: a two-tone chime, not a blip

  • 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.

Screen: detection type and the source MAC

Boards with a display show, top to bottom:

  1. Severity β€” CLEAR / CAUTION / ALERT, header colour matching the LED.
  2. The detection type β€” e.g. Flock-cam-OUI, AirTag, ALPR-OUI.
  3. 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).

Screen: decaying alert and caution tallies

Below the detection details the panel shows two counts, each coloured to match its level:

  • ALERTS: n in red β€” how many times the device has reached the alert level.
  • CAUTIONS: n in 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.

Critical alerts hold the screen for 15 seconds

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.

Phase Schedule

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.


Hardware

M5Stack Atom Lite (primary)

Board M5Stack Atom Lite
MCU ESP32-PICO-D4
LED SK6812 NeoPixel on GPIO27
Button GPIO39 (input only)
Flash 4 MB

LILYGO T-Dongle C5 (experimental)

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.


Build & Flash

# 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 upload

Dependencies (installed automatically by PlatformIO):

  • adafruit/Adafruit NeoPixel @ ^1.15.1
  • h2zero/NimBLE-Arduino @ ^1.4.3
  • adafruit/Adafruit ST7735 and ST7789 Library @ ^1.10.4 (T-Dongle C5 only)
  • adafruit/Adafruit GFX Library @ ^1.11.9 (T-Dongle C5 only)

Serial Output

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.

Reading the log back: dumplog / clearlog

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).


Flock Safety OUI Table

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 assignment
  • 82: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.


πŸ§ͺ Native Unit Tests

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, 8 FLOCK_MFR_OUIS (incl. 00:03:7f and 14:b5:cd), and 31 CAM_OUIS prefixes
  • 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_KW keyword matching (case-insensitive)
  • FLOCK_BLE_NAMES substring matching via strContainsCI()
  • Firmware-default MACs must match all six bytes β€” near-misses inside the same 00:03:7f block (…:50:00:02) must not, and a random 00:03:7f device 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_UUIDS contents, and that the Flock accessory service is not presented as a Raven UUID
  • Raven service range 0x3100–0x3500 (including 0x3101/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_NAMES table contents/counts, and RAVEN_UUIDS vendor-only count (the standard SIG services 0x180A/0x1809/0x1819 must never match, whether via the table or the range parser β€” three tests enforce this)
  • FLOCK_SSID_KW matching for the CVE-2025-59409 test_flck spelling
  • FLOCK_BLE_MFR_IDS / flockBleMfrIdMatch() (incl. rejection of the old incorrect 0x05A7)
  • 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; and hits==1 marks a new window (the contract CHECK_TRACKER relies on to re-arm its once-per-follow score)
  • nullptr-termination sanity for every pattern array

Notes

  • 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.

About

Passive surveillance detector for the M5Stack Atom Lite. Scans BLE and WiFi for body cameras, ALPR systems, AirTags, trackers, drones, and hidden cameras. LED turns red when something is watching.

Topics

Resources

Stars

13 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages