Files
flock-you/main.cpp
Colonel Panic ac12e69198 datasets: pull in @NitekryDPaul's 2026-05-13 status update
Reflects the changes in nite-oui-collection commit 1ed6333 (his
"Update MAC status and remove unnecessary sections" push earlier
today):

  - 08:3a:88 picks up a "BLE Ring conflict" flag. The firmware
    array is unchanged — promiscuous WiFi detection isn't affected
    — but a header comment in main.cpp now calls out the caveat,
    and the dataset markdown grows a new "Flagged but still tracked"
    section so BLE consumers (oui-spy-unified-blue's flockyou mode)
    know to expect occasional Ring-doorbell false positives on this
    prefix.
  - The "Demoted / low confidence" section in the dataset markdown
    now also catalogues cc:cc:cc and 00:0c:e7 — both Removed in his
    notes, but only f8:a2:d6 was documented here previously.

OUI count is unchanged (42); the firmware array doesn't need to
move. This is documentation-tracking so the next sync against
nite-oui-collection has clean priors to diff against.
2026-05-13 17:15:56 -04:00

1449 lines
49 KiB
C++

#include <Arduino.h>
#include <WiFi.h>
#include "esp_wifi.h"
#include <ctype.h>
#include <string.h>
#include <SPIFFS.h>
// ============================================================
// 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[] = {
// @NitekryDPaul / OrdoOuroborous — original promiscuous-mode set, 29 OUIs.
// f8:a2:d6 has been demoted (Sony Media Player false positive — see
// nite-oui-collection/groups/flockers/my_tested_flock.md).
// 08:3a:88 stays in the array but is flagged for a BLE Ring conflict;
// promiscuous WiFi detection is unaffected, BLE-side users should be
// aware of occasional Ring-doorbell false positives.
"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", "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",
// @NitekryDPaul April 2026 additions (nite-oui-collection).
"04:0d:84", "f0:82:c0", "1c:34:f1", "38:5b:44", "94:34:69",
"b4:e3:f9", "b4:1e:52", "14:b5:cd", "94:2a:6f", "f4:e2:c6",
"d4:11:d6", "e0:0a:f6",
// 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).
// Sized to fit the longest line we emit: a replay-detection JSON record
// with worst-case JSON-escaped SSID (32 chars → up to 192 bytes) plus the
// envelope fields — ~600 B comfortably under 1024.
static char _dualBuf[1024];
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);
}
// Replay emission — used for both live-table dumps and SPIFFS-backed
// historical dumps. Same Flask JSON shape as live detections, but flagged
// with "replay":true and the source ("live"|"prev") plus the device's
// monotonic millis() snapshots so the host can decide how to present them.
static void emitReplayDetection(const char* mac, const char* method,
int8_t rssi, uint8_t ch, const char* ssid,
uint16_t count,
uint32_t firstMs, uint32_t lastMs,
const char* source) {
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\","
"\"replay\":true,"
"\"replay_source\":\"%s\","
"\"detection_method\":\"wifi_%s\","
"\"protocol\":\"wifi_2_4ghz\","
"\"mac_address\":\"%s\","
"\"oui\":\"%s\","
"\"device_name\":\"\","
"\"rssi\":%d,"
"\"channel\":%u,"
"\"frequency\":%u,"
"\"ssid\":\"%s\","
"\"detection_count\":%u,"
"\"device_first_ms\":%lu,"
"\"device_last_ms\":%lu}\n",
source, method, mac, oui, rssi,
(unsigned)ch, (unsigned)channelFreqMhz(ch), ssidEsc,
(unsigned)count, (unsigned long)firstMs, (unsigned long)lastMs);
}
// ============================================================
// HOST COMMAND INTERFACE (Flask ↔ firmware over USB-CDC)
// ============================================================
//
// Line-based protocol. The host writes one ASCII command per line
// terminated by \n (or \r\n). The firmware replies with one or more JSON
// objects, each on its own line, in the same `{"event":...}` schema the
// Flask reader already understands.
//
// Commands:
// CMD:STATUS → emits one {"event":"status",...} line
// CMD:DUMP_LIVE → streams the current in-RAM detection table as
// replay-detection lines, then a replay_complete
// sentinel with source="live"
// CMD:DUMP_PREV → same, but reads /prev_session.json from SPIFFS
// (the previous boot's persisted session)
// CMD:CLEAR_LIVE → wipes the in-RAM detection table
// CMD:CLEAR_PREV → deletes /prev_session.json (and any /session.tmp)
// CMD:VERSION → emits {"event":"version",...}
//
// Commands are case-sensitive. Unknown commands emit an "error" event.
// Lines longer than CMD_BUF_SIZE-1 are silently truncated at the boundary.
#define CMD_BUF_SIZE 80
#define REPLAY_OBJ_CAP 384 // generous: longest serialized entry ~330 B
static char cmdBuf[CMD_BUF_SIZE];
static size_t cmdLen = 0;
// Find the start of `"<key>":` inside a flat JSON object string.
// Returns pointer to the byte after the closing `:` (i.e. start of the value),
// or null. The caller must skip whitespace.
static const char* jsonValueStart(const char* obj, const char* key) {
char pat[24];
int n = snprintf(pat, sizeof(pat), "\"%s\":", key);
if (n <= 0 || (size_t)n >= sizeof(pat)) return nullptr;
const char* p = strstr(obj, pat);
if (!p) return nullptr;
return p + n;
}
// Copy the contents of a JSON string field into dst (un-escaped).
// Returns false if the field isn't a string or doesn't exist.
static bool jsonGetString(const char* obj, const char* key, char* dst, size_t cap) {
const char* p = jsonValueStart(obj, key);
if (!p) return false;
while (*p == ' ' || *p == '\t') p++;
if (*p != '"') return false;
p++;
size_t out = 0;
bool esc = false;
while (*p && out < cap - 1) {
if (esc) {
dst[out++] = *p++;
esc = false;
} else if (*p == '\\') {
esc = true; p++;
} else if (*p == '"') {
break;
} else {
dst[out++] = *p++;
}
}
dst[out] = '\0';
return true;
}
static bool jsonGetInt(const char* obj, const char* key, long* out) {
const char* p = jsonValueStart(obj, key);
if (!p) return false;
while (*p == ' ' || *p == '\t') p++;
char* endp = nullptr;
long v = strtol(p, &endp, 10);
if (endp == p) return false;
*out = v;
return true;
}
// Stream-read one top-level `{...}` JSON object from `f` into `buf`.
// Skips whitespace, commas, and the array `[`. Returns false on `]`, EOF,
// or malformed input. String-aware brace counting handles `{`/`}` inside
// SSID values (the writer doesn't escape those).
static bool readNextJSONObject(File& f, char* buf, size_t cap) {
int c;
while ((c = f.read()) >= 0) {
if (c == '{') break;
if (c == ']') return false;
}
if (c != '{') return false;
size_t pos = 0;
buf[pos++] = '{';
int depth = 1;
bool in_str = false;
bool esc = false;
while ((c = f.read()) >= 0) {
if (pos >= cap - 1) return false;
buf[pos++] = (char)c;
if (esc) { esc = false; continue; }
if (in_str) {
if (c == '\\') esc = true;
else if (c == '"') in_str = false;
} else {
if (c == '"') in_str = true;
else if (c == '{') depth++;
else if (c == '}') {
depth--;
if (depth == 0) { buf[pos] = '\0'; return true; }
}
}
}
return false;
}
static void cmdEmitStatus() {
size_t prevSize = 0;
bool prevExists = false;
if (fySpiffsReady && SPIFFS.exists(FY_PREV_FILE)) {
prevExists = true;
File v = SPIFFS.open(FY_PREV_FILE, "r");
if (v) { prevSize = v.size(); v.close(); }
}
dualPrintf(
"{\"event\":\"status\","
"\"fy_det\":%d,"
"\"oui_count\":%u,"
"\"spiffs\":%d,"
"\"prev_session\":%d,"
"\"prev_bytes\":%u,"
"\"uptime_ms\":%lu,"
"\"free_heap\":%u,"
"\"channel\":%u,"
"\"channel_mode\":\"%s\","
"\"rssi_min\":%d}\n",
fyDetCount, (unsigned)OUI_COUNT, fySpiffsReady ? 1 : 0,
prevExists ? 1 : 0, (unsigned)prevSize,
(unsigned long)millis(), (unsigned)ESP.getFreeHeap(),
(unsigned)currentChannel, channelModeName(), RSSI_MIN);
}
static void cmdEmitVersion() {
dualPrintf(
"{\"event\":\"version\","
"\"firmware\":\"flock-you-promiscious\","
"\"branch\":\"promiscious\","
"\"oui_count\":%u,"
"\"max_detections\":%d,"
"\"autosave_ms\":%lu}\n",
(unsigned)OUI_COUNT, MAX_DETECTIONS, (unsigned long)AUTOSAVE_INTERVAL_MS);
}
static int cmdDumpLive() {
int n = 0;
for (int i = 0; i < fyDetCount; i++) {
const FYDetection& d = fyDet[i];
emitReplayDetection(d.mac, d.method, d.rssi, d.channel,
d.ssid, d.count, d.firstSeen, d.lastSeen, "live");
n++;
}
return n;
}
static int cmdDumpPrev() {
if (!fySpiffsReady) return -2;
if (!SPIFFS.exists(FY_PREV_FILE)) return -1;
if (!fyValidateSessionFile(FY_PREV_FILE)) return -3;
File f = SPIFFS.open(FY_PREV_FILE, "r");
if (!f) return -4;
// Discard envelope header line; the array starts on line 2.
f.readStringUntil('\n');
char obj[REPLAY_OBJ_CAP];
int n = 0;
while (readNextJSONObject(f, obj, sizeof(obj))) {
char mac[18] = {0};
char method[16]= {0};
char ssid[33] = {0};
long rssi = 0, channel = 0, count = 1, firstMs = 0, lastMs = 0;
if (!jsonGetString(obj, "mac", mac, sizeof(mac))) continue;
if (!jsonGetString(obj, "method", method, sizeof(method))) continue;
jsonGetInt(obj, "rssi", &rssi);
jsonGetInt(obj, "channel", &channel);
jsonGetInt(obj, "count", &count);
jsonGetInt(obj, "first", &firstMs);
jsonGetInt(obj, "last", &lastMs);
jsonGetString(obj, "ssid", ssid, sizeof(ssid));
if (rssi < -128) rssi = -128; else if (rssi > 127) rssi = 127;
if (channel < 0) channel = 0; else if (channel > 255) channel = 255;
if (count < 0) count = 0; else if (count > 0xFFFF) count = 0xFFFF;
emitReplayDetection(mac, method, (int8_t)rssi, (uint8_t)channel,
ssid, (uint16_t)count,
(uint32_t)firstMs, (uint32_t)lastMs, "prev");
n++;
}
f.close();
return n;
}
static void cmdClearLive() {
fyDetCount = 0;
fyDirty = true; // force the next autosave to overwrite the file
dualPrintf("{\"event\":\"clear\",\"target\":\"live\",\"ok\":true}\n");
}
static void cmdClearPrev() {
bool ok = false;
if (fySpiffsReady) {
if (SPIFFS.exists(FY_PREV_FILE)) ok = SPIFFS.remove(FY_PREV_FILE) || ok;
// Also sweep any stray /session.tmp left over from an aborted save.
if (SPIFFS.exists(FY_SESSION_TMP)) SPIFFS.remove(FY_SESSION_TMP);
if (!SPIFFS.exists(FY_PREV_FILE)) ok = true;
}
dualPrintf("{\"event\":\"clear\",\"target\":\"prev\",\"ok\":%s}\n",
ok ? "true" : "false");
}
static void handleCommand(const char* cmd) {
if (strcmp(cmd, "CMD:STATUS") == 0) {
cmdEmitStatus();
} else if (strcmp(cmd, "CMD:VERSION") == 0) {
cmdEmitVersion();
} else if (strcmp(cmd, "CMD:DUMP_LIVE") == 0) {
int n = cmdDumpLive();
dualPrintf("{\"event\":\"replay_complete\",\"source\":\"live\","
"\"count\":%d,\"ok\":true}\n", n);
} else if (strcmp(cmd, "CMD:DUMP_PREV") == 0) {
int n = cmdDumpPrev();
if (n >= 0) {
dualPrintf("{\"event\":\"replay_complete\",\"source\":\"prev\","
"\"count\":%d,\"ok\":true}\n", n);
} else {
const char* reason =
(n == -1) ? "no_file" :
(n == -2) ? "spiffs_down" :
(n == -3) ? "crc_mismatch" :
(n == -4) ? "open_failed" : "unknown";
dualPrintf("{\"event\":\"replay_complete\",\"source\":\"prev\","
"\"count\":0,\"ok\":false,\"reason\":\"%s\"}\n", reason);
}
} else if (strcmp(cmd, "CMD:CLEAR_LIVE") == 0) {
cmdClearLive();
} else if (strcmp(cmd, "CMD:CLEAR_PREV") == 0) {
cmdClearPrev();
} else {
char escCmd[CMD_BUF_SIZE * 2];
jsonEscape(escCmd, sizeof(escCmd), cmd);
dualPrintf("{\"event\":\"error\",\"reason\":\"unknown_command\","
"\"cmd\":\"%s\"}\n", escCmd);
}
}
static void serialCmdTick() {
while (Serial.available() > 0) {
int b = Serial.read();
if (b < 0) break;
if (b == '\n' || b == '\r') {
if (cmdLen > 0) {
cmdBuf[cmdLen] = '\0';
handleCommand(cmdBuf);
cmdLen = 0;
}
} else if (cmdLen < CMD_BUF_SIZE - 1) {
cmdBuf[cmdLen++] = (char)b;
}
// Lines longer than CMD_BUF_SIZE-1 silently truncate; the closing
// newline still flushes whatever fits and handleCommand sees garbage,
// which gets rejected as "unknown_command".
}
}
// ============================================================
// 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
serialCmdTick(); // CMD:STATUS / CMD:DUMP_* / CMD:CLEAR_* over USB-CDC
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);
}