From d9825be40b5514269a4138212175e0afbb2d84b9 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 2 Feb 2026 00:48:48 -0700 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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)); +}