From d9825be40b5514269a4138212175e0afbb2d84b9 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 2 Feb 2026 00:48:48 -0700 Subject: [PATCH 01/23] Add pluggable detector system with scoring pipeline and device tracking --- .../flocksquawk_m5stick.ino | 206 ++----------- .../flocksquawk_m5stick/src/DetectorTypes.h | 69 +++++ m5stack/flocksquawk_m5stick/src/Detectors.h | 183 ++++++++++++ .../src/DeviceSignatures.h | 35 +-- m5stack/flocksquawk_m5stick/src/EventBus.h | 8 +- .../flocksquawk_m5stick/src/RadioScanner.h | 6 +- .../src/TelemetryReporter.h | 66 ++++- .../flocksquawk_m5stick/src/ThreatAnalyzer.h | 270 ++++++++++++++++-- 8 files changed, 593 insertions(+), 250 deletions(-) create mode 100644 m5stack/flocksquawk_m5stick/src/DetectorTypes.h create mode 100644 m5stack/flocksquawk_m5stick/src/Detectors.h diff --git a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino index 412428b..65443ec 100644 --- a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino +++ b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino @@ -78,6 +78,9 @@ namespace { portMUX_TYPE wifiMux = portMUX_INITIALIZER_UNLOCKED; volatile bool wifiFramePending = false; WiFiFrameEvent pendingWiFiFrame; + portMUX_TYPE bleMux = portMUX_INITIALIZER_UNLOCKED; + volatile bool bleDevicePending = false; + BluetoothDeviceEvent pendingBleDevice; bool statusMessageActive = false; uint32_t statusMessageUntilMs = 0; @@ -510,193 +513,12 @@ void RadioScannerManager::wifiPacketHandler(void* buffer, wifi_promiscuous_pkt_t EventBus::publishWifiFrame(event); } -uint8_t RadioScannerManager::currentWifiChannel = 1; +volatile uint8_t RadioScannerManager::currentWifiChannel = 1; unsigned long RadioScannerManager::lastChannelSwitch = 0; unsigned long RadioScannerManager::lastBLEScan = 0; NimBLEScan* RadioScannerManager::bleScanner = nullptr; bool RadioScannerManager::isScanningBLE = false; -// ThreatAnalyzer implementation -void ThreatAnalyzer::initialize() { - // Analyzer ready -} - -void ThreatAnalyzer::analyzeWiFiFrame(const WiFiFrameEvent& frame) { - bool nameMatch = strlen(frame.ssid) > 0 && matchesNetworkName(frame.ssid); - bool macMatch = matchesMACPrefix(frame.mac); - - if (nameMatch || macMatch) { - uint8_t certainty = calculateCertainty(nameMatch, macMatch, false); - emitThreatDetection(frame, "wifi", certainty); - } -} - -void ThreatAnalyzer::analyzeBluetoothDevice(const BluetoothDeviceEvent& device) { - bool nameMatch = strlen(device.name) > 0 && matchesBLEName(device.name); - bool macMatch = matchesMACPrefix(device.mac); - bool uuidMatch = device.hasServiceUUID && matchesRavenService(device.serviceUUID); - - if (nameMatch || macMatch || uuidMatch) { - uint8_t certainty = calculateCertainty(nameMatch, macMatch, uuidMatch); - const char* category = determineCategory(uuidMatch); - emitThreatDetection(device, "bluetooth", certainty, category); - } -} - -bool ThreatAnalyzer::matchesNetworkName(const char* ssid) { - if (!ssid) return false; - - for (size_t i = 0; i < DeviceProfiles::NetworkNameCount; i++) { - if (strcasestr(ssid, DeviceProfiles::NetworkNames[i])) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesMACPrefix(const uint8_t* mac) { - char macStr[9]; - snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x", mac[0], mac[1], mac[2]); - - for (size_t i = 0; i < DeviceProfiles::MACPrefixCount; i++) { - if (strncasecmp(macStr, DeviceProfiles::MACPrefixes[i], 8) == 0) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesBLEName(const char* name) { - if (!name) return false; - - for (size_t i = 0; i < DeviceProfiles::BLEIdentifierCount; i++) { - if (strcasestr(name, DeviceProfiles::BLEIdentifiers[i])) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesRavenService(const char* uuid) { - if (!uuid) return false; - - for (size_t i = 0; i < DeviceProfiles::RavenServiceCount; i++) { - if (strcasecmp(uuid, DeviceProfiles::RavenServices[i]) == 0) { - return true; - } - } - return false; -} - -uint8_t ThreatAnalyzer::calculateCertainty(bool nameMatch, bool macMatch, bool uuidMatch) { - if (nameMatch && macMatch && uuidMatch) return 100; - if (nameMatch && macMatch) return 95; - if (uuidMatch) return 90; - if (nameMatch || macMatch) return 85; - return 70; -} - -const char* ThreatAnalyzer::determineCategory(bool isRaven) { - return isRaven ? "acoustic_detector" : "surveillance_device"; -} - -void ThreatAnalyzer::emitThreatDetection(const WiFiFrameEvent& frame, const char* radio, uint8_t certainty) { - ThreatEvent threat; - memset(&threat, 0, sizeof(threat)); - memcpy(threat.mac, frame.mac, 6); - strncpy(threat.identifier, frame.ssid, sizeof(threat.identifier) - 1); - threat.rssi = frame.rssi; - threat.channel = frame.channel; - threat.radioType = radio; - threat.certainty = certainty; - threat.category = "surveillance_device"; - - EventBus::publishThreat(threat); -} - -void ThreatAnalyzer::emitThreatDetection(const BluetoothDeviceEvent& device, const char* radio, uint8_t certainty, const char* category) { - ThreatEvent threat; - memset(&threat, 0, sizeof(threat)); - memcpy(threat.mac, device.mac, 6); - strncpy(threat.identifier, device.name, sizeof(threat.identifier) - 1); - threat.rssi = device.rssi; - threat.channel = 0; - threat.radioType = radio; - threat.certainty = certainty; - threat.category = category; - - EventBus::publishThreat(threat); -} - -// TelemetryReporter implementation -void TelemetryReporter::initialize() { - bootTime = millis(); -} - -void TelemetryReporter::handleThreatDetection(const ThreatEvent& threat) { - DynamicJsonDocument doc(2048); - - doc["event"] = "target_detected"; - doc["ms_since_boot"] = millis() - bootTime; - - appendSourceInfo(threat, doc); - appendTargetIdentity(threat, doc); - appendIndicators(threat, doc); - appendMetadata(threat, doc); - - outputJSON(doc); -} - -void TelemetryReporter::appendSourceInfo(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject source = doc.createNestedObject("source"); - source["radio"] = threat.radioType; - source["channel"] = threat.channel; - source["rssi"] = threat.rssi; -} - -void TelemetryReporter::appendTargetIdentity(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject target = doc.createNestedObject("target"); - JsonObject identity = target.createNestedObject("identity"); - - char macStr[18]; - snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x", - threat.mac[0], threat.mac[1], threat.mac[2], - threat.mac[3], threat.mac[4], threat.mac[5]); - identity["mac"] = macStr; - - char oui[9]; - snprintf(oui, sizeof(oui), "%02x:%02x:%02x", threat.mac[0], threat.mac[1], threat.mac[2]); - identity["oui"] = oui; - - identity["label"] = threat.identifier; -} - -void TelemetryReporter::appendIndicators(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject indicators = doc["target"].createNestedObject("indicators"); - - bool hasName = strlen(threat.identifier) > 0; - indicators["ssid_match"] = (hasName && strcmp(threat.radioType, "wifi") == 0); - indicators["mac_match"] = true; - indicators["name_match"] = (hasName && strcmp(threat.radioType, "bluetooth") == 0); - indicators["service_uuid_match"] = (strcmp(threat.category, "acoustic_detector") == 0); -} - -void TelemetryReporter::appendMetadata(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject metadata = doc.createNestedObject("metadata"); - - if (strcmp(threat.radioType, "wifi") == 0) { - metadata["frame_type"] = "beacon"; - } else { - metadata["frame_type"] = "advertisement"; - } - - metadata["detection_method"] = "combined_signature"; -} - -void TelemetryReporter::outputJSON(const JsonDocument& doc) { - serializeJson(doc, Serial); - Serial.println(); -} // Main system initialization void setup() { @@ -726,7 +548,10 @@ void setup() { }); EventBus::subscribeBluetoothDevice([](const BluetoothDeviceEvent& event) { - threatEngine.analyzeBluetoothDevice(event); + portENTER_CRITICAL(&bleMux); + pendingBleDevice = event; + bleDevicePending = true; + portEXIT_CRITICAL(&bleMux); }); EventBus::subscribeThreat([](const ThreatEvent& event) { @@ -777,6 +602,19 @@ void loop() { threatEngine.analyzeWiFiFrame(frameCopy); } + if (bleDevicePending) { + BluetoothDeviceEvent bleCopy; + portENTER_CRITICAL(&bleMux); + bleCopy = pendingBleDevice; + bleDevicePending = false; + portEXIT_CRITICAL(&bleMux); + threatEngine.analyzeBluetoothDevice(bleCopy); + } + + if (threatEngine.tick(now)) { + M5.Speaker.tone(1800, 40); + } + if (threatPending) { ThreatEvent threatCopy; portENTER_CRITICAL(&threatMux); @@ -784,7 +622,7 @@ void loop() { threatPending = false; portEXIT_CRITICAL(&threatMux); reporter.handleThreatDetection(threatCopy); - triggerAlert(now); + if (threatCopy.shouldAlert) triggerAlert(now); } if (M5.BtnB.pressedFor(2000) && !powerToggleHandled) { diff --git a/m5stack/flocksquawk_m5stick/src/DetectorTypes.h b/m5stack/flocksquawk_m5stick/src/DetectorTypes.h new file mode 100644 index 0000000..8d2d1fd --- /dev/null +++ b/m5stack/flocksquawk_m5stick/src/DetectorTypes.h @@ -0,0 +1,69 @@ +#ifndef DETECTOR_TYPES_H +#define DETECTOR_TYPES_H + +#include +#include + +// Result returned by every detector function. Stack-allocated, no heap. +struct DetectorResult { + bool matched; + uint8_t weight; + const char* detectorName; +}; + +// Bitmask flags for tracking which detectors fired. +// Each detector gets one bit; stored in ThreatEvent::matchFlags. +enum DetectorFlag : uint16_t { + DET_NONE = 0, + DET_SSID_FORMAT = (1 << 0), + DET_SSID_KEYWORD = (1 << 1), + DET_MAC_OUI = (1 << 2), + DET_BLE_NAME = (1 << 3), + DET_RAVEN_CUSTOM_UUID = (1 << 4), + DET_RAVEN_STD_UUID = (1 << 5), + DET_RSSI_MODIFIER = (1 << 6), +}; + +// Forward declarations (defined in EventBus.h) +struct WiFiFrameEvent; +struct BluetoothDeviceEvent; + +// Detector function pointer types +typedef DetectorResult (*WiFiDetectorFn)(const WiFiFrameEvent& frame); +typedef DetectorResult (*BLEDetectorFn)(const BluetoothDeviceEvent& device); + +// Registry entries pair a function with its flag bit +struct WiFiDetectorEntry { + WiFiDetectorFn fn; + DetectorFlag flag; +}; + +struct BLEDetectorEntry { + BLEDetectorFn fn; + DetectorFlag flag; +}; + +// Device presence tracking +enum class DeviceState : uint8_t { + EMPTY, + NEW_DETECT, + IN_RANGE, + DEPARTED, +}; + +struct TrackedDevice { + uint8_t mac[6]; + uint32_t firstSeenMs; + uint32_t lastSeenMs; + uint8_t maxCertainty; + DeviceState state; + // 6 + 4 + 4 + 1 + 1 = 16 bytes per slot +}; + +// Constants +static const uint8_t MAX_TRACKED_DEVICES = 32; +static const uint32_t DEVICE_TIMEOUT_MS = 60000; +static const uint32_t HEARTBEAT_INTERVAL_MS = 10000; +static const uint8_t ALERT_THRESHOLD = 65; + +#endif diff --git a/m5stack/flocksquawk_m5stick/src/Detectors.h b/m5stack/flocksquawk_m5stick/src/Detectors.h new file mode 100644 index 0000000..c78d00f --- /dev/null +++ b/m5stack/flocksquawk_m5stick/src/Detectors.h @@ -0,0 +1,183 @@ +#ifndef DETECTORS_H +#define DETECTORS_H + +#include "DetectorTypes.h" +#include "EventBus.h" +#include "DeviceSignatures.h" +#include +#include + +// ============================================================ +// Helpers +// ============================================================ + +inline bool isHexChar(char c) { + return (c >= '0' && c <= '9') || + (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'); +} + +inline bool isHexSuffix(const char* s, uint8_t len) { + for (uint8_t i = 0; i < len; i++) { + if (!isHexChar(s[i])) return false; + } + return s[len] == '\0'; +} + +inline bool isDecimalSuffix(const char* s, uint8_t len) { + for (uint8_t i = 0; i < len; i++) { + if (s[i] < '0' || s[i] > '9') return false; + } + return s[len] == '\0'; +} + +inline bool ouiMatchesKnownPrefix(const uint8_t* mac) { + char macStr[9]; + snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x", mac[0], mac[1], mac[2]); + for (size_t i = 0; i < DeviceProfiles::MACPrefixCount; i++) { + if (strncasecmp(macStr, DeviceProfiles::MACPrefixes[i], 8) == 0) + return true; + } + return false; +} + +// ============================================================ +// WiFi Detectors +// ============================================================ + +// SSID Format Match (weight 75) +// Validates highly specific patterns: +// "Flock-" + exactly 6 hex chars +// "Penguin-" + exactly 10 decimal digits +// Exact "FS Ext Battery" +inline DetectorResult detectSsidFormat(const WiFiFrameEvent& frame) { + DetectorResult r = { false, 75, "ssid_format" }; + const char* ssid = frame.ssid; + if (ssid[0] == '\0') return r; + size_t len = strlen(ssid); + + if (len == 12 && strncmp(ssid, "Flock-", 6) == 0 && + isHexSuffix(ssid + 6, 6)) { + r.matched = true; + return r; + } + + if (len == 18 && strncmp(ssid, "Penguin-", 8) == 0 && + isDecimalSuffix(ssid + 8, 10)) { + r.matched = true; + return r; + } + + if (strcmp(ssid, "FS Ext Battery") == 0) { + r.matched = true; + return r; + } + + return r; +} + +// SSID Keyword Match (weight 45) +// Case-insensitive substring search for known keywords. +inline DetectorResult detectSsidKeyword(const WiFiFrameEvent& frame) { + DetectorResult r = { false, 45, "ssid_keyword" }; + const char* ssid = frame.ssid; + if (ssid[0] == '\0') return r; + + static const char* const keywords[] = { + "flock", "penguin", "pigvision" + }; + static const uint8_t count = sizeof(keywords) / sizeof(keywords[0]); + + for (uint8_t i = 0; i < count; i++) { + if (strcasestr(ssid, keywords[i])) { + r.matched = true; + return r; + } + } + return r; +} + +// WiFi MAC OUI Match (weight 20) +inline DetectorResult detectWifiMacOui(const WiFiFrameEvent& frame) { + DetectorResult r = { false, 20, "mac_oui" }; + if (ouiMatchesKnownPrefix(frame.mac)) r.matched = true; + return r; +} + +// ============================================================ +// BLE Detectors +// ============================================================ + +// BLE Device Name Match (weight 55) +inline DetectorResult detectBleName(const BluetoothDeviceEvent& device) { + DetectorResult r = { false, 55, "ble_name" }; + if (device.name[0] == '\0') return r; + + static const char* const names[] = { + "Flock", "Penguin", "FS Ext Battery", "Pigvision" + }; + static const uint8_t count = sizeof(names) / sizeof(names[0]); + + for (uint8_t i = 0; i < count; i++) { + if (strcasestr(device.name, names[i])) { + r.matched = true; + return r; + } + } + return r; +} + +// Raven Custom UUID Match (weight 80) +// Matches UUIDs with 16-bit short IDs 0x3100 through 0x3500. +// Format: "0000XXXX-0000-1000-8000-00805f9b34fb" +inline DetectorResult detectRavenCustomUuid(const BluetoothDeviceEvent& device) { + DetectorResult r = { false, 80, "raven_custom_uuid" }; + if (!device.hasServiceUUID || device.serviceUUID[0] == '\0') return r; + + const char* uuid = device.serviceUUID; + if (strlen(uuid) < 8) return r; + + // Check prefix "00003X00" where X is 1-5 + if (uuid[0] == '0' && uuid[1] == '0' && uuid[2] == '0' && uuid[3] == '0' && + uuid[4] == '3' && uuid[5] >= '1' && uuid[5] <= '5' && + uuid[6] == '0' && uuid[7] == '0') { + r.matched = true; + } + return r; +} + +// Raven Standard UUID Match (weight 10) +// Matches standard BLE SIG UUIDs that Raven also uses. +// Low weight because these are very common across consumer devices. +// 0x180A = Device Information, 0x1809 = Health Thermometer, 0x1819 = Location/Navigation +inline DetectorResult detectRavenStdUuid(const BluetoothDeviceEvent& device) { + DetectorResult r = { false, 10, "raven_std_uuid" }; + if (!device.hasServiceUUID || device.serviceUUID[0] == '\0') return r; + + if (strncasecmp(device.serviceUUID, "0000180a", 8) == 0 || + strncasecmp(device.serviceUUID, "00001809", 8) == 0 || + strncasecmp(device.serviceUUID, "00001819", 8) == 0) { + r.matched = true; + } + return r; +} + +// BLE MAC OUI Match (weight 20) +inline DetectorResult detectBleMacOui(const BluetoothDeviceEvent& device) { + DetectorResult r = { false, 20, "mac_oui" }; + if (ouiMatchesKnownPrefix(device.mac)) r.matched = true; + return r; +} + +// ============================================================ +// RSSI Modifier +// ============================================================ + +inline int8_t rssiModifier(int8_t rssi) { + if (rssi > -50) return 10; + if (rssi > -70) return 0; + if (rssi > -85) return -5; + return -10; +} + +#endif diff --git a/m5stack/flocksquawk_m5stick/src/DeviceSignatures.h b/m5stack/flocksquawk_m5stick/src/DeviceSignatures.h index 78a105f..fd7b3b4 100644 --- a/m5stack/flocksquawk_m5stick/src/DeviceSignatures.h +++ b/m5stack/flocksquawk_m5stick/src/DeviceSignatures.h @@ -4,19 +4,8 @@ #include namespace DeviceProfiles { - - // Network name patterns for target identification - const char* const NetworkNames[] = { - "flock", - "Flock", - "FLOCK", - "FS Ext Battery", - "Penguin", - "Pigvision", - }; - const size_t NetworkNameCount = sizeof(NetworkNames) / sizeof(NetworkNames[0]); - // MAC address OUI prefixes for target devices + // MAC address OUI prefixes for target devices (Lite-On Technology) const char* const MACPrefixes[] = { "58:8e:81", "cc:cc:cc", "ec:1b:bd", "90:35:ea", "04:0d:84", "f0:82:c0", "1c:34:f1", "38:5b:44", "94:34:69", "b4:e3:f9", @@ -24,28 +13,6 @@ namespace DeviceProfiles { "74:4c:a1", "08:3a:88", "9c:2f:9d", "94:08:53", "e4:aa:ea" }; const size_t MACPrefixCount = sizeof(MACPrefixes) / sizeof(MACPrefixes[0]); - - // Bluetooth device name patterns - const char* const BLEIdentifiers[] = { - "FS Ext Battery", - "Penguin", - "Flock", - "Pigvision" - }; - const size_t BLEIdentifierCount = sizeof(BLEIdentifiers) / sizeof(BLEIdentifiers[0]); - - // Raven acoustic detection device service UUIDs - const char* const RavenServices[] = { - "0000180a-0000-1000-8000-00805f9b34fb", // Device info (all versions) - "00003100-0000-1000-8000-00805f9b34fb", // GPS (1.2.0+) - "00003200-0000-1000-8000-00805f9b34fb", // Power/Battery (1.2.0+) - "00003300-0000-1000-8000-00805f9b34fb", // Network (1.2.0+) - "00003400-0000-1000-8000-00805f9b34fb", // Upload stats (1.2.0+) - "00003500-0000-1000-8000-00805f9b34fb", // Error tracking (1.2.0+) - "00001809-0000-1000-8000-00805f9b34fb", // Health/Temp (legacy 1.1.7) - "00001819-0000-1000-8000-00805f9b34fb" // Location (legacy 1.1.7) - }; - const size_t RavenServiceCount = sizeof(RavenServices) / sizeof(RavenServices[0]); } #endif diff --git a/m5stack/flocksquawk_m5stick/src/EventBus.h b/m5stack/flocksquawk_m5stick/src/EventBus.h index 6fa14fa..237a7d2 100644 --- a/m5stack/flocksquawk_m5stick/src/EventBus.h +++ b/m5stack/flocksquawk_m5stick/src/EventBus.h @@ -32,9 +32,13 @@ struct ThreatEvent { char identifier[64]; int8_t rssi; uint8_t channel; - const char* radioType; + char radioType[16]; uint8_t certainty; - const char* category; + char category[24]; + uint16_t matchFlags; + uint8_t detectorWeights[8]; + int8_t rssiModifier; + bool shouldAlert; }; class EventBus { diff --git a/m5stack/flocksquawk_m5stick/src/RadioScanner.h b/m5stack/flocksquawk_m5stick/src/RadioScanner.h index 0d72b08..fb369bd 100644 --- a/m5stack/flocksquawk_m5stick/src/RadioScanner.h +++ b/m5stack/flocksquawk_m5stick/src/RadioScanner.h @@ -13,16 +13,16 @@ class RadioScannerManager { public: static const uint8_t MAX_WIFI_CHANNEL = 13; - static const uint16_t CHANNEL_SWITCH_MS = 500; + static const uint16_t CHANNEL_SWITCH_MS = 1000; static const uint8_t BLE_SCAN_SECONDS = 1; static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; void initialize(); void update(); // Call from main loop static uint8_t getCurrentWifiChannel(); - + private: - static uint8_t currentWifiChannel; + static volatile uint8_t currentWifiChannel; static unsigned long lastChannelSwitch; static unsigned long lastBLEScan; static NimBLEScan* bleScanner; diff --git a/m5stack/flocksquawk_m5stick/src/TelemetryReporter.h b/m5stack/flocksquawk_m5stick/src/TelemetryReporter.h index ca38c42..55d5c29 100644 --- a/m5stack/flocksquawk_m5stick/src/TelemetryReporter.h +++ b/m5stack/flocksquawk_m5stick/src/TelemetryReporter.h @@ -4,21 +4,65 @@ #include #include #include "EventBus.h" +#include "DetectorTypes.h" class TelemetryReporter { public: - void initialize(); - void handleThreatDetection(const ThreatEvent& threat); - + void initialize() { + bootTime = millis(); + } + + void handleThreatDetection(const ThreatEvent& threat) { + StaticJsonDocument<512> doc; + + doc["event"] = "target_detected"; + doc["ms_since_boot"] = millis() - bootTime; + + // Source info + JsonObject source = doc.createNestedObject("source"); + source["radio"] = threat.radioType; + source["channel"] = threat.channel; + source["rssi"] = threat.rssi; + + // Target identity + JsonObject target = doc.createNestedObject("target"); + + char macStr[18]; + snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x", + threat.mac[0], threat.mac[1], threat.mac[2], + threat.mac[3], threat.mac[4], threat.mac[5]); + target["mac"] = macStr; + target["label"] = threat.identifier; + target["certainty"] = threat.certainty; + target["category"] = threat.category; + target["should_alert"] = threat.shouldAlert; + + // Detector details from matchFlags + JsonObject detectors = target.createNestedObject("detectors"); + + static const char* const detectorNames[] = { + "ssid_format", "ssid_keyword", "mac_oui", + "ble_name", "raven_custom_uuid", "raven_std_uuid", + "rssi_modifier" + }; + + for (uint8_t bit = 0; bit < 7; bit++) { + if (threat.matchFlags & (1 << bit)) { + if (bit == 6) { + // rssi_modifier is signed + detectors[detectorNames[bit]] = threat.rssiModifier; + } else { + detectors[detectorNames[bit]] = threat.detectorWeights[bit]; + } + } + } + + serializeJson(doc, Serial); + Serial.println(); + } + private: unsigned long bootTime; - - void serializeThreatToJSON(const ThreatEvent& threat, JsonDocument& doc); - void appendSourceInfo(const ThreatEvent& threat, JsonDocument& doc); - void appendTargetIdentity(const ThreatEvent& threat, JsonDocument& doc); - void appendIndicators(const ThreatEvent& threat, JsonDocument& doc); - void appendMetadata(const ThreatEvent& threat, JsonDocument& doc); - void outputJSON(const JsonDocument& doc); }; -#endif \ No newline at end of file +#endif diff --git a/m5stack/flocksquawk_m5stick/src/ThreatAnalyzer.h b/m5stack/flocksquawk_m5stick/src/ThreatAnalyzer.h index 12c95ae..9891223 100644 --- a/m5stack/flocksquawk_m5stick/src/ThreatAnalyzer.h +++ b/m5stack/flocksquawk_m5stick/src/ThreatAnalyzer.h @@ -3,25 +3,263 @@ #include #include "EventBus.h" -#include "DeviceSignatures.h" +#include "DetectorTypes.h" +#include "Detectors.h" + +// ============================================================ +// Detector Registry +// To add a detector: append one entry to the appropriate array. +// ============================================================ + +static const WiFiDetectorEntry wifiDetectors[] = { + { detectSsidFormat, DET_SSID_FORMAT }, + { detectSsidKeyword, DET_SSID_KEYWORD }, + { detectWifiMacOui, DET_MAC_OUI }, +}; +static const uint8_t WIFI_DETECTOR_COUNT = + sizeof(wifiDetectors) / sizeof(wifiDetectors[0]); + +static const BLEDetectorEntry bleDetectors[] = { + { detectBleName, DET_BLE_NAME }, + { detectRavenCustomUuid, DET_RAVEN_CUSTOM_UUID }, + { detectRavenStdUuid, DET_RAVEN_STD_UUID }, + { detectBleMacOui, DET_MAC_OUI }, +}; +static const uint8_t BLE_DETECTOR_COUNT = + sizeof(bleDetectors) / sizeof(bleDetectors[0]); + +// ============================================================ +// Device Presence Tracker +// ============================================================ + +class DeviceTracker { +public: + void initialize() { + for (uint8_t i = 0; i < MAX_TRACKED_DEVICES; i++) { + slots[i].state = DeviceState::EMPTY; + } + } + + // Age out stale devices. Call every loop iteration. + void tick(uint32_t nowMs) { + for (uint8_t i = 0; i < MAX_TRACKED_DEVICES; i++) { + if (slots[i].state == DeviceState::IN_RANGE || + slots[i].state == DeviceState::NEW_DETECT) { + if (nowMs - slots[i].lastSeenMs > DEVICE_TIMEOUT_MS) { + slots[i].state = DeviceState::DEPARTED; + } + } + } + } + + // Record a detection. Returns the state the device was in BEFORE + // this update (EMPTY = first time seen). + DeviceState recordDetection(const uint8_t* mac, uint32_t nowMs, + uint8_t certainty) { + for (uint8_t i = 0; i < MAX_TRACKED_DEVICES; i++) { + if (slots[i].state != DeviceState::EMPTY && + slots[i].state != DeviceState::DEPARTED && + memcmp(slots[i].mac, mac, 6) == 0) { + DeviceState prev = slots[i].state; + slots[i].lastSeenMs = nowMs; + slots[i].state = DeviceState::IN_RANGE; + if (certainty > slots[i].maxCertainty) + slots[i].maxCertainty = certainty; + return prev; + } + } + + uint8_t slot = findFreeSlot(); + memcpy(slots[slot].mac, mac, 6); + slots[slot].firstSeenMs = nowMs; + slots[slot].lastSeenMs = nowMs; + slots[slot].maxCertainty = certainty; + slots[slot].state = DeviceState::NEW_DETECT; + return DeviceState::EMPTY; + } + + // Returns true if any tracked device is IN_RANGE and above threshold. + bool hasHighConfidenceInRange() const { + for (uint8_t i = 0; i < MAX_TRACKED_DEVICES; i++) { + if (slots[i].state == DeviceState::IN_RANGE && + slots[i].maxCertainty >= ALERT_THRESHOLD) { + return true; + } + } + return false; + } + +private: + TrackedDevice slots[MAX_TRACKED_DEVICES]; + + uint8_t findFreeSlot() { + for (uint8_t i = 0; i < MAX_TRACKED_DEVICES; i++) { + if (slots[i].state == DeviceState::EMPTY) return i; + } + uint8_t oldest = 0; + uint32_t oldestTime = UINT32_MAX; + for (uint8_t i = 0; i < MAX_TRACKED_DEVICES; i++) { + if (slots[i].state == DeviceState::DEPARTED && + slots[i].lastSeenMs < oldestTime) { + oldest = i; + oldestTime = slots[i].lastSeenMs; + } + } + if (oldestTime < UINT32_MAX) return oldest; + // All slots active -- evict LRU + oldest = 0; + oldestTime = UINT32_MAX; + for (uint8_t i = 0; i < MAX_TRACKED_DEVICES; i++) { + if (slots[i].lastSeenMs < oldestTime) { + oldest = i; + oldestTime = slots[i].lastSeenMs; + } + } + return oldest; + } +}; + +// ============================================================ +// Bit position helper +// ============================================================ + +inline uint8_t detectorBitPosition(uint16_t flag) { + uint8_t pos = 0; + while (flag > 1) { flag >>= 1; pos++; } + return pos; +} + +// ============================================================ +// ThreatAnalyzer +// Pure logic -- no display/buzzer access. Safe from any context. +// ============================================================ class ThreatAnalyzer { public: - void initialize(); - void analyzeWiFiFrame(const WiFiFrameEvent& frame); - void analyzeBluetoothDevice(const BluetoothDeviceEvent& device); - + void initialize() { + tracker.initialize(); + lastHeartbeatMs = 0; + } + + void analyzeWiFiFrame(const WiFiFrameEvent& frame) { + uint16_t matchFlags = 0; + uint8_t weights[8]; + memset(weights, 0, sizeof(weights)); + int16_t totalWeight = 0; + + for (uint8_t i = 0; i < WIFI_DETECTOR_COUNT; i++) { + DetectorResult res = wifiDetectors[i].fn(frame); + if (res.matched) { + matchFlags |= wifiDetectors[i].flag; + uint8_t bit = detectorBitPosition(wifiDetectors[i].flag); + if (bit < sizeof(weights)) weights[bit] = res.weight; + totalWeight += res.weight; + } + } + + // Subsumption: SSID format supersedes SSID keyword + if ((matchFlags & DET_SSID_FORMAT) && (matchFlags & DET_SSID_KEYWORD)) { + totalWeight -= weights[detectorBitPosition(DET_SSID_KEYWORD)]; + matchFlags &= ~DET_SSID_KEYWORD; + weights[detectorBitPosition(DET_SSID_KEYWORD)] = 0; + } + + if (matchFlags == DET_NONE) return; + + int8_t rssiMod = rssiModifier(frame.rssi); + totalWeight += rssiMod; + uint8_t certainty = (uint8_t)constrain(totalWeight, 0, 100); + + uint32_t nowMs = millis(); + DeviceState prevState = tracker.recordDetection( + frame.mac, nowMs, certainty); + + ThreatEvent threat; + memset(&threat, 0, sizeof(threat)); + memcpy(threat.mac, frame.mac, 6); + strncpy(threat.identifier, frame.ssid, + sizeof(threat.identifier) - 1); + threat.rssi = frame.rssi; + threat.channel = frame.channel; + strncpy(threat.radioType, "wifi", sizeof(threat.radioType) - 1); + threat.certainty = certainty; + strncpy(threat.category, "surveillance_device", sizeof(threat.category) - 1); + threat.matchFlags = matchFlags | DET_RSSI_MODIFIER; + memcpy(threat.detectorWeights, weights, sizeof(weights)); + threat.rssiModifier = rssiMod; + threat.shouldAlert = (certainty >= ALERT_THRESHOLD && + prevState == DeviceState::EMPTY); + + EventBus::publishThreat(threat); + } + + void analyzeBluetoothDevice(const BluetoothDeviceEvent& device) { + uint16_t matchFlags = 0; + uint8_t weights[8]; + memset(weights, 0, sizeof(weights)); + int16_t totalWeight = 0; + + for (uint8_t i = 0; i < BLE_DETECTOR_COUNT; i++) { + DetectorResult res = bleDetectors[i].fn(device); + if (res.matched) { + matchFlags |= bleDetectors[i].flag; + uint8_t bit = detectorBitPosition(bleDetectors[i].flag); + if (bit < sizeof(weights)) weights[bit] = res.weight; + totalWeight += res.weight; + } + } + + if (matchFlags == DET_NONE) return; + + int8_t rssiMod = rssiModifier(device.rssi); + totalWeight += rssiMod; + uint8_t certainty = (uint8_t)constrain(totalWeight, 0, 100); + + uint32_t nowMs = millis(); + DeviceState prevState = tracker.recordDetection( + device.mac, nowMs, certainty); + + const char* cat = + (matchFlags & (DET_RAVEN_CUSTOM_UUID | DET_RAVEN_STD_UUID)) + ? "acoustic_detector" + : "surveillance_device"; + + ThreatEvent threat; + memset(&threat, 0, sizeof(threat)); + memcpy(threat.mac, device.mac, 6); + strncpy(threat.identifier, device.name, + sizeof(threat.identifier) - 1); + threat.rssi = device.rssi; + threat.channel = 0; + strncpy(threat.radioType, "bluetooth", sizeof(threat.radioType) - 1); + threat.certainty = certainty; + strncpy(threat.category, cat, sizeof(threat.category) - 1); + threat.matchFlags = matchFlags | DET_RSSI_MODIFIER; + memcpy(threat.detectorWeights, weights, sizeof(weights)); + threat.rssiModifier = rssiMod; + threat.shouldAlert = (certainty >= ALERT_THRESHOLD && + prevState == DeviceState::EMPTY); + + EventBus::publishThreat(threat); + } + + // Call from loop(). Ages out stale devices and returns true + // if a heartbeat beep should be emitted (caller handles hardware). + bool tick(uint32_t nowMs) { + tracker.tick(nowMs); + + if (nowMs - lastHeartbeatMs >= HEARTBEAT_INTERVAL_MS) { + lastHeartbeatMs = nowMs; + if (tracker.hasHighConfidenceInRange()) { + return true; + } + } + return false; + } + private: - bool matchesNetworkName(const char* ssid); - bool matchesMACPrefix(const uint8_t* mac); - bool matchesBLEName(const char* name); - bool matchesRavenService(const char* uuid); - uint8_t calculateCertainty(bool nameMatch, bool macMatch, bool uuidMatch); - const char* determineCategory(bool isRaven); - void emitThreatDetection(const WiFiFrameEvent& frame, const char* radio, uint8_t certainty); - void emitThreatDetection(const BluetoothDeviceEvent& device, const char* radio, uint8_t certainty, const char* category); - void formatMACAddress(const uint8_t* mac, char* output); - void extractOUI(const uint8_t* mac, char* output); + DeviceTracker tracker; + uint32_t lastHeartbeatMs; }; -#endif \ No newline at end of file +#endif From cfa53a37d4b91c05639b94300a53d9dde440ff50 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 31 Jan 2026 12:03:17 -0700 Subject: [PATCH 02/23] Add CLAUDE.md with project architecture and build guidance Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..12f2fcc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +FlockSquawk is an ESP32 Arduino project for passive RF awareness. It sniffs WiFi management frames and BLE advertisements, matches them against known surveillance device signatures, and outputs alerts via displays, audio, and JSON telemetry over Serial. + +## Build System + +This is a pure **Arduino IDE** project (no CMake, PlatformIO, or Makefile). Each hardware variant is a self-contained Arduino sketch that opens directly in the IDE. + +**Critical constraint:** ESP32 board package must be **version 3.0.7 or older** (newer versions cause IRAM overflow). + +### Building with arduino-cli + +```bash +# Install ESP32 core +arduino-cli core install esp32:esp32@3.0.7 + +# Install required libraries +arduino-cli lib install ArduinoJson NimBLE-Arduino M5Unified + +# Compile a variant (example: M5StickC Plus2) +arduino-cli compile --fqbn esp32:esp32:m5stick_c_plus2 m5stack/flocksquawk_m5stick/ + +# Upload +arduino-cli upload --fqbn esp32:esp32:m5stick_c_plus2 --port /dev/ttyUSB0 m5stack/flocksquawk_m5stick/ + +# Serial monitor (115200 baud for JSON telemetry) +arduino-cli monitor --port /dev/ttyUSB0 --config baudrate=115200 +``` + +Board FQBNs vary per variant — check each variant's README for exact board settings. + +## Hardware Variants + +Six self-contained variants, each in its own directory with a dedicated README: + +| Variant | Path | Display | Audio | +|---|---|---|---| +| M5StickC Plus2 | `m5stack/flocksquawk_m5stick/` | Built-in TFT 135x240 | Buzzer tones | +| M5Stack FIRE | `m5stack/flocksquawk_m5fire/` | Built-in TFT 320x240 | Built-in speaker | +| Mini12864 | `Mini12864/flocksquawk_mini12864/` | ST7567 LCD 128x64 | I2S (MAX98357A) | +| 128x32 OLED | `128x32_OLED/flocksquawk_128x32/` | SSD1306/SH1106 I2C | I2S (MAX98357A) | +| 128x32 Portable | `128x32_OLED/flocksquawk_128x32_portable/` | SSD1306/SH1106 I2C | GPIO buzzer | +| Flipper Zero | `flipper-zero/dev-board-firmware/` | None (UART only) | None | + +Variants do **not** share source files — each has its own copy of the core headers in `src/`. Changes to shared logic (EventBus, ThreatAnalyzer, etc.) must be applied to each variant individually. + +## Architecture + +Data flows through a publish-subscribe pipeline: + +``` +RadioScannerManager (WiFi promiscuous + BLE scan) + → EventBus (WiFiFrameEvent / BluetoothDeviceEvent) + → ThreatAnalyzer (signature matching → ThreatEvent) + → TelemetryReporter (JSON over Serial) + → Display (variant-specific UI) + → SoundEngine (alerts) +``` + +### Core subsystems (in each variant's `src/`) + +- **EventBus.h** — Header-only static publish/subscribe. Event types: `WifiFrameCaptured`, `BluetoothDeviceFound`, `ThreatIdentified`, `SystemReady`. +- **RadioScanner.h** — WiFi promiscuous mode (channels 1-13, 1s hop interval) and BLE scanning via NimBLE (5s interval, 1s duration). Uses FreeRTOS portMUX spinlocks for thread safety between ISR callbacks and main loop. +- **ThreatAnalyzer.h** — Compares observations against `DeviceSignatures.h`. Tracks up to 32 devices with LRU eviction (states: NEW_DETECT → IN_RANGE → DEPARTED). Alert threshold: 65% certainty. +- **DeviceSignatures.h** — Static arrays of known SSIDs, MAC OUI prefixes, BLE device names, and service UUIDs. +- **TelemetryReporter.h** — Serializes detections as JSON via ArduinoJson (StaticJsonDocument<512>). +- **SoundEngine.h** — I2S WAV playback from LittleFS (Mini12864/128x32) or M5.Speaker tone generation (M5Stack variants). + +### Pluggable detector system (M5Stick variant only — newest architecture) + +The M5Stick variant introduces a function-pointer-based detector registry: + +- **DetectorTypes.h** — Defines `DetectorResult` (matched/weight/name) and `WiFiDetectorEntry`/`BLEDetectorEntry` structs. +- **Detectors.h** — Registers detector functions. WiFi: `detectSsidFormat` (weight 75), `detectSsidKeyword` (45), `detectWifiMacOui` (20). BLE: `detectBleName` (55), `detectRavenCustomUuid` (80), `detectRavenStdUuid` (10), `detectBleMacOui` (20). +- Weighted scoring with subsumption (higher-confidence detectors override lower ones) and RSSI-based modifiers. + +This detector pattern is the intended direction for all variants. + +## Thread Safety Pattern + +WiFi promiscuous callbacks and BLE scan callbacks run on different cores/tasks than `loop()`. All variants use the same pattern: + +- `portMUX_TYPE` spinlocks guard shared volatile state in ISR callbacks +- `taskENTER_CRITICAL` / `taskEXIT_CRITICAL` for atomic reads/writes +- Main loop copies event data under lock, then processes outside the critical section + +## Key Constants + +- WiFi channel hop: 1 second +- BLE scan interval: 5 seconds, 1 second duration +- Device tracking slots: 32 (LRU eviction) +- Device departure timeout: 60 seconds +- Heartbeat re-alert interval: 10 seconds +- Alert certainty threshold: 65% +- Serial baud rate: 115200 From 519a3da0fc977d93336a265d9cfd7f2fd2787fbf Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 31 Jan 2026 12:22:27 -0700 Subject: [PATCH 03/23] Add Makefile build system wrapping arduino-cli Provides per-variant build/upload/flash/monitor targets for all 6 hardware variants, plus LittleFS data upload for variants with audio assets. Uses GNU Make define/eval/call to generate targets from a single template. Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + Makefile | 188 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30bcfa4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..31c96e9 --- /dev/null +++ b/Makefile @@ -0,0 +1,188 @@ +# FlockSquawk — arduino-cli build system +# Usage: make help + +# ────────────────────────────────────────────── +# User-configurable variables +# ────────────────────────────────────────────── +PORT ?= +BAUD ?= 115200 +VARIANT ?= m5stick +CORE_VERSION ?= 3.0.7 + +BUILD_DIR := $(CURDIR)/.build + +# Arduino toolchain paths (Linux default; macOS overrides below) +ARDUINO_DATA ?= $(HOME)/.arduino15 +ifeq ($(shell uname),Darwin) + ARDUINO_DATA := $(HOME)/Library/Arduino15 +endif + +ESPTOOL = $(firstword $(wildcard $(ARDUINO_DATA)/packages/esp32/tools/esptool_py/*/esptool.py) \ + $(wildcard $(ARDUINO_DATA)/packages/esp32/tools/esptool_py/*/esptool)) +MKLITTLEFS = $(firstword $(wildcard $(ARDUINO_DATA)/packages/esp32/tools/mklittlefs/*/mklittlefs)) + +# ────────────────────────────────────────────── +# Variant definitions: NAME FQBN SKETCH_DIR HAS_DATA +# ────────────────────────────────────────────── +VARIANTS := m5stick m5fire mini12864 oled portable flipper + +m5stick_FQBN := esp32:esp32:m5stick_c_plus2 +m5stick_SKETCH := m5stack/flocksquawk_m5stick +m5stick_DATA := + +m5fire_FQBN := esp32:esp32:m5stack_fire +m5fire_SKETCH := m5stack/flocksquawk_m5fire +m5fire_DATA := 1 + +mini12864_FQBN := esp32:esp32:esp32 +mini12864_SKETCH := Mini12864/flocksquawk_mini12864 +mini12864_DATA := 1 + +oled_FQBN := esp32:esp32:esp32 +oled_SKETCH := 128x32_OLED/flocksquawk_128x32 +oled_DATA := 1 + +portable_FQBN := esp32:esp32:esp32 +portable_SKETCH := 128x32_OLED/flocksquawk_128x32_portable +portable_DATA := + +flipper_FQBN := esp32:esp32:esp32s2 +flipper_SKETCH := flipper-zero/dev-board-firmware/flocksquawk-flipper +flipper_DATA := + +# LittleFS defaults (4 MB flash, default partition table) +LITTLEFS_OFFSET ?= 0x290000 +LITTLEFS_SIZE ?= 0x160000 +LITTLEFS_PAGE ?= 256 +LITTLEFS_BLOCK ?= 4096 + +# ────────────────────────────────────────────── +# Auto-detect serial port when PORT is empty +# ────────────────────────────────────────────── +define detect_port +$(or $(PORT),$(firstword $(wildcard /dev/ttyUSB* /dev/ttyACM* /dev/cu.usbserial* /dev/cu.usbmodem* /dev/cu.wchusbserial*))) +endef + +# ────────────────────────────────────────────── +# Per-variant target template +# ────────────────────────────────────────────── +define VARIANT_TARGETS + +.PHONY: build-$(1) upload-$(1) flash-$(1) monitor-$(1) + +build-$(1): + arduino-cli compile \ + --fqbn $($(1)_FQBN) \ + --output-dir $(BUILD_DIR)/$(1) \ + $($(1)_SKETCH) + +upload-$(1): + $$(if $$(call detect_port),, $$(error PORT not set and no device auto-detected)) + arduino-cli upload \ + --fqbn $($(1)_FQBN) \ + --port $$(call detect_port) \ + --input-dir $(BUILD_DIR)/$(1) \ + $($(1)_SKETCH) + +flash-$(1): build-$(1) upload-$(1) + +monitor-$(1): + $$(if $$(call detect_port),, $$(error PORT not set and no device auto-detected)) + arduino-cli monitor \ + --port $$(call detect_port) \ + --config baudrate=$(BAUD) + +# Only generate upload-data target for variants with a data/ directory +ifneq ($($(1)_DATA),) +.PHONY: upload-data-$(1) +upload-data-$(1): + $$(if $$(call detect_port),, $$(error PORT not set and no device auto-detected)) + $$(if $(MKLITTLEFS),, $$(error mklittlefs not found — install ESP32 core first)) + $$(if $(ESPTOOL),, $$(error esptool not found — install ESP32 core first)) + @echo "Building LittleFS image from $($(1)_SKETCH)/data …" + $(MKLITTLEFS) -c $($(1)_SKETCH)/data \ + -p $(LITTLEFS_PAGE) -b $(LITTLEFS_BLOCK) \ + -s $(LITTLEFS_SIZE) \ + $(BUILD_DIR)/$(1)/littlefs.bin + @echo "Flashing LittleFS image to $$(call detect_port) …" + $(ESPTOOL) --chip esp32 --port $$(call detect_port) --baud 921600 \ + write_flash $(LITTLEFS_OFFSET) $(BUILD_DIR)/$(1)/littlefs.bin +endif + +endef + +# Expand targets for every variant +$(foreach v,$(VARIANTS),$(eval $(call VARIANT_TARGETS,$(v)))) + +# ────────────────────────────────────────────── +# Shorthand targets (use VARIANT variable) +# ────────────────────────────────────────────── +.PHONY: build upload flash monitor + +build: build-$(VARIANT) +upload: upload-$(VARIANT) +flash: flash-$(VARIANT) +monitor: monitor-$(VARIANT) + +# ────────────────────────────────────────────── +# Global targets +# ────────────────────────────────────────────── +.PHONY: all clean install-deps help + +all: $(foreach v,$(VARIANTS),build-$(v)) + +clean: + rm -rf $(BUILD_DIR) + +install-deps: + arduino-cli core update-index + arduino-cli core install esp32:esp32@$(CORE_VERSION) + arduino-cli lib install \ + ArduinoJson \ + "NimBLE-Arduino" \ + M5Unified \ + U8g2 \ + "Adafruit GFX Library" \ + "Adafruit SSD1306" + +# ────────────────────────────────────────────── +# Help (default target) +# ────────────────────────────────────────────── +.DEFAULT_GOAL := help + +help: + @echo "FlockSquawk Build System" + @echo "========================" + @echo "" + @echo "Variants: $(VARIANTS)" + @echo "" + @echo "Global targets:" + @echo " make help Show this message" + @echo " make all Compile all variants" + @echo " make clean Remove build output (.build/)" + @echo " make install-deps Install ESP32 core and Arduino libraries" + @echo "" + @echo "Per-variant targets (replace with a variant name above):" + @echo " make build- Compile sketch" + @echo " make upload- Upload firmware to device" + @echo " make flash- Compile + upload" + @echo " make monitor- Open serial monitor" + @echo " make upload-data- Flash LittleFS data (m5fire, mini12864, oled only)" + @echo "" + @echo "Shorthand (uses VARIANT, default: $(VARIANT)):" + @echo " make build => build-\$$(VARIANT)" + @echo " make upload => upload-\$$(VARIANT)" + @echo " make flash => flash-\$$(VARIANT)" + @echo " make monitor => monitor-\$$(VARIANT)" + @echo "" + @echo "Variables:" + @echo " PORT= Serial port (auto-detected if unset)" + @echo " BAUD= Monitor baud rate (default: 115200)" + @echo " VARIANT= Default variant (default: m5stick)" + @echo " CORE_VERSION= ESP32 core version (default: 3.0.7)" + @echo "" + @echo "Examples:" + @echo " make build-m5stick" + @echo " make flash-oled PORT=/dev/cu.usbserial-0001" + @echo " make upload-data-mini12864 PORT=/dev/ttyUSB0" + @echo " make all" From b6a0f243ded0a72c0312226598693626d6c12b25 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 31 Jan 2026 13:03:30 -0700 Subject: [PATCH 04/23] Add host-side unit test infrastructure using doctest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests compile natively with clang++/g++ — no ESP32 hardware needed. Covers detectors, device tracker state machine, and threat analyzer scoring pipeline (37 cases, 126 assertions) targeting the M5Stick variant's pure-logic headers. Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + Makefile | 32 ++++ test/eventbus_impl.cpp | 40 +++++ test/mocks/Arduino.h | 28 +++ test/test_detectors.cpp | 322 ++++++++++++++++++++++++++++++++++ test/test_device_tracker.cpp | 148 ++++++++++++++++ test/test_main.cpp | 2 + test/test_threat_analyzer.cpp | 290 ++++++++++++++++++++++++++++++ 8 files changed, 863 insertions(+) create mode 100644 test/eventbus_impl.cpp create mode 100644 test/mocks/Arduino.h create mode 100644 test/test_detectors.cpp create mode 100644 test/test_device_tracker.cpp create mode 100644 test/test_main.cpp create mode 100644 test/test_threat_analyzer.cpp diff --git a/.gitignore b/.gitignore index 30bcfa4..794cd06 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .build/ +test/doctest.h diff --git a/Makefile b/Makefile index 31c96e9..c2fa301 100644 --- a/Makefile +++ b/Makefile @@ -124,6 +124,36 @@ upload: upload-$(VARIANT) flash: flash-$(VARIANT) monitor: monitor-$(VARIANT) +# ────────────────────────────────────────────── +# Host-side unit tests (no ESP32 needed) +# ────────────────────────────────────────────── +TEST_CXX ?= clang++ +TEST_CXXFLAGS := -std=c++17 -Wall -Wextra -g -O0 +TEST_INCLUDES := -isystem test/mocks -I m5stack/flocksquawk_m5stick/src -I test +TEST_SRCS := test/test_main.cpp test/eventbus_impl.cpp \ + test/test_detectors.cpp test/test_device_tracker.cpp \ + test/test_threat_analyzer.cpp +TEST_BIN := $(BUILD_DIR)/test_runner +DOCTEST_URL := https://raw.githubusercontent.com/doctest/doctest/v2.4.11/doctest/doctest.h + +.PHONY: test test-verbose fetch-doctest + +fetch-doctest: test/doctest.h + +test/doctest.h: + @echo "Fetching doctest.h …" + curl -sL -o $@ $(DOCTEST_URL) + +test: fetch-doctest + @mkdir -p $(BUILD_DIR) + $(TEST_CXX) $(TEST_CXXFLAGS) $(TEST_INCLUDES) $(TEST_SRCS) -o $(TEST_BIN) + $(TEST_BIN) + +test-verbose: fetch-doctest + @mkdir -p $(BUILD_DIR) + $(TEST_CXX) $(TEST_CXXFLAGS) $(TEST_INCLUDES) $(TEST_SRCS) -o $(TEST_BIN) + $(TEST_BIN) --success + # ────────────────────────────────────────────── # Global targets # ────────────────────────────────────────────── @@ -159,6 +189,8 @@ help: @echo "Global targets:" @echo " make help Show this message" @echo " make all Compile all variants" + @echo " make test Run host-side unit tests" + @echo " make test-verbose Run tests with per-assertion output" @echo " make clean Remove build output (.build/)" @echo " make install-deps Install ESP32 core and Arduino libraries" @echo "" diff --git a/test/eventbus_impl.cpp b/test/eventbus_impl.cpp new file mode 100644 index 0000000..e757e64 --- /dev/null +++ b/test/eventbus_impl.cpp @@ -0,0 +1,40 @@ +// Provides storage for EventBus static members and the mock millis value. +// Linked into the test binary so ThreatAnalyzer's calls to +// EventBus::publishThreat() resolve without pulling in real hardware code. + +#include "EventBus.h" + +// Test-controllable time +uint32_t mock_millis_value = 0; + +// EventBus static member definitions (no-op handlers by default) +EventBus::WiFiFrameHandler EventBus::wifiHandler; +EventBus::BluetoothHandler EventBus::bluetoothHandler; +EventBus::ThreatHandler EventBus::threatHandler; +EventBus::SystemEventHandler EventBus::systemReadyHandler; + +void EventBus::publishWifiFrame(const WiFiFrameEvent& event) { + if (wifiHandler) wifiHandler(event); +} +void EventBus::publishBluetoothDevice(const BluetoothDeviceEvent& event) { + if (bluetoothHandler) bluetoothHandler(event); +} +void EventBus::publishThreat(const ThreatEvent& event) { + if (threatHandler) threatHandler(event); +} +void EventBus::publishSystemReady() { + if (systemReadyHandler) systemReadyHandler(); +} + +void EventBus::subscribeWifiFrame(WiFiFrameHandler handler) { + wifiHandler = handler; +} +void EventBus::subscribeBluetoothDevice(BluetoothHandler handler) { + bluetoothHandler = handler; +} +void EventBus::subscribeThreat(ThreatHandler handler) { + threatHandler = handler; +} +void EventBus::subscribeSystemReady(SystemEventHandler handler) { + systemReadyHandler = handler; +} diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h new file mode 100644 index 0000000..c66ab97 --- /dev/null +++ b/test/mocks/Arduino.h @@ -0,0 +1,28 @@ +// Minimal Arduino.h mock for host-side unit tests. +// Provides only what the testable production headers actually use. +#ifndef ARDUINO_H_MOCK +#define ARDUINO_H_MOCK + +#include +#include +#include +#include +#include + +// On Linux, strcasestr needs _GNU_SOURCE (already available on macOS). +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include + +// constrain macro (matches Arduino) +#ifndef constrain +#define constrain(x, low, high) \ + ((x) < (low) ? (low) : ((x) > (high) ? (high) : (x))) +#endif + +// Test-controllable millis() — defined in eventbus_impl.cpp +extern uint32_t mock_millis_value; +inline uint32_t millis() { return mock_millis_value; } + +#endif // ARDUINO_H_MOCK diff --git a/test/test_detectors.cpp b/test/test_detectors.cpp new file mode 100644 index 0000000..4c52b73 --- /dev/null +++ b/test/test_detectors.cpp @@ -0,0 +1,322 @@ +#include "doctest.h" +#include "Detectors.h" + +// ============================================================ +// Helper: build WiFiFrameEvent +// ============================================================ +static WiFiFrameEvent makeWiFiFrame(const char* ssid, int8_t rssi = -60, + uint8_t channel = 6) { + WiFiFrameEvent f; + memset(&f, 0, sizeof(f)); + strncpy(f.ssid, ssid, sizeof(f.ssid) - 1); + f.rssi = rssi; + f.channel = channel; + f.frameSubtype = 0x20; + return f; +} + +// Helper: build WiFiFrameEvent with specific MAC +static WiFiFrameEvent makeWiFiFrameMAC(const uint8_t mac[6], + const char* ssid = "", + int8_t rssi = -60) { + WiFiFrameEvent f; + memset(&f, 0, sizeof(f)); + memcpy(f.mac, mac, 6); + strncpy(f.ssid, ssid, sizeof(f.ssid) - 1); + f.rssi = rssi; + f.channel = 6; + f.frameSubtype = 0x20; + return f; +} + +// Helper: build BluetoothDeviceEvent +static BluetoothDeviceEvent makeBLEDevice(const char* name = "", + int8_t rssi = -60, + const char* uuid = "", + bool hasUUID = false) { + BluetoothDeviceEvent d; + memset(&d, 0, sizeof(d)); + strncpy(d.name, name, sizeof(d.name) - 1); + d.rssi = rssi; + d.hasServiceUUID = hasUUID; + if (uuid[0] != '\0') { + strncpy(d.serviceUUID, uuid, sizeof(d.serviceUUID) - 1); + d.hasServiceUUID = true; + } + return d; +} + +// Helper: build BLE device with specific MAC +static BluetoothDeviceEvent makeBLEDeviceMAC(const uint8_t mac[6], + const char* name = "", + int8_t rssi = -60) { + BluetoothDeviceEvent d; + memset(&d, 0, sizeof(d)); + memcpy(d.mac, mac, 6); + strncpy(d.name, name, sizeof(d.name) - 1); + d.rssi = rssi; + d.hasServiceUUID = false; + return d; +} + +// ============================================================ +// isHexChar +// ============================================================ +TEST_CASE("isHexChar") { + CHECK(isHexChar('0')); + CHECK(isHexChar('9')); + CHECK(isHexChar('a')); + CHECK(isHexChar('f')); + CHECK(isHexChar('A')); + CHECK(isHexChar('F')); + CHECK_FALSE(isHexChar('g')); + CHECK_FALSE(isHexChar('G')); + CHECK_FALSE(isHexChar('z')); + CHECK_FALSE(isHexChar('-')); + CHECK_FALSE(isHexChar(' ')); +} + +// ============================================================ +// isHexSuffix +// ============================================================ +TEST_CASE("isHexSuffix") { + CHECK(isHexSuffix("abcdef", 6)); + CHECK(isHexSuffix("ABCDEF", 6)); + CHECK(isHexSuffix("012345", 6)); + CHECK_FALSE(isHexSuffix("abcdeg", 6)); + CHECK_FALSE(isHexSuffix("abcde", 6)); // too short — char at [5] is '\0' but len=6 checks s[5] + CHECK(isHexSuffix("ab", 2)); + CHECK(isHexSuffix("", 0)); // zero-length suffix — s[0] must be '\0' +} + +// ============================================================ +// isDecimalSuffix +// ============================================================ +TEST_CASE("isDecimalSuffix") { + CHECK(isDecimalSuffix("1234567890", 10)); + CHECK(isDecimalSuffix("0000000000", 10)); + CHECK_FALSE(isDecimalSuffix("123456789a", 10)); + CHECK_FALSE(isDecimalSuffix("12345", 10)); // char at index 5+ isn't a digit + CHECK(isDecimalSuffix("42", 2)); + CHECK(isDecimalSuffix("", 0)); +} + +// ============================================================ +// ouiMatchesKnownPrefix +// ============================================================ +TEST_CASE("ouiMatchesKnownPrefix") { + // 58:8e:81 is in DeviceProfiles::MACPrefixes + uint8_t known[] = { 0x58, 0x8E, 0x81, 0x11, 0x22, 0x33 }; + CHECK(ouiMatchesKnownPrefix(known)); + + // cc:cc:cc is in the list + uint8_t known2[] = { 0xCC, 0xCC, 0xCC, 0x00, 0x00, 0x00 }; + CHECK(ouiMatchesKnownPrefix(known2)); + + // aa:bb:cc is NOT in the list + uint8_t unknown[] = { 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33 }; + CHECK_FALSE(ouiMatchesKnownPrefix(unknown)); +} + +// ============================================================ +// rssiModifier +// ============================================================ +TEST_CASE("rssiModifier") { + CHECK(rssiModifier(-30) == 10); // very close + CHECK(rssiModifier(-49) == 10); // just above -50 + CHECK(rssiModifier(-50) == 0); // boundary: > -50 is false, > -70 is true + CHECK(rssiModifier(-60) == 0); // medium range + CHECK(rssiModifier(-70) == -5); // boundary: > -70 is false, > -85 is true + CHECK(rssiModifier(-80) == -5); // weak + CHECK(rssiModifier(-85) == -10); // boundary: > -85 is false + CHECK(rssiModifier(-90) == -10); // very weak +} + +// ============================================================ +// detectSsidFormat +// ============================================================ +TEST_CASE("detectSsidFormat") { + SUBCASE("Flock- pattern matches") { + auto r = detectSsidFormat(makeWiFiFrame("Flock-a1b2c3")); + CHECK(r.matched); + CHECK(r.weight == 75); + } + SUBCASE("Flock- with uppercase hex matches") { + CHECK(detectSsidFormat(makeWiFiFrame("Flock-ABCDEF")).matched); + } + SUBCASE("Flock- wrong length rejects") { + CHECK_FALSE(detectSsidFormat(makeWiFiFrame("Flock-a1b2c")).matched); + CHECK_FALSE(detectSsidFormat(makeWiFiFrame("Flock-a1b2c3d")).matched); + } + SUBCASE("Flock- non-hex suffix rejects") { + CHECK_FALSE(detectSsidFormat(makeWiFiFrame("Flock-a1b2gX")).matched); + } + SUBCASE("Penguin- pattern matches") { + CHECK(detectSsidFormat(makeWiFiFrame("Penguin-1234567890")).matched); + } + SUBCASE("Penguin- wrong length rejects") { + CHECK_FALSE(detectSsidFormat(makeWiFiFrame("Penguin-123456789")).matched); + } + SUBCASE("Penguin- non-decimal rejects") { + CHECK_FALSE(detectSsidFormat(makeWiFiFrame("Penguin-12345678ab")).matched); + } + SUBCASE("FS Ext Battery exact match") { + CHECK(detectSsidFormat(makeWiFiFrame("FS Ext Battery")).matched); + } + SUBCASE("FS Ext Battery prefix does not match") { + CHECK_FALSE(detectSsidFormat(makeWiFiFrame("FS Ext Battery v2")).matched); + } + SUBCASE("Empty SSID rejects") { + CHECK_FALSE(detectSsidFormat(makeWiFiFrame("")).matched); + } + SUBCASE("Unrelated SSID rejects") { + CHECK_FALSE(detectSsidFormat(makeWiFiFrame("MyHomeWiFi")).matched); + } +} + +// ============================================================ +// detectSsidKeyword +// ============================================================ +TEST_CASE("detectSsidKeyword") { + SUBCASE("flock keyword matches (case-insensitive)") { + CHECK(detectSsidKeyword(makeWiFiFrame("Flock-a1b2c3")).matched); + CHECK(detectSsidKeyword(makeWiFiFrame("MyFLOCKnet")).matched); + } + SUBCASE("penguin keyword matches") { + CHECK(detectSsidKeyword(makeWiFiFrame("Penguin-1234567890")).matched); + } + SUBCASE("pigvision keyword matches") { + CHECK(detectSsidKeyword(makeWiFiFrame("PigVision_AP")).matched); + } + SUBCASE("weight is 45") { + auto r = detectSsidKeyword(makeWiFiFrame("flock-test")); + CHECK(r.weight == 45); + } + SUBCASE("no match for unrelated SSID") { + CHECK_FALSE(detectSsidKeyword(makeWiFiFrame("Starbucks WiFi")).matched); + } + SUBCASE("empty SSID rejects") { + CHECK_FALSE(detectSsidKeyword(makeWiFiFrame("")).matched); + } +} + +// ============================================================ +// detectWifiMacOui +// ============================================================ +TEST_CASE("detectWifiMacOui") { + SUBCASE("known OUI matches") { + uint8_t mac[] = { 0x58, 0x8E, 0x81, 0x11, 0x22, 0x33 }; + auto f = makeWiFiFrameMAC(mac); + auto r = detectWifiMacOui(f); + CHECK(r.matched); + CHECK(r.weight == 20); + } + SUBCASE("unknown OUI does not match") { + uint8_t mac[] = { 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33 }; + auto f = makeWiFiFrameMAC(mac); + CHECK_FALSE(detectWifiMacOui(f).matched); + } +} + +// ============================================================ +// detectBleName +// ============================================================ +TEST_CASE("detectBleName") { + SUBCASE("Flock matches") { + auto r = detectBleName(makeBLEDevice("Flock Tracker")); + CHECK(r.matched); + CHECK(r.weight == 55); + } + SUBCASE("Penguin matches case-insensitively") { + CHECK(detectBleName(makeBLEDevice("PENGUIN-unit")).matched); + } + SUBCASE("FS Ext Battery matches") { + CHECK(detectBleName(makeBLEDevice("FS Ext Battery")).matched); + } + SUBCASE("Pigvision matches") { + CHECK(detectBleName(makeBLEDevice("pigvision-3")).matched); + } + SUBCASE("unrelated name does not match") { + CHECK_FALSE(detectBleName(makeBLEDevice("AirPods Pro")).matched); + } + SUBCASE("empty name does not match") { + CHECK_FALSE(detectBleName(makeBLEDevice("")).matched); + } +} + +// ============================================================ +// detectRavenCustomUuid +// ============================================================ +TEST_CASE("detectRavenCustomUuid") { + SUBCASE("0x3100 matches") { + auto d = makeBLEDevice("", -60, "00003100-0000-1000-8000-00805f9b34fb"); + CHECK(detectRavenCustomUuid(d).matched); + } + SUBCASE("0x3500 matches") { + auto d = makeBLEDevice("", -60, "00003500-0000-1000-8000-00805f9b34fb"); + CHECK(detectRavenCustomUuid(d).matched); + } + SUBCASE("0x3000 does not match (digit 0 out of 1-5 range)") { + auto d = makeBLEDevice("", -60, "00003000-0000-1000-8000-00805f9b34fb"); + CHECK_FALSE(detectRavenCustomUuid(d).matched); + } + SUBCASE("0x3600 does not match (digit 6 out of range)") { + auto d = makeBLEDevice("", -60, "00003600-0000-1000-8000-00805f9b34fb"); + CHECK_FALSE(detectRavenCustomUuid(d).matched); + } + SUBCASE("no UUID does not match") { + auto d = makeBLEDevice("SomeName", -60); + CHECK_FALSE(detectRavenCustomUuid(d).matched); + } + SUBCASE("weight is 80") { + auto d = makeBLEDevice("", -60, "00003200-0000-1000-8000-00805f9b34fb"); + CHECK(detectRavenCustomUuid(d).weight == 80); + } +} + +// ============================================================ +// detectRavenStdUuid +// ============================================================ +TEST_CASE("detectRavenStdUuid") { + SUBCASE("0x180A (Device Information) matches") { + auto d = makeBLEDevice("", -60, "0000180a-0000-1000-8000-00805f9b34fb"); + CHECK(detectRavenStdUuid(d).matched); + } + SUBCASE("0x1809 (Health Thermometer) matches") { + auto d = makeBLEDevice("", -60, "00001809-0000-1000-8000-00805f9b34fb"); + CHECK(detectRavenStdUuid(d).matched); + } + SUBCASE("0x1819 (Location/Navigation) matches") { + auto d = makeBLEDevice("", -60, "00001819-0000-1000-8000-00805f9b34fb"); + CHECK(detectRavenStdUuid(d).matched); + } + SUBCASE("0x180A uppercase matches") { + auto d = makeBLEDevice("", -60, "0000180A-0000-1000-8000-00805f9b34fb"); + CHECK(detectRavenStdUuid(d).matched); + } + SUBCASE("unrelated UUID does not match") { + auto d = makeBLEDevice("", -60, "0000180f-0000-1000-8000-00805f9b34fb"); + CHECK_FALSE(detectRavenStdUuid(d).matched); + } + SUBCASE("weight is 10") { + auto d = makeBLEDevice("", -60, "0000180a-0000-1000-8000-00805f9b34fb"); + CHECK(detectRavenStdUuid(d).weight == 10); + } +} + +// ============================================================ +// detectBleMacOui +// ============================================================ +TEST_CASE("detectBleMacOui") { + SUBCASE("known OUI matches") { + uint8_t mac[] = { 0xEC, 0x1B, 0xBD, 0x44, 0x55, 0x66 }; + auto d = makeBLEDeviceMAC(mac); + auto r = detectBleMacOui(d); + CHECK(r.matched); + CHECK(r.weight == 20); + } + SUBCASE("unknown OUI does not match") { + uint8_t mac[] = { 0x00, 0x11, 0x22, 0x33, 0x44, 0x55 }; + CHECK_FALSE(detectBleMacOui(makeBLEDeviceMAC(mac)).matched); + } +} diff --git a/test/test_device_tracker.cpp b/test/test_device_tracker.cpp new file mode 100644 index 0000000..1cbe656 --- /dev/null +++ b/test/test_device_tracker.cpp @@ -0,0 +1,148 @@ +#include "doctest.h" +#include "ThreatAnalyzer.h" + +extern uint32_t mock_millis_value; + +// ============================================================ +// Helpers +// ============================================================ + +static void setMAC(uint8_t* dst, uint8_t a, uint8_t b, uint8_t c, + uint8_t d, uint8_t e, uint8_t f) { + dst[0] = a; dst[1] = b; dst[2] = c; + dst[3] = d; dst[4] = e; dst[5] = f; +} + +// ============================================================ +// DeviceTracker tests +// ============================================================ + +TEST_CASE("DeviceTracker: initialize clears all slots") { + DeviceTracker tracker; + tracker.initialize(); + // After initialize, no device should be in range + CHECK_FALSE(tracker.hasHighConfidenceInRange()); +} + +TEST_CASE("DeviceTracker: first detection returns EMPTY") { + DeviceTracker tracker; + tracker.initialize(); + uint8_t mac[6]; + setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); + DeviceState prev = tracker.recordDetection(mac, 1000, 80); + CHECK(prev == DeviceState::EMPTY); +} + +TEST_CASE("DeviceTracker: second detection returns NEW_DETECT") { + DeviceTracker tracker; + tracker.initialize(); + uint8_t mac[6]; + setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); + tracker.recordDetection(mac, 1000, 80); + DeviceState prev = tracker.recordDetection(mac, 2000, 80); + CHECK(prev == DeviceState::NEW_DETECT); +} + +TEST_CASE("DeviceTracker: third detection returns IN_RANGE") { + DeviceTracker tracker; + tracker.initialize(); + uint8_t mac[6]; + setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); + tracker.recordDetection(mac, 1000, 80); + tracker.recordDetection(mac, 2000, 80); + DeviceState prev = tracker.recordDetection(mac, 3000, 80); + CHECK(prev == DeviceState::IN_RANGE); +} + +TEST_CASE("DeviceTracker: timeout transitions to DEPARTED") { + DeviceTracker tracker; + tracker.initialize(); + uint8_t mac[6]; + setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); + tracker.recordDetection(mac, 1000, 80); + tracker.recordDetection(mac, 2000, 80); // now IN_RANGE, lastSeenMs=2000 + // Tick past the 60s timeout from last-seen time + tracker.tick(2000 + DEVICE_TIMEOUT_MS + 1); + CHECK_FALSE(tracker.hasHighConfidenceInRange()); +} + +TEST_CASE("DeviceTracker: hasHighConfidenceInRange") { + DeviceTracker tracker; + tracker.initialize(); + uint8_t mac[6]; + setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); + + SUBCASE("above threshold and IN_RANGE returns true") { + tracker.recordDetection(mac, 1000, 80); + tracker.recordDetection(mac, 2000, 80); // IN_RANGE + CHECK(tracker.hasHighConfidenceInRange()); + } + SUBCASE("below threshold returns false") { + tracker.recordDetection(mac, 1000, 30); + tracker.recordDetection(mac, 2000, 30); + CHECK_FALSE(tracker.hasHighConfidenceInRange()); + } + SUBCASE("NEW_DETECT alone is not IN_RANGE") { + // NEW_DETECT with high certainty shouldn't count + tracker.recordDetection(mac, 1000, 90); + CHECK_FALSE(tracker.hasHighConfidenceInRange()); + } +} + +TEST_CASE("DeviceTracker: max certainty updates on higher value") { + DeviceTracker tracker; + tracker.initialize(); + uint8_t mac[6]; + setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); + tracker.recordDetection(mac, 1000, 30); // NEW_DETECT + tracker.recordDetection(mac, 2000, 80); // IN_RANGE, certainty bumped to 80 + CHECK(tracker.hasHighConfidenceInRange()); +} + +TEST_CASE("DeviceTracker: LRU eviction prefers empty slots") { + DeviceTracker tracker; + tracker.initialize(); + // Fill all 32 slots + for (uint8_t i = 0; i < MAX_TRACKED_DEVICES; i++) { + uint8_t mac[6]; + setMAC(mac, 0x10, 0x20, 0x30, 0x00, 0x00, i); + tracker.recordDetection(mac, 1000 + i, 50); + } + // 33rd device should evict the oldest departed (none departed) + // so it evicts oldest active device (slot 0, lastSeen=1000) + uint8_t newMac[6]; + setMAC(newMac, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x01); + DeviceState prev = tracker.recordDetection(newMac, 5000, 70); + CHECK(prev == DeviceState::EMPTY); +} + +TEST_CASE("DeviceTracker: eviction prefers departed over active") { + DeviceTracker tracker; + tracker.initialize(); + // Fill 32 slots + for (uint8_t i = 0; i < MAX_TRACKED_DEVICES; i++) { + uint8_t mac[6]; + setMAC(mac, 0x10, 0x20, 0x30, 0x00, 0x00, i); + tracker.recordDetection(mac, 1000, 50); + } + // Timeout all → DEPARTED + tracker.tick(1000 + DEVICE_TIMEOUT_MS + 1); + // New device should reuse a departed slot + uint8_t newMac[6]; + setMAC(newMac, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x01); + DeviceState prev = tracker.recordDetection(newMac, 200000, 70); + CHECK(prev == DeviceState::EMPTY); +} + +TEST_CASE("DeviceTracker: NEW_DETECT times out") { + DeviceTracker tracker; + tracker.initialize(); + uint8_t mac[6]; + setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); + tracker.recordDetection(mac, 1000, 80); // NEW_DETECT + // Tick past timeout — NEW_DETECT should also depart + tracker.tick(1000 + DEVICE_TIMEOUT_MS + 1); + // Re-detect should return EMPTY (departed slot doesn't match) + DeviceState prev = tracker.recordDetection(mac, 200000, 80); + CHECK(prev == DeviceState::EMPTY); +} diff --git a/test/test_main.cpp b/test/test_main.cpp new file mode 100644 index 0000000..a3f832e --- /dev/null +++ b/test/test_main.cpp @@ -0,0 +1,2 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include "doctest.h" diff --git a/test/test_threat_analyzer.cpp b/test/test_threat_analyzer.cpp new file mode 100644 index 0000000..04fdf67 --- /dev/null +++ b/test/test_threat_analyzer.cpp @@ -0,0 +1,290 @@ +#include "doctest.h" +#include "ThreatAnalyzer.h" + +extern uint32_t mock_millis_value; + +// Capture the last ThreatEvent published by the analyzer +static ThreatEvent lastThreat; +static int threatCount; + +static void resetCapture() { + memset(&lastThreat, 0, sizeof(lastThreat)); + threatCount = 0; + EventBus::subscribeThreat([](const ThreatEvent& t) { + lastThreat = t; + threatCount++; + }); +} + +// ============================================================ +// Helpers +// ============================================================ + +static WiFiFrameEvent makeWiFiFrame(const char* ssid, int8_t rssi = -60, + uint8_t channel = 6) { + WiFiFrameEvent f; + memset(&f, 0, sizeof(f)); + strncpy(f.ssid, ssid, sizeof(f.ssid) - 1); + f.rssi = rssi; + f.channel = channel; + f.frameSubtype = 0x20; + // Use a unique-ish MAC based on first char so each test gets distinct devices + f.mac[0] = 0xDE; f.mac[1] = 0xAD; + f.mac[2] = (uint8_t)ssid[0]; f.mac[3] = (uint8_t)ssid[1]; + f.mac[4] = 0x00; f.mac[5] = 0x01; + return f; +} + +static BluetoothDeviceEvent makeBLEDevice(const char* name = "", + int8_t rssi = -60, + const char* uuid = "") { + BluetoothDeviceEvent d; + memset(&d, 0, sizeof(d)); + strncpy(d.name, name, sizeof(d.name) - 1); + d.rssi = rssi; + d.hasServiceUUID = (uuid[0] != '\0'); + if (d.hasServiceUUID) { + strncpy(d.serviceUUID, uuid, sizeof(d.serviceUUID) - 1); + } + d.mac[0] = 0xBE; d.mac[1] = 0xEF; + d.mac[2] = (uint8_t)name[0]; d.mac[3] = (uint8_t)(name[0] ? name[1] : 0); + d.mac[4] = 0x00; d.mac[5] = 0x02; + return d; +} + +// ============================================================ +// WiFi scoring +// ============================================================ + +TEST_CASE("ThreatAnalyzer: WiFi SSID format match produces threat") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + analyzer.analyzeWiFiFrame(makeWiFiFrame("Flock-a1b2c3", -60)); + CHECK(threatCount == 1); + CHECK(lastThreat.certainty > 0); + CHECK(strcmp(lastThreat.radioType, "wifi") == 0); + CHECK(strcmp(lastThreat.category, "surveillance_device") == 0); +} + +TEST_CASE("ThreatAnalyzer: WiFi no-match produces no threat") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + analyzer.analyzeWiFiFrame(makeWiFiFrame("Starbucks WiFi", -60)); + CHECK(threatCount == 0); +} + +TEST_CASE("ThreatAnalyzer: WiFi subsumption removes keyword weight") { + // "Flock-a1b2c3" matches both SSID_FORMAT (75) and SSID_KEYWORD (45). + // Subsumption should remove the keyword weight. + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + // RSSI -60 → rssiModifier = 0 + analyzer.analyzeWiFiFrame(makeWiFiFrame("Flock-a1b2c3", -60)); + REQUIRE(threatCount == 1); + // Should be 75 (format) + 0 (rssi) = 75, NOT 75+45=120 + CHECK(lastThreat.certainty == 75); + // SSID_KEYWORD flag should be cleared + CHECK((lastThreat.matchFlags & DET_SSID_KEYWORD) == 0); + CHECK((lastThreat.matchFlags & DET_SSID_FORMAT) != 0); +} + +TEST_CASE("ThreatAnalyzer: WiFi RSSI modifier applied") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + // "Flock-a1b2c3" at RSSI -30 → format=75, rssi=+10 → 85 + auto frame = makeWiFiFrame("Flock-a1b2c3", -30); + // Give unique MAC to avoid hitting existing device + frame.mac[5] = 0x10; + analyzer.analyzeWiFiFrame(frame); + REQUIRE(threatCount == 1); + CHECK(lastThreat.certainty == 85); + CHECK(lastThreat.rssiModifier == 10); +} + +TEST_CASE("ThreatAnalyzer: WiFi certainty clamped to 100") { + // Keyword-only at very close range: 45 + 10 = 55 (no clamp needed) + // But let's verify format + OUI + close range doesn't exceed 100: + // format=75, OUI=20, rssi=+10 = 105 → clamped to 100 + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto frame = makeWiFiFrame("Flock-a1b2c3", -30); + // Set a known OUI + frame.mac[0] = 0x58; frame.mac[1] = 0x8E; frame.mac[2] = 0x81; + frame.mac[3] = 0x99; frame.mac[4] = 0x99; frame.mac[5] = 0x99; + analyzer.analyzeWiFiFrame(frame); + REQUIRE(threatCount == 1); + CHECK(lastThreat.certainty == 100); +} + +TEST_CASE("ThreatAnalyzer: WiFi certainty clamped to 0 minimum") { + // Keyword alone (45) at very weak signal (-90 → -10) = 35 + // That's still positive. Use OUI alone (20) at -90 → 20-10 = 10. + // Can't easily get negative with existing detectors, but verify >= 0. + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + // MAC OUI match only (weight 20) at RSSI -90 → 20 + (-10) = 10 + auto frame = makeWiFiFrame("", -90); + frame.ssid[0] = '\0'; // ensure no SSID match + frame.mac[0] = 0x58; frame.mac[1] = 0x8E; frame.mac[2] = 0x81; + frame.mac[3] = 0xBB; frame.mac[4] = 0xBB; frame.mac[5] = 0xBB; + analyzer.analyzeWiFiFrame(frame); + REQUIRE(threatCount == 1); + CHECK(lastThreat.certainty == 10); +} + +// ============================================================ +// shouldAlert logic +// ============================================================ + +TEST_CASE("ThreatAnalyzer: shouldAlert true on first high-certainty detection") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + analyzer.analyzeWiFiFrame(makeWiFiFrame("Flock-a1b2c3", -60)); + REQUIRE(threatCount == 1); + CHECK(lastThreat.shouldAlert == true); + CHECK(lastThreat.certainty >= ALERT_THRESHOLD); +} + +TEST_CASE("ThreatAnalyzer: shouldAlert false on repeat detection") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto frame = makeWiFiFrame("Flock-a1b2c3", -60); + frame.mac[5] = 0x20; // unique MAC + analyzer.analyzeWiFiFrame(frame); + CHECK(lastThreat.shouldAlert == true); + + // Same device again + mock_millis_value = 6000; + analyzer.analyzeWiFiFrame(frame); + CHECK(lastThreat.shouldAlert == false); +} + +TEST_CASE("ThreatAnalyzer: shouldAlert false when below threshold") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + // OUI-only match at weak signal: certainty = 20 + (-10) = 10 + auto frame = makeWiFiFrame("", -90); + frame.ssid[0] = '\0'; + frame.mac[0] = 0xCC; frame.mac[1] = 0xCC; frame.mac[2] = 0xCC; + frame.mac[3] = 0xAA; frame.mac[4] = 0xAA; frame.mac[5] = 0xAA; + analyzer.analyzeWiFiFrame(frame); + REQUIRE(threatCount == 1); + CHECK(lastThreat.shouldAlert == false); +} + +// ============================================================ +// BLE scoring +// ============================================================ + +TEST_CASE("ThreatAnalyzer: BLE Raven UUID produces acoustic_detector category") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto device = makeBLEDevice("", -60, "00003100-0000-1000-8000-00805f9b34fb"); + device.mac[5] = 0x30; + analyzer.analyzeBluetoothDevice(device); + REQUIRE(threatCount == 1); + CHECK(strcmp(lastThreat.category, "acoustic_detector") == 0); + CHECK(strcmp(lastThreat.radioType, "bluetooth") == 0); + CHECK(lastThreat.channel == 0); +} + +TEST_CASE("ThreatAnalyzer: BLE name-only produces surveillance_device category") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto device = makeBLEDevice("Flock Tracker", -60); + device.mac[5] = 0x40; + analyzer.analyzeBluetoothDevice(device); + REQUIRE(threatCount == 1); + CHECK(strcmp(lastThreat.category, "surveillance_device") == 0); +} + +TEST_CASE("ThreatAnalyzer: BLE no-match produces no threat") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto device = makeBLEDevice("AirPods Pro", -60); + device.mac[5] = 0x50; + analyzer.analyzeBluetoothDevice(device); + CHECK(threatCount == 0); +} + +// ============================================================ +// Heartbeat tick +// ============================================================ + +TEST_CASE("ThreatAnalyzer: tick returns false when no devices tracked") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + CHECK_FALSE(analyzer.tick(0)); + CHECK_FALSE(analyzer.tick(HEARTBEAT_INTERVAL_MS)); +} + +TEST_CASE("ThreatAnalyzer: tick returns true when high-confidence device in range") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 1000; + + // Inject a high-certainty device + auto frame = makeWiFiFrame("Flock-a1b2c3", -60); + frame.mac[5] = 0x60; + analyzer.analyzeWiFiFrame(frame); + // Second detection to move to IN_RANGE + mock_millis_value = 2000; + analyzer.analyzeWiFiFrame(frame); + + // First tick at heartbeat interval should fire + bool heartbeat = analyzer.tick(HEARTBEAT_INTERVAL_MS); + CHECK(heartbeat == true); +} + +TEST_CASE("ThreatAnalyzer: tick returns false before heartbeat interval") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 1000; + + auto frame = makeWiFiFrame("Flock-a1b2c3", -60); + frame.mac[5] = 0x70; + analyzer.analyzeWiFiFrame(frame); + mock_millis_value = 2000; + analyzer.analyzeWiFiFrame(frame); + + // Tick before interval elapses + CHECK_FALSE(analyzer.tick(HEARTBEAT_INTERVAL_MS - 1)); +} From 3cbc876145a57c17167c4e557e0c3b5d97e4fb3a Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 31 Jan 2026 13:26:28 -0700 Subject: [PATCH 05/23] Extract shared headers into common/ directory Move DetectorTypes.h, Detectors.h, DeviceSignatures.h, EventBus.h, ThreatAnalyzer.h, and TelemetryReporter.h from the M5Stick variant's src/ into a new top-level common/ directory. Update Makefile to pass -I common via build.extra_flags for all variants, update test includes, and fix the M5Stick FQBN (m5stick_c_plus2 -> m5stack_stickc_plus2). Merge AudioEvent (from M5Fire's EventBus.h) into the shared header so other variants can adopt it when migrated. Co-Authored-By: Claude Opus 4.5 --- Makefile | 6 ++++-- .../src => common}/DetectorTypes.h | 0 .../src => common}/Detectors.h | 0 .../src => common}/DeviceSignatures.h | 0 .../src => common}/EventBus.h | 11 ++++++++++- .../src => common}/TelemetryReporter.h | 0 .../src => common}/ThreatAnalyzer.h | 0 .../flocksquawk_m5stick/flocksquawk_m5stick.ino | 17 +++++++++++++---- test/eventbus_impl.cpp | 7 +++++++ 9 files changed, 34 insertions(+), 7 deletions(-) rename {m5stack/flocksquawk_m5stick/src => common}/DetectorTypes.h (100%) rename {m5stack/flocksquawk_m5stick/src => common}/Detectors.h (100%) rename {m5stack/flocksquawk_m5stick/src => common}/DeviceSignatures.h (100%) rename {m5stack/flocksquawk_m5stick/src => common}/EventBus.h (84%) rename {m5stack/flocksquawk_m5stick/src => common}/TelemetryReporter.h (100%) rename {m5stack/flocksquawk_m5stick/src => common}/ThreatAnalyzer.h (100%) diff --git a/Makefile b/Makefile index c2fa301..43356aa 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ VARIANT ?= m5stick CORE_VERSION ?= 3.0.7 BUILD_DIR := $(CURDIR)/.build +COMMON_DIR := $(CURDIR)/common # Arduino toolchain paths (Linux default; macOS overrides below) ARDUINO_DATA ?= $(HOME)/.arduino15 @@ -26,7 +27,7 @@ MKLITTLEFS = $(firstword $(wildcard $(ARDUINO_DATA)/packages/esp32/tools/mkli # ────────────────────────────────────────────── VARIANTS := m5stick m5fire mini12864 oled portable flipper -m5stick_FQBN := esp32:esp32:m5stick_c_plus2 +m5stick_FQBN := esp32:esp32:m5stack_stickc_plus2 m5stick_SKETCH := m5stack/flocksquawk_m5stick m5stick_DATA := @@ -73,6 +74,7 @@ define VARIANT_TARGETS build-$(1): arduino-cli compile \ --fqbn $($(1)_FQBN) \ + --build-property "build.extra_flags=-I$(COMMON_DIR)" \ --output-dir $(BUILD_DIR)/$(1) \ $($(1)_SKETCH) @@ -129,7 +131,7 @@ monitor: monitor-$(VARIANT) # ────────────────────────────────────────────── TEST_CXX ?= clang++ TEST_CXXFLAGS := -std=c++17 -Wall -Wextra -g -O0 -TEST_INCLUDES := -isystem test/mocks -I m5stack/flocksquawk_m5stick/src -I test +TEST_INCLUDES := -isystem test/mocks -I common -I test TEST_SRCS := test/test_main.cpp test/eventbus_impl.cpp \ test/test_detectors.cpp test/test_device_tracker.cpp \ test/test_threat_analyzer.cpp diff --git a/m5stack/flocksquawk_m5stick/src/DetectorTypes.h b/common/DetectorTypes.h similarity index 100% rename from m5stack/flocksquawk_m5stick/src/DetectorTypes.h rename to common/DetectorTypes.h diff --git a/m5stack/flocksquawk_m5stick/src/Detectors.h b/common/Detectors.h similarity index 100% rename from m5stack/flocksquawk_m5stick/src/Detectors.h rename to common/Detectors.h diff --git a/m5stack/flocksquawk_m5stick/src/DeviceSignatures.h b/common/DeviceSignatures.h similarity index 100% rename from m5stack/flocksquawk_m5stick/src/DeviceSignatures.h rename to common/DeviceSignatures.h diff --git a/m5stack/flocksquawk_m5stick/src/EventBus.h b/common/EventBus.h similarity index 84% rename from m5stack/flocksquawk_m5stick/src/EventBus.h rename to common/EventBus.h index 237a7d2..6d1513f 100644 --- a/m5stack/flocksquawk_m5stick/src/EventBus.h +++ b/common/EventBus.h @@ -8,7 +8,8 @@ enum class EventType { WifiFrameCaptured, BluetoothDeviceFound, ThreatIdentified, - SystemReady + SystemReady, + AudioPlaybackRequested }; struct WiFiFrameEvent { @@ -41,28 +42,36 @@ struct ThreatEvent { bool shouldAlert; }; +struct AudioEvent { + const char* soundFile; +}; + class EventBus { public: typedef std::function WiFiFrameHandler; typedef std::function BluetoothHandler; typedef std::function ThreatHandler; typedef std::function SystemEventHandler; + typedef std::function AudioHandler; static void publishWifiFrame(const WiFiFrameEvent& event); static void publishBluetoothDevice(const BluetoothDeviceEvent& event); static void publishThreat(const ThreatEvent& event); static void publishSystemReady(); + static void publishAudioRequest(const AudioEvent& event); static void subscribeWifiFrame(WiFiFrameHandler handler); static void subscribeBluetoothDevice(BluetoothHandler handler); static void subscribeThreat(ThreatHandler handler); static void subscribeSystemReady(SystemEventHandler handler); + static void subscribeAudioRequest(AudioHandler handler); private: static WiFiFrameHandler wifiHandler; static BluetoothHandler bluetoothHandler; static ThreatHandler threatHandler; static SystemEventHandler systemReadyHandler; + static AudioHandler audioHandler; }; #endif \ No newline at end of file diff --git a/m5stack/flocksquawk_m5stick/src/TelemetryReporter.h b/common/TelemetryReporter.h similarity index 100% rename from m5stack/flocksquawk_m5stick/src/TelemetryReporter.h rename to common/TelemetryReporter.h diff --git a/m5stack/flocksquawk_m5stick/src/ThreatAnalyzer.h b/common/ThreatAnalyzer.h similarity index 100% rename from m5stack/flocksquawk_m5stick/src/ThreatAnalyzer.h rename to common/ThreatAnalyzer.h diff --git a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino index 65443ec..a2a3a0f 100644 --- a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino +++ b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino @@ -13,11 +13,11 @@ #include "esp_wifi.h" #include "esp_wifi_types.h" -#include "src/EventBus.h" -#include "src/DeviceSignatures.h" +#include "EventBus.h" +#include "DeviceSignatures.h" #include "src/RadioScanner.h" -#include "src/ThreatAnalyzer.h" -#include "src/TelemetryReporter.h" +#include "ThreatAnalyzer.h" +#include "TelemetryReporter.h" // Global system components RadioScannerManager rfScanner; @@ -29,6 +29,7 @@ EventBus::WiFiFrameHandler EventBus::wifiHandler = nullptr; EventBus::BluetoothHandler EventBus::bluetoothHandler = nullptr; EventBus::ThreatHandler EventBus::threatHandler = nullptr; EventBus::SystemEventHandler EventBus::systemReadyHandler = nullptr; +EventBus::AudioHandler EventBus::audioHandler = nullptr; namespace { const uint16_t STARTUP_BEEP_FREQ = 2000; @@ -366,6 +367,14 @@ void EventBus::subscribeSystemReady(SystemEventHandler handler) { systemReadyHandler = handler; } +void EventBus::publishAudioRequest(const AudioEvent& event) { + if (audioHandler) audioHandler(event); +} + +void EventBus::subscribeAudioRequest(AudioHandler handler) { + audioHandler = handler; +} + // RadioScannerManager implementation void RadioScannerManager::initialize() { configureWiFiSniffer(); diff --git a/test/eventbus_impl.cpp b/test/eventbus_impl.cpp index e757e64..aa44824 100644 --- a/test/eventbus_impl.cpp +++ b/test/eventbus_impl.cpp @@ -12,6 +12,7 @@ EventBus::WiFiFrameHandler EventBus::wifiHandler; EventBus::BluetoothHandler EventBus::bluetoothHandler; EventBus::ThreatHandler EventBus::threatHandler; EventBus::SystemEventHandler EventBus::systemReadyHandler; +EventBus::AudioHandler EventBus::audioHandler; void EventBus::publishWifiFrame(const WiFiFrameEvent& event) { if (wifiHandler) wifiHandler(event); @@ -38,3 +39,9 @@ void EventBus::subscribeThreat(ThreatHandler handler) { void EventBus::subscribeSystemReady(SystemEventHandler handler) { systemReadyHandler = handler; } +void EventBus::publishAudioRequest(const AudioEvent& event) { + if (audioHandler) audioHandler(event); +} +void EventBus::subscribeAudioRequest(AudioHandler handler) { + audioHandler = handler; +} From 851b8c9ab0cd5a8780b453df3bd9f06455a8ae14 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 31 Jan 2026 13:32:52 -0700 Subject: [PATCH 06/23] Migrate M5Fire variant to common/ shared headers Delete local copies of EventBus.h, DeviceSignatures.h, ThreatAnalyzer.h, and TelemetryReporter.h from m5fire/src/. Replace legacy ThreatAnalyzer (simple boolean matching) and TelemetryReporter (DynamicJsonDocument with nested objects) implementations with the shared detector-based system. Add ISR-safe deferred event processing with portMUX spinlocks for WiFi, BLE, and threat events. Add ThreatAnalyzer::tick() heartbeat in loop() and shouldAlert gate on triggerAlert(). Co-Authored-By: Claude Opus 4.5 --- .../flocksquawk_m5fire/flocksquawk_m5fire.ino | 281 +++++------------- .../flocksquawk_m5fire/src/DeviceSignatures.h | 51 ---- m5stack/flocksquawk_m5fire/src/EventBus.h | 73 ----- .../src/TelemetryReporter.h | 24 -- .../flocksquawk_m5fire/src/ThreatAnalyzer.h | 27 -- 5 files changed, 77 insertions(+), 379 deletions(-) delete mode 100644 m5stack/flocksquawk_m5fire/src/DeviceSignatures.h delete mode 100644 m5stack/flocksquawk_m5fire/src/EventBus.h delete mode 100644 m5stack/flocksquawk_m5fire/src/TelemetryReporter.h delete mode 100644 m5stack/flocksquawk_m5fire/src/ThreatAnalyzer.h diff --git a/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino b/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino index d6ce947..911ef9e 100644 --- a/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino +++ b/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino @@ -12,15 +12,17 @@ #include #include #include +#include "freertos/FreeRTOS.h" +#include "freertos/portmacro.h" #include "esp_wifi.h" #include "esp_wifi_types.h" -#include "src/EventBus.h" -#include "src/DeviceSignatures.h" +#include "EventBus.h" +#include "DeviceSignatures.h" #include "src/RadioScanner.h" -#include "src/ThreatAnalyzer.h" +#include "ThreatAnalyzer.h" #include "src/SoundEngine.h" -#include "src/TelemetryReporter.h" +#include "TelemetryReporter.h" // Global system components RadioScannerManager rfScanner; @@ -172,6 +174,17 @@ void EventBus::subscribeAudioRequest(AudioHandler handler) { audioHandler = handler; } +// Thread-safe deferred event processing +static portMUX_TYPE wifiMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool wifiFramePending = false; +static WiFiFrameEvent pendingWiFiFrame; +static portMUX_TYPE bleMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool bleDevicePending = false; +static BluetoothDeviceEvent pendingBleDevice; +static portMUX_TYPE threatMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool threatPending = false; +static ThreatEvent pendingThreat; + // RadioScannerManager implementation void RadioScannerManager::initialize() { configureWiFiSniffer(); @@ -329,118 +342,6 @@ unsigned long RadioScannerManager::lastBLEScan = 0; NimBLEScan* RadioScannerManager::bleScanner = nullptr; bool RadioScannerManager::isScanningBLE = false; -// ThreatAnalyzer implementation -void ThreatAnalyzer::initialize() { - // Analyzer ready -} - -void ThreatAnalyzer::analyzeWiFiFrame(const WiFiFrameEvent& frame) { - bool nameMatch = strlen(frame.ssid) > 0 && matchesNetworkName(frame.ssid); - bool macMatch = matchesMACPrefix(frame.mac); - - if (nameMatch || macMatch) { - uint8_t certainty = calculateCertainty(nameMatch, macMatch, false); - emitThreatDetection(frame, "wifi", certainty); - } -} - -void ThreatAnalyzer::analyzeBluetoothDevice(const BluetoothDeviceEvent& device) { - bool nameMatch = strlen(device.name) > 0 && matchesBLEName(device.name); - bool macMatch = matchesMACPrefix(device.mac); - bool uuidMatch = device.hasServiceUUID && matchesRavenService(device.serviceUUID); - - if (nameMatch || macMatch || uuidMatch) { - uint8_t certainty = calculateCertainty(nameMatch, macMatch, uuidMatch); - const char* category = determineCategory(uuidMatch); - emitThreatDetection(device, "bluetooth", certainty, category); - } -} - -bool ThreatAnalyzer::matchesNetworkName(const char* ssid) { - if (!ssid) return false; - - for (size_t i = 0; i < DeviceProfiles::NetworkNameCount; i++) { - if (strcasestr(ssid, DeviceProfiles::NetworkNames[i])) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesMACPrefix(const uint8_t* mac) { - char macStr[9]; - snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x", mac[0], mac[1], mac[2]); - - for (size_t i = 0; i < DeviceProfiles::MACPrefixCount; i++) { - if (strncasecmp(macStr, DeviceProfiles::MACPrefixes[i], 8) == 0) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesBLEName(const char* name) { - if (!name) return false; - - for (size_t i = 0; i < DeviceProfiles::BLEIdentifierCount; i++) { - if (strcasestr(name, DeviceProfiles::BLEIdentifiers[i])) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesRavenService(const char* uuid) { - if (!uuid) return false; - - for (size_t i = 0; i < DeviceProfiles::RavenServiceCount; i++) { - if (strcasecmp(uuid, DeviceProfiles::RavenServices[i]) == 0) { - return true; - } - } - return false; -} - -uint8_t ThreatAnalyzer::calculateCertainty(bool nameMatch, bool macMatch, bool uuidMatch) { - if (nameMatch && macMatch && uuidMatch) return 100; - if (nameMatch && macMatch) return 95; - if (uuidMatch) return 90; - if (nameMatch || macMatch) return 85; - return 70; -} - -const char* ThreatAnalyzer::determineCategory(bool isRaven) { - return isRaven ? "acoustic_detector" : "surveillance_device"; -} - -void ThreatAnalyzer::emitThreatDetection(const WiFiFrameEvent& frame, const char* radio, uint8_t certainty) { - ThreatEvent threat; - memset(&threat, 0, sizeof(threat)); - memcpy(threat.mac, frame.mac, 6); - strncpy(threat.identifier, frame.ssid, sizeof(threat.identifier) - 1); - threat.rssi = frame.rssi; - threat.channel = frame.channel; - threat.radioType = radio; - threat.certainty = certainty; - threat.category = "surveillance_device"; - - EventBus::publishThreat(threat); -} - -void ThreatAnalyzer::emitThreatDetection(const BluetoothDeviceEvent& device, const char* radio, uint8_t certainty, const char* category) { - ThreatEvent threat; - memset(&threat, 0, sizeof(threat)); - memcpy(threat.mac, device.mac, 6); - strncpy(threat.identifier, device.name, sizeof(threat.identifier) - 1); - threat.rssi = device.rssi; - threat.channel = 0; - threat.radioType = radio; - threat.certainty = certainty; - threat.category = category; - - EventBus::publishThreat(threat); -} - // SoundEngine implementation void SoundEngine::initialize() { volumeLevel = DEFAULT_VOLUME; @@ -560,76 +461,6 @@ void SoundEngine::handleAudioRequest(const AudioEvent& event) { playSound(event.soundFile); } -// TelemetryReporter implementation -void TelemetryReporter::initialize() { - bootTime = millis(); -} - -void TelemetryReporter::handleThreatDetection(const ThreatEvent& threat) { - DynamicJsonDocument doc(2048); - - doc["event"] = "target_detected"; - doc["ms_since_boot"] = millis() - bootTime; - - appendSourceInfo(threat, doc); - appendTargetIdentity(threat, doc); - appendIndicators(threat, doc); - appendMetadata(threat, doc); - - outputJSON(doc); -} - -void TelemetryReporter::appendSourceInfo(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject source = doc.createNestedObject("source"); - source["radio"] = threat.radioType; - source["channel"] = threat.channel; - source["rssi"] = threat.rssi; -} - -void TelemetryReporter::appendTargetIdentity(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject target = doc.createNestedObject("target"); - JsonObject identity = target.createNestedObject("identity"); - - char macStr[18]; - snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x", - threat.mac[0], threat.mac[1], threat.mac[2], - threat.mac[3], threat.mac[4], threat.mac[5]); - identity["mac"] = macStr; - - char oui[9]; - snprintf(oui, sizeof(oui), "%02x:%02x:%02x", threat.mac[0], threat.mac[1], threat.mac[2]); - identity["oui"] = oui; - - identity["label"] = threat.identifier; -} - -void TelemetryReporter::appendIndicators(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject indicators = doc["target"].createNestedObject("indicators"); - - bool hasName = strlen(threat.identifier) > 0; - indicators["ssid_match"] = (hasName && strcmp(threat.radioType, "wifi") == 0); - indicators["mac_match"] = true; - indicators["name_match"] = (hasName && strcmp(threat.radioType, "bluetooth") == 0); - indicators["service_uuid_match"] = (strcmp(threat.category, "acoustic_detector") == 0); -} - -void TelemetryReporter::appendMetadata(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject metadata = doc.createNestedObject("metadata"); - - if (strcmp(threat.radioType, "wifi") == 0) { - metadata["frame_type"] = "beacon"; - } else { - metadata["frame_type"] = "advertisement"; - } - - metadata["detection_method"] = "combined_signature"; -} - -void TelemetryReporter::outputJSON(const JsonDocument& doc) { - serializeJson(doc, Serial); - Serial.println(); -} - // Main system initialization void setup() { Serial.begin(115200); @@ -654,28 +485,24 @@ void setup() { audioSystem.playSound("/startup.wav"); EventBus::subscribeWifiFrame([](const WiFiFrameEvent& event) { - threatEngine.analyzeWiFiFrame(event); - snprintf(lastMacAddress, sizeof(lastMacAddress), - "%02x:%02x:%02x:%02x:%02x:%02x", - event.mac[0], event.mac[1], event.mac[2], - event.mac[3], event.mac[4], event.mac[5]); - lastRssi = event.rssi; - rssiHistory[rssiIndex] = lastRssi; - rssiIndex = (rssiIndex + 1) % RSSI_GRAPH_POINTS; - if (rssiIndex == 0) rssiFilled = true; + portENTER_CRITICAL(&wifiMux); + pendingWiFiFrame = event; + wifiFramePending = true; + portEXIT_CRITICAL(&wifiMux); }); - + EventBus::subscribeBluetoothDevice([](const BluetoothDeviceEvent& event) { - threatEngine.analyzeBluetoothDevice(event); - lastRssi = event.rssi; - rssiHistory[rssiIndex] = lastRssi; - rssiIndex = (rssiIndex + 1) % RSSI_GRAPH_POINTS; - if (rssiIndex == 0) rssiFilled = true; + portENTER_CRITICAL(&bleMux); + pendingBleDevice = event; + bleDevicePending = true; + portEXIT_CRITICAL(&bleMux); }); - + EventBus::subscribeThreat([](const ThreatEvent& event) { - reporter.handleThreatDetection(event); - triggerAlert(true); + portENTER_CRITICAL(&threatMux); + pendingThreat = event; + threatPending = true; + portEXIT_CRITICAL(&threatMux); }); EventBus::subscribeAudioRequest([](const AudioEvent& event) { @@ -1296,6 +1123,52 @@ void loop() { M5.update(); rfScanner.update(); audioSystem.update(); + uint32_t now = millis(); + + if (wifiFramePending) { + WiFiFrameEvent frameCopy; + portENTER_CRITICAL(&wifiMux); + frameCopy = pendingWiFiFrame; + wifiFramePending = false; + portEXIT_CRITICAL(&wifiMux); + snprintf(lastMacAddress, sizeof(lastMacAddress), + "%02x:%02x:%02x:%02x:%02x:%02x", + frameCopy.mac[0], frameCopy.mac[1], frameCopy.mac[2], + frameCopy.mac[3], frameCopy.mac[4], frameCopy.mac[5]); + lastRssi = frameCopy.rssi; + rssiHistory[rssiIndex] = lastRssi; + rssiIndex = (rssiIndex + 1) % RSSI_GRAPH_POINTS; + if (rssiIndex == 0) rssiFilled = true; + threatEngine.analyzeWiFiFrame(frameCopy); + } + + if (bleDevicePending) { + BluetoothDeviceEvent bleCopy; + portENTER_CRITICAL(&bleMux); + bleCopy = pendingBleDevice; + bleDevicePending = false; + portEXIT_CRITICAL(&bleMux); + lastRssi = bleCopy.rssi; + rssiHistory[rssiIndex] = lastRssi; + rssiIndex = (rssiIndex + 1) % RSSI_GRAPH_POINTS; + if (rssiIndex == 0) rssiFilled = true; + threatEngine.analyzeBluetoothDevice(bleCopy); + } + + if (threatEngine.tick(now)) { + M5.Speaker.tone(1800, 40); + } + + if (threatPending) { + ThreatEvent threatCopy; + portENTER_CRITICAL(&threatMux); + threatCopy = pendingThreat; + threatPending = false; + portEXIT_CRITICAL(&threatMux); + reporter.handleThreatDetection(threatCopy); + if (threatCopy.shouldAlert) triggerAlert(true); + } + #if ENABLE_HOME_UI if (batterySaverEnabled && (M5.BtnA.wasPressed() || M5.BtnB.wasPressed() || M5.BtnC.wasPressed())) { batterySaverEnabled = false; diff --git a/m5stack/flocksquawk_m5fire/src/DeviceSignatures.h b/m5stack/flocksquawk_m5fire/src/DeviceSignatures.h deleted file mode 100644 index 141814e..0000000 --- a/m5stack/flocksquawk_m5fire/src/DeviceSignatures.h +++ /dev/null @@ -1,51 +0,0 @@ -#ifndef DEVICE_SIGNATURES_H -#define DEVICE_SIGNATURES_H - -#include - -namespace DeviceProfiles { - - // Network name patterns for target identification - const char* const NetworkNames[] = { - "flock", - "Flock", - "FLOCK", - "FS Ext Battery", - "Penguin", - "Pigvision" - }; - const size_t NetworkNameCount = 6; - - // MAC address OUI prefixes for target devices - const char* const MACPrefixes[] = { - "58:8e:81", "cc:cc:cc", "ec:1b:bd", "90:35:ea", "04:0d:84", - "f0:82:c0", "1c:34:f1", "38:5b:44", "94:34:69", "b4:e3:f9", - "70:c9:4e", "3c:91:80", "d8:f3:bc", "80:30:49", "14:5a:fc", - "74:4c:a1", "08:3a:88", "9c:2f:9d", "94:08:53", "e4:aa:ea" - }; - const size_t MACPrefixCount = 20; - - // Bluetooth device name patterns - const char* const BLEIdentifiers[] = { - "FS Ext Battery", - "Penguin", - "Flock", - "Pigvision" - }; - const size_t BLEIdentifierCount = 4; - - // Raven acoustic detection device service UUIDs - const char* const RavenServices[] = { - "0000180a-0000-1000-8000-00805f9b34fb", // Device info (all versions) - "00003100-0000-1000-8000-00805f9b34fb", // GPS (1.2.0+) - "00003200-0000-1000-8000-00805f9b34fb", // Power/Battery (1.2.0+) - "00003300-0000-1000-8000-00805f9b34fb", // Network (1.2.0+) - "00003400-0000-1000-8000-00805f9b34fb", // Upload stats (1.2.0+) - "00003500-0000-1000-8000-00805f9b34fb", // Error tracking (1.2.0+) - "00001809-0000-1000-8000-00805f9b34fb", // Health/Temp (legacy 1.1.7) - "00001819-0000-1000-8000-00805f9b34fb" // Location (legacy 1.1.7) - }; - const size_t RavenServiceCount = 8; -} - -#endif \ No newline at end of file diff --git a/m5stack/flocksquawk_m5fire/src/EventBus.h b/m5stack/flocksquawk_m5fire/src/EventBus.h deleted file mode 100644 index 599399e..0000000 --- a/m5stack/flocksquawk_m5fire/src/EventBus.h +++ /dev/null @@ -1,73 +0,0 @@ -#ifndef EVENT_BUS_H -#define EVENT_BUS_H - -#include -#include - -enum class EventType { - WifiFrameCaptured, - BluetoothDeviceFound, - ThreatIdentified, - SystemReady, - AudioPlaybackRequested -}; - -struct WiFiFrameEvent { - uint8_t mac[6]; - char ssid[33]; - int8_t rssi; - uint8_t channel; - uint8_t frameSubtype; // 0x20 = probe, 0x80 = beacon -}; - -struct BluetoothDeviceEvent { - uint8_t mac[6]; - char name[64]; - int8_t rssi; - bool hasServiceUUID; - char serviceUUID[64]; -}; - -struct ThreatEvent { - uint8_t mac[6]; - char identifier[64]; - int8_t rssi; - uint8_t channel; - const char* radioType; - uint8_t certainty; - const char* category; -}; - -struct AudioEvent { - const char* soundFile; -}; - -class EventBus { -public: - typedef std::function WiFiFrameHandler; - typedef std::function BluetoothHandler; - typedef std::function ThreatHandler; - typedef std::function SystemEventHandler; - typedef std::function AudioHandler; - - static void publishWifiFrame(const WiFiFrameEvent& event); - static void publishBluetoothDevice(const BluetoothDeviceEvent& event); - static void publishThreat(const ThreatEvent& event); - static void publishSystemReady(); - static void publishAudioRequest(const AudioEvent& event); - - static void subscribeWifiFrame(WiFiFrameHandler handler); - static void subscribeBluetoothDevice(BluetoothHandler handler); - static void subscribeThreat(ThreatHandler handler); - static void subscribeSystemReady(SystemEventHandler handler); - static void subscribeAudioRequest(AudioHandler handler); - -private: - static WiFiFrameHandler wifiHandler; - static BluetoothHandler bluetoothHandler; - static ThreatHandler threatHandler; - static SystemEventHandler systemReadyHandler; - static AudioHandler audioHandler; -}; - -#endif \ No newline at end of file diff --git a/m5stack/flocksquawk_m5fire/src/TelemetryReporter.h b/m5stack/flocksquawk_m5fire/src/TelemetryReporter.h deleted file mode 100644 index ca38c42..0000000 --- a/m5stack/flocksquawk_m5fire/src/TelemetryReporter.h +++ /dev/null @@ -1,24 +0,0 @@ -#ifndef TELEMETRY_REPORTER_H -#define TELEMETRY_REPORTER_H - -#include -#include -#include "EventBus.h" - -class TelemetryReporter { -public: - void initialize(); - void handleThreatDetection(const ThreatEvent& threat); - -private: - unsigned long bootTime; - - void serializeThreatToJSON(const ThreatEvent& threat, JsonDocument& doc); - void appendSourceInfo(const ThreatEvent& threat, JsonDocument& doc); - void appendTargetIdentity(const ThreatEvent& threat, JsonDocument& doc); - void appendIndicators(const ThreatEvent& threat, JsonDocument& doc); - void appendMetadata(const ThreatEvent& threat, JsonDocument& doc); - void outputJSON(const JsonDocument& doc); -}; - -#endif \ No newline at end of file diff --git a/m5stack/flocksquawk_m5fire/src/ThreatAnalyzer.h b/m5stack/flocksquawk_m5fire/src/ThreatAnalyzer.h deleted file mode 100644 index 12c95ae..0000000 --- a/m5stack/flocksquawk_m5fire/src/ThreatAnalyzer.h +++ /dev/null @@ -1,27 +0,0 @@ -#ifndef THREAT_ANALYZER_H -#define THREAT_ANALYZER_H - -#include -#include "EventBus.h" -#include "DeviceSignatures.h" - -class ThreatAnalyzer { -public: - void initialize(); - void analyzeWiFiFrame(const WiFiFrameEvent& frame); - void analyzeBluetoothDevice(const BluetoothDeviceEvent& device); - -private: - bool matchesNetworkName(const char* ssid); - bool matchesMACPrefix(const uint8_t* mac); - bool matchesBLEName(const char* name); - bool matchesRavenService(const char* uuid); - uint8_t calculateCertainty(bool nameMatch, bool macMatch, bool uuidMatch); - const char* determineCategory(bool isRaven); - void emitThreatDetection(const WiFiFrameEvent& frame, const char* radio, uint8_t certainty); - void emitThreatDetection(const BluetoothDeviceEvent& device, const char* radio, uint8_t certainty, const char* category); - void formatMACAddress(const uint8_t* mac, char* output); - void extractOUI(const uint8_t* mac, char* output); -}; - -#endif \ No newline at end of file From 62c0e2db0359350aacd194c33e472e14a8d3dbaa Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 31 Jan 2026 13:35:54 -0700 Subject: [PATCH 07/23] Migrate Mini12864 variant to common/ shared headers Delete local copies of EventBus.h, DeviceSignatures.h, ThreatAnalyzer.h, and TelemetryReporter.h from mini12864/src/. Replace legacy ThreatAnalyzer and TelemetryReporter implementations with shared detector-based system. Add ISR-safe deferred event processing with portMUX spinlocks. Move display notifications (Mini12864DisplayNotifyWifiFrame, ShowAlert) and audio playback to the main loop's deferred handlers. Add tick() heartbeat and shouldAlert gate. Co-Authored-By: Claude Opus 4.5 --- .../flocksquawk_mini12864.ino | 267 +++++------------- .../src/DeviceSignatures.h | 51 ---- .../flocksquawk_mini12864/src/EventBus.h | 73 ----- .../src/TelemetryReporter.h | 24 -- .../src/ThreatAnalyzer.h | 27 -- 5 files changed, 70 insertions(+), 372 deletions(-) delete mode 100644 Mini12864/flocksquawk_mini12864/src/DeviceSignatures.h delete mode 100644 Mini12864/flocksquawk_mini12864/src/EventBus.h delete mode 100644 Mini12864/flocksquawk_mini12864/src/TelemetryReporter.h delete mode 100644 Mini12864/flocksquawk_mini12864/src/ThreatAnalyzer.h diff --git a/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino b/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino index 26c2b21..d8dc2d9 100644 --- a/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino +++ b/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino @@ -12,15 +12,17 @@ #include #include #include +#include "freertos/FreeRTOS.h" +#include "freertos/portmacro.h" #include "esp_wifi.h" #include "esp_wifi_types.h" -#include "src/EventBus.h" -#include "src/DeviceSignatures.h" +#include "EventBus.h" +#include "DeviceSignatures.h" #include "src/RadioScanner.h" -#include "src/ThreatAnalyzer.h" +#include "ThreatAnalyzer.h" #include "src/SoundEngine.h" -#include "src/TelemetryReporter.h" +#include "TelemetryReporter.h" #include "src/Mini12864Display.h" // Global system components @@ -76,6 +78,17 @@ void EventBus::subscribeAudioRequest(AudioHandler handler) { audioHandler = handler; } +// Thread-safe deferred event processing +static portMUX_TYPE wifiMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool wifiFramePending = false; +static WiFiFrameEvent pendingWiFiFrame; +static portMUX_TYPE bleMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool bleDevicePending = false; +static BluetoothDeviceEvent pendingBleDevice; +static portMUX_TYPE threatMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool threatPending = false; +static ThreatEvent pendingThreat; + // RadioScannerManager implementation void RadioScannerManager::initialize() { configureWiFiSniffer(); @@ -232,118 +245,6 @@ uint8_t RadioScannerManager::getCurrentWifiChannel() { return currentWifiChannel; } -// ThreatAnalyzer implementation -void ThreatAnalyzer::initialize() { - // Analyzer ready -} - -void ThreatAnalyzer::analyzeWiFiFrame(const WiFiFrameEvent& frame) { - bool nameMatch = strlen(frame.ssid) > 0 && matchesNetworkName(frame.ssid); - bool macMatch = matchesMACPrefix(frame.mac); - - if (nameMatch || macMatch) { - uint8_t certainty = calculateCertainty(nameMatch, macMatch, false); - emitThreatDetection(frame, "wifi", certainty); - } -} - -void ThreatAnalyzer::analyzeBluetoothDevice(const BluetoothDeviceEvent& device) { - bool nameMatch = strlen(device.name) > 0 && matchesBLEName(device.name); - bool macMatch = matchesMACPrefix(device.mac); - bool uuidMatch = device.hasServiceUUID && matchesRavenService(device.serviceUUID); - - if (nameMatch || macMatch || uuidMatch) { - uint8_t certainty = calculateCertainty(nameMatch, macMatch, uuidMatch); - const char* category = determineCategory(uuidMatch); - emitThreatDetection(device, "bluetooth", certainty, category); - } -} - -bool ThreatAnalyzer::matchesNetworkName(const char* ssid) { - if (!ssid) return false; - - for (size_t i = 0; i < DeviceProfiles::NetworkNameCount; i++) { - if (strcasestr(ssid, DeviceProfiles::NetworkNames[i])) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesMACPrefix(const uint8_t* mac) { - char macStr[9]; - snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x", mac[0], mac[1], mac[2]); - - for (size_t i = 0; i < DeviceProfiles::MACPrefixCount; i++) { - if (strncasecmp(macStr, DeviceProfiles::MACPrefixes[i], 8) == 0) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesBLEName(const char* name) { - if (!name) return false; - - for (size_t i = 0; i < DeviceProfiles::BLEIdentifierCount; i++) { - if (strcasestr(name, DeviceProfiles::BLEIdentifiers[i])) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesRavenService(const char* uuid) { - if (!uuid) return false; - - for (size_t i = 0; i < DeviceProfiles::RavenServiceCount; i++) { - if (strcasecmp(uuid, DeviceProfiles::RavenServices[i]) == 0) { - return true; - } - } - return false; -} - -uint8_t ThreatAnalyzer::calculateCertainty(bool nameMatch, bool macMatch, bool uuidMatch) { - if (nameMatch && macMatch && uuidMatch) return 100; - if (nameMatch && macMatch) return 95; - if (uuidMatch) return 90; - if (nameMatch || macMatch) return 85; - return 70; -} - -const char* ThreatAnalyzer::determineCategory(bool isRaven) { - return isRaven ? "acoustic_detector" : "surveillance_device"; -} - -void ThreatAnalyzer::emitThreatDetection(const WiFiFrameEvent& frame, const char* radio, uint8_t certainty) { - ThreatEvent threat; - memset(&threat, 0, sizeof(threat)); - memcpy(threat.mac, frame.mac, 6); - strncpy(threat.identifier, frame.ssid, sizeof(threat.identifier) - 1); - threat.rssi = frame.rssi; - threat.channel = frame.channel; - threat.radioType = radio; - threat.certainty = certainty; - threat.category = "surveillance_device"; - - EventBus::publishThreat(threat); -} - -void ThreatAnalyzer::emitThreatDetection(const BluetoothDeviceEvent& device, const char* radio, uint8_t certainty, const char* category) { - ThreatEvent threat; - memset(&threat, 0, sizeof(threat)); - memcpy(threat.mac, device.mac, 6); - strncpy(threat.identifier, device.name, sizeof(threat.identifier) - 1); - threat.rssi = device.rssi; - threat.channel = 0; - threat.radioType = radio; - threat.certainty = certainty; - threat.category = category; - - EventBus::publishThreat(threat); -} - // SoundEngine implementation void SoundEngine::initialize() { volumeLevel = DEFAULT_VOLUME; @@ -442,76 +343,6 @@ void SoundEngine::handleAudioRequest(const AudioEvent& event) { playSound(event.soundFile); } -// TelemetryReporter implementation -void TelemetryReporter::initialize() { - bootTime = millis(); -} - -void TelemetryReporter::handleThreatDetection(const ThreatEvent& threat) { - DynamicJsonDocument doc(2048); - - doc["event"] = "target_detected"; - doc["ms_since_boot"] = millis() - bootTime; - - appendSourceInfo(threat, doc); - appendTargetIdentity(threat, doc); - appendIndicators(threat, doc); - appendMetadata(threat, doc); - - outputJSON(doc); -} - -void TelemetryReporter::appendSourceInfo(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject source = doc.createNestedObject("source"); - source["radio"] = threat.radioType; - source["channel"] = threat.channel; - source["rssi"] = threat.rssi; -} - -void TelemetryReporter::appendTargetIdentity(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject target = doc.createNestedObject("target"); - JsonObject identity = target.createNestedObject("identity"); - - char macStr[18]; - snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x", - threat.mac[0], threat.mac[1], threat.mac[2], - threat.mac[3], threat.mac[4], threat.mac[5]); - identity["mac"] = macStr; - - char oui[9]; - snprintf(oui, sizeof(oui), "%02x:%02x:%02x", threat.mac[0], threat.mac[1], threat.mac[2]); - identity["oui"] = oui; - - identity["label"] = threat.identifier; -} - -void TelemetryReporter::appendIndicators(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject indicators = doc["target"].createNestedObject("indicators"); - - bool hasName = strlen(threat.identifier) > 0; - indicators["ssid_match"] = (hasName && strcmp(threat.radioType, "wifi") == 0); - indicators["mac_match"] = true; - indicators["name_match"] = (hasName && strcmp(threat.radioType, "bluetooth") == 0); - indicators["service_uuid_match"] = (strcmp(threat.category, "acoustic_detector") == 0); -} - -void TelemetryReporter::appendMetadata(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject metadata = doc.createNestedObject("metadata"); - - if (strcmp(threat.radioType, "wifi") == 0) { - metadata["frame_type"] = "beacon"; - } else { - metadata["frame_type"] = "advertisement"; - } - - metadata["detection_method"] = "combined_signature"; -} - -void TelemetryReporter::outputJSON(const JsonDocument& doc) { - serializeJson(doc, Serial); - Serial.println(); -} - // Main system initialization void setup() { Serial.begin(115200); @@ -525,20 +356,24 @@ void setup() { audioSystem.playSound("/startup.wav"); EventBus::subscribeWifiFrame([](const WiFiFrameEvent& event) { - threatEngine.analyzeWiFiFrame(event); - Mini12864DisplayNotifyWifiFrame(event.mac, event.channel, event.rssi); + portENTER_CRITICAL(&wifiMux); + pendingWiFiFrame = event; + wifiFramePending = true; + portEXIT_CRITICAL(&wifiMux); }); - + EventBus::subscribeBluetoothDevice([](const BluetoothDeviceEvent& event) { - threatEngine.analyzeBluetoothDevice(event); + portENTER_CRITICAL(&bleMux); + pendingBleDevice = event; + bleDevicePending = true; + portEXIT_CRITICAL(&bleMux); }); - + EventBus::subscribeThreat([](const ThreatEvent& event) { - reporter.handleThreatDetection(event); - Mini12864DisplayShowAlert(); - AudioEvent audioEvent; - audioEvent.soundFile = "/alert.wav"; - EventBus::publishAudioRequest(audioEvent); + portENTER_CRITICAL(&threatMux); + pendingThreat = event; + threatPending = true; + portEXIT_CRITICAL(&threatMux); }); EventBus::subscribeAudioRequest([](const AudioEvent& event) { @@ -561,6 +396,45 @@ void setup() { } void loop() { + rfScanner.update(); + uint32_t now = millis(); + + if (wifiFramePending) { + WiFiFrameEvent frameCopy; + portENTER_CRITICAL(&wifiMux); + frameCopy = pendingWiFiFrame; + wifiFramePending = false; + portEXIT_CRITICAL(&wifiMux); + Mini12864DisplayNotifyWifiFrame(frameCopy.mac, frameCopy.channel, frameCopy.rssi); + threatEngine.analyzeWiFiFrame(frameCopy); + } + + if (bleDevicePending) { + BluetoothDeviceEvent bleCopy; + portENTER_CRITICAL(&bleMux); + bleCopy = pendingBleDevice; + bleDevicePending = false; + portEXIT_CRITICAL(&bleMux); + threatEngine.analyzeBluetoothDevice(bleCopy); + } + + threatEngine.tick(now); + + if (threatPending) { + ThreatEvent threatCopy; + portENTER_CRITICAL(&threatMux); + threatCopy = pendingThreat; + threatPending = false; + portEXIT_CRITICAL(&threatMux); + reporter.handleThreatDetection(threatCopy); + if (threatCopy.shouldAlert) { + Mini12864DisplayShowAlert(); + AudioEvent audioEvent; + audioEvent.soundFile = "/alert.wav"; + EventBus::publishAudioRequest(audioEvent); + } + } + Mini12864DisplayUpdate(); float newVolume = 0.0f; if (Mini12864DisplayConsumeVolume(&newVolume)) { @@ -572,5 +446,4 @@ void loop() { audioEvent.soundFile = "/alert.wav"; EventBus::publishAudioRequest(audioEvent); } - rfScanner.update(); } \ No newline at end of file diff --git a/Mini12864/flocksquawk_mini12864/src/DeviceSignatures.h b/Mini12864/flocksquawk_mini12864/src/DeviceSignatures.h deleted file mode 100644 index 141814e..0000000 --- a/Mini12864/flocksquawk_mini12864/src/DeviceSignatures.h +++ /dev/null @@ -1,51 +0,0 @@ -#ifndef DEVICE_SIGNATURES_H -#define DEVICE_SIGNATURES_H - -#include - -namespace DeviceProfiles { - - // Network name patterns for target identification - const char* const NetworkNames[] = { - "flock", - "Flock", - "FLOCK", - "FS Ext Battery", - "Penguin", - "Pigvision" - }; - const size_t NetworkNameCount = 6; - - // MAC address OUI prefixes for target devices - const char* const MACPrefixes[] = { - "58:8e:81", "cc:cc:cc", "ec:1b:bd", "90:35:ea", "04:0d:84", - "f0:82:c0", "1c:34:f1", "38:5b:44", "94:34:69", "b4:e3:f9", - "70:c9:4e", "3c:91:80", "d8:f3:bc", "80:30:49", "14:5a:fc", - "74:4c:a1", "08:3a:88", "9c:2f:9d", "94:08:53", "e4:aa:ea" - }; - const size_t MACPrefixCount = 20; - - // Bluetooth device name patterns - const char* const BLEIdentifiers[] = { - "FS Ext Battery", - "Penguin", - "Flock", - "Pigvision" - }; - const size_t BLEIdentifierCount = 4; - - // Raven acoustic detection device service UUIDs - const char* const RavenServices[] = { - "0000180a-0000-1000-8000-00805f9b34fb", // Device info (all versions) - "00003100-0000-1000-8000-00805f9b34fb", // GPS (1.2.0+) - "00003200-0000-1000-8000-00805f9b34fb", // Power/Battery (1.2.0+) - "00003300-0000-1000-8000-00805f9b34fb", // Network (1.2.0+) - "00003400-0000-1000-8000-00805f9b34fb", // Upload stats (1.2.0+) - "00003500-0000-1000-8000-00805f9b34fb", // Error tracking (1.2.0+) - "00001809-0000-1000-8000-00805f9b34fb", // Health/Temp (legacy 1.1.7) - "00001819-0000-1000-8000-00805f9b34fb" // Location (legacy 1.1.7) - }; - const size_t RavenServiceCount = 8; -} - -#endif \ No newline at end of file diff --git a/Mini12864/flocksquawk_mini12864/src/EventBus.h b/Mini12864/flocksquawk_mini12864/src/EventBus.h deleted file mode 100644 index 599399e..0000000 --- a/Mini12864/flocksquawk_mini12864/src/EventBus.h +++ /dev/null @@ -1,73 +0,0 @@ -#ifndef EVENT_BUS_H -#define EVENT_BUS_H - -#include -#include - -enum class EventType { - WifiFrameCaptured, - BluetoothDeviceFound, - ThreatIdentified, - SystemReady, - AudioPlaybackRequested -}; - -struct WiFiFrameEvent { - uint8_t mac[6]; - char ssid[33]; - int8_t rssi; - uint8_t channel; - uint8_t frameSubtype; // 0x20 = probe, 0x80 = beacon -}; - -struct BluetoothDeviceEvent { - uint8_t mac[6]; - char name[64]; - int8_t rssi; - bool hasServiceUUID; - char serviceUUID[64]; -}; - -struct ThreatEvent { - uint8_t mac[6]; - char identifier[64]; - int8_t rssi; - uint8_t channel; - const char* radioType; - uint8_t certainty; - const char* category; -}; - -struct AudioEvent { - const char* soundFile; -}; - -class EventBus { -public: - typedef std::function WiFiFrameHandler; - typedef std::function BluetoothHandler; - typedef std::function ThreatHandler; - typedef std::function SystemEventHandler; - typedef std::function AudioHandler; - - static void publishWifiFrame(const WiFiFrameEvent& event); - static void publishBluetoothDevice(const BluetoothDeviceEvent& event); - static void publishThreat(const ThreatEvent& event); - static void publishSystemReady(); - static void publishAudioRequest(const AudioEvent& event); - - static void subscribeWifiFrame(WiFiFrameHandler handler); - static void subscribeBluetoothDevice(BluetoothHandler handler); - static void subscribeThreat(ThreatHandler handler); - static void subscribeSystemReady(SystemEventHandler handler); - static void subscribeAudioRequest(AudioHandler handler); - -private: - static WiFiFrameHandler wifiHandler; - static BluetoothHandler bluetoothHandler; - static ThreatHandler threatHandler; - static SystemEventHandler systemReadyHandler; - static AudioHandler audioHandler; -}; - -#endif \ No newline at end of file diff --git a/Mini12864/flocksquawk_mini12864/src/TelemetryReporter.h b/Mini12864/flocksquawk_mini12864/src/TelemetryReporter.h deleted file mode 100644 index ca38c42..0000000 --- a/Mini12864/flocksquawk_mini12864/src/TelemetryReporter.h +++ /dev/null @@ -1,24 +0,0 @@ -#ifndef TELEMETRY_REPORTER_H -#define TELEMETRY_REPORTER_H - -#include -#include -#include "EventBus.h" - -class TelemetryReporter { -public: - void initialize(); - void handleThreatDetection(const ThreatEvent& threat); - -private: - unsigned long bootTime; - - void serializeThreatToJSON(const ThreatEvent& threat, JsonDocument& doc); - void appendSourceInfo(const ThreatEvent& threat, JsonDocument& doc); - void appendTargetIdentity(const ThreatEvent& threat, JsonDocument& doc); - void appendIndicators(const ThreatEvent& threat, JsonDocument& doc); - void appendMetadata(const ThreatEvent& threat, JsonDocument& doc); - void outputJSON(const JsonDocument& doc); -}; - -#endif \ No newline at end of file diff --git a/Mini12864/flocksquawk_mini12864/src/ThreatAnalyzer.h b/Mini12864/flocksquawk_mini12864/src/ThreatAnalyzer.h deleted file mode 100644 index 12c95ae..0000000 --- a/Mini12864/flocksquawk_mini12864/src/ThreatAnalyzer.h +++ /dev/null @@ -1,27 +0,0 @@ -#ifndef THREAT_ANALYZER_H -#define THREAT_ANALYZER_H - -#include -#include "EventBus.h" -#include "DeviceSignatures.h" - -class ThreatAnalyzer { -public: - void initialize(); - void analyzeWiFiFrame(const WiFiFrameEvent& frame); - void analyzeBluetoothDevice(const BluetoothDeviceEvent& device); - -private: - bool matchesNetworkName(const char* ssid); - bool matchesMACPrefix(const uint8_t* mac); - bool matchesBLEName(const char* name); - bool matchesRavenService(const char* uuid); - uint8_t calculateCertainty(bool nameMatch, bool macMatch, bool uuidMatch); - const char* determineCategory(bool isRaven); - void emitThreatDetection(const WiFiFrameEvent& frame, const char* radio, uint8_t certainty); - void emitThreatDetection(const BluetoothDeviceEvent& device, const char* radio, uint8_t certainty, const char* category); - void formatMACAddress(const uint8_t* mac, char* output); - void extractOUI(const uint8_t* mac, char* output); -}; - -#endif \ No newline at end of file From 324fef14b86a766a249ebc4df3bcdbeba6d58e23 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 31 Jan 2026 13:59:27 -0700 Subject: [PATCH 08/23] Migrate 128x32 OLED variant to common/ shared headers Also fix Makefile build.extra_flags override that was clobbering ESP32 core defines (-DESP32=ESP32 etc). Use build.defines instead, which is included within build.extra_flags and starts empty. Co-Authored-By: Claude Opus 4.5 --- .../flocksquawk_128x32/flocksquawk_128x32.ino | 261 +++++------------- .../flocksquawk_128x32/src/DeviceSignatures.h | 51 ---- 128x32_OLED/flocksquawk_128x32/src/EventBus.h | 73 ----- .../src/TelemetryReporter.h | 24 -- .../flocksquawk_128x32/src/ThreatAnalyzer.h | 27 -- Makefile | 2 +- 6 files changed, 68 insertions(+), 370 deletions(-) delete mode 100644 128x32_OLED/flocksquawk_128x32/src/DeviceSignatures.h delete mode 100644 128x32_OLED/flocksquawk_128x32/src/EventBus.h delete mode 100644 128x32_OLED/flocksquawk_128x32/src/TelemetryReporter.h delete mode 100644 128x32_OLED/flocksquawk_128x32/src/ThreatAnalyzer.h diff --git a/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino b/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino index 4778bf7..b718302 100644 --- a/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino +++ b/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino @@ -10,15 +10,17 @@ #include #include #include +#include "freertos/FreeRTOS.h" +#include "freertos/portmacro.h" #include "esp_wifi.h" #include "esp_wifi_types.h" -#include "src/EventBus.h" -#include "src/DeviceSignatures.h" +#include "EventBus.h" +#include "DeviceSignatures.h" #include "src/RadioScanner.h" -#include "src/ThreatAnalyzer.h" +#include "ThreatAnalyzer.h" #include "src/SoundEngine.h" -#include "src/TelemetryReporter.h" +#include "TelemetryReporter.h" #include "src/DisplayEngine.h" // Global system components @@ -75,6 +77,17 @@ void EventBus::subscribeAudioRequest(AudioHandler handler) { audioHandler = handler; } +// Thread-safe deferred event processing +static portMUX_TYPE wifiMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool wifiFramePending = false; +static WiFiFrameEvent pendingWiFiFrame; +static portMUX_TYPE bleMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool bleDevicePending = false; +static BluetoothDeviceEvent pendingBleDevice; +static portMUX_TYPE threatMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool threatPending = false; +static ThreatEvent pendingThreat; + // RadioScannerManager implementation void RadioScannerManager::initialize() { configureWiFiSniffer(); @@ -224,118 +237,6 @@ unsigned long RadioScannerManager::lastBLEScan = 0; NimBLEScan* RadioScannerManager::bleScanner = nullptr; bool RadioScannerManager::isScanningBLE = false; -// ThreatAnalyzer implementation -void ThreatAnalyzer::initialize() { - // Analyzer ready -} - -void ThreatAnalyzer::analyzeWiFiFrame(const WiFiFrameEvent& frame) { - bool nameMatch = strlen(frame.ssid) > 0 && matchesNetworkName(frame.ssid); - bool macMatch = matchesMACPrefix(frame.mac); - - if (nameMatch || macMatch) { - uint8_t certainty = calculateCertainty(nameMatch, macMatch, false); - emitThreatDetection(frame, "wifi", certainty); - } -} - -void ThreatAnalyzer::analyzeBluetoothDevice(const BluetoothDeviceEvent& device) { - bool nameMatch = strlen(device.name) > 0 && matchesBLEName(device.name); - bool macMatch = matchesMACPrefix(device.mac); - bool uuidMatch = device.hasServiceUUID && matchesRavenService(device.serviceUUID); - - if (nameMatch || macMatch || uuidMatch) { - uint8_t certainty = calculateCertainty(nameMatch, macMatch, uuidMatch); - const char* category = determineCategory(uuidMatch); - emitThreatDetection(device, "bluetooth", certainty, category); - } -} - -bool ThreatAnalyzer::matchesNetworkName(const char* ssid) { - if (!ssid) return false; - - for (size_t i = 0; i < DeviceProfiles::NetworkNameCount; i++) { - if (strcasestr(ssid, DeviceProfiles::NetworkNames[i])) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesMACPrefix(const uint8_t* mac) { - char macStr[9]; - snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x", mac[0], mac[1], mac[2]); - - for (size_t i = 0; i < DeviceProfiles::MACPrefixCount; i++) { - if (strncasecmp(macStr, DeviceProfiles::MACPrefixes[i], 8) == 0) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesBLEName(const char* name) { - if (!name) return false; - - for (size_t i = 0; i < DeviceProfiles::BLEIdentifierCount; i++) { - if (strcasestr(name, DeviceProfiles::BLEIdentifiers[i])) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesRavenService(const char* uuid) { - if (!uuid) return false; - - for (size_t i = 0; i < DeviceProfiles::RavenServiceCount; i++) { - if (strcasecmp(uuid, DeviceProfiles::RavenServices[i]) == 0) { - return true; - } - } - return false; -} - -uint8_t ThreatAnalyzer::calculateCertainty(bool nameMatch, bool macMatch, bool uuidMatch) { - if (nameMatch && macMatch && uuidMatch) return 100; - if (nameMatch && macMatch) return 95; - if (uuidMatch) return 90; - if (nameMatch || macMatch) return 85; - return 70; -} - -const char* ThreatAnalyzer::determineCategory(bool isRaven) { - return isRaven ? "acoustic_detector" : "surveillance_device"; -} - -void ThreatAnalyzer::emitThreatDetection(const WiFiFrameEvent& frame, const char* radio, uint8_t certainty) { - ThreatEvent threat; - memset(&threat, 0, sizeof(threat)); - memcpy(threat.mac, frame.mac, 6); - strncpy(threat.identifier, frame.ssid, sizeof(threat.identifier) - 1); - threat.rssi = frame.rssi; - threat.channel = frame.channel; - threat.radioType = radio; - threat.certainty = certainty; - threat.category = "surveillance_device"; - - EventBus::publishThreat(threat); -} - -void ThreatAnalyzer::emitThreatDetection(const BluetoothDeviceEvent& device, const char* radio, uint8_t certainty, const char* category) { - ThreatEvent threat; - memset(&threat, 0, sizeof(threat)); - memcpy(threat.mac, device.mac, 6); - strncpy(threat.identifier, device.name, sizeof(threat.identifier) - 1); - threat.rssi = device.rssi; - threat.channel = 0; - threat.radioType = radio; - threat.certainty = certainty; - threat.category = category; - - EventBus::publishThreat(threat); -} - // SoundEngine implementation void SoundEngine::initialize() { volumeLevel = DEFAULT_VOLUME; @@ -566,76 +467,6 @@ void DisplayEngine::clearDisplay() { } } -// TelemetryReporter implementation -void TelemetryReporter::initialize() { - bootTime = millis(); -} - -void TelemetryReporter::handleThreatDetection(const ThreatEvent& threat) { - DynamicJsonDocument doc(2048); - - doc["event"] = "target_detected"; - doc["ms_since_boot"] = millis() - bootTime; - - appendSourceInfo(threat, doc); - appendTargetIdentity(threat, doc); - appendIndicators(threat, doc); - appendMetadata(threat, doc); - - outputJSON(doc); -} - -void TelemetryReporter::appendSourceInfo(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject source = doc.createNestedObject("source"); - source["radio"] = threat.radioType; - source["channel"] = threat.channel; - source["rssi"] = threat.rssi; -} - -void TelemetryReporter::appendTargetIdentity(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject target = doc.createNestedObject("target"); - JsonObject identity = target.createNestedObject("identity"); - - char macStr[18]; - snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x", - threat.mac[0], threat.mac[1], threat.mac[2], - threat.mac[3], threat.mac[4], threat.mac[5]); - identity["mac"] = macStr; - - char oui[9]; - snprintf(oui, sizeof(oui), "%02x:%02x:%02x", threat.mac[0], threat.mac[1], threat.mac[2]); - identity["oui"] = oui; - - identity["label"] = threat.identifier; -} - -void TelemetryReporter::appendIndicators(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject indicators = doc["target"].createNestedObject("indicators"); - - bool hasName = strlen(threat.identifier) > 0; - indicators["ssid_match"] = (hasName && strcmp(threat.radioType, "wifi") == 0); - indicators["mac_match"] = true; - indicators["name_match"] = (hasName && strcmp(threat.radioType, "bluetooth") == 0); - indicators["service_uuid_match"] = (strcmp(threat.category, "acoustic_detector") == 0); -} - -void TelemetryReporter::appendMetadata(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject metadata = doc.createNestedObject("metadata"); - - if (strcmp(threat.radioType, "wifi") == 0) { - metadata["frame_type"] = "beacon"; - } else { - metadata["frame_type"] = "advertisement"; - } - - metadata["detection_method"] = "combined_signature"; -} - -void TelemetryReporter::outputJSON(const JsonDocument& doc) { - serializeJson(doc, Serial); - Serial.println(); -} - // Main system initialization void setup() { Serial.begin(115200); @@ -648,18 +479,24 @@ void setup() { audioSystem.initialize(); EventBus::subscribeWifiFrame([](const WiFiFrameEvent& event) { - threatEngine.analyzeWiFiFrame(event); + portENTER_CRITICAL(&wifiMux); + pendingWiFiFrame = event; + wifiFramePending = true; + portEXIT_CRITICAL(&wifiMux); }); - + EventBus::subscribeBluetoothDevice([](const BluetoothDeviceEvent& event) { - threatEngine.analyzeBluetoothDevice(event); + portENTER_CRITICAL(&bleMux); + pendingBleDevice = event; + bleDevicePending = true; + portEXIT_CRITICAL(&bleMux); }); - + EventBus::subscribeThreat([](const ThreatEvent& event) { - reporter.handleThreatDetection(event); - AudioEvent audioEvent; - audioEvent.soundFile = "/alert.wav"; - EventBus::publishAudioRequest(audioEvent); + portENTER_CRITICAL(&threatMux); + pendingThreat = event; + threatPending = true; + portEXIT_CRITICAL(&threatMux); }); EventBus::subscribeAudioRequest([](const AudioEvent& event) { @@ -690,6 +527,42 @@ void setup() { void loop() { rfScanner.update(); + uint32_t now = millis(); + + if (wifiFramePending) { + WiFiFrameEvent frameCopy; + portENTER_CRITICAL(&wifiMux); + frameCopy = pendingWiFiFrame; + wifiFramePending = false; + portEXIT_CRITICAL(&wifiMux); + threatEngine.analyzeWiFiFrame(frameCopy); + } + + if (bleDevicePending) { + BluetoothDeviceEvent bleCopy; + portENTER_CRITICAL(&bleMux); + bleCopy = pendingBleDevice; + bleDevicePending = false; + portEXIT_CRITICAL(&bleMux); + threatEngine.analyzeBluetoothDevice(bleCopy); + } + + threatEngine.tick(now); + + if (threatPending) { + ThreatEvent threatCopy; + portENTER_CRITICAL(&threatMux); + threatCopy = pendingThreat; + threatPending = false; + portEXIT_CRITICAL(&threatMux); + reporter.handleThreatDetection(threatCopy); + if (threatCopy.shouldAlert) { + AudioEvent audioEvent; + audioEvent.soundFile = "/alert.wav"; + EventBus::publishAudioRequest(audioEvent); + } + } + displaySystem.update(); delay(100); } \ No newline at end of file diff --git a/128x32_OLED/flocksquawk_128x32/src/DeviceSignatures.h b/128x32_OLED/flocksquawk_128x32/src/DeviceSignatures.h deleted file mode 100644 index 141814e..0000000 --- a/128x32_OLED/flocksquawk_128x32/src/DeviceSignatures.h +++ /dev/null @@ -1,51 +0,0 @@ -#ifndef DEVICE_SIGNATURES_H -#define DEVICE_SIGNATURES_H - -#include - -namespace DeviceProfiles { - - // Network name patterns for target identification - const char* const NetworkNames[] = { - "flock", - "Flock", - "FLOCK", - "FS Ext Battery", - "Penguin", - "Pigvision" - }; - const size_t NetworkNameCount = 6; - - // MAC address OUI prefixes for target devices - const char* const MACPrefixes[] = { - "58:8e:81", "cc:cc:cc", "ec:1b:bd", "90:35:ea", "04:0d:84", - "f0:82:c0", "1c:34:f1", "38:5b:44", "94:34:69", "b4:e3:f9", - "70:c9:4e", "3c:91:80", "d8:f3:bc", "80:30:49", "14:5a:fc", - "74:4c:a1", "08:3a:88", "9c:2f:9d", "94:08:53", "e4:aa:ea" - }; - const size_t MACPrefixCount = 20; - - // Bluetooth device name patterns - const char* const BLEIdentifiers[] = { - "FS Ext Battery", - "Penguin", - "Flock", - "Pigvision" - }; - const size_t BLEIdentifierCount = 4; - - // Raven acoustic detection device service UUIDs - const char* const RavenServices[] = { - "0000180a-0000-1000-8000-00805f9b34fb", // Device info (all versions) - "00003100-0000-1000-8000-00805f9b34fb", // GPS (1.2.0+) - "00003200-0000-1000-8000-00805f9b34fb", // Power/Battery (1.2.0+) - "00003300-0000-1000-8000-00805f9b34fb", // Network (1.2.0+) - "00003400-0000-1000-8000-00805f9b34fb", // Upload stats (1.2.0+) - "00003500-0000-1000-8000-00805f9b34fb", // Error tracking (1.2.0+) - "00001809-0000-1000-8000-00805f9b34fb", // Health/Temp (legacy 1.1.7) - "00001819-0000-1000-8000-00805f9b34fb" // Location (legacy 1.1.7) - }; - const size_t RavenServiceCount = 8; -} - -#endif \ No newline at end of file diff --git a/128x32_OLED/flocksquawk_128x32/src/EventBus.h b/128x32_OLED/flocksquawk_128x32/src/EventBus.h deleted file mode 100644 index 599399e..0000000 --- a/128x32_OLED/flocksquawk_128x32/src/EventBus.h +++ /dev/null @@ -1,73 +0,0 @@ -#ifndef EVENT_BUS_H -#define EVENT_BUS_H - -#include -#include - -enum class EventType { - WifiFrameCaptured, - BluetoothDeviceFound, - ThreatIdentified, - SystemReady, - AudioPlaybackRequested -}; - -struct WiFiFrameEvent { - uint8_t mac[6]; - char ssid[33]; - int8_t rssi; - uint8_t channel; - uint8_t frameSubtype; // 0x20 = probe, 0x80 = beacon -}; - -struct BluetoothDeviceEvent { - uint8_t mac[6]; - char name[64]; - int8_t rssi; - bool hasServiceUUID; - char serviceUUID[64]; -}; - -struct ThreatEvent { - uint8_t mac[6]; - char identifier[64]; - int8_t rssi; - uint8_t channel; - const char* radioType; - uint8_t certainty; - const char* category; -}; - -struct AudioEvent { - const char* soundFile; -}; - -class EventBus { -public: - typedef std::function WiFiFrameHandler; - typedef std::function BluetoothHandler; - typedef std::function ThreatHandler; - typedef std::function SystemEventHandler; - typedef std::function AudioHandler; - - static void publishWifiFrame(const WiFiFrameEvent& event); - static void publishBluetoothDevice(const BluetoothDeviceEvent& event); - static void publishThreat(const ThreatEvent& event); - static void publishSystemReady(); - static void publishAudioRequest(const AudioEvent& event); - - static void subscribeWifiFrame(WiFiFrameHandler handler); - static void subscribeBluetoothDevice(BluetoothHandler handler); - static void subscribeThreat(ThreatHandler handler); - static void subscribeSystemReady(SystemEventHandler handler); - static void subscribeAudioRequest(AudioHandler handler); - -private: - static WiFiFrameHandler wifiHandler; - static BluetoothHandler bluetoothHandler; - static ThreatHandler threatHandler; - static SystemEventHandler systemReadyHandler; - static AudioHandler audioHandler; -}; - -#endif \ No newline at end of file diff --git a/128x32_OLED/flocksquawk_128x32/src/TelemetryReporter.h b/128x32_OLED/flocksquawk_128x32/src/TelemetryReporter.h deleted file mode 100644 index ca38c42..0000000 --- a/128x32_OLED/flocksquawk_128x32/src/TelemetryReporter.h +++ /dev/null @@ -1,24 +0,0 @@ -#ifndef TELEMETRY_REPORTER_H -#define TELEMETRY_REPORTER_H - -#include -#include -#include "EventBus.h" - -class TelemetryReporter { -public: - void initialize(); - void handleThreatDetection(const ThreatEvent& threat); - -private: - unsigned long bootTime; - - void serializeThreatToJSON(const ThreatEvent& threat, JsonDocument& doc); - void appendSourceInfo(const ThreatEvent& threat, JsonDocument& doc); - void appendTargetIdentity(const ThreatEvent& threat, JsonDocument& doc); - void appendIndicators(const ThreatEvent& threat, JsonDocument& doc); - void appendMetadata(const ThreatEvent& threat, JsonDocument& doc); - void outputJSON(const JsonDocument& doc); -}; - -#endif \ No newline at end of file diff --git a/128x32_OLED/flocksquawk_128x32/src/ThreatAnalyzer.h b/128x32_OLED/flocksquawk_128x32/src/ThreatAnalyzer.h deleted file mode 100644 index 12c95ae..0000000 --- a/128x32_OLED/flocksquawk_128x32/src/ThreatAnalyzer.h +++ /dev/null @@ -1,27 +0,0 @@ -#ifndef THREAT_ANALYZER_H -#define THREAT_ANALYZER_H - -#include -#include "EventBus.h" -#include "DeviceSignatures.h" - -class ThreatAnalyzer { -public: - void initialize(); - void analyzeWiFiFrame(const WiFiFrameEvent& frame); - void analyzeBluetoothDevice(const BluetoothDeviceEvent& device); - -private: - bool matchesNetworkName(const char* ssid); - bool matchesMACPrefix(const uint8_t* mac); - bool matchesBLEName(const char* name); - bool matchesRavenService(const char* uuid); - uint8_t calculateCertainty(bool nameMatch, bool macMatch, bool uuidMatch); - const char* determineCategory(bool isRaven); - void emitThreatDetection(const WiFiFrameEvent& frame, const char* radio, uint8_t certainty); - void emitThreatDetection(const BluetoothDeviceEvent& device, const char* radio, uint8_t certainty, const char* category); - void formatMACAddress(const uint8_t* mac, char* output); - void extractOUI(const uint8_t* mac, char* output); -}; - -#endif \ No newline at end of file diff --git a/Makefile b/Makefile index 43356aa..02a907c 100644 --- a/Makefile +++ b/Makefile @@ -74,7 +74,7 @@ define VARIANT_TARGETS build-$(1): arduino-cli compile \ --fqbn $($(1)_FQBN) \ - --build-property "build.extra_flags=-I$(COMMON_DIR)" \ + --build-property "build.defines=-I$(COMMON_DIR)" \ --output-dir $(BUILD_DIR)/$(1) \ $($(1)_SKETCH) From d693bb7ed62f71581ca410c03a5039af82f4971b Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 31 Jan 2026 14:02:00 -0700 Subject: [PATCH 09/23] Migrate 128x32 Portable variant to common/ shared headers Co-Authored-By: Claude Opus 4.5 --- .../flocksquawk_128x32_portable.ino | 319 ++++++------------ .../src/DeviceSignatures.h | 51 --- .../src/EventBus.h | 64 ---- .../src/TelemetryReporter.h | 24 -- .../src/ThreatAnalyzer.h | 27 -- 5 files changed, 101 insertions(+), 384 deletions(-) delete mode 100644 128x32_OLED/flocksquawk_128x32_portable/src/DeviceSignatures.h delete mode 100644 128x32_OLED/flocksquawk_128x32_portable/src/EventBus.h delete mode 100644 128x32_OLED/flocksquawk_128x32_portable/src/TelemetryReporter.h delete mode 100644 128x32_OLED/flocksquawk_128x32_portable/src/ThreatAnalyzer.h diff --git a/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino b/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino index 74430be..59ef4a7 100644 --- a/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino +++ b/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino @@ -10,14 +10,16 @@ #include #include #include +#include "freertos/FreeRTOS.h" +#include "freertos/portmacro.h" #include "esp_wifi.h" #include "esp_wifi_types.h" -#include "src/EventBus.h" -#include "src/DeviceSignatures.h" +#include "EventBus.h" +#include "DeviceSignatures.h" #include "src/RadioScanner.h" -#include "src/ThreatAnalyzer.h" -#include "src/TelemetryReporter.h" +#include "ThreatAnalyzer.h" +#include "TelemetryReporter.h" // 0.91" 128x32 SSD1306 OLED (I2C) static constexpr int kScreenWidth = 128; @@ -163,6 +165,7 @@ EventBus::WiFiFrameHandler EventBus::wifiHandler = nullptr; EventBus::BluetoothHandler EventBus::bluetoothHandler = nullptr; EventBus::ThreatHandler EventBus::threatHandler = nullptr; EventBus::SystemEventHandler EventBus::systemReadyHandler = nullptr; +EventBus::AudioHandler EventBus::audioHandler = nullptr; void EventBus::publishWifiFrame(const WiFiFrameEvent& event) { if (wifiHandler) wifiHandler(event); @@ -180,6 +183,10 @@ void EventBus::publishSystemReady() { if (systemReadyHandler) systemReadyHandler(); } +void EventBus::publishAudioRequest(const AudioEvent& event) { + if (audioHandler) audioHandler(event); +} + void EventBus::subscribeWifiFrame(WiFiFrameHandler handler) { wifiHandler = handler; } @@ -196,6 +203,21 @@ void EventBus::subscribeSystemReady(SystemEventHandler handler) { systemReadyHandler = handler; } +void EventBus::subscribeAudioRequest(AudioHandler handler) { + audioHandler = handler; +} + +// Thread-safe deferred event processing +static portMUX_TYPE wifiMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool wifiFramePending = false; +static WiFiFrameEvent pendingWiFiFrame; +static portMUX_TYPE bleMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool bleDevicePending = false; +static BluetoothDeviceEvent pendingBleDevice; +static portMUX_TYPE threatMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool threatPending = false; +static ThreatEvent pendingThreat; + // RadioScannerManager implementation void RadioScannerManager::initialize() { configureWiFiSniffer(); @@ -345,187 +367,6 @@ unsigned long RadioScannerManager::lastBLEScan = 0; NimBLEScan* RadioScannerManager::bleScanner = nullptr; bool RadioScannerManager::isScanningBLE = false; -// ThreatAnalyzer implementation -void ThreatAnalyzer::initialize() { - // Analyzer ready -} - -void ThreatAnalyzer::analyzeWiFiFrame(const WiFiFrameEvent& frame) { - bool nameMatch = strlen(frame.ssid) > 0 && matchesNetworkName(frame.ssid); - bool macMatch = matchesMACPrefix(frame.mac); - - if (nameMatch || macMatch) { - uint8_t certainty = calculateCertainty(nameMatch, macMatch, false); - emitThreatDetection(frame, "wifi", certainty); - } -} - -void ThreatAnalyzer::analyzeBluetoothDevice(const BluetoothDeviceEvent& device) { - bool nameMatch = strlen(device.name) > 0 && matchesBLEName(device.name); - bool macMatch = matchesMACPrefix(device.mac); - bool uuidMatch = device.hasServiceUUID && matchesRavenService(device.serviceUUID); - - if (nameMatch || macMatch || uuidMatch) { - uint8_t certainty = calculateCertainty(nameMatch, macMatch, uuidMatch); - const char* category = determineCategory(uuidMatch); - emitThreatDetection(device, "bluetooth", certainty, category); - } -} - -bool ThreatAnalyzer::matchesNetworkName(const char* ssid) { - if (!ssid) return false; - - for (size_t i = 0; i < DeviceProfiles::NetworkNameCount; i++) { - if (strcasestr(ssid, DeviceProfiles::NetworkNames[i])) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesMACPrefix(const uint8_t* mac) { - char macStr[9]; - snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x", mac[0], mac[1], mac[2]); - - for (size_t i = 0; i < DeviceProfiles::MACPrefixCount; i++) { - if (strncasecmp(macStr, DeviceProfiles::MACPrefixes[i], 8) == 0) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesBLEName(const char* name) { - if (!name) return false; - - for (size_t i = 0; i < DeviceProfiles::BLEIdentifierCount; i++) { - if (strcasestr(name, DeviceProfiles::BLEIdentifiers[i])) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesRavenService(const char* uuid) { - if (!uuid) return false; - - for (size_t i = 0; i < DeviceProfiles::RavenServiceCount; i++) { - if (strcasecmp(uuid, DeviceProfiles::RavenServices[i]) == 0) { - return true; - } - } - return false; -} - -uint8_t ThreatAnalyzer::calculateCertainty(bool nameMatch, bool macMatch, bool uuidMatch) { - if (nameMatch && macMatch && uuidMatch) return 100; - if (nameMatch && macMatch) return 95; - if (uuidMatch) return 90; - if (nameMatch || macMatch) return 85; - return 70; -} - -const char* ThreatAnalyzer::determineCategory(bool isRaven) { - return isRaven ? "acoustic_detector" : "surveillance_device"; -} - -void ThreatAnalyzer::emitThreatDetection(const WiFiFrameEvent& frame, const char* radio, uint8_t certainty) { - ThreatEvent threat; - memset(&threat, 0, sizeof(threat)); - memcpy(threat.mac, frame.mac, 6); - strncpy(threat.identifier, frame.ssid, sizeof(threat.identifier) - 1); - threat.rssi = frame.rssi; - threat.channel = frame.channel; - threat.radioType = radio; - threat.certainty = certainty; - threat.category = "surveillance_device"; - - EventBus::publishThreat(threat); -} - -void ThreatAnalyzer::emitThreatDetection(const BluetoothDeviceEvent& device, const char* radio, uint8_t certainty, const char* category) { - ThreatEvent threat; - memset(&threat, 0, sizeof(threat)); - memcpy(threat.mac, device.mac, 6); - strncpy(threat.identifier, device.name, sizeof(threat.identifier) - 1); - threat.rssi = device.rssi; - threat.channel = 0; - threat.radioType = radio; - threat.certainty = certainty; - threat.category = category; - - EventBus::publishThreat(threat); -} - -// TelemetryReporter implementation -void TelemetryReporter::initialize() { - bootTime = millis(); -} - -void TelemetryReporter::handleThreatDetection(const ThreatEvent& threat) { - DynamicJsonDocument doc(2048); - - doc["event"] = "target_detected"; - doc["ms_since_boot"] = millis() - bootTime; - - appendSourceInfo(threat, doc); - appendTargetIdentity(threat, doc); - appendIndicators(threat, doc); - appendMetadata(threat, doc); - - outputJSON(doc); -} - -void TelemetryReporter::appendSourceInfo(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject source = doc.createNestedObject("source"); - source["radio"] = threat.radioType; - source["channel"] = threat.channel; - source["rssi"] = threat.rssi; -} - -void TelemetryReporter::appendTargetIdentity(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject target = doc.createNestedObject("target"); - JsonObject identity = target.createNestedObject("identity"); - - char macStr[18]; - snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x", - threat.mac[0], threat.mac[1], threat.mac[2], - threat.mac[3], threat.mac[4], threat.mac[5]); - identity["mac"] = macStr; - - char oui[9]; - snprintf(oui, sizeof(oui), "%02x:%02x:%02x", threat.mac[0], threat.mac[1], threat.mac[2]); - identity["oui"] = oui; - - identity["label"] = threat.identifier; -} - -void TelemetryReporter::appendIndicators(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject indicators = doc["target"].createNestedObject("indicators"); - - bool hasName = strlen(threat.identifier) > 0; - indicators["ssid_match"] = (hasName && strcmp(threat.radioType, "wifi") == 0); - indicators["mac_match"] = true; - indicators["name_match"] = (hasName && strcmp(threat.radioType, "bluetooth") == 0); - indicators["service_uuid_match"] = (strcmp(threat.category, "acoustic_detector") == 0); -} - -void TelemetryReporter::appendMetadata(const ThreatEvent& threat, JsonDocument& doc) { - JsonObject metadata = doc.createNestedObject("metadata"); - - if (strcmp(threat.radioType, "wifi") == 0) { - metadata["frame_type"] = "beacon"; - } else { - metadata["frame_type"] = "advertisement"; - } - - metadata["detection_method"] = "combined_signature"; -} - -void TelemetryReporter::outputJSON(const JsonDocument& doc) { - serializeJson(doc, Serial); - Serial.println(); -} // Main system initialization void setup() { @@ -544,43 +385,24 @@ void setup() { buzzerBeep(2400, 120); EventBus::subscribeWifiFrame([](const WiFiFrameEvent& event) { - threatEngine.analyzeWiFiFrame(event); - if (screenMode != ScreenMode::Radar) return; - unsigned long now = millis(); - if (now - lastDisplayUpdateMs >= kDisplayUpdateMs) { - char line1[20]; - char line2[20]; - snprintf(line1, sizeof(line1), "WiFi ch %u", event.channel); - snprintf(line2, sizeof(line2), "RSSI %d", event.rssi); - updateStatusLines(line1, line2); - displayShowRadarOverlay(lastStatusLine1, lastStatusLine2); - lastDisplayUpdateMs = now; - } + portENTER_CRITICAL(&wifiMux); + pendingWiFiFrame = event; + wifiFramePending = true; + portEXIT_CRITICAL(&wifiMux); }); - + EventBus::subscribeBluetoothDevice([](const BluetoothDeviceEvent& event) { - threatEngine.analyzeBluetoothDevice(event); - if (screenMode != ScreenMode::Radar) return; - unsigned long now = millis(); - if (now - lastDisplayUpdateMs >= kDisplayUpdateMs) { - char line1[20]; - char line2[20]; - snprintf(line1, sizeof(line1), "BLE device"); - snprintf(line2, sizeof(line2), "RSSI %d", event.rssi); - updateStatusLines(line1, line2); - displayShowRadarOverlay(lastStatusLine1, lastStatusLine2); - lastDisplayUpdateMs = now; - } + portENTER_CRITICAL(&bleMux); + pendingBleDevice = event; + bleDevicePending = true; + portEXIT_CRITICAL(&bleMux); }); - + EventBus::subscribeThreat([](const ThreatEvent& event) { - reporter.handleThreatDetection(event); - const char* label = (event.identifier[0] != '\0') ? event.identifier : "Target"; - screenMode = ScreenMode::ReadyHold; - displayShowStatus("ALERT", label); - buzzerBeep(2800, 120); - delay(80); - buzzerBeep(2800, 120); + portENTER_CRITICAL(&threatMux); + pendingThreat = event; + threatPending = true; + portEXIT_CRITICAL(&threatMux); }); EventBus::subscribeSystemReady([]() { @@ -605,6 +427,67 @@ void setup() { void loop() { rfScanner.update(); + uint32_t now = millis(); + + if (wifiFramePending) { + WiFiFrameEvent frameCopy; + portENTER_CRITICAL(&wifiMux); + frameCopy = pendingWiFiFrame; + wifiFramePending = false; + portEXIT_CRITICAL(&wifiMux); + + if (screenMode == ScreenMode::Radar && (now - lastDisplayUpdateMs >= kDisplayUpdateMs)) { + char line1[20]; + char line2[20]; + snprintf(line1, sizeof(line1), "WiFi ch %u", frameCopy.channel); + snprintf(line2, sizeof(line2), "RSSI %d", frameCopy.rssi); + updateStatusLines(line1, line2); + displayShowRadarOverlay(lastStatusLine1, lastStatusLine2); + lastDisplayUpdateMs = now; + } + threatEngine.analyzeWiFiFrame(frameCopy); + } + + if (bleDevicePending) { + BluetoothDeviceEvent bleCopy; + portENTER_CRITICAL(&bleMux); + bleCopy = pendingBleDevice; + bleDevicePending = false; + portEXIT_CRITICAL(&bleMux); + + if (screenMode == ScreenMode::Radar && (now - lastDisplayUpdateMs >= kDisplayUpdateMs)) { + char line1[20]; + char line2[20]; + snprintf(line1, sizeof(line1), "BLE device"); + snprintf(line2, sizeof(line2), "RSSI %d", bleCopy.rssi); + updateStatusLines(line1, line2); + displayShowRadarOverlay(lastStatusLine1, lastStatusLine2); + lastDisplayUpdateMs = now; + } + threatEngine.analyzeBluetoothDevice(bleCopy); + } + + if (threatEngine.tick(now)) { + buzzerBeep(1800, 40); + } + + if (threatPending) { + ThreatEvent threatCopy; + portENTER_CRITICAL(&threatMux); + threatCopy = pendingThreat; + threatPending = false; + portEXIT_CRITICAL(&threatMux); + reporter.handleThreatDetection(threatCopy); + if (threatCopy.shouldAlert) { + const char* label = (threatCopy.identifier[0] != '\0') ? threatCopy.identifier : "Target"; + screenMode = ScreenMode::ReadyHold; + displayShowStatus("ALERT", label); + buzzerBeep(2800, 120); + delay(80); + buzzerBeep(2800, 120); + } + } + updateRadarSweep(); if (screenMode == ScreenMode::Radar && displayReady) { displayShowRadarOverlay(lastStatusLine1, lastStatusLine2); diff --git a/128x32_OLED/flocksquawk_128x32_portable/src/DeviceSignatures.h b/128x32_OLED/flocksquawk_128x32_portable/src/DeviceSignatures.h deleted file mode 100644 index 141814e..0000000 --- a/128x32_OLED/flocksquawk_128x32_portable/src/DeviceSignatures.h +++ /dev/null @@ -1,51 +0,0 @@ -#ifndef DEVICE_SIGNATURES_H -#define DEVICE_SIGNATURES_H - -#include - -namespace DeviceProfiles { - - // Network name patterns for target identification - const char* const NetworkNames[] = { - "flock", - "Flock", - "FLOCK", - "FS Ext Battery", - "Penguin", - "Pigvision" - }; - const size_t NetworkNameCount = 6; - - // MAC address OUI prefixes for target devices - const char* const MACPrefixes[] = { - "58:8e:81", "cc:cc:cc", "ec:1b:bd", "90:35:ea", "04:0d:84", - "f0:82:c0", "1c:34:f1", "38:5b:44", "94:34:69", "b4:e3:f9", - "70:c9:4e", "3c:91:80", "d8:f3:bc", "80:30:49", "14:5a:fc", - "74:4c:a1", "08:3a:88", "9c:2f:9d", "94:08:53", "e4:aa:ea" - }; - const size_t MACPrefixCount = 20; - - // Bluetooth device name patterns - const char* const BLEIdentifiers[] = { - "FS Ext Battery", - "Penguin", - "Flock", - "Pigvision" - }; - const size_t BLEIdentifierCount = 4; - - // Raven acoustic detection device service UUIDs - const char* const RavenServices[] = { - "0000180a-0000-1000-8000-00805f9b34fb", // Device info (all versions) - "00003100-0000-1000-8000-00805f9b34fb", // GPS (1.2.0+) - "00003200-0000-1000-8000-00805f9b34fb", // Power/Battery (1.2.0+) - "00003300-0000-1000-8000-00805f9b34fb", // Network (1.2.0+) - "00003400-0000-1000-8000-00805f9b34fb", // Upload stats (1.2.0+) - "00003500-0000-1000-8000-00805f9b34fb", // Error tracking (1.2.0+) - "00001809-0000-1000-8000-00805f9b34fb", // Health/Temp (legacy 1.1.7) - "00001819-0000-1000-8000-00805f9b34fb" // Location (legacy 1.1.7) - }; - const size_t RavenServiceCount = 8; -} - -#endif \ No newline at end of file diff --git a/128x32_OLED/flocksquawk_128x32_portable/src/EventBus.h b/128x32_OLED/flocksquawk_128x32_portable/src/EventBus.h deleted file mode 100644 index 6fa14fa..0000000 --- a/128x32_OLED/flocksquawk_128x32_portable/src/EventBus.h +++ /dev/null @@ -1,64 +0,0 @@ -#ifndef EVENT_BUS_H -#define EVENT_BUS_H - -#include -#include - -enum class EventType { - WifiFrameCaptured, - BluetoothDeviceFound, - ThreatIdentified, - SystemReady -}; - -struct WiFiFrameEvent { - uint8_t mac[6]; - char ssid[33]; - int8_t rssi; - uint8_t channel; - uint8_t frameSubtype; // 0x20 = probe, 0x80 = beacon -}; - -struct BluetoothDeviceEvent { - uint8_t mac[6]; - char name[64]; - int8_t rssi; - bool hasServiceUUID; - char serviceUUID[64]; -}; - -struct ThreatEvent { - uint8_t mac[6]; - char identifier[64]; - int8_t rssi; - uint8_t channel; - const char* radioType; - uint8_t certainty; - const char* category; -}; - -class EventBus { -public: - typedef std::function WiFiFrameHandler; - typedef std::function BluetoothHandler; - typedef std::function ThreatHandler; - typedef std::function SystemEventHandler; - - static void publishWifiFrame(const WiFiFrameEvent& event); - static void publishBluetoothDevice(const BluetoothDeviceEvent& event); - static void publishThreat(const ThreatEvent& event); - static void publishSystemReady(); - - static void subscribeWifiFrame(WiFiFrameHandler handler); - static void subscribeBluetoothDevice(BluetoothHandler handler); - static void subscribeThreat(ThreatHandler handler); - static void subscribeSystemReady(SystemEventHandler handler); - -private: - static WiFiFrameHandler wifiHandler; - static BluetoothHandler bluetoothHandler; - static ThreatHandler threatHandler; - static SystemEventHandler systemReadyHandler; -}; - -#endif \ No newline at end of file diff --git a/128x32_OLED/flocksquawk_128x32_portable/src/TelemetryReporter.h b/128x32_OLED/flocksquawk_128x32_portable/src/TelemetryReporter.h deleted file mode 100644 index ca38c42..0000000 --- a/128x32_OLED/flocksquawk_128x32_portable/src/TelemetryReporter.h +++ /dev/null @@ -1,24 +0,0 @@ -#ifndef TELEMETRY_REPORTER_H -#define TELEMETRY_REPORTER_H - -#include -#include -#include "EventBus.h" - -class TelemetryReporter { -public: - void initialize(); - void handleThreatDetection(const ThreatEvent& threat); - -private: - unsigned long bootTime; - - void serializeThreatToJSON(const ThreatEvent& threat, JsonDocument& doc); - void appendSourceInfo(const ThreatEvent& threat, JsonDocument& doc); - void appendTargetIdentity(const ThreatEvent& threat, JsonDocument& doc); - void appendIndicators(const ThreatEvent& threat, JsonDocument& doc); - void appendMetadata(const ThreatEvent& threat, JsonDocument& doc); - void outputJSON(const JsonDocument& doc); -}; - -#endif \ No newline at end of file diff --git a/128x32_OLED/flocksquawk_128x32_portable/src/ThreatAnalyzer.h b/128x32_OLED/flocksquawk_128x32_portable/src/ThreatAnalyzer.h deleted file mode 100644 index 12c95ae..0000000 --- a/128x32_OLED/flocksquawk_128x32_portable/src/ThreatAnalyzer.h +++ /dev/null @@ -1,27 +0,0 @@ -#ifndef THREAT_ANALYZER_H -#define THREAT_ANALYZER_H - -#include -#include "EventBus.h" -#include "DeviceSignatures.h" - -class ThreatAnalyzer { -public: - void initialize(); - void analyzeWiFiFrame(const WiFiFrameEvent& frame); - void analyzeBluetoothDevice(const BluetoothDeviceEvent& device); - -private: - bool matchesNetworkName(const char* ssid); - bool matchesMACPrefix(const uint8_t* mac); - bool matchesBLEName(const char* name); - bool matchesRavenService(const char* uuid); - uint8_t calculateCertainty(bool nameMatch, bool macMatch, bool uuidMatch); - const char* determineCategory(bool isRaven); - void emitThreatDetection(const WiFiFrameEvent& frame, const char* radio, uint8_t certainty); - void emitThreatDetection(const BluetoothDeviceEvent& device, const char* radio, uint8_t certainty, const char* category); - void formatMACAddress(const uint8_t* mac, char* output); - void extractOUI(const uint8_t* mac, char* output); -}; - -#endif \ No newline at end of file From 856c28f982607a6c74f274e45072a0ec66976066 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 31 Jan 2026 14:05:20 -0700 Subject: [PATCH 10/23] Migrate Flipper Zero variant to common/ shared headers Move variant-specific src/ files into sketch directory to follow Arduino convention. Keep Flipper's own TelemetryReporter (line-based protocol for Flipper app). Fix radioType null-check for char array. Co-Authored-By: Claude Opus 4.5 --- .../flocksquawk-flipper.ino | 187 ++++++------------ .../src/RadioScanner.h | 0 .../src/SoundEngine.h | 0 .../src/TelemetryReporter.h | 0 .../dev-board-firmware/src/DeviceSignatures.h | 51 ----- .../dev-board-firmware/src/EventBus.h | 73 ------- .../dev-board-firmware/src/ThreatAnalyzer.h | 27 --- 7 files changed, 65 insertions(+), 273 deletions(-) rename flipper-zero/dev-board-firmware/{ => flocksquawk-flipper}/src/RadioScanner.h (100%) rename flipper-zero/dev-board-firmware/{ => flocksquawk-flipper}/src/SoundEngine.h (100%) rename flipper-zero/dev-board-firmware/{ => flocksquawk-flipper}/src/TelemetryReporter.h (100%) delete mode 100644 flipper-zero/dev-board-firmware/src/DeviceSignatures.h delete mode 100644 flipper-zero/dev-board-firmware/src/EventBus.h delete mode 100644 flipper-zero/dev-board-firmware/src/ThreatAnalyzer.h diff --git a/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino b/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino index 1f8dcba..88c2a41 100644 --- a/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino +++ b/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino @@ -4,13 +4,15 @@ #include #include #define FLOCK_RGB_AVAILABLE 1 +#include "freertos/FreeRTOS.h" +#include "freertos/portmacro.h" #include "esp_wifi.h" #include "esp_wifi_types.h" -#include "src/EventBus.h" -#include "src/DeviceSignatures.h" +#include "EventBus.h" +#include "DeviceSignatures.h" #include "src/RadioScanner.h" -#include "src/ThreatAnalyzer.h" +#include "ThreatAnalyzer.h" #include "src/TelemetryReporter.h" // Global system components @@ -145,6 +147,17 @@ void EventBus::subscribeAudioRequest(AudioHandler handler) { audioHandler = handler; } +// Thread-safe deferred event processing +static portMUX_TYPE wifiMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool wifiFramePending = false; +static WiFiFrameEvent pendingWiFiFrame; +static portMUX_TYPE bleMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool bleDevicePending = false; +static BluetoothDeviceEvent pendingBleDevice; +static portMUX_TYPE threatMux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool threatPending = false; +static ThreatEvent pendingThreat; + // RadioScannerManager implementation void RadioScannerManager::initialize() { configureWiFiSniffer(); @@ -304,118 +317,6 @@ NimBLEScan* RadioScannerManager::bleScanner = nullptr; bool RadioScannerManager::isScanningBLE = false; #endif -// ThreatAnalyzer implementation -void ThreatAnalyzer::initialize() { - // Analyzer ready -} - -void ThreatAnalyzer::analyzeWiFiFrame(const WiFiFrameEvent& frame) { - bool nameMatch = strlen(frame.ssid) > 0 && matchesNetworkName(frame.ssid); - bool macMatch = matchesMACPrefix(frame.mac); - - if (nameMatch || macMatch) { - uint8_t certainty = calculateCertainty(nameMatch, macMatch, false); - emitThreatDetection(frame, "wifi", certainty); - } -} - -void ThreatAnalyzer::analyzeBluetoothDevice(const BluetoothDeviceEvent& device) { - bool nameMatch = strlen(device.name) > 0 && matchesBLEName(device.name); - bool macMatch = matchesMACPrefix(device.mac); - bool uuidMatch = device.hasServiceUUID && matchesRavenService(device.serviceUUID); - - if (nameMatch || macMatch || uuidMatch) { - uint8_t certainty = calculateCertainty(nameMatch, macMatch, uuidMatch); - const char* category = determineCategory(uuidMatch); - emitThreatDetection(device, "bluetooth", certainty, category); - } -} - -bool ThreatAnalyzer::matchesNetworkName(const char* ssid) { - if (!ssid) return false; - - for (size_t i = 0; i < DeviceProfiles::NetworkNameCount; i++) { - if (strcasestr(ssid, DeviceProfiles::NetworkNames[i])) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesMACPrefix(const uint8_t* mac) { - char macStr[9]; - snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x", mac[0], mac[1], mac[2]); - - for (size_t i = 0; i < DeviceProfiles::MACPrefixCount; i++) { - if (strncasecmp(macStr, DeviceProfiles::MACPrefixes[i], 8) == 0) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesBLEName(const char* name) { - if (!name) return false; - - for (size_t i = 0; i < DeviceProfiles::BLEIdentifierCount; i++) { - if (strcasestr(name, DeviceProfiles::BLEIdentifiers[i])) { - return true; - } - } - return false; -} - -bool ThreatAnalyzer::matchesRavenService(const char* uuid) { - if (!uuid) return false; - - for (size_t i = 0; i < DeviceProfiles::RavenServiceCount; i++) { - if (strcasecmp(uuid, DeviceProfiles::RavenServices[i]) == 0) { - return true; - } - } - return false; -} - -uint8_t ThreatAnalyzer::calculateCertainty(bool nameMatch, bool macMatch, bool uuidMatch) { - if (nameMatch && macMatch && uuidMatch) return 100; - if (nameMatch && macMatch) return 95; - if (uuidMatch) return 90; - if (nameMatch || macMatch) return 85; - return 70; -} - -const char* ThreatAnalyzer::determineCategory(bool isRaven) { - return isRaven ? "acoustic_detector" : "surveillance_device"; -} - -void ThreatAnalyzer::emitThreatDetection(const WiFiFrameEvent& frame, const char* radio, uint8_t certainty) { - ThreatEvent threat; - memset(&threat, 0, sizeof(threat)); - memcpy(threat.mac, frame.mac, 6); - strncpy(threat.identifier, frame.ssid, sizeof(threat.identifier) - 1); - threat.rssi = frame.rssi; - threat.channel = frame.channel; - threat.radioType = radio; - threat.certainty = certainty; - threat.category = "surveillance_device"; - - EventBus::publishThreat(threat); -} - -void ThreatAnalyzer::emitThreatDetection(const BluetoothDeviceEvent& device, const char* radio, uint8_t certainty, const char* category) { - ThreatEvent threat; - memset(&threat, 0, sizeof(threat)); - memcpy(threat.mac, device.mac, 6); - strncpy(threat.identifier, device.name, sizeof(threat.identifier) - 1); - threat.rssi = device.rssi; - threat.channel = 0; - threat.radioType = radio; - threat.certainty = certainty; - threat.category = category; - - EventBus::publishThreat(threat); -} - // TelemetryReporter implementation void TelemetryReporter::initialize() { bootTime = millis(); @@ -454,7 +355,7 @@ void TelemetryReporter::emitAlert(const ThreatEvent& threat) { threat.mac[3], threat.mac[4], threat.mac[5]); Serial.printf("ALERT,RSSI=%d,MAC=%s", threat.rssi, macStr); - if (threat.radioType) { + if (threat.radioType[0] != '\0') { Serial.printf(",RADIO=%s", threat.radioType); } if (threat.channel > 0) { @@ -505,16 +406,24 @@ void setup() { #endif EventBus::subscribeWifiFrame([](const WiFiFrameEvent& event) { - threatEngine.analyzeWiFiFrame(event); - reporter.handleWiFiFrameSeen(event); + portENTER_CRITICAL(&wifiMux); + pendingWiFiFrame = event; + wifiFramePending = true; + portEXIT_CRITICAL(&wifiMux); }); - + EventBus::subscribeBluetoothDevice([](const BluetoothDeviceEvent& event) { - threatEngine.analyzeBluetoothDevice(event); + portENTER_CRITICAL(&bleMux); + pendingBleDevice = event; + bleDevicePending = true; + portEXIT_CRITICAL(&bleMux); }); - + EventBus::subscribeThreat([](const ThreatEvent& event) { - reporter.handleThreatDetection(event); + portENTER_CRITICAL(&threatMux); + pendingThreat = event; + threatPending = true; + portEXIT_CRITICAL(&threatMux); }); EventBus::subscribeSystemReady([]() { @@ -530,6 +439,40 @@ void setup() { void loop() { rfScanner.update(); + uint32_t now = millis(); + + if (wifiFramePending) { + WiFiFrameEvent frameCopy; + portENTER_CRITICAL(&wifiMux); + frameCopy = pendingWiFiFrame; + wifiFramePending = false; + portEXIT_CRITICAL(&wifiMux); + reporter.handleWiFiFrameSeen(frameCopy); + threatEngine.analyzeWiFiFrame(frameCopy); + } + + if (bleDevicePending) { + BluetoothDeviceEvent bleCopy; + portENTER_CRITICAL(&bleMux); + bleCopy = pendingBleDevice; + bleDevicePending = false; + portEXIT_CRITICAL(&bleMux); + threatEngine.analyzeBluetoothDevice(bleCopy); + } + + threatEngine.tick(now); + + if (threatPending) { + ThreatEvent threatCopy; + portENTER_CRITICAL(&threatMux); + threatCopy = pendingThreat; + threatPending = false; + portEXIT_CRITICAL(&threatMux); + if (threatCopy.shouldAlert) { + reporter.handleThreatDetection(threatCopy); + } + } + reporter.update(); #if FLOCK_TARGET_ESP32S2 && FLOCK_RGB_AVAILABLE if (reporter.isAlertActive()) { diff --git a/flipper-zero/dev-board-firmware/src/RadioScanner.h b/flipper-zero/dev-board-firmware/flocksquawk-flipper/src/RadioScanner.h similarity index 100% rename from flipper-zero/dev-board-firmware/src/RadioScanner.h rename to flipper-zero/dev-board-firmware/flocksquawk-flipper/src/RadioScanner.h diff --git a/flipper-zero/dev-board-firmware/src/SoundEngine.h b/flipper-zero/dev-board-firmware/flocksquawk-flipper/src/SoundEngine.h similarity index 100% rename from flipper-zero/dev-board-firmware/src/SoundEngine.h rename to flipper-zero/dev-board-firmware/flocksquawk-flipper/src/SoundEngine.h diff --git a/flipper-zero/dev-board-firmware/src/TelemetryReporter.h b/flipper-zero/dev-board-firmware/flocksquawk-flipper/src/TelemetryReporter.h similarity index 100% rename from flipper-zero/dev-board-firmware/src/TelemetryReporter.h rename to flipper-zero/dev-board-firmware/flocksquawk-flipper/src/TelemetryReporter.h diff --git a/flipper-zero/dev-board-firmware/src/DeviceSignatures.h b/flipper-zero/dev-board-firmware/src/DeviceSignatures.h deleted file mode 100644 index 141814e..0000000 --- a/flipper-zero/dev-board-firmware/src/DeviceSignatures.h +++ /dev/null @@ -1,51 +0,0 @@ -#ifndef DEVICE_SIGNATURES_H -#define DEVICE_SIGNATURES_H - -#include - -namespace DeviceProfiles { - - // Network name patterns for target identification - const char* const NetworkNames[] = { - "flock", - "Flock", - "FLOCK", - "FS Ext Battery", - "Penguin", - "Pigvision" - }; - const size_t NetworkNameCount = 6; - - // MAC address OUI prefixes for target devices - const char* const MACPrefixes[] = { - "58:8e:81", "cc:cc:cc", "ec:1b:bd", "90:35:ea", "04:0d:84", - "f0:82:c0", "1c:34:f1", "38:5b:44", "94:34:69", "b4:e3:f9", - "70:c9:4e", "3c:91:80", "d8:f3:bc", "80:30:49", "14:5a:fc", - "74:4c:a1", "08:3a:88", "9c:2f:9d", "94:08:53", "e4:aa:ea" - }; - const size_t MACPrefixCount = 20; - - // Bluetooth device name patterns - const char* const BLEIdentifiers[] = { - "FS Ext Battery", - "Penguin", - "Flock", - "Pigvision" - }; - const size_t BLEIdentifierCount = 4; - - // Raven acoustic detection device service UUIDs - const char* const RavenServices[] = { - "0000180a-0000-1000-8000-00805f9b34fb", // Device info (all versions) - "00003100-0000-1000-8000-00805f9b34fb", // GPS (1.2.0+) - "00003200-0000-1000-8000-00805f9b34fb", // Power/Battery (1.2.0+) - "00003300-0000-1000-8000-00805f9b34fb", // Network (1.2.0+) - "00003400-0000-1000-8000-00805f9b34fb", // Upload stats (1.2.0+) - "00003500-0000-1000-8000-00805f9b34fb", // Error tracking (1.2.0+) - "00001809-0000-1000-8000-00805f9b34fb", // Health/Temp (legacy 1.1.7) - "00001819-0000-1000-8000-00805f9b34fb" // Location (legacy 1.1.7) - }; - const size_t RavenServiceCount = 8; -} - -#endif \ No newline at end of file diff --git a/flipper-zero/dev-board-firmware/src/EventBus.h b/flipper-zero/dev-board-firmware/src/EventBus.h deleted file mode 100644 index 599399e..0000000 --- a/flipper-zero/dev-board-firmware/src/EventBus.h +++ /dev/null @@ -1,73 +0,0 @@ -#ifndef EVENT_BUS_H -#define EVENT_BUS_H - -#include -#include - -enum class EventType { - WifiFrameCaptured, - BluetoothDeviceFound, - ThreatIdentified, - SystemReady, - AudioPlaybackRequested -}; - -struct WiFiFrameEvent { - uint8_t mac[6]; - char ssid[33]; - int8_t rssi; - uint8_t channel; - uint8_t frameSubtype; // 0x20 = probe, 0x80 = beacon -}; - -struct BluetoothDeviceEvent { - uint8_t mac[6]; - char name[64]; - int8_t rssi; - bool hasServiceUUID; - char serviceUUID[64]; -}; - -struct ThreatEvent { - uint8_t mac[6]; - char identifier[64]; - int8_t rssi; - uint8_t channel; - const char* radioType; - uint8_t certainty; - const char* category; -}; - -struct AudioEvent { - const char* soundFile; -}; - -class EventBus { -public: - typedef std::function WiFiFrameHandler; - typedef std::function BluetoothHandler; - typedef std::function ThreatHandler; - typedef std::function SystemEventHandler; - typedef std::function AudioHandler; - - static void publishWifiFrame(const WiFiFrameEvent& event); - static void publishBluetoothDevice(const BluetoothDeviceEvent& event); - static void publishThreat(const ThreatEvent& event); - static void publishSystemReady(); - static void publishAudioRequest(const AudioEvent& event); - - static void subscribeWifiFrame(WiFiFrameHandler handler); - static void subscribeBluetoothDevice(BluetoothHandler handler); - static void subscribeThreat(ThreatHandler handler); - static void subscribeSystemReady(SystemEventHandler handler); - static void subscribeAudioRequest(AudioHandler handler); - -private: - static WiFiFrameHandler wifiHandler; - static BluetoothHandler bluetoothHandler; - static ThreatHandler threatHandler; - static SystemEventHandler systemReadyHandler; - static AudioHandler audioHandler; -}; - -#endif \ No newline at end of file diff --git a/flipper-zero/dev-board-firmware/src/ThreatAnalyzer.h b/flipper-zero/dev-board-firmware/src/ThreatAnalyzer.h deleted file mode 100644 index 12c95ae..0000000 --- a/flipper-zero/dev-board-firmware/src/ThreatAnalyzer.h +++ /dev/null @@ -1,27 +0,0 @@ -#ifndef THREAT_ANALYZER_H -#define THREAT_ANALYZER_H - -#include -#include "EventBus.h" -#include "DeviceSignatures.h" - -class ThreatAnalyzer { -public: - void initialize(); - void analyzeWiFiFrame(const WiFiFrameEvent& frame); - void analyzeBluetoothDevice(const BluetoothDeviceEvent& device); - -private: - bool matchesNetworkName(const char* ssid); - bool matchesMACPrefix(const uint8_t* mac); - bool matchesBLEName(const char* name); - bool matchesRavenService(const char* uuid); - uint8_t calculateCertainty(bool nameMatch, bool macMatch, bool uuidMatch); - const char* determineCategory(bool isRaven); - void emitThreatDetection(const WiFiFrameEvent& frame, const char* radio, uint8_t certainty); - void emitThreatDetection(const BluetoothDeviceEvent& device, const char* radio, uint8_t certainty, const char* category); - void formatMACAddress(const uint8_t* mac, char* output); - void extractOUI(const uint8_t* mac, char* output); -}; - -#endif \ No newline at end of file From 1cffdd8ef69143372291c450e9e5c1c2b14c5ac9 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 31 Jan 2026 17:34:52 -0700 Subject: [PATCH 11/23] Add Docker build environment with all dependencies pre-baked Dockerfile builds a debian:bookworm-slim image containing arduino-cli, ESP32 core v3.0.7, all Arduino libraries (version-pinned), doctest.h, and pre-warmed core caches for all 4 FQBNs. Source is bind-mounted at runtime so the image is reusable across branches. Also adds docker-compose.yml (build-all, test, shell, build-variant services), entrypoint.sh (seeds doctest.h into bind-mount), .dockerignore, and Makefile docker-* targets. Fixes a portability bug in test/mocks/Arduino.h: adds for snprintf, which macOS clang resolves transitively but Debian clang-14 does not. Co-Authored-By: Claude Opus 4.5 --- .dockerignore | 4 +++ Dockerfile | 73 ++++++++++++++++++++++++++++++++++++++++++++ Makefile | 38 +++++++++++++++++++++++ docker-compose.yml | 28 +++++++++++++++++ entrypoint.sh | 13 ++++++++ test/mocks/Arduino.h | 1 + 6 files changed, 157 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100755 entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1c8738c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.build/ +.git/ +images/ +.claude/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1603259 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,73 @@ +# FlockSquawk Docker Build Environment +# All toolchains and libraries pre-baked for zero-install builds. +# +# Build: docker build -t flocksquawk-build:latest . +# Run: docker run --rm -v .:/workspace flocksquawk-build:latest make all + +FROM debian:bookworm-slim + +# ── 1. System packages ────────────────────────────────────────────── +RUN apt-get update && apt-get install -y --no-install-recommends \ + clang \ + make \ + python3 \ + python3-serial \ + curl \ + ca-certificates \ + git \ + && rm -rf /var/lib/apt/lists/* + +# ── 2. arduino-cli (pinned latest stable) ─────────────────────────── +RUN mkdir -p /tmp/acli \ + && cd /tmp/acli \ + && curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh -s -- \ + && mv bin/arduino-cli /usr/local/bin/ \ + && rm -rf /tmp/acli + +# ── 3. ESP32 board manager URL ────────────────────────────────────── +RUN arduino-cli config init \ + && arduino-cli config add board_manager.additional_urls \ + https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json + +# ── 4. ESP32 core v3.0.7 (~1.5 GB — includes Xtensa + RISC-V) ───── +RUN arduino-cli core update-index \ + && arduino-cli core install esp32:esp32@3.0.7 + +# ── 5. Arduino libraries (version-pinned) ─────────────────────────── +RUN arduino-cli lib install \ + ArduinoJson@7.3.0 \ + "NimBLE-Arduino@2.2.1" \ + M5Unified@0.2.2 \ + U8g2@2.35.30 \ + "Adafruit GFX Library@1.12.4" \ + "Adafruit SSD1306@2.5.13" + +# ── 6. doctest.h (pre-fetched for host-side tests) ────────────────── +RUN mkdir -p /opt/flocksquawk-deps \ + && curl -sL -o /opt/flocksquawk-deps/doctest.h \ + https://raw.githubusercontent.com/doctest/doctest/v2.4.11/doctest/doctest.h + +# ── 7. Core cache warm-up ─────────────────────────────────────────── +# Dummy-compile a minimal sketch against each distinct FQBN so the +# per-board core cache is pre-generated inside the image. This avoids +# a ~60 s first-build penalty at runtime. +RUN mkdir -p /tmp/warmup/warmup \ + && echo 'void setup(){} void loop(){}' > /tmp/warmup/warmup/warmup.ino \ + && for fqbn in \ + esp32:esp32:m5stack_stickc_plus2 \ + esp32:esp32:m5stack_fire \ + esp32:esp32:esp32 \ + esp32:esp32:esp32s2 ; do \ + echo "Warming core cache for ${fqbn} ..." \ + && arduino-cli compile --fqbn "${fqbn}" /tmp/warmup/warmup || true ; \ + done \ + && rm -rf /tmp/warmup + +# ── 8. Workspace ──────────────────────────────────────────────────── +WORKDIR /workspace + +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["entrypoint.sh"] +CMD ["make", "help"] diff --git a/Makefile b/Makefile index 02a907c..18c4ea8 100644 --- a/Makefile +++ b/Makefile @@ -177,6 +177,35 @@ install-deps: "Adafruit GFX Library" \ "Adafruit SSD1306" +# ────────────────────────────────────────────── +# Docker build environment +# ────────────────────────────────────────────── +DOCKER_IMAGE ?= flocksquawk-build:latest + +.PHONY: docker-build-image docker-build-all docker-build docker-test \ + docker-test-verbose docker-shell docker-clean + +docker-build-image: + docker build -t $(DOCKER_IMAGE) . + +docker-build-all: + docker run --rm -v "$(CURDIR)":/workspace $(DOCKER_IMAGE) make all + +docker-build: + docker run --rm -v "$(CURDIR)":/workspace $(DOCKER_IMAGE) make build-$(VARIANT) + +docker-test: + docker run --rm -v "$(CURDIR)":/workspace $(DOCKER_IMAGE) make test + +docker-test-verbose: + docker run --rm -v "$(CURDIR)":/workspace $(DOCKER_IMAGE) make test-verbose + +docker-shell: + docker run --rm -it -v "$(CURDIR)":/workspace $(DOCKER_IMAGE) /bin/bash + +docker-clean: + docker rmi $(DOCKER_IMAGE) + # ────────────────────────────────────────────── # Help (default target) # ────────────────────────────────────────────── @@ -209,6 +238,15 @@ help: @echo " make flash => flash-\$$(VARIANT)" @echo " make monitor => monitor-\$$(VARIANT)" @echo "" + @echo "Docker targets:" + @echo " make docker-build-image Build the Docker image (all deps pre-baked)" + @echo " make docker-build-all Compile all variants in container" + @echo " make docker-build Compile VARIANT in container" + @echo " make docker-test Run host-side unit tests in container" + @echo " make docker-test-verbose Run verbose tests in container" + @echo " make docker-shell Interactive shell in container" + @echo " make docker-clean Remove the Docker image" + @echo "" @echo "Variables:" @echo " PORT= Serial port (auto-detected if unset)" @echo " BAUD= Monitor baud rate (default: 115200)" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a27250f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + build-all: + image: flocksquawk-build:latest + volumes: + - .:/workspace + command: make all + + test: + image: flocksquawk-build:latest + volumes: + - .:/workspace + command: make test + + shell: + image: flocksquawk-build:latest + volumes: + - .:/workspace + stdin_open: true + tty: true + command: /bin/bash + + build-variant: + image: flocksquawk-build:latest + volumes: + - .:/workspace + environment: + - VARIANT=${VARIANT:-m5stick} + command: sh -c 'make build-$$VARIANT' diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..e7398ca --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# FlockSquawk Docker entrypoint +# Seeds pre-baked doctest.h into the bind-mounted source tree if missing, +# then hands off to the requested command. + +DOCTEST_SRC="/opt/flocksquawk-deps/doctest.h" +DOCTEST_DST="test/doctest.h" + +if [ -d "test" ] && [ ! -f "${DOCTEST_DST}" ] && [ -f "${DOCTEST_SRC}" ]; then + cp "${DOCTEST_SRC}" "${DOCTEST_DST}" +fi + +exec "$@" diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index c66ab97..883a475 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include From b197e5f8a858ae061169312711ff37695d23f2f7 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 31 Jan 2026 18:10:52 -0700 Subject: [PATCH 12/23] Update dependencies and centralize version pins in versions.env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit all dependency versions against latest available in the Arduino library index, update where possible, and centralize pins in a single versions.env file consumed by both Makefile (include) and Dockerfile (--build-arg). Version changes: - Base image: debian:bookworm-slim → debian:trixie-slim (Debian 13) - arduino-cli: unpinned → 1.4.1 - ArduinoJson: 7.3.0 → 7.4.2 - NimBLE-Arduino: 2.2.1 → 2.3.7 - M5Unified: 0.2.2 → 0.2.11 - Adafruit SSD1306: 2.5.13 → 2.5.16 - doctest: 2.4.11 → 2.4.12 - ESP32 core: 3.0.7 (unchanged — newer causes IRAM overflow) - U8g2: 2.35.30 (unchanged — already latest in Arduino index) - Adafruit GFX: 1.12.4 (unchanged — already latest) Makefile install-deps now pins library versions from versions.env, matching what the Dockerfile installs. Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 44 ++++++++++++++++++++++++++++++-------------- Makefile | 34 +++++++++++++++++++++++++--------- versions.env | 12 ++++++++++++ 3 files changed, 67 insertions(+), 23 deletions(-) create mode 100644 versions.env diff --git a/Dockerfile b/Dockerfile index 1603259..045f1ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,25 @@ # FlockSquawk Docker Build Environment # All toolchains and libraries pre-baked for zero-install builds. # -# Build: docker build -t flocksquawk-build:latest . +# Build: make docker-build-image (reads versions.env) +# or: docker build -t flocksquawk-build . (uses ARG defaults below) # Run: docker run --rm -v .:/workspace flocksquawk-build:latest make all -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:trixie-slim +FROM ${BASE_IMAGE} + +# ── Dependency versions ────────────────────────────────────────────── +# Defaults here mirror versions.env. When building via the Makefile, +# versions.env is the source of truth and overrides these via --build-arg. +ARG ARDUINO_CLI_VERSION=1.4.1 +ARG ESP32_CORE_VERSION=3.0.7 +ARG ARDUINOJSON_VERSION=7.4.2 +ARG NIMBLE_VERSION=2.3.7 +ARG M5UNIFIED_VERSION=0.2.11 +ARG U8G2_VERSION=2.35.30 +ARG ADAFRUIT_GFX_VERSION=1.12.4 +ARG ADAFRUIT_SSD1306_VERSION=2.5.16 +ARG DOCTEST_VERSION=2.4.12 # ── 1. System packages ────────────────────────────────────────────── RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -17,10 +32,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ git \ && rm -rf /var/lib/apt/lists/* -# ── 2. arduino-cli (pinned latest stable) ─────────────────────────── +# ── 2. arduino-cli ────────────────────────────────────────────────── RUN mkdir -p /tmp/acli \ && cd /tmp/acli \ - && curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh -s -- \ + && curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh -s -- ${ARDUINO_CLI_VERSION} \ && mv bin/arduino-cli /usr/local/bin/ \ && rm -rf /tmp/acli @@ -29,23 +44,24 @@ RUN arduino-cli config init \ && arduino-cli config add board_manager.additional_urls \ https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json -# ── 4. ESP32 core v3.0.7 (~1.5 GB — includes Xtensa + RISC-V) ───── +# ── 4. ESP32 core (~1.5 GB — includes Xtensa + RISC-V) ───────────── +# Pinned to 3.0.7: newer versions cause IRAM overflow on these targets. RUN arduino-cli core update-index \ - && arduino-cli core install esp32:esp32@3.0.7 + && arduino-cli core install esp32:esp32@${ESP32_CORE_VERSION} -# ── 5. Arduino libraries (version-pinned) ─────────────────────────── +# ── 5. Arduino libraries ──────────────────────────────────────────── RUN arduino-cli lib install \ - ArduinoJson@7.3.0 \ - "NimBLE-Arduino@2.2.1" \ - M5Unified@0.2.2 \ - U8g2@2.35.30 \ - "Adafruit GFX Library@1.12.4" \ - "Adafruit SSD1306@2.5.13" + ArduinoJson@${ARDUINOJSON_VERSION} \ + "NimBLE-Arduino@${NIMBLE_VERSION}" \ + M5Unified@${M5UNIFIED_VERSION} \ + U8g2@${U8G2_VERSION} \ + "Adafruit GFX Library@${ADAFRUIT_GFX_VERSION}" \ + "Adafruit SSD1306@${ADAFRUIT_SSD1306_VERSION}" # ── 6. doctest.h (pre-fetched for host-side tests) ────────────────── RUN mkdir -p /opt/flocksquawk-deps \ && curl -sL -o /opt/flocksquawk-deps/doctest.h \ - https://raw.githubusercontent.com/doctest/doctest/v2.4.11/doctest/doctest.h + https://raw.githubusercontent.com/doctest/doctest/v${DOCTEST_VERSION}/doctest/doctest.h # ── 7. Core cache warm-up ─────────────────────────────────────────── # Dummy-compile a minimal sketch against each distinct FQBN so the diff --git a/Makefile b/Makefile index 18c4ea8..691a924 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,18 @@ # FlockSquawk — arduino-cli build system # Usage: make help +# ────────────────────────────────────────────── +# Dependency versions (shared with Dockerfile) +# ────────────────────────────────────────────── +include versions.env + # ────────────────────────────────────────────── # User-configurable variables # ────────────────────────────────────────────── PORT ?= BAUD ?= 115200 VARIANT ?= m5stick -CORE_VERSION ?= 3.0.7 +CORE_VERSION ?= $(ESP32_CORE_VERSION) BUILD_DIR := $(CURDIR)/.build COMMON_DIR := $(CURDIR)/common @@ -136,7 +141,7 @@ TEST_SRCS := test/test_main.cpp test/eventbus_impl.cpp \ test/test_detectors.cpp test/test_device_tracker.cpp \ test/test_threat_analyzer.cpp TEST_BIN := $(BUILD_DIR)/test_runner -DOCTEST_URL := https://raw.githubusercontent.com/doctest/doctest/v2.4.11/doctest/doctest.h +DOCTEST_URL := https://raw.githubusercontent.com/doctest/doctest/v$(DOCTEST_VERSION)/doctest/doctest.h .PHONY: test test-verbose fetch-doctest @@ -170,12 +175,12 @@ install-deps: arduino-cli core update-index arduino-cli core install esp32:esp32@$(CORE_VERSION) arduino-cli lib install \ - ArduinoJson \ - "NimBLE-Arduino" \ - M5Unified \ - U8g2 \ - "Adafruit GFX Library" \ - "Adafruit SSD1306" + ArduinoJson@$(ARDUINOJSON_VERSION) \ + "NimBLE-Arduino@$(NIMBLE_VERSION)" \ + M5Unified@$(M5UNIFIED_VERSION) \ + U8g2@$(U8G2_VERSION) \ + "Adafruit GFX Library@$(ADAFRUIT_GFX_VERSION)" \ + "Adafruit SSD1306@$(ADAFRUIT_SSD1306_VERSION)" # ────────────────────────────────────────────── # Docker build environment @@ -186,7 +191,18 @@ DOCKER_IMAGE ?= flocksquawk-build:latest docker-test-verbose docker-shell docker-clean docker-build-image: - docker build -t $(DOCKER_IMAGE) . + docker build -t $(DOCKER_IMAGE) \ + --build-arg BASE_IMAGE=$(BASE_IMAGE) \ + --build-arg ARDUINO_CLI_VERSION=$(ARDUINO_CLI_VERSION) \ + --build-arg ESP32_CORE_VERSION=$(CORE_VERSION) \ + --build-arg ARDUINOJSON_VERSION=$(ARDUINOJSON_VERSION) \ + --build-arg NIMBLE_VERSION=$(NIMBLE_VERSION) \ + --build-arg M5UNIFIED_VERSION=$(M5UNIFIED_VERSION) \ + --build-arg U8G2_VERSION=$(U8G2_VERSION) \ + --build-arg ADAFRUIT_GFX_VERSION=$(ADAFRUIT_GFX_VERSION) \ + --build-arg ADAFRUIT_SSD1306_VERSION=$(ADAFRUIT_SSD1306_VERSION) \ + --build-arg DOCTEST_VERSION=$(DOCTEST_VERSION) \ + . docker-build-all: docker run --rm -v "$(CURDIR)":/workspace $(DOCKER_IMAGE) make all diff --git a/versions.env b/versions.env new file mode 100644 index 0000000..c45e484 --- /dev/null +++ b/versions.env @@ -0,0 +1,12 @@ +# FlockSquawk dependency versions — single source of truth. +# Consumed by both Makefile (include) and Dockerfile (--build-arg). +BASE_IMAGE=debian:trixie-slim +ARDUINO_CLI_VERSION=1.4.1 +ESP32_CORE_VERSION=3.0.7 +ARDUINOJSON_VERSION=7.4.2 +NIMBLE_VERSION=2.3.7 +M5UNIFIED_VERSION=0.2.11 +U8G2_VERSION=2.35.30 +ADAFRUIT_GFX_VERSION=1.12.4 +ADAFRUIT_SSD1306_VERSION=2.5.16 +DOCTEST_VERSION=2.4.12 From beac8529de6890dc5a8b6081b670e77a2fae203f Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 31 Jan 2026 19:21:40 -0700 Subject: [PATCH 13/23] Extract shared docs into docs/ and slim variant READMEs Move duplicated content (setup, architecture, telemetry, configuration, troubleshooting, extending) from 6 variant READMEs into 8 shared docs/ files. Add new docs for build system and testing (previously undocumented). Restructure CLAUDE.md as a scannable agent gateway with dispatch table. Fix incorrect .ino filename reference in portable variant README. Co-Authored-By: Claude Opus 4.5 --- 128x32_OLED/flocksquawk_128x32/README.md | 359 +++------------ .../flocksquawk_128x32_portable/README.md | 219 ++------- CLAUDE.md | 134 +++--- Mini12864/flocksquawk_mini12864/README.md | 422 ++++-------------- README.md | 108 ++--- docs/architecture.md | 110 +++++ docs/build-system.md | 152 +++++++ docs/configuration.md | 83 ++++ docs/extending.md | 123 +++++ docs/getting-started.md | 61 +++ docs/telemetry-format.md | 94 ++++ docs/testing.md | 109 +++++ docs/troubleshooting.md | 59 +++ flipper-zero/README.md | 223 ++------- m5stack/flocksquawk_m5fire/README.md | 285 ++---------- m5stack/flocksquawk_m5stick/README.md | 238 ++-------- 16 files changed, 1206 insertions(+), 1573 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/build-system.md create mode 100644 docs/configuration.md create mode 100644 docs/extending.md create mode 100644 docs/getting-started.md create mode 100644 docs/telemetry-format.md create mode 100644 docs/testing.md create mode 100644 docs/troubleshooting.md diff --git a/128x32_OLED/flocksquawk_128x32/README.md b/128x32_OLED/flocksquawk_128x32/README.md index 2a206e1..9ba76a0 100644 --- a/128x32_OLED/flocksquawk_128x32/README.md +++ b/128x32_OLED/flocksquawk_128x32/README.md @@ -1,6 +1,6 @@ -# FlockSquawk +# FlockSquawk (128x32 OLED) -A modular, event-driven ESP32 project that passively detects surveillance devices using WiFi promiscuous mode and Bluetooth Low Energy scanning. Features audio alerts via I2S playback and JSON telemetry output. +128x32 I2C OLED variant with I2S audio playback. Passively detects surveillance devices using WiFi promiscuous mode and Bluetooth Low Energy scanning. ## Features @@ -8,112 +8,60 @@ A modular, event-driven ESP32 project that passively detects surveillance device - **Bluetooth Low Energy**: Active scanning for device names, MAC addresses, and service UUIDs - **Pattern Matching**: Identifies devices based on SSID patterns, MAC prefixes, device names, and service UUIDs - **Audio Alerts**: I2S-based audio playback with volume control (MAX98357A amplifier) +- **128x32 OLED Display**: Compact status display over I2C - **JSON Telemetry**: Structured event reporting via serial output - **Event-Driven Architecture**: Modular design for easy extension ## Hardware Requirements -### Required Components - - **ESP32 Development Board** (e.g., ESP32 DevKit, NodeMCU-32S) - **MAX98357A I2S Audio Amplifier Module** -- **Speaker** (4-8Ω, 3-5W recommended) +- **Speaker** (4-8 ohm, 3-5W recommended) +- **128x32 I2C OLED Display** (SSD1306/SH1106 compatible) - **USB Cable** for programming and power - **Breadboard and jumper wires** (optional, for prototyping) -### Pin Connections +### I2S Audio Wiring | ESP32 Pin | MAX98357A Pin | Description | |-----------|---------------|-------------| -| GPIO 27 | BCLK | Bit Clock | -| GPIO 26 | LRC | Left/Right Clock (Word Select) | -| GPIO 25 | DIN | Data Input | -| 5V | VIN | Power (5V) | -| GND | GND | Ground | - -**Speaker Connection:** -- MAX98357A `OUT+` → Speaker positive terminal -- MAX98357A `OUT-` → Speaker negative terminal - -### Wiring Diagram - -``` -ESP32 MAX98357A ------- --------- -GPIO 27 ───────────────> BCLK -GPIO 26 ───────────────> LRC -GPIO 25 ───────────────> DIN -5V ───────────────> VIN -GND ───────────────> GND - -MAX98357A ---------- -OUT+ ────> Speaker (+) -OUT- ────> Speaker (-) -``` - -## Software Setup - -### Prerequisites - -1. **Arduino IDE** (version 1.8.19 or later, or Arduino IDE 2.x) -2. **ESP32 Board Support** installed in Arduino IDE - -### Installing ESP32 Board Support - -1. Open Arduino IDE -2. Go to **File** → **Preferences** -3. In "Additional Boards Manager URLs", add: - ``` - https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json - ``` -4. Go to **Tools** → **Board** → **Boards Manager** -5. Search for "ESP32" and install "esp32 by Espressif Systems" -6. Select your ESP32 board: **Tools** → **Board** → **ESP32 Arduino** → **ESP32 Dev Module** (or your specific board) - -**Important:** Use **esp32 by Espressif Systems** version **3.0.7 or older**. Newer versions fail to compile due to an **IRAM overflow** issue. - -### Required Libraries - -Install the following libraries via Arduino IDE Library Manager: +| GPIO 27 | BCLK | Bit Clock | +| GPIO 26 | LRC | Left/Right Clock (Word Select) | +| GPIO 25 | DIN | Data Input | +| 5V | VIN | Power (5V) | +| GND | GND | Ground | -1. **ArduinoJson** by Benoit Blanchon (version 6.x or 7.x) - - **Tools** → **Manage Libraries** → Search "ArduinoJson" → Install +**Speaker:** MAX98357A `OUT+` > Speaker (+), `OUT-` > Speaker (-) -2. **NimBLE-Arduino** by h2zero - - **Tools** → **Manage Libraries** → Search "NimBLE-Arduino" → Install +### OLED Wiring (I2C) -### Additional ESP32 Tools +| OLED Pin | ESP32 Pin | Notes | +|----------|-----------|-------| +| VCC | 3.3V | Do not use 5V | +| GND | GND | Common ground | +| SDA | GPIO 21 | Default ESP32 I2C SDA | +| SCL | GPIO 22 | Default ESP32 I2C SCL | -The following components are included with ESP32 board support: -- WiFi (built-in) -- LittleFS (built-in) -- I2S driver (built-in) +I2C address: most modules use `0x3C` (some use `0x3D`). -## Installation from GitHub +## Setup -### Step 1: Clone or Download Repository +For Arduino IDE installation and ESP32 board support, see [Getting Started](../../docs/getting-started.md). -```bash -git clone -cd FlockSquawk-main/128x32_OLED/flocksquawk_128x32 -``` - -Or download as ZIP and extract. +### Additional Libraries -### Step 2: Open Project in Arduino IDE +Install via Arduino IDE Library Manager: -1. Open Arduino IDE -2. Navigate to **File** → **Open** -3. Select `flocksquawk_128x32.ino` from this folder +- **Adafruit GFX Library** by Adafruit +- **Adafruit SSD1306** by Adafruit -### Step 3: Prepare Audio Files +### Audio Files -The project requires three WAV audio files in the `data` folder: +The project requires three WAV audio files in the `data/` folder, uploaded to LittleFS: -- `/data/startup.wav` - Plays on boot -- `/data/ready.wav` - Plays when system is ready -- `/data/alert.wav` - Plays on threat detection +- `data/startup.wav` -- Plays on boot +- `data/ready.wav` -- Plays when system is ready +- `data/alert.wav` -- Plays on threat detection **Audio File Requirements:** - Format: 16-bit PCM WAV @@ -121,57 +69,32 @@ The project requires three WAV audio files in the `data` folder: - Channels: Mono (1 channel) - Header: Standard 44-byte WAV header -**Adding Audio Files:** - -1. Place your WAV files in the `data/` directory if you wish to change them: - ``` - flocksquawk_128x32/ - ├── flocksquawk_128x32.ino - └── data/ - ├── startup.wav - ├── ready.wav - └── alert.wav - ``` - -2. Install the **ESP32 LittleFS Filesystem Uploader** plugin: - - Download from: https://github.com/lorol/arduino-esp32fs-plugin/releases - - Extract to: `/tools/ESP32FS/tool/esp32fs.jar` - - Restart Arduino IDE +**Upload via Arduino IDE:** +1. Install the **ESP32 LittleFS Filesystem Uploader** plugin +2. Use Ctrl+Shift+P, type "upload", select the LittleFS upload option +3. Or use **Tools** > **ESP32 Sketch Data Upload** -3. Write audio files to ESP32 - - Within the arduino IDE, use Ctrl + Shift + P, and type "upload" - - Find the LittleFS upload option, and select - - If you get an error saying unable to connect to the serial port, make sure that all serial terminals and processes are not running +**Upload via Makefile:** `make upload-data-oled` -4. Upload filesystem: - - **Tools** → **ESP32 Sketch Data Upload** - - Wait for upload to complete +### Board Settings -### Step 4: Configure Board Settings +1. Select board: **Tools** > **Board** > **ESP32 Dev Module** +2. Upload speed: **115200** +3. CPU frequency: **240MHz (WiFi/BT)** +4. Partition scheme: **Default 4MB with spiffs** or **Huge APP (3MB No OTA/1MB SPIFFS)** -1. Select your board: **Tools** → **Board** → **ESP32 Dev Module** -2. Set upload speed: **Tools** → **Upload Speed** → **115200** (or lower if upload fails) -3. Set CPU frequency: **Tools** → **CPU Frequency** → **240MHz (WiFi/BT)** -4. Set partition scheme: **Tools** → **Partition Scheme** → **Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)** or **Huge APP (3MB No OTA/1MB SPIFFS)** +### Upload -### Step 5: Upload Code +1. Connect via USB +2. Select port: **Tools** > **Port** +3. Click **Upload** -1. Connect ESP32 via USB -2. Select the correct port: **Tools** → **Port** → Select your ESP32 port -3. Click **Upload** button (or **Sketch** → **Upload**) -4. Wait for compilation and upload to complete +### Serial Monitor -### Step 6: Monitor Serial Output - -1. Open Serial Monitor: **Tools** → **Serial Monitor** -2. Set baud rate to **115200** -3. Set line ending to **Newline** -4. You should see initialization messages and detection events +Open at **115200** baud. See [Telemetry Format](../../docs/telemetry-format.md) for the JSON schema. ## Usage -### Basic Operation - 1. Power on the ESP32 2. The system will: - Initialize filesystem and audio @@ -180,203 +103,67 @@ The project requires three WAV audio files in the `data` folder: - Play ready sound - Begin scanning for targets -### Serial Output - -The system outputs JSON telemetry when threats are detected: - -```json -{ - "event": "target_detected", - "ms_since_boot": 15234, - "source": { - "radio": "wifi", - "channel": 6, - "rssi": -67 - }, - "target": { - "identity": { - "mac": "aa:bb:cc:dd:ee:ff", - "oui": "aa:bb:cc", - "label": "Network Name" - }, - "indicators": { - "ssid_match": true, - "mac_match": true, - "name_match": false, - "service_uuid_match": false - }, - "category": "surveillance_device", - "certainty": 95 - }, - "metadata": { - "frame_type": "beacon", - "detection_method": "combined_signature" - } -} -``` - -### Audio Alerts - -- **Startup**: Plays when system boots -- **Ready**: Plays when scanning begins -- **Alert**: Plays when a threat is detected - ### Volume Control -Default volume is set to 40% (0.4). To adjust: - -1. Open `src/SoundEngine.h` -2. Change `DEFAULT_VOLUME` value: - ```cpp - static constexpr float DEFAULT_VOLUME = 0.4f; // 0.0 to 1.0 - ``` -3. Re-upload code - -Or use the serial command (if implemented) to change volume at runtime. - -## Configuration - -### WiFi Channel Hopping +Default volume is 40% (0.4). To adjust, edit `src/SoundEngine.h`: -Default: Channels 1-13, switching every 500ms - -To modify, edit `src/RadioScanner.h`: ```cpp -static const uint8_t MAX_WIFI_CHANNEL = 13; -static const uint16_t CHANNEL_SWITCH_MS = 500; +static constexpr float DEFAULT_VOLUME = 0.4f; // 0.0 to 1.0 ``` -### BLE Scan Interval - -Default: 1 second scan every 5 seconds - -To modify, edit `src/RadioScanner.h`: -```cpp -static const uint8_t BLE_SCAN_SECONDS = 1; -static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; -``` - -### Detection Patterns - -Detection patterns are defined in `src/DeviceSignatures.h`. Patterns include: -- Network SSID names -- MAC address prefixes (OUI) -- Bluetooth device names -- Service UUIDs (e.g., Raven acoustic detectors) - -## Troubleshooting +## Variant-Specific Troubleshooting ### Audio Not Playing 1. **Check wiring**: Verify all I2S connections -2. **Check filesystem**: Ensure audio files are uploaded via "ESP32 Sketch Data Upload" +2. **Check filesystem**: Ensure audio files are uploaded via LittleFS uploader 3. **Check volume**: Try increasing `DEFAULT_VOLUME` in `SoundEngine.h` 4. **Check speaker**: Test speaker with another device 5. **Check serial output**: Look for audio file open errors -### No Detections - -1. **Check serial output**: Verify system initialized correctly -2. **Test with known device**: Use a smartphone with WiFi hotspot named "Flock" -3. **Check channel**: WiFi channel hopping may miss brief transmissions -4. **Verify patterns**: Check `DeviceSignatures.h` matches your target devices - -### Compilation Errors - -1. **Missing libraries**: Install ArduinoJson and NimBLE-Arduino -2. **Wrong board**: Select correct ESP32 board variant -3. **ESP32 core too new**: Install version **3.0.7 or older** (newer versions hit IRAM overflow) -4. **File structure**: Ensure all `.h` files are in `src/` directory +### OLED Screen Blank -### Upload Failures - -1. **Hold BOOT button**: Hold BOOT button while clicking Upload, release after upload starts -2. **Lower upload speed**: Change to 115200 or 9600 baud -3. **Check USB cable**: Use a data cable, not charge-only -4. **Driver issues**: Install ESP32 USB drivers (CP210x or CH340) +1. Verify VCC is 3.3V (not 5V) +2. Confirm SDA = GPIO21, SCL = GPIO22 +3. Run an I2C scanner sketch to confirm address +4. Try both `0x3C` and `0x3D` ### Filesystem Upload Fails -1. **Check partition scheme**: Use partition scheme with SPIFFS or LittleFS -2. **Check file sizes**: Ensure total data size fits in filesystem partition -3. **Restart IDE**: Close and reopen Arduino IDE -4. **Manual upload**: Use esptool.py or other tools to upload filesystem +1. **Check partition scheme**: Use partition scheme with SPIFFS/LittleFS +2. **Close serial monitors**: Uploader needs exclusive port access +3. **Restart Arduino IDE** and retry ## Project Structure ``` flocksquawk_128x32/ ├── flocksquawk_128x32.ino # Main orchestrator -├── README.md # This file +├── README.md ├── src/ -│ ├── EventBus.h # Event system interface -│ ├── DeviceSignatures.h # Detection patterns -│ ├── RadioScanner.h # RF scanning interface -│ ├── ThreatAnalyzer.h # Detection engine interface -│ ├── SoundEngine.h # Audio playback interface -│ └── TelemetryReporter.h # JSON reporting interface +│ ├── DisplayEngine.h # OLED display driver +│ ├── RadioScanner.h # Variant-specific RF scanning +│ └── SoundEngine.h # I2S audio playback └── data/ - ├── startup.wav # Boot sound - ├── ready.wav # Ready sound - └── alert.wav # Alert sound -``` - -## Architecture - -The system uses an event-driven architecture: - -``` -RadioScannerManager → WiFi/Bluetooth Events → EventBus - ↓ - ThreatAnalyzer - ↓ - Threat Events - ↓ - ┌──────────────────────────┴──────────────────┐ - ↓ ↓ - TelemetryReporter SoundEngine - ↓ ↓ - JSON Output Audio Alert -``` - -## Extending the System - -### Adding New Detection Patterns - -Edit `src/DeviceSignatures.h`: -```cpp -const char* const NetworkNames[] = { - "flock", - "YourNewPattern", // Add here - // ... -}; + ├── startup.wav + ├── ready.wav + └── alert.wav ``` -### Adding Display Support +Shared headers (`EventBus.h`, `ThreatAnalyzer.h`, `Detectors.h`, etc.) are in [`common/`](../../common/). -Subscribe to `ThreatHandler` in `setup()`: -```cpp -EventBus::subscribeThreat([](const ThreatEvent& event) { - display.showThreat(event); // Your display code -}); -``` +## Further Reading -### Adding LED Indicators - -Subscribe to events and control GPIO: -```cpp -EventBus::subscribeThreat([](const ThreatEvent& event) { - digitalWrite(LED_PIN, HIGH); - delay(500); - digitalWrite(LED_PIN, LOW); -}); -``` +- [Configuration](../../docs/configuration.md) -- WiFi/BLE tuning, detection patterns, volume +- [Architecture](../../docs/architecture.md) -- pipeline, detectors, thread safety +- [Extending](../../docs/extending.md) -- adding detectors, patterns, new variants +- [Build System](../../docs/build-system.md) -- Makefile and Docker builds +- [Troubleshooting](../../docs/troubleshooting.md) -- common issues ## License [GNU GENERAL PUBLIC LICENSE](https://github.com/f1yaw4y/FlockSquawk/blob/main/LICENSE) - ## Acknowledgments - Inspired by [flock-you](https://github.com/colonelpanichacks/flock-you) diff --git a/128x32_OLED/flocksquawk_128x32_portable/README.md b/128x32_OLED/flocksquawk_128x32_portable/README.md index 2e173e4..da95931 100644 --- a/128x32_OLED/flocksquawk_128x32_portable/README.md +++ b/128x32_OLED/flocksquawk_128x32_portable/README.md @@ -1,10 +1,6 @@ -# FlockSquawk (128x32 OLED Portable Variant) +# FlockSquawk (128x32 OLED Portable) -A modular, event-driven ESP32 project that passively detects surveillance devices using WiFi promiscuous mode and Bluetooth Low Energy scanning. Features buzzer alerts and JSON telemetry output. - -This README applies **only to the 128x32 I2C OLED version** of FlockSquawk. - ---- +Portable variant with buzzer alerts and compact OLED display. Passively detects surveillance devices using WiFi promiscuous mode and Bluetooth Low Energy scanning. ## Features @@ -16,123 +12,66 @@ This README applies **only to the 128x32 I2C OLED version** of FlockSquawk. - **JSON Telemetry**: Structured event reporting via serial output - **Event-Driven Architecture**: Modular design for easy extension ---- - ## Hardware Requirements -### Required Components - - **ESP32 Development Board** (e.g., ESP32 DevKit, NodeMCU-32S) - **Piezo Buzzer** (active or passive) - **128x32 I2C OLED Display** (SSD1306 / SH1106 compatible) - **USB Cable** for programming and power - **Breadboard and jumper wires** (optional, for prototyping) -Typical display listings: -> "0.91 inch 128x32 I2C OLED SSD1306" - -Supported controllers in most cases: -- SSD1306 (most common) -- SH1106 (usually compatible) -- SSD1315 - ---- +Typical display listings: "0.91 inch 128x32 I2C OLED SSD1306" -## Pin Connections - -### Buzzer +### Buzzer Wiring | ESP32 Pin | Buzzer Pin | Description | |-----------|------------|-------------| -| GPIO 23 | + / SIG | Buzzer signal | -| GND | - | Ground | - -If your wiring uses a different GPIO, update `kBuzzerPin` in `flocksquawk_128x32.ino`. - ---- +| GPIO 23 | + / SIG | Buzzer signal | +| GND | - | Ground | -## OLED Wiring (I2C) +If your wiring uses a different GPIO, update `kBuzzerPin` in `flocksquawk_128x32_portable.ino`. -Most 128x32 OLED modules only require four wires: +### OLED Wiring (I2C) | OLED Pin | ESP32 Pin | Notes | -|---------|----------|------| +|----------|-----------|-------| | VCC | 3.3V | Do not use 5V | | GND | GND | Common ground | | SDA | GPIO 21 | Default ESP32 I2C SDA | | SCL | GPIO 22 | Default ESP32 I2C SCL | -### I2C Address - -Most modules use: -- `0x3C` - -Some use: -- `0x3D` - -If the display stays blank, upload an I2C scanner sketch to confirm the address. +I2C address: most modules use `0x3C` (some use `0x3D`). ---- +## Setup -## Software Setup +For Arduino IDE installation and ESP32 board support, see [Getting Started](../../docs/getting-started.md). -### Prerequisites +### Additional Libraries -1. **Arduino IDE** (version 1.8.19 or later, or Arduino IDE 2.x) -2. **ESP32 Board Support** installed in Arduino IDE +Install via Arduino IDE Library Manager: -### Installing ESP32 Board Support +- **Adafruit GFX Library** by Adafruit +- **Adafruit SSD1306** by Adafruit -1. Open Arduino IDE -2. Go to **File** → **Preferences** -3. In "Additional Boards Manager URLs", add: - ``` - https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json - ``` -4. Go to **Tools** → **Board** → **Boards Manager** -5. Search for "ESP32" and install "esp32 by Espressif Systems" -6. Select your ESP32 board: **Tools** → **Board** → **ESP32 Arduino** → **ESP32 Dev Module** (or your specific board) +### Board Settings -**Important:** Use **esp32 by Espressif Systems** version **3.0.7 or older**. Newer versions fail to compile due to an **IRAM overflow** issue. +1. Select board: **Tools** > **Board** > **ESP32 Dev Module** +2. Upload speed: **115200** +3. CPU frequency: **240MHz (WiFi/BT)** +4. Partition scheme: **Default 4MB with spiffs** or **Huge APP (3MB No OTA/1MB SPIFFS)** ---- +### Upload -## Required Libraries +1. Connect via USB +2. Select port: **Tools** > **Port** +3. Click **Upload** -Install the following libraries via Arduino IDE Library Manager: +### Serial Monitor -1. **ArduinoJson** by Benoit Blanchon (version 6.x or 7.x) -2. **NimBLE-Arduino** by h2zero -3. **Adafruit GFX Library** by Adafruit -4. **Adafruit SSD1306** by Adafruit - -No NeoPixel, encoder, or Mini12864 libraries are required for this variant. - ---- - -## Installation from GitHub - -### Step 1: Clone or Download Repository - -```bash -git clone -cd FlockSquawk-main/128x32_OLED/flocksquawk_128x32_portable -``` - -Or download as ZIP and extract. - -### Step 2: Open Project in Arduino IDE - -1. Open Arduino IDE -2. Navigate to **File** → **Open** -3. Select `flocksquawk_128x32_portable.ino` from this folder - ---- +Open at **115200** baud. See [Telemetry Format](../../docs/telemetry-format.md) for the JSON schema. ## Usage -### Basic Operation - 1. Power on the ESP32 2. The system will: - Initialize WiFi sniffer and BLE scanner @@ -140,117 +79,39 @@ Or download as ZIP and extract. - Begin scanning for targets 3. Status information is shown on the 128x32 OLED -### Serial Output - -The system outputs JSON telemetry when threats are detected: +## Variant-Specific Troubleshooting -```json -{ - "event": "target_detected", - "ms_since_boot": 15234, - "source": { - "radio": "wifi", - "channel": 6, - "rssi": -67 - } -} -``` - ---- - -## Configuration - -### WiFi Channel Hopping - -Default: Channels 1-13, switching every 500ms - -Edit `src/RadioScanner.h`: -```cpp -static const uint8_t MAX_WIFI_CHANNEL = 13; -static const uint16_t CHANNEL_SWITCH_MS = 500; -``` - -### BLE Scan Interval - -Default: 1 second scan every 5 seconds - -Edit `src/RadioScanner.h`: -```cpp -static const uint8_t BLE_SCAN_SECONDS = 1; -static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; -``` - -### Detection Patterns - -Edit `src/DeviceSignatures.h` to customize detection rules: -- Network SSID names -- MAC address prefixes (OUI) -- Bluetooth device names -- Service UUIDs - ---- - -## Troubleshooting - -### OLED screen blank +### OLED Screen Blank 1. Verify VCC is 3.3V (not 5V) 2. Confirm SDA = GPIO21, SCL = GPIO22 3. Run an I2C scanner sketch to confirm address -4. Try both `0x3C` and `0x3D` if configurable in code - -### Upload Failures - -1. Hold BOOT button while clicking Upload -2. Lower upload speed to 115200 -3. Use a known good USB data cable -4. Install correct USB driver (CP210x or CH340) - ---- +4. Try both `0x3C` and `0x3D` ## Project Structure ``` flocksquawk_128x32_portable/ ├── flocksquawk_128x32_portable.ino # Main orchestrator -├── README.md # This file -├── src/ -│ ├── EventBus.h -│ ├── DeviceSignatures.h -│ ├── RadioScanner.h -│ ├── ThreatAnalyzer.h -│ └── TelemetryReporter.h -└── data/ # Optional assets (unused for buzzer build) +├── README.md +└── src/ + └── RadioScanner.h # Variant-specific RF scanning ``` ---- - -## Architecture - -The system uses an event-driven architecture: +Shared headers (`EventBus.h`, `ThreatAnalyzer.h`, `Detectors.h`, etc.) are in [`common/`](../../common/). -``` -RadioScannerManager → WiFi/Bluetooth Events → EventBus - ↓ - ThreatAnalyzer - ↓ - Threat Events - ↓ - ┌──────────────────────────┐ - ↓ ↓ - TelemetryReporter Buzzer Alert - ↓ - JSON Output -``` +## Further Reading ---- +- [Configuration](../../docs/configuration.md) -- WiFi/BLE tuning, detection patterns +- [Architecture](../../docs/architecture.md) -- pipeline, detectors, thread safety +- [Extending](../../docs/extending.md) -- adding detectors, patterns, new variants +- [Build System](../../docs/build-system.md) -- Makefile and Docker builds +- [Troubleshooting](../../docs/troubleshooting.md) -- common issues ## License [GNU GENERAL PUBLIC LICENSE](https://github.com/f1yaw4y/FlockSquawk/blob/main/LICENSE) ---- - ## Acknowledgments - Inspired by [flock-you](https://github.com/colonelpanichacks/flock-you) diff --git a/CLAUDE.md b/CLAUDE.md index 12f2fcc..983143d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,99 +1,77 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +FlockSquawk is an ESP32 Arduino project for passive RF awareness. It sniffs WiFi management frames and BLE advertisements, matches them against known surveillance device signatures, and outputs alerts via displays, audio, and JSON telemetry. -## Project Overview +## Quick Reference -FlockSquawk is an ESP32 Arduino project for passive RF awareness. It sniffs WiFi management frames and BLE advertisements, matches them against known surveillance device signatures, and outputs alerts via displays, audio, and JSON telemetry over Serial. +| Item | Value | +|------|-------| +| ESP32 core | **3.0.7 or older** (newer = IRAM overflow) | +| Serial baud | 115200 | +| Build tools | Arduino IDE, Makefile (`make help`), Docker | +| Dependency versions | `versions.env` (single source of truth) | +| Tests | `make test` (host-side doctest, no hardware needed) | -## Build System - -This is a pure **Arduino IDE** project (no CMake, PlatformIO, or Makefile). Each hardware variant is a self-contained Arduino sketch that opens directly in the IDE. - -**Critical constraint:** ESP32 board package must be **version 3.0.7 or older** (newer versions cause IRAM overflow). - -### Building with arduino-cli - -```bash -# Install ESP32 core -arduino-cli core install esp32:esp32@3.0.7 - -# Install required libraries -arduino-cli lib install ArduinoJson NimBLE-Arduino M5Unified - -# Compile a variant (example: M5StickC Plus2) -arduino-cli compile --fqbn esp32:esp32:m5stick_c_plus2 m5stack/flocksquawk_m5stick/ - -# Upload -arduino-cli upload --fqbn esp32:esp32:m5stick_c_plus2 --port /dev/ttyUSB0 m5stack/flocksquawk_m5stick/ - -# Serial monitor (115200 baud for JSON telemetry) -arduino-cli monitor --port /dev/ttyUSB0 --config baudrate=115200 -``` - -Board FQBNs vary per variant — check each variant's README for exact board settings. - -## Hardware Variants - -Six self-contained variants, each in its own directory with a dedicated README: - -| Variant | Path | Display | Audio | -|---|---|---|---| -| M5StickC Plus2 | `m5stack/flocksquawk_m5stick/` | Built-in TFT 135x240 | Buzzer tones | -| M5Stack FIRE | `m5stack/flocksquawk_m5fire/` | Built-in TFT 320x240 | Built-in speaker | -| Mini12864 | `Mini12864/flocksquawk_mini12864/` | ST7567 LCD 128x64 | I2S (MAX98357A) | -| 128x32 OLED | `128x32_OLED/flocksquawk_128x32/` | SSD1306/SH1106 I2C | I2S (MAX98357A) | -| 128x32 Portable | `128x32_OLED/flocksquawk_128x32_portable/` | SSD1306/SH1106 I2C | GPIO buzzer | -| Flipper Zero | `flipper-zero/dev-board-firmware/` | None (UART only) | None | - -Variants do **not** share source files — each has its own copy of the core headers in `src/`. Changes to shared logic (EventBus, ThreatAnalyzer, etc.) must be applied to each variant individually. - -## Architecture - -Data flows through a publish-subscribe pipeline: +## Pipeline ``` RadioScannerManager (WiFi promiscuous + BLE scan) - → EventBus (WiFiFrameEvent / BluetoothDeviceEvent) - → ThreatAnalyzer (signature matching → ThreatEvent) - → TelemetryReporter (JSON over Serial) - → Display (variant-specific UI) - → SoundEngine (alerts) + -> EventBus (WiFiFrameEvent / BluetoothDeviceEvent) + -> ThreatAnalyzer (signature matching -> ThreatEvent) + -> TelemetryReporter (JSON over Serial) + -> Display (variant-specific UI) + -> SoundEngine (alerts) ``` -### Core subsystems (in each variant's `src/`) - -- **EventBus.h** — Header-only static publish/subscribe. Event types: `WifiFrameCaptured`, `BluetoothDeviceFound`, `ThreatIdentified`, `SystemReady`. -- **RadioScanner.h** — WiFi promiscuous mode (channels 1-13, 1s hop interval) and BLE scanning via NimBLE (5s interval, 1s duration). Uses FreeRTOS portMUX spinlocks for thread safety between ISR callbacks and main loop. -- **ThreatAnalyzer.h** — Compares observations against `DeviceSignatures.h`. Tracks up to 32 devices with LRU eviction (states: NEW_DETECT → IN_RANGE → DEPARTED). Alert threshold: 65% certainty. -- **DeviceSignatures.h** — Static arrays of known SSIDs, MAC OUI prefixes, BLE device names, and service UUIDs. -- **TelemetryReporter.h** — Serializes detections as JSON via ArduinoJson (StaticJsonDocument<512>). -- **SoundEngine.h** — I2S WAV playback from LittleFS (Mini12864/128x32) or M5.Speaker tone generation (M5Stack variants). +## Variants -### Pluggable detector system (M5Stick variant only — newest architecture) +| Variant | FQBN | Sketch Path | Display | Audio | +|---------|------|-------------|---------|-------| +| M5StickC Plus2 | `esp32:esp32:m5stack_stickc_plus2` | `m5stack/flocksquawk_m5stick/` | Built-in TFT | Buzzer tones | +| M5Stack FIRE | `esp32:esp32:m5stack_fire` | `m5stack/flocksquawk_m5fire/` | Built-in TFT | Built-in speaker | +| Mini12864 | `esp32:esp32:esp32` | `Mini12864/flocksquawk_mini12864/` | ST7567 LCD 128x64 | I2S (MAX98357A) | +| 128x32 OLED | `esp32:esp32:esp32` | `128x32_OLED/flocksquawk_128x32/` | SSD1306/SH1106 I2C | I2S (MAX98357A) | +| 128x32 Portable | `esp32:esp32:esp32` | `128x32_OLED/flocksquawk_128x32_portable/` | SSD1306/SH1106 I2C | GPIO buzzer | +| Flipper Zero | `esp32:esp32:esp32s2` | `flipper-zero/dev-board-firmware/flocksquawk-flipper/` | None (UART) | None | -The M5Stick variant introduces a function-pointer-based detector registry: +Each variant has its own README with wiring, board settings, and usage instructions. -- **DetectorTypes.h** — Defines `DetectorResult` (matched/weight/name) and `WiFiDetectorEntry`/`BLEDetectorEntry` structs. -- **Detectors.h** — Registers detector functions. WiFi: `detectSsidFormat` (weight 75), `detectSsidKeyword` (45), `detectWifiMacOui` (20). BLE: `detectBleName` (55), `detectRavenCustomUuid` (80), `detectRavenStdUuid` (10), `detectBleMacOui` (20). -- Weighted scoring with subsumption (higher-confidence detectors override lower ones) and RSSI-based modifiers. +## Source Layout -This detector pattern is the intended direction for all variants. +``` +common/ # Shared headers included via -I common + EventBus.h # Pub/sub event bus + DetectorTypes.h # DetectorResult, flags, TrackedDevice, constants + Detectors.h # All detector functions (WiFi + BLE) + DeviceSignatures.h # MAC OUI prefix table + ThreatAnalyzer.h # Scoring pipeline + DeviceTracker + TelemetryReporter.h # JSON serialization + +/src/ # Variant-specific hardware code + RadioScanner.h # WiFi promiscuous + BLE init (per-variant) + SoundEngine.h # Audio output (per-variant) + DisplayEngine.h / ... # Display driver (some variants) + +test/ # Host-side unit tests (doctest) +``` -## Thread Safety Pattern +Shared headers live in `common/` and are included at compile time via `-I common` (set automatically by the Makefile). Each variant also has its own `src/` for hardware-specific code. Some variants still have local copies of shared headers in `src/` from before the migration. -WiFi promiscuous callbacks and BLE scan callbacks run on different cores/tasks than `loop()`. All variants use the same pattern: +## Thread Safety - `portMUX_TYPE` spinlocks guard shared volatile state in ISR callbacks - `taskENTER_CRITICAL` / `taskEXIT_CRITICAL` for atomic reads/writes - Main loop copies event data under lock, then processes outside the critical section -## Key Constants - -- WiFi channel hop: 1 second -- BLE scan interval: 5 seconds, 1 second duration -- Device tracking slots: 32 (LRU eviction) -- Device departure timeout: 60 seconds -- Heartbeat re-alert interval: 10 seconds -- Alert certainty threshold: 65% -- Serial baud rate: 115200 +## When Modifying Code + +| Task | Key files | Docs | +|------|-----------|------| +| Add/change detection patterns | `common/Detectors.h`, `common/DeviceSignatures.h` | [docs/extending.md](docs/extending.md) | +| Write a new detector | `common/Detectors.h`, `common/DetectorTypes.h`, `common/ThreatAnalyzer.h` | [docs/extending.md](docs/extending.md) | +| Understand scoring/alerts | `common/ThreatAnalyzer.h`, `common/DetectorTypes.h` | [docs/architecture.md](docs/architecture.md) | +| Change telemetry output | `common/TelemetryReporter.h` | [docs/telemetry-format.md](docs/telemetry-format.md) | +| Tune WiFi/BLE parameters | Variant's `src/RadioScanner.h` | [docs/configuration.md](docs/configuration.md) | +| Build/compile/upload | `Makefile`, `versions.env` | [docs/build-system.md](docs/build-system.md) | +| Run or add tests | `test/`, `Makefile` | [docs/testing.md](docs/testing.md) | +| Add a new variant | New directory + Makefile entry | [docs/extending.md](docs/extending.md) | diff --git a/Mini12864/flocksquawk_mini12864/README.md b/Mini12864/flocksquawk_mini12864/README.md index dbd6a2f..20655da 100644 --- a/Mini12864/flocksquawk_mini12864/README.md +++ b/Mini12864/flocksquawk_mini12864/README.md @@ -1,6 +1,6 @@ -# FlockSquawk +# FlockSquawk (Mini12864) -A modular, event-driven ESP32 project that passively detects surveillance devices using WiFi promiscuous mode and Bluetooth Low Energy scanning. Features audio alerts via I2S playback and JSON telemetry output. +Full-featured variant with ST7567 LCD display, rotary encoder, menu system, and I2S audio. Passively detects surveillance devices using WiFi promiscuous mode and Bluetooth Low Energy scanning. ## Features @@ -16,137 +16,65 @@ A modular, event-driven ESP32 project that passively detects surveillance device ## Hardware Requirements -### Required Components - - **ESP32 Development Board** (e.g., ESP32 DevKit, NodeMCU-32S) - **MAX98357A I2S Audio Amplifier Module** -- **Speaker** (4-8Ω, 3-5W recommended) +- **Speaker** (4-8 ohm, 3-5W recommended) - **Mini12864 128x64 Display** (ST7567) - **Rotary Encoder with Push Button** - **USB Cable** for programming and power - **Breadboard and jumper wires** (optional, for prototyping) -### Pin Connections +### I2S Audio Wiring | ESP32 Pin | MAX98357A Pin | Description | |-----------|---------------|-------------| -| GPIO 27 | BCLK | Bit Clock | -| GPIO 26 | LRC | Left/Right Clock (Word Select) | -| GPIO 25 | DIN | Data Input | -| 5V | VIN | Power (5V) | -| GND | GND | Ground | +| GPIO 27 | BCLK | Bit Clock | +| GPIO 26 | LRC | Left/Right Clock (Word Select) | +| GPIO 25 | DIN | Data Input | +| 5V | VIN | Power (5V) | +| GND | GND | Ground | + +**Speaker:** MAX98357A `OUT+` > Speaker (+), `OUT-` > Speaker (-) -### Display + Encoder Pins (Mini12864 + Encoder) +### Display + Encoder Pins | ESP32 Pin | Display Pin | Description | |-----------|-------------|-------------| -| GPIO 5 | CS | LCD chip select | -| GPIO 16 | RST | LCD reset | -| GPIO 17 | DC | LCD data/command | -| GPIO 23 | MOSI | LCD data | -| GPIO 18 | SCK | LCD clock | -| GPIO 19 | MISO | Not used by display | +| GPIO 5 | CS | LCD chip select | +| GPIO 16 | RST | LCD reset | +| GPIO 17 | DC | LCD data/command | +| GPIO 23 | MOSI | LCD data | +| GPIO 18 | SCK | LCD clock | | ESP32 Pin | Encoder Pin | Description | |-----------|-------------|-------------| -| GPIO 22 | A | Encoder A | -| GPIO 14 | B | Encoder B | -| GPIO 13 | SW | Encoder button | +| GPIO 22 | A | Encoder A | +| GPIO 14 | B | Encoder B | +| GPIO 13 | SW | Encoder button | ### Backlight / LED Ring - **WS2811/NeoPixel backlight (default)**: `GPIO 4` as data line - **PWM RGB backlight**: `GPIO 32/33/4` (see `Mini12864Display.cpp`) -**Speaker Connection:** -- MAX98357A `OUT+` → Speaker positive terminal -- MAX98357A `OUT-` → Speaker negative terminal - -### Wiring Diagram - -``` -ESP32 MAX98357A ------- --------- -GPIO 27 ───────────────> BCLK -GPIO 26 ───────────────> LRC -GPIO 25 ───────────────> DIN -5V ───────────────> VIN -GND ───────────────> GND - -MAX98357A ---------- -OUT+ ────> Speaker (+) -OUT- ────> Speaker (-) -``` - -## Software Setup - -### Prerequisites - -1. **Arduino IDE** (version 1.8.19 or later, or Arduino IDE 2.x) -2. **ESP32 Board Support** installed in Arduino IDE - -### Installing ESP32 Board Support +## Setup -1. Open Arduino IDE -2. Go to **File** → **Preferences** -3. In "Additional Boards Manager URLs", add: - ``` - https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json - ``` -4. Go to **Tools** → **Board** → **Boards Manager** -5. Search for "ESP32" and install "esp32 by Espressif Systems" -6. Select your ESP32 board: **Tools** → **Board** → **ESP32 Arduino** → **ESP32 Dev Module** (or your specific board) +For Arduino IDE installation and ESP32 board support, see [Getting Started](../../docs/getting-started.md). -**Important:** Use **esp32 by Espressif Systems** version **3.0.7 or older**. Newer versions fail to compile due to an **IRAM overflow** issue. +### Additional Libraries -### Required Libraries +Install via Arduino IDE Library Manager: -Install the following libraries via Arduino IDE Library Manager: +- **U8g2** by olikraus +- **Adafruit NeoPixel** by Adafruit (optional, only if using WS2811 backlight) -1. **ArduinoJson** by Benoit Blanchon (version 6.x or 7.x) - - **Tools** → **Manage Libraries** → Search "ArduinoJson" → Install - -2. **NimBLE-Arduino** by h2zero - - **Tools** → **Manage Libraries** → Search "NimBLE-Arduino" → Install - -3. **U8g2** by olikraus - - **Tools** → **Manage Libraries** → Search "U8g2" → Install - -4. **Adafruit NeoPixel** by Adafruit (optional, only if using WS2811 backlight) - - **Tools** → **Manage Libraries** → Search "Adafruit NeoPixel" → Install - -### Additional ESP32 Tools - -The following components are included with ESP32 board support: -- WiFi (built-in) -- LittleFS (built-in) -- I2S driver (built-in) - -## Installation from GitHub - -### Step 1: Clone or Download Repository - -```bash -git clone -cd FlockSquawk-main/Mini12864/flocksquawk_mini12864 -``` +### Audio Files -Or download as ZIP and extract. +The project requires three WAV audio files in the `data/` folder, uploaded to LittleFS: -### Step 2: Open Project in Arduino IDE - -1. Open Arduino IDE -2. Navigate to **File** → **Open** -3. Select `flocksquawk_mini12864.ino` from this folder - -### Step 3: Prepare Audio Files - -The project requires three WAV audio files in the `data` folder: - -- `/data/startup.wav` - Plays on boot -- `/data/ready.wav` - Plays when system is ready -- `/data/alert.wav` - Plays on threat detection +- `data/startup.wav` -- Plays on boot +- `data/ready.wav` -- Plays when system is ready +- `data/alert.wav` -- Plays on threat detection **Audio File Requirements:** - Format: 16-bit PCM WAV @@ -154,308 +82,126 @@ The project requires three WAV audio files in the `data` folder: - Channels: Mono (1 channel) - Header: Standard 44-byte WAV header -**Adding Audio Files:** - -1. Place your WAV files in the `data/` directory if you wish to change them: - ``` - flocksquawk_mini12864/ - ├── flocksquawk_mini12864.ino - └── data/ - ├── startup.wav - ├── ready.wav - └── alert.wav - ``` - -2. Install the **ESP32 LittleFS Filesystem Uploader** plugin: +**Upload via Arduino IDE:** +1. Install the **ESP32 LittleFS Filesystem Uploader** plugin: - Download from: https://github.com/lorol/arduino-esp32fs-plugin/releases - Extract to: `/tools/ESP32FS/tool/esp32fs.jar` - Restart Arduino IDE +2. Use Ctrl+Shift+P, type "upload", select the LittleFS upload option +3. Or use **Tools** > **ESP32 Sketch Data Upload** -3. Write audio files to ESP32 - - Within the arduino IDE, use Ctrl + Shift + P, and type "upload" - - Find the LittleFS upload option, and select - - If you get an error saying unable to connect to the serial port, make sure that all serial terminals and processes are not running - -4. Upload filesystem: - - **Tools** → **ESP32 Sketch Data Upload** - - Wait for upload to complete +**Upload via Makefile:** `make upload-data-mini12864` -### Step 4: Configure Board Settings +### Board Settings -1. Select your board: **Tools** → **Board** → **ESP32 Dev Module** -2. Set upload speed: **Tools** → **Upload Speed** → **115200** (or lower if upload fails) -3. Set CPU frequency: **Tools** → **CPU Frequency** → **240MHz (WiFi/BT)** -4. Set partition scheme: **Tools** → **Partition Scheme** → **Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)** or **Huge APP (3MB No OTA/1MB SPIFFS)** +1. Select board: **Tools** > **Board** > **ESP32 Dev Module** +2. Upload speed: **115200** +3. CPU frequency: **240MHz (WiFi/BT)** +4. Partition scheme: **Default 4MB with spiffs** or **Huge APP (3MB No OTA/1MB SPIFFS)** -### Step 5: Upload Code +### Upload -1. Connect ESP32 via USB -2. Select the correct port: **Tools** → **Port** → Select your ESP32 port -3. Click **Upload** button (or **Sketch** → **Upload**) -4. Wait for compilation and upload to complete +1. Connect via USB +2. Select port: **Tools** > **Port** +3. Click **Upload** -### Step 6: Monitor Serial Output +### Serial Monitor -1. Open Serial Monitor: **Tools** → **Serial Monitor** -2. Set baud rate to **115200** -3. Set line ending to **Newline** -4. You should see initialization messages and detection events +Open at **115200** baud. See [Telemetry Format](../../docs/telemetry-format.md) for the JSON schema. ## Usage -### Basic Operation - 1. Power on the ESP32 2. The system will: - Initialize filesystem and audio - - Play startup sound - - Show boot progress on the display + - Play startup sound, show boot progress on the display - Initialize WiFi sniffer and BLE scanner - Play ready sound - Begin scanning for targets -### Serial Output - -The system outputs JSON telemetry when threats are detected: - -```json -{ - "event": "target_detected", - "ms_since_boot": 15234, - "source": { - "radio": "wifi", - "channel": 6, - "rssi": -67 - }, - "target": { - "identity": { - "mac": "aa:bb:cc:dd:ee:ff", - "oui": "aa:bb:cc", - "label": "Network Name" - }, - "indicators": { - "ssid_match": true, - "mac_match": true, - "name_match": false, - "service_uuid_match": false - }, - "category": "surveillance_device", - "certainty": 95 - }, - "metadata": { - "frame_type": "beacon", - "detection_method": "combined_signature" - } -} -``` - ### Display + Controls -**Home screen** +**Home screen:** - Shows "Scanning for Flock signatures" with a radar sweep - Live channel indicator (`CH`) and volume (`Vol 0-10`) - Live MAC line: most recent WiFi frame and its channel - Radar dots appear for each captured WiFi frame; dot size scales with RSSI strength -**Encoder controls** -- Turn on Home screen to adjust volume (0.0–1.0 in steps of 0.1) +**Encoder controls:** +- Turn on Home screen to adjust volume (0.0-1.0 in steps of 0.1) - Press to open Menu -**Menu** -- `Backlight` → adjust Display RGB or LED Ring presets/custom RGB -- `Test Alert` (temporary) → plays alert and shows alert screen -- `Back` → returns to Home +**Menu:** +- `Backlight` -- adjust Display RGB or LED Ring presets/custom RGB +- `Test Alert` (temporary) -- plays alert and shows alert screen +- `Back` -- returns to Home -**Alert screen** -- Flashes “ALERT” with red backlight +**Alert screen:** +- Flashes "ALERT" with red backlight - Auto-exits after ~10 seconds - Press encoder button to dismiss early -### Audio Alerts - -- **Startup**: Plays when system boots -- **Ready**: Plays when scanning begins -- **Alert**: Plays when a threat is detected - ### Volume Control -Default volume is set to 40% (0.4). To adjust at runtime: - -- Use the encoder on the Home screen to set `Vol 0–10`. - -To change the default: - -1. Open `src/SoundEngine.h` -2. Change `DEFAULT_VOLUME` value: - ```cpp - static constexpr float DEFAULT_VOLUME = 0.4f; // 0.0 to 1.0 - ``` -3. Re-upload code - -## Configuration -### Startup Backlight Timing - -Edit `src/Mini12864Display.cpp`: -```cpp -static const uint32_t STARTUP_RED_MS = 1000; -static const uint32_t STARTUP_GREEN_MS = 1000; -static const uint32_t STARTUP_BLUE_MS = 1000; -static const uint32_t STARTUP_NEO_MS = 1000; -``` - -### Radar Dot Behavior - -Edit `src/Mini12864Display.cpp`: -```cpp -static const uint16_t RADAR_DOT_TTL_MS = 8000; -static const uint8_t RADAR_DOT_STEP = 3; -static const uint8_t RADAR_DOT_MAX = 40; -``` - - -### WiFi Channel Hopping - -Default: Channels 1-13, switching every 500ms +Default volume is 40% (0.4). Adjustable at runtime via the encoder, or edit `src/SoundEngine.h`: -To modify, edit `src/RadioScanner.h`: ```cpp -static const uint8_t MAX_WIFI_CHANNEL = 13; -static const uint16_t CHANNEL_SWITCH_MS = 500; +static constexpr float DEFAULT_VOLUME = 0.4f; // 0.0 to 1.0 ``` -### BLE Scan Interval - -Default: 1 second scan every 5 seconds - -To modify, edit `src/RadioScanner.h`: -```cpp -static const uint8_t BLE_SCAN_SECONDS = 1; -static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; -``` - -### Detection Patterns - -Detection patterns are defined in `src/DeviceSignatures.h`. Patterns include: -- Network SSID names -- MAC address prefixes (OUI) -- Bluetooth device names -- Service UUIDs (e.g., Raven acoustic detectors) - -## Troubleshooting +## Variant-Specific Troubleshooting ### Audio Not Playing 1. **Check wiring**: Verify all I2S connections -2. **Check filesystem**: Ensure audio files are uploaded via "ESP32 Sketch Data Upload" +2. **Check filesystem**: Ensure audio files are uploaded via LittleFS uploader 3. **Check volume**: Try increasing `DEFAULT_VOLUME` in `SoundEngine.h` 4. **Check speaker**: Test speaker with another device 5. **Check serial output**: Look for audio file open errors -### No Detections - -1. **Check serial output**: Verify system initialized correctly -2. **Test with known device**: Use a smartphone with WiFi hotspot named "Flock" -3. **Check channel**: WiFi channel hopping may miss brief transmissions -4. **Verify patterns**: Check `DeviceSignatures.h` matches your target devices - -### Compilation Errors - -1. **Missing libraries**: Install ArduinoJson and NimBLE-Arduino -2. **Wrong board**: Select correct ESP32 board variant -3. **ESP32 core too new**: Install version **3.0.7 or older** (newer versions hit IRAM overflow) -4. **File structure**: Ensure all `.h` files are in `src/` directory - -### Upload Failures +### Filesystem Upload Fails -1. **Hold BOOT button**: Hold BOOT button while clicking Upload, release after upload starts -2. **Lower upload speed**: Change to 115200 or 9600 baud -3. **Check USB cable**: Use a data cable, not charge-only -4. **Driver issues**: Install ESP32 USB drivers (CP210x or CH340) +1. **Check partition scheme**: Use partition scheme with SPIFFS/LittleFS +2. **Check file sizes**: Ensure total data fits in filesystem partition +3. **Close serial monitors**: Uploader needs exclusive port access +4. **Restart Arduino IDE** and retry -### Filesystem Upload Fails +### Display Not Working -1. **Check partition scheme**: Use partition scheme with SPIFFS or LittleFS -2. **Check file sizes**: Ensure total data size fits in filesystem partition -3. **Restart IDE**: Close and reopen Arduino IDE -4. **Manual upload**: Use esptool.py or other tools to upload filesystem +1. Verify SPI wiring (CS, RST, DC, MOSI, SCK) +2. Check that the correct constructor is used in `Mini12864Display.h` for your display variant ## Project Structure ``` flocksquawk_mini12864/ ├── flocksquawk_mini12864.ino # Main orchestrator -├── README.md # This file +├── README.md ├── src/ -│ ├── EventBus.h # Event system interface -│ ├── DeviceSignatures.h # Detection patterns -│ ├── RadioScanner.h # RF scanning interface -│ ├── ThreatAnalyzer.h # Detection engine interface -│ ├── SoundEngine.h # Audio playback interface -│ ├── Mini12864Display.h # Display and menu interface -│ └── Mini12864Display.cpp # Display implementation -│ └── TelemetryReporter.h # JSON reporting interface +│ ├── Mini12864Display.h # Display and menu interface +│ ├── Mini12864Display.cpp # Display implementation +│ ├── RadioScanner.h # Variant-specific RF scanning +│ └── SoundEngine.h # I2S audio playback └── data/ - ├── startup.wav # Boot sound - ├── ready.wav # Ready sound - └── alert.wav # Alert sound -``` - -## Architecture - -The system uses an event-driven architecture: - -``` -RadioScannerManager → WiFi/Bluetooth Events → EventBus - ↓ - ThreatAnalyzer - ↓ - Threat Events - ↓ - ┌──────────────────────────┴──────────────────┐ - ↓ ↓ - TelemetryReporter SoundEngine - ↓ ↓ - JSON Output Audio Alert -``` - -## Extending the System - -### Adding New Detection Patterns - -Edit `src/DeviceSignatures.h`: -```cpp -const char* const NetworkNames[] = { - "flock", - "YourNewPattern", // Add here - // ... -}; + ├── startup.wav + ├── ready.wav + └── alert.wav ``` -### Adding Display Support +Shared headers (`EventBus.h`, `ThreatAnalyzer.h`, `Detectors.h`, etc.) are in [`common/`](../../common/). -Subscribe to `ThreatHandler` in `setup()`: -```cpp -EventBus::subscribeThreat([](const ThreatEvent& event) { - display.showThreat(event); // Your display code -}); -``` +## Further Reading -### Adding LED Indicators - -Subscribe to events and control GPIO: -```cpp -EventBus::subscribeThreat([](const ThreatEvent& event) { - digitalWrite(LED_PIN, HIGH); - delay(500); - digitalWrite(LED_PIN, LOW); -}); -``` +- [Configuration](../../docs/configuration.md) -- WiFi/BLE tuning, detection patterns, display settings +- [Architecture](../../docs/architecture.md) -- pipeline, detectors, thread safety +- [Extending](../../docs/extending.md) -- adding detectors, patterns, new variants +- [Build System](../../docs/build-system.md) -- Makefile and Docker builds +- [Troubleshooting](../../docs/troubleshooting.md) -- common issues ## License [GNU GENERAL PUBLIC LICENSE](https://github.com/f1yaw4y/FlockSquawk/blob/main/LICENSE) - ## Acknowledgments - Inspired by [flock-you](https://github.com/colonelpanichacks/flock-you) diff --git a/README.md b/README.md index 6fab0dd..55dcd8b 100644 --- a/README.md +++ b/README.md @@ -33,31 +33,37 @@ At runtime, FlockSquawk: Use **esp32 by Espressif Systems** version **3.0.7 or older**. Newer versions fail to compile due to an **IRAM overflow** issue. - --- -## Display Variants +## Hardware Variants FlockSquawk supports multiple hardware front-ends. Each variant lives in its own folder and includes its **own dedicated README with wiring and setup instructions**. -Current variants: - -- **Mini12864 (ST7567 LCD + Encoder)** - A full-featured UI with menus, rotary encoder input, RGB backlight support, and a rich visual interface. +| Variant | Path | Display | Audio | +|---------|------|---------|-------| +| [M5StickC Plus2](m5stack/flocksquawk_m5stick/README.md) | `m5stack/flocksquawk_m5stick/` | Built-in TFT | Buzzer tones | +| [M5Stack FIRE](m5stack/flocksquawk_m5fire/README.md) | `m5stack/flocksquawk_m5fire/` | Built-in TFT | Built-in speaker | +| [Mini12864](Mini12864/flocksquawk_mini12864/README.md) | `Mini12864/flocksquawk_mini12864/` | ST7567 LCD 128x64 | I2S (MAX98357A) | +| [128x32 OLED](128x32_OLED/flocksquawk_128x32/README.md) | `128x32_OLED/flocksquawk_128x32/` | SSD1306/SH1106 I2C | I2S (MAX98357A) | +| [128x32 Portable](128x32_OLED/flocksquawk_128x32_portable/README.md) | `128x32_OLED/flocksquawk_128x32_portable/` | SSD1306/SH1106 I2C | GPIO buzzer | +| [Flipper Zero](flipper-zero/README.md) | `flipper-zero/dev-board-firmware/` | None (UART) | None | -- **128x32 I2C OLED (SSD1306/SH1106)** - Compact display option with I2C wiring. Includes a portable buzzer-only build. - -- **M5Stack Fire** - Uses the built-in speaker and SD card storage on the M5Stack FIRE. +Each variant is self-contained and can be opened directly in the Arduino IDE. -- **M5StickC Plus2** - Compact handheld variant with buzzer and built-in display. +--- -- **Flipper Zero WiFi Dev Board (ESP32-S2)** - ESP32-S2 firmware that streams UART telemetry to a Flipper Zero app (WiFi only, no BLE). +## Documentation -Each variant is self-contained and can be opened directly in the Arduino IDE. +| Guide | Description | +|-------|-------------| +| [Getting Started](docs/getting-started.md) | Arduino IDE setup, ESP32 board support, shared libraries | +| [Architecture](docs/architecture.md) | Pipeline, event types, detector system, thread safety | +| [Build System](docs/build-system.md) | Makefile, Docker, arduino-cli, versions.env | +| [Configuration](docs/configuration.md) | WiFi/BLE tuning, detection patterns, audio/volume | +| [Telemetry Format](docs/telemetry-format.md) | JSON schema reference, Flipper UART protocol | +| [Testing](docs/testing.md) | Host-side doctest tests, mocks, running tests | +| [Extending](docs/extending.md) | Adding detectors, patterns, subscribers, new variants | +| [Troubleshooting](docs/troubleshooting.md) | Common issues: compilation, upload, no detections | --- @@ -65,43 +71,27 @@ Each variant is self-contained and can be opened directly in the Arduino IDE. ``` FlockSquawk/ +├── common/ # Shared headers (EventBus, Detectors, ThreatAnalyzer, ...) +├── docs/ # Project-wide documentation +├── m5stack/ +│ ├── flocksquawk_m5stick/ # M5StickC Plus2 variant +│ └── flocksquawk_m5fire/ # M5Stack FIRE variant ├── Mini12864/ -│ └── flocksquawk_mini12864/ -│ ├── flocksquawk_mini12864.ino -│ ├── src/ -│ ├── data/ -│ └── README.md +│ └── flocksquawk_mini12864/ # Mini12864 LCD variant ├── 128x32_OLED/ -│ ├── flocksquawk_128x32/ -│ │ ├── flocksquawk_128x32.ino -│ │ ├── src/ -│ │ ├── data/ -│ │ └── README.md -│ └── flocksquawk_128x32_portable/ -│ ├── flocksquawk_128x32_portable.ino -│ ├── src/ -│ └── README.md -├── m5stack/ -│ ├── flocksquawk_m5fire/ -│ │ ├── flocksquawk_m5fire.ino -│ │ ├── src/ -│ │ ├── data/ -│ │ └── README.md -│ └── flocksquawk_m5stick/ -│ ├── flocksquawk_m5stick.ino -│ ├── src/ -│ └── README.md +│ ├── flocksquawk_128x32/ # 128x32 OLED + I2S audio variant +│ └── flocksquawk_128x32_portable/ # 128x32 OLED + buzzer variant ├── flipper-zero/ -│ ├── dev-board-firmware/ -│ │ ├── flocksquawk-flipper/ -│ │ │ └── flocksquawk-flipper.ino -│ │ └── src/ -│ │ └── ... -│ └── README.md -└── README.md ← you are here (project overview) +│ ├── dev-board-firmware/ # Flipper Zero ESP32-S2 firmware +│ └── flock_scanner.fap # Flipper Zero companion app +├── test/ # Host-side unit tests +├── Makefile # Build automation +├── Dockerfile # Reproducible build environment +├── versions.env # Pinned dependency versions +└── README.md # This file ``` -If you are trying to build the project, start by entering one of the variant folders and follow that README. +If you are trying to build the project, start by entering one of the variant folders and follow that README, or see [Getting Started](docs/getting-started.md). --- @@ -109,23 +99,13 @@ If you are trying to build the project, start by entering one of the variant fol All variants share the same core subsystems: -- **RadioScannerManager** - Handles WiFi promiscuous mode and BLE scanning - -- **ThreatAnalyzer** - Compares observed data against signature patterns - -- **EventBus** - Lightweight publish/subscribe system connecting components - -- **SoundEngine** - I2S-based WAV playback using LittleFS - -- **TelemetryReporter** - Emits structured JSON output over Serial - -Because of this structure, new interfaces can be added cleanly: displays, LEDs, network reporting, logging, etc. +- **RadioScannerManager** -- Handles WiFi promiscuous mode and BLE scanning +- **ThreatAnalyzer** -- Compares observed data against signature patterns +- **EventBus** -- Lightweight publish/subscribe system connecting components +- **SoundEngine** -- I2S-based WAV playback or buzzer tones (variant-specific) +- **TelemetryReporter** -- Emits structured JSON output over Serial +Because of this structure, new interfaces can be added cleanly: displays, LEDs, network reporting, logging, etc. See [Architecture](docs/architecture.md) for details. --- diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..99083d2 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,110 @@ +# Architecture + +## Pipeline + +Data flows through a publish-subscribe pipeline: + +``` +RadioScannerManager (WiFi promiscuous + BLE scan) + -> EventBus (WiFiFrameEvent / BluetoothDeviceEvent) + -> ThreatAnalyzer (signature matching -> ThreatEvent) + -> TelemetryReporter (JSON over Serial) + -> Display (variant-specific UI) + -> SoundEngine (alerts) +``` + +## Event Types + +Defined in `common/EventBus.h`: + +| Event | Published when | Consumed by | +|-------|---------------|-------------| +| `WifiFrameCaptured` | WiFi promiscuous callback fires | ThreatAnalyzer | +| `BluetoothDeviceFound` | BLE scan finds an advertiser | ThreatAnalyzer | +| `ThreatIdentified` | Signature match above threshold | TelemetryReporter, Display, SoundEngine | +| `SystemReady` | All subsystems initialized | Display, SoundEngine | +| `AudioPlaybackRequested` | Alert or status sound needed | SoundEngine | + +## Core Subsystems + +### EventBus (`common/EventBus.h`) + +Header-only static publish/subscribe bus. Single handler per event type. Handlers are `std::function` callbacks registered via `subscribe*()` methods and invoked by `publish*()` methods. + +### RadioScanner (variant `src/RadioScanner.h`) + +WiFi promiscuous mode scanning (channels 1-13) and BLE scanning via NimBLE. Each variant has its own RadioScanner since hardware initialization differs. Uses FreeRTOS `portMUX_TYPE` spinlocks for thread safety between ISR callbacks and the main loop. + +### ThreatAnalyzer (`common/ThreatAnalyzer.h`) + +Runs detectors against observations and manages device presence tracking. + +- **DeviceTracker**: Tracks up to 32 devices with LRU eviction. States: `EMPTY` -> `NEW_DETECT` -> `IN_RANGE` -> `DEPARTED`. Departure timeout: 60 seconds. +- **Detector scoring**: Runs all registered detectors, sums weights, applies subsumption rules and RSSI modifier, clamps to 0-100. +- **Alert logic**: `shouldAlert` is true only on first detection of a device above the 65% certainty threshold. Heartbeat re-alerts every 10 seconds while a high-confidence device remains in range. + +### TelemetryReporter (`common/TelemetryReporter.h`) + +Serializes `ThreatEvent` as JSON via ArduinoJson (`StaticJsonDocument<512>`). See [Telemetry Format](telemetry-format.md) for the schema. + +### DeviceSignatures (`common/DeviceSignatures.h`) + +Static arrays of known MAC OUI prefixes. SSID patterns and BLE identifiers are matched by detector functions in `Detectors.h` rather than by static lookup tables. + +### SoundEngine (variant `src/SoundEngine.h`) + +Variant-specific audio output: +- **Mini12864 / 128x32 OLED**: I2S WAV playback from LittleFS (MAX98357A amplifier) +- **M5Stack variants**: M5.Speaker tone generation or SD card WAV playback +- **128x32 Portable**: GPIO buzzer tones +- **Flipper Zero**: No audio (UART-only) + +## Pluggable Detector System + +Defined in `common/DetectorTypes.h` and `common/Detectors.h`. + +Each detector is a plain function that takes a frame/device event and returns a `DetectorResult` (matched, weight, name). Detectors are registered in static arrays in `common/ThreatAnalyzer.h`. + +### WiFi Detectors + +| Function | Flag | Weight | What it matches | +|----------|------|--------|----------------| +| `detectSsidFormat` | `DET_SSID_FORMAT` | 75 | Exact patterns: `Flock-XXXXXX` (hex), `Penguin-XXXXXXXXXX` (decimal), `FS Ext Battery` | +| `detectSsidKeyword` | `DET_SSID_KEYWORD` | 45 | Case-insensitive substring: "flock", "penguin", "pigvision" | +| `detectWifiMacOui` | `DET_MAC_OUI` | 20 | MAC OUI prefix in `DeviceSignatures.h` | + +### BLE Detectors + +| Function | Flag | Weight | What it matches | +|----------|------|--------|----------------| +| `detectBleName` | `DET_BLE_NAME` | 55 | Case-insensitive substring in device name | +| `detectRavenCustomUuid` | `DET_RAVEN_CUSTOM_UUID` | 80 | UUID prefix `00003X00` where X is 1-5 | +| `detectRavenStdUuid` | `DET_RAVEN_STD_UUID` | 10 | Standard BLE SIG UUIDs (0x180A, 0x1809, 0x1819) | +| `detectBleMacOui` | `DET_MAC_OUI` | 20 | MAC OUI prefix in `DeviceSignatures.h` | + +### RSSI Modifier + +Applied to all detections after detector scoring: + +| RSSI range | Modifier | +|-----------|----------| +| > -50 dBm (very close) | +10 | +| -50 to -70 dBm | 0 | +| -70 to -85 dBm | -5 | +| < -85 dBm (very weak) | -10 | + +### Subsumption + +When both `DET_SSID_FORMAT` and `DET_SSID_KEYWORD` match the same frame, the keyword weight is removed (format is more specific and already includes the keyword signal). + +## Thread Safety + +WiFi promiscuous callbacks and BLE scan callbacks run on different cores/tasks than `loop()`. All variants use the same pattern: + +- `portMUX_TYPE` spinlocks guard shared volatile state in ISR callbacks +- `taskENTER_CRITICAL` / `taskEXIT_CRITICAL` for atomic reads/writes +- Main loop copies event data under lock, then processes outside the critical section + +## Source Layout + +Shared headers live in `common/` and are included at compile time via `-I common` (set in the Makefile). Each variant also has its own `src/` directory for hardware-specific code (RadioScanner, SoundEngine, Display). Some variants still have local copies of shared headers in `src/` from before the `common/` migration. diff --git a/docs/build-system.md b/docs/build-system.md new file mode 100644 index 0000000..efd10a8 --- /dev/null +++ b/docs/build-system.md @@ -0,0 +1,152 @@ +# Build System + +FlockSquawk is a pure Arduino project -- each variant is a self-contained sketch that opens directly in the Arduino IDE. For automated and reproducible builds, the project also provides a Makefile and Docker environment. + +## Quick Reference + +| Tool | Purpose | +|------|---------| +| Arduino IDE | Manual build and upload (open the `.ino` file) | +| `Makefile` | Automated compile, upload, test, LittleFS flashing | +| Docker | Reproducible builds with all dependencies pre-baked | +| `versions.env` | Single source of truth for all dependency versions | + +## Dependency Versions + +All pinned versions are centralized in `versions.env`: + +| Dependency | Variable | Purpose | +|-----------|----------|---------| +| ESP32 board core | `ESP32_CORE_VERSION` | Must be 3.0.7 or older (IRAM overflow) | +| ArduinoJson | `ARDUINOJSON_VERSION` | JSON serialization | +| NimBLE-Arduino | `NIMBLE_VERSION` | BLE scanning | +| M5Unified | `M5UNIFIED_VERSION` | M5Stack hardware abstraction | +| U8g2 | `U8G2_VERSION` | Mini12864 display driver | +| Adafruit GFX + SSD1306 | `ADAFRUIT_GFX_VERSION`, `ADAFRUIT_SSD1306_VERSION` | 128x32 OLED display | +| doctest | `DOCTEST_VERSION` | Host-side unit test framework | + +## Makefile + +### Variants + +Six build targets, one per hardware variant: + +| Name | FQBN | Sketch Path | +|------|------|-------------| +| `m5stick` | `esp32:esp32:m5stack_stickc_plus2` | `m5stack/flocksquawk_m5stick` | +| `m5fire` | `esp32:esp32:m5stack_fire` | `m5stack/flocksquawk_m5fire` | +| `mini12864` | `esp32:esp32:esp32` | `Mini12864/flocksquawk_mini12864` | +| `oled` | `esp32:esp32:esp32` | `128x32_OLED/flocksquawk_128x32` | +| `portable` | `esp32:esp32:esp32` | `128x32_OLED/flocksquawk_128x32_portable` | +| `flipper` | `esp32:esp32:esp32s2` | `flipper-zero/dev-board-firmware/flocksquawk-flipper` | + +### Common Commands + +```bash +# Install all dependencies (ESP32 core + libraries) +make install-deps + +# Compile a specific variant +make build-m5stick +make build-oled + +# Compile all variants +make all + +# Compile + upload (auto-detects serial port) +make flash-m5stick +make flash-oled PORT=/dev/cu.usbserial-0001 + +# Upload LittleFS audio files (variants with data/ directory) +make upload-data-mini12864 PORT=/dev/ttyUSB0 + +# Open serial monitor +make monitor + +# Run host-side unit tests +make test +make test-verbose + +# Clean build artifacts +make clean +``` + +### Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | auto-detected | Serial port for upload/monitor | +| `BAUD` | `115200` | Serial monitor baud rate | +| `VARIANT` | `m5stick` | Default variant for shorthand targets | +| `CORE_VERSION` | from `versions.env` | ESP32 core version | + +### Shorthand Targets + +`make build`, `make upload`, `make flash`, and `make monitor` operate on the variant set by `VARIANT` (default: `m5stick`). + +## Docker + +A Docker image with all toolchains and libraries pre-baked, for zero-install CI or local builds. + +### Building the Image + +```bash +make docker-build-image +``` + +This reads versions from `versions.env` and installs the ESP32 core, all Arduino libraries, and warms up the per-board core cache. + +### Using Docker + +```bash +# Compile all variants +make docker-build-all + +# Compile a specific variant +VARIANT=oled make docker-build + +# Run tests +make docker-test +make docker-test-verbose + +# Interactive shell +make docker-shell +``` + +### docker-compose + +The `docker-compose.yml` provides named services: + +```bash +docker compose run build-all # make all +docker compose run test # make test +docker compose run shell # interactive bash +VARIANT=mini12864 docker compose run build-variant # single variant +``` + +## arduino-cli (without Makefile) + +If you prefer direct arduino-cli commands: + +```bash +# Install ESP32 core +arduino-cli core install esp32:esp32@3.0.7 + +# Install libraries +arduino-cli lib install ArduinoJson NimBLE-Arduino M5Unified U8g2 \ + "Adafruit GFX Library" "Adafruit SSD1306" + +# Compile +arduino-cli compile --fqbn esp32:esp32:m5stack_stickc_plus2 \ + --build-property "build.defines=-I$(pwd)/common" \ + m5stack/flocksquawk_m5stick/ + +# Upload +arduino-cli upload --fqbn esp32:esp32:m5stack_stickc_plus2 \ + --port /dev/ttyUSB0 m5stack/flocksquawk_m5stick/ + +# Serial monitor +arduino-cli monitor --port /dev/ttyUSB0 --config baudrate=115200 +``` + +Note the `--build-property "build.defines=-I$(pwd)/common"` flag -- this is how shared headers in `common/` are made available to variant sketches. The Makefile handles this automatically. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..c5da212 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,83 @@ +# Configuration + +Runtime behavior is controlled by constants in each variant's `src/` headers. After changing a value, recompile and re-upload. + +## WiFi Channel Hopping + +Edit `src/RadioScanner.h` in your variant: + +```cpp +static const uint8_t MAX_WIFI_CHANNEL = 13; +static const uint16_t CHANNEL_SWITCH_MS = 500; +``` + +- `MAX_WIFI_CHANNEL` -- highest channel to scan (1-13, or 1-11 for US-only) +- `CHANNEL_SWITCH_MS` -- dwell time per channel in milliseconds (500 in most variants, 1000 in M5Stick) + +## BLE Scan Interval + +Edit `src/RadioScanner.h` in your variant: + +```cpp +static const uint8_t BLE_SCAN_SECONDS = 1; +static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; +``` + +- `BLE_SCAN_SECONDS` -- active scan duration per cycle +- `BLE_SCAN_INTERVAL_MS` -- time between scan cycles + +Not applicable to the Flipper Zero variant (ESP32-S2 has no BLE). + +## Detection Patterns + +Detection logic is implemented in `common/Detectors.h`. To add or modify what the system detects: + +### MAC OUI Prefixes + +Edit `common/DeviceSignatures.h`: + +```cpp +const char* const MACPrefixes[] = { + "58:8e:81", "cc:cc:cc", + "your:ne:wp", // add here +}; +``` + +### SSID Patterns + +Modify the detector functions in `common/Detectors.h`: + +- **Exact format patterns** (`detectSsidFormat`) -- add new prefix+suffix rules +- **Keyword substrings** (`detectSsidKeyword`) -- add entries to the `keywords[]` array +- **BLE device names** (`detectBleName`) -- add entries to the `names[]` array + +See [Architecture: Pluggable Detector System](architecture.md#pluggable-detector-system) for how detectors work. + +## Audio Volume + +Variants with I2S audio or M5Stack speaker can adjust volume in `src/SoundEngine.h`: + +```cpp +static constexpr float DEFAULT_VOLUME = 0.4f; // 0.0 to 1.0 +``` + +The Mini12864 variant also supports runtime volume adjustment via the rotary encoder. + +## Display-Specific Settings + +### Mini12864 + +Edit `src/Mini12864Display.cpp`: + +```cpp +// Startup backlight color timing +static const uint32_t STARTUP_RED_MS = 1000; +static const uint32_t STARTUP_GREEN_MS = 1000; +static const uint32_t STARTUP_BLUE_MS = 1000; +static const uint32_t STARTUP_NEO_MS = 1000; + +// Radar visualization +static const uint16_t RADAR_DOT_TTL_MS = 8000; +static const uint8_t RADAR_DOT_STEP = 3; +static const uint8_t RADAR_DOT_MAX = 40; +``` diff --git a/docs/extending.md b/docs/extending.md new file mode 100644 index 0000000..88d303e --- /dev/null +++ b/docs/extending.md @@ -0,0 +1,123 @@ +# Extending FlockSquawk + +## Adding Detection Patterns + +### New MAC OUI Prefixes + +Edit `common/DeviceSignatures.h` and add entries to the `MACPrefixes` array: + +```cpp +const char* const MACPrefixes[] = { + "58:8e:81", "cc:cc:cc", + "aa:bb:cc", // your new OUI +}; +``` + +The `MACPrefixCount` is computed automatically from the array size. + +### New SSID Keywords + +Edit `common/Detectors.h`, find the `detectSsidKeyword` function, and add to the `keywords[]` array: + +```cpp +static const char* const keywords[] = { + "flock", "penguin", "pigvision", + "yournewkeyword", // add here +}; +``` + +### New SSID Format Patterns + +Add a new branch in `detectSsidFormat()` in `common/Detectors.h`: + +```cpp +if (len == expectedLen && strncmp(ssid, "Prefix-", 7) == 0 && + isHexSuffix(ssid + 7, expectedLen - 7)) { + r.matched = true; + return r; +} +``` + +### New BLE Device Names + +Edit `detectBleName()` in `common/Detectors.h` and add to the `names[]` array. + +## Writing a New Detector + +Detectors are plain functions registered in `common/ThreatAnalyzer.h`. To add a new one: + +1. **Define the function** in `common/Detectors.h`: + +```cpp +inline DetectorResult detectMyNewSignal(const WiFiFrameEvent& frame) { + DetectorResult r = { false, 50, "my_new_signal" }; + // your matching logic here + if (/* match condition */) r.matched = true; + return r; +} +``` + +2. **Add a flag bit** in `common/DetectorTypes.h`: + +```cpp +DET_MY_NEW_SIGNAL = (1 << 7), // next available bit +``` + +3. **Register it** in the `wifiDetectors[]` or `bleDetectors[]` array in `common/ThreatAnalyzer.h`: + +```cpp +static const WiFiDetectorEntry wifiDetectors[] = { + { detectSsidFormat, DET_SSID_FORMAT }, + { detectSsidKeyword, DET_SSID_KEYWORD }, + { detectWifiMacOui, DET_MAC_OUI }, + { detectMyNewSignal, DET_MY_NEW_SIGNAL }, // add here +}; +``` + +4. **Update TelemetryReporter** if you want the new detector name in JSON output. Add the name string to the `detectorNames[]` array in `common/TelemetryReporter.h` at the bit position matching your flag. + +## Adding New EventBus Subscribers + +Subscribe to events in `setup()` of the variant's `.ino` file: + +```cpp +// React to threat detections +EventBus::subscribeThreat([](const ThreatEvent& event) { + // your code here -- update display, toggle GPIO, send network message, etc. +}); + +// React to raw WiFi frames +EventBus::subscribeWifiFrame([](const WiFiFrameEvent& frame) { + // your code here +}); + +// React to BLE advertisements +EventBus::subscribeBluetoothDevice([](const BluetoothDeviceEvent& device) { + // your code here +}); +``` + +## Adding a New Hardware Variant + +1. Create a new directory following the existing convention (e.g., `myboard/flocksquawk_myboard/`) +2. Create the main `.ino` sketch file +3. Add a `src/` subdirectory with at minimum: + - `RadioScanner.h` -- hardware-specific WiFi/BLE initialization + - `SoundEngine.h` -- audio output (or stub if none) + - Any display driver code +4. Shared headers (`EventBus.h`, `ThreatAnalyzer.h`, `Detectors.h`, etc.) are included from `common/` via the Makefile's `-I common` flag. If building via the Arduino IDE without the Makefile, copy the shared headers into your `src/` directory. +5. Add your variant to the Makefile: + ```makefile + myboard_FQBN := esp32:esp32:your_fqbn + myboard_SKETCH := myboard/flocksquawk_myboard + myboard_DATA := # set to 1 if your variant has a data/ directory + ``` + And add `myboard` to the `VARIANTS` list. + +## Syncing Changes Across Variants + +Shared logic lives in `common/`. Changes to files there apply to all variants at build time (when using the Makefile). Some variants still have local copies of shared headers in their `src/` directories from before the `common/` migration. When modifying shared logic: + +1. Edit the file in `common/` +2. Check if any variant has a local override in its `src/` -- if so, either update it too or remove it so the variant picks up the shared version +3. Run `make all` or `make test` to verify nothing broke diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..2c0ec12 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,61 @@ +# Getting Started + +Common setup steps for all FlockSquawk variants. After completing these steps, return to your variant's README for board-specific configuration and upload instructions. + +## Prerequisites + +1. **Arduino IDE** (version 1.8.19 or later, or Arduino IDE 2.x) +2. **ESP32 Board Support** installed in Arduino IDE + +## Installing ESP32 Board Support + +1. Open Arduino IDE +2. Go to **File** > **Preferences** +3. In "Additional Boards Manager URLs", add: + ``` + https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json + ``` +4. Go to **Tools** > **Board** > **Boards Manager** +5. Search for "ESP32" and install **esp32 by Espressif Systems** + +**Important:** Use version **3.0.7 or older**. Newer versions fail to compile due to an IRAM overflow issue. + +## Shared Libraries + +All variants require these libraries (install via **Tools** > **Manage Libraries**): + +| Library | Author | Notes | +|---------|--------|-------| +| ArduinoJson | Benoit Blanchon | Version 6.x or 7.x | +| NimBLE-Arduino | h2zero | Not needed for Flipper Zero (ESP32-S2, no BLE) | + +Individual variants require additional libraries -- see the variant README for the complete list. + +## Clone and Open + +```bash +git clone +cd FlockSquawk +``` + +Each variant is a self-contained Arduino sketch. Open the `.ino` file for your variant directly in the Arduino IDE: + +| Variant | Open this file | +|---------|---------------| +| M5StickC Plus2 | `m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino` | +| M5Stack FIRE | `m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino` | +| Mini12864 | `Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino` | +| 128x32 OLED | `128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino` | +| 128x32 Portable | `128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino` | +| Flipper Zero | `flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino` | + +## Next Steps + +Return to your variant's README for: +- Board selection and FQBN settings +- Variant-specific library requirements +- Pin wiring (if applicable) +- Audio file preparation (if applicable) +- Upload and serial monitor instructions + +See also: [Build System](build-system.md) for Makefile and Docker-based builds. diff --git a/docs/telemetry-format.md b/docs/telemetry-format.md new file mode 100644 index 0000000..061016d --- /dev/null +++ b/docs/telemetry-format.md @@ -0,0 +1,94 @@ +# Telemetry Format + +FlockSquawk outputs structured event data over Serial at 115200 baud. The format depends on the variant. + +## JSON Telemetry (all variants except Flipper Zero) + +Each detection produces a single JSON object followed by a newline. Generated by `common/TelemetryReporter.h`. + +### Schema + +```json +{ + "event": "target_detected", + "ms_since_boot": 15234, + "source": { + "radio": "wifi", + "channel": 6, + "rssi": -67 + }, + "target": { + "mac": "58:8e:81:11:22:33", + "label": "Flock-a1b2c3", + "certainty": 75, + "category": "surveillance_device", + "should_alert": true, + "detectors": { + "ssid_format": 75, + "rssi_modifier": 0 + } + } +} +``` + +### Field Reference + +| Field | Type | Description | +|-------|------|-------------| +| `event` | string | Always `"target_detected"` | +| `ms_since_boot` | integer | Milliseconds since `TelemetryReporter::initialize()` | +| `source.radio` | string | `"wifi"` or `"bluetooth"` | +| `source.channel` | integer | WiFi channel (0 for BLE) | +| `source.rssi` | integer | Received signal strength in dBm | +| `target.mac` | string | Device MAC address (`xx:xx:xx:xx:xx:xx`) | +| `target.label` | string | SSID (WiFi) or device name (BLE) | +| `target.certainty` | integer | 0-100 confidence score | +| `target.category` | string | `"surveillance_device"` or `"acoustic_detector"` | +| `target.should_alert` | boolean | `true` on first detection above threshold | +| `target.detectors` | object | Which detectors fired and their weights | + +### Detector Keys + +The `detectors` object only includes detectors that matched. Possible keys: + +| Key | Meaning | Typical weight | +|-----|---------|---------------| +| `ssid_format` | Exact SSID pattern match | 75 | +| `ssid_keyword` | SSID keyword substring match | 45 | +| `mac_oui` | MAC OUI prefix match | 20 | +| `ble_name` | BLE device name match | 55 | +| `raven_custom_uuid` | Raven custom UUID match | 80 | +| `raven_std_uuid` | Raven standard UUID match | 10 | +| `rssi_modifier` | RSSI proximity adjustment | -10 to +10 | + +### Categories + +- `surveillance_device` -- default for WiFi and BLE name/OUI matches +- `acoustic_detector` -- assigned when Raven UUID detectors fire + +## Line-Based UART Protocol (Flipper Zero) + +The Flipper Zero variant outputs line-based, newline-terminated messages instead of JSON. These are consumed by the companion Flipper app (`flock_scanner.fap`). + +### Message Types + +``` +STATUS,SCANNING +STATUS,BLE_UNSUPPORTED +SEEN,RSSI=-62,MAC=AA:BB:CC:DD:EE:FF,CH=6 +ALERT,RSSI=-62,MAC=AA:BB:CC:DD:EE:FF,RADIO=wifi,CH=6,ID=Flock,CERTAINTY=95 +CLEAR +``` + +| Prefix | Purpose | +|--------|---------| +| `STATUS` | System state updates | +| `SEEN` | Raw frame observation (no threat match) | +| `ALERT` | Threat detection with identity and confidence | +| `CLEAR` | No active threats | + +### Flipper Notes + +- ESP32-S2 has no BLE, so `STATUS,BLE_UNSUPPORTED` is always emitted +- Baud rate: 115200 +- UART pins: TX=GPIO43, RX=GPIO44 (wired on the official WiFi Dev Board) diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..0409469 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,109 @@ +# Testing + +FlockSquawk includes host-side unit tests that run on your development machine (no ESP32 hardware needed). Tests use the [doctest](https://github.com/doctest/doctest) framework. + +## Running Tests + +### Via Makefile + +```bash +make test # compile and run +make test-verbose # run with per-assertion output +``` + +### Via Docker + +```bash +make docker-test +make docker-test-verbose +``` + +### Manual + +```bash +clang++ -std=c++17 -Wall -Wextra -g -O0 \ + -isystem test/mocks -I common -I test \ + test/test_main.cpp test/eventbus_impl.cpp \ + test/test_detectors.cpp test/test_device_tracker.cpp \ + test/test_threat_analyzer.cpp \ + -o .build/test_runner +.build/test_runner +``` + +## Test Structure + +``` +test/ +├── test_main.cpp # doctest entry point (DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN) +├── eventbus_impl.cpp # EventBus static member storage + mock millis() +├── test_detectors.cpp # Tests for all detector functions +├── test_device_tracker.cpp # DeviceTracker state machine tests +├── test_threat_analyzer.cpp # ThreatAnalyzer integration tests +├── mocks/ +│ └── Arduino.h # Minimal Arduino.h mock for host compilation +└── doctest.h # Test framework header (auto-fetched, gitignored) +``` + +## What's Tested + +### Detector Functions (`test_detectors.cpp`) + +Tests each detector in `common/Detectors.h` individually: + +- **Helper functions**: `isHexChar`, `isHexSuffix`, `isDecimalSuffix`, `ouiMatchesKnownPrefix` +- **RSSI modifier**: boundary values at -50, -70, -85 dBm +- **`detectSsidFormat`**: Flock-hex, Penguin-decimal, FS Ext Battery patterns; wrong lengths, non-hex/decimal suffixes +- **`detectSsidKeyword`**: case-insensitive keyword matching, empty/unrelated SSIDs +- **`detectWifiMacOui` / `detectBleMacOui`**: known vs unknown OUI prefixes +- **`detectBleName`**: name substring matching +- **`detectRavenCustomUuid`**: 0x3100-0x3500 range, boundary exclusion +- **`detectRavenStdUuid`**: 0x180A, 0x1809, 0x1819 matching + +### Device Tracker (`test_device_tracker.cpp`) + +Tests the `DeviceTracker` state machine: + +- State transitions: `EMPTY` -> `NEW_DETECT` -> `IN_RANGE` -> `DEPARTED` +- Timeout behavior (60-second departure) +- High-confidence detection filtering +- Max certainty update logic +- LRU eviction when all 32 slots are full +- Preference for departed slots over active during eviction + +### Threat Analyzer (`test_threat_analyzer.cpp`) + +Integration tests for the full scoring pipeline: + +- WiFi SSID format match produces correct certainty +- No-match produces no threat event +- Subsumption removes keyword weight when format also matches +- RSSI modifier adjusts certainty +- Certainty clamped to 0-100 +- `shouldAlert` true on first detection, false on repeat +- BLE Raven UUID produces `acoustic_detector` category +- Heartbeat tick behavior + +## Mock Environment + +### `test/mocks/Arduino.h` + +Provides minimal Arduino API stubs for host compilation: + +- `millis()` -- returns `mock_millis_value` (controllable from tests) +- `constrain()` macro +- Standard C/C++ includes (``, ``, ``) + +### `test/eventbus_impl.cpp` + +Provides storage for `EventBus` static members and the mock `millis()` backing variable. Handlers default to empty `std::function` (no-op). + +## Adding Tests + +1. Create a new `test/test_*.cpp` file +2. Include `"doctest.h"` and the header under test +3. Add the file to `TEST_SRCS` in the Makefile +4. Run `make test` to verify + +## doctest.h + +The test framework header is auto-fetched on first `make test` run (or pre-baked in the Docker image). It is listed in `.gitignore` and not committed to the repository. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..ce21611 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,59 @@ +# Troubleshooting + +Common issues and solutions across all variants. For variant-specific problems (display, audio hardware, SD card), see the variant README. + +## Compilation Errors + +### IRAM overflow / linker errors + +**Cause:** ESP32 board package is too new. + +**Fix:** Install **esp32 by Espressif Systems** version **3.0.7 or older** via Boards Manager. + +### Missing libraries + +**Fix:** Install required libraries via **Tools** > **Manage Libraries**: +- ArduinoJson +- NimBLE-Arduino +- Variant-specific libraries (M5Unified, U8g2, Adafruit SSD1306, etc.) + +See [Getting Started](getting-started.md) for the shared library list and your variant's README for additional requirements. + +### Wrong board selected + +**Fix:** Select the correct board in **Tools** > **Board**. Each variant uses a different FQBN: + +| Variant | Board Selection | +|---------|----------------| +| M5StickC Plus2 | M5StickC Plus2 | +| M5Stack FIRE | M5Stack Fire | +| Mini12864 / OLED / Portable | ESP32 Dev Module | +| Flipper Zero | ESP32S2 Dev Module | + +### File structure errors + +Ensure all `.h` files are in the variant's `src/` directory. The Arduino IDE expects sketch-local headers to be in `src/` relative to the `.ino` file. + +## Upload Failures + +1. **Hold BOOT button** while clicking Upload, release after upload starts +2. **Lower upload speed** to 115200 or 9600 baud in **Tools** > **Upload Speed** +3. **Use a data cable**, not a charge-only USB cable +4. **Install USB drivers**: CP210x (Silicon Labs) or CH340 depending on your board + +## No Detections + +1. **Check serial output** at 115200 baud -- verify the system initialized without errors +2. **Test with a known device**: Create a WiFi hotspot named "Flock" on a smartphone +3. **Check channel timing**: Brief transmissions may be missed between hops. Try increasing `CHANNEL_SWITCH_MS` in `src/RadioScanner.h` +4. **Verify detection patterns**: Check `common/DeviceSignatures.h` and `common/Detectors.h` match your target devices + +## Filesystem Upload Fails (LittleFS variants) + +Applies to Mini12864, 128x32 OLED, and M5Stack FIRE variants that use audio files. + +1. **Check partition scheme**: Select a scheme with SPIFFS/LittleFS space +2. **Check file sizes**: Total data must fit in the filesystem partition +3. **Close serial monitors**: The filesystem uploader needs exclusive port access +4. **Restart Arduino IDE** and retry +5. **Alternative**: Use `make upload-data-` from the [build system](build-system.md) diff --git a/flipper-zero/README.md b/flipper-zero/README.md index 8439a8b..d125ff9 100644 --- a/flipper-zero/README.md +++ b/flipper-zero/README.md @@ -1,8 +1,8 @@ # FlockSquawk (Flipper Zero WiFi Dev Board) -ESP32-S2 firmware for the official Flipper Zero WiFi Dev Board. The ESP32-S2 -handles RF scanning and outputs line-based UART messages to a companion Flipper -Zero app (`flock_scanner.fap`). +ESP32-S2 firmware for the official Flipper Zero WiFi Dev Board. The ESP32-S2 handles RF scanning and outputs line-based UART messages to a companion Flipper Zero app (`flock_scanner.fap`). + +> Note: ESP32-S2 does not support Bluetooth/BLE, so BLE scanning is disabled. ## Features @@ -11,8 +11,6 @@ Zero app (`flock_scanner.fap`). - **UART Telemetry**: Line-based serial protocol for the Flipper app - **Event-Driven Architecture**: Modular design for easy extension -> Note: ESP32-S2 does not support Bluetooth/BLE, so BLE scanning is disabled. - ## Hardware Requirements - **Flipper Zero** @@ -20,94 +18,47 @@ Zero app (`flock_scanner.fap`). - **USB-C cable** (for flashing the ESP32-S2) ### UART Pins (ESP32-S2) + The WiFi Dev Board routes ESP32-S2 UART0 to the Flipper GPIO header: - **TX** = GPIO43 - **RX** = GPIO44 These are wired on the official board; no extra wiring is required. -## Software Setup - -### Prerequisites +## Setup -1. **Arduino IDE** (version 1.8.19 or later, or Arduino IDE 2.x) -2. **ESP32 Board Support** installed in Arduino IDE +For Arduino IDE installation and ESP32 board support, see [Getting Started](../docs/getting-started.md). -### Installing ESP32 Board Support +### Libraries -1. Open Arduino IDE -2. Go to **File** → **Preferences** -3. In "Additional Boards Manager URLs", add: - ``` - https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json - ``` -4. Go to **Tools** → **Board** → **Boards Manager** -5. Search for "ESP32" and install "esp32 by Espressif Systems" -6. Select your ESP32 board: **Tools** → **Board** → **ESP32 Arduino** → **ESP32S2 Dev Module** - -**Important:** Use **esp32 by Espressif Systems** version **3.0.7 or older**. Newer versions fail to compile due to an **IRAM overflow** issue. - -### Required Libraries - -No additional libraries are required for the ESP32-S2 build. +No additional libraries are required for the ESP32-S2 build (NimBLE is not used). If your Arduino-ESP32 core does not provide `esp32-hal-neopixel.h`, install: -- **Adafruit NeoPixel** (used as a fallback for the onboard RGB LED) - -### Additional ESP32 Tools -The following components are included with ESP32 board support: -- WiFi (built-in) - -## Installation from GitHub - -### Step 1: Clone or Download Repository +- **Adafruit NeoPixel** (fallback for the onboard RGB LED) -```bash -git clone -cd FlockSquawk-main/flipper-zero/dev-board-firmware/flocksquawk-flipper -``` - -Or download as ZIP and extract. - -### Step 2: Open Project in Arduino IDE - -1. Open Arduino IDE -2. Navigate to **File** → **Open** -3. Select `flocksquawk-flipper.ino` from the `flocksquawk-flipper` folder - -### Step 3: Configure Board Settings - -1. Select your board: **Tools** → **Board** → **ESP32 Arduino** → **ESP32S2 Dev Module** -2. Set upload speed: **Tools** → **Upload Speed** → **115200** (or lower if upload fails) -3. Set CPU frequency: **Tools** → **CPU Frequency** → **240MHz (WiFi/BT)** -4. Set partition scheme: **Tools** → **Partition Scheme** → **Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)** or **Huge APP (3MB No OTA/1MB SPIFFS)** +### Board Settings -### Step 4: Upload Code +1. Select board: **Tools** > **Board** > **ESP32S2 Dev Module** +2. Upload speed: **115200** (or lower if upload fails) +3. CPU frequency: **240MHz (WiFi/BT)** +4. Partition scheme: **Default 4MB with spiffs** or **Huge APP (3MB No OTA/1MB SPIFFS)** -1. Connect ESP32 via USB -2. Select the correct port: **Tools** → **Port** → Select your ESP32 port -3. Click **Upload** button (or **Sketch** → **Upload**) -4. Wait for compilation and upload to complete +### Upload -### Step 5: Monitor Serial Output - -1. Open Serial Monitor: **Tools** → **Serial Monitor** -2. Set baud rate to **115200** -3. Set line ending to **Newline** -4. You should see status lines and detection events +1. Connect ESP32-S2 via USB-C +2. Select port: **Tools** > **Port** +3. Click **Upload** ## Usage ### Basic Operation -1. Power on the ESP32 -2. The system will: - - Initialize WiFi sniffer - - Begin scanning for targets +1. Seat the WiFi Dev Board on the Flipper GPIO header +2. Power on -- the ESP32-S2 initializes WiFi scanning automatically -### Serial Output (UART Protocol) +### UART Protocol -Line-based, newline-terminated messages: +Line-based, newline-terminated messages (see [Telemetry Format](../docs/telemetry-format.md) for full details): ``` STATUS,SCANNING @@ -119,65 +70,22 @@ CLEAR The Flipper app consumes these lines directly. -### RGB LED Behavior (ESP32-S2) -The onboard RGB LED is used for quick status feedback: +### RGB LED Behavior + +The onboard RGB LED provides status feedback: - Boot: cycles red/green/blue - Scanning: flashes blue - Alert: solid red -The official WiFi Dev Board uses a **discrete RGB LED** (active-low) on: -- **R** = GPIO6 -- **G** = GPIO5 -- **B** = GPIO4 - -## Configuration +LED pins (discrete RGB, active-low): R=GPIO6, G=GPIO5, B=GPIO4. -### WiFi Channel Hopping - -Default: Channels 1-13, switching every 500ms - -To modify, edit `src/RadioScanner.h`: -```cpp -static const uint8_t MAX_WIFI_CHANNEL = 13; -static const uint16_t CHANNEL_SWITCH_MS = 500; -``` - -### Detection Patterns - -Detection patterns are defined in `src/DeviceSignatures.h`. Patterns include: -- Network SSID names -- MAC address prefixes (OUI) - -> BLE identifiers remain in `DeviceSignatures.h` for non-S2 builds, but are not -> used on the ESP32-S2 WiFi Dev Board. - -## Troubleshooting - -### No Detections - -1. **Check serial output**: Verify system initialized correctly -2. **Test with known device**: Use a smartphone with WiFi hotspot named "Flock" -3. **Check channel**: WiFi channel hopping may miss brief transmissions -4. **Verify patterns**: Check `DeviceSignatures.h` matches your target devices - -### Compilation Errors - -1. **Wrong board**: Select correct ESP32 board variant -2. **ESP32 core too new**: Install version **3.0.7 or older** (newer versions hit IRAM overflow) -3. **File structure**: Ensure all `.h` files are in `src/` directory - -### Upload Failures - -1. **Hold BOOT button**: Hold BOOT button while clicking Upload, release after upload starts -2. **Lower upload speed**: Change to 115200 or 9600 baud -3. **Check USB cable**: Use a data cable, not charge-only -4. **Driver issues**: Install ESP32 USB drivers (CP210x or CH340) +## Variant-Specific Troubleshooting ### Flipper App Not Receiving UART 1. **Confirm baud rate**: 115200 -2. **Confirm app**: `flipper_flock_app` installed -3. **Check cables**: Dev board seated properly on Flipper GPIO header +2. **Confirm app**: `flipper_flock_app` installed on the Flipper +3. **Check seating**: Dev board seated properly on Flipper GPIO header 4. **Check protocol**: Use serial monitor to verify line-based output ## Project Structure @@ -185,73 +93,30 @@ Detection patterns are defined in `src/DeviceSignatures.h`. Patterns include: ``` flipper-zero/ ├── dev-board-firmware/ -│ ├── flocksquawk-flipper/ -│ │ └── flocksquawk-flipper.ino # Main sketch -│ └── src/ -│ ├── EventBus.h # Event system interface -│ ├── DeviceSignatures.h # Detection patterns -│ ├── RadioScanner.h # RF scanning interface -│ ├── ThreatAnalyzer.h # Detection engine interface -│ ├── SoundEngine.h # Legacy audio (unused for Flipper) -│ └── TelemetryReporter.h # UART reporting interface -├── flock_scanner.fap # Flipper Zero app (prebuilt) -└── README.md # This file +│ └── flocksquawk-flipper/ +│ ├── flocksquawk-flipper.ino # Main sketch +│ └── src/ +│ ├── RadioScanner.h # Variant-specific RF scanning +│ ├── SoundEngine.h # Stub (no audio on Flipper) +│ └── TelemetryReporter.h # UART line-based reporter +├── flock_scanner.fap # Flipper Zero app (prebuilt) +└── README.md ``` -## Architecture +Shared headers (`EventBus.h`, `ThreatAnalyzer.h`, `Detectors.h`, etc.) are in [`common/`](../common/). -The system uses an event-driven architecture: +## Further Reading -``` -RadioScannerManager → WiFi Events → EventBus - ↓ - ThreatAnalyzer - ↓ - Threat Events - ↓ - TelemetryReporter - ↓ - UART Output -``` - -## Extending the System - -### Adding New Detection Patterns - -Edit `src/DeviceSignatures.h`: -```cpp -const char* const NetworkNames[] = { - "flock", - "YourNewPattern", // Add here - // ... -}; -``` - -### Adding Display Support - -Subscribe to `ThreatHandler` in `setup()`: -```cpp -EventBus::subscribeThreat([](const ThreatEvent& event) { - display.showThreat(event); // Your display code -}); -``` - -### Adding LED Indicators - -Subscribe to events and control GPIO: -```cpp -EventBus::subscribeThreat([](const ThreatEvent& event) { - digitalWrite(LED_PIN, HIGH); - delay(500); - digitalWrite(LED_PIN, LOW); -}); -``` +- [Configuration](../docs/configuration.md) -- WiFi tuning, detection patterns +- [Architecture](../docs/architecture.md) -- pipeline, detectors, thread safety +- [Extending](../docs/extending.md) -- adding detectors, patterns, new variants +- [Build System](../docs/build-system.md) -- Makefile and Docker builds +- [Troubleshooting](../docs/troubleshooting.md) -- common issues ## License [GNU GENERAL PUBLIC LICENSE](https://github.com/f1yaw4y/FlockSquawk/blob/main/LICENSE) - ## Acknowledgments - Inspired by [flock-you](https://github.com/colonelpanichacks/flock-you) diff --git a/m5stack/flocksquawk_m5fire/README.md b/m5stack/flocksquawk_m5fire/README.md index c56f0db..9aefece 100644 --- a/m5stack/flocksquawk_m5fire/README.md +++ b/m5stack/flocksquawk_m5fire/README.md @@ -1,6 +1,6 @@ -# FlockSquawk +# FlockSquawk (M5Stack FIRE) -A modular, event-driven ESP32 project that passively detects surveillance devices using WiFi promiscuous mode and Bluetooth Low Energy scanning. Features built-in speaker audio alerts and JSON telemetry output. +M5Stack FIRE variant with built-in speaker and SD card audio. Passively detects surveillance devices using WiFi promiscuous mode and Bluetooth Low Energy scanning. ## Features @@ -13,75 +13,26 @@ A modular, event-driven ESP32 project that passively detects surveillance device ## Hardware Requirements -### Required Components - - **M5Stack FIRE IoT Development Kit (PSRAM) V2.7** - **USB Cable** for programming and power -## Software Setup - -### Prerequisites - -1. **Arduino IDE** (version 1.8.19 or later, or Arduino IDE 2.x) -2. **ESP32 Board Support** installed in Arduino IDE - -### Installing ESP32 Board Support - -1. Open Arduino IDE -2. Go to **File** → **Preferences** -3. In "Additional Boards Manager URLs", add: - ``` - https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json - ``` -4. Go to **Tools** → **Board** → **Boards Manager** -5. Search for "ESP32" and install "esp32 by Espressif Systems" -6. Select your ESP32 board: **Tools** → **Board** → **M5Stack Fire** - -**Important:** Use **esp32 by Espressif Systems** version **3.0.7 or older**. Newer versions fail to compile due to an **IRAM overflow** issue. - -### Required Libraries +## Setup -Install the following libraries via Arduino IDE Library Manager: +For Arduino IDE installation and ESP32 board support, see [Getting Started](../../docs/getting-started.md). -1. **M5Unified** by M5Stack - - **Tools** → **Manage Libraries** → Search "M5Unified" → Install +### Additional Libraries -2. **ArduinoJson** by Benoit Blanchon (version 6.x or 7.x) - - **Tools** → **Manage Libraries** → Search "ArduinoJson" → Install - -3. **NimBLE-Arduino** by h2zero - - **Tools** → **Manage Libraries** → Search "NimBLE-Arduino" → Install - -### Additional ESP32 Tools - -The following components are included with ESP32 board support: -- WiFi (built-in) -- SD (built-in) - -## Installation from GitHub - -### Step 1: Clone or Download Repository - -```bash -git clone -cd FlockSquawk-main/m5stack/flocksquawk_m5fire -``` +Install via Arduino IDE Library Manager: -Or download as ZIP and extract. +- **M5Unified** by M5Stack -### Step 2: Open Project in Arduino IDE - -1. Open Arduino IDE -2. Navigate to **File** → **Open** -3. Select `flocksquawk_m5fire.ino` from this folder - -### Step 3: Prepare Audio Files +### Audio Files The project requires three WAV audio files on the SD card (root directory): -- `/startup.wav` - Plays on boot -- `/ready.wav` - Plays when system is ready -- `/alert.wav` - Plays on threat detection +- `/startup.wav` -- Plays on boot +- `/ready.wav` -- Plays when system is ready +- `/alert.wav` -- Plays on threat detection **Audio File Requirements:** - Format: 16-bit PCM WAV @@ -89,44 +40,32 @@ The project requires three WAV audio files on the SD card (root directory): - Channels: Mono (1 channel) - Header: Standard 44-byte WAV header -**Adding Audio Files:** +**Setup:** +1. Format the SD card as FAT32 +2. Copy the WAV files to the root of the SD card +3. Insert the SD card into the M5Stack FIRE before powering on -1. Format the SD card as FAT32. -2. Copy the WAV files to the root of the SD card: - ``` - /startup.wav - /ready.wav - /alert.wav - ``` -3. Insert the SD card into the M5Stack FIRE before powering on. +### Board Settings -### Step 4: Configure Board Settings +1. Select board: **Tools** > **Board** > **M5Stack Fire** +2. PSRAM: **Enabled** +3. Upload speed: **115200** +4. CPU frequency: **240MHz (WiFi/BT)** +5. Partition scheme: **Default 4MB with spiffs** or **Huge APP (3MB No OTA/1MB SPIFFS)** -1. Select your board: **Tools** → **Board** → **M5Stack Fire** -2. Set PSRAM: **Tools** → **PSRAM** → **Enabled** -3. Set upload speed: **Tools** → **Upload Speed** → **115200** (or lower if upload fails) -4. Set CPU frequency: **Tools** → **CPU Frequency** → **240MHz (WiFi/BT)** -5. Set partition scheme: **Tools** → **Partition Scheme** → **Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)** or **Huge APP (3MB No OTA/1MB SPIFFS)** +### Upload -### Step 5: Upload Code +1. Connect via USB +2. Select port: **Tools** > **Port** +3. Click **Upload** -1. Connect ESP32 via USB -2. Select the correct port: **Tools** → **Port** → Select your ESP32 port -3. Click **Upload** button (or **Sketch** → **Upload**) -4. Wait for compilation and upload to complete +### Serial Monitor -### Step 6: Monitor Serial Output - -1. Open Serial Monitor: **Tools** → **Serial Monitor** -2. Set baud rate to **115200** -3. Set line ending to **Newline** -4. You should see initialization messages and detection events +Open at **115200** baud. See [Telemetry Format](../../docs/telemetry-format.md) for the JSON schema. ## Usage -### Basic Operation - -1. Power on the ESP32 +1. Power on the M5Stack FIRE 2. The system will: - Initialize filesystem and audio - Play startup sound @@ -134,41 +73,6 @@ The project requires three WAV audio files on the SD card (root directory): - Play ready sound - Begin scanning for targets -### Serial Output - -The system outputs JSON telemetry when threats are detected: - -```json -{ - "event": "target_detected", - "ms_since_boot": 15234, - "source": { - "radio": "wifi", - "channel": 6, - "rssi": -67 - }, - "target": { - "identity": { - "mac": "aa:bb:cc:dd:ee:ff", - "oui": "aa:bb:cc", - "label": "Network Name" - }, - "indicators": { - "ssid_match": true, - "mac_match": true, - "name_match": false, - "service_uuid_match": false - }, - "category": "surveillance_device", - "certainty": 95 - }, - "metadata": { - "frame_type": "beacon", - "detection_method": "combined_signature" - } -} -``` - ### Audio Alerts - **Startup**: Plays when system boots (display shows "Startup") @@ -177,48 +81,13 @@ The system outputs JSON telemetry when threats are detected: ### Volume Control -Default volume is set to 40% (0.4). To adjust: - -1. Open `src/SoundEngine.h` -2. Change `DEFAULT_VOLUME` value: - ```cpp - static constexpr float DEFAULT_VOLUME = 0.4f; // 0.0 to 1.0 - ``` -3. Re-upload code - -Or use the serial command (if implemented) to change volume at runtime. - -## Configuration - -### WiFi Channel Hopping - -Default: Channels 1-13, switching every 500ms - -To modify, edit `src/RadioScanner.h`: -```cpp -static const uint8_t MAX_WIFI_CHANNEL = 13; -static const uint16_t CHANNEL_SWITCH_MS = 500; -``` - -### BLE Scan Interval +Default volume is 40% (0.4). To adjust, edit `src/SoundEngine.h`: -Default: 1 second scan every 5 seconds - -To modify, edit `src/RadioScanner.h`: ```cpp -static const uint8_t BLE_SCAN_SECONDS = 1; -static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; +static constexpr float DEFAULT_VOLUME = 0.4f; // 0.0 to 1.0 ``` -### Detection Patterns - -Detection patterns are defined in `src/DeviceSignatures.h`. Patterns include: -- Network SSID names -- MAC address prefixes (OUI) -- Bluetooth device names -- Service UUIDs (e.g., Raven acoustic detectors) - -## Troubleshooting +## Variant-Specific Troubleshooting ### Audio Not Playing @@ -226,27 +95,6 @@ Detection patterns are defined in `src/DeviceSignatures.h`. Patterns include: 2. **Check volume**: Try increasing `DEFAULT_VOLUME` in `SoundEngine.h` 3. **Check serial output**: Look for audio file open errors -### No Detections - -1. **Check serial output**: Verify system initialized correctly -2. **Test with known device**: Use a smartphone with WiFi hotspot named "Flock" -3. **Check channel**: WiFi channel hopping may miss brief transmissions -4. **Verify patterns**: Check `DeviceSignatures.h` matches your target devices - -### Compilation Errors - -1. **Missing libraries**: Install M5Unified, ArduinoJson, and NimBLE-Arduino -2. **Wrong board**: Select correct ESP32 board variant -3. **ESP32 core too new**: Install version **3.0.7 or older** (newer versions hit IRAM overflow) -4. **File structure**: Ensure all `.h` files are in `src/` directory - -### Upload Failures - -1. **Hold BOOT button**: Hold BOOT button while clicking Upload, release after upload starts -2. **Lower upload speed**: Change to 115200 or 9600 baud -3. **Check USB cable**: Use a data cable, not charge-only -4. **Driver issues**: Install ESP32 USB drivers (CP210x or CH340) - ### SD Card Not Detected 1. **Check format**: Use FAT32 @@ -258,73 +106,30 @@ Detection patterns are defined in `src/DeviceSignatures.h`. Patterns include: ``` flocksquawk_m5fire/ ├── flocksquawk_m5fire.ino # Main orchestrator -├── README.md # This file +├── README.md ├── src/ -│ ├── EventBus.h # Event system interface -│ ├── DeviceSignatures.h # Detection patterns -│ ├── RadioScanner.h # RF scanning interface -│ ├── ThreatAnalyzer.h # Detection engine interface -│ ├── SoundEngine.h # Audio playback interface -│ └── TelemetryReporter.h # JSON reporting interface -└── (audio files on SD card root) +│ ├── RadioScanner.h # Variant-specific RF scanning +│ └── SoundEngine.h # M5Stack speaker audio +└── data/ + ├── startup.wav + ├── ready.wav + └── alert.wav ``` -## Architecture +Shared headers (`EventBus.h`, `ThreatAnalyzer.h`, `Detectors.h`, etc.) are in [`common/`](../../common/). -The system uses an event-driven architecture: - -``` -RadioScannerManager → WiFi/Bluetooth Events → EventBus - ↓ - ThreatAnalyzer - ↓ - Threat Events - ↓ - ┌──────────────────────────┴──────────────────┐ - ↓ ↓ - TelemetryReporter SoundEngine - ↓ ↓ - JSON Output Audio Alert -``` +## Further Reading -## Extending the System - -### Adding New Detection Patterns - -Edit `src/DeviceSignatures.h`: -```cpp -const char* const NetworkNames[] = { - "flock", - "YourNewPattern", // Add here - // ... -}; -``` - -### Adding Display Support - -Subscribe to `ThreatHandler` in `setup()`: -```cpp -EventBus::subscribeThreat([](const ThreatEvent& event) { - display.showThreat(event); // Your display code -}); -``` - -### Adding LED Indicators - -Subscribe to events and control GPIO: -```cpp -EventBus::subscribeThreat([](const ThreatEvent& event) { - digitalWrite(LED_PIN, HIGH); - delay(500); - digitalWrite(LED_PIN, LOW); -}); -``` +- [Configuration](../../docs/configuration.md) -- WiFi/BLE tuning, detection patterns, volume +- [Architecture](../../docs/architecture.md) -- pipeline, detectors, thread safety +- [Extending](../../docs/extending.md) -- adding detectors, patterns, new variants +- [Build System](../../docs/build-system.md) -- Makefile and Docker builds +- [Troubleshooting](../../docs/troubleshooting.md) -- common issues ## License [GNU GENERAL PUBLIC LICENSE](https://github.com/f1yaw4y/FlockSquawk/blob/main/LICENSE) - ## Acknowledgments - Inspired by [flock-you](https://github.com/colonelpanichacks/flock-you) diff --git a/m5stack/flocksquawk_m5stick/README.md b/m5stack/flocksquawk_m5stick/README.md index 4294767..b44867b 100644 --- a/m5stack/flocksquawk_m5stick/README.md +++ b/m5stack/flocksquawk_m5stick/README.md @@ -1,6 +1,6 @@ -# FlockSquawk +# FlockSquawk (M5StickC Plus2) -A modular, event-driven ESP32 project that passively detects surveillance devices using WiFi promiscuous mode and Bluetooth Low Energy scanning. Features on-device buzzer alerts, a status display, and JSON telemetry output. +A compact handheld variant with built-in display and buzzer alerts. Passively detects surveillance devices using WiFi promiscuous mode and Bluetooth Low Energy scanning. ## Features @@ -14,254 +14,74 @@ A modular, event-driven ESP32 project that passively detects surveillance device ## Hardware Requirements -### Required Components - - **M5StickC PLUS2 (ESP32 V3 Mini)** device - **USB Cable** for programming and power -## Software Setup - -### Prerequisites - -1. **Arduino IDE** (version 1.8.19 or later, or Arduino IDE 2.x) -2. **ESP32 Board Support** installed in Arduino IDE - -### Installing ESP32 Board Support - -1. Open Arduino IDE -2. Go to **File** → **Preferences** -3. In "Additional Boards Manager URLs", add: - ``` - https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json - ``` -4. Go to **Tools** → **Board** → **Boards Manager** -5. Search for "ESP32" and install "esp32 by Espressif Systems" -6. Select your board: **Tools** → **Board** → **M5Stack** → **M5StickC Plus2** - -**Important:** Use **esp32 by Espressif Systems** version **3.0.7 or older**. Newer versions fail to compile due to an **IRAM overflow** issue. - -### Required Libraries +## Setup -Install the following libraries via Arduino IDE Library Manager: +For Arduino IDE installation and ESP32 board support, see [Getting Started](../../docs/getting-started.md). -1. **ArduinoJson** by Benoit Blanchon (version 6.x or 7.x) - - **Tools** → **Manage Libraries** → Search "ArduinoJson" → Install +### Additional Libraries -2. **NimBLE-Arduino** by h2zero - - **Tools** → **Manage Libraries** → Search "NimBLE-Arduino" → Install +Install via Arduino IDE Library Manager: -3. **M5Unified** by M5Stack - - **Tools** → **Manage Libraries** → Search "M5Unified" → Install - -### Additional ESP32 Tools - -The following components are included with ESP32 board support: -- WiFi (built-in) - -## Installation from GitHub - -### Step 1: Clone or Download Repository - -```bash -git clone -cd FlockSquawk-main/m5stack/flocksquawk_m5stick -``` +- **M5Unified** by M5Stack -Or download as ZIP and extract. +### Board Settings -### Step 2: Open Project in Arduino IDE +1. Select board: **Tools** > **Board** > **M5Stack** > **M5StickC Plus2** +2. Upload speed: **115200** +3. CPU frequency: **240MHz (WiFi/BT)** +4. Partition scheme: **Default 4MB with spiffs** or **Huge APP (3MB No OTA/1MB SPIFFS)** -1. Open Arduino IDE -2. Navigate to **File** → **Open** -3. Select `flocksquawk_m5stick.ino` from this folder +### Upload -### Step 3: Configure Board Settings +1. Connect via USB +2. Select port: **Tools** > **Port** +3. Click **Upload** -1. Select your board: **Tools** → **Board** → **M5StickC Plus2** -2. Set upload speed: **Tools** → **Upload Speed** → **115200** (or lower if upload fails) -3. Set CPU frequency: **Tools** → **CPU Frequency** → **240MHz (WiFi/BT)** -4. Set partition scheme: **Tools** → **Partition Scheme** → **Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)** or **Huge APP (3MB No OTA/1MB SPIFFS)** +### Serial Monitor -### Step 4: Upload Code - -1. Connect ESP32 via USB -2. Select the correct port: **Tools** → **Port** → Select your ESP32 port -3. Click **Upload** button (or **Sketch** → **Upload**) -4. Wait for compilation and upload to complete - -### Step 5: Monitor Serial Output - -1. Open Serial Monitor: **Tools** → **Serial Monitor** -2. Set baud rate to **115200** -3. Set line ending to **Newline** -4. You should see initialization messages and detection events +Open at **115200** baud. See [Telemetry Format](../../docs/telemetry-format.md) for the JSON schema. ## Usage -### Basic Operation - -1. Power on the ESP32 +1. Power on the M5StickC Plus2 2. The system will: - Show `starting...` on the display with short beeps - Initialize WiFi sniffer and BLE scanner - Begin scanning for targets - Show `Scanning` and current WiFi channel on the display -### Serial Output - -The system outputs JSON telemetry when threats are detected: - -```json -{ - "event": "target_detected", - "ms_since_boot": 15234, - "source": { - "radio": "wifi", - "channel": 6, - "rssi": -67 - }, - "target": { - "identity": { - "mac": "aa:bb:cc:dd:ee:ff", - "oui": "aa:bb:cc", - "label": "Network Name" - }, - "indicators": { - "ssid_match": true, - "mac_match": true, - "name_match": false, - "service_uuid_match": false - }, - "category": "surveillance_device", - "certainty": 95 - }, - "metadata": { - "frame_type": "beacon", - "detection_method": "combined_signature" - } -} -``` - ### Buzzer Alerts - **Startup**: Short beeps when the system boots - **Alert**: Two beeps on threat detection -## Configuration - -### WiFi Channel Hopping - -Default: Channels 1-13, switching every 500ms - -To modify, edit `src/RadioScanner.h`: -```cpp -static const uint8_t MAX_WIFI_CHANNEL = 13; -static const uint16_t CHANNEL_SWITCH_MS = 500; -``` - -### BLE Scan Interval - -Default: 1 second scan every 5 seconds - -To modify, edit `src/RadioScanner.h`: -```cpp -static const uint8_t BLE_SCAN_SECONDS = 1; -static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; -``` - -### Detection Patterns - -Detection patterns are defined in `src/DeviceSignatures.h`. Patterns include: -- Network SSID names -- MAC address prefixes (OUI) -- Bluetooth device names -- Service UUIDs (e.g., Raven acoustic detectors) - -## Troubleshooting - -### No Detections - -1. **Check serial output**: Verify system initialized correctly -2. **Test with known device**: Use a smartphone with WiFi hotspot named "Flock" -3. **Check channel**: WiFi channel hopping may miss brief transmissions -4. **Verify patterns**: Check `DeviceSignatures.h` matches your target devices - -### Compilation Errors - -1. **Missing libraries**: Install ArduinoJson and NimBLE-Arduino -2. **Wrong board**: Select correct ESP32 board variant -3. **ESP32 core too new**: Install version **3.0.7 or older** (newer versions hit IRAM overflow) -4. **File structure**: Ensure all `.h` files are in `src/` directory - -### Upload Failures - -1. **Hold BOOT button**: Hold BOOT button while clicking Upload, release after upload starts -2. **Lower upload speed**: Change to 115200 or 9600 baud -3. **Check USB cable**: Use a data cable, not charge-only -4. **Driver issues**: Install ESP32 USB drivers (CP210x or CH340) - ## Project Structure ``` flocksquawk_m5stick/ ├── flocksquawk_m5stick.ino # Main orchestrator -├── README.md # This file -├── src/ -│ ├── EventBus.h # Event system interface -│ ├── DeviceSignatures.h # Detection patterns -│ ├── RadioScanner.h # RF scanning interface -│ ├── ThreatAnalyzer.h # Detection engine interface -│ └── TelemetryReporter.h # JSON reporting interface -└── data/ # (unused) -``` - -## Architecture - -The system uses an event-driven architecture: - +├── README.md +└── src/ + └── RadioScanner.h # Variant-specific RF scanning ``` -RadioScannerManager → WiFi/Bluetooth Events → EventBus - ↓ - ThreatAnalyzer - ↓ - Threat Events - ↓ - ┌──────────────────────────┴──────────────────┐ - ↓ ↓ - TelemetryReporter Buzzer + Display - ↓ ↓ - JSON Output User Alerts -``` - -## Extending the System - -### Adding New Detection Patterns -Edit `src/DeviceSignatures.h`: -```cpp -const char* const NetworkNames[] = { - "flock", - "YourNewPattern", // Add here - // ... -}; -``` +Shared headers (`EventBus.h`, `ThreatAnalyzer.h`, `Detectors.h`, etc.) are in [`common/`](../../common/). -### Adding LED Indicators +## Further Reading -Subscribe to events and control GPIO: -```cpp -EventBus::subscribeThreat([](const ThreatEvent& event) { - digitalWrite(LED_PIN, HIGH); - delay(500); - digitalWrite(LED_PIN, LOW); -}); -``` +- [Configuration](../../docs/configuration.md) -- WiFi/BLE tuning, detection patterns +- [Architecture](../../docs/architecture.md) -- pipeline, detectors, thread safety +- [Extending](../../docs/extending.md) -- adding detectors, patterns, new variants +- [Build System](../../docs/build-system.md) -- Makefile and Docker builds +- [Troubleshooting](../../docs/troubleshooting.md) -- common issues ## License [GNU GENERAL PUBLIC LICENSE](https://github.com/f1yaw4y/FlockSquawk/blob/main/LICENSE) - ## Acknowledgments - Inspired by [flock-you](https://github.com/colonelpanichacks/flock-you) From 93c9b4a14f7e07f947272bd0fb47e91421d78c79 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 2 Feb 2026 00:52:08 -0700 Subject: [PATCH 14/23] Tune scan parameters and capture WiFi probe responses --- .../flocksquawk_128x32/flocksquawk_128x32.ino | 15 ++++++++------- 128x32_OLED/flocksquawk_128x32/src/RadioScanner.h | 4 ++-- .../flocksquawk_128x32_portable.ino | 15 ++++++++------- .../src/RadioScanner.h | 4 ++-- .../flocksquawk_mini12864.ino | 15 ++++++++------- .../flocksquawk_mini12864/src/RadioScanner.h | 4 ++-- .../flocksquawk-flipper/flocksquawk-flipper.ino | 15 ++++++++------- .../flocksquawk-flipper/src/RadioScanner.h | 4 ++-- m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino | 15 ++++++++------- m5stack/flocksquawk_m5fire/src/RadioScanner.h | 4 ++-- .../flocksquawk_m5stick/flocksquawk_m5stick.ino | 15 ++++++++------- m5stack/flocksquawk_m5stick/src/RadioScanner.h | 4 ++-- 12 files changed, 60 insertions(+), 54 deletions(-) diff --git a/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino b/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino index b718302..a84fae4 100644 --- a/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino +++ b/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino @@ -202,21 +202,22 @@ void RadioScannerManager::wifiPacketHandler(void* buffer, wifi_promiscuous_pkt_t uint8_t frameSubtype = (header->frameControl & 0x0F) >> 4; bool isProbeRequest = (frameSubtype == 0x04); + bool isProbeResponse = (frameSubtype == 0x05); bool isBeacon = (frameSubtype == 0x08); - - if (!isProbeRequest && !isBeacon) return; - + + if (!isProbeRequest && !isProbeResponse && !isBeacon) return; + WiFiFrameEvent event; memset(&event, 0, sizeof(event)); - + memcpy(event.mac, header->source, 6); event.rssi = packet->rx_ctrl.rssi; event.frameSubtype = frameSubtype; event.channel = RadioScannerManager::currentWifiChannel; - + const uint8_t* payload = rawData + sizeof(WiFi80211Header); - - if (isBeacon) { + + if (isBeacon || isProbeResponse) { payload += 12; } diff --git a/128x32_OLED/flocksquawk_128x32/src/RadioScanner.h b/128x32_OLED/flocksquawk_128x32/src/RadioScanner.h index 6484951..79eb981 100644 --- a/128x32_OLED/flocksquawk_128x32/src/RadioScanner.h +++ b/128x32_OLED/flocksquawk_128x32/src/RadioScanner.h @@ -13,8 +13,8 @@ class RadioScannerManager { public: static const uint8_t MAX_WIFI_CHANNEL = 13; - static const uint16_t CHANNEL_SWITCH_MS = 500; - static const uint8_t BLE_SCAN_SECONDS = 1; + static const uint16_t CHANNEL_SWITCH_MS = 300; + static const uint8_t BLE_SCAN_SECONDS = 2; static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; void initialize(); diff --git a/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino b/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino index 59ef4a7..380e0e5 100644 --- a/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino +++ b/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino @@ -332,21 +332,22 @@ void RadioScannerManager::wifiPacketHandler(void* buffer, wifi_promiscuous_pkt_t uint8_t frameSubtype = (header->frameControl & 0x00F0) >> 4; bool isProbeRequest = (frameSubtype == 0x04); + bool isProbeResponse = (frameSubtype == 0x05); bool isBeacon = (frameSubtype == 0x08); - - if (!isProbeRequest && !isBeacon) return; - + + if (!isProbeRequest && !isProbeResponse && !isBeacon) return; + WiFiFrameEvent event; memset(&event, 0, sizeof(event)); - + memcpy(event.mac, header->source, 6); event.rssi = packet->rx_ctrl.rssi; event.frameSubtype = frameSubtype; event.channel = RadioScannerManager::currentWifiChannel; - + const uint8_t* payload = rawData + sizeof(WiFi80211Header); - - if (isBeacon) { + + if (isBeacon || isProbeResponse) { payload += 12; } diff --git a/128x32_OLED/flocksquawk_128x32_portable/src/RadioScanner.h b/128x32_OLED/flocksquawk_128x32_portable/src/RadioScanner.h index 6484951..79eb981 100644 --- a/128x32_OLED/flocksquawk_128x32_portable/src/RadioScanner.h +++ b/128x32_OLED/flocksquawk_128x32_portable/src/RadioScanner.h @@ -13,8 +13,8 @@ class RadioScannerManager { public: static const uint8_t MAX_WIFI_CHANNEL = 13; - static const uint16_t CHANNEL_SWITCH_MS = 500; - static const uint8_t BLE_SCAN_SECONDS = 1; + static const uint16_t CHANNEL_SWITCH_MS = 300; + static const uint8_t BLE_SCAN_SECONDS = 2; static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; void initialize(); diff --git a/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino b/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino index d8dc2d9..9abbf86 100644 --- a/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino +++ b/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino @@ -206,21 +206,22 @@ void RadioScannerManager::wifiPacketHandler(void* buffer, wifi_promiscuous_pkt_t uint8_t frameSubtype = (header->frameControl & 0x00F0) >> 4; bool isProbeRequest = (frameSubtype == 0x04); + bool isProbeResponse = (frameSubtype == 0x05); bool isBeacon = (frameSubtype == 0x08); - - if (!isProbeRequest && !isBeacon) return; - + + if (!isProbeRequest && !isProbeResponse && !isBeacon) return; + WiFiFrameEvent event; memset(&event, 0, sizeof(event)); - + memcpy(event.mac, header->source, 6); event.rssi = packet->rx_ctrl.rssi; event.frameSubtype = frameSubtype; event.channel = RadioScannerManager::currentWifiChannel; - + const uint8_t* payload = rawData + sizeof(WiFi80211Header); - - if (isBeacon) { + + if (isBeacon || isProbeResponse) { payload += 12; } diff --git a/Mini12864/flocksquawk_mini12864/src/RadioScanner.h b/Mini12864/flocksquawk_mini12864/src/RadioScanner.h index 0d72b08..ce0fabe 100644 --- a/Mini12864/flocksquawk_mini12864/src/RadioScanner.h +++ b/Mini12864/flocksquawk_mini12864/src/RadioScanner.h @@ -13,8 +13,8 @@ class RadioScannerManager { public: static const uint8_t MAX_WIFI_CHANNEL = 13; - static const uint16_t CHANNEL_SWITCH_MS = 500; - static const uint8_t BLE_SCAN_SECONDS = 1; + static const uint16_t CHANNEL_SWITCH_MS = 300; + static const uint8_t BLE_SCAN_SECONDS = 2; static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; void initialize(); diff --git a/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino b/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino index 88c2a41..c925c11 100644 --- a/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino +++ b/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino @@ -280,21 +280,22 @@ void RadioScannerManager::wifiPacketHandler(void* buffer, wifi_promiscuous_pkt_t uint8_t frameSubtype = (header->frameControl & 0x00F0) >> 4; bool isProbeRequest = (frameSubtype == 0x04); + bool isProbeResponse = (frameSubtype == 0x05); bool isBeacon = (frameSubtype == 0x08); - - if (!isProbeRequest && !isBeacon) return; - + + if (!isProbeRequest && !isProbeResponse && !isBeacon) return; + WiFiFrameEvent event; memset(&event, 0, sizeof(event)); - + memcpy(event.mac, header->source, 6); event.rssi = packet->rx_ctrl.rssi; event.frameSubtype = frameSubtype; event.channel = RadioScannerManager::currentWifiChannel; - + const uint8_t* payload = rawData + sizeof(WiFi80211Header); - - if (isBeacon) { + + if (isBeacon || isProbeResponse) { payload += 12; } diff --git a/flipper-zero/dev-board-firmware/flocksquawk-flipper/src/RadioScanner.h b/flipper-zero/dev-board-firmware/flocksquawk-flipper/src/RadioScanner.h index d86772c..f140704 100644 --- a/flipper-zero/dev-board-firmware/flocksquawk-flipper/src/RadioScanner.h +++ b/flipper-zero/dev-board-firmware/flocksquawk-flipper/src/RadioScanner.h @@ -28,8 +28,8 @@ class RadioScannerManager { public: static const uint8_t MAX_WIFI_CHANNEL = 13; - static const uint16_t CHANNEL_SWITCH_MS = 500; - static const uint8_t BLE_SCAN_SECONDS = 1; + static const uint16_t CHANNEL_SWITCH_MS = 300; + static const uint8_t BLE_SCAN_SECONDS = 2; static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; void initialize(); diff --git a/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino b/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino index 911ef9e..891e297 100644 --- a/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino +++ b/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino @@ -307,21 +307,22 @@ void RadioScannerManager::wifiPacketHandler(void* buffer, wifi_promiscuous_pkt_t uint8_t frameSubtype = (header->frameControl & 0x00F0) >> 4; bool isProbeRequest = (frameSubtype == 0x04); + bool isProbeResponse = (frameSubtype == 0x05); bool isBeacon = (frameSubtype == 0x08); - - if (!isProbeRequest && !isBeacon) return; - + + if (!isProbeRequest && !isProbeResponse && !isBeacon) return; + WiFiFrameEvent event; memset(&event, 0, sizeof(event)); - + memcpy(event.mac, header->source, 6); event.rssi = packet->rx_ctrl.rssi; event.frameSubtype = frameSubtype; event.channel = RadioScannerManager::currentWifiChannel; - + const uint8_t* payload = rawData + sizeof(WiFi80211Header); - - if (isBeacon) { + + if (isBeacon || isProbeResponse) { payload += 12; } diff --git a/m5stack/flocksquawk_m5fire/src/RadioScanner.h b/m5stack/flocksquawk_m5fire/src/RadioScanner.h index 4c85869..2b005e5 100644 --- a/m5stack/flocksquawk_m5fire/src/RadioScanner.h +++ b/m5stack/flocksquawk_m5fire/src/RadioScanner.h @@ -13,8 +13,8 @@ class RadioScannerManager { public: static const uint8_t MAX_WIFI_CHANNEL = 13; - static const uint16_t CHANNEL_SWITCH_MS = 500; - static const uint8_t BLE_SCAN_SECONDS = 1; + static const uint16_t CHANNEL_SWITCH_MS = 300; + static const uint8_t BLE_SCAN_SECONDS = 2; static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; void initialize(); diff --git a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino index a2a3a0f..b7f5aeb 100644 --- a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino +++ b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino @@ -493,21 +493,22 @@ void RadioScannerManager::wifiPacketHandler(void* buffer, wifi_promiscuous_pkt_t uint8_t frameSubtype = (header->frameControl & 0x00F0) >> 4; bool isProbeRequest = (frameSubtype == 0x04); + bool isProbeResponse = (frameSubtype == 0x05); bool isBeacon = (frameSubtype == 0x08); - - if (!isProbeRequest && !isBeacon) return; - + + if (!isProbeRequest && !isProbeResponse && !isBeacon) return; + WiFiFrameEvent event; memset(&event, 0, sizeof(event)); - + memcpy(event.mac, header->source, 6); event.rssi = packet->rx_ctrl.rssi; event.frameSubtype = frameSubtype; event.channel = RadioScannerManager::currentWifiChannel; - + const uint8_t* payload = rawData + sizeof(WiFi80211Header); - - if (isBeacon) { + + if (isBeacon || isProbeResponse) { payload += 12; } diff --git a/m5stack/flocksquawk_m5stick/src/RadioScanner.h b/m5stack/flocksquawk_m5stick/src/RadioScanner.h index fb369bd..8f5621b 100644 --- a/m5stack/flocksquawk_m5stick/src/RadioScanner.h +++ b/m5stack/flocksquawk_m5stick/src/RadioScanner.h @@ -13,8 +13,8 @@ class RadioScannerManager { public: static const uint8_t MAX_WIFI_CHANNEL = 13; - static const uint16_t CHANNEL_SWITCH_MS = 1000; - static const uint8_t BLE_SCAN_SECONDS = 1; + static const uint16_t CHANNEL_SWITCH_MS = 300; + static const uint8_t BLE_SCAN_SECONDS = 2; static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; void initialize(); From 2f1e5cdeedc222a0efdebda007ee4b57d7b2d46f Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 2 Feb 2026 00:52:14 -0700 Subject: [PATCH 15/23] Replace scoring thresholds with flag-based alert tiers --- .../flocksquawk_128x32/flocksquawk_128x32.ino | 2 + .../flocksquawk_128x32_portable.ino | 2 + .../flocksquawk_mini12864.ino | 2 + common/DetectorTypes.h | 13 +- common/Detectors.h | 23 +- common/DeviceSignatures.h | 3 + common/EventBus.h | 2 + common/TelemetryReporter.h | 5 +- common/ThreatAnalyzer.h | 79 +++++-- .../flocksquawk-flipper.ino | 4 +- .../flocksquawk_m5fire/flocksquawk_m5fire.ino | 6 +- .../flocksquawk_m5stick.ino | 6 +- test/test_device_tracker.cpp | 49 ++-- test/test_threat_analyzer.cpp | 221 ++++++++++++++---- 14 files changed, 316 insertions(+), 101 deletions(-) diff --git a/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino b/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino index a84fae4..d8b4d97 100644 --- a/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino +++ b/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino @@ -563,6 +563,8 @@ void loop() { EventBus::publishAudioRequest(audioEvent); } } + // SUSPICIOUS events are captured via telemetry; display-only variants + // show them through the normal DisplayEngine state machine. displaySystem.update(); delay(100); diff --git a/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino b/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino index 380e0e5..a241357 100644 --- a/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino +++ b/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino @@ -486,6 +486,8 @@ void loop() { buzzerBeep(2800, 120); delay(80); buzzerBeep(2800, 120); + } else if (threatCopy.alertLevel == ALERT_SUSPICIOUS && threatCopy.firstDetection) { + buzzerBeep(1800, 60); } } diff --git a/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino b/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino index 9abbf86..147d067 100644 --- a/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino +++ b/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino @@ -433,6 +433,8 @@ void loop() { AudioEvent audioEvent; audioEvent.soundFile = "/alert.wav"; EventBus::publishAudioRequest(audioEvent); + } else if (threatCopy.alertLevel == ALERT_SUSPICIOUS && threatCopy.firstDetection) { + Mini12864DisplayShowAlert(); } } diff --git a/common/DetectorTypes.h b/common/DetectorTypes.h index 8d2d1fd..f72ff5a 100644 --- a/common/DetectorTypes.h +++ b/common/DetectorTypes.h @@ -22,6 +22,8 @@ enum DetectorFlag : uint16_t { DET_RAVEN_CUSTOM_UUID = (1 << 4), DET_RAVEN_STD_UUID = (1 << 5), DET_RSSI_MODIFIER = (1 << 6), + DET_FLOCK_OUI = (1 << 7), + DET_SURVEILLANCE_OUI = (1 << 8), }; // Forward declarations (defined in EventBus.h) @@ -43,6 +45,14 @@ struct BLEDetectorEntry { DetectorFlag flag; }; +// Alert severity tiers — derived from detector flags, not numeric scores. +enum AlertLevel : uint8_t { + ALERT_NONE = 0, // no match (event still published for telemetry) + ALERT_INFO = 1, // other surveillance camera OUI — display only + ALERT_SUSPICIOUS = 2, // weak signal, needs context + ALERT_CONFIRMED = 3, // high confidence — full alert +}; + // Device presence tracking enum class DeviceState : uint8_t { EMPTY, @@ -55,7 +65,7 @@ struct TrackedDevice { uint8_t mac[6]; uint32_t firstSeenMs; uint32_t lastSeenMs; - uint8_t maxCertainty; + AlertLevel maxAlertLevel; DeviceState state; // 6 + 4 + 4 + 1 + 1 = 16 bytes per slot }; @@ -64,6 +74,5 @@ struct TrackedDevice { static const uint8_t MAX_TRACKED_DEVICES = 32; static const uint32_t DEVICE_TIMEOUT_MS = 60000; static const uint32_t HEARTBEAT_INTERVAL_MS = 10000; -static const uint8_t ALERT_THRESHOLD = 65; #endif diff --git a/common/Detectors.h b/common/Detectors.h index c78d00f..5240db9 100644 --- a/common/Detectors.h +++ b/common/Detectors.h @@ -84,7 +84,7 @@ inline DetectorResult detectSsidKeyword(const WiFiFrameEvent& frame) { if (ssid[0] == '\0') return r; static const char* const keywords[] = { - "flock", "penguin", "pigvision" + "flock", "penguin", "pigvision", "test_flck" }; static const uint8_t count = sizeof(keywords) / sizeof(keywords[0]); @@ -104,6 +104,17 @@ inline DetectorResult detectWifiMacOui(const WiFiFrameEvent& frame) { return r; } +// Flock Safety OUI Match (weight 90) +// B4:1E:52 is Flock Safety's own registered MAC prefix — near-certain. +inline DetectorResult detectFlockOui(const WiFiFrameEvent& frame) { + DetectorResult r = { false, 90, "flock_oui" }; + char macStr[9]; + snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x", frame.mac[0], frame.mac[1], frame.mac[2]); + if (strncasecmp(macStr, DeviceProfiles::FlockSafetyOUI, 8) == 0) + r.matched = true; + return r; +} + // ============================================================ // BLE Detectors // ============================================================ @@ -169,6 +180,16 @@ inline DetectorResult detectBleMacOui(const BluetoothDeviceEvent& device) { return r; } +// BLE Flock Safety OUI Match (weight 90) +inline DetectorResult detectBleFlockOui(const BluetoothDeviceEvent& device) { + DetectorResult r = { false, 90, "flock_oui" }; + char macStr[9]; + snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x", device.mac[0], device.mac[1], device.mac[2]); + if (strncasecmp(macStr, DeviceProfiles::FlockSafetyOUI, 8) == 0) + r.matched = true; + return r; +} + // ============================================================ // RSSI Modifier // ============================================================ diff --git a/common/DeviceSignatures.h b/common/DeviceSignatures.h index fd7b3b4..7097810 100644 --- a/common/DeviceSignatures.h +++ b/common/DeviceSignatures.h @@ -13,6 +13,9 @@ namespace DeviceProfiles { "74:4c:a1", "08:3a:88", "9c:2f:9d", "94:08:53", "e4:aa:ea" }; const size_t MACPrefixCount = sizeof(MACPrefixes) / sizeof(MACPrefixes[0]); + + // Flock Safety (direct OUI registration — high confidence) + const char* const FlockSafetyOUI = "b4:1e:52"; } #endif diff --git a/common/EventBus.h b/common/EventBus.h index 6d1513f..9344273 100644 --- a/common/EventBus.h +++ b/common/EventBus.h @@ -39,6 +39,8 @@ struct ThreatEvent { uint16_t matchFlags; uint8_t detectorWeights[8]; int8_t rssiModifier; + uint8_t alertLevel; // AlertLevel enum value + bool firstDetection; // true when device was not previously tracked bool shouldAlert; }; diff --git a/common/TelemetryReporter.h b/common/TelemetryReporter.h index 55d5c29..c9633ab 100644 --- a/common/TelemetryReporter.h +++ b/common/TelemetryReporter.h @@ -34,6 +34,7 @@ class TelemetryReporter { target["mac"] = macStr; target["label"] = threat.identifier; target["certainty"] = threat.certainty; + target["alert_level"] = threat.alertLevel; target["category"] = threat.category; target["should_alert"] = threat.shouldAlert; @@ -43,10 +44,10 @@ class TelemetryReporter { static const char* const detectorNames[] = { "ssid_format", "ssid_keyword", "mac_oui", "ble_name", "raven_custom_uuid", "raven_std_uuid", - "rssi_modifier" + "rssi_modifier", "flock_oui", "surveillance_oui" }; - for (uint8_t bit = 0; bit < 7; bit++) { + for (uint8_t bit = 0; bit < 9; bit++) { if (threat.matchFlags & (1 << bit)) { if (bit == 6) { // rssi_modifier is signed diff --git a/common/ThreatAnalyzer.h b/common/ThreatAnalyzer.h index 9891223..5b57560 100644 --- a/common/ThreatAnalyzer.h +++ b/common/ThreatAnalyzer.h @@ -15,6 +15,7 @@ static const WiFiDetectorEntry wifiDetectors[] = { { detectSsidFormat, DET_SSID_FORMAT }, { detectSsidKeyword, DET_SSID_KEYWORD }, { detectWifiMacOui, DET_MAC_OUI }, + { detectFlockOui, DET_FLOCK_OUI }, }; static const uint8_t WIFI_DETECTOR_COUNT = sizeof(wifiDetectors) / sizeof(wifiDetectors[0]); @@ -24,10 +25,41 @@ static const BLEDetectorEntry bleDetectors[] = { { detectRavenCustomUuid, DET_RAVEN_CUSTOM_UUID }, { detectRavenStdUuid, DET_RAVEN_STD_UUID }, { detectBleMacOui, DET_MAC_OUI }, + { detectBleFlockOui, DET_FLOCK_OUI }, }; static const uint8_t BLE_DETECTOR_COUNT = sizeof(bleDetectors) / sizeof(bleDetectors[0]); +// ============================================================ +// Flag-based alert tier computation +// ============================================================ + +inline AlertLevel computeWiFiAlertLevel(uint16_t matchFlags, bool hiddenSsid) { + if (matchFlags & (DET_SSID_FORMAT | DET_FLOCK_OUI)) + return ALERT_CONFIRMED; + if ((matchFlags & DET_SSID_KEYWORD) && (matchFlags & DET_MAC_OUI)) + return ALERT_CONFIRMED; + if (matchFlags & DET_SSID_KEYWORD) + return ALERT_SUSPICIOUS; + if ((matchFlags & DET_MAC_OUI) && hiddenSsid) + return ALERT_SUSPICIOUS; + if (matchFlags & DET_SURVEILLANCE_OUI) + return ALERT_INFO; + return ALERT_NONE; +} + +inline AlertLevel computeBLEAlertLevel(uint16_t matchFlags) { + if (matchFlags & (DET_BLE_NAME | DET_RAVEN_CUSTOM_UUID | DET_FLOCK_OUI)) + return ALERT_CONFIRMED; + if (matchFlags & DET_MAC_OUI) + return ALERT_SUSPICIOUS; + if (matchFlags & DET_RAVEN_STD_UUID) + return ALERT_SUSPICIOUS; + if (matchFlags & DET_SURVEILLANCE_OUI) + return ALERT_INFO; + return ALERT_NONE; +} + // ============================================================ // Device Presence Tracker // ============================================================ @@ -55,7 +87,7 @@ class DeviceTracker { // Record a detection. Returns the state the device was in BEFORE // this update (EMPTY = first time seen). DeviceState recordDetection(const uint8_t* mac, uint32_t nowMs, - uint8_t certainty) { + AlertLevel level) { for (uint8_t i = 0; i < MAX_TRACKED_DEVICES; i++) { if (slots[i].state != DeviceState::EMPTY && slots[i].state != DeviceState::DEPARTED && @@ -63,26 +95,26 @@ class DeviceTracker { DeviceState prev = slots[i].state; slots[i].lastSeenMs = nowMs; slots[i].state = DeviceState::IN_RANGE; - if (certainty > slots[i].maxCertainty) - slots[i].maxCertainty = certainty; + if (level > slots[i].maxAlertLevel) + slots[i].maxAlertLevel = level; return prev; } } uint8_t slot = findFreeSlot(); memcpy(slots[slot].mac, mac, 6); - slots[slot].firstSeenMs = nowMs; - slots[slot].lastSeenMs = nowMs; - slots[slot].maxCertainty = certainty; - slots[slot].state = DeviceState::NEW_DETECT; + slots[slot].firstSeenMs = nowMs; + slots[slot].lastSeenMs = nowMs; + slots[slot].maxAlertLevel = level; + slots[slot].state = DeviceState::NEW_DETECT; return DeviceState::EMPTY; } - // Returns true if any tracked device is IN_RANGE and above threshold. + // Returns true if any tracked device is IN_RANGE at SUSPICIOUS or above. bool hasHighConfidenceInRange() const { for (uint8_t i = 0; i < MAX_TRACKED_DEVICES; i++) { if (slots[i].state == DeviceState::IN_RANGE && - slots[i].maxCertainty >= ALERT_THRESHOLD) { + slots[i].maxAlertLevel >= ALERT_SUSPICIOUS) { return true; } } @@ -157,22 +189,19 @@ class ThreatAnalyzer { } } - // Subsumption: SSID format supersedes SSID keyword - if ((matchFlags & DET_SSID_FORMAT) && (matchFlags & DET_SSID_KEYWORD)) { - totalWeight -= weights[detectorBitPosition(DET_SSID_KEYWORD)]; - matchFlags &= ~DET_SSID_KEYWORD; - weights[detectorBitPosition(DET_SSID_KEYWORD)] = 0; - } - if (matchFlags == DET_NONE) return; + bool hiddenSsid = (frame.ssid[0] == '\0'); + int8_t rssiMod = rssiModifier(frame.rssi); totalWeight += rssiMod; uint8_t certainty = (uint8_t)constrain(totalWeight, 0, 100); + AlertLevel level = computeWiFiAlertLevel(matchFlags, hiddenSsid); + uint32_t nowMs = millis(); DeviceState prevState = tracker.recordDetection( - frame.mac, nowMs, certainty); + frame.mac, nowMs, level); ThreatEvent threat; memset(&threat, 0, sizeof(threat)); @@ -187,8 +216,10 @@ class ThreatAnalyzer { threat.matchFlags = matchFlags | DET_RSSI_MODIFIER; memcpy(threat.detectorWeights, weights, sizeof(weights)); threat.rssiModifier = rssiMod; - threat.shouldAlert = (certainty >= ALERT_THRESHOLD && - prevState == DeviceState::EMPTY); + threat.alertLevel = level; + threat.firstDetection = (prevState == DeviceState::EMPTY); + threat.shouldAlert = (level >= ALERT_CONFIRMED && + threat.firstDetection); EventBus::publishThreat(threat); } @@ -215,9 +246,11 @@ class ThreatAnalyzer { totalWeight += rssiMod; uint8_t certainty = (uint8_t)constrain(totalWeight, 0, 100); + AlertLevel level = computeBLEAlertLevel(matchFlags); + uint32_t nowMs = millis(); DeviceState prevState = tracker.recordDetection( - device.mac, nowMs, certainty); + device.mac, nowMs, level); const char* cat = (matchFlags & (DET_RAVEN_CUSTOM_UUID | DET_RAVEN_STD_UUID)) @@ -237,8 +270,10 @@ class ThreatAnalyzer { threat.matchFlags = matchFlags | DET_RSSI_MODIFIER; memcpy(threat.detectorWeights, weights, sizeof(weights)); threat.rssiModifier = rssiMod; - threat.shouldAlert = (certainty >= ALERT_THRESHOLD && - prevState == DeviceState::EMPTY); + threat.alertLevel = level; + threat.firstDetection = (prevState == DeviceState::EMPTY); + threat.shouldAlert = (level >= ALERT_CONFIRMED && + threat.firstDetection); EventBus::publishThreat(threat); } diff --git a/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino b/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino index c925c11..5f5e46f 100644 --- a/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino +++ b/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino @@ -469,9 +469,7 @@ void loop() { threatCopy = pendingThreat; threatPending = false; portEXIT_CRITICAL(&threatMux); - if (threatCopy.shouldAlert) { - reporter.handleThreatDetection(threatCopy); - } + reporter.handleThreatDetection(threatCopy); } reporter.update(); diff --git a/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino b/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino index 891e297..1f5d2f1 100644 --- a/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino +++ b/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino @@ -1167,7 +1167,11 @@ void loop() { threatPending = false; portEXIT_CRITICAL(&threatMux); reporter.handleThreatDetection(threatCopy); - if (threatCopy.shouldAlert) triggerAlert(true); + if (threatCopy.shouldAlert) { + triggerAlert(true); + } else if (threatCopy.alertLevel == ALERT_SUSPICIOUS && threatCopy.firstDetection) { + M5.Speaker.tone(1800, 60); + } } #if ENABLE_HOME_UI diff --git a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino index b7f5aeb..22d2511 100644 --- a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino +++ b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino @@ -632,7 +632,11 @@ void loop() { threatPending = false; portEXIT_CRITICAL(&threatMux); reporter.handleThreatDetection(threatCopy); - if (threatCopy.shouldAlert) triggerAlert(now); + if (threatCopy.shouldAlert) { + triggerAlert(now); + } else if (threatCopy.alertLevel == ALERT_SUSPICIOUS && threatCopy.firstDetection) { + M5.Speaker.tone(1800, 60); + } } if (M5.BtnB.pressedFor(2000) && !powerToggleHandled) { diff --git a/test/test_device_tracker.cpp b/test/test_device_tracker.cpp index 1cbe656..0630994 100644 --- a/test/test_device_tracker.cpp +++ b/test/test_device_tracker.cpp @@ -29,7 +29,7 @@ TEST_CASE("DeviceTracker: first detection returns EMPTY") { tracker.initialize(); uint8_t mac[6]; setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); - DeviceState prev = tracker.recordDetection(mac, 1000, 80); + DeviceState prev = tracker.recordDetection(mac, 1000, ALERT_CONFIRMED); CHECK(prev == DeviceState::EMPTY); } @@ -38,8 +38,8 @@ TEST_CASE("DeviceTracker: second detection returns NEW_DETECT") { tracker.initialize(); uint8_t mac[6]; setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); - tracker.recordDetection(mac, 1000, 80); - DeviceState prev = tracker.recordDetection(mac, 2000, 80); + tracker.recordDetection(mac, 1000, ALERT_CONFIRMED); + DeviceState prev = tracker.recordDetection(mac, 2000, ALERT_CONFIRMED); CHECK(prev == DeviceState::NEW_DETECT); } @@ -48,9 +48,9 @@ TEST_CASE("DeviceTracker: third detection returns IN_RANGE") { tracker.initialize(); uint8_t mac[6]; setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); - tracker.recordDetection(mac, 1000, 80); - tracker.recordDetection(mac, 2000, 80); - DeviceState prev = tracker.recordDetection(mac, 3000, 80); + tracker.recordDetection(mac, 1000, ALERT_CONFIRMED); + tracker.recordDetection(mac, 2000, ALERT_CONFIRMED); + DeviceState prev = tracker.recordDetection(mac, 3000, ALERT_CONFIRMED); CHECK(prev == DeviceState::IN_RANGE); } @@ -59,8 +59,8 @@ TEST_CASE("DeviceTracker: timeout transitions to DEPARTED") { tracker.initialize(); uint8_t mac[6]; setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); - tracker.recordDetection(mac, 1000, 80); - tracker.recordDetection(mac, 2000, 80); // now IN_RANGE, lastSeenMs=2000 + tracker.recordDetection(mac, 1000, ALERT_CONFIRMED); + tracker.recordDetection(mac, 2000, ALERT_CONFIRMED); // now IN_RANGE, lastSeenMs=2000 // Tick past the 60s timeout from last-seen time tracker.tick(2000 + DEVICE_TIMEOUT_MS + 1); CHECK_FALSE(tracker.hasHighConfidenceInRange()); @@ -72,30 +72,29 @@ TEST_CASE("DeviceTracker: hasHighConfidenceInRange") { uint8_t mac[6]; setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); - SUBCASE("above threshold and IN_RANGE returns true") { - tracker.recordDetection(mac, 1000, 80); - tracker.recordDetection(mac, 2000, 80); // IN_RANGE + SUBCASE("SUSPICIOUS and IN_RANGE returns true") { + tracker.recordDetection(mac, 1000, ALERT_SUSPICIOUS); + tracker.recordDetection(mac, 2000, ALERT_SUSPICIOUS); // IN_RANGE CHECK(tracker.hasHighConfidenceInRange()); } - SUBCASE("below threshold returns false") { - tracker.recordDetection(mac, 1000, 30); - tracker.recordDetection(mac, 2000, 30); + SUBCASE("NONE level returns false") { + tracker.recordDetection(mac, 1000, ALERT_NONE); + tracker.recordDetection(mac, 2000, ALERT_NONE); CHECK_FALSE(tracker.hasHighConfidenceInRange()); } SUBCASE("NEW_DETECT alone is not IN_RANGE") { - // NEW_DETECT with high certainty shouldn't count - tracker.recordDetection(mac, 1000, 90); + tracker.recordDetection(mac, 1000, ALERT_CONFIRMED); CHECK_FALSE(tracker.hasHighConfidenceInRange()); } } -TEST_CASE("DeviceTracker: max certainty updates on higher value") { +TEST_CASE("DeviceTracker: max alert level updates on higher value") { DeviceTracker tracker; tracker.initialize(); uint8_t mac[6]; setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); - tracker.recordDetection(mac, 1000, 30); // NEW_DETECT - tracker.recordDetection(mac, 2000, 80); // IN_RANGE, certainty bumped to 80 + tracker.recordDetection(mac, 1000, ALERT_NONE); // NEW_DETECT + tracker.recordDetection(mac, 2000, ALERT_SUSPICIOUS); // IN_RANGE, level bumped CHECK(tracker.hasHighConfidenceInRange()); } @@ -106,13 +105,13 @@ TEST_CASE("DeviceTracker: LRU eviction prefers empty slots") { for (uint8_t i = 0; i < MAX_TRACKED_DEVICES; i++) { uint8_t mac[6]; setMAC(mac, 0x10, 0x20, 0x30, 0x00, 0x00, i); - tracker.recordDetection(mac, 1000 + i, 50); + tracker.recordDetection(mac, 1000 + i, ALERT_SUSPICIOUS); } // 33rd device should evict the oldest departed (none departed) // so it evicts oldest active device (slot 0, lastSeen=1000) uint8_t newMac[6]; setMAC(newMac, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x01); - DeviceState prev = tracker.recordDetection(newMac, 5000, 70); + DeviceState prev = tracker.recordDetection(newMac, 5000, ALERT_CONFIRMED); CHECK(prev == DeviceState::EMPTY); } @@ -123,14 +122,14 @@ TEST_CASE("DeviceTracker: eviction prefers departed over active") { for (uint8_t i = 0; i < MAX_TRACKED_DEVICES; i++) { uint8_t mac[6]; setMAC(mac, 0x10, 0x20, 0x30, 0x00, 0x00, i); - tracker.recordDetection(mac, 1000, 50); + tracker.recordDetection(mac, 1000, ALERT_SUSPICIOUS); } // Timeout all → DEPARTED tracker.tick(1000 + DEVICE_TIMEOUT_MS + 1); // New device should reuse a departed slot uint8_t newMac[6]; setMAC(newMac, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x01); - DeviceState prev = tracker.recordDetection(newMac, 200000, 70); + DeviceState prev = tracker.recordDetection(newMac, 200000, ALERT_CONFIRMED); CHECK(prev == DeviceState::EMPTY); } @@ -139,10 +138,10 @@ TEST_CASE("DeviceTracker: NEW_DETECT times out") { tracker.initialize(); uint8_t mac[6]; setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); - tracker.recordDetection(mac, 1000, 80); // NEW_DETECT + tracker.recordDetection(mac, 1000, ALERT_CONFIRMED); // NEW_DETECT // Tick past timeout — NEW_DETECT should also depart tracker.tick(1000 + DEVICE_TIMEOUT_MS + 1); // Re-detect should return EMPTY (departed slot doesn't match) - DeviceState prev = tracker.recordDetection(mac, 200000, 80); + DeviceState prev = tracker.recordDetection(mac, 200000, ALERT_CONFIRMED); CHECK(prev == DeviceState::EMPTY); } diff --git a/test/test_threat_analyzer.cpp b/test/test_threat_analyzer.cpp index 04fdf67..bce775f 100644 --- a/test/test_threat_analyzer.cpp +++ b/test/test_threat_analyzer.cpp @@ -53,7 +53,7 @@ static BluetoothDeviceEvent makeBLEDevice(const char* name = "", } // ============================================================ -// WiFi scoring +// WiFi basic detection // ============================================================ TEST_CASE("ThreatAnalyzer: WiFi SSID format match produces threat") { @@ -79,82 +79,148 @@ TEST_CASE("ThreatAnalyzer: WiFi no-match produces no threat") { CHECK(threatCount == 0); } -TEST_CASE("ThreatAnalyzer: WiFi subsumption removes keyword weight") { - // "Flock-a1b2c3" matches both SSID_FORMAT (75) and SSID_KEYWORD (45). - // Subsumption should remove the keyword weight. +TEST_CASE("ThreatAnalyzer: WiFi RSSI modifier applied") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto frame = makeWiFiFrame("Flock-a1b2c3", -30); + frame.mac[5] = 0x10; + analyzer.analyzeWiFiFrame(frame); + REQUIRE(threatCount == 1); + CHECK(lastThreat.rssiModifier == 10); +} + +TEST_CASE("ThreatAnalyzer: WiFi certainty clamped to 100") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto frame = makeWiFiFrame("Flock-a1b2c3", -30); + frame.mac[0] = 0x58; frame.mac[1] = 0x8E; frame.mac[2] = 0x81; + frame.mac[3] = 0x99; frame.mac[4] = 0x99; frame.mac[5] = 0x99; + analyzer.analyzeWiFiFrame(frame); + REQUIRE(threatCount == 1); + CHECK(lastThreat.certainty == 100); +} + +// ============================================================ +// WiFi alert level tiers +// ============================================================ + +TEST_CASE("ThreatAnalyzer: SSID format match is CONFIRMED") { ThreatAnalyzer analyzer; analyzer.initialize(); resetCapture(); mock_millis_value = 5000; - // RSSI -60 → rssiModifier = 0 analyzer.analyzeWiFiFrame(makeWiFiFrame("Flock-a1b2c3", -60)); REQUIRE(threatCount == 1); - // Should be 75 (format) + 0 (rssi) = 75, NOT 75+45=120 - CHECK(lastThreat.certainty == 75); - // SSID_KEYWORD flag should be cleared - CHECK((lastThreat.matchFlags & DET_SSID_KEYWORD) == 0); + CHECK(lastThreat.alertLevel == ALERT_CONFIRMED); + CHECK(lastThreat.shouldAlert == true); CHECK((lastThreat.matchFlags & DET_SSID_FORMAT) != 0); } -TEST_CASE("ThreatAnalyzer: WiFi RSSI modifier applied") { +TEST_CASE("ThreatAnalyzer: SSID format without OUI is still CONFIRMED") { ThreatAnalyzer analyzer; analyzer.initialize(); resetCapture(); mock_millis_value = 5000; - // "Flock-a1b2c3" at RSSI -30 → format=75, rssi=+10 → 85 - auto frame = makeWiFiFrame("Flock-a1b2c3", -30); - // Give unique MAC to avoid hitting existing device - frame.mac[5] = 0x10; + // MAC doesn't match any known OUI + auto frame = makeWiFiFrame("Flock-a1b2c3", -60); + frame.mac[0] = 0xAA; frame.mac[1] = 0xBB; frame.mac[2] = 0xCC; + frame.mac[3] = 0x01; frame.mac[4] = 0x02; frame.mac[5] = 0x03; analyzer.analyzeWiFiFrame(frame); REQUIRE(threatCount == 1); - CHECK(lastThreat.certainty == 85); - CHECK(lastThreat.rssiModifier == 10); + CHECK(lastThreat.alertLevel == ALERT_CONFIRMED); } -TEST_CASE("ThreatAnalyzer: WiFi certainty clamped to 100") { - // Keyword-only at very close range: 45 + 10 = 55 (no clamp needed) - // But let's verify format + OUI + close range doesn't exceed 100: - // format=75, OUI=20, rssi=+10 = 105 → clamped to 100 +TEST_CASE("ThreatAnalyzer: SSID keyword + Lite-On OUI is CONFIRMED") { ThreatAnalyzer analyzer; analyzer.initialize(); resetCapture(); mock_millis_value = 5000; - auto frame = makeWiFiFrame("Flock-a1b2c3", -30); - // Set a known OUI + auto frame = makeWiFiFrame("test_flck", -60); frame.mac[0] = 0x58; frame.mac[1] = 0x8E; frame.mac[2] = 0x81; - frame.mac[3] = 0x99; frame.mac[4] = 0x99; frame.mac[5] = 0x99; + frame.mac[3] = 0xD1; frame.mac[4] = 0xD2; frame.mac[5] = 0xD3; analyzer.analyzeWiFiFrame(frame); REQUIRE(threatCount == 1); - CHECK(lastThreat.certainty == 100); + CHECK(lastThreat.alertLevel == ALERT_CONFIRMED); + CHECK(lastThreat.shouldAlert == true); +} + +TEST_CASE("ThreatAnalyzer: SSID keyword alone is SUSPICIOUS") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + // "flock" keyword but no known OUI + auto frame = makeWiFiFrame("Flockdale WiFi", -60); + frame.mac[0] = 0xAA; frame.mac[1] = 0xBB; frame.mac[2] = 0xCC; + frame.mac[3] = 0xE1; frame.mac[4] = 0xE2; frame.mac[5] = 0xE3; + analyzer.analyzeWiFiFrame(frame); + REQUIRE(threatCount == 1); + CHECK(lastThreat.alertLevel == ALERT_SUSPICIOUS); + CHECK(lastThreat.shouldAlert == false); +} + +TEST_CASE("ThreatAnalyzer: Lite-On OUI + hidden SSID is SUSPICIOUS") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto frame = makeWiFiFrame("", -60); + frame.ssid[0] = '\0'; + frame.mac[0] = 0x58; frame.mac[1] = 0x8E; frame.mac[2] = 0x81; + frame.mac[3] = 0xC0; frame.mac[4] = 0xC0; frame.mac[5] = 0xC0; + analyzer.analyzeWiFiFrame(frame); + REQUIRE(threatCount == 1); + CHECK(lastThreat.alertLevel == ALERT_SUSPICIOUS); + CHECK(lastThreat.shouldAlert == false); } -TEST_CASE("ThreatAnalyzer: WiFi certainty clamped to 0 minimum") { - // Keyword alone (45) at very weak signal (-90 → -10) = 35 - // That's still positive. Use OUI alone (20) at -90 → 20-10 = 10. - // Can't easily get negative with existing detectors, but verify >= 0. +TEST_CASE("ThreatAnalyzer: Lite-On OUI + visible non-matching SSID is NONE") { ThreatAnalyzer analyzer; analyzer.initialize(); resetCapture(); mock_millis_value = 5000; - // MAC OUI match only (weight 20) at RSSI -90 → 20 + (-10) = 10 - auto frame = makeWiFiFrame("", -90); - frame.ssid[0] = '\0'; // ensure no SSID match + auto frame = makeWiFiFrame("SomeNetwork", -60); frame.mac[0] = 0x58; frame.mac[1] = 0x8E; frame.mac[2] = 0x81; - frame.mac[3] = 0xBB; frame.mac[4] = 0xBB; frame.mac[5] = 0xBB; + frame.mac[3] = 0xC4; frame.mac[4] = 0xC4; frame.mac[5] = 0xC4; analyzer.analyzeWiFiFrame(frame); REQUIRE(threatCount == 1); - CHECK(lastThreat.certainty == 10); + CHECK(lastThreat.alertLevel == ALERT_NONE); + CHECK(lastThreat.shouldAlert == false); +} + +TEST_CASE("ThreatAnalyzer: B4:1E:52 Flock Safety OUI is CONFIRMED") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto frame = makeWiFiFrame("SomeSSID", -60); + frame.mac[0] = 0xB4; frame.mac[1] = 0x1E; frame.mac[2] = 0x52; + frame.mac[3] = 0xAA; frame.mac[4] = 0xBB; frame.mac[5] = 0xCC; + analyzer.analyzeWiFiFrame(frame); + REQUIRE(threatCount == 1); + CHECK(lastThreat.alertLevel == ALERT_CONFIRMED); + CHECK((lastThreat.matchFlags & DET_FLOCK_OUI) != 0); + CHECK(lastThreat.shouldAlert == true); } // ============================================================ -// shouldAlert logic +// shouldAlert / firstDetection logic // ============================================================ -TEST_CASE("ThreatAnalyzer: shouldAlert true on first high-certainty detection") { +TEST_CASE("ThreatAnalyzer: shouldAlert true on first CONFIRMED detection") { ThreatAnalyzer analyzer; analyzer.initialize(); resetCapture(); @@ -163,7 +229,8 @@ TEST_CASE("ThreatAnalyzer: shouldAlert true on first high-certainty detection") analyzer.analyzeWiFiFrame(makeWiFiFrame("Flock-a1b2c3", -60)); REQUIRE(threatCount == 1); CHECK(lastThreat.shouldAlert == true); - CHECK(lastThreat.certainty >= ALERT_THRESHOLD); + CHECK(lastThreat.firstDetection == true); + CHECK(lastThreat.alertLevel == ALERT_CONFIRMED); } TEST_CASE("ThreatAnalyzer: shouldAlert false on repeat detection") { @@ -181,29 +248,32 @@ TEST_CASE("ThreatAnalyzer: shouldAlert false on repeat detection") { mock_millis_value = 6000; analyzer.analyzeWiFiFrame(frame); CHECK(lastThreat.shouldAlert == false); + CHECK(lastThreat.firstDetection == false); } -TEST_CASE("ThreatAnalyzer: shouldAlert false when below threshold") { +TEST_CASE("ThreatAnalyzer: shouldAlert false for SUSPICIOUS level") { ThreatAnalyzer analyzer; analyzer.initialize(); resetCapture(); mock_millis_value = 5000; - // OUI-only match at weak signal: certainty = 20 + (-10) = 10 - auto frame = makeWiFiFrame("", -90); + // Hidden SSID + OUI = SUSPICIOUS, not CONFIRMED + auto frame = makeWiFiFrame("", -60); frame.ssid[0] = '\0'; frame.mac[0] = 0xCC; frame.mac[1] = 0xCC; frame.mac[2] = 0xCC; frame.mac[3] = 0xAA; frame.mac[4] = 0xAA; frame.mac[5] = 0xAA; analyzer.analyzeWiFiFrame(frame); REQUIRE(threatCount == 1); + CHECK(lastThreat.alertLevel == ALERT_SUSPICIOUS); CHECK(lastThreat.shouldAlert == false); + CHECK(lastThreat.firstDetection == true); } // ============================================================ -// BLE scoring +// BLE alert levels // ============================================================ -TEST_CASE("ThreatAnalyzer: BLE Raven UUID produces acoustic_detector category") { +TEST_CASE("ThreatAnalyzer: BLE Raven custom UUID is CONFIRMED") { ThreatAnalyzer analyzer; analyzer.initialize(); resetCapture(); @@ -213,12 +283,13 @@ TEST_CASE("ThreatAnalyzer: BLE Raven UUID produces acoustic_detector category") device.mac[5] = 0x30; analyzer.analyzeBluetoothDevice(device); REQUIRE(threatCount == 1); + CHECK(lastThreat.alertLevel == ALERT_CONFIRMED); CHECK(strcmp(lastThreat.category, "acoustic_detector") == 0); CHECK(strcmp(lastThreat.radioType, "bluetooth") == 0); CHECK(lastThreat.channel == 0); } -TEST_CASE("ThreatAnalyzer: BLE name-only produces surveillance_device category") { +TEST_CASE("ThreatAnalyzer: BLE name match is CONFIRMED") { ThreatAnalyzer analyzer; analyzer.initialize(); resetCapture(); @@ -228,9 +299,54 @@ TEST_CASE("ThreatAnalyzer: BLE name-only produces surveillance_device category") device.mac[5] = 0x40; analyzer.analyzeBluetoothDevice(device); REQUIRE(threatCount == 1); + CHECK(lastThreat.alertLevel == ALERT_CONFIRMED); CHECK(strcmp(lastThreat.category, "surveillance_device") == 0); } +TEST_CASE("ThreatAnalyzer: BLE Flock Safety OUI is CONFIRMED") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto device = makeBLEDevice("", -60); + device.mac[0] = 0xB4; device.mac[1] = 0x1E; device.mac[2] = 0x52; + device.mac[3] = 0xDD; device.mac[4] = 0xEE; device.mac[5] = 0xFF; + analyzer.analyzeBluetoothDevice(device); + REQUIRE(threatCount == 1); + CHECK(lastThreat.alertLevel == ALERT_CONFIRMED); + CHECK((lastThreat.matchFlags & DET_FLOCK_OUI) != 0); +} + +TEST_CASE("ThreatAnalyzer: BLE Lite-On OUI only is SUSPICIOUS") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto device = makeBLEDevice("", -60); + device.mac[0] = 0x58; device.mac[1] = 0x8E; device.mac[2] = 0x81; + device.mac[3] = 0xA1; device.mac[4] = 0xA2; device.mac[5] = 0xA3; + analyzer.analyzeBluetoothDevice(device); + REQUIRE(threatCount == 1); + CHECK(lastThreat.alertLevel == ALERT_SUSPICIOUS); + CHECK(lastThreat.shouldAlert == false); +} + +TEST_CASE("ThreatAnalyzer: BLE Raven std UUID only is SUSPICIOUS") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto device = makeBLEDevice("", -60, "0000180a-0000-1000-8000-00805f9b34fb"); + device.mac[0] = 0xAA; device.mac[1] = 0xBB; device.mac[2] = 0xCC; + device.mac[3] = 0xB1; device.mac[4] = 0xB2; device.mac[5] = 0xB3; + analyzer.analyzeBluetoothDevice(device); + REQUIRE(threatCount == 1); + CHECK(lastThreat.alertLevel == ALERT_SUSPICIOUS); +} + TEST_CASE("ThreatAnalyzer: BLE no-match produces no threat") { ThreatAnalyzer analyzer; analyzer.initialize(); @@ -254,13 +370,13 @@ TEST_CASE("ThreatAnalyzer: tick returns false when no devices tracked") { CHECK_FALSE(analyzer.tick(HEARTBEAT_INTERVAL_MS)); } -TEST_CASE("ThreatAnalyzer: tick returns true when high-confidence device in range") { +TEST_CASE("ThreatAnalyzer: tick returns true when confirmed device in range") { ThreatAnalyzer analyzer; analyzer.initialize(); resetCapture(); mock_millis_value = 1000; - // Inject a high-certainty device + // Inject a CONFIRMED device auto frame = makeWiFiFrame("Flock-a1b2c3", -60); frame.mac[5] = 0x60; analyzer.analyzeWiFiFrame(frame); @@ -288,3 +404,20 @@ TEST_CASE("ThreatAnalyzer: tick returns false before heartbeat interval") { // Tick before interval elapses CHECK_FALSE(analyzer.tick(HEARTBEAT_INTERVAL_MS - 1)); } + +// ============================================================ +// test_flck keyword +// ============================================================ + +TEST_CASE("ThreatAnalyzer: test_flck keyword triggers detection") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto frame = makeWiFiFrame("test_flck", -60); + frame.mac[3] = 0xF1; frame.mac[4] = 0xF2; frame.mac[5] = 0xF3; + analyzer.analyzeWiFiFrame(frame); + REQUIRE(threatCount == 1); + CHECK((lastThreat.matchFlags & DET_SSID_KEYWORD) != 0); +} From 57602beeb68b07349ef2c6445fac38504a76c660 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 2 Feb 2026 00:52:19 -0700 Subject: [PATCH 16/23] Add Flock Safety and surveillance camera OUI detection --- common/DetectorTypes.h | 3 +++ common/Detectors.h | 28 ++++++++++++++++++++++++ common/DeviceSignatures.h | 36 ++++++++++++++++++++++++++++++ common/EventBus.h | 5 +++-- common/TelemetryReporter.h | 2 +- common/ThreatAnalyzer.h | 39 +++++++++++++++++++-------------- test/test_threat_analyzer.cpp | 41 +++++++++++++++++++++++++++++++++++ 7 files changed, 135 insertions(+), 19 deletions(-) diff --git a/common/DetectorTypes.h b/common/DetectorTypes.h index f72ff5a..cdb9be8 100644 --- a/common/DetectorTypes.h +++ b/common/DetectorTypes.h @@ -26,6 +26,9 @@ enum DetectorFlag : uint16_t { DET_SURVEILLANCE_OUI = (1 << 8), }; +// Number of detector weight slots — one per DetectorFlag bit position. +static const uint8_t MAX_DETECTOR_WEIGHTS = 9; + // Forward declarations (defined in EventBus.h) struct WiFiFrameEvent; struct BluetoothDeviceEvent; diff --git a/common/Detectors.h b/common/Detectors.h index 5240db9..3f51cc4 100644 --- a/common/Detectors.h +++ b/common/Detectors.h @@ -190,6 +190,34 @@ inline DetectorResult detectBleFlockOui(const BluetoothDeviceEvent& device) { return r; } +// ============================================================ +// Surveillance Camera OUI Detectors +// ============================================================ + +inline bool ouiMatchesSurveillance(const uint8_t* mac) { + char macStr[9]; + snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x", mac[0], mac[1], mac[2]); + for (size_t i = 0; i < DeviceProfiles::SurveillancePrefixCount; i++) { + if (strncasecmp(macStr, DeviceProfiles::SurveillancePrefixes[i].prefix, 8) == 0) + return true; + } + return false; +} + +// WiFi Surveillance Camera OUI Match (weight 30) +inline DetectorResult detectSurveillanceOui(const WiFiFrameEvent& frame) { + DetectorResult r = { false, 30, "surveillance_oui" }; + if (ouiMatchesSurveillance(frame.mac)) r.matched = true; + return r; +} + +// BLE Surveillance Camera OUI Match (weight 30) +inline DetectorResult detectBleSurveillanceOui(const BluetoothDeviceEvent& device) { + DetectorResult r = { false, 30, "surveillance_oui" }; + if (ouiMatchesSurveillance(device.mac)) r.matched = true; + return r; +} + // ============================================================ // RSSI Modifier // ============================================================ diff --git a/common/DeviceSignatures.h b/common/DeviceSignatures.h index 7097810..e0ca76f 100644 --- a/common/DeviceSignatures.h +++ b/common/DeviceSignatures.h @@ -16,6 +16,42 @@ namespace DeviceProfiles { // Flock Safety (direct OUI registration — high confidence) const char* const FlockSafetyOUI = "b4:1e:52"; + + // Surveillance camera manufacturers (curated from FlockOff database). + // Dedicated security/surveillance companies only. + struct SurveillanceOUI { + const char* prefix; + const char* manufacturer; + }; + + const SurveillanceOUI SurveillancePrefixes[] = { + // Avigilon Alta + { "70:1a:d5", "Avigilon Alta" }, + // Axis Communications + { "00:40:8c", "Axis Communications" }, + { "ac:cc:8e", "Axis Communications" }, + { "b8:a4:4f", "Axis Communications" }, + { "e8:27:25", "Axis Communications" }, + // FLIR Systems + { "00:13:56", "FLIR Systems" }, + { "00:40:7f", "FLIR Systems" }, + { "00:1b:d8", "FLIR Systems" }, + // GeoVision + { "00:13:e2", "GeoVision" }, + // Hanwha Vision + { "44:b4:23", "Hanwha Vision" }, + { "8c:1d:55", "Hanwha Vision" }, + { "e4:30:22", "Hanwha Vision" }, + // March Networks + { "00:10:be", "March Networks" }, + { "00:12:81", "March Networks" }, + // Mobotix + { "00:03:c5", "Mobotix" }, + // Sunell Electronics + { "00:1c:27", "Sunell Electronics" }, + }; + const size_t SurveillancePrefixCount = + sizeof(SurveillancePrefixes) / sizeof(SurveillancePrefixes[0]); } #endif diff --git a/common/EventBus.h b/common/EventBus.h index 9344273..1d9f9b3 100644 --- a/common/EventBus.h +++ b/common/EventBus.h @@ -3,6 +3,7 @@ #include #include +#include "DetectorTypes.h" enum class EventType { WifiFrameCaptured, @@ -37,9 +38,9 @@ struct ThreatEvent { uint8_t certainty; char category[24]; uint16_t matchFlags; - uint8_t detectorWeights[8]; + uint8_t detectorWeights[MAX_DETECTOR_WEIGHTS]; int8_t rssiModifier; - uint8_t alertLevel; // AlertLevel enum value + AlertLevel alertLevel; bool firstDetection; // true when device was not previously tracked bool shouldAlert; }; diff --git a/common/TelemetryReporter.h b/common/TelemetryReporter.h index c9633ab..c19a92f 100644 --- a/common/TelemetryReporter.h +++ b/common/TelemetryReporter.h @@ -47,7 +47,7 @@ class TelemetryReporter { "rssi_modifier", "flock_oui", "surveillance_oui" }; - for (uint8_t bit = 0; bit < 9; bit++) { + for (uint8_t bit = 0; bit < MAX_DETECTOR_WEIGHTS; bit++) { if (threat.matchFlags & (1 << bit)) { if (bit == 6) { // rssi_modifier is signed diff --git a/common/ThreatAnalyzer.h b/common/ThreatAnalyzer.h index 5b57560..85614d7 100644 --- a/common/ThreatAnalyzer.h +++ b/common/ThreatAnalyzer.h @@ -12,20 +12,22 @@ // ============================================================ static const WiFiDetectorEntry wifiDetectors[] = { - { detectSsidFormat, DET_SSID_FORMAT }, - { detectSsidKeyword, DET_SSID_KEYWORD }, - { detectWifiMacOui, DET_MAC_OUI }, - { detectFlockOui, DET_FLOCK_OUI }, + { detectSsidFormat, DET_SSID_FORMAT }, + { detectSsidKeyword, DET_SSID_KEYWORD }, + { detectWifiMacOui, DET_MAC_OUI }, + { detectFlockOui, DET_FLOCK_OUI }, + { detectSurveillanceOui, DET_SURVEILLANCE_OUI }, }; static const uint8_t WIFI_DETECTOR_COUNT = sizeof(wifiDetectors) / sizeof(wifiDetectors[0]); static const BLEDetectorEntry bleDetectors[] = { - { detectBleName, DET_BLE_NAME }, - { detectRavenCustomUuid, DET_RAVEN_CUSTOM_UUID }, - { detectRavenStdUuid, DET_RAVEN_STD_UUID }, - { detectBleMacOui, DET_MAC_OUI }, - { detectBleFlockOui, DET_FLOCK_OUI }, + { detectBleName, DET_BLE_NAME }, + { detectRavenCustomUuid, DET_RAVEN_CUSTOM_UUID }, + { detectRavenStdUuid, DET_RAVEN_STD_UUID }, + { detectBleMacOui, DET_MAC_OUI }, + { detectBleFlockOui, DET_FLOCK_OUI }, + { detectBleSurveillanceOui, DET_SURVEILLANCE_OUI }, }; static const uint8_t BLE_DETECTOR_COUNT = sizeof(bleDetectors) / sizeof(bleDetectors[0]); @@ -175,7 +177,7 @@ class ThreatAnalyzer { void analyzeWiFiFrame(const WiFiFrameEvent& frame) { uint16_t matchFlags = 0; - uint8_t weights[8]; + uint8_t weights[MAX_DETECTOR_WEIGHTS]; memset(weights, 0, sizeof(weights)); int16_t totalWeight = 0; @@ -212,7 +214,9 @@ class ThreatAnalyzer { threat.channel = frame.channel; strncpy(threat.radioType, "wifi", sizeof(threat.radioType) - 1); threat.certainty = certainty; - strncpy(threat.category, "surveillance_device", sizeof(threat.category) - 1); + const char* wifiCat = (matchFlags & DET_SURVEILLANCE_OUI) + ? "surveillance_camera" : "surveillance_device"; + strncpy(threat.category, wifiCat, sizeof(threat.category) - 1); threat.matchFlags = matchFlags | DET_RSSI_MODIFIER; memcpy(threat.detectorWeights, weights, sizeof(weights)); threat.rssiModifier = rssiMod; @@ -226,7 +230,7 @@ class ThreatAnalyzer { void analyzeBluetoothDevice(const BluetoothDeviceEvent& device) { uint16_t matchFlags = 0; - uint8_t weights[8]; + uint8_t weights[MAX_DETECTOR_WEIGHTS]; memset(weights, 0, sizeof(weights)); int16_t totalWeight = 0; @@ -252,10 +256,13 @@ class ThreatAnalyzer { DeviceState prevState = tracker.recordDetection( device.mac, nowMs, level); - const char* cat = - (matchFlags & (DET_RAVEN_CUSTOM_UUID | DET_RAVEN_STD_UUID)) - ? "acoustic_detector" - : "surveillance_device"; + const char* cat; + if (matchFlags & (DET_RAVEN_CUSTOM_UUID | DET_RAVEN_STD_UUID)) + cat = "acoustic_detector"; + else if (matchFlags & DET_SURVEILLANCE_OUI) + cat = "surveillance_camera"; + else + cat = "surveillance_device"; ThreatEvent threat; memset(&threat, 0, sizeof(threat)); diff --git a/test/test_threat_analyzer.cpp b/test/test_threat_analyzer.cpp index bce775f..8fb4cb5 100644 --- a/test/test_threat_analyzer.cpp +++ b/test/test_threat_analyzer.cpp @@ -421,3 +421,44 @@ TEST_CASE("ThreatAnalyzer: test_flck keyword triggers detection") { REQUIRE(threatCount == 1); CHECK((lastThreat.matchFlags & DET_SSID_KEYWORD) != 0); } + +// ============================================================ +// Surveillance camera OUI detection +// ============================================================ + +TEST_CASE("ThreatAnalyzer: Axis Communications OUI produces INFO alert") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + // Axis Communications OUI: 00:40:8c + auto frame = makeWiFiFrame("AxisCam", -60); + frame.mac[0] = 0x00; frame.mac[1] = 0x40; frame.mac[2] = 0x8C; + frame.mac[3] = 0x01; frame.mac[4] = 0x02; frame.mac[5] = 0x03; + analyzer.analyzeWiFiFrame(frame); + REQUIRE(threatCount == 1); + CHECK(lastThreat.alertLevel == ALERT_INFO); + CHECK((lastThreat.matchFlags & DET_SURVEILLANCE_OUI) != 0); + CHECK(strcmp(lastThreat.category, "surveillance_camera") == 0); + CHECK(lastThreat.shouldAlert == false); + // Verify weight stored at correct bit position (was an OOB bug when array was [8]) + CHECK(lastThreat.detectorWeights[detectorBitPosition(DET_SURVEILLANCE_OUI)] == 30); +} + +TEST_CASE("ThreatAnalyzer: BLE Hanwha Vision OUI produces INFO alert") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + // Hanwha Vision OUI: 44:b4:23 + auto device = makeBLEDevice("", -60); + device.mac[0] = 0x44; device.mac[1] = 0xB4; device.mac[2] = 0x23; + device.mac[3] = 0x01; device.mac[4] = 0x02; device.mac[5] = 0x03; + analyzer.analyzeBluetoothDevice(device); + REQUIRE(threatCount == 1); + CHECK(lastThreat.alertLevel == ALERT_INFO); + CHECK((lastThreat.matchFlags & DET_SURVEILLANCE_OUI) != 0); + CHECK(strcmp(lastThreat.category, "surveillance_camera") == 0); +} From 8ff5dccf8b02ff4453855c5a27105732bae8384e Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 31 Jan 2026 19:51:34 -0700 Subject: [PATCH 17/23] Add power-aware scanning and display for M5 variants Make RadioScanner timing values dynamic across all variants by replacing static const members with static members initialized to battery-mode defaults. Add setPerformanceMode() to switch between battery and external-power scanning parameters. M5Stick and M5Fire main loops now detect external power via M5.Power.isCharging() and automatically switch to aggressive scanning (200ms dwell, 3s BLE scan, 4s interval) and force the display awake. Non-M5 variants get the API but remain at battery-mode defaults. Co-Authored-By: Claude Opus 4.5 --- .../flocksquawk_128x32/flocksquawk_128x32.ino | 3 +++ .../flocksquawk_128x32/src/RadioScanner.h | 24 +++++++++++++----- .../flocksquawk_128x32_portable.ino | 3 +++ .../src/RadioScanner.h | 24 +++++++++++++----- .../flocksquawk_mini12864.ino | 3 +++ .../flocksquawk_mini12864/src/RadioScanner.h | 24 +++++++++++++----- .../flocksquawk-flipper.ino | 3 +++ .../flocksquawk-flipper/src/RadioScanner.h | 24 +++++++++++++----- .../flocksquawk_m5fire/flocksquawk_m5fire.ino | 20 +++++++++++++++ m5stack/flocksquawk_m5fire/src/RadioScanner.h | 25 ++++++++++++++----- .../flocksquawk_m5stick.ino | 18 ++++++++++++- .../flocksquawk_m5stick/src/RadioScanner.h | 23 +++++++++++++---- 12 files changed, 158 insertions(+), 36 deletions(-) diff --git a/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino b/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino index d8b4d97..9e33b15 100644 --- a/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino +++ b/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino @@ -237,6 +237,9 @@ unsigned long RadioScannerManager::lastChannelSwitch = 0; unsigned long RadioScannerManager::lastBLEScan = 0; NimBLEScan* RadioScannerManager::bleScanner = nullptr; bool RadioScannerManager::isScanningBLE = false; +uint16_t RadioScannerManager::CHANNEL_SWITCH_MS = 300; +uint8_t RadioScannerManager::BLE_SCAN_SECONDS = 2; +uint32_t RadioScannerManager::BLE_SCAN_INTERVAL_MS = 5000; // SoundEngine implementation void SoundEngine::initialize() { diff --git a/128x32_OLED/flocksquawk_128x32/src/RadioScanner.h b/128x32_OLED/flocksquawk_128x32/src/RadioScanner.h index 79eb981..694f309 100644 --- a/128x32_OLED/flocksquawk_128x32/src/RadioScanner.h +++ b/128x32_OLED/flocksquawk_128x32/src/RadioScanner.h @@ -13,26 +13,38 @@ class RadioScannerManager { public: static const uint8_t MAX_WIFI_CHANNEL = 13; - static const uint16_t CHANNEL_SWITCH_MS = 300; - static const uint8_t BLE_SCAN_SECONDS = 2; - static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; + static uint16_t CHANNEL_SWITCH_MS; + static uint8_t BLE_SCAN_SECONDS; + static uint32_t BLE_SCAN_INTERVAL_MS; void initialize(); void update(); // Call from main loop - + + static void setPerformanceMode(bool highPerformance) { + if (highPerformance) { + CHANNEL_SWITCH_MS = 200; + BLE_SCAN_SECONDS = 3; + BLE_SCAN_INTERVAL_MS = 4000; + } else { + CHANNEL_SWITCH_MS = 300; + BLE_SCAN_SECONDS = 2; + BLE_SCAN_INTERVAL_MS = 5000; + } + } + private: static uint8_t currentWifiChannel; static unsigned long lastChannelSwitch; static unsigned long lastBLEScan; static NimBLEScan* bleScanner; static bool isScanningBLE; - + void configureWiFiSniffer(); void configureBluetoothScanner(); void switchWifiChannel(); void performBLEScan(); static void wifiPacketHandler(void* buffer, wifi_promiscuous_pkt_type_t type); - + // BLE callback handler class BLEDeviceObserver; friend class BLEDeviceObserver; diff --git a/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino b/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino index a241357..fac512e 100644 --- a/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino +++ b/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino @@ -367,6 +367,9 @@ unsigned long RadioScannerManager::lastChannelSwitch = 0; unsigned long RadioScannerManager::lastBLEScan = 0; NimBLEScan* RadioScannerManager::bleScanner = nullptr; bool RadioScannerManager::isScanningBLE = false; +uint16_t RadioScannerManager::CHANNEL_SWITCH_MS = 300; +uint8_t RadioScannerManager::BLE_SCAN_SECONDS = 2; +uint32_t RadioScannerManager::BLE_SCAN_INTERVAL_MS = 5000; // Main system initialization diff --git a/128x32_OLED/flocksquawk_128x32_portable/src/RadioScanner.h b/128x32_OLED/flocksquawk_128x32_portable/src/RadioScanner.h index 79eb981..694f309 100644 --- a/128x32_OLED/flocksquawk_128x32_portable/src/RadioScanner.h +++ b/128x32_OLED/flocksquawk_128x32_portable/src/RadioScanner.h @@ -13,26 +13,38 @@ class RadioScannerManager { public: static const uint8_t MAX_WIFI_CHANNEL = 13; - static const uint16_t CHANNEL_SWITCH_MS = 300; - static const uint8_t BLE_SCAN_SECONDS = 2; - static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; + static uint16_t CHANNEL_SWITCH_MS; + static uint8_t BLE_SCAN_SECONDS; + static uint32_t BLE_SCAN_INTERVAL_MS; void initialize(); void update(); // Call from main loop - + + static void setPerformanceMode(bool highPerformance) { + if (highPerformance) { + CHANNEL_SWITCH_MS = 200; + BLE_SCAN_SECONDS = 3; + BLE_SCAN_INTERVAL_MS = 4000; + } else { + CHANNEL_SWITCH_MS = 300; + BLE_SCAN_SECONDS = 2; + BLE_SCAN_INTERVAL_MS = 5000; + } + } + private: static uint8_t currentWifiChannel; static unsigned long lastChannelSwitch; static unsigned long lastBLEScan; static NimBLEScan* bleScanner; static bool isScanningBLE; - + void configureWiFiSniffer(); void configureBluetoothScanner(); void switchWifiChannel(); void performBLEScan(); static void wifiPacketHandler(void* buffer, wifi_promiscuous_pkt_type_t type); - + // BLE callback handler class BLEDeviceObserver; friend class BLEDeviceObserver; diff --git a/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino b/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino index 147d067..a7a0f32 100644 --- a/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino +++ b/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino @@ -241,6 +241,9 @@ unsigned long RadioScannerManager::lastChannelSwitch = 0; unsigned long RadioScannerManager::lastBLEScan = 0; NimBLEScan* RadioScannerManager::bleScanner = nullptr; bool RadioScannerManager::isScanningBLE = false; +uint16_t RadioScannerManager::CHANNEL_SWITCH_MS = 300; +uint8_t RadioScannerManager::BLE_SCAN_SECONDS = 2; +uint32_t RadioScannerManager::BLE_SCAN_INTERVAL_MS = 5000; uint8_t RadioScannerManager::getCurrentWifiChannel() { return currentWifiChannel; diff --git a/Mini12864/flocksquawk_mini12864/src/RadioScanner.h b/Mini12864/flocksquawk_mini12864/src/RadioScanner.h index ce0fabe..39cfae0 100644 --- a/Mini12864/flocksquawk_mini12864/src/RadioScanner.h +++ b/Mini12864/flocksquawk_mini12864/src/RadioScanner.h @@ -13,27 +13,39 @@ class RadioScannerManager { public: static const uint8_t MAX_WIFI_CHANNEL = 13; - static const uint16_t CHANNEL_SWITCH_MS = 300; - static const uint8_t BLE_SCAN_SECONDS = 2; - static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; + static uint16_t CHANNEL_SWITCH_MS; + static uint8_t BLE_SCAN_SECONDS; + static uint32_t BLE_SCAN_INTERVAL_MS; void initialize(); void update(); // Call from main loop static uint8_t getCurrentWifiChannel(); - + + static void setPerformanceMode(bool highPerformance) { + if (highPerformance) { + CHANNEL_SWITCH_MS = 200; + BLE_SCAN_SECONDS = 3; + BLE_SCAN_INTERVAL_MS = 4000; + } else { + CHANNEL_SWITCH_MS = 300; + BLE_SCAN_SECONDS = 2; + BLE_SCAN_INTERVAL_MS = 5000; + } + } + private: static uint8_t currentWifiChannel; static unsigned long lastChannelSwitch; static unsigned long lastBLEScan; static NimBLEScan* bleScanner; static bool isScanningBLE; - + void configureWiFiSniffer(); void configureBluetoothScanner(); void switchWifiChannel(); void performBLEScan(); static void wifiPacketHandler(void* buffer, wifi_promiscuous_pkt_type_t type); - + // BLE callback handler class BLEDeviceObserver; friend class BLEDeviceObserver; diff --git a/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino b/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino index 5f5e46f..ed1112e 100644 --- a/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino +++ b/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino @@ -317,6 +317,9 @@ unsigned long RadioScannerManager::lastBLEScan = 0; NimBLEScan* RadioScannerManager::bleScanner = nullptr; bool RadioScannerManager::isScanningBLE = false; #endif +uint16_t RadioScannerManager::CHANNEL_SWITCH_MS = 300; +uint8_t RadioScannerManager::BLE_SCAN_SECONDS = 2; +uint32_t RadioScannerManager::BLE_SCAN_INTERVAL_MS = 5000; // TelemetryReporter implementation void TelemetryReporter::initialize() { diff --git a/flipper-zero/dev-board-firmware/flocksquawk-flipper/src/RadioScanner.h b/flipper-zero/dev-board-firmware/flocksquawk-flipper/src/RadioScanner.h index f140704..9809558 100644 --- a/flipper-zero/dev-board-firmware/flocksquawk-flipper/src/RadioScanner.h +++ b/flipper-zero/dev-board-firmware/flocksquawk-flipper/src/RadioScanner.h @@ -28,13 +28,25 @@ class RadioScannerManager { public: static const uint8_t MAX_WIFI_CHANNEL = 13; - static const uint16_t CHANNEL_SWITCH_MS = 300; - static const uint8_t BLE_SCAN_SECONDS = 2; - static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; + static uint16_t CHANNEL_SWITCH_MS; + static uint8_t BLE_SCAN_SECONDS; + static uint32_t BLE_SCAN_INTERVAL_MS; void initialize(); void update(); // Call from main loop - + + static void setPerformanceMode(bool highPerformance) { + if (highPerformance) { + CHANNEL_SWITCH_MS = 200; + BLE_SCAN_SECONDS = 3; + BLE_SCAN_INTERVAL_MS = 4000; + } else { + CHANNEL_SWITCH_MS = 300; + BLE_SCAN_SECONDS = 2; + BLE_SCAN_INTERVAL_MS = 5000; + } + } + private: static uint8_t currentWifiChannel; static unsigned long lastChannelSwitch; @@ -43,13 +55,13 @@ class RadioScannerManager { static NimBLEScan* bleScanner; static bool isScanningBLE; #endif - + void configureWiFiSniffer(); void configureBluetoothScanner(); void switchWifiChannel(); void performBLEScan(); static void wifiPacketHandler(void* buffer, wifi_promiscuous_pkt_type_t type); - + // BLE callback handler #if FLOCK_BLE_SUPPORTED class BLEDeviceObserver; diff --git a/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino b/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino index 1f5d2f1..c1cb705 100644 --- a/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino +++ b/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino @@ -342,6 +342,9 @@ unsigned long RadioScannerManager::lastChannelSwitch = 0; unsigned long RadioScannerManager::lastBLEScan = 0; NimBLEScan* RadioScannerManager::bleScanner = nullptr; bool RadioScannerManager::isScanningBLE = false; +uint16_t RadioScannerManager::CHANNEL_SWITCH_MS = 300; +uint8_t RadioScannerManager::BLE_SCAN_SECONDS = 2; +uint32_t RadioScannerManager::BLE_SCAN_INTERVAL_MS = 5000; // SoundEngine implementation void SoundEngine::initialize() { @@ -1126,6 +1129,23 @@ void loop() { audioSystem.update(); uint32_t now = millis(); + // Check external power periodically and adjust scan/display modes + static bool lastOnExternalPower = false; + static uint32_t lastPowerCheckMs = 0; + if (now - lastPowerCheckMs >= 5000) { + bool onExternalPower = M5.Power.isCharging(); + if (onExternalPower != lastOnExternalPower) { + RadioScannerManager::setPerformanceMode(onExternalPower); + if (onExternalPower && batterySaverEnabled) { + batterySaverEnabled = false; + setDisplayPower(true); + resetHomeUi(); + } + lastOnExternalPower = onExternalPower; + } + lastPowerCheckMs = now; + } + if (wifiFramePending) { WiFiFrameEvent frameCopy; portENTER_CRITICAL(&wifiMux); diff --git a/m5stack/flocksquawk_m5fire/src/RadioScanner.h b/m5stack/flocksquawk_m5fire/src/RadioScanner.h index 2b005e5..8aebbc1 100644 --- a/m5stack/flocksquawk_m5fire/src/RadioScanner.h +++ b/m5stack/flocksquawk_m5fire/src/RadioScanner.h @@ -13,28 +13,41 @@ class RadioScannerManager { public: static const uint8_t MAX_WIFI_CHANNEL = 13; - static const uint16_t CHANNEL_SWITCH_MS = 300; - static const uint8_t BLE_SCAN_SECONDS = 2; - static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; + static uint16_t CHANNEL_SWITCH_MS; + static uint8_t BLE_SCAN_SECONDS; + static uint32_t BLE_SCAN_INTERVAL_MS; void initialize(); void update(); // Call from main loop static uint8_t getCurrentWifiChannel(); static bool isBluetoothScanning(); - + + // Switch between battery-optimized and high-performance scanning + static void setPerformanceMode(bool highPerformance) { + if (highPerformance) { + CHANNEL_SWITCH_MS = 200; + BLE_SCAN_SECONDS = 3; + BLE_SCAN_INTERVAL_MS = 4000; + } else { + CHANNEL_SWITCH_MS = 300; + BLE_SCAN_SECONDS = 2; + BLE_SCAN_INTERVAL_MS = 5000; + } + } + private: static uint8_t currentWifiChannel; static unsigned long lastChannelSwitch; static unsigned long lastBLEScan; static NimBLEScan* bleScanner; static bool isScanningBLE; - + void configureWiFiSniffer(); void configureBluetoothScanner(); void switchWifiChannel(); void performBLEScan(); static void wifiPacketHandler(void* buffer, wifi_promiscuous_pkt_type_t type); - + // BLE callback handler class BLEDeviceObserver; friend class BLEDeviceObserver; diff --git a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino index 22d2511..16cdacf 100644 --- a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino +++ b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino @@ -528,6 +528,9 @@ unsigned long RadioScannerManager::lastChannelSwitch = 0; unsigned long RadioScannerManager::lastBLEScan = 0; NimBLEScan* RadioScannerManager::bleScanner = nullptr; bool RadioScannerManager::isScanningBLE = false; +uint16_t RadioScannerManager::CHANNEL_SWITCH_MS = 300; +uint8_t RadioScannerManager::BLE_SCAN_SECONDS = 2; +uint32_t RadioScannerManager::BLE_SCAN_INTERVAL_MS = 5000; // Main system initialization @@ -598,9 +601,22 @@ void loop() { static bool wasAlertActive = false; static bool powerToggleHandled = false; static bool lastShouldPowerSave = false; + static bool lastOnExternalPower = false; + static uint32_t lastPowerCheckMs = 0; uint8_t channel = RadioScannerManager::getCurrentWifiChannel(); uint32_t now = millis(); - bool shouldPowerSave = powerSaverEnabled; + + // Check external power periodically and adjust scan/display modes + if (now - lastPowerCheckMs >= BATTERY_UPDATE_MS) { + bool onExternalPower = M5.Power.isCharging(); + if (onExternalPower != lastOnExternalPower) { + RadioScannerManager::setPerformanceMode(onExternalPower); + lastOnExternalPower = onExternalPower; + } + lastPowerCheckMs = now; + } + + bool shouldPowerSave = lastOnExternalPower ? false : powerSaverEnabled; if (wifiFramePending) { WiFiFrameEvent frameCopy; diff --git a/m5stack/flocksquawk_m5stick/src/RadioScanner.h b/m5stack/flocksquawk_m5stick/src/RadioScanner.h index 8f5621b..2123dfb 100644 --- a/m5stack/flocksquawk_m5stick/src/RadioScanner.h +++ b/m5stack/flocksquawk_m5stick/src/RadioScanner.h @@ -13,27 +13,40 @@ class RadioScannerManager { public: static const uint8_t MAX_WIFI_CHANNEL = 13; - static const uint16_t CHANNEL_SWITCH_MS = 300; - static const uint8_t BLE_SCAN_SECONDS = 2; - static const uint32_t BLE_SCAN_INTERVAL_MS = 5000; + static uint16_t CHANNEL_SWITCH_MS; + static uint8_t BLE_SCAN_SECONDS; + static uint32_t BLE_SCAN_INTERVAL_MS; void initialize(); void update(); // Call from main loop static uint8_t getCurrentWifiChannel(); + // Switch between battery-optimized and high-performance scanning + static void setPerformanceMode(bool highPerformance) { + if (highPerformance) { + CHANNEL_SWITCH_MS = 200; + BLE_SCAN_SECONDS = 3; + BLE_SCAN_INTERVAL_MS = 4000; + } else { + CHANNEL_SWITCH_MS = 300; + BLE_SCAN_SECONDS = 2; + BLE_SCAN_INTERVAL_MS = 5000; + } + } + private: static volatile uint8_t currentWifiChannel; static unsigned long lastChannelSwitch; static unsigned long lastBLEScan; static NimBLEScan* bleScanner; static bool isScanningBLE; - + void configureWiFiSniffer(); void configureBluetoothScanner(); void switchWifiChannel(); void performBLEScan(); static void wifiPacketHandler(void* buffer, wifi_promiscuous_pkt_type_t type); - + // BLE callback handler class BLEDeviceObserver; friend class BLEDeviceObserver; From 68e72ebaf355ae347da321e85eeae22f9a7022d9 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 2 Feb 2026 00:53:00 -0700 Subject: [PATCH 18/23] Replace RSSI graph with scrollable device list on M5StickC --- .../flocksquawk_m5stick.ino | 499 +++++++++++------- 1 file changed, 305 insertions(+), 194 deletions(-) diff --git a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino index 16cdacf..63263e7 100644 --- a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino +++ b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino @@ -36,17 +36,12 @@ namespace { const uint16_t ALERT_BEEP_FREQ = 2600; const uint16_t BEEP_DURATION_MS = 80; const uint16_t BEEP_GAP_MS = 60; - const uint16_t RADAR_LINE_COLOR = TFT_GREEN; const uint16_t STATUS_TEXT_COLOR = TFT_WHITE; const uint32_t DOT_UPDATE_MS = 400; const uint32_t BATTERY_UPDATE_MS = 3000; - const uint32_t SWEEP_UPDATE_MS = 10; const uint8_t MAX_DOTS = 3; - const uint32_t RSSI_UPDATE_MS = 300; - const int8_t RSSI_MIN_DBM = -100; - const int8_t RSSI_MAX_DBM = -20; - const uint8_t RSSI_HISTORY_LEN = 80; - const uint16_t RSSI_LINE_COLOR = TFT_CYAN; + const uint32_t LIST_REFRESH_MS = 2000; + const uint32_t DEVICE_DEPART_MS = 90000; const uint32_t ALERT_DURATION_MS = 4000; const uint32_t ALERT_FLASH_MS = 300; const uint16_t ALERT_BEEP_MS = 180; @@ -62,10 +57,34 @@ namespace { } } - int8_t rssiHistory[RSSI_HISTORY_LEN]; - uint8_t rssiIndex = 0; - bool rssiHistoryInitialized = false; - int8_t latestRssi = RSSI_MIN_DBM; + // Device list display + const uint8_t LIST_HEADER_H = 16; + const uint8_t LIST_SEPARATOR_Y = 16; + const uint8_t LIST_TOP_Y = 17; + const uint8_t LIST_AREA_H = 116; // 17..132 = 116px + const uint8_t LIST_ROW_H = 14; + const uint8_t LIST_VISIBLE_ROWS = 8; + const uint8_t LIST_SCROLL_BAR_H = 2; + const uint8_t MAX_DISPLAY_DEVICES = 32; + + struct DisplayDevice { + uint8_t mac[6]; + char label[21]; + int8_t rssi; + uint8_t alertLevel; + char radioType; + uint32_t firstSeenMs; + uint32_t lastSeenMs; + bool active; + }; + + DisplayDevice displayDevices[MAX_DISPLAY_DEVICES]; + uint8_t displayDeviceCount = 0; + uint8_t scrollOffset = 0; + LGFX_Sprite* listSprite = nullptr; + LGFX_Sprite* headerSprite = nullptr; + bool spriteCreated = false; + bool alertActive = false; bool alertVisible = false; uint32_t alertStartMs = 0; @@ -94,12 +113,6 @@ namespace { DisplayState displayState = DisplayState::Awake; uint32_t displayStateMs = 0; - int16_t graphTop() { - int16_t lineHeight = M5.Display.fontHeight(); - return (lineHeight * 2) + 4 + 2; - } - - void setDisplayOn() { M5.Display.wakeup(); M5.Display.setBrightness(DISPLAY_BRIGHTNESS_ON); @@ -110,132 +123,270 @@ namespace { M5.Display.sleep(); } - void ensureRssiHistoryInitialized() { - if (rssiHistoryInitialized) { - return; + uint16_t batteryColor(uint8_t percent) { + if (percent <= 35) { + return TFT_RED; } - for (uint8_t i = 0; i < RSSI_HISTORY_LEN; i++) { - rssiHistory[i] = -70; + if (percent <= 75) { + return TFT_BLUE; } - rssiIndex = 0; - rssiHistoryInitialized = true; - } - - void addRssiSample(int8_t rssi) { - ensureRssiHistoryInitialized(); - if (rssi < RSSI_MIN_DBM) rssi = RSSI_MIN_DBM; - if (rssi > RSSI_MAX_DBM) rssi = RSSI_MAX_DBM; - rssiHistory[rssiIndex] = rssi; - rssiIndex = (rssiIndex + 1) % RSSI_HISTORY_LEN; + return TFT_GREEN; } - void drawRssiChart() { - int16_t top = graphTop(); - int16_t bottom = M5.Display.height() - 1; - if (bottom <= top + 6) return; - - int16_t width = M5.Display.width(); - int16_t height = bottom - top; - - M5.Display.fillRect(0, top, width, height, TFT_BLACK); - M5.Display.drawRect(0, top, width, height, TFT_WHITE); - - int16_t plotTop = top + 1; - int16_t plotBottom = bottom - 1; - int16_t plotHeight = plotBottom - plotTop; + // --- Device list management --- + + void updateDisplayDevice(const ThreatEvent& threat, uint32_t nowMs) { + if (threat.alertLevel == ALERT_NONE) return; + + // Find existing device by MAC + for (uint8_t i = 0; i < displayDeviceCount; i++) { + if (memcmp(displayDevices[i].mac, threat.mac, 6) == 0) { + displayDevices[i].rssi = threat.rssi; + displayDevices[i].lastSeenMs = nowMs; + displayDevices[i].active = true; + if (threat.alertLevel > displayDevices[i].alertLevel) { + displayDevices[i].alertLevel = threat.alertLevel; + } + // Update label if we got a better identifier + if (threat.identifier[0] != '\0') { + strncpy(displayDevices[i].label, threat.identifier, 20); + displayDevices[i].label[20] = '\0'; + } + return; + } + } - int16_t lastX = -1; - int16_t lastY = -1; - for (uint8_t i = 0; i < RSSI_HISTORY_LEN; i++) { - uint8_t idx = (rssiIndex + i) % RSSI_HISTORY_LEN; - int16_t x = (int32_t)i * (width - 3) / (RSSI_HISTORY_LEN - 1) + 1; - int16_t rssi = rssiHistory[idx]; - int32_t norm = (int32_t)(rssi - RSSI_MIN_DBM) * (plotHeight - 1) / (RSSI_MAX_DBM - RSSI_MIN_DBM); - int16_t y = plotBottom - norm; - if (lastX >= 0) { - M5.Display.drawLine(lastX, lastY, x, y, RSSI_LINE_COLOR); + // Add new device + uint8_t slot = displayDeviceCount; + if (displayDeviceCount >= MAX_DISPLAY_DEVICES) { + // Evict oldest inactive device, or oldest overall + uint32_t oldestMs = UINT32_MAX; + slot = 0; + for (uint8_t i = 0; i < MAX_DISPLAY_DEVICES; i++) { + if (!displayDevices[i].active && displayDevices[i].lastSeenMs < oldestMs) { + oldestMs = displayDevices[i].lastSeenMs; + slot = i; + } + } + if (oldestMs == UINT32_MAX) { + // All active — evict oldest active + for (uint8_t i = 0; i < MAX_DISPLAY_DEVICES; i++) { + if (displayDevices[i].lastSeenMs < oldestMs) { + oldestMs = displayDevices[i].lastSeenMs; + slot = i; + } + } } - lastX = x; - lastY = y; + } else { + displayDeviceCount++; + } + + DisplayDevice& dev = displayDevices[slot]; + memcpy(dev.mac, threat.mac, 6); + dev.rssi = threat.rssi; + dev.alertLevel = threat.alertLevel; + dev.radioType = (threat.radioType[0] == 'B') ? 'B' : 'W'; + dev.firstSeenMs = nowMs; + dev.lastSeenMs = nowMs; + dev.active = true; + + if (threat.identifier[0] != '\0') { + strncpy(dev.label, threat.identifier, 20); + dev.label[20] = '\0'; + } else { + snprintf(dev.label, sizeof(dev.label), "%02X:%02X:%02X:%02X:%02X:%02X", + threat.mac[0], threat.mac[1], threat.mac[2], + threat.mac[3], threat.mac[4], threat.mac[5]); } } - void drawGraphBox() { - int16_t top = graphTop(); - int16_t bottom = M5.Display.height() - 1; - int16_t height = bottom - top + 1; - M5.Display.drawRect(0, top, M5.Display.width(), height, TFT_WHITE); + void ageDisplayDevices(uint32_t nowMs) { + for (uint8_t i = 0; i < displayDeviceCount; i++) { + if (displayDevices[i].active && (nowMs - displayDevices[i].lastSeenMs) >= DEVICE_DEPART_MS) { + displayDevices[i].active = false; + } + } } - uint16_t batteryColor(uint8_t percent) { - if (percent <= 35) { - return TFT_RED; + uint8_t countActiveDevices() { + uint8_t count = 0; + for (uint8_t i = 0; i < displayDeviceCount; i++) { + if (displayDevices[i].active) count++; } - if (percent <= 75) { - return TFT_BLUE; + return count; + } + + void buildSortedIndices(uint8_t* indices, uint8_t& count) { + count = displayDeviceCount; + for (uint8_t i = 0; i < count; i++) indices[i] = i; + + // Simple insertion sort: active first, then by alertLevel desc, then by lastSeenMs desc + for (uint8_t i = 1; i < count; i++) { + uint8_t key = indices[i]; + const DisplayDevice& keyDev = displayDevices[key]; + int8_t j = i - 1; + while (j >= 0) { + const DisplayDevice& jDev = displayDevices[indices[j]]; + bool swap = false; + if (keyDev.active && !jDev.active) { + swap = true; + } else if (keyDev.active == jDev.active) { + if (keyDev.alertLevel > jDev.alertLevel) { + swap = true; + } else if (keyDev.alertLevel == jDev.alertLevel) { + if (keyDev.lastSeenMs > jDev.lastSeenMs) { + swap = true; + } + } + } + if (!swap) break; + indices[j + 1] = indices[j]; + j--; + } + indices[j + 1] = key; } - return TFT_GREEN; } - void drawBatteryPercent(uint8_t percent) { - char text[8]; - snprintf(text, sizeof(text), "%u%%", percent); - int16_t textWidth = M5.Display.textWidth(text); - int16_t height = M5.Display.fontHeight(); - int16_t x = M5.Display.width() - textWidth; - M5.Display.fillRect(x - 2, 0, textWidth + 2, height, TFT_BLACK); - M5.Display.setCursor(x, 0); - M5.Display.setTextColor(batteryColor(percent), TFT_BLACK); - M5.Display.print(text); + // --- Drawing functions --- + + uint16_t alertColor(uint8_t level) { + switch (level) { + case ALERT_CONFIRMED: return TFT_RED; + case ALERT_SUSPICIOUS: return TFT_YELLOW; + case ALERT_INFO: return TFT_CYAN; + default: return TFT_DARKGREY; + } } - void drawDetectionCount(uint32_t count) { - char text[12]; - snprintf(text, sizeof(text), "%u", count); - int16_t textWidth = M5.Display.textWidth(text); - int16_t height = M5.Display.fontHeight(); - int16_t x = M5.Display.width() - textWidth; - int16_t y = height + 2; - M5.Display.fillRect(x - 2, y, textWidth + 2, height, TFT_BLACK); - M5.Display.setCursor(x, y); - M5.Display.setTextColor(RADAR_LINE_COLOR, TFT_BLACK); - M5.Display.print(text); + void formatAge(uint32_t ms, char* buf, uint8_t bufSize) { + uint32_t secs = ms / 1000; + if (secs < 60) { + snprintf(buf, bufSize, "%us", (unsigned)secs); + } else if (secs < 3600) { + snprintf(buf, bufSize, "%um", (unsigned)(secs / 60)); + } else { + snprintf(buf, bufSize, "%uh", (unsigned)(secs / 3600)); + } } - void drawBatteryArea(uint8_t percent, uint32_t count) { - drawBatteryPercent(percent); - drawDetectionCount(count); + void drawDeviceListHeader(uint8_t dots, uint8_t battery, uint8_t activeCount) { + if (!spriteCreated) return; + + headerSprite->fillSprite(TFT_BLACK); + headerSprite->setTextSize(2); + + // Left: "Scan..." with animated dots + char scanText[12]; + char dotStr[4] = "..."; + dotStr[dots] = '\0'; + snprintf(scanText, sizeof(scanText), "Scan%s", dotStr); + headerSprite->setCursor(0, 0); + headerSprite->setTextColor(STATUS_TEXT_COLOR, TFT_BLACK); + headerSprite->print(scanText); + + // Right: "B:85% D:3" + char rightText[16]; + snprintf(rightText, sizeof(rightText), "B:%u%% D:%u", battery, activeCount); + int16_t textWidth = headerSprite->textWidth(rightText); + headerSprite->setCursor(240 - textWidth, 0); + headerSprite->setTextColor(batteryColor(battery), TFT_BLACK); + headerSprite->print(rightText); + + // Separator line at bottom of header sprite + headerSprite->drawFastHLine(0, LIST_SEPARATOR_Y, 240, TFT_DARKGREY); + + headerSprite->pushSprite(0, 0); } - void drawStatusText(uint8_t channel, uint8_t dots) { - char text[16]; - char dotText[4] = "..."; - dotText[dots] = '\0'; - snprintf(text, sizeof(text), "Scanning%s", dotText); + void drawDeviceList(uint32_t nowMs) { + if (!spriteCreated) return; - int16_t lineHeight = M5.Display.fontHeight(); - int16_t width = M5.Display.width(); - int16_t statusHeight = (lineHeight * 2) + 4; + uint8_t sortedIdx[MAX_DISPLAY_DEVICES]; + uint8_t sortedCount = 0; + buildSortedIndices(sortedIdx, sortedCount); - M5.Display.fillRect(0, 0, width, statusHeight, TFT_BLACK); - M5.Display.setCursor(0, 0); - M5.Display.setTextColor(STATUS_TEXT_COLOR, TFT_BLACK); - M5.Display.print(text); - M5.Display.setCursor(0, lineHeight + 4); - M5.Display.setTextColor(STATUS_TEXT_COLOR, TFT_BLACK); - M5.Display.printf("WiFi ch: %u", channel); + // Clamp scroll offset + if (sortedCount <= LIST_VISIBLE_ROWS) { + scrollOffset = 0; + } else if (scrollOffset > sortedCount - LIST_VISIBLE_ROWS) { + scrollOffset = sortedCount - LIST_VISIBLE_ROWS; + } + + listSprite->fillSprite(TFT_BLACK); + listSprite->setTextSize(1); + + if (sortedCount == 0) { + listSprite->setTextColor(TFT_DARKGREY); + listSprite->setCursor(20, (LIST_AREA_H - LIST_SCROLL_BAR_H) / 2 - 4); + listSprite->print("No devices detected"); + listSprite->pushSprite(0, LIST_TOP_Y); + return; + } + + uint8_t rowsAvailable = LIST_AREA_H - LIST_SCROLL_BAR_H; + for (uint8_t row = 0; row < LIST_VISIBLE_ROWS && (scrollOffset + row) < sortedCount; row++) { + const DisplayDevice& dev = displayDevices[sortedIdx[scrollOffset + row]]; + int16_t y = row * LIST_ROW_H; + uint16_t color = dev.active ? alertColor(dev.alertLevel) : TFT_DARKGREY; + + // Color dot (4x8) + listSprite->fillRect(0, y + 3, 4, 8, color); + + // Radio type + listSprite->setTextColor(color, TFT_BLACK); + listSprite->setCursor(6, y + 3); + listSprite->print(dev.radioType); + + // Label (truncated) + listSprite->setCursor(18, y + 3); + listSprite->print(dev.label); + + // RSSI + char rssiStr[8]; + snprintf(rssiStr, sizeof(rssiStr), "%ddB", dev.rssi); + int16_t rssiWidth = listSprite->textWidth(rssiStr); + listSprite->setCursor(195 - rssiWidth, y + 3); + listSprite->print(rssiStr); + + // Age + char ageStr[6]; + uint32_t ageMs = nowMs - dev.firstSeenMs; + formatAge(ageMs, ageStr, sizeof(ageStr)); + int16_t ageWidth = listSprite->textWidth(ageStr); + listSprite->setCursor(240 - ageWidth - 1, y + 3); + listSprite->print(ageStr); + } + + // Scroll indicator bar + if (sortedCount > LIST_VISIBLE_ROWS) { + int16_t barY = rowsAvailable; + int16_t barWidth = (int32_t)LIST_VISIBLE_ROWS * 240 / sortedCount; + if (barWidth < 10) barWidth = 10; + int16_t barX = (int32_t)scrollOffset * (240 - barWidth) / (sortedCount - LIST_VISIBLE_ROWS); + listSprite->fillRect(barX, barY, barWidth, LIST_SCROLL_BAR_H, TFT_DARKGREY); + } + + listSprite->pushSprite(0, LIST_TOP_Y); } - void initScanningUi(uint8_t channel, uint32_t nowMs) { + void initScanningUi(uint32_t nowMs) { setDisplayOn(); M5.Display.clear(TFT_BLACK); M5.Display.setTextColor(STATUS_TEXT_COLOR, TFT_BLACK); M5.Display.setTextSize(2); - drawStatusText(channel, 1); - drawBatteryArea(M5.Power.getBatteryLevel(), detectionCount); - ensureRssiHistoryInitialized(); - drawRssiChart(); - drawGraphBox(); + + if (!spriteCreated) { + headerSprite = new LGFX_Sprite(&M5.Display); + headerSprite->setColorDepth(16); + headerSprite->createSprite(240, LIST_TOP_Y); + listSprite = new LGFX_Sprite(&M5.Display); + listSprite->setColorDepth(16); + listSprite->createSprite(240, LIST_AREA_H); + spriteCreated = true; + } + + drawDeviceListHeader(1, M5.Power.getBatteryLevel(), countActiveDevices()); + drawDeviceList(nowMs); displayState = DisplayState::Awake; displayStateMs = nowMs; } @@ -581,29 +732,21 @@ void setup() { Serial.println("System operational - scanning for targets"); Serial.println(); - initScanningUi(RadioScannerManager::getCurrentWifiChannel(), millis()); + initScanningUi(millis()); EventBus::publishSystemReady(); } void loop() { M5.update(); rfScanner.update(); - static uint8_t lastChannel = 0; - static uint8_t lastBattery = 255; static uint8_t dots = 1; - static int16_t sweepX = 0; - static int16_t lastSweepX = -1; - static int8_t sweepDir = 1; static uint32_t lastDotMs = 0; - static uint32_t lastBatteryMs = 0; - static uint32_t lastSweepMs = 0; - static uint32_t lastRssiMs = 0; + static uint32_t lastListRefreshMs = 0; static bool wasAlertActive = false; static bool powerToggleHandled = false; static bool lastShouldPowerSave = false; static bool lastOnExternalPower = false; static uint32_t lastPowerCheckMs = 0; - uint8_t channel = RadioScannerManager::getCurrentWifiChannel(); uint32_t now = millis(); // Check external power periodically and adjust scan/display modes @@ -624,7 +767,6 @@ void loop() { frameCopy = pendingWiFiFrame; wifiFramePending = false; portEXIT_CRITICAL(&wifiMux); - latestRssi = frameCopy.rssi; threatEngine.analyzeWiFiFrame(frameCopy); } @@ -637,9 +779,7 @@ void loop() { threatEngine.analyzeBluetoothDevice(bleCopy); } - if (threatEngine.tick(now)) { - M5.Speaker.tone(1800, 40); - } + threatEngine.tick(now); if (threatPending) { ThreatEvent threatCopy; @@ -648,11 +788,33 @@ void loop() { threatPending = false; portEXIT_CRITICAL(&threatMux); reporter.handleThreatDetection(threatCopy); + updateDisplayDevice(threatCopy, now); if (threatCopy.shouldAlert) { triggerAlert(now); } else if (threatCopy.alertLevel == ALERT_SUSPICIOUS && threatCopy.firstDetection) { M5.Speaker.tone(1800, 60); } + // Immediate list refresh on new detection (if display is awake and not alerting) + if (!alertActive && !statusMessageActive && displayState == DisplayState::Awake) { + drawDeviceListHeader(dots, M5.Power.getBatteryLevel(), countActiveDevices()); + drawDeviceList(now); + } + } + + // Scroll buttons — before the long-press handler + if (M5.BtnPWR.wasClicked() && !alertActive && !statusMessageActive && displayState == DisplayState::Awake) { + if (scrollOffset > 0) { + scrollOffset--; + drawDeviceList(now); + } + } + + if (M5.BtnB.wasClicked() && !alertActive && !statusMessageActive && displayState == DisplayState::Awake) { + uint8_t total = displayDeviceCount; + if (total > LIST_VISIBLE_ROWS && scrollOffset < total - LIST_VISIBLE_ROWS) { + scrollOffset++; + drawDeviceList(now); + } } if (M5.BtnB.pressedFor(2000) && !powerToggleHandled) { @@ -668,13 +830,9 @@ void loop() { } if (M5.BtnA.wasPressed() && shouldPowerSave) { - initScanningUi(channel, now); - lastChannel = channel; + initScanningUi(now); lastDotMs = now; - lastBatteryMs = now; - lastSweepMs = now; - lastRssiMs = now; - lastSweepX = -1; + lastListRefreshMs = now; } bool isAlerting = updateAlert(now); @@ -687,33 +845,21 @@ void loop() { setDisplayOff(); displayState = DisplayState::Off; } else { - initScanningUi(channel, now); - lastChannel = channel; + initScanningUi(now); lastDotMs = now; - lastBatteryMs = now; - lastSweepMs = now; - lastRssiMs = now; - lastSweepX = -1; + lastListRefreshMs = now; } } wasAlertActive = isAlerting; if (statusMessageActive && now >= statusMessageUntilMs) { statusMessageActive = false; - if (shouldPowerSave) { - initScanningUi(channel, now); - } else { - initScanningUi(channel, now); - } + initScanningUi(now); } if (!isAlerting && !statusMessageActive && shouldPowerSave != lastShouldPowerSave) { lastShouldPowerSave = shouldPowerSave; - if (shouldPowerSave) { - initScanningUi(channel, now); - } else { - initScanningUi(channel, now); - } + initScanningUi(now); } if (!isAlerting && !statusMessageActive) { @@ -725,61 +871,26 @@ void loop() { displayState = DisplayState::Off; } } else if (displayState != DisplayState::Awake) { - initScanningUi(channel, now); + initScanningUi(now); } } - if (!isAlerting && !statusMessageActive && displayState == DisplayState::Awake && channel != lastChannel) { - drawStatusText(channel, dots); - drawBatteryArea(M5.Power.getBatteryLevel(), detectionCount); - lastChannel = channel; - } - + // Header updates (dots animation + battery/count) if (!isAlerting && !statusMessageActive && displayState == DisplayState::Awake && now - lastDotMs >= DOT_UPDATE_MS) { dots = (dots % MAX_DOTS) + 1; - drawStatusText(channel, dots); - drawBatteryArea(M5.Power.getBatteryLevel(), detectionCount); + drawDeviceListHeader(dots, M5.Power.getBatteryLevel(), countActiveDevices()); lastDotMs = now; } - if (!isAlerting && !statusMessageActive && displayState == DisplayState::Awake && now - lastBatteryMs >= BATTERY_UPDATE_MS) { - uint8_t battery = M5.Power.getBatteryLevel(); - if (battery != lastBattery) { - drawBatteryArea(battery, detectionCount); - lastBattery = battery; - } - lastBatteryMs = now; - } - - if (!isAlerting && !statusMessageActive && now - lastRssiMs >= RSSI_UPDATE_MS) { - addRssiSample(latestRssi); + // Periodic device list refresh — age devices and redraw + if (!isAlerting && !statusMessageActive && now - lastListRefreshMs >= LIST_REFRESH_MS) { + ageDisplayDevices(now); if (displayState == DisplayState::Awake) { - drawRssiChart(); - drawGraphBox(); + drawDeviceListHeader(dots, M5.Power.getBatteryLevel(), countActiveDevices()); + drawDeviceList(now); } - lastRssiMs = now; + lastListRefreshMs = now; } - if (!isAlerting && !statusMessageActive && displayState == DisplayState::Awake && now - lastSweepMs >= SWEEP_UPDATE_MS) { - int16_t width = M5.Display.width(); - int16_t height = M5.Display.height(); - int16_t sweepTopValue = graphTop(); - - if (lastSweepX >= 0) { - M5.Display.drawLine(lastSweepX, sweepTopValue, lastSweepX, height - 1, TFT_BLACK); - } - drawGraphBox(); - - M5.Display.drawLine(sweepX, sweepTopValue, sweepX, height - 1, RADAR_LINE_COLOR); - lastSweepX = sweepX; - - sweepX += sweepDir; - if (sweepX <= 0 || sweepX >= width - 1) { - sweepDir = -sweepDir; - sweepX += sweepDir; - } - - lastSweepMs = now; - } delay(30); } From bccfdcb1d871c75d00a5549e787a67a12ffd8818 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sun, 1 Feb 2026 16:37:54 -0700 Subject: [PATCH 19/23] Add BLE GATT server for DeFlock app communication NimBLE GATT server streams newline-delimited JSON telemetry to the DeFlock app over BLE, enabling iOS support and wireless operation on Android. When a BLE client connects, scan duty is reduced to share radio time; when it disconnects (e.g. USB takeover), full scan duty resumes. - Add common/BleTransport.h: GATT server with notify characteristic, MTU negotiation up to 512, chunked notifications, client state callback - Modify TelemetryReporter.h: dual output to Serial + BLE, with buffer overflow protection and truncation detection - Modify RadioScanner.h: adaptive scan duty cycle based on BLE client state, deferred to main loop for thread safety - Wire BleTransport into M5StickC setup, guard NimBLE double-init Co-Authored-By: Claude Opus 4.5 --- common/BleTransport.h | 112 ++++++++++++++++++ common/TelemetryReporter.h | 24 +++- .../flocksquawk_m5stick.ino | 30 ++++- .../flocksquawk_m5stick/src/RadioScanner.h | 51 +++++++- 4 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 common/BleTransport.h diff --git a/common/BleTransport.h b/common/BleTransport.h new file mode 100644 index 0000000..1d32ddf --- /dev/null +++ b/common/BleTransport.h @@ -0,0 +1,112 @@ +#ifndef BLE_TRANSPORT_H +#define BLE_TRANSPORT_H + +#include +#include + +// FlockSquawk BLE GATT UUIDs — must match the Flutter side. +#define FLOCKSQUAWK_SERVICE_UUID "a1b2c3d4-e5f6-7890-abcd-ef0123456789" +#define FLOCKSQUAWK_TX_CHAR_UUID "a1b2c3d4-e5f6-7890-abcd-ef01234567aa" + +/// NimBLE GATT server that streams newline-delimited JSON telemetry to a +/// connected BLE client (DeFlock app on iOS / Android). +/// +/// Usage: +/// BleTransport bleTransport; +/// bleTransport.initialize(); // after NimBLEDevice::init() +/// reporter.setBleTransport(&bleTransport); +/// +/// When a client connects, the ESP32 reduces BLE scan duty to share radio +/// time. When the client disconnects (e.g. phone switches to USB), scan +/// duty returns to normal. +class BleTransport : public NimBLEServerCallbacks { +public: + /// Optional callback invoked when a BLE client connects or disconnects. + /// The bool parameter is true on connect, false on disconnect. + typedef void (*ClientStateCallback)(bool connected); + + void setClientStateCallback(ClientStateCallback cb) { _clientCb = cb; } + + /// Call after NimBLEDevice::init(""). + /// Creates server, service, TX characteristic, and starts advertising. + void initialize() { + NimBLEDevice::setMTU(512); + + _server = NimBLEDevice::createServer(); + _server->setCallbacks(this); + + NimBLEService* service = _server->createService(FLOCKSQUAWK_SERVICE_UUID); + + _txChar = service->createCharacteristic( + FLOCKSQUAWK_TX_CHAR_UUID, + NIMBLE_PROPERTY::NOTIFY + ); + + service->start(); + + NimBLEAdvertising* advertising = NimBLEDevice::getAdvertising(); + advertising->addServiceUUID(service->getUUID()); + advertising->enableScanResponse(true); + advertising->start(); + + Serial.println("[BLE] GATT server started, advertising"); + } + + /// Send a newline-delimited JSON line to the connected client. + /// If no client is connected, this is a no-op. + /// If the payload exceeds MTU-3, it is chunked; the client reassembles + /// via the newline delimiter in JsonLineParser. + void sendLine(const char* data, size_t len) { + if (!_connected || _txChar == nullptr) return; + + uint16_t maxPayload = _negotiatedMtu - 3; + if (maxPayload < 20) maxPayload = 20; + + if (len <= maxPayload) { + _txChar->setValue((const uint8_t*)data, len); + _txChar->notify(); + } else { + // Chunk the data to fit within the negotiated MTU + size_t offset = 0; + while (offset < len) { + size_t chunkSize = len - offset; + if (chunkSize > maxPayload) chunkSize = maxPayload; + _txChar->setValue((const uint8_t*)(data + offset), chunkSize); + _txChar->notify(); + offset += chunkSize; + } + } + } + + bool isClientConnected() const { return _connected; } + + // -- NimBLEServerCallbacks -- + + void onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo) override { + _connected = true; + Serial.println("[BLE] Client connected"); + if (_clientCb) _clientCb(true); + } + + void onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) override { + _connected = false; + _negotiatedMtu = 23; + Serial.println("[BLE] Client disconnected, restarting advertising"); + if (_clientCb) _clientCb(false); + NimBLEDevice::getAdvertising()->start(); + } + + void onMTUChange(uint16_t MTU, NimBLEConnInfo& connInfo) override { + _negotiatedMtu = MTU; + Serial.printf("[BLE] MTU changed to %u\n", MTU); + } + +private: + NimBLEServer* _server = nullptr; + NimBLECharacteristic* _txChar = nullptr; + volatile bool _connected = false; + volatile uint16_t _negotiatedMtu = 23; + ClientStateCallback _clientCb = nullptr; +}; + +#endif diff --git a/common/TelemetryReporter.h b/common/TelemetryReporter.h index c19a92f..4b4c716 100644 --- a/common/TelemetryReporter.h +++ b/common/TelemetryReporter.h @@ -6,12 +6,18 @@ #include "EventBus.h" #include "DetectorTypes.h" +class BleTransport; // forward declaration + class TelemetryReporter { public: void initialize() { bootTime = millis(); } + void setBleTransport(BleTransport* transport) { + _bleTransport = transport; + } + void handleThreatDetection(const ThreatEvent& threat) { StaticJsonDocument<512> doc; @@ -58,12 +64,28 @@ class TelemetryReporter { } } - serializeJson(doc, Serial); + // +1 byte for the newline that _sendViaBle appends + char buf[513]; + size_t len = serializeJson(doc, buf, sizeof(buf) - 1); + + // Always output to Serial (USB) + Serial.write(buf, len); Serial.println(); + + // Also send via BLE if a client is connected. + // Skip if JSON was truncated (len == sizeof(buf)-1) since the + // client would fail to parse it anyway. + if (_bleTransport && len < sizeof(buf) - 1) { + _sendViaBle(buf, len); + } } private: unsigned long bootTime; + BleTransport* _bleTransport = nullptr; + + // Defined in .ino after BleTransport.h is included + inline void _sendViaBle(char* buf, size_t len); }; #endif diff --git a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino index 63263e7..59e2468 100644 --- a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino +++ b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino @@ -17,12 +17,23 @@ #include "DeviceSignatures.h" #include "src/RadioScanner.h" #include "ThreatAnalyzer.h" +#include "BleTransport.h" #include "TelemetryReporter.h" +// TelemetryReporter::_sendViaBle requires BleTransport to be fully defined. +// Both headers are now included above, so we can provide the inline definition. +inline void TelemetryReporter::_sendViaBle(char* buf, size_t len) { + if (_bleTransport->isClientConnected()) { + buf[len] = '\n'; + _bleTransport->sendLine(buf, len + 1); + } +} + // Global system components RadioScannerManager rfScanner; ThreatAnalyzer threatEngine; TelemetryReporter reporter; +BleTransport bleTransport; // Event bus handler implementations EventBus::WiFiFrameHandler EventBus::wifiHandler = nullptr; @@ -545,7 +556,9 @@ void RadioScannerManager::configureWiFiSniffer() { } void RadioScannerManager::configureBluetoothScanner() { - NimBLEDevice::init(""); + if (!NimBLEDevice::isInitialized()) { + NimBLEDevice::init(""); + } bleScanner = NimBLEDevice::getScan(); bleScanner->setActiveScan(true); bleScanner->setInterval(100); @@ -682,6 +695,9 @@ bool RadioScannerManager::isScanningBLE = false; uint16_t RadioScannerManager::CHANNEL_SWITCH_MS = 300; uint8_t RadioScannerManager::BLE_SCAN_SECONDS = 2; uint32_t RadioScannerManager::BLE_SCAN_INTERVAL_MS = 5000; +bool RadioScannerManager::_highPerformance = false; +volatile bool RadioScannerManager::_bleClientConnected = false; +volatile bool RadioScannerManager::_dutyCycleDirty = false; // Main system initialization @@ -727,6 +743,17 @@ void setup() { threatEngine.initialize(); reporter.initialize(); + + // Initialize NimBLE once, then set up GATT server before radio scanner. + // RadioScanner::configureBluetoothScanner() will skip NimBLEDevice::init() + // if already initialized. + NimBLEDevice::init(""); + bleTransport.setClientStateCallback([](bool connected) { + RadioScannerManager::setBleClientConnected(connected); + }); + bleTransport.initialize(); + reporter.setBleTransport(&bleTransport); + rfScanner.initialize(); Serial.println("System operational - scanning for targets"); @@ -738,6 +765,7 @@ void setup() { void loop() { M5.update(); + RadioScannerManager::applyPendingDutyCycle(); rfScanner.update(); static uint8_t dots = 1; static uint32_t lastDotMs = 0; diff --git a/m5stack/flocksquawk_m5stick/src/RadioScanner.h b/m5stack/flocksquawk_m5stick/src/RadioScanner.h index 2123dfb..ca95cda 100644 --- a/m5stack/flocksquawk_m5stick/src/RadioScanner.h +++ b/m5stack/flocksquawk_m5stick/src/RadioScanner.h @@ -23,18 +23,57 @@ class RadioScannerManager { // Switch between battery-optimized and high-performance scanning static void setPerformanceMode(bool highPerformance) { - if (highPerformance) { - CHANNEL_SWITCH_MS = 200; - BLE_SCAN_SECONDS = 3; + _highPerformance = highPerformance; + _applyDutyCycle(); + } + + /// Notify the scanner that a BLE GATT client connected / disconnected. + /// Safe to call from any task (e.g. NimBLE callback); the actual scan + /// parameter update is deferred to the main loop via a volatile flag. + static void setBleClientConnected(bool connected) { + _bleClientConnected = connected; + _dutyCycleDirty = true; + } + + /// Call from the main loop to apply any pending duty-cycle changes. + static void applyPendingDutyCycle() { + if (_dutyCycleDirty) { + _dutyCycleDirty = false; + _applyDutyCycle(); + } + } + +private: + static bool _highPerformance; + static volatile bool _bleClientConnected; + static volatile bool _dutyCycleDirty; + + /// Recompute scan parameters from current performance mode and BLE client + /// connection state. + static void _applyDutyCycle() { + if (_highPerformance && !_bleClientConnected) { + // Full performance, no BLE client — maximum scan duty + CHANNEL_SWITCH_MS = 200; + BLE_SCAN_SECONDS = 3; + BLE_SCAN_INTERVAL_MS = 3000; + } else if (_highPerformance && _bleClientConnected) { + // High performance but sharing radio with BLE client + CHANNEL_SWITCH_MS = 200; + BLE_SCAN_SECONDS = 2; BLE_SCAN_INTERVAL_MS = 4000; + } else if (!_highPerformance && !_bleClientConnected) { + // Battery mode, no BLE client — moderate boost + CHANNEL_SWITCH_MS = 300; + BLE_SCAN_SECONDS = 3; + BLE_SCAN_INTERVAL_MS = 3000; } else { - CHANNEL_SWITCH_MS = 300; - BLE_SCAN_SECONDS = 2; + // Battery mode + BLE client — conservative (original defaults) + CHANNEL_SWITCH_MS = 300; + BLE_SCAN_SECONDS = 2; BLE_SCAN_INTERVAL_MS = 5000; } } -private: static volatile uint8_t currentWifiChannel; static unsigned long lastChannelSwitch; static unsigned long lastBLEScan; From 8408e2f0d9880637232db9ce904ea332dd422605 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sun, 1 Feb 2026 19:40:45 -0700 Subject: [PATCH 20/23] Smooth battery display with rolling median filter Battery readings from M5.Power.getBatteryLevel() jitter at charge boundaries (e.g. 79%<->80%). Replace raw reads with a rolling median of the last 8 samples (taken every 3s) to stabilize the display. The filter is extracted to common/BatterySmoothing.h so the median logic is host-testable with doctest. Co-Authored-By: Claude Opus 4.5 --- Makefile | 2 +- common/BatterySmoothing.h | 43 ++++ .../flocksquawk_m5stick.ino | 27 ++- test/test_battery_smoothing.cpp | 200 ++++++++++++++++++ 4 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 common/BatterySmoothing.h create mode 100644 test/test_battery_smoothing.cpp diff --git a/Makefile b/Makefile index 691a924..7bf71ba 100644 --- a/Makefile +++ b/Makefile @@ -139,7 +139,7 @@ TEST_CXXFLAGS := -std=c++17 -Wall -Wextra -g -O0 TEST_INCLUDES := -isystem test/mocks -I common -I test TEST_SRCS := test/test_main.cpp test/eventbus_impl.cpp \ test/test_detectors.cpp test/test_device_tracker.cpp \ - test/test_threat_analyzer.cpp + test/test_threat_analyzer.cpp test/test_battery_smoothing.cpp TEST_BIN := $(BUILD_DIR)/test_runner DOCTEST_URL := https://raw.githubusercontent.com/doctest/doctest/v$(DOCTEST_VERSION)/doctest/doctest.h diff --git a/common/BatterySmoothing.h b/common/BatterySmoothing.h new file mode 100644 index 0000000..0795b4c --- /dev/null +++ b/common/BatterySmoothing.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include + +// Rolling median filter for battery percentage readings. +// Smooths noisy getBatteryLevel() values that oscillate at charge +// boundaries (e.g. 79%<->80%). +struct BatteryFilter { + static const uint8_t HISTORY_SIZE = 8; + uint8_t history[HISTORY_SIZE] = {0}; + uint8_t idx = 0; + bool full = false; + uint8_t smoothed = 0; + + // Fill the entire buffer with a single value so the filter + // produces a stable result immediately after boot. + void seed(uint8_t value) { + for (uint8_t i = 0; i < HISTORY_SIZE; i++) history[i] = value; + full = true; + smoothed = value; + } + + // Push a new raw reading and recompute the median. + void addSample(uint8_t raw) { + history[idx] = raw; + idx = (idx + 1) % HISTORY_SIZE; + if (!full && idx == 0) full = true; + + uint8_t count = full ? HISTORY_SIZE : idx; + if (count == 0) { smoothed = raw; return; } + + // Insertion sort on a tiny copy, then take the median. + uint8_t sorted[HISTORY_SIZE]; + memcpy(sorted, history, count); + for (uint8_t i = 1; i < count; i++) { + uint8_t val = sorted[i]; + int8_t j = i - 1; + while (j >= 0 && sorted[j] > val) { sorted[j + 1] = sorted[j]; j--; } + sorted[j + 1] = val; + } + smoothed = sorted[count / 2]; + } +}; diff --git a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino index 59e2468..c4c60cc 100644 --- a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino +++ b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino @@ -18,6 +18,7 @@ #include "src/RadioScanner.h" #include "ThreatAnalyzer.h" #include "BleTransport.h" +#include "BatterySmoothing.h" #include "TelemetryReporter.h" // TelemetryReporter::_sendViaBle requires BleTransport to be fully defined. @@ -115,6 +116,16 @@ namespace { bool statusMessageActive = false; uint32_t statusMessageUntilMs = 0; + // Battery smoothing + BatteryFilter batteryFilter; + uint32_t lastBatteryReadMs = 0; + + void updateBattery(uint32_t now) { + if (now - lastBatteryReadMs < BATTERY_UPDATE_MS) return; + lastBatteryReadMs = now; + batteryFilter.addSample(M5.Power.getBatteryLevel()); + } + enum class DisplayState { Awake, PowerSaveMessage, @@ -396,7 +407,7 @@ namespace { spriteCreated = true; } - drawDeviceListHeader(1, M5.Power.getBatteryLevel(), countActiveDevices()); + drawDeviceListHeader(1, batteryFilter.smoothed, countActiveDevices()); drawDeviceList(nowMs); displayState = DisplayState::Awake; displayStateMs = nowMs; @@ -758,7 +769,10 @@ void setup() { Serial.println("System operational - scanning for targets"); Serial.println(); - + + // Seed battery smoothing buffer + batteryFilter.seed(M5.Power.getBatteryLevel()); + initScanningUi(millis()); EventBus::publishSystemReady(); } @@ -777,6 +791,9 @@ void loop() { static uint32_t lastPowerCheckMs = 0; uint32_t now = millis(); + // Update smoothed battery reading (gated by BATTERY_UPDATE_MS internally) + updateBattery(now); + // Check external power periodically and adjust scan/display modes if (now - lastPowerCheckMs >= BATTERY_UPDATE_MS) { bool onExternalPower = M5.Power.isCharging(); @@ -824,7 +841,7 @@ void loop() { } // Immediate list refresh on new detection (if display is awake and not alerting) if (!alertActive && !statusMessageActive && displayState == DisplayState::Awake) { - drawDeviceListHeader(dots, M5.Power.getBatteryLevel(), countActiveDevices()); + drawDeviceListHeader(dots, batteryFilter.smoothed, countActiveDevices()); drawDeviceList(now); } } @@ -906,7 +923,7 @@ void loop() { // Header updates (dots animation + battery/count) if (!isAlerting && !statusMessageActive && displayState == DisplayState::Awake && now - lastDotMs >= DOT_UPDATE_MS) { dots = (dots % MAX_DOTS) + 1; - drawDeviceListHeader(dots, M5.Power.getBatteryLevel(), countActiveDevices()); + drawDeviceListHeader(dots, batteryFilter.smoothed, countActiveDevices()); lastDotMs = now; } @@ -914,7 +931,7 @@ void loop() { if (!isAlerting && !statusMessageActive && now - lastListRefreshMs >= LIST_REFRESH_MS) { ageDisplayDevices(now); if (displayState == DisplayState::Awake) { - drawDeviceListHeader(dots, M5.Power.getBatteryLevel(), countActiveDevices()); + drawDeviceListHeader(dots, batteryFilter.smoothed, countActiveDevices()); drawDeviceList(now); } lastListRefreshMs = now; diff --git a/test/test_battery_smoothing.cpp b/test/test_battery_smoothing.cpp new file mode 100644 index 0000000..3483beb --- /dev/null +++ b/test/test_battery_smoothing.cpp @@ -0,0 +1,200 @@ +#include "doctest.h" +#include "BatterySmoothing.h" + +// ============================================================ +// BatteryFilter::seed +// ============================================================ +TEST_CASE("BatteryFilter: seed fills buffer and sets smoothed") { + BatteryFilter f; + f.seed(80); + + CHECK(f.smoothed == 80); + CHECK(f.full == true); + for (uint8_t i = 0; i < BatteryFilter::HISTORY_SIZE; i++) { + CHECK(f.history[i] == 80); + } +} + +TEST_CASE("BatteryFilter: seed with zero") { + BatteryFilter f; + f.seed(0); + CHECK(f.smoothed == 0); +} + +TEST_CASE("BatteryFilter: seed with 100") { + BatteryFilter f; + f.seed(100); + CHECK(f.smoothed == 100); +} + +// ============================================================ +// BatteryFilter::addSample — single sample +// ============================================================ +TEST_CASE("BatteryFilter: single sample on unseeded filter") { + BatteryFilter f; + f.addSample(75); + + CHECK(f.smoothed == 75); + CHECK(f.idx == 1); + CHECK(f.full == false); +} + +// ============================================================ +// BatteryFilter::addSample — partial fill +// ============================================================ +TEST_CASE("BatteryFilter: partial fill returns median of available samples") { + BatteryFilter f; + + SUBCASE("two samples") { + f.addSample(70); + f.addSample(80); + // sorted: [70, 80], count=2, median index=1 -> 80 + CHECK(f.smoothed == 80); + } + + SUBCASE("three samples") { + f.addSample(70); + f.addSample(80); + f.addSample(75); + // sorted: [70, 75, 80], count=3, median index=1 -> 75 + CHECK(f.smoothed == 75); + } + + SUBCASE("four samples") { + f.addSample(70); + f.addSample(80); + f.addSample(75); + f.addSample(72); + // sorted: [70, 72, 75, 80], count=4, median index=2 -> 75 + CHECK(f.smoothed == 75); + } +} + +// ============================================================ +// BatteryFilter::addSample — full buffer +// ============================================================ +TEST_CASE("BatteryFilter: full buffer computes correct median") { + BatteryFilter f; + // Fill: 80, 79, 80, 79, 80, 79, 80, 79 + for (int i = 0; i < 8; i++) { + f.addSample(i % 2 == 0 ? 80 : 79); + } + CHECK(f.full == true); + // sorted: [79, 79, 79, 79, 80, 80, 80, 80], median index=4 -> 80 + CHECK(f.smoothed == 80); +} + +TEST_CASE("BatteryFilter: all same values") { + BatteryFilter f; + for (int i = 0; i < 8; i++) f.addSample(50); + CHECK(f.smoothed == 50); +} + +TEST_CASE("BatteryFilter: ascending values") { + BatteryFilter f; + for (int i = 0; i < 8; i++) f.addSample(40 + i); + // sorted: [40, 41, 42, 43, 44, 45, 46, 47], median index=4 -> 44 + CHECK(f.smoothed == 44); +} + +TEST_CASE("BatteryFilter: descending values") { + BatteryFilter f; + for (int i = 0; i < 8; i++) f.addSample(47 - i); + // sorted: [40, 41, 42, 43, 44, 45, 46, 47], median index=4 -> 44 + CHECK(f.smoothed == 44); +} + +// ============================================================ +// BatteryFilter — oscillation smoothing (the actual use case) +// ============================================================ +TEST_CASE("BatteryFilter: oscillating 79/80 stabilizes to 80") { + BatteryFilter f; + f.seed(80); + + // Simulate boundary oscillation: 79, 80, 79, 80, 79, 80, 79, 80 + for (int i = 0; i < 8; i++) { + f.addSample(i % 2 == 0 ? 79 : 80); + } + // 4x 79, 4x 80 -> sorted: [79,79,79,79,80,80,80,80] -> index 4 = 80 + CHECK(f.smoothed == 80); +} + +TEST_CASE("BatteryFilter: gradual discharge") { + BatteryFilter f; + f.seed(80); + + // Mostly 79 with occasional 80 noise — should converge to 79 + // After seed, buffer is [80, 80, 80, 80, 80, 80, 80, 80] + f.addSample(79); // [79, 80, 80, 80, 80, 80, 80, 80] -> median: 80 + CHECK(f.smoothed == 80); + f.addSample(79); // [79, 79, 80, 80, 80, 80, 80, 80] -> median: 80 + CHECK(f.smoothed == 80); + f.addSample(79); + f.addSample(79); + // [79, 79, 79, 79, 80, 80, 80, 80] -> median index 4: 80 + CHECK(f.smoothed == 80); + f.addSample(79); + // [79, 79, 79, 79, 79, 80, 80, 80] -> median index 4: 79 + CHECK(f.smoothed == 79); +} + +// ============================================================ +// BatteryFilter — single outlier rejected +// ============================================================ +TEST_CASE("BatteryFilter: single outlier does not change smoothed") { + BatteryFilter f; + f.seed(75); + + // Add one wildly different reading + f.addSample(50); + // Buffer: [50, 75, 75, 75, 75, 75, 75, 75] -> median: 75 + CHECK(f.smoothed == 75); +} + +// ============================================================ +// BatteryFilter — wrapping +// ============================================================ +TEST_CASE("BatteryFilter: buffer wraps correctly past HISTORY_SIZE") { + BatteryFilter f; + // Add 12 samples (wraps once at 8) + for (int i = 0; i < 12; i++) { + f.addSample(60 + (i % 3)); // 60, 61, 62, 60, 61, 62, ... + } + CHECK(f.full == true); + // Last 8 samples: 61, 62, 60, 61, 62, 60, 61, 62 + // sorted: [60, 60, 61, 61, 61, 62, 62, 62], median index 4 -> 61 + CHECK(f.smoothed == 61); +} + +// ============================================================ +// BatteryFilter — edge values +// ============================================================ +TEST_CASE("BatteryFilter: handles 0 and 100") { + BatteryFilter f; + f.seed(0); + f.addSample(100); + // Buffer: [100, 0, 0, 0, 0, 0, 0, 0] -> median: 0 + CHECK(f.smoothed == 0); + + // Fill with 100 + for (int i = 0; i < 8; i++) f.addSample(100); + CHECK(f.smoothed == 100); +} + +// ============================================================ +// BatteryFilter — re-seed resets state +// ============================================================ +TEST_CASE("BatteryFilter: re-seed overrides previous state") { + BatteryFilter f; + f.seed(50); + f.addSample(60); + f.addSample(70); + + f.seed(90); + CHECK(f.smoothed == 90); + CHECK(f.full == true); + // All history should be 90 + for (uint8_t i = 0; i < BatteryFilter::HISTORY_SIZE; i++) { + CHECK(f.history[i] == 90); + } +} From 3699c8b462a7e2ad4624b14c0102b1aca7962e56 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 2 Feb 2026 00:53:10 -0700 Subject: [PATCH 21/23] Add BLE/USB connection and power indicators to M5StickC header --- Makefile | 3 +- common/BatterySmoothing.h | 3 + common/ConnectionStatus.h | 20 ++ .../flocksquawk_m5stick.ino | 175 ++++++++++++++---- .../flocksquawk_m5stick/src/RadioScanner.h | 1 + test/test_connection_status.cpp | 60 ++++++ 6 files changed, 223 insertions(+), 39 deletions(-) create mode 100644 common/ConnectionStatus.h create mode 100644 test/test_connection_status.cpp diff --git a/Makefile b/Makefile index 7bf71ba..d98da39 100644 --- a/Makefile +++ b/Makefile @@ -139,7 +139,8 @@ TEST_CXXFLAGS := -std=c++17 -Wall -Wextra -g -O0 TEST_INCLUDES := -isystem test/mocks -I common -I test TEST_SRCS := test/test_main.cpp test/eventbus_impl.cpp \ test/test_detectors.cpp test/test_device_tracker.cpp \ - test/test_threat_analyzer.cpp test/test_battery_smoothing.cpp + test/test_threat_analyzer.cpp test/test_battery_smoothing.cpp \ + test/test_connection_status.cpp TEST_BIN := $(BUILD_DIR)/test_runner DOCTEST_URL := https://raw.githubusercontent.com/doctest/doctest/v$(DOCTEST_VERSION)/doctest/doctest.h diff --git a/common/BatterySmoothing.h b/common/BatterySmoothing.h index 0795b4c..f365c33 100644 --- a/common/BatterySmoothing.h +++ b/common/BatterySmoothing.h @@ -11,6 +11,7 @@ struct BatteryFilter { uint8_t idx = 0; bool full = false; uint8_t smoothed = 0; + uint8_t lastRaw = 0; // Fill the entire buffer with a single value so the filter // produces a stable result immediately after boot. @@ -18,10 +19,12 @@ struct BatteryFilter { for (uint8_t i = 0; i < HISTORY_SIZE; i++) history[i] = value; full = true; smoothed = value; + lastRaw = value; } // Push a new raw reading and recompute the median. void addSample(uint8_t raw) { + lastRaw = raw; history[idx] = raw; idx = (idx + 1) % HISTORY_SIZE; if (!full && idx == 0) full = true; diff --git a/common/ConnectionStatus.h b/common/ConnectionStatus.h new file mode 100644 index 0000000..c8a02dd --- /dev/null +++ b/common/ConnectionStatus.h @@ -0,0 +1,20 @@ +#pragma once +#include + +const uint8_t CONN_NONE = 0; +const uint8_t CONN_SERIAL = 1; + +const uint32_t SERIAL_ALIVE_TIMEOUT_MS = 5000; + +// Pure function: determine serial connection state from timing inputs. +inline uint8_t computeSerialState(uint32_t lastSerialRxMs, uint32_t now) { + bool alive = lastSerialRxMs > 0 && (now - lastSerialRxMs < SERIAL_ALIVE_TIMEOUT_MS); + return alive ? CONN_SERIAL : CONN_NONE; +} + +// Indirect charging detection via battery level trend. +// Returns true when battery is likely charging (level rising or held at 100%). +inline bool isBatteryRising(uint8_t currentLevel, uint8_t previousLevel) { + return (currentLevel > previousLevel) + || (currentLevel == 100 && previousLevel == 100); +} diff --git a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino index c4c60cc..bb5cfcb 100644 --- a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino +++ b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino @@ -19,6 +19,7 @@ #include "ThreatAnalyzer.h" #include "BleTransport.h" #include "BatterySmoothing.h" +#include "ConnectionStatus.h" #include "TelemetryReporter.h" // TelemetryReporter::_sendViaBle requires BleTransport to be fully defined. @@ -103,7 +104,7 @@ namespace { uint32_t alertLastFlashMs = 0; uint32_t alertUntilMs = 0; uint32_t detectionCount = 0; - bool powerSaverEnabled = true; + bool powerSaverEnabled = false; portMUX_TYPE threatMux = portMUX_INITIALIZER_UNLOCKED; volatile bool threatPending = false; ThreatEvent pendingThreat; @@ -128,6 +129,7 @@ namespace { enum class DisplayState { Awake, + Debug, PowerSaveMessage, Off }; @@ -291,7 +293,8 @@ namespace { } } - void drawDeviceListHeader(uint8_t dots, uint8_t battery, uint8_t activeCount) { + void drawDeviceListHeader(uint8_t dots, uint8_t battery, + bool bleConnected, uint8_t serialState, bool batteryRising) { if (!spriteCreated) return; headerSprite->fillSprite(TFT_BLACK); @@ -306,13 +309,49 @@ namespace { headerSprite->setTextColor(STATUS_TEXT_COLOR, TFT_BLACK); headerSprite->print(scanText); - // Right: "B:85% D:3" - char rightText[16]; - snprintf(rightText, sizeof(rightText), "B:%u%% D:%u", battery, activeCount); - int16_t textWidth = headerSprite->textWidth(rightText); - headerSprite->setCursor(240 - textWidth, 0); + // Right side, drawn right-to-left: + // "X +80%" + int16_t x = 240; + + // Battery percentage (color-coded) — reserve fixed width for "100%" + // so elements to the left don't shift as digit count changes. + char battText[6]; + snprintf(battText, sizeof(battText), "%u%%", battery); + int16_t battMaxW = headerSprite->textWidth("100%"); + int16_t battActualW = headerSprite->textWidth(battText); + x -= battMaxW; + headerSprite->setCursor(x + (battMaxW - battActualW), 0); headerSprite->setTextColor(batteryColor(battery), TFT_BLACK); - headerSprite->print(rightText); + headerSprite->print(battText); + + // Charging indicator "+" — always reserve space, only draw when rising + x -= headerSprite->textWidth("+"); + if (batteryRising) { + headerSprite->setCursor(x, 0); + headerSprite->setTextColor(TFT_GREEN, TFT_BLACK); + headerSprite->print("+"); + } + + // Gap + x -= 8; + + // Data connection: B (BLE), S (serial), or X (none) + char connChar; + uint16_t connColor; + if (bleConnected) { + connChar = 'B'; + connColor = TFT_BLUE; + } else if (serialState == CONN_SERIAL) { + connChar = 'S'; + connColor = TFT_CYAN; + } else { + connChar = 'X'; + connColor = TFT_RED; + } + x -= headerSprite->textWidth("B"); + headerSprite->setCursor(x, 0); + headerSprite->setTextColor(connColor, TFT_BLACK); + headerSprite->print(connChar); // Separator line at bottom of header sprite headerSprite->drawFastHLine(0, LIST_SEPARATOR_Y, 240, TFT_DARKGREY); @@ -391,7 +430,8 @@ namespace { listSprite->pushSprite(0, LIST_TOP_Y); } - void initScanningUi(uint32_t nowMs) { + void initScanningUi(uint32_t nowMs, bool bleConn = false, + uint8_t serSt = 0, bool batRising = false) { setDisplayOn(); M5.Display.clear(TFT_BLACK); M5.Display.setTextColor(STATUS_TEXT_COLOR, TFT_BLACK); @@ -407,7 +447,7 @@ namespace { spriteCreated = true; } - drawDeviceListHeader(1, batteryFilter.smoothed, countActiveDevices()); + drawDeviceListHeader(1, batteryFilter.smoothed, bleConn, serSt, batRising); drawDeviceList(nowMs); displayState = DisplayState::Awake; displayStateMs = nowMs; @@ -493,6 +533,32 @@ namespace { alertUntilMs = nowMs + ALERT_DURATION_MS; } + const uint32_t DEBUG_REFRESH_MS = 400; + + // Draws debug info directly to display, overwriting in place. + // Call fillScreen(BLACK) once before the first call. + void drawDebugScreen() { + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + M5.Display.setTextSize(2); + M5.Display.setCursor(0, 0); + + // Scan status + M5.Display.printf("WiFi CH: %-4u\n", RadioScannerManager::getCurrentWifiChannel()); + M5.Display.printf("BLE: %-4s\n", RadioScannerManager::isBleScanning() ? "scan" : "idle"); + + // System health + M5.Display.printf("Heap: %-4ukB\n", (unsigned)(ESP.getFreeHeap() / 1024)); + M5.Display.printf("Up: %-6us\n", (unsigned)(millis() / 1000)); + + // Battery — raw vs smoothed + M5.Display.printf("Batt: %u/%-4u%%\n", batteryFilter.lastRaw, batteryFilter.smoothed); + + // Detection stats + uint8_t active = countActiveDevices(); + M5.Display.printf("Devs: %u/%-4u\n", active, displayDeviceCount); + M5.Display.printf("Alerts: %-6u\n", (unsigned)detectionCount); + } + void showStatusMessage(const char* line1, const char* line2) { setDisplayOn(); drawCenteredMessage(line1, line2, TFT_BLACK, TFT_WHITE); @@ -769,7 +835,7 @@ void setup() { Serial.println("System operational - scanning for targets"); Serial.println(); - + // Seed battery smoothing buffer batteryFilter.seed(M5.Power.getBatteryLevel()); @@ -784,27 +850,40 @@ void loop() { static uint8_t dots = 1; static uint32_t lastDotMs = 0; static uint32_t lastListRefreshMs = 0; + static uint32_t lastDebugRefreshMs = 0; static bool wasAlertActive = false; static bool powerToggleHandled = false; static bool lastShouldPowerSave = false; - static bool lastOnExternalPower = false; - static uint32_t lastPowerCheckMs = 0; uint32_t now = millis(); // Update smoothed battery reading (gated by BATTERY_UPDATE_MS internally) updateBattery(now); - // Check external power periodically and adjust scan/display modes - if (now - lastPowerCheckMs >= BATTERY_UPDATE_MS) { - bool onExternalPower = M5.Power.isCharging(); - if (onExternalPower != lastOnExternalPower) { - RadioScannerManager::setPerformanceMode(onExternalPower); - lastOnExternalPower = onExternalPower; + // Drain incoming serial data (heartbeat pings from app) + static uint32_t lastSerialRxMs = 0; + while (Serial.available()) { + Serial.read(); + lastSerialRxMs = now; + } + + // Battery trend tracking for indirect charge detection (uses raw for fast response) + static uint8_t prevBatteryRaw = 0; + static bool batteryRising = false; + static uint32_t lastBatteryTrendMs = 0; + if (now - lastBatteryTrendMs >= BATTERY_UPDATE_MS) { + // Skip trend comparison on first iteration (prev is unseeded) + if (lastBatteryTrendMs > 0) { + batteryRising = isBatteryRising(batteryFilter.lastRaw, prevBatteryRaw); } - lastPowerCheckMs = now; + prevBatteryRaw = batteryFilter.lastRaw; + lastBatteryTrendMs = now; } - bool shouldPowerSave = lastOnExternalPower ? false : powerSaverEnabled; + // Connection indicators for header + uint8_t serialState = computeSerialState(lastSerialRxMs, now); + bool bleConn = bleTransport.isClientConnected(); + + bool shouldPowerSave = powerSaverEnabled; if (wifiFramePending) { WiFiFrameEvent frameCopy; @@ -839,14 +918,14 @@ void loop() { } else if (threatCopy.alertLevel == ALERT_SUSPICIOUS && threatCopy.firstDetection) { M5.Speaker.tone(1800, 60); } - // Immediate list refresh on new detection (if display is awake and not alerting) + // Immediate list refresh on new detection (main screen only) if (!alertActive && !statusMessageActive && displayState == DisplayState::Awake) { - drawDeviceListHeader(dots, batteryFilter.smoothed, countActiveDevices()); + drawDeviceListHeader(dots, batteryFilter.smoothed, bleConn, serialState, batteryRising); drawDeviceList(now); } } - // Scroll buttons — before the long-press handler + // Scroll buttons — main screen only if (M5.BtnPWR.wasClicked() && !alertActive && !statusMessageActive && displayState == DisplayState::Awake) { if (scrollOffset > 0) { scrollOffset--; @@ -874,10 +953,23 @@ void loop() { powerToggleHandled = false; } - if (M5.BtnA.wasPressed() && shouldPowerSave) { - initScanningUi(now); - lastDotMs = now; - lastListRefreshMs = now; + // BtnA: toggle between main screen and debug screen + if (M5.BtnA.wasPressed()) { + if (shouldPowerSave && displayState == DisplayState::Off) { + initScanningUi(now, bleConn, serialState, batteryRising); + lastDotMs = now; + lastListRefreshMs = now; + } else if (displayState == DisplayState::Debug) { + initScanningUi(now, bleConn, serialState, batteryRising); + lastDotMs = now; + lastListRefreshMs = now; + } else if (displayState == DisplayState::Awake) { + setDisplayOn(); + M5.Display.fillScreen(TFT_BLACK); + drawDebugScreen(); + displayState = DisplayState::Debug; + lastDebugRefreshMs = now; + } } bool isAlerting = updateAlert(now); @@ -890,7 +982,7 @@ void loop() { setDisplayOff(); displayState = DisplayState::Off; } else { - initScanningUi(now); + initScanningUi(now, bleConn, serialState, batteryRising); lastDotMs = now; lastListRefreshMs = now; } @@ -899,12 +991,12 @@ void loop() { if (statusMessageActive && now >= statusMessageUntilMs) { statusMessageActive = false; - initScanningUi(now); + initScanningUi(now, bleConn, serialState, batteryRising); } if (!isAlerting && !statusMessageActive && shouldPowerSave != lastShouldPowerSave) { lastShouldPowerSave = shouldPowerSave; - initScanningUi(now); + initScanningUi(now, bleConn, serialState, batteryRising); } if (!isAlerting && !statusMessageActive) { @@ -915,23 +1007,30 @@ void loop() { setDisplayOff(); displayState = DisplayState::Off; } - } else if (displayState != DisplayState::Awake) { - initScanningUi(now); + } else if (displayState == DisplayState::Off) { + initScanningUi(now, bleConn, serialState, batteryRising); } } - // Header updates (dots animation + battery/count) - if (!isAlerting && !statusMessageActive && displayState == DisplayState::Awake && now - lastDotMs >= DOT_UPDATE_MS) { + // Debug screen refresh — skip during status messages and alerts + if (displayState == DisplayState::Debug && !statusMessageActive && !isAlerting + && now - lastDebugRefreshMs >= DEBUG_REFRESH_MS) { + drawDebugScreen(); + lastDebugRefreshMs = now; + } + + // Header updates (dots animation + battery/count) — main screen only + if (displayState == DisplayState::Awake && !isAlerting && !statusMessageActive && now - lastDotMs >= DOT_UPDATE_MS) { dots = (dots % MAX_DOTS) + 1; - drawDeviceListHeader(dots, batteryFilter.smoothed, countActiveDevices()); + drawDeviceListHeader(dots, batteryFilter.smoothed, bleConn, serialState, batteryRising); lastDotMs = now; } - // Periodic device list refresh — age devices and redraw + // Periodic device list refresh — main screen only if (!isAlerting && !statusMessageActive && now - lastListRefreshMs >= LIST_REFRESH_MS) { ageDisplayDevices(now); if (displayState == DisplayState::Awake) { - drawDeviceListHeader(dots, batteryFilter.smoothed, countActiveDevices()); + drawDeviceListHeader(dots, batteryFilter.smoothed, bleConn, serialState, batteryRising); drawDeviceList(now); } lastListRefreshMs = now; diff --git a/m5stack/flocksquawk_m5stick/src/RadioScanner.h b/m5stack/flocksquawk_m5stick/src/RadioScanner.h index ca95cda..85c2019 100644 --- a/m5stack/flocksquawk_m5stick/src/RadioScanner.h +++ b/m5stack/flocksquawk_m5stick/src/RadioScanner.h @@ -20,6 +20,7 @@ class RadioScannerManager { void initialize(); void update(); // Call from main loop static uint8_t getCurrentWifiChannel(); + static bool isBleScanning() { return isScanningBLE; } // Switch between battery-optimized and high-performance scanning static void setPerformanceMode(bool highPerformance) { diff --git a/test/test_connection_status.cpp b/test/test_connection_status.cpp new file mode 100644 index 0000000..dc49574 --- /dev/null +++ b/test/test_connection_status.cpp @@ -0,0 +1,60 @@ +#include "doctest.h" +#include "ConnectionStatus.h" + +// ============================================================ +// computeSerialState +// ============================================================ +TEST_CASE("computeSerialState: no serial ever (lastSerialRxMs == 0)") { + CHECK(computeSerialState(0, 10000) == CONN_NONE); +} + +TEST_CASE("computeSerialState: serial within timeout") { + // 10000 - 8000 = 2000ms < 5000ms timeout + CHECK(computeSerialState(8000, 10000) == CONN_SERIAL); +} + +TEST_CASE("computeSerialState: serial expired") { + // 10000 - 4000 = 6000ms > 5000ms timeout + CHECK(computeSerialState(4000, 10000) == CONN_NONE); +} + +TEST_CASE("computeSerialState: exactly at timeout boundary") { + // now - lastSerialRxMs == 5000, which is NOT < 5000, so not alive + CHECK(computeSerialState(5000, 10000) == CONN_NONE); +} + +TEST_CASE("computeSerialState: one ms before timeout") { + // now - lastSerialRxMs == 4999, which IS < 5000 + CHECK(computeSerialState(5001, 10000) == CONN_SERIAL); +} + +// ============================================================ +// isBatteryRising +// ============================================================ +TEST_CASE("isBatteryRising: level increased") { + CHECK(isBatteryRising(80, 79) == true); +} + +TEST_CASE("isBatteryRising: level unchanged") { + CHECK(isBatteryRising(80, 80) == false); +} + +TEST_CASE("isBatteryRising: level decreased") { + CHECK(isBatteryRising(79, 80) == false); +} + +TEST_CASE("isBatteryRising: held at 100%") { + CHECK(isBatteryRising(100, 100) == true); +} + +TEST_CASE("isBatteryRising: rose to 100%") { + CHECK(isBatteryRising(100, 99) == true); +} + +TEST_CASE("isBatteryRising: dropped from 100%") { + CHECK(isBatteryRising(99, 100) == false); +} + +TEST_CASE("isBatteryRising: both zero") { + CHECK(isBatteryRising(0, 0) == false); +} From 3a916bbade81e3084457d3dd197d60d15cd58961 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Feb 2026 21:21:54 -0700 Subject: [PATCH 22/23] Fix WiFi frame subtype bitmask in 128x32 OLED variant The mask 0x0F selects bits 0-3 (protocol version + frame type), which is always 0 for management frames, silently breaking all WiFi subtype detection. The correct mask 0x00F0 selects bits 4-7 (subtype field). All other 5 variants already used the correct mask. Co-Authored-By: Claude Opus 4.6 --- 128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino b/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino index 9e33b15..1b3528f 100644 --- a/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino +++ b/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino @@ -199,7 +199,7 @@ void RadioScannerManager::wifiPacketHandler(void* buffer, wifi_promiscuous_pkt_t if (packet->rx_ctrl.sig_len < 24) return; const WiFi80211Header* header = (const WiFi80211Header*)rawData; - uint8_t frameSubtype = (header->frameControl & 0x0F) >> 4; + uint8_t frameSubtype = (header->frameControl & 0x00F0) >> 4; bool isProbeRequest = (frameSubtype == 0x04); bool isProbeResponse = (frameSubtype == 0x05); From ff5b4d1cbcaea5abd06eb09c65ad4cd5f693e57b Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Feb 2026 21:22:00 -0700 Subject: [PATCH 23/23] Add missing OUI prefixes from flock-you PR #29 and Issue #28 Add 7 Flock WiFi OUI prefixes (Liteon/USI contract manufacturers) field-confirmed and IEEE cross-referenced from upstream reports: f4:6a:dd, f8:a2:d6, e0:0a:f6, 00:f4:8d, d0:39:57, e8:d0:fc, 24:b2:b9 Add SoundThinking (ShotSpotter) BLE OUI d4:11:d6 to surveillance prefixes for acoustic gunshot detection sensor awareness. Co-Authored-By: Claude Opus 4.6 --- common/DeviceSignatures.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/common/DeviceSignatures.h b/common/DeviceSignatures.h index e0ca76f..443d312 100644 --- a/common/DeviceSignatures.h +++ b/common/DeviceSignatures.h @@ -10,7 +10,9 @@ namespace DeviceProfiles { "58:8e:81", "cc:cc:cc", "ec:1b:bd", "90:35:ea", "04:0d:84", "f0:82:c0", "1c:34:f1", "38:5b:44", "94:34:69", "b4:e3:f9", "70:c9:4e", "3c:91:80", "d8:f3:bc", "80:30:49", "14:5a:fc", - "74:4c:a1", "08:3a:88", "9c:2f:9d", "94:08:53", "e4:aa:ea" + "74:4c:a1", "08:3a:88", "9c:2f:9d", "94:08:53", "e4:aa:ea", + "f4:6a:dd", "f8:a2:d6", "e0:0a:f6", "00:f4:8d", "d0:39:57", + "e8:d0:fc", "24:b2:b9" }; const size_t MACPrefixCount = sizeof(MACPrefixes) / sizeof(MACPrefixes[0]); @@ -47,6 +49,8 @@ namespace DeviceProfiles { { "00:12:81", "March Networks" }, // Mobotix { "00:03:c5", "Mobotix" }, + // SoundThinking (ShotSpotter) + { "d4:11:d6", "SoundThinking" }, // Sunell Electronics { "00:1c:27", "Sunell Electronics" }, };