diff --git a/README.md b/README.md index 7526737..288b459 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,189 @@ -# Flock-You: Surveillance Device Detector +# Flock-You: Promiscuous WiFi Edition (`promiscious-dev` branch) Flock You -**Standalone BLE surveillance device detector with web dashboard, GPS wardriving, and session persistence.** +**Passive 2.4 GHz promiscuous-mode detector for Flock Safety surveillance infrastructure. Runs standalone or feeds the Flask dashboard over USB for live GPS-tagged wardriving.** -Available as part of the OUI-SPY project at [colonelpanic.tech](https://colonelpanic.tech) +> **Dev note:** This is the `promiscious-dev` branch — adds the +> DeFlockJoplin wildcard-probe tightening and a 31st OUI on top of the +> `promiscious` baseline. See "Further research" below. --- -## Overview +## Credit -Flock-You detects Flock Safety surveillance cameras, Raven gunshot detectors, and related monitoring hardware using BLE-only heuristics. It runs a WiFi access point with a live web dashboard on your phone, tags detections with GPS from your phone's browser, and exports everything as JSON, CSV, or KML for Google Earth. +All WiFi promiscuous detection research — the **30-OUI target list**, the **promiscuous-mode strategy**, and the **addr1-receiver detection technique** — is the work of **ØяĐöØцяöЪöяцฐ / @NitekryDPaul**. The firmware here is a mod of his original firmware with added SPIFFS persistence and Flask-dashboard integration. Full research writeup: [`datasets/NitekryDPaul_wifi_ouis.md`](datasets/NitekryDPaul_wifi_ouis.md). -No WiFi sniffing — the radio is dedicated to serving the dashboard AP while BLE scans continuously in the background via ESP32 coexistence. +Additional research credit to **Michael / DeFlockJoplin** for the **wildcard-probe-request signature** and the 31st OUI (`82:6b:f2`). Field-tested to 11/12 cameras caught with only 2 false positives in Joplin. Source: [DeflockJoplin/flock-you](https://github.com/DeflockJoplin/flock-you). --- -## Detection Methods +## What this branch does -All detection is BLE-based: +Turns a Seeed XIAO ESP32-S3 into a passive WiFi receiver that watches 2.4 GHz management and data frames for Flock Safety MAC OUIs. No AP, no transmit — the radio stays dedicated to sniffing while the device hops channels 1 / 6 / 11 at 350 ms dwell. -| Method | Description | -|--------|-------------| -| **MAC prefix** | 20 known Flock Safety OUI prefixes (FS Ext Battery, Flock WiFi modules) | -| **BLE device name** | Case-insensitive substring match: `FS Ext Battery`, `Penguin`, `Flock`, `Pigvision` | -| **Manufacturer ID** | `0x09C8` (XUNTONG) — catches devices with no broadcast name. *From [wgreenberg/flock-you](https://github.com/wgreenberg/flock-you)* | -| **Raven service UUID** | Identifies Raven gunshot detectors by BLE GATT service UUIDs | -| **Raven FW estimation** | Determines firmware version (1.1.x / 1.2.x / 1.3.x) from advertised service patterns | +Every detection is: + +- beeped (piezo on GPIO3) and flashed (onboard LED on GPIO21) +- written to on-device SPIFFS in an atomic CRC-envelope format, surviving power loss +- emitted as one JSON line over USB CDC in the schema `api/flockyou.py` expects, so the Flask dashboard auto-ingests it with GPS temporal matching + +The device works standalone (no USB host needed) and plugged in (live dashboard) without any mode switch. --- -## Features +## Why promiscuous mode, and why `addr1` -- **WiFi AP**: `flockyou` / password `flockyou123` -- **Web dashboard** at `192.168.4.1` — live detection feed, pattern database, export tools -- **GPS wardriving** — phone GPS via browser Geolocation API tags every detection with coordinates -- **Session persistence** — detections auto-save to flash (SPIFFS) every 60 seconds -- **Prior session tab** — previous session survives reboot and is viewable in the PREV tab -- **Export formats**: JSON, CSV, and KML (Google Earth) — current and prior sessions -- **Serial output** — Flask-compatible JSON over serial for live desktop ingestion -- **200 unique device storage** with FreeRTOS mutex thread safety -- **Crow call boot sounds** — modulated descending frequency sweeps with warble texture -- **Detection alerts** — ascending chirps + descending caw on new device detection -- **Heartbeat** — soft double coo every 10s while a device stays in range +Most WiFi sniffers only check the transmitter address (`addr2`). Flock infrastructure spends most of its duty cycle **asleep** — it wakes briefly in bursts, uploads, then sleeps again. During the silence it may never transmit a single frame in your capture window. + +But it may still appear on the air as the **destination** (`addr1`) of probe responses or data frames from nearby APs. + +Checking `addr1` in addition to `addr2` picks those silent stations up. It requires two guards to avoid false positives: + +- `addr1` is broadcast (`ff:ff:ff:ff:ff:ff`) in beacons and broadcasts — **multicast filter** +- Modern devices use randomised (locally-administered) MACs that can't be fingerprinted by OUI — **randomised-MAC filter** on byte 0 bit 1 + +Both are applied before the OUI match. This whole approach, including the 30-OUI list, is **@NitekryDPaul's research**. --- -## Enabling GPS (Android Chrome) +## Further research — the wildcard-probe signature (DeFlockJoplin) -The dashboard uses your phone's GPS to geotag detections. Because it's served over HTTP, Chrome requires a one-time flag change: +Michael / DeFlockJoplin used the OUI + addr1/addr2/addr3 work above as a starting point and characterised what Flock cameras actually do on the air. His finding: -1. Open a new Chrome tab and go to `chrome://flags` -2. Search for **"Insecure origins treated as secure"** -3. Add `http://192.168.4.1` to the text field -4. Set the flag to **Enabled** -5. Tap **Relaunch** +> The cameras are hopping channels and sending out a wildcard WiFi probe request on every channel. This specific type of request combined with OUI matching has created what seems to be a fairly unique signature. -After relaunching, connect to the `flockyou` AP, open `192.168.4.1`, and tap the **GPS** card in the stats bar to grant location permission. +His drive-test in Joplin caught **11 of 12 cameras** with only **2 false positives**. The 12th camera was doing the same wildcard-probe behaviour but with an OUI (`82:6b:f2`) that wasn't in @NitekryDPaul's original 30 — it's now the 31st entry in our list, credited to him. -> **Note:** iOS Safari does not support Geolocation over HTTP. GPS wardriving requires Android with Chrome. +The tightened signature that's active on this branch: + +1. Frame is 802.11 Management, type=0 subtype=4 (**Probe Request**) +2. SSID Information Element (tag 0) is present with **length 0** (wildcard) +3. `addr2` (transmitter) matches the known-OUI list + +When all three hit, we emit `detection_method: wifi_wildcard_probe` — the high-precision class. Non-probe frames from the same OUIs still emit `wifi_oui_addr2`, and the `addr1` receiver-side sleeper-catch still runs independently. + +His proof-of-concept firmware (different enough we're not just pulling it in wholesale, but the core idea carried over cleanly): [DeflockJoplin/flock-you](https://github.com/DeflockJoplin/flock-you). The wildcard-probe analysis is his; we ported the detection into this firmware and kept our SPIFFS persistence, Flask JSON emission, and audio/LED feedback on top. + +--- + +## Detection pipeline + +``` + [2.4GHz air] + │ + ▼ + wifiSniffer() ← IRAM promiscuous callback (WiFi task) + │ fast match only, no Serial / no malloc + ▼ + alertQueue[32] ← lock-free ring buffer (ISR-safe mux) + │ + ▼ + drainAlertQueue() ← loop() context, per-iteration drain + │ + ├─► fyAddDetection() ← always, every hit + │ │ + │ ▼ + │ fyDet[200] ← unique-by-MAC on-device table + │ │ + │ ▼ + │ autosaveTick() ← every 60s when dirty + │ │ + │ ▼ + │ fySaveSession() ← atomic CRC-envelope write to SPIFFS + │ + ├─► shouldSuppressDuplicate() ← 5s per-MAC serial-emit rate limit + │ + └─► emitDetectionJSON() ← USB CDC line for Flask + buzzerBeep() + ledFlash() +``` + +The split between callback and loop is deliberate: the WiFi task has hard real-time constraints and cannot call `Serial.print` or `malloc` safely. The callback writes only to the lock-free ring buffer; `loop()` does all the heavy work. + +--- + +## OUI target list (@NitekryDPaul research) + +All lowercase, colon-separated. 31 Flock Safety infrastructure prefixes: + +``` +70:c9:4e 3c:91:80 d8:f3:bc 80:30:49 b8:35:32 +14:5a:fc 74:4c:a1 08:3a:88 9c:2f:9d c0:35:32 +94:08:53 e4:aa:ea f4:6a:dd f8:a2:d6 24:b2:b9 +00:f4:8d d0:39:57 e8:d0:fc e0:4f:43 b8:1e:a4 +70:08:94 58:8e:81 ec:1b:bd 3c:71:bf 58:00:e3 +90:35:ea 5c:93:a2 64:6e:69 48:27:ea a4:cf:12 +82:6b:f2 ← contributed by Michael / DeFlockJoplin +``` + +Pre-compiled into a byte table in `setup()` so the matcher stays entirely in IRAM with no flash-resident lookups during callback execution. + +Full dataset and methodology: [`datasets/NitekryDPaul_wifi_ouis.md`](datasets/NitekryDPaul_wifi_ouis.md). + +--- + +## SPIFFS wire format + +On-flash layout, atomic and crash-safe: + +``` +Line 1: {"v":1,"count":N,"bytes":B,"crc":"0xXXXXXXXX"} +Line 2: [{"mac":"...","method":"...","rssi":...,...},...] +``` + +Save procedure: + +1. Compute CRC32 + byte count over the serialised payload +2. Write envelope header + payload to `/session.tmp` +3. Re-read and re-validate `/session.tmp` (CRC check) +4. Remove `/session.json` +5. Atomic rename `/session.tmp` → `/session.json` (copy+delete fallback) + +Boot recovery: + +1. If `/session.json` validates, promote it to `/prev_session.json` +2. Otherwise try `/session.tmp` (interrupted save) +3. Delete both working files, start with an empty live table +4. `/prev_session.json` stays around for inspection + +CRC32 uses the standard `0xEDB88320` polynomial so the same file can be verified on a host with any off-the-shelf CRC tool. + +--- + +## Flask dashboard integration + +The firmware emits one JSON line per detection in the same schema the BLE detector uses, so `api/flockyou.py` picks it up with zero changes: + +```json +{"event":"detection","detection_method":"wifi_oui_addr2","protocol":"wifi_2_4ghz","mac_address":"aa:bb:cc:dd:ee:ff","oui":"aa:bb:cc","device_name":"","rssi":-62,"channel":6,"frequency":2437,"ssid":""} +``` + +`detection_method` values: + +- `wifi_wildcard_probe` — **Probe Request + wildcard SSID from a known OUI** (the DeFlockJoplin high-precision signature). When this fires, the `addr2` broad alert is suppressed for the same frame to avoid double-counting. +- `wifi_oui_addr2` — transmitter-side OUI match on any non-probe frame +- `wifi_oui_addr1` — **receiver-side OUI match** (the @NitekryDPaul technique) +- `wifi_oui_addr3` — BSSID OUI match (mgmt frames only; disabled by default) +- `wifi_ssid` — SSID keyword match (disabled by default) + +### GPS wardriving + +GPS is handled Flask-side, since the ESP32 radio is dedicated to sniffing and there's no on-device AP. Two options: + +- **USB NMEA puck** plugged into the host running Flask — Flask reads NMEA and timestamps a GPS timeline +- **Flask dashboard open in a phone browser** — browser Geolocation API posts updates to Flask + +Flask does a temporal match between detection timestamp and GPS timeline, then exports JSON / CSV / KML for Google Earth. + +### Running Flask + +```bash +cd api +pip install -r requirements.txt +python flockyou.py +``` + +Open `http://localhost:5000`, pick your serial port from the UI, detections start showing up live. --- @@ -69,70 +194,70 @@ After relaunching, connect to the `flockyou` AP, open `192.168.4.1`, and tap the | Pin | Function | |-----|----------| | GPIO 3 | Piezo buzzer | -| GPIO 21 | LED (optional) | +| GPIO 21 | Onboard user LED (active low) | +| GPIO 43 | Serial1 TX mirror (115200 baud) | + +Boot sound: first 6 notes of Super Mario Bros. World 1-2 (underground). --- -## Building & Flashing +## Build and flash Requires [PlatformIO](https://platformio.org/). ```bash -cd flock-you pio run # build pio run -t upload # flash pio device monitor # serial output ``` -**Dependencies** (managed by PlatformIO): - -- `NimBLE-Arduino` — BLE scanning -- `ESP Async WebServer` + `AsyncTCP` — web dashboard -- `ArduinoJson` — JSON serialization -- `SPIFFS` — session persistence to flash +`platformio.ini` and `partitions.csv` are at the root (1.9 MB SPIFFS partition, 6 MB app). No extra libraries needed beyond the Arduino-ESP32 core that ships with the espressif32 platform. --- -## Flask Companion App +## Config cheatsheet (top of `main.cpp`) -The `api/` folder contains a Flask web application for desktop analysis of detection data. - -```bash -cd api -pip install -r requirements.txt -python flockyou.py -``` - -Open `http://localhost:5000` for the desktop dashboard. - -**Import support:** JSON, CSV, and KML files exported from the ESP32 can be imported directly into the Flask app. Live serial ingestion is also supported — connect the ESP32 via USB and select the serial port in the Flask UI. +| Define | Default | Notes | +|---|---|---| +| `CHANNEL_MODE` | `CHANNEL_MODE_CUSTOM` | `CUSTOM` (1/6/11), `FULL_HOP` (1-11), or `SINGLE` | +| `CHANNEL_DWELL_MS` | 350 | Time on each channel before hop | +| `RSSI_MIN` | -95 | Drop frames weaker than this | +| `ALERT_COOLDOWN_MS` | 5000 | Per-MAC serial-emit rate limit | +| `CHECK_ADDR1` | 1 | The @NitekryDPaul receiver-side technique | +| `CHECK_ADDR3` | 0 | BSSID fallback (mgmt frames only) | +| `ENABLE_SSID_MATCH` | 0 | Substring match against `target_ssid_keywords[]` | +| `PROCESS_MGMT_FRAMES` | 1 | Beacons, probe req/resp, etc. | +| `PROCESS_DATA_FRAMES` | 1 | Data frames (where addr1 catch shines) | +| `MAX_DETECTIONS` | 200 | On-device table cap | +| `AUTOSAVE_INTERVAL_MS` | 60000 | SPIFFS save cadence | +| `LED_PIN` | 21 | Onboard user LED | +| `BUZZER_PIN` | 3 | Piezo | --- -## Raven Gunshot Detector Detection +## Standalone vs connected -Flock-You identifies SoundThinking/ShotSpotter Raven devices through BLE service UUID fingerprinting: +**Without USB:** device boots, plays the SMB 1-2 intro, starts scanning, stores every unique detection to SPIFFS, flashes the onboard LED on each hit. Plug in later — the prior session is sitting in `/prev_session.json`. -| Service | UUID | Description | -|---------|------|-------------| -| Device Info | `0000180a-...` | Serial, model, firmware | -| GPS | `00003100-...` | Real-time coordinates | -| Power | `00003200-...` | Battery & solar status | -| Network | `00003300-...` | LTE/WiFi connectivity | -| Upload | `00003400-...` | Data transmission metrics | -| Error | `00003500-...` | Diagnostics & error logs | -| Health (legacy) | `00001809-...` | Firmware 1.1.x | -| Location (legacy) | `00001819-...` | Firmware 1.1.x | +**With USB + Flask running:** same thing, plus every detection streams live to the dashboard as a JSON line. Flask adds GPS (if configured) and deduplicates across MAC, building the wardriving map as you move. -Firmware version is estimated automatically from which service UUIDs are advertised. +Both modes work simultaneously — the SPIFFS write path doesn't care if a host is listening. + +--- + +## BLE companion firmware + +The BLE-only sibling of this firmware lives on the [`main` branch](https://github.com/colonelpanichacks/flock-you/tree/main). It detects Flock and Raven gear via BLE advertisements (OUI prefix, device name, manufacturer ID `0x09C8`, Raven service UUIDs), runs its own WiFi AP with a phone-facing dashboard at `192.168.4.1`, and emits the same Flask JSON schema. Flash both on separate boards for overlapping BLE + WiFi coverage feeding one Flask dashboard. --- ## Acknowledgments -- **Will Greenberg** ([@wgreenberg](https://github.com/wgreenberg)) — BLE manufacturer company ID detection (`0x09C8` XUNTONG) sourced from his [flock-you](https://github.com/wgreenberg/flock-you) fork +- **ØяĐöØцяöЪöяцฐ (@NitekryDPaul)** — **WiFi promiscuous detection research**: the 30-OUI Flock Safety target list and the addr1-receiver detection technique that are the baseline of this firmware. The code here is a mod of his original work. +- **Michael / DeFlockJoplin** ([DeflockJoplin/flock-you](https://github.com/DeflockJoplin/flock-you), [deflockjoplin.today](https://deflockjoplin.today)) — **wildcard-probe-request signature** + the 31st OUI (`82:6b:f2`). Drive-tested in Joplin to 11/12 cameras caught with only 2 false positives. +- **Will Greenberg** ([@wgreenberg](https://github.com/wgreenberg)) — BLE manufacturer company ID detection (`0x09C8` XUNTONG) sourced from his [flock-you](https://github.com/wgreenberg/flock-you) fork (used by the BLE companion on `main`) - **[DeFlock](https://deflock.me)** ([FoggedLens/deflock](https://github.com/FoggedLens/deflock)) — crowdsourced ALPR location data and detection methodologies. Datasets included in `datasets/` -- **[GainSec](https://github.com/GainSec)** — Raven BLE service UUID dataset (`raven_configurations.json`) enabling detection of SoundThinking/ShotSpotter acoustic surveillance devices +- **[GainSec](https://github.com/GainSec)** — Raven BLE service UUID dataset (`raven_configurations.json`) used by the BLE companion --- @@ -162,4 +287,4 @@ Flock-You is part of the OUI-SPY firmware family: ## Disclaimer -This tool is intended for security research, privacy auditing, and educational purposes. Detecting the presence of surveillance hardware in public spaces is legal in most jurisdictions. Always comply with local laws regarding wireless scanning and signal interception. The authors are not responsible for misuse. +Passive reception of publicly-broadcast 802.11 frames for security research, privacy auditing, and education. The device does not transmit and does not authenticate to any network. Detecting the presence of surveillance hardware in public spaces is legal in most jurisdictions; always comply with local laws regarding wireless reception. diff --git a/datasets/NitekryDPaul_wifi_ouis.md b/datasets/NitekryDPaul_wifi_ouis.md index c5be892..fc4261d 100644 --- a/datasets/NitekryDPaul_wifi_ouis.md +++ b/datasets/NitekryDPaul_wifi_ouis.md @@ -10,7 +10,12 @@ Flock stations spend most of their duty cycle asleep, waking briefly to upload a This addr1 technique is @NitekryDPaul's discovery and is the basis of the `promiscuis-flock-you` firmware. -## OUI list (30 prefixes, lowercase, colon-separated) +## OUI list (31 prefixes, lowercase, colon-separated) + +@NitekryDPaul contributed the first 30. The 31st (`82:6b:f2`) was contributed +by **Michael / DeFlockJoplin** during follow-up drive-testing in Joplin — it's +the OUI of the 12th camera in his field test, which the original list didn't +catch. See [DeflockJoplin/flock-you](https://github.com/DeflockJoplin/flock-you). ``` 70:c9:4e @@ -43,6 +48,7 @@ ec:1b:bd 64:6e:69 48:27:ea a4:cf:12 +82:6b:f2 ``` ## CSV form @@ -79,6 +85,7 @@ a4:cf:12 | 64:6e:69 | Flock Safety infrastructure | WiFi 2.4 GHz | @NitekryDPaul | | 48:27:ea | Flock Safety infrastructure | WiFi 2.4 GHz | @NitekryDPaul | | a4:cf:12 | Flock Safety infrastructure | WiFi 2.4 GHz | @NitekryDPaul | +| 82:6b:f2 | Flock Safety infrastructure | WiFi 2.4 GHz (wildcard probe) | Michael / DeFlockJoplin | ## Detection strategy @@ -90,6 +97,20 @@ For each observed 802.11 management or data frame: 4. Match `addr1` (receiver) against the OUI list — **the addr1 insight** 5. Optional: match `addr3` (BSSID) on mgmt frames when addr2 is randomised +### Wildcard-probe tightening (DeFlockJoplin) + +Michael / DeFlockJoplin observed that Flock cameras channel-hop and spam +wildcard 802.11 Probe Requests on every channel. Combining that with the +OUI match yields a very tight signature: + +1. Frame is Management, type=0 subtype=4 (Probe Request) +2. SSID Information Element (tag 0) is present with length 0 +3. `addr2` (transmitter) matches the OUI list + +Field-tested in Joplin: **11 of 12 cameras caught with only 2 false +positives**. The 12th camera used OUI `82:6b:f2`, which is now in the +list above. Source: [DeflockJoplin/flock-you](https://github.com/DeflockJoplin/flock-you). + ## Firmware The `promiscuis-flock-you` firmware implementing this research is a mod of @NitekryDPaul's promiscuous-mode firmware. It emits Flask-compatible JSON over USB for ingestion by the `flock-you` dashboard and persists detections to on-device SPIFFS. diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..4621777 --- /dev/null +++ b/main.cpp @@ -0,0 +1,1122 @@ +#include +#include +#include "esp_wifi.h" +#include +#include +#include + +// ============================================================ +// CONFIG +// ============================================================ + +#define BUZZER_PIN 3 +#define USE_BUZZER 1 + +// Onboard user LED on Seeed XIAO ESP32-S3 is GPIO21 and is ACTIVE LOW +// (driving the pin LOW lights the LED). +#define LED_PIN 21 +#define USE_LED 1 +#define LED_ACTIVE_HIGH 0 +#define LED_FLASH_MS 120 + +#define MIRROR_SERIAL 1 +#define MIRROR_TX_PIN 43 +#define MIRROR_BAUD 115200 + +#define CHANNEL_MODE_FULL_HOP 0 +#define CHANNEL_MODE_CUSTOM 1 +#define CHANNEL_MODE_SINGLE 2 + +#define CHANNEL_MODE CHANNEL_MODE_CUSTOM +#define CHANNEL_DWELL_MS 350 +#define SINGLE_CHANNEL 1 + +static const uint8_t customChannels[] = {1, 6, 11}; +static const size_t customChannelCount = sizeof(customChannels) / sizeof(customChannels[0]); + +static const uint8_t fullHopChannels[] = {1,2,3,4,5,6,7,8,9,10,11}; +static const size_t fullHopChannelCount = sizeof(fullHopChannels) / sizeof(fullHopChannels[0]); + +#define HEARTBEAT_MS 30000 +#define RSSI_MIN -95 +#define ALERT_COOLDOWN_MS 5000 + +// Audio cadence: two fast ascending beeps on a NEW MAC, then while any +// target is still in range (seen within HB_DEVICE_ACTIVE_MS), two monotone +// heartbeat beeps every HB_BEEP_INTERVAL_MS. +#define HB_DEVICE_ACTIVE_MS 3000 +#define HB_BEEP_INTERVAL_MS 10000 +// A MAC we haven't heard from in REDISCOVER_MS counts as a fresh discovery +// next time it shows up — fires the ascending chirp again. Shorter than a +// Flock's burst-sleep gap would mean false chirps; longer means you'd miss +// a drive-away/return. 30 s is a good middle ground. +#define REDISCOVER_MS 30000 +#define NEW_CHIRP_LO_HZ 2000 +#define NEW_CHIRP_HI_HZ 2800 +#define NEW_CHIRP_NOTE_MS 55 +#define NEW_CHIRP_GAP_MS 25 +#define HB_BEEP_HZ 1500 +#define HB_BEEP_NOTE_MS 70 +#define HB_BEEP_GAP_MS 70 + +#define ENABLE_SSID_MATCH 0 +#define CHECK_ADDR1 1 // dst/rx — catches Flock STAs receiving probe responses +#define CHECK_ADDR3 0 // bssid fallback for randomised addr2 +static const char* target_ssid_keywords[] = { "flock" }; +static const size_t SSID_KEYWORD_COUNT = sizeof(target_ssid_keywords) / sizeof(target_ssid_keywords[0]); + +#define STOP_ON_SSID_HIT 0 +#define STOP_ON_OUI_HIT 0 +#define PROCESS_MGMT_FRAMES 1 +#define PROCESS_DATA_FRAMES 1 + +// Persistence +#define MAX_DETECTIONS 200 +#define FY_SESSION_FILE "/session.json" +#define FY_SESSION_TMP "/session.tmp" +#define FY_PREV_FILE "/prev_session.json" +#define AUTOSAVE_INTERVAL_MS 60000 + +// ============================================================ +// TARGET OUI LIST (all lowercase, colons only) +// ============================================================ + +static const char* target_ouis[] = { + "70:c9:4e", "3c:91:80", "d8:f3:bc", "80:30:49", "b8:35:32", + "14:5a:fc", "74:4c:a1", "08:3a:88", "9c:2f:9d", "c0:35:32", + "94:08:53", "e4:aa:ea", "f4:6a:dd", "f8:a2:d6", "24:b2:b9", + "00:f4:8d", "d0:39:57", "e8:d0:fc", "e0:4f:43", "b8:1e:a4", + "70:08:94", "58:8e:81", "ec:1b:bd", "3c:71:bf", "58:00:e3", + "90:35:ea", "5c:93:a2", "64:6e:69", "48:27:ea", "a4:cf:12", + // Contributed by Michael / DeFlockJoplin — discovered via wildcard-probe + // + OUI signature during field testing. The 12th camera in his drive-test + // used this prefix and wasn't in @NitekryDPaul's original 30. + "82:6b:f2" + +}; +static const size_t OUI_COUNT = sizeof(target_ouis) / sizeof(target_ouis[0]); + +// Pre-compiled byte table — populated once in setup(), never touched again. +// Keeps matchOuiRaw entirely in IRAM with no flash-resident function calls. +static uint8_t oui_bytes[OUI_COUNT][3]; + +// ============================================================ +// ALERT QUEUE (callback → loop, avoids Serial in WiFi task) +// ============================================================ + +#define ALERT_QUEUE_SIZE 32 + +typedef enum : uint8_t { + ALERT_OUI_ADDR2 = 0, + ALERT_OUI_ADDR1 = 1, + ALERT_OUI_ADDR3 = 2, + ALERT_SSID = 3, + // Probe Request + wildcard SSID (tag 0, length 0) from a known-OUI addr2. + // Tight signature from Michael / DeFlockJoplin field research: + // https://github.com/DeflockJoplin/flock-you + ALERT_WILDCARD_PROBE = 4, +} AlertType; + +typedef struct { + AlertType type; + uint8_t mac[6]; + int8_t rssi; + uint8_t channel; + char ssid[33]; // populated for SSID hits + char frameKind[12]; +} AlertEntry; + +static volatile AlertEntry alertQueue[ALERT_QUEUE_SIZE]; +static volatile size_t alertHead = 0; // written by callback +static volatile size_t alertTail = 0; // read by loop() +static portMUX_TYPE queueMux = portMUX_INITIALIZER_UNLOCKED; + +static void IRAM_ATTR enqueueAlert(AlertType type, const uint8_t* mac, int8_t rssi, + uint8_t ch, const char* ssid, const char* kind) { + portENTER_CRITICAL_ISR(&queueMux); + size_t next = (alertHead + 1) % ALERT_QUEUE_SIZE; + if (next == alertTail) { // drop if full — loop() is behind + portEXIT_CRITICAL_ISR(&queueMux); + return; + } + + AlertEntry* e = (AlertEntry*)&alertQueue[alertHead]; + e->type = type; + e->rssi = rssi; + e->channel = ch; + memcpy((void*)e->mac, mac, 6); + + if (ssid) { strncpy((char*)e->ssid, ssid, 32); ((char*)e->ssid)[32] = '\0'; } + else { ((char*)e->ssid)[0] = '\0'; } + + if (kind) { strncpy((char*)e->frameKind, kind, 11); ((char*)e->frameKind)[11] = '\0'; } + else { ((char*)e->frameKind)[0] = '\0'; } + + alertHead = next; + portEXIT_CRITICAL_ISR(&queueMux); +} + +// ============================================================ +// DETECTION TABLE (on-device storage, persisted to SPIFFS) +// ============================================================ +// +// Single-threaded: only touched from loop() — drainAlertQueue() adds, and +// fySaveSession() reads. No mutex needed. The WiFi-task callback never +// touches this table; it only writes to the lock-free alert ring buffer. + +typedef struct { + char mac[18]; + char method[16]; // "oui_addr2" / "oui_addr1" / "oui_addr3" / "ssid" + int8_t rssi; + uint8_t channel; + uint32_t firstSeen; // millis() at first hit + uint32_t lastSeen; // millis() at latest hit + uint16_t count; + char ssid[33]; // "" unless an SSID hit populated it +} FYDetection; + +static FYDetection fyDet[MAX_DETECTIONS]; +static int fyDetCount = 0; +static bool fySpiffsReady = false; +static bool fyDirty = false; +static unsigned long fyLastSaveAt = 0; +static int fyLastSaveCount = 0; + +// ============================================================ +// STATE +// ============================================================ + +static uint8_t currentChannel = 1; +static size_t customChannelIndex = 0; +static size_t fullHopIndex = 0; +static unsigned long lastHop = 0; +static unsigned long lastHeartbeat = 0; +static volatile bool sniffingStopped = false; + +// Dedupe table (small circular, avoids single-slot eviction bug). +// This is the *serial-rate-limit* dedup — it suppresses beep + emit within +// ALERT_COOLDOWN_MS of a prior hit on the same MAC. The detection table +// (above) still counts every hit regardless of this suppression. +#define DEDUPE_SLOTS 8 +static struct { + char mac[18]; + unsigned long ts; +} dedupeTable[DEDUPE_SLOTS]; +static size_t dedupeIdx = 0; + +// LED one-shot pulse timer +static volatile unsigned long ledOffAt = 0; + +// Heartbeat audio state: last time any target was seen, last time the +// heartbeat beep-pair was played. When nothing has been seen for +// HB_DEVICE_ACTIVE_MS the heartbeat stops until the next new detection. +static unsigned long fyLastTargetSeen = 0; +static unsigned long fyLastHeartbeatAt = 0; + +// ============================================================ +// 802.11 HEADER +// ============================================================ + +typedef struct __attribute__((packed)) { + uint16_t frame_ctrl; + uint16_t duration; + uint8_t addr1[6]; + uint8_t addr2[6]; + uint8_t addr3[6]; + uint16_t seq_ctrl; +} wifi_ieee80211_mac_hdr_t; + +// ============================================================ +// HELPERS +// ============================================================ + +// Dual-output: prints to both Serial (USB) and Serial1 (GPIO43) +static char _dualBuf[384]; + +static void dualPrintf(const char* fmt, ...) __attribute__((format(printf, 1, 2))); +static void dualPrintf(const char* fmt, ...) { + va_list args; + va_start(args, fmt); + int n = vsnprintf(_dualBuf, sizeof(_dualBuf), fmt, args); + va_end(args); + if (n > 0) { + Serial.write(_dualBuf, n); +#if MIRROR_SERIAL + Serial1.write(_dualBuf, n); +#endif + } +} + +static void dualPrintln(const char* str) { + Serial.println(str); +#if MIRROR_SERIAL + Serial1.println(str); +#endif +} + +static inline void ledSet(bool on) { +#if USE_LED +#if LED_ACTIVE_HIGH + digitalWrite(LED_PIN, on ? HIGH : LOW); +#else + digitalWrite(LED_PIN, on ? LOW : HIGH); +#endif +#endif +} + +static void ledFlash(unsigned ms) { +#if USE_LED + ledSet(true); + ledOffAt = millis() + ms; + if (ledOffAt == 0) ledOffAt = 1; // avoid the "off" sentinel +#endif +} + +static void ledTick() { +#if USE_LED + if (ledOffAt && (long)(millis() - ledOffAt) >= 0) { + ledSet(false); + ledOffAt = 0; + } +#endif +} + +static void buzzerBeep(unsigned int ms) { +#if USE_BUZZER + digitalWrite(BUZZER_PIN, HIGH); delay(ms); digitalWrite(BUZZER_PIN, LOW); +#endif +} + +// Two fast ascending beeps — played on the FIRST sighting of a MAC. +static void newDetectChirp() { +#if USE_BUZZER + tone(BUZZER_PIN, NEW_CHIRP_LO_HZ); delay(NEW_CHIRP_NOTE_MS); noTone(BUZZER_PIN); + delay(NEW_CHIRP_GAP_MS); + tone(BUZZER_PIN, NEW_CHIRP_HI_HZ); delay(NEW_CHIRP_NOTE_MS); noTone(BUZZER_PIN); +#endif +} + +// Two monotone beeps — periodic heartbeat while at least one target is still +// in range (last seen within HB_DEVICE_ACTIVE_MS). +static void heartbeatBeep() { +#if USE_BUZZER + tone(BUZZER_PIN, HB_BEEP_HZ); delay(HB_BEEP_NOTE_MS); noTone(BUZZER_PIN); + delay(HB_BEEP_GAP_MS); + tone(BUZZER_PIN, HB_BEEP_HZ); delay(HB_BEEP_NOTE_MS); noTone(BUZZER_PIN); +#endif +} +static void startupBeep() { +#if USE_BUZZER + // First 6 notes of SMB World 1-2 (underground). Koji Kondo's descending + // pattern: C5 → C4 → A4 → A3 → G#4 → G#3 (alternating-octave pairs). + static const uint16_t notes[6] = { 523, 262, 440, 220, 415, 208 }; + for (int i = 0; i < 6; i++) { + tone(BUZZER_PIN, notes[i]); + delay((i == 5) ? 160 : 95); + noTone(BUZZER_PIN); + if (i < 5) delay(22); + } +#endif +} + +static void macToStr(const uint8_t* mac, char* buf, size_t len) { + snprintf(buf, len, "%02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); +} +static void ouiFromMac(const uint8_t* mac, char* buf, size_t len) { + snprintf(buf, len, "%02x:%02x:%02x", mac[0], mac[1], mac[2]); +} + +static void precompileOuis() { + for (size_t i = 0; i < OUI_COUNT; i++) { + const char* o = target_ouis[i]; + oui_bytes[i][0] = (uint8_t)strtol(o, nullptr, 16); + oui_bytes[i][1] = (uint8_t)strtol(o + 3, nullptr, 16); + oui_bytes[i][2] = (uint8_t)strtol(o + 6, nullptr, 16); + } +} + +// Bit 0 of byte 0 set = multicast/broadcast — never a real device transmitter or receiver +// we care about. Guards addr1 checks against 01:xx, 33:33:xx, ff:ff:ff:ff:ff:ff etc. +static inline bool IRAM_ATTR isMulticast(const uint8_t* mac) { + return mac[0] & 0x01; +} + +static bool IRAM_ATTR matchOuiRaw(const uint8_t* mac) { + // Locally-administered (randomised) MACs have bit 1 of byte 0 set. + // Fixed infrastructure devices never use them — skip immediately. + if (mac[0] & 0x02) return false; + + for (size_t i = 0; i < OUI_COUNT; i++) { + if (mac[0] == oui_bytes[i][0] && + mac[1] == oui_bytes[i][1] && + mac[2] == oui_bytes[i][2]) return true; + } + return false; +} + +static char* strcasestr_local(const char* haystack, const char* needle) { + if (!*needle) return (char*)haystack; + for (; *haystack; ++haystack) { + const char* h = haystack; const char* n = needle; + while (*h && *n && tolower((unsigned char)*h) == tolower((unsigned char)*n)) { ++h; ++n; } + if (!*n) return (char*)haystack; + } + return nullptr; +} +static bool matchSsidKeyword(const char* ssid) { + for (size_t i = 0; i < SSID_KEYWORD_COUNT; i++) + if (strcasestr_local(ssid, target_ssid_keywords[i])) return true; + return false; +} + +static const char* channelModeName() { + switch (CHANNEL_MODE) { + case CHANNEL_MODE_FULL_HOP: return "FULL_HOP"; + case CHANNEL_MODE_CUSTOM: return "CUSTOM"; + case CHANNEL_MODE_SINGLE: return "SINGLE"; + default: return "UNKNOWN"; + } +} + +static inline uint16_t channelFreqMhz(uint8_t ch) { + return (ch >= 1 && ch <= 14) ? (uint16_t)(2407 + 5 * ch) : 0; +} + +static bool shouldSuppressDuplicate(const char* macStr) { + unsigned long now = millis(); + for (size_t i = 0; i < DEDUPE_SLOTS; i++) { + if (strcmp(dedupeTable[i].mac, macStr) == 0) { + if ((now - dedupeTable[i].ts) < ALERT_COOLDOWN_MS) return true; + dedupeTable[i].ts = now; + return false; + } + } + // Not found — insert into next slot + strlcpy(dedupeTable[dedupeIdx].mac, macStr, 18); + dedupeTable[dedupeIdx].ts = now; + dedupeIdx = (dedupeIdx + 1) % DEDUPE_SLOTS; + return false; +} + +static void stopSniffing(const char* reason) { + if (sniffingStopped) return; + sniffingStopped = true; + esp_wifi_set_promiscuous(false); + dualPrintf("[flockyou] sniffing stopped: %s\n", reason); +} + +static void applyInitialChannel() { +#if CHANNEL_MODE == CHANNEL_MODE_SINGLE + currentChannel = SINGLE_CHANNEL; +#elif CHANNEL_MODE == CHANNEL_MODE_CUSTOM + currentChannel = customChannels[0]; +#else + currentChannel = fullHopChannels[0]; +#endif + esp_wifi_set_channel(currentChannel, WIFI_SECOND_CHAN_NONE); + lastHop = millis(); // start dwell timer precisely when channel is first set +} + +static void updateChannelMode() { + if (sniffingStopped) return; +#if CHANNEL_MODE == CHANNEL_MODE_SINGLE + if (currentChannel != SINGLE_CHANNEL) { + currentChannel = SINGLE_CHANNEL; + esp_wifi_set_channel(currentChannel, WIFI_SECOND_CHAN_NONE); + } + return; +#else + if (millis() - lastHop < CHANNEL_DWELL_MS) return; + #if CHANNEL_MODE == CHANNEL_MODE_CUSTOM + customChannelIndex = (customChannelIndex + 1) % customChannelCount; + currentChannel = customChannels[customChannelIndex]; + #else + fullHopIndex = (fullHopIndex + 1) % fullHopChannelCount; + currentChannel = fullHopChannels[fullHopIndex]; + #endif + esp_wifi_set_channel(currentChannel, WIFI_SECOND_CHAN_NONE); + lastHop = millis(); +#endif +} + +static void printHeartbeat() { + if (millis() - lastHeartbeat >= HEARTBEAT_MS) { + dualPrintf("[flockyou] scanning (ch=%u mode=%s det=%d)\n", + currentChannel, channelModeName(), fyDetCount); + lastHeartbeat = millis(); + } +} + +// ============================================================ +// DETECTION TABLE OPS +// ============================================================ + +static const char* alertTypeToMethod(AlertType t) { + switch (t) { + case ALERT_OUI_ADDR2: return "oui_addr2"; + case ALERT_OUI_ADDR1: return "oui_addr1"; + case ALERT_OUI_ADDR3: return "oui_addr3"; + case ALERT_SSID: return "ssid"; + case ALERT_WILDCARD_PROBE: return "wildcard_probe"; + default: return "unknown"; + } +} + +// Returns index of entry (new or updated), or -1 if table is full. +// Returns index, and sets *outChirpWorthy = true when the caller should fire +// the ascending new-discovery chirp. Chirp-worthy means either (a) MAC is +// brand new to this session, or (b) MAC is known but hasn't been seen in +// REDISCOVER_MS — i.e. it left RF range and came back. +static int fyAddDetection(const char* mac, const char* method, + int8_t rssi, uint8_t ch, const char* ssid, + bool* outChirpWorthy) { + uint32_t now = millis(); + for (int i = 0; i < fyDetCount; i++) { + if (strcasecmp(fyDet[i].mac, mac) == 0) { + bool rediscover = (now - fyDet[i].lastSeen) > REDISCOVER_MS; + if (fyDet[i].count < 0xFFFF) fyDet[i].count++; + fyDet[i].lastSeen = now; + fyDet[i].rssi = rssi; + fyDet[i].channel = ch; + if (ssid && ssid[0] && !fyDet[i].ssid[0]) { + strlcpy(fyDet[i].ssid, ssid, sizeof(fyDet[i].ssid)); + } + fyDirty = true; + if (outChirpWorthy) *outChirpWorthy = rediscover; + return i; + } + } + if (fyDetCount >= MAX_DETECTIONS) { + if (outChirpWorthy) *outChirpWorthy = false; + return -1; + } + FYDetection& d = fyDet[fyDetCount]; + strlcpy(d.mac, mac, sizeof(d.mac)); + strlcpy(d.method, method ? method : "", sizeof(d.method)); + d.rssi = rssi; + d.channel = ch; + d.firstSeen = now; + d.lastSeen = now; + d.count = 1; + if (ssid && ssid[0]) strlcpy(d.ssid, ssid, sizeof(d.ssid)); + else d.ssid[0] = '\0'; + fyDetCount++; + fyDirty = true; + if (outChirpWorthy) *outChirpWorthy = true; + return fyDetCount - 1; +} + +// ============================================================ +// JSON ESCAPE — only needed for SSIDs (user-controlled bytes) +// ============================================================ + +static size_t jsonEscape(char* dst, size_t cap, const char* src) { + size_t o = 0; + if (cap == 0) return 0; + for (size_t i = 0; src[i]; i++) { + char c = src[i]; + if (c == '"' || c == '\\') { + if (o + 2 >= cap) break; + dst[o++] = '\\'; dst[o++] = c; + } else if ((unsigned char)c < 0x20) { + if (o + 6 >= cap) break; + int n = snprintf(dst + o, cap - o, "\\u%04x", (unsigned)(unsigned char)c); + if (n <= 0 || (size_t)n >= cap - o) break; + o += (size_t)n; + } else { + if (o + 1 >= cap) break; + dst[o++] = c; + } + } + dst[o] = '\0'; + return o; +} + +// ============================================================ +// CRC32 (zlib / SPIFFS-tool compatible polynomial 0xEDB88320) +// ============================================================ + +static uint32_t fyCRC32Update(uint32_t crc, const uint8_t* data, size_t len) { + crc = ~crc; + for (size_t i = 0; i < len; i++) { + crc ^= data[i]; + for (int k = 0; k < 8; k++) + crc = (crc >> 1) ^ (0xEDB88320u & -(int32_t)(crc & 1)); + } + return ~crc; +} + +// ============================================================ +// SPIFFS SESSION PERSISTENCE — bulletproof envelope format +// ============================================================ +// +// Wire format on disk: +// Line 1: {"v":1,"count":N,"bytes":B,"crc":"0xXXXXXXXX"}\n +// Line 2+: [{"mac":...},...] (exactly B bytes, CRC32 == X) +// +// Atomic write procedure: +// 1. Compute payload size + CRC (pass 1) +// 2. Write envelope + payload to /session.tmp (pass 2) +// 3. Re-validate /session.tmp from disk +// 4. Remove /session.json, rename tmp → main (with copy+delete fallback) +// +// Boot-time recovery: +// - Try /session.json. If missing or CRC-invalid, try /session.tmp. +// - Copy whichever validates to /prev_session.json, then delete both. + +static size_t fySerializeDet(const FYDetection& d, char* dst, size_t cap) { + char ssidEsc[sizeof(d.ssid) * 6 + 1]; + jsonEscape(ssidEsc, sizeof(ssidEsc), d.ssid); + int n = snprintf(dst, cap, + "{\"mac\":\"%s\",\"method\":\"%s\",\"rssi\":%d,\"channel\":%u," + "\"first\":%lu,\"last\":%lu,\"count\":%u,\"ssid\":\"%s\"}", + d.mac, d.method, d.rssi, (unsigned)d.channel, + (unsigned long)d.firstSeen, (unsigned long)d.lastSeen, (unsigned)d.count, + ssidEsc); + return (n > 0 && (size_t)n < cap) ? (size_t)n : 0; +} + +static uint32_t fyComputePayloadCRC(size_t& outBytes) { + char line[384]; + uint32_t crc = 0; + outBytes = 0; + crc = fyCRC32Update(crc, (const uint8_t*)"[", 1); outBytes += 1; + for (int i = 0; i < fyDetCount; i++) { + if (i > 0) { crc = fyCRC32Update(crc, (const uint8_t*)",", 1); outBytes += 1; } + size_t n = fySerializeDet(fyDet[i], line, sizeof(line)); + if (n == 0) continue; + crc = fyCRC32Update(crc, (const uint8_t*)line, n); + outBytes += n; + } + crc = fyCRC32Update(crc, (const uint8_t*)"]", 1); outBytes += 1; + return crc; +} + +// Minimal envelope parser: pulls bytes + crc fields by substring search. +// Robust to field reordering; rejects anything without both required keys. +static bool fyParseEnvelope(const char* hdr, size_t& outBytes, uint32_t& outCrc) { + const char* b = strstr(hdr, "\"bytes\":"); + const char* c = strstr(hdr, "\"crc\":\"0x"); + if (!b || !c) return false; + b += 8; + long long bv = 0; + if (sscanf(b, "%lld", &bv) != 1 || bv < 0) return false; + c += 9; + unsigned cv = 0; + if (sscanf(c, "%x", &cv) != 1) return false; + outBytes = (size_t)bv; + outCrc = (uint32_t)cv; + return true; +} + +static bool fyValidateSessionFile(const char* path) { + if (!SPIFFS.exists(path)) return false; + File f = SPIFFS.open(path, "r"); + if (!f) return false; + + String hdr = f.readStringUntil('\n'); + if (hdr.length() < 10 || hdr[0] != '{') { f.close(); return false; } + + size_t expectedBytes = 0; + uint32_t expectedCRC = 0; + if (!fyParseEnvelope(hdr.c_str(), expectedBytes, expectedCRC)) { + f.close(); return false; + } + + size_t bodyOffset = hdr.length() + 1; + size_t fileSize = f.size(); + if (fileSize < bodyOffset + expectedBytes) { f.close(); return false; } + if ((fileSize - bodyOffset) != expectedBytes) { f.close(); return false; } + + uint8_t buf[256]; + uint32_t crc = 0; + size_t remaining = expectedBytes; + while (remaining > 0) { + int n = f.read(buf, remaining < sizeof(buf) ? remaining : sizeof(buf)); + if (n <= 0) break; + crc = fyCRC32Update(crc, buf, (size_t)n); + remaining -= (size_t)n; + } + f.close(); + return (remaining == 0 && crc == expectedCRC); +} + +static bool fySpiffsCopy(const char* src, const char* dst) { + File s = SPIFFS.open(src, "r"); + if (!s) return false; + File d = SPIFFS.open(dst, "w"); + if (!d) { s.close(); return false; } + uint8_t buf[256]; + int n; + bool ok = true; + while ((n = s.read(buf, sizeof(buf))) > 0) { + if (d.write(buf, (size_t)n) != (size_t)n) { ok = false; break; } + } + s.close(); + d.close(); + return ok; +} + +static bool fyAtomicPromote(const char* src, const char* dst) { + if (SPIFFS.rename(src, dst)) return true; + if (!fySpiffsCopy(src, dst)) return false; + SPIFFS.remove(src); + return true; +} + +static void fySaveSession() { + if (!fySpiffsReady) return; + if (!fyDirty && fyDetCount == fyLastSaveCount) return; + + size_t payloadBytes = 0; + uint32_t crc = fyComputePayloadCRC(payloadBytes); + int savedCount = fyDetCount; + + File f = SPIFFS.open(FY_SESSION_TMP, "w"); + if (!f) { + dualPrintf("[flockyou] save failed: cannot open %s\n", FY_SESSION_TMP); + return; + } + f.printf("{\"v\":1,\"count\":%d,\"bytes\":%u,\"crc\":\"0x%08lX\"}\n", + savedCount, (unsigned)payloadBytes, (unsigned long)crc); + + char line[384]; + size_t wrote = 0; + f.write((uint8_t*)"[", 1); wrote++; + for (int i = 0; i < fyDetCount; i++) { + if (i > 0) { f.write((uint8_t*)",", 1); wrote++; } + size_t n = fySerializeDet(fyDet[i], line, sizeof(line)); + if (n == 0) continue; + f.write((uint8_t*)line, n); + wrote += n; + } + f.write((uint8_t*)"]", 1); wrote++; + f.close(); + + if (wrote != payloadBytes) { + dualPrintf("[flockyou] save WARNING: wrote %u expected %u — aborting\n", + (unsigned)wrote, (unsigned)payloadBytes); + return; + } + + if (!fyValidateSessionFile(FY_SESSION_TMP)) { + dualPrintf("[flockyou] save verify FAILED — old session preserved\n"); + return; + } + + SPIFFS.remove(FY_SESSION_FILE); + if (!fyAtomicPromote(FY_SESSION_TMP, FY_SESSION_FILE)) { + dualPrintf("[flockyou] promote FAILED — data in %s for recovery\n", FY_SESSION_TMP); + return; + } + + fyLastSaveAt = millis(); + fyLastSaveCount = savedCount; + fyDirty = false; + dualPrintf("[flockyou] session saved: %d det, %u bytes, crc=0x%08lX\n", + savedCount, (unsigned)payloadBytes, (unsigned long)crc); +} + +// Promote any valid session file from last boot into /prev_session.json, then +// start this boot with a fresh empty table. Preserves history across power cycles. +static void fyPromotePrevSession() { + if (!fySpiffsReady) return; + + const char* source = nullptr; + if (fyValidateSessionFile(FY_SESSION_FILE)) source = FY_SESSION_FILE; + else if (fyValidateSessionFile(FY_SESSION_TMP)) source = FY_SESSION_TMP; + + if (!source) { + if (SPIFFS.exists(FY_SESSION_FILE)) SPIFFS.remove(FY_SESSION_FILE); + if (SPIFFS.exists(FY_SESSION_TMP)) SPIFFS.remove(FY_SESSION_TMP); + dualPrintln("[flockyou] no valid prior session to promote"); + return; + } + + if (!fySpiffsCopy(source, FY_PREV_FILE)) { + dualPrintf("[flockyou] failed to promote %s → %s\n", source, FY_PREV_FILE); + return; + } + if (SPIFFS.exists(FY_SESSION_FILE)) SPIFFS.remove(FY_SESSION_FILE); + if (SPIFFS.exists(FY_SESSION_TMP)) SPIFFS.remove(FY_SESSION_TMP); + + File v = SPIFFS.open(FY_PREV_FILE, "r"); + size_t sz = v ? v.size() : 0; + if (v) v.close(); + dualPrintf("[flockyou] prior session promoted from %s (%u bytes)\n", + source, (unsigned)sz); +} + +// ============================================================ +// FLASK-COMPATIBLE JSON EMISSION +// ============================================================ +// +// The Flask app (flock-you/api/flockyou.py) reads one JSON object per line +// from the USB CDC serial port. It filters by presence of `detection_method` +// and extracts these fields: mac_address, rssi, channel, frequency, ssid, +// device_name, gps.latitude, gps.longitude, gps.accuracy. +// +// GPS is handled Flask-side via its own USB NMEA puck or browser geolocation; +// we don't embed GPS here because there's no on-device AP / phone link. + +static void emitDetectionJSON(const char* mac, const char* method, + int8_t rssi, uint8_t ch, const char* ssid) { + char ssidEsc[sizeof(((FYDetection*)0)->ssid) * 6 + 1]; + jsonEscape(ssidEsc, sizeof(ssidEsc), ssid ? ssid : ""); + char oui[9]; + uint8_t mbytes[6] = {0}; + sscanf(mac, "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mbytes[0], &mbytes[1], &mbytes[2], &mbytes[3], &mbytes[4], &mbytes[5]); + ouiFromMac(mbytes, oui, sizeof(oui)); + + dualPrintf( + "{\"event\":\"detection\"," + "\"detection_method\":\"wifi_%s\"," + "\"protocol\":\"wifi_2_4ghz\"," + "\"mac_address\":\"%s\"," + "\"oui\":\"%s\"," + "\"device_name\":\"\"," + "\"rssi\":%d," + "\"channel\":%u," + "\"frequency\":%u," + "\"ssid\":\"%s\"}\n", + method, mac, oui, rssi, + (unsigned)ch, (unsigned)channelFreqMhz(ch), ssidEsc); +} + +// ============================================================ +// PROMISCUOUS CALLBACK — keep it fast, no Serial, no malloc +// ============================================================ + +static bool IRAM_ATTR extractSsidFromMgmtBody(const uint8_t* body, int len, + char* outSsid, size_t outLen) { + if (!body || len <= 0 || !outSsid || outLen == 0) return false; + while (len >= 2) { + uint8_t id = body[0], elen = body[1]; + if ((int)elen + 2 > len) break; + if (id == 0) { + size_t n = (elen < (outLen - 1)) ? elen : (outLen - 1); + memcpy(outSsid, body + 2, n); + outSsid[n] = '\0'; + return true; + } + body += elen + 2; len -= elen + 2; + } + return false; +} + +// Returns: +// 1 = wildcard SSID IE found (tag 0, length 0) → Flock-style probe +// 0 = SSID IE found, non-zero length → directed probe, not ours +// -1 = no SSID IE found at all → caller should retry with +// FCS-stripped length, then bail +static int IRAM_ATTR isWildcardProbeIE(const uint8_t* body, int len) { + if (!body || len < 2) return -1; + while (len >= 2) { + uint8_t id = body[0]; + uint8_t elen = body[1]; + if ((int)elen + 2 > len) break; + if (id == 0) return (elen == 0) ? 1 : 0; + body += elen + 2; + len -= elen + 2; + } + return -1; +} + +static void IRAM_ATTR wifiSniffer(void* buf, wifi_promiscuous_pkt_type_t type) { + if (!buf || sniffingStopped) return; + +#if PROCESS_MGMT_FRAMES && PROCESS_DATA_FRAMES + if (type != WIFI_PKT_MGMT && type != WIFI_PKT_DATA) return; +#elif PROCESS_MGMT_FRAMES + if (type != WIFI_PKT_MGMT) return; +#elif PROCESS_DATA_FRAMES + if (type != WIFI_PKT_DATA) return; +#else + return; // nothing configured to process +#endif + + wifi_promiscuous_pkt_t* pkt = (wifi_promiscuous_pkt_t*)buf; + if (pkt->rx_ctrl.sig_len < sizeof(wifi_ieee80211_mac_hdr_t)) return; + wifi_ieee80211_mac_hdr_t* hdr = (wifi_ieee80211_mac_hdr_t*)pkt->payload; + int8_t rssi = pkt->rx_ctrl.rssi; + + if (rssi < RSSI_MIN) return; + + uint8_t ch = (uint8_t)pkt->rx_ctrl.channel; // actual rx channel from driver + + // --- OUI check: addr2 (transmitter/source) --- + // + // For mgmt Probe Requests (type=0 subtype=4) from a matched OUI, tighten + // to the DeFlockJoplin wildcard-probe signature: SSID IE (tag 0) length + // must be zero. This reduces false positives dramatically (Michael's field + // test: 11/12 true-positive with only 2 false-positives in Joplin). + // + // Non-probe frames from the same OUI still emit the broad ADDR2 alert. + // See: https://github.com/DeflockJoplin/flock-you + if (matchOuiRaw(hdr->addr2)) { + bool emitted = false; + if (type == WIFI_PKT_MGMT) { + uint8_t fc0 = hdr->frame_ctrl & 0xFF; + uint8_t ftype = (fc0 >> 2) & 0x03; + uint8_t subtype = (fc0 >> 4) & 0x0F; + if (ftype == 0 && subtype == 4) { // Probe Request + int sigLen = (int)pkt->rx_ctrl.sig_len; + int bodyLen = sigLen - (int)sizeof(wifi_ieee80211_mac_hdr_t); + const uint8_t* body = pkt->payload + sizeof(wifi_ieee80211_mac_hdr_t); + int r = (bodyLen > 0) ? isWildcardProbeIE(body, bodyLen) : -1; + // FCS-trailer retry: only when the first parse found no SSID IE AT + // ALL (-1). A found-but-nonzero (0) means legit directed probe; do + // not retry — it would mis-classify. + if (r == -1 && bodyLen > 4) r = isWildcardProbeIE(body, bodyLen - 4); + if (r == 1) { + enqueueAlert(ALERT_WILDCARD_PROBE, hdr->addr2, rssi, ch, + nullptr, "probe_req"); + emitted = true; + } + } + } + if (!emitted) { + enqueueAlert(ALERT_OUI_ADDR2, hdr->addr2, rssi, ch, nullptr, "addr2"); + } + } + +#if CHECK_ADDR1 + // addr1 (receiver/destination): catches Flock STAs that appear only as the + // dst of probe responses and data frames — never transmitting in the capture + // window due to their burst-sleep duty cycle. Multicast guard is mandatory + // here since addr1 is broadcast (ff:ff:ff:ff:ff:ff) in beacons/broadcasts. + if (!isMulticast(hdr->addr1) && matchOuiRaw(hdr->addr1)) { + enqueueAlert(ALERT_OUI_ADDR1, hdr->addr1, rssi, ch, nullptr, "addr1"); + } +#endif + +#if CHECK_ADDR3 + // addr3 fallback: catches cases where addr2 is randomised but addr3 + // carries the real BSSID OUI (management frames only). + if (type == WIFI_PKT_MGMT && matchOuiRaw(hdr->addr3)) { + enqueueAlert(ALERT_OUI_ADDR3, hdr->addr3, rssi, ch, nullptr, "addr3"); + } +#endif + +#if ENABLE_SSID_MATCH + if (type == WIFI_PKT_MGMT) { + uint8_t fc0 = hdr->frame_ctrl & 0xFF; + uint8_t subtype = (fc0 >> 4) & 0x0F; + uint8_t ftype = (fc0 >> 2) & 0x03; + + if (ftype == 0) { + int sigLen = pkt->rx_ctrl.sig_len - 4; // strip 4-byte FCS + if (sigLen < (int)sizeof(wifi_ieee80211_mac_hdr_t)) return; + + const uint8_t* mgmtBody = nullptr; + int mgmtBodyLen = 0; + const char* frameKind = nullptr; + + if (subtype == 8 || subtype == 5) { + // Beacon / Probe Response: fixed params = 12 bytes after MAC hdr + int off = sizeof(wifi_ieee80211_mac_hdr_t) + 12; + if (sigLen > off) { + frameKind = (subtype == 8) ? "beacon" : "probe_resp"; + mgmtBody = pkt->payload + off; + mgmtBodyLen = sigLen - off; + } + } else if (subtype == 4) { + // Probe Request: IEs follow directly after MAC hdr + int off = sizeof(wifi_ieee80211_mac_hdr_t); + if (sigLen > off) { + frameKind = "probe_req"; + mgmtBody = pkt->payload + off; + mgmtBodyLen = sigLen - off; + } + } + + if (mgmtBody && mgmtBodyLen > 0) { + char ssid[33] = {0}; + if (extractSsidFromMgmtBody(mgmtBody, mgmtBodyLen, ssid, sizeof(ssid))) { + if (matchSsidKeyword(ssid)) { + enqueueAlert(ALERT_SSID, hdr->addr2, rssi, ch, ssid, frameKind); + } + } + } + } + } +#endif +} + +// ============================================================ +// DRAIN QUEUE — called from loop(), safe to Serial.print here +// ============================================================ + +static void drainAlertQueue() { + while (true) { + portENTER_CRITICAL(&queueMux); + if (alertTail == alertHead) { portEXIT_CRITICAL(&queueMux); break; } + AlertEntry e; + memcpy(&e, (const void*)&alertQueue[alertTail], sizeof(AlertEntry)); + alertTail = (alertTail + 1) % ALERT_QUEUE_SIZE; + portEXIT_CRITICAL(&queueMux); + + char macStr[18]; + macToStr(e.mac, macStr, sizeof(macStr)); + const char* method = alertTypeToMethod(e.type); + + // Always update the on-device detection table (survives reboot via SPIFFS). + // chirpWorthy = true for brand-new MACs AND for MACs rediscovered after + // REDISCOVER_MS of silence (drove away and came back). + bool chirpWorthy = false; + int idx = fyAddDetection(macStr, method, e.rssi, e.channel, + (e.type == ALERT_SSID) ? e.ssid : nullptr, + &chirpWorthy); + + // Refresh the global "still around" timer for the heartbeat tick. + // Done unconditionally so a device counts as active even when serial is + // rate-limited (still audible via heartbeat, just quieter on the wire). + fyLastTargetSeen = millis(); + + // Serial-rate-limit: suppress emit/beep/flash within ALERT_COOLDOWN_MS. + if (shouldSuppressDuplicate(macStr)) continue; + + // Human-readable line (for serial terminal / mirror). + char oui[9]; + ouiFromMac(e.mac, oui, sizeof(oui)); + if (e.type == ALERT_SSID) { + dualPrintf("[flockyou] DETECT-SSID type=%s mac=%s ssid=\"%s\" rssi=%d ch=%u count=%d\n", + e.frameKind, macStr, e.ssid, e.rssi, e.channel, + (idx >= 0) ? (int)fyDet[idx].count : 0); + } else { + dualPrintf("[flockyou] DETECT-OUI mac=%s oui=%s rssi=%d ch=%u addr=%s count=%d\n", + macStr, oui, e.rssi, e.channel, + e.frameKind[0] ? e.frameKind : "addr2", + (idx >= 0) ? (int)fyDet[idx].count : 0); + } + + // Flask-compatible JSON line (parsed by api/flockyou.py over USB CDC). + emitDetectionJSON(macStr, method, e.rssi, e.channel, + (e.type == ALERT_SSID) ? e.ssid : ""); + + // Audio feedback: + // - NEW MAC → two fast ascending beeps (clearly distinct sound) + // - REPEAT → silent; the heartbeat tick covers continued presence + // LED flashes on every emitted detection either way. + if (chirpWorthy) { + newDetectChirp(); + // Reset the heartbeat phase so the first follow-up beep lands + // HB_BEEP_INTERVAL_MS after the initial chirp, not mid-window. + fyLastHeartbeatAt = millis(); + } + ledFlash(LED_FLASH_MS); + +#if STOP_ON_OUI_HIT + if (e.type != ALERT_SSID) stopSniffing("OUI hit"); +#endif +#if STOP_ON_SSID_HIT + if (e.type == ALERT_SSID) stopSniffing("SSID hit"); +#endif + } +} + +// ============================================================ +// AUTOSAVE +// ============================================================ + +static void autosaveTick() { + if (!fySpiffsReady || !fyDirty) return; + if (millis() - fyLastSaveAt < AUTOSAVE_INTERVAL_MS) return; + fySaveSession(); +} + +// Heartbeat beep while at least one target was seen in the last +// HB_DEVICE_ACTIVE_MS. Fires HB_BEEP_INTERVAL_MS apart. +static void heartbeatTick() { + if (fyLastTargetSeen == 0) return; // never seen one + unsigned long now = millis(); + if (now - fyLastTargetSeen > HB_DEVICE_ACTIVE_MS) return; // gone silent + if (now - fyLastHeartbeatAt < HB_BEEP_INTERVAL_MS) return; // too soon + heartbeatBeep(); + fyLastHeartbeatAt = now; +} + +// ============================================================ +// SETUP / LOOP +// ============================================================ + +void setup() { + Serial.begin(115200); + // Crucial for USB-optional operation: without this, Serial.write() will + // block indefinitely on an ESP32-S3 USB-CDC port when no host is attached. + Serial.setTxTimeoutMs(0); + delay(300); + +#if MIRROR_SERIAL + Serial1.begin(MIRROR_BAUD, SERIAL_8N1, -1, MIRROR_TX_PIN); // TX-only on GPIO43 +#endif + +#if USE_BUZZER + pinMode(BUZZER_PIN, OUTPUT); + digitalWrite(BUZZER_PIN, LOW); +#endif + +#if USE_LED + pinMode(LED_PIN, OUTPUT); + ledSet(false); +#endif + + startupBeep(); +#if USE_LED + ledFlash(200); +#endif + + precompileOuis(); + memset(dedupeTable, 0, sizeof(dedupeTable)); + + // SPIFFS — format on first boot if missing. Non-fatal if it fails. + if (SPIFFS.begin(true)) { + fySpiffsReady = true; + dualPrintln("[flockyou] SPIFFS ready"); + fyPromotePrevSession(); + } else { + dualPrintln("[flockyou] SPIFFS init FAILED — running without persistence"); + } + + WiFi.mode(WIFI_MODE_NULL); + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + esp_wifi_init(&cfg); + esp_wifi_set_storage(WIFI_STORAGE_RAM); + esp_wifi_set_mode(WIFI_MODE_NULL); + esp_wifi_start(); + + applyInitialChannel(); + + wifi_promiscuous_filter_t filt = { + .filter_mask = 0 +#if PROCESS_MGMT_FRAMES + | WIFI_PROMIS_FILTER_MASK_MGMT +#endif +#if PROCESS_DATA_FRAMES + | WIFI_PROMIS_FILTER_MASK_DATA +#endif + }; + esp_wifi_set_promiscuous_filter(&filt); + esp_wifi_set_promiscuous_rx_cb(&wifiSniffer); + esp_wifi_set_promiscuous(true); + + dualPrintln("[flockyou] merged WiFi detector started"); + dualPrintf("[flockyou] mode=%s dwell_ms=%u start_channel=%u rssi_min=%d spiffs=%d\n", + channelModeName(), CHANNEL_DWELL_MS, currentChannel, + RSSI_MIN, fySpiffsReady ? 1 : 0); + + lastHeartbeat = millis(); + fyLastSaveAt = millis(); +} + +void loop() { + updateChannelMode(); + drainAlertQueue(); // Serial.printf happens here, not in callback + autosaveTick(); // periodic SPIFFS write if dirty + heartbeatTick(); // audible beep-pair while a target is still in range + ledTick(); // turn off LED after LED_FLASH_MS + printHeartbeat(); + delay(1); +} diff --git a/platformio.ini b/platformio.ini index 4bf3b22..2b3da17 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,3 +1,6 @@ +[platformio] +src_dir = . + [env:xiao_esp32s3] platform = espressif32@^6.3.0 board = seeed_xiao_esp32s3 @@ -5,26 +8,13 @@ framework = arduino monitor_speed = 115200 upload_speed = 921600 -; Build options -build_flags = +build_flags = -DCORE_DEBUG_LEVEL=0 -DARDUINO_USB_CDC_ON_BOOT=1 -DBOARD_HAS_PSRAM - -mfix-esp32-psram-cache-issue - -DCONFIG_BT_NIMBLE_ENABLED=1 -; Libraries -lib_deps = - h2zero/NimBLE-Arduino@^1.4.0 - mathieucarbou/ESP Async WebServer@^3.0.6 - bblanchon/ArduinoJson@^7.0.4 +build_src_filter = + -; Board configuration board_build.arduino.memory_type = qio_opi board_build.partitions = partitions.csv board_build.filesystem = spiffs - -; USB CDC configuration -board_build.f_cpu = 240000000L -board_build.f_flash = 80000000L -board_build.flash_mode = qio diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt deleted file mode 100644 index 483bc0c..0000000 --- a/src/CMakeLists.txt +++ /dev/null @@ -1,6 +0,0 @@ -# This file was automatically generated for projects -# without default 'CMakeLists.txt' file. - -FILE(GLOB_RECURSE app_sources ${CMAKE_SOURCE_DIR}/src/*.*) - -idf_component_register(SRCS ${app_sources}) diff --git a/src/main.cpp b/src/main.cpp deleted file mode 100644 index d6bba1b..0000000 --- a/src/main.cpp +++ /dev/null @@ -1,1476 +0,0 @@ -// ============================================================================ -// FLOCK-YOU: Surveillance Device Detector with Web Dashboard -// ============================================================================ -// Detection methods (BLE only - WiFi radio used for AP): -// 1. BLE MAC prefix matching (known Flock Safety OUIs) -// 2. BLE device name pattern matching (case-insensitive substring) -// 3. BLE manufacturer company ID matching (0x09C8 XUNTONG) [from wgreenberg] -// 4. Raven gunshot detector service UUID matching -// 5. Raven firmware version estimation from service UUID patterns -// -// WiFi AP "flockyou" / "flockyou123" serves web dashboard at 192.168.4.1 -// All detections stored in memory, exportable as JSON or CSV -// Optional WiFi STA connection for future features -// ============================================================================ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "esp_wifi.h" - -// ============================================================================ -// CONFIGURATION -// ============================================================================ - -#define BUZZER_PIN 3 - -// Audio -#define LOW_FREQ 200 -#define HIGH_FREQ 800 -#define DETECT_FREQ 1000 -#define HEARTBEAT_FREQ 600 -#define BOOT_BEEP_DURATION 300 -#define DETECT_BEEP_DURATION 150 -#define HEARTBEAT_DURATION 100 - -// BLE scanning (mutable — companion mode increases scan duty cycle) -static int fyBleScanDuration = 2; // seconds per scan -static unsigned long fyBleScanInterval = 3000; // ms between scans - -// Detection storage -#define MAX_DETECTIONS 200 - -// WiFi AP credentials -#define FY_AP_SSID "flockyou" -#define FY_AP_PASS "flockyou123" - -// ============================================================================ -// DETECTION PATTERNS -// ============================================================================ - -// MAC address prefixes (OUIs) - -// Flock Safety — high-confidence OUIs (direct registration or exclusive use) -static const char* flock_mac_prefixes[] = { - // FS Ext Battery devices - "58:8e:81", "cc:cc:cc", "ec:1b:bd", "90:35:ea", "04:0d:84", - "f0:82:c0", "1c:34:f1", "38:5b:44", "94:34:69", "b4:e3:f9", - // Flock WiFi devices - "70:c9:4e", "3c:91:80", "d8:f3:bc", "80:30:49", "14:5a:fc", - "74:4c:a1", "08:3a:88", "9c:2f:9d", "94:08:53", "e4:aa:ea", - // Flock Safety (direct IEEE registration) - "b4:1e:52" -}; - -// Flock Safety contract manufacturers — lower confidence alone. -// These OUIs belong to Liteon Technology and USI (Universal Scientific -// Industrial), which produce Flock hardware but also ship unrelated -// consumer/enterprise devices. MAC match alone may be a false positive. -static const char* flock_mfr_mac_prefixes[] = { - "f4:6a:dd", "f8:a2:d6", "e0:0a:f6", "00:f4:8d", "d0:39:57", - "e8:d0:fc" -}; - -// SoundThinking (formerly ShotSpotter) — acoustic gunshot detection sensors. -// d4:11:d6 is registered to SoundThinking in the IEEE OUI database. -static const char* soundthinking_mac_prefixes[] = { - "d4:11:d6" -}; - -// BLE device name patterns (matched case-insensitive substring) -static const char* device_name_patterns[] = { - "FS Ext Battery", - "Penguin", - "Flock", - "Pigvision" -}; - -// BLE Manufacturer Company IDs -// Source: wgreenberg/flock-you - XUNTONG ID associated with Flock Safety devices -static const uint16_t ble_manufacturer_ids[] = { - 0x09C8 // XUNTONG -}; - -// ============================================================================ -// RAVEN SURVEILLANCE DEVICE UUID PATTERNS -// ============================================================================ - -#define RAVEN_DEVICE_INFO_SERVICE "0000180a-0000-1000-8000-00805f9b34fb" -#define RAVEN_GPS_SERVICE "00003100-0000-1000-8000-00805f9b34fb" -#define RAVEN_POWER_SERVICE "00003200-0000-1000-8000-00805f9b34fb" -#define RAVEN_NETWORK_SERVICE "00003300-0000-1000-8000-00805f9b34fb" -#define RAVEN_UPLOAD_SERVICE "00003400-0000-1000-8000-00805f9b34fb" -#define RAVEN_ERROR_SERVICE "00003500-0000-1000-8000-00805f9b34fb" -#define RAVEN_OLD_HEALTH_SERVICE "00001809-0000-1000-8000-00805f9b34fb" -#define RAVEN_OLD_LOCATION_SERVICE "00001819-0000-1000-8000-00805f9b34fb" - -static const char* raven_service_uuids[] = { - RAVEN_DEVICE_INFO_SERVICE, - RAVEN_GPS_SERVICE, - RAVEN_POWER_SERVICE, - RAVEN_NETWORK_SERVICE, - RAVEN_UPLOAD_SERVICE, - RAVEN_ERROR_SERVICE, - RAVEN_OLD_HEALTH_SERVICE, - RAVEN_OLD_LOCATION_SERVICE -}; - -// ============================================================================ -// DETECTION STORAGE -// ============================================================================ - -struct FYDetection { - char mac[18]; - char name[48]; - int rssi; - char method[32]; - unsigned long firstSeen; - unsigned long lastSeen; - int count; - bool isRaven; - char ravenFW[16]; - // GPS from phone (wardriving) - double gpsLat; - double gpsLon; - float gpsAcc; - bool hasGPS; -}; - -static FYDetection fyDet[MAX_DETECTIONS]; -static int fyDetCount = 0; -static SemaphoreHandle_t fyMutex = NULL; // guards fyDet[] + fyDetCount -static SemaphoreHandle_t fyGPSMutex = NULL; // guards fyGPS* globals - -// ============================================================================ -// GLOBALS -// ============================================================================ - -static bool fyBuzzerOn = true; -static unsigned long fyLastBleScan = 0; -static bool fyTriggered = false; -static bool fyDeviceInRange = false; -static unsigned long fyLastDetTime = 0; -static unsigned long fyLastHB = 0; -static NimBLEScan* fyBLEScan = NULL; -static AsyncWebServer fyServer(80); - -// BLE GATT server (DeFlock app connectivity) -#define FY_SERVICE_UUID "a1b2c3d4-e5f6-7890-abcd-ef0123456789" -#define FY_TX_CHAR_UUID "a1b2c3d4-e5f6-7890-abcd-ef01234567aa" -static NimBLEServer* fyBLEServer = NULL; -static NimBLECharacteristic* fyTxChar = NULL; -static volatile bool fyBLEClientConnected = false; -static volatile uint16_t fyNegotiatedMTU = 23; - -// Serial host detection (USB heartbeat from DeFlock desktop) -static volatile bool fySerialHostConnected = false; -static unsigned long fyLastSerialHeartbeat = 0; -#define FY_SERIAL_TIMEOUT_MS 5000 - -// Deferred companion mode switch — BLE callbacks set this flag, -// loop() applies the WiFi/scan changes in the Arduino task context. -static volatile bool fyCompanionChangePending = false; - -// Phone GPS state (updated via browser Geolocation API -> /api/gps) -static double fyGPSLat = 0; -static double fyGPSLon = 0; -static float fyGPSAcc = 0; -static bool fyGPSValid = false; -static unsigned long fyGPSLastUpdate = 0; -#define GPS_STALE_MS 30000 // GPS considered stale after 30s without update - -// Session persistence (SPIFFS) -#define FY_SESSION_FILE "/session.json" -#define FY_PREV_FILE "/prev_session.json" -#define FY_SAVE_INTERVAL 15000 // Auto-save every 15 seconds (prevent data loss on quick power-cycle) -static unsigned long fyLastSave = 0; -static int fyLastSaveCount = 0; // Track changes to avoid unnecessary writes -static bool fySpiffsReady = false; - -// ============================================================================ -// AUDIO SYSTEM -// ============================================================================ - -static void fyBeep(int freq, int dur) { - if (!fyBuzzerOn) return; - tone(BUZZER_PIN, freq, dur); - delay(dur + 50); -} - -// Crow caw: harsh descending sweep with warble texture -static void fyCaw(int startFreq, int endFreq, int durationMs, int warbleHz) { - if (!fyBuzzerOn) return; - int steps = durationMs / 8; // 8ms per step - float fStep = (float)(endFreq - startFreq) / steps; - for (int i = 0; i < steps; i++) { - int f = startFreq + (int)(fStep * i); - // Add warble: oscillate frequency +/- for raspy texture - if (warbleHz > 0 && (i % 3 == 0)) { - f += ((i % 6 < 3) ? warbleHz : -warbleHz); - } - if (f < 100) f = 100; - tone(BUZZER_PIN, f, 10); - delay(8); - } - noTone(BUZZER_PIN); -} - -static void fyBootBeep() { - printf("[FLOCK-YOU] Boot sound (buzzer %s)\n", fyBuzzerOn ? "ON" : "OFF"); - if (!fyBuzzerOn) return; - - // === CROW CALL SEQUENCE === - // Caw 1: sharp descending caw - fyCaw(850, 380, 180, 40); - delay(100); - - // Caw 2: slightly lower, shorter - fyCaw(780, 350, 150, 50); - delay(100); - - // Caw 3: longer trailing caw with more rasp - fyCaw(820, 280, 220, 60); - delay(80); - - // Quick staccato ending "kk-kk" - tone(BUZZER_PIN, 600, 25); delay(40); - tone(BUZZER_PIN, 550, 25); delay(40); - noTone(BUZZER_PIN); - - printf("[FLOCK-YOU] *caw caw caw*\n"); -} - -static void fyDetectBeep() { - printf("[FLOCK-YOU] Detection alert!\n"); - if (!fyBuzzerOn) return; - // Alarm crow: two sharp ascending chirps then a caw - fyCaw(400, 900, 100, 30); // rising alarm chirp - delay(60); - fyCaw(450, 950, 100, 30); // second chirp, higher - delay(60); - fyCaw(900, 350, 200, 50); // descending caw -} - -static void fyHeartbeat() { - if (!fyBuzzerOn) return; - // Soft double coo - like a distant crow - fyCaw(500, 400, 80, 20); - delay(120); - fyCaw(480, 380, 80, 20); -} - -// ============================================================================ -// DETECTION HELPERS -// ============================================================================ - -static bool checkFlockMAC(const char* mac_str) { - for (size_t i = 0; i < sizeof(flock_mac_prefixes)/sizeof(flock_mac_prefixes[0]); i++) { - if (strncasecmp(mac_str, flock_mac_prefixes[i], 8) == 0) return true; - } - return false; -} - -static bool checkFlockMfrMAC(const char* mac_str) { - for (size_t i = 0; i < sizeof(flock_mfr_mac_prefixes)/sizeof(flock_mfr_mac_prefixes[0]); i++) { - if (strncasecmp(mac_str, flock_mfr_mac_prefixes[i], 8) == 0) return true; - } - return false; -} - -static bool checkSoundThinkingMAC(const char* mac_str) { - for (size_t i = 0; i < sizeof(soundthinking_mac_prefixes)/sizeof(soundthinking_mac_prefixes[0]); i++) { - if (strncasecmp(mac_str, soundthinking_mac_prefixes[i], 8) == 0) return true; - } - return false; -} - -static bool checkDeviceName(const char* name) { - if (!name || !name[0]) return false; - for (size_t i = 0; i < sizeof(device_name_patterns)/sizeof(device_name_patterns[0]); i++) { - if (strcasestr(name, device_name_patterns[i])) return true; - } - return false; -} - -static bool checkManufacturerID(uint16_t id) { - for (size_t i = 0; i < sizeof(ble_manufacturer_ids)/sizeof(ble_manufacturer_ids[0]); i++) { - if (ble_manufacturer_ids[i] == id) return true; - } - return false; -} - -// ============================================================================ -// RAVEN UUID DETECTION -// ============================================================================ - -static bool checkRavenUUID(NimBLEAdvertisedDevice* device, char* out_uuid = nullptr) { - if (!device || !device->haveServiceUUID()) return false; - int count = device->getServiceUUIDCount(); - if (count == 0) return false; - for (int i = 0; i < count; i++) { - NimBLEUUID svc = device->getServiceUUID(i); - std::string str = svc.toString(); - for (size_t j = 0; j < sizeof(raven_service_uuids)/sizeof(raven_service_uuids[0]); j++) { - if (strcasecmp(str.c_str(), raven_service_uuids[j]) == 0) { - if (out_uuid) strncpy(out_uuid, str.c_str(), 40); - return true; - } - } - } - return false; -} - -static const char* estimateRavenFW(NimBLEAdvertisedDevice* device) { - if (!device || !device->haveServiceUUID()) return "?"; - bool has_new_gps = false, has_old_loc = false, has_power = false; - int count = device->getServiceUUIDCount(); - for (int i = 0; i < count; i++) { - std::string u = device->getServiceUUID(i).toString(); - if (strcasecmp(u.c_str(), RAVEN_GPS_SERVICE) == 0) has_new_gps = true; - if (strcasecmp(u.c_str(), RAVEN_OLD_LOCATION_SERVICE) == 0) has_old_loc = true; - if (strcasecmp(u.c_str(), RAVEN_POWER_SERVICE) == 0) has_power = true; - } - if (has_old_loc && !has_new_gps) return "1.1.x"; - if (has_new_gps && !has_power) return "1.2.x"; - if (has_new_gps && has_power) return "1.3.x"; - return "?"; -} - -// ============================================================================ -// GPS HELPERS (mutex-protected snapshot pattern) -// ============================================================================ - -// Fast advisory check — safe lock-free (for UI/stats only, don't trust for writes) -static bool fyGPSIsFresh() { - return fyGPSValid && (millis() - fyGPSLastUpdate < GPS_STALE_MS); -} - -// Atomic snapshot: returns true and fills out-params if GPS is fresh & valid. -// Safe to call from BLE callback context — never races with producer. -static bool fyGPSSnapshot(double& lat, double& lon, float& acc) { - if (!fyGPSMutex) return false; - if (xSemaphoreTake(fyGPSMutex, pdMS_TO_TICKS(20)) != pdTRUE) return false; - bool fresh = fyGPSValid && (millis() - fyGPSLastUpdate < GPS_STALE_MS); - if (fresh) { lat = fyGPSLat; lon = fyGPSLon; acc = fyGPSAcc; } - xSemaphoreGive(fyGPSMutex); - return fresh; -} - -// Atomic GPS publish (from phone via /api/gps or from companion app) -static void fyGPSUpdate(double lat, double lon, float acc) { - if (!fyGPSMutex) return; - if (xSemaphoreTake(fyGPSMutex, pdMS_TO_TICKS(20)) != pdTRUE) return; - fyGPSLat = lat; - fyGPSLon = lon; - fyGPSAcc = acc; - fyGPSValid = true; - fyGPSLastUpdate = millis(); - xSemaphoreGive(fyGPSMutex); -} - -// Stamp a detection with current GPS if available (used at first-sight and re-sight) -static void fyAttachGPS(FYDetection& d) { - double lat, lon; - float acc; - if (fyGPSSnapshot(lat, lon, acc)) { - d.hasGPS = true; - d.gpsLat = lat; - d.gpsLon = lon; - d.gpsAcc = acc; - } -} - -// Periodic: back-fill GPS on detections recorded before a fix was available. -// Runs in main loop — MUST NOT be called from BLE callback (takes fyMutex). -static void fyBackfillGPS() { - double lat, lon; - float acc; - if (!fyGPSSnapshot(lat, lon, acc)) return; - if (!fyMutex || xSemaphoreTake(fyMutex, pdMS_TO_TICKS(50)) != pdTRUE) return; - int filled = 0; - for (int i = 0; i < fyDetCount; i++) { - if (!fyDet[i].hasGPS) { - fyDet[i].hasGPS = true; - fyDet[i].gpsLat = lat; - fyDet[i].gpsLon = lon; - fyDet[i].gpsAcc = acc; - filled++; - } - } - xSemaphoreGive(fyMutex); - if (filled) printf("[FLOCK-YOU] GPS backfilled %d detection(s)\n", filled); -} - -// ============================================================================ -// CRC32 (IEEE 802.3) — for session file integrity -// ============================================================================ - -static uint32_t fyCRC32Update(uint32_t crc, const uint8_t* data, size_t len) { - crc = ~crc; - while (len--) { - crc ^= *data++; - for (int k = 0; k < 8; k++) crc = (crc >> 1) ^ (0xEDB88320UL & -(crc & 1)); - } - return ~crc; -} - -// ============================================================================ -// DETECTION MANAGEMENT -// ============================================================================ - -static int fyAddDetection(const char* mac, const char* name, int rssi, - const char* method, bool isRaven = false, - const char* ravenFW = "") { - if (!fyMutex || xSemaphoreTake(fyMutex, pdMS_TO_TICKS(100)) != pdTRUE) return -1; - - // Update existing by MAC - for (int i = 0; i < fyDetCount; i++) { - if (strcasecmp(fyDet[i].mac, mac) == 0) { - fyDet[i].count++; - fyDet[i].lastSeen = millis(); - fyDet[i].rssi = rssi; - if (name && name[0]) { - strncpy(fyDet[i].name, name, sizeof(fyDet[i].name) - 1); - } - // Update GPS on every re-sighting (captures movement) - fyAttachGPS(fyDet[i]); - xSemaphoreGive(fyMutex); - return i; - } - } - - // Add new - if (fyDetCount < MAX_DETECTIONS) { - FYDetection& d = fyDet[fyDetCount]; - memset(&d, 0, sizeof(d)); - strncpy(d.mac, mac, sizeof(d.mac) - 1); - // Sanitize name for JSON safety - if (name) { - for (int j = 0; j < (int)sizeof(d.name) - 1 && name[j]; j++) { - d.name[j] = (name[j] == '"' || name[j] == '\\') ? '_' : name[j]; - } - } - d.rssi = rssi; - strncpy(d.method, method, sizeof(d.method) - 1); - d.firstSeen = millis(); - d.lastSeen = millis(); - d.count = 1; - d.isRaven = isRaven; - strncpy(d.ravenFW, ravenFW ? ravenFW : "", sizeof(d.ravenFW) - 1); - // Attach GPS from phone - fyAttachGPS(d); - int idx = fyDetCount++; - xSemaphoreGive(fyMutex); - return idx; - } - - xSemaphoreGive(fyMutex); - return -1; -} - -// ============================================================================ -// BLE GATT SERVER (DeFlock companion connectivity) -// ============================================================================ - -class FYServerCallbacks : public NimBLEServerCallbacks { - void onConnect(NimBLEServer* pServer, ble_gap_conn_desc* desc) override { - fyBLEClientConnected = true; - fyCompanionChangePending = true; - } - void onDisconnect(NimBLEServer* pServer, ble_gap_conn_desc* desc) override { - fyBLEClientConnected = false; - fyNegotiatedMTU = 23; - NimBLEDevice::startAdvertising(); - fyCompanionChangePending = true; - } - void onMTUChange(uint16_t mtu, ble_gap_conn_desc* desc) override { - fyNegotiatedMTU = mtu; - printf("[FLOCK-YOU] MTU negotiated: %u\n", mtu); - } -}; - -static void fySendBLE(const char* data, size_t len) { - if (!fyBLEClientConnected || !fyTxChar) return; - uint16_t chunkSize = fyNegotiatedMTU - 3; - if (chunkSize < 1) chunkSize = 1; - if (len <= chunkSize) { - fyTxChar->setValue((const uint8_t*)data, len); - fyTxChar->notify(); - } else { - size_t offset = 0; - while (offset < len) { - size_t remaining = len - offset; - size_t send = remaining < chunkSize ? remaining : chunkSize; - fyTxChar->setValue((const uint8_t*)(data + offset), send); - fyTxChar->notify(); - offset += send; - } - } -} - -// ============================================================================ -// COMPANION MODE (WiFi AP vs BLE/serial) -// ============================================================================ - -static void fyOnCompanionChange() { - if (fyBLEClientConnected || fySerialHostConnected) { - // Companion mode — disable WiFi AP, boost BLE scanning - WiFi.softAPdisconnect(true); - WiFi.mode(WIFI_OFF); - fyBleScanDuration = 3; - printf("[FLOCK-YOU] Companion mode: WiFi AP OFF, scan duration %ds\n", - fyBleScanDuration); - } else { - // Standalone mode — re-enable WiFi AP and web dashboard - WiFi.mode(WIFI_AP); - delay(100); - WiFi.softAP(FY_AP_SSID, FY_AP_PASS); - fyBleScanDuration = 2; - printf("[FLOCK-YOU] Standalone mode: WiFi AP ON (%s), scan duration %ds\n", - FY_AP_SSID, fyBleScanDuration); - } -} - -// ============================================================================ -// BLE SCANNING -// ============================================================================ - -class FYBLECallbacks : public NimBLEAdvertisedDeviceCallbacks { - void onResult(NimBLEAdvertisedDevice* dev) override { - NimBLEAddress addr = dev->getAddress(); - std::string addrStr = addr.toString(); - - // Extract MAC prefix string for OUI checks - char macPrefix[9]; - snprintf(macPrefix, sizeof(macPrefix), "%.8s", addrStr.c_str()); - - int rssi = dev->getRSSI(); - std::string name = dev->haveName() ? dev->getName() : ""; - - bool detected = false; - bool highConfidence = true; - const char* method = ""; - bool isRaven = false; - const char* ravenFW = ""; - - // 1. Check Flock Safety direct OUIs (high confidence) - if (checkFlockMAC(macPrefix)) { - detected = true; - method = "mac_prefix"; - } - - // 2. Check SoundThinking/ShotSpotter OUIs (high confidence) - if (!detected && checkSoundThinkingMAC(macPrefix)) { - detected = true; - method = "mac_prefix_soundthinking"; - } - - // 3. Check Flock contract manufacturer OUIs (low confidence) - if (!detected && checkFlockMfrMAC(macPrefix)) { - detected = true; - method = "mac_prefix_mfr"; - highConfidence = false; - } - - // 4. Check BLE device name patterns - if (!detected && !name.empty() && checkDeviceName(name.c_str())) { - detected = true; - method = "device_name"; - } - - // 5. Check BLE manufacturer company IDs (from wgreenberg/flock-you) - if (!detected) { - for (int i = 0; i < (int)dev->getManufacturerDataCount(); i++) { - std::string data = dev->getManufacturerData(i); - if (data.size() >= 2) { - uint16_t code = ((uint16_t)(uint8_t)data[1] << 8) | - (uint16_t)(uint8_t)data[0]; - if (checkManufacturerID(code)) { - detected = true; - method = "ble_mfr_id"; - break; - } - } - } - } - - // 6. Check Raven gunshot detector service UUIDs - if (!detected) { - char detUUID[41] = {0}; - if (checkRavenUUID(dev, detUUID)) { - detected = true; - method = "raven_uuid"; - isRaven = true; - ravenFW = estimateRavenFW(dev); - } - } - - if (detected) { - int idx = fyAddDetection(addrStr.c_str(), name.c_str(), rssi, - method, isRaven, ravenFW); - - // Human-readable log - printf("[FLOCK-YOU] DETECTED: %s %s RSSI:%d [%s] count:%d\n", - addrStr.c_str(), name.c_str(), rssi, method, - idx >= 0 ? fyDet[idx].count : 0); - - // JSON output — build into buffer for serial + BLE - // Use atomic snapshot to avoid races with /api/gps writer - char gpsBuf[80] = ""; - { - double sLat, sLon; float sAcc; - if (fyGPSSnapshot(sLat, sLon, sAcc)) { - snprintf(gpsBuf, sizeof(gpsBuf), - ",\"gps\":{\"latitude\":%.8f,\"longitude\":%.8f,\"accuracy\":%.1f}", - sLat, sLon, sAcc); - } - } - char jsonBuf[512]; - int jsonLen = snprintf(jsonBuf, sizeof(jsonBuf), - "{\"event\":\"detection\",\"detection_method\":\"%s\"," - "\"protocol\":\"bluetooth_le\",\"mac_address\":\"%s\"," - "\"device_name\":\"%s\",\"rssi\":%d," - "\"is_raven\":%s,\"raven_fw\":\"%s\"%s}", - method, addrStr.c_str(), name.c_str(), rssi, - isRaven ? "true" : "false", isRaven ? ravenFW : "", gpsBuf); - printf("%s\n", jsonBuf); - // Append newline for BLE framing and send - if (jsonLen > 0 && jsonLen < (int)sizeof(jsonBuf) - 1) { - jsonBuf[jsonLen] = '\n'; - fySendBLE(jsonBuf, jsonLen + 1); - } - - if (!fyTriggered && highConfidence) { - fyTriggered = true; - fyDetectBeep(); - } - if (highConfidence) { - fyDeviceInRange = true; - fyLastDetTime = millis(); - fyLastHB = millis(); - } - } - } -}; - -// ============================================================================ -// JSON HELPER -// ============================================================================ - -static void writeDetectionsJSON(AsyncResponseStream *resp) { - resp->print("["); - if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(500)) == pdTRUE) { - for (int i = 0; i < fyDetCount; i++) { - if (i > 0) resp->print(","); - resp->printf( - "{\"mac\":\"%s\",\"name\":\"%s\",\"rssi\":%d,\"method\":\"%s\"," - "\"first\":%lu,\"last\":%lu,\"count\":%d," - "\"raven\":%s,\"fw\":\"%s\"", - fyDet[i].mac, fyDet[i].name, fyDet[i].rssi, fyDet[i].method, - fyDet[i].firstSeen, fyDet[i].lastSeen, fyDet[i].count, - fyDet[i].isRaven ? "true" : "false", fyDet[i].ravenFW); - // Append GPS if present - if (fyDet[i].hasGPS) { - resp->printf(",\"gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}", - fyDet[i].gpsLat, fyDet[i].gpsLon, fyDet[i].gpsAcc); - } - resp->print("}"); - } - xSemaphoreGive(fyMutex); - } - resp->print("]"); -} - -// ============================================================================ -// SESSION PERSISTENCE (SPIFFS) — bulletproof envelope format -// ============================================================================ -// -// Wire format on disk: -// Line 1: {"v":1,"count":N,"bytes":B,"crc":"0xXXXXXXXX"}\n -// Line 2+: [{"mac":...},{"mac":...},...] (exactly B bytes, CRC32 == X) -// -// Atomic write procedure: -// 1. Compute size+CRC over the detections payload (pass 1, under fyMutex) -// 2. Write envelope header + payload to /session.tmp (pass 2, under same lock) -// 3. Remove /session.json -// 4. Rename /session.tmp → /session.json (with copy+delete fallback) -// -// Recovery: if /session.json is missing or CRC-invalid, fall back to /session.tmp. - -#define FY_SESSION_TMP "/session.tmp" - -// Serialize a single detection to `dst`. Returns bytes written (0 on overflow). -static size_t fySerializeDet(const FYDetection& d, char* dst, size_t cap) { - int n; - if (d.hasGPS) { - n = snprintf(dst, cap, - "{\"mac\":\"%s\",\"name\":\"%s\",\"rssi\":%d,\"method\":\"%s\"," - "\"first\":%lu,\"last\":%lu,\"count\":%d," - "\"raven\":%s,\"fw\":\"%s\"," - "\"gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}}", - d.mac, d.name, d.rssi, d.method, - d.firstSeen, d.lastSeen, d.count, - d.isRaven ? "true" : "false", d.ravenFW, - d.gpsLat, d.gpsLon, d.gpsAcc); - } else { - n = snprintf(dst, cap, - "{\"mac\":\"%s\",\"name\":\"%s\",\"rssi\":%d,\"method\":\"%s\"," - "\"first\":%lu,\"last\":%lu,\"count\":%d," - "\"raven\":%s,\"fw\":\"%s\"}", - d.mac, d.name, d.rssi, d.method, - d.firstSeen, d.lastSeen, d.count, - d.isRaven ? "true" : "false", d.ravenFW); - } - return (n > 0 && (size_t)n < cap) ? (size_t)n : 0; -} - -// Pass 1: compute exact payload size + CRC32 without allocating. -// Caller MUST hold fyMutex. -static uint32_t fyComputePayloadCRC(size_t& outBytes) { - char line[512]; - uint32_t crc = 0; - outBytes = 0; - crc = fyCRC32Update(crc, (const uint8_t*)"[", 1); outBytes += 1; - for (int i = 0; i < fyDetCount; i++) { - if (i > 0) { crc = fyCRC32Update(crc, (const uint8_t*)",", 1); outBytes += 1; } - size_t n = fySerializeDet(fyDet[i], line, sizeof(line)); - if (n == 0) continue; - crc = fyCRC32Update(crc, (const uint8_t*)line, n); - outBytes += n; - } - crc = fyCRC32Update(crc, (const uint8_t*)"]", 1); outBytes += 1; - return crc; -} - -// Validate a session file envelope and its payload CRC. Returns true if intact. -static bool fyValidateSessionFile(const char* path) { - if (!SPIFFS.exists(path)) return false; - File f = SPIFFS.open(path, "r"); - if (!f) return false; - - String hdr = f.readStringUntil('\n'); - if (hdr.length() < 10 || hdr[0] != '{') { f.close(); return false; } - - JsonDocument doc; - if (deserializeJson(doc, hdr) != DeserializationError::Ok) { f.close(); return false; } - if ((int)(doc["v"] | 0) != 1) { f.close(); return false; } - size_t expectedBytes = (size_t)(doc["bytes"] | 0); - uint32_t expectedCRC = 0; - const char* crcStr = doc["crc"] | ""; - if (sscanf(crcStr, "%x", &expectedCRC) != 1) { f.close(); return false; } - - size_t bodyOffset = hdr.length() + 1; - size_t fileSize = f.size(); - if (fileSize < bodyOffset + expectedBytes) { f.close(); return false; } - size_t actualBytes = fileSize - bodyOffset; - if (actualBytes != expectedBytes) { f.close(); return false; } - - uint8_t buf[256]; - uint32_t crc = 0; - size_t remaining = expectedBytes; - while (remaining > 0) { - int n = f.read(buf, remaining < sizeof(buf) ? remaining : sizeof(buf)); - if (n <= 0) break; - crc = fyCRC32Update(crc, buf, (size_t)n); - remaining -= (size_t)n; - } - f.close(); - return (remaining == 0 && crc == expectedCRC); -} - -// Copy src→dst in chunks. Returns true on success. -static bool fySpiffsCopy(const char* src, const char* dst) { - File s = SPIFFS.open(src, "r"); - if (!s) return false; - File d = SPIFFS.open(dst, "w"); - if (!d) { s.close(); return false; } - uint8_t buf[256]; - int n; - bool ok = true; - while ((n = s.read(buf, sizeof(buf))) > 0) { - if (d.write(buf, (size_t)n) != (size_t)n) { ok = false; break; } - } - s.close(); - d.close(); - return ok; -} - -// Atomic rename: try SPIFFS rename first, fall back to copy+delete if rename fails. -static bool fyAtomicPromote(const char* src, const char* dst) { - if (SPIFFS.rename(src, dst)) return true; - if (!fySpiffsCopy(src, dst)) return false; - SPIFFS.remove(src); - return true; -} - -static void fySaveSession() { - if (!fySpiffsReady || !fyMutex) return; - if (xSemaphoreTake(fyMutex, pdMS_TO_TICKS(500)) != pdTRUE) { - printf("[FLOCK-YOU] Save skipped: fyMutex busy\n"); - return; - } - - // Pass 1: compute CRC + byte count - size_t payloadBytes = 0; - uint32_t crc = fyComputePayloadCRC(payloadBytes); - int savedCount = fyDetCount; - - // Pass 2: write envelope + payload to tmp - File f = SPIFFS.open(FY_SESSION_TMP, "w"); - if (!f) { - xSemaphoreGive(fyMutex); - printf("[FLOCK-YOU] Save failed: cannot open %s\n", FY_SESSION_TMP); - return; - } - f.printf("{\"v\":1,\"count\":%d,\"bytes\":%u,\"crc\":\"0x%08lX\"}\n", - savedCount, (unsigned)payloadBytes, (unsigned long)crc); - - char line[512]; - size_t wrote = 0; - f.write((uint8_t*)"[", 1); wrote++; - for (int i = 0; i < fyDetCount; i++) { - if (i > 0) { f.write((uint8_t*)",", 1); wrote++; } - size_t n = fySerializeDet(fyDet[i], line, sizeof(line)); - if (n == 0) continue; - f.write((uint8_t*)line, n); - wrote += n; - } - f.write((uint8_t*)"]", 1); wrote++; - f.close(); - xSemaphoreGive(fyMutex); - - if (wrote != payloadBytes) { - printf("[FLOCK-YOU] Save WARNING: wrote %u expected %u — aborting promote\n", - (unsigned)wrote, (unsigned)payloadBytes); - return; - } - - if (!fyValidateSessionFile(FY_SESSION_TMP)) { - printf("[FLOCK-YOU] Save verify FAILED — aborting promote (old session preserved)\n"); - return; - } - - SPIFFS.remove(FY_SESSION_FILE); - if (!fyAtomicPromote(FY_SESSION_TMP, FY_SESSION_FILE)) { - printf("[FLOCK-YOU] Promote FAILED — data in %s for recovery\n", FY_SESSION_TMP); - return; - } - - fyLastSaveCount = savedCount; - printf("[FLOCK-YOU] Session saved: %d det, %u bytes, crc=0x%08lX\n", - savedCount, (unsigned)payloadBytes, (unsigned long)crc); -} - -static void fyPromotePrevSession() { - if (!fySpiffsReady) return; - - const char* source = nullptr; - if (fyValidateSessionFile(FY_SESSION_FILE)) { - source = FY_SESSION_FILE; - } else if (fyValidateSessionFile(FY_SESSION_TMP)) { - printf("[FLOCK-YOU] Main session corrupt/missing — recovering from tmp\n"); - source = FY_SESSION_TMP; - } else { - // Legacy fallback: old format (raw array, no envelope) - if (SPIFFS.exists(FY_SESSION_FILE)) { - File f = SPIFFS.open(FY_SESSION_FILE, "r"); - if (f && f.size() > 2) { - int first = f.peek(); - f.close(); - if (first == '[') { - source = FY_SESSION_FILE; - printf("[FLOCK-YOU] Legacy-format session detected — promoting\n"); - } - } else if (f) { f.close(); } - } - } - - if (!source) { - if (SPIFFS.exists(FY_SESSION_FILE)) SPIFFS.remove(FY_SESSION_FILE); - if (SPIFFS.exists(FY_SESSION_TMP)) SPIFFS.remove(FY_SESSION_TMP); - printf("[FLOCK-YOU] No valid prior session to promote\n"); - return; - } - - if (!fySpiffsCopy(source, FY_PREV_FILE)) { - printf("[FLOCK-YOU] Failed to promote %s → %s\n", source, FY_PREV_FILE); - return; - } - - if (SPIFFS.exists(FY_SESSION_FILE)) SPIFFS.remove(FY_SESSION_FILE); - if (SPIFFS.exists(FY_SESSION_TMP)) SPIFFS.remove(FY_SESSION_TMP); - - File v = SPIFFS.open(FY_PREV_FILE, "r"); - size_t sz = v ? v.size() : 0; - if (v) v.close(); - printf("[FLOCK-YOU] Prior session promoted from %s (%u bytes)\n", source, (unsigned)sz); -} - -// Read prev_session as a raw detection JSON array (strips envelope header if present). -static void fyStreamPrevSessionBody(AsyncResponseStream* resp) { - if (!fySpiffsReady || !SPIFFS.exists(FY_PREV_FILE)) { resp->print("[]"); return; } - File f = SPIFFS.open(FY_PREV_FILE, "r"); - if (!f) { resp->print("[]"); return; } - - int first = f.peek(); - if (first == '{') f.readStringUntil('\n'); - - uint8_t buf[256]; - int n; - size_t streamed = 0; - while ((n = f.read(buf, sizeof(buf))) > 0) { - resp->write(buf, (size_t)n); - streamed += (size_t)n; - } - f.close(); - if (streamed == 0) resp->print("[]"); -} - -// ============================================================================ -// KML EXPORT -// ============================================================================ - -static void writeDetectionsKML(AsyncResponseStream *resp) { - resp->print("\n" - "\n\n" - "Flock-You Detections\n" - "Surveillance device detections with GPS\n"); - - // Detection pin style - resp->print("\n" - "\n"); - - if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(500)) == pdTRUE) { - for (int i = 0; i < fyDetCount; i++) { - FYDetection& d = fyDet[i]; - if (!d.hasGPS) continue; // Skip detections without GPS - resp->print("\n"); - resp->printf("%s\n", d.mac); - resp->printf("#%s\n", d.isRaven ? "raven" : "det"); - resp->print("printf("Name: %s
", d.name); - resp->printf("Method: %s
" - "RSSI: %d dBm
" - "Count: %d
", - d.method, d.rssi, d.count); - if (d.isRaven) resp->printf("Raven FW: %s
", d.ravenFW); - resp->printf("Accuracy: %.1f m", d.gpsAcc); - resp->print("]]>
\n"); - resp->printf("%.8f,%.8f,0\n", - d.gpsLon, d.gpsLat); - resp->print("
\n"); - } - xSemaphoreGive(fyMutex); - } - resp->print("
\n
"); -} - -// ============================================================================ -// DASHBOARD HTML -// ============================================================================ - -static const char FY_HTML[] PROGMEM = R"rawliteral( - - -FLOCK-YOU - -

FLOCK-YOU

Surveillance Device Detector • Wardriving + GPS
-
-
0
DETECTED
-
0
RAVEN
-
ON
BLE
-
TAP
GPS
-
-
- - - - -
-
-
-
Scanning for surveillance devices...
BLE active on all channels
-
-
Loading prior session...
-
Loading patterns...
-
-

EXPORT DETECTIONS

-

Download current session to import into Flask dashboard

- - - -
-

PRIOR SESSION

- - -
- -
-
- -)rawliteral"; - -// ============================================================================ -// WEB SERVER SETUP -// ============================================================================ - -static void fySetupServer() { - // Dashboard - fyServer.on("/", HTTP_GET, [](AsyncWebServerRequest *r) { - r->send(200, "text/html", FY_HTML); - }); - - // API: Detection list - fyServer.on("/api/detections", HTTP_GET, [](AsyncWebServerRequest *r) { - AsyncResponseStream *resp = r->beginResponseStream("application/json"); - writeDetectionsJSON(resp); - r->send(resp); - }); - - // API: Stats (includes GPS status) - fyServer.on("/api/stats", HTTP_GET, [](AsyncWebServerRequest *r) { - int raven = 0, withGPS = 0; - if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(100)) == pdTRUE) { - for (int i = 0; i < fyDetCount; i++) { - if (fyDet[i].isRaven) raven++; - if (fyDet[i].hasGPS) withGPS++; - } - xSemaphoreGive(fyMutex); - } - char buf[256]; - snprintf(buf, sizeof(buf), - "{\"total\":%d,\"raven\":%d,\"ble\":\"active\"," - "\"gps_valid\":%s,\"gps_age\":%lu,\"gps_tagged\":%d}", - fyDetCount, raven, - fyGPSIsFresh() ? "true" : "false", - fyGPSValid ? (millis() - fyGPSLastUpdate) : 0UL, - withGPS); - r->send(200, "application/json", buf); - }); - - // API: Receive GPS from phone browser (atomic publish under fyGPSMutex) - fyServer.on("/api/gps", HTTP_GET, [](AsyncWebServerRequest *r) { - if (r->hasParam("lat") && r->hasParam("lon")) { - double lat = r->getParam("lat")->value().toDouble(); - double lon = r->getParam("lon")->value().toDouble(); - float acc = r->hasParam("acc") ? r->getParam("acc")->value().toFloat() : 0; - fyGPSUpdate(lat, lon, acc); - r->send(200, "application/json", "{\"status\":\"ok\"}"); - } else { - r->send(400, "application/json", "{\"error\":\"lat,lon required\"}"); - } - }); - - // API: Pattern database - fyServer.on("/api/patterns", HTTP_GET, [](AsyncWebServerRequest *r) { - AsyncResponseStream *resp = r->beginResponseStream("application/json"); - resp->print("{\"macs\":["); - for (size_t i = 0; i < sizeof(flock_mac_prefixes)/sizeof(flock_mac_prefixes[0]); i++) { - if (i > 0) resp->print(","); - resp->printf("\"%s\"", flock_mac_prefixes[i]); - } - resp->print("],\"macs_mfr\":["); - for (size_t i = 0; i < sizeof(flock_mfr_mac_prefixes)/sizeof(flock_mfr_mac_prefixes[0]); i++) { - if (i > 0) resp->print(","); - resp->printf("\"%s\"", flock_mfr_mac_prefixes[i]); - } - resp->print("],\"macs_soundthinking\":["); - for (size_t i = 0; i < sizeof(soundthinking_mac_prefixes)/sizeof(soundthinking_mac_prefixes[0]); i++) { - if (i > 0) resp->print(","); - resp->printf("\"%s\"", soundthinking_mac_prefixes[i]); - } - resp->print("],\"names\":["); - for (size_t i = 0; i < sizeof(device_name_patterns)/sizeof(device_name_patterns[0]); i++) { - if (i > 0) resp->print(","); - resp->printf("\"%s\"", device_name_patterns[i]); - } - resp->print("],\"mfr\":["); - for (size_t i = 0; i < sizeof(ble_manufacturer_ids)/sizeof(ble_manufacturer_ids[0]); i++) { - if (i > 0) resp->print(","); - resp->printf("%u", ble_manufacturer_ids[i]); - } - resp->print("],\"raven\":["); - for (size_t i = 0; i < sizeof(raven_service_uuids)/sizeof(raven_service_uuids[0]); i++) { - if (i > 0) resp->print(","); - resp->printf("\"%s\"", raven_service_uuids[i]); - } - resp->print("]}"); - r->send(resp); - }); - - // API: Export JSON (downloadable file) - fyServer.on("/api/export/json", HTTP_GET, [](AsyncWebServerRequest *r) { - AsyncResponseStream *resp = r->beginResponseStream("application/json"); - resp->addHeader("Content-Disposition", "attachment; filename=\"flockyou_detections.json\""); - writeDetectionsJSON(resp); - r->send(resp); - }); - - // API: Export CSV (downloadable file, includes GPS) - fyServer.on("/api/export/csv", HTTP_GET, [](AsyncWebServerRequest *r) { - AsyncResponseStream *resp = r->beginResponseStream("text/csv"); - resp->addHeader("Content-Disposition", "attachment; filename=\"flockyou_detections.csv\""); - resp->println("mac,name,rssi,method,first_seen_ms,last_seen_ms,count,is_raven,raven_fw,latitude,longitude,gps_accuracy"); - if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(500)) == pdTRUE) { - for (int i = 0; i < fyDetCount; i++) { - FYDetection& d = fyDet[i]; - if (d.hasGPS) { - resp->printf("\"%s\",\"%s\",%d,\"%s\",%lu,%lu,%d,%s,\"%s\",%.8f,%.8f,%.1f\n", - d.mac, d.name, d.rssi, d.method, - d.firstSeen, d.lastSeen, d.count, - d.isRaven ? "true" : "false", d.ravenFW, - d.gpsLat, d.gpsLon, d.gpsAcc); - } else { - resp->printf("\"%s\",\"%s\",%d,\"%s\",%lu,%lu,%d,%s,\"%s\",,,\n", - d.mac, d.name, d.rssi, d.method, - d.firstSeen, d.lastSeen, d.count, - d.isRaven ? "true" : "false", d.ravenFW); - } - } - xSemaphoreGive(fyMutex); - } - r->send(resp); - }); - - // API: Export KML (GPS-tagged detections for Google Earth) - fyServer.on("/api/export/kml", HTTP_GET, [](AsyncWebServerRequest *r) { - AsyncResponseStream *resp = r->beginResponseStream("application/vnd.google-earth.kml+xml"); - resp->addHeader("Content-Disposition", "attachment; filename=\"flockyou_detections.kml\""); - writeDetectionsKML(resp); - r->send(resp); - }); - - // API: Prior session history (JSON) — strips envelope header if present - fyServer.on("/api/history", HTTP_GET, [](AsyncWebServerRequest *r) { - AsyncResponseStream *resp = r->beginResponseStream("application/json"); - fyStreamPrevSessionBody(resp); - r->send(resp); - }); - - // API: Download prior session as JSON file (body-only, envelope stripped) - fyServer.on("/api/history/json", HTTP_GET, [](AsyncWebServerRequest *r) { - if (!fySpiffsReady || !SPIFFS.exists(FY_PREV_FILE)) { - r->send(404, "application/json", "{\"error\":\"no prior session\"}"); - return; - } - AsyncResponseStream *resp = r->beginResponseStream("application/json"); - resp->addHeader("Content-Disposition", - "attachment; filename=\"flockyou_prev_session.json\""); - fyStreamPrevSessionBody(resp); - r->send(resp); - }); - - // API: Download prior session as KML (reads JSON body from SPIFFS, converts) - fyServer.on("/api/history/kml", HTTP_GET, [](AsyncWebServerRequest *r) { - if (!fySpiffsReady || !SPIFFS.exists(FY_PREV_FILE)) { - r->send(404, "application/json", "{\"error\":\"no prior session\"}"); - return; - } - File f = SPIFFS.open(FY_PREV_FILE, "r"); - if (!f) { r->send(500, "text/plain", "read error"); return; } - // Strip envelope header if present - if (f.peek() == '{') f.readStringUntil('\n'); - String content = f.readString(); - f.close(); - if (content.length() == 0) { - r->send(404, "application/json", "{\"error\":\"prior session empty\"}"); - return; - } - AsyncResponseStream *resp = r->beginResponseStream("application/vnd.google-earth.kml+xml"); - resp->addHeader("Content-Disposition", "attachment; filename=\"flockyou_prev_session.kml\""); - resp->print("\n" - "\n\n" - "Flock-You Prior Session\n" - "Surveillance device detections from prior session\n" - "\n" - "\n"); - // Parse JSON array and emit placemarks - JsonDocument doc; - DeserializationError err = deserializeJson(doc, content); - if (!err && doc.is()) { - int placed = 0; - for (JsonObject d : doc.as()) { - JsonObject gps = d["gps"]; - if (!gps || !gps.containsKey("lat")) continue; - bool isRaven = d["raven"] | false; - resp->printf("%s\n", d["mac"] | "?"); - resp->printf("#%s\n", isRaven ? "raven" : "det"); - resp->print("() && strlen(d["name"] | "") > 0) - resp->printf("Name: %s
", d["name"] | ""); - resp->printf("Method: %s
RSSI: %d
Count: %d", - d["method"] | "?", d["rssi"] | 0, d["count"] | 1); - if (isRaven && d["fw"].is()) - resp->printf("
Raven FW: %s", d["fw"] | ""); - resp->print("]]>
\n"); - resp->printf("%.8f,%.8f,0\n", - (double)(gps["lon"] | 0.0), (double)(gps["lat"] | 0.0)); - resp->print("
\n"); - placed++; - } - printf("[FLOCK-YOU] Prior session KML: %d placemarks\n", placed); - } else { - printf("[FLOCK-YOU] Prior session KML: JSON parse failed\n"); - } - resp->print("
\n
"); - r->send(resp); - }); - - // API: Clear all detections (saves current session first) - fyServer.on("/api/clear", HTTP_GET, [](AsyncWebServerRequest *r) { - fySaveSession(); // Persist before clearing - if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(200)) == pdTRUE) { - fyDetCount = 0; - memset(fyDet, 0, sizeof(fyDet)); - fyTriggered = false; - fyDeviceInRange = false; - xSemaphoreGive(fyMutex); - } - r->send(200, "application/json", "{\"status\":\"cleared\"}"); - printf("[FLOCK-YOU] All detections cleared (session saved)\n"); - }); - - fyServer.begin(); - printf("[FLOCK-YOU] Web server started on port 80\n"); -} - -// ============================================================================ -// MAIN FUNCTIONS -// ============================================================================ - -void setup() { - Serial.begin(115200); - delay(500); - - // Standalone mode: buzzer always on by default - fyBuzzerOn = true; - - pinMode(BUZZER_PIN, OUTPUT); - digitalWrite(BUZZER_PIN, LOW); - - fyMutex = xSemaphoreCreateMutex(); - fyGPSMutex = xSemaphoreCreateMutex(); - - // Init SPIFFS for session persistence - if (SPIFFS.begin(true)) { - fySpiffsReady = true; - printf("[FLOCK-YOU] SPIFFS ready\n"); - // Promote last session to prev_session before we start a new one - fyPromotePrevSession(); - } else { - printf("[FLOCK-YOU] SPIFFS init failed - no persistence\n"); - } - - printf("\n========================================\n"); - printf(" FLOCK-YOU Surveillance Detector\n"); - printf(" Buzzer: %s\n", fyBuzzerOn ? "ON" : "OFF"); - printf("========================================\n"); - - // Init BLE with device name and large MTU for GATT notifications - NimBLEDevice::init("flockyou"); - NimBLEDevice::setMTU(512); - - // BLE scanner setup - fyBLEScan = NimBLEDevice::getScan(); - fyBLEScan->setAdvertisedDeviceCallbacks(new FYBLECallbacks()); - fyBLEScan->setActiveScan(true); - fyBLEScan->setInterval(100); - fyBLEScan->setWindow(99); - - // Kick off the first scan right away - fyBLEScan->start(fyBleScanDuration, false); - fyLastBleScan = millis(); - printf("[FLOCK-YOU] BLE scanning ACTIVE\n"); - - // BLE GATT server — DeFlock app connectivity - fyBLEServer = NimBLEDevice::createServer(); - fyBLEServer->setCallbacks(new FYServerCallbacks()); - NimBLEService* pService = fyBLEServer->createService(FY_SERVICE_UUID); - fyTxChar = pService->createCharacteristic( - FY_TX_CHAR_UUID, - NIMBLE_PROPERTY::NOTIFY - ); - pService->start(); - - NimBLEAdvertising* pAdv = NimBLEDevice::getAdvertising(); - pAdv->addServiceUUID(FY_SERVICE_UUID); - pAdv->setName("flockyou"); - pAdv->setScanResponse(true); - pAdv->start(); - printf("[FLOCK-YOU] BLE GATT server advertising (service %s)\n", FY_SERVICE_UUID); - - // Crow calls play WHILE BLE is already scanning - fyBootBeep(); - - // Start WiFi AP (no need to connect to anything -- AP only) - WiFi.mode(WIFI_AP); - delay(100); - WiFi.softAP(FY_AP_SSID, FY_AP_PASS); - printf("[FLOCK-YOU] AP: %s / %s\n", FY_AP_SSID, FY_AP_PASS); - printf("[FLOCK-YOU] IP: %s\n", WiFi.softAPIP().toString().c_str()); - - // Start web dashboard - fySetupServer(); - - printf("[FLOCK-YOU] Detection methods: MAC prefix, device name, manufacturer ID, Raven UUID\n"); - printf("[FLOCK-YOU] Dashboard: http://192.168.4.1\n"); - printf("[FLOCK-YOU] Ready - BLE GATT + AP mode\n\n"); -} - -void loop() { - // Serial host detection (heartbeat from DeFlock desktop app) - if (Serial.available()) { - while (Serial.available()) Serial.read(); // drain buffer - fyLastSerialHeartbeat = millis(); - if (!fySerialHostConnected) { - fySerialHostConnected = true; - fyCompanionChangePending = true; - } - } else if (fySerialHostConnected && - millis() - fyLastSerialHeartbeat >= FY_SERIAL_TIMEOUT_MS) { - fySerialHostConnected = false; - fyCompanionChangePending = true; - } - - // Apply deferred companion mode switch (from BLE callbacks or serial detection) - if (fyCompanionChangePending) { - fyCompanionChangePending = false; - fyOnCompanionChange(); - } - - // BLE scanning cycle - if (millis() - fyLastBleScan >= fyBleScanInterval && !fyBLEScan->isScanning()) { - fyBLEScan->start(fyBleScanDuration, false); - fyLastBleScan = millis(); - } - - if (!fyBLEScan->isScanning() && millis() - fyLastBleScan > (unsigned long)fyBleScanDuration * 1000) { - fyBLEScan->clearResults(); - } - - // Heartbeat tracking - if (fyDeviceInRange) { - if (millis() - fyLastHB >= 10000) { - fyHeartbeat(); - fyLastHB = millis(); - } - if (millis() - fyLastDetTime >= 30000) { - printf("[FLOCK-YOU] Device out of range - stopping heartbeat\n"); - fyDeviceInRange = false; - fyTriggered = false; - } - } - - // Back-fill GPS on any detections captured before the first fix (every 2s) - static unsigned long lastBackfill = 0; - if (millis() - lastBackfill >= 2000) { - fyBackfillGPS(); - lastBackfill = millis(); - } - - // Bulletproof save cadence: - // - within 5s of first detection (quick first-save) - // - any time fyDetCount increases (new unique device), throttled to 3s minimum - // - every FY_SAVE_INTERVAL (15s) as a safety net - if (fySpiffsReady && fyDetCount > 0) { - unsigned long now = millis(); - bool countChanged = (fyDetCount != fyLastSaveCount); - bool minGap = (now - fyLastSave >= 3000); - bool firstSave = (fyLastSaveCount == 0 && now - fyLastSave >= 5000); - bool periodic = (now - fyLastSave >= FY_SAVE_INTERVAL); - - if (firstSave || (countChanged && minGap) || periodic) { - fySaveSession(); - fyLastSave = millis(); - } - } - - delay(100); -}