Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.build/
test/doctest.h
261 changes: 67 additions & 194 deletions 128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@
#include <string.h>
#include <ctype.h>
#include <stdint.h>
#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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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);

Copilot AI Feb 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent handling of the tick() return value across variants. The ThreatAnalyzer::tick() method returns true when a heartbeat beep should be emitted, but this return value is only checked in the m5stick and m5fire variants (lines 623-625 and 1158-1160 respectively).

The 128x32 OLED variant at line 550, Mini12864 variant at line 421, and Flipper Zero variant at line 463 all ignore the return value. While this may be intentional (e.g., Flipper has no audio), the 128x32_portable variant does have buzzer capability and should probably check the return value to emit heartbeat beeps.

Consider either using the return value consistently across all variants that have audio capability, or documenting why certain variants intentionally ignore it.

Suggested change
threatEngine.tick(now);
bool heartbeat = threatEngine.tick(now);
if (heartbeat) {
AudioEvent audioEvent;
audioEvent.soundFile = "/heartbeat.wav";
EventBus::publishAudioRequest(audioEvent);
}

Copilot uses AI. Check for mistakes.

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);
}
51 changes: 0 additions & 51 deletions 128x32_OLED/flocksquawk_128x32/src/DeviceSignatures.h

This file was deleted.

Loading
Loading