mirror of
https://github.com/colonelpanichacks/flock-you.git
synced 2026-06-09 13:51:53 -07:00
60bed01781
Replaces the single beep-per-detection with two distinct patterns: - New MAC (first sighting): two fast ascending beeps, 2000 -> 2800 Hz, 55 ms each with 25 ms gap - Same MAC still active (last seen within 20 s): two monotone 1500 Hz heartbeat beeps, 70 ms each, every 10 s - Silent once nothing has been seen for 20 s, until the next new MAC Global "last seen" timer refreshes on every inbound hit, including ones suppressed by the serial rate limit, so quieter repeats still count as "still around" for the heartbeat. LED still flashes on every emitted detection.
1047 lines
34 KiB
C++
1047 lines
34 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 20000
|
|
#define HB_BEEP_INTERVAL_MS 10000
|
|
#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"
|
|
|
|
};
|
|
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,
|
|
} 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";
|
|
default: return "unknown";
|
|
}
|
|
}
|
|
|
|
// Returns index of entry (new or updated), or -1 if table is full.
|
|
static int fyAddDetection(const char* mac, const char* method,
|
|
int8_t rssi, uint8_t ch, const char* ssid) {
|
|
for (int i = 0; i < fyDetCount; i++) {
|
|
if (strcasecmp(fyDet[i].mac, mac) == 0) {
|
|
if (fyDet[i].count < 0xFFFF) fyDet[i].count++;
|
|
fyDet[i].lastSeen = millis();
|
|
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;
|
|
return i;
|
|
}
|
|
}
|
|
if (fyDetCount >= MAX_DETECTIONS) 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 = millis();
|
|
d.lastSeen = d.firstSeen;
|
|
d.count = 1;
|
|
if (ssid && ssid[0]) strlcpy(d.ssid, ssid, sizeof(d.ssid));
|
|
else d.ssid[0] = '\0';
|
|
fyDetCount++;
|
|
fyDirty = 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;
|
|
}
|
|
|
|
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) ---
|
|
if (matchOuiRaw(hdr->addr2)) {
|
|
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).
|
|
int idx = fyAddDetection(macStr, method, e.rssi, e.channel,
|
|
(e.type == ALERT_SSID) ? e.ssid : nullptr);
|
|
|
|
// A MAC is "new" iff fyAddDetection just inserted it (count is exactly 1).
|
|
bool isNew = (idx >= 0 && fyDet[idx].count == 1);
|
|
|
|
// 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 (isNew) {
|
|
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);
|
|
}
|