mirror of
https://github.com/colonelpanichacks/flock-you.git
synced 2026-06-09 21:53:31 -07:00
2d0131dafd
Firmware (main.cpp): adds a line-based CMD:* protocol on the same
USB-CDC port that already streams live detection JSON, so Flask can pull
state without re-flashing:
- CMD:STATUS emits {"event":"status",...} with det count,
SPIFFS state, free heap, uptime, channel
- CMD:VERSION emits firmware identifier + compile-time constants
- CMD:DUMP_LIVE streams the in-RAM detection table as replay
JSON lines, then a replay_complete sentinel
- CMD:DUMP_PREV same, but reads /prev_session.json from SPIFFS
(parses the CRC envelope and the embedded array)
- CMD:CLEAR_LIVE wipes fyDet[] and dirties the autosave
- CMD:CLEAR_PREV deletes /prev_session.json and any /session.tmp
Implementation:
- Minimal string-aware JSON object reader (string-aware brace counter,
backslash handling) lifts entries from the SPIFFS array one at a
time without slurping the whole file
- jsonGetString / jsonGetInt field extractors over flat objects
- emitReplayDetection() reuses the existing Flask schema and adds
replay / replay_source / detection_count / device_first_ms /
device_last_ms so the host can tell historical from live
- serialCmdTick() runs once per loop() and only acts on completed
lines — non-blocking and safe alongside the live detection path
- dualPrintf buffer bumped 384 → 1024 B to fit the longer replay line
(and to remove a latent truncation risk on a long-SSID live line)
Flask (api/flockyou.py): turns the protocol into REST endpoints and
ingests replayed detections without confusing them with live ones:
- flock_reader now dispatches {"event":"status"|"version"|"clear"|
"replay_complete"|"error"} responses to threading.Event slots, and
routes {"replay":true,"detection_method":...} lines through a new
add_replay_detection_from_serial() that skips GPS temporal matching,
flags timestamp_source="device_replay", and merges into an existing
fresher live entry instead of overwriting it
- send_command(cmd, response_event_name, timeout) serializes one
command at a time and blocks until the matching event arrives
- new endpoints: /api/flock/{status,version,dump_prev,dump_live,
clear_prev,clear_live}
Verified: pio run completes clean (RAM 19.1%, flash 12.0%); flockyou.py
passes py_compile. README documents the protocol, the per-event shape,
and the canonical post-wardrive dump_prev → clear_prev workflow.
1446 lines
49 KiB
C++
1446 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).
|
|
"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);
|
|
}
|