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/.gitignore b/.gitignore new file mode 100644 index 0000000..794cd06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.build/ +test/doctest.h 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/flocksquawk_128x32.ino b/128x32_OLED/flocksquawk_128x32/flocksquawk_128x32.ino index 4778bf7..1b3528f 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(); @@ -186,24 +199,25 @@ 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); 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; } @@ -223,118 +237,9 @@ 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); -} +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() { @@ -566,76 +471,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 +483,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 +531,44 @@ 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); + } + } + // SUSPICIOUS events are captured via telemetry; display-only variants + // show them through the normal DisplayEngine state machine. + 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/RadioScanner.h b/128x32_OLED/flocksquawk_128x32/src/RadioScanner.h index 6484951..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 = 500; - static const uint8_t BLE_SCAN_SECONDS = 1; - 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/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/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/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino b/128x32_OLED/flocksquawk_128x32_portable/flocksquawk_128x32_portable.ino index 74430be..fac512e 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(); @@ -310,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; } @@ -344,188 +367,10 @@ 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; -// 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 +389,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 +431,69 @@ 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); + } else if (threatCopy.alertLevel == ALERT_SUSPICIOUS && threatCopy.firstDetection) { + buzzerBeep(1800, 60); + } + } + 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/RadioScanner.h b/128x32_OLED/flocksquawk_128x32_portable/src/RadioScanner.h index 6484951..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 = 500; - static const uint8_t BLE_SCAN_SECONDS = 1; - 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/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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..983143d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +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. + +## Quick Reference + +| 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) | + +## Pipeline + +``` +RadioScannerManager (WiFi promiscuous + BLE scan) + -> EventBus (WiFiFrameEvent / BluetoothDeviceEvent) + -> ThreatAnalyzer (signature matching -> ThreatEvent) + -> TelemetryReporter (JSON over Serial) + -> Display (variant-specific UI) + -> SoundEngine (alerts) +``` + +## Variants + +| 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 | + +Each variant has its own README with wiring, board settings, and usage instructions. + +## Source Layout + +``` +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) +``` + +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. + +## 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 + +## 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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..045f1ef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,89 @@ +# FlockSquawk Docker Build Environment +# All toolchains and libraries pre-baked for zero-install builds. +# +# 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 + +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 \ + clang \ + make \ + python3 \ + python3-serial \ + curl \ + ca-certificates \ + git \ + && rm -rf /var/lib/apt/lists/* + +# ── 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 -- ${ARDUINO_CLI_VERSION} \ + && 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 (~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@${ESP32_CORE_VERSION} + +# ── 5. Arduino libraries ──────────────────────────────────────────── +RUN arduino-cli lib install \ + 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/v${DOCTEST_VERSION}/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 new file mode 100644 index 0000000..d98da39 --- /dev/null +++ b/Makefile @@ -0,0 +1,277 @@ +# 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 ?= $(ESP32_CORE_VERSION) + +BUILD_DIR := $(CURDIR)/.build +COMMON_DIR := $(CURDIR)/common + +# 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:m5stack_stickc_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) \ + --build-property "build.defines=-I$(COMMON_DIR)" \ + --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) + +# ────────────────────────────────────────────── +# 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 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_connection_status.cpp +TEST_BIN := $(BUILD_DIR)/test_runner +DOCTEST_URL := https://raw.githubusercontent.com/doctest/doctest/v$(DOCTEST_VERSION)/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 +# ────────────────────────────────────────────── +.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@$(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 +# ────────────────────────────────────────────── +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) \ + --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 + +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) +# ────────────────────────────────────────────── +.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 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 "" + @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 "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)" + @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" 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/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino b/Mini12864/flocksquawk_mini12864/flocksquawk_mini12864.ino index 26c2b21..a7a0f32 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(); @@ -193,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; } @@ -227,123 +241,14 @@ 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; } -// 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 +347,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 +360,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 +400,47 @@ 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); + } else if (threatCopy.alertLevel == ALERT_SUSPICIOUS && threatCopy.firstDetection) { + Mini12864DisplayShowAlert(); + } + } + Mini12864DisplayUpdate(); float newVolume = 0.0f; if (Mini12864DisplayConsumeVolume(&newVolume)) { @@ -572,5 +452,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/RadioScanner.h b/Mini12864/flocksquawk_mini12864/src/RadioScanner.h index 0d72b08..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 = 500; - static const uint8_t BLE_SCAN_SECONDS = 1; - 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/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 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/common/BatterySmoothing.h b/common/BatterySmoothing.h new file mode 100644 index 0000000..f365c33 --- /dev/null +++ b/common/BatterySmoothing.h @@ -0,0 +1,46 @@ +#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; + uint8_t lastRaw = 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; + 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; + + 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/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/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/common/DetectorTypes.h b/common/DetectorTypes.h new file mode 100644 index 0000000..cdb9be8 --- /dev/null +++ b/common/DetectorTypes.h @@ -0,0 +1,81 @@ +#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), + DET_FLOCK_OUI = (1 << 7), + 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; + +// 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; +}; + +// 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, + NEW_DETECT, + IN_RANGE, + DEPARTED, +}; + +struct TrackedDevice { + uint8_t mac[6]; + uint32_t firstSeenMs; + uint32_t lastSeenMs; + AlertLevel maxAlertLevel; + 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; + +#endif diff --git a/common/Detectors.h b/common/Detectors.h new file mode 100644 index 0000000..3f51cc4 --- /dev/null +++ b/common/Detectors.h @@ -0,0 +1,232 @@ +#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", "test_flck" + }; + 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; +} + +// 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 +// ============================================================ + +// 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; +} + +// 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; +} + +// ============================================================ +// 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 +// ============================================================ + +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/common/DeviceSignatures.h b/common/DeviceSignatures.h new file mode 100644 index 0000000..443d312 --- /dev/null +++ b/common/DeviceSignatures.h @@ -0,0 +1,61 @@ +#ifndef DEVICE_SIGNATURES_H +#define DEVICE_SIGNATURES_H + +#include + +namespace DeviceProfiles { + + // 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", + "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", + "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]); + + // 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" }, + // SoundThinking (ShotSpotter) + { "d4:11:d6", "SoundThinking" }, + // Sunell Electronics + { "00:1c:27", "Sunell Electronics" }, + }; + const size_t SurveillancePrefixCount = + sizeof(SurveillancePrefixes) / sizeof(SurveillancePrefixes[0]); +} + +#endif diff --git a/Mini12864/flocksquawk_mini12864/src/EventBus.h b/common/EventBus.h similarity index 86% rename from Mini12864/flocksquawk_mini12864/src/EventBus.h rename to common/EventBus.h index 599399e..1d9f9b3 100644 --- a/Mini12864/flocksquawk_mini12864/src/EventBus.h +++ b/common/EventBus.h @@ -3,6 +3,7 @@ #include #include +#include "DetectorTypes.h" enum class EventType { WifiFrameCaptured, @@ -33,9 +34,15 @@ 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[MAX_DETECTOR_WEIGHTS]; + int8_t rssiModifier; + AlertLevel alertLevel; + bool firstDetection; // true when device was not previously tracked + bool shouldAlert; }; struct AudioEvent { diff --git a/common/TelemetryReporter.h b/common/TelemetryReporter.h new file mode 100644 index 0000000..4b4c716 --- /dev/null +++ b/common/TelemetryReporter.h @@ -0,0 +1,91 @@ +#ifndef TELEMETRY_REPORTER_H +#define TELEMETRY_REPORTER_H + +#include +#include +#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; + + 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["alert_level"] = threat.alertLevel; + 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", "flock_oui", "surveillance_oui" + }; + + for (uint8_t bit = 0; bit < MAX_DETECTOR_WEIGHTS; 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]; + } + } + } + + // +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/common/ThreatAnalyzer.h b/common/ThreatAnalyzer.h new file mode 100644 index 0000000..85614d7 --- /dev/null +++ b/common/ThreatAnalyzer.h @@ -0,0 +1,307 @@ +#ifndef THREAT_ANALYZER_H +#define THREAT_ANALYZER_H + +#include +#include "EventBus.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 }, + { 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 }, + { detectBleSurveillanceOui, DET_SURVEILLANCE_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 +// ============================================================ + +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, + AlertLevel level) { + 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 (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].maxAlertLevel = level; + slots[slot].state = DeviceState::NEW_DETECT; + return DeviceState::EMPTY; + } + + // 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].maxAlertLevel >= ALERT_SUSPICIOUS) { + 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() { + tracker.initialize(); + lastHeartbeatMs = 0; + } + + void analyzeWiFiFrame(const WiFiFrameEvent& frame) { + uint16_t matchFlags = 0; + uint8_t weights[MAX_DETECTOR_WEIGHTS]; + 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; + } + } + + 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, level); + + 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; + 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; + threat.alertLevel = level; + threat.firstDetection = (prevState == DeviceState::EMPTY); + threat.shouldAlert = (level >= ALERT_CONFIRMED && + threat.firstDetection); + + EventBus::publishThreat(threat); + } + + void analyzeBluetoothDevice(const BluetoothDeviceEvent& device) { + uint16_t matchFlags = 0; + uint8_t weights[MAX_DETECTOR_WEIGHTS]; + 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); + + AlertLevel level = computeBLEAlertLevel(matchFlags); + + uint32_t nowMs = millis(); + DeviceState prevState = tracker.recordDetection( + device.mac, nowMs, level); + + 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)); + 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.alertLevel = level; + threat.firstDetection = (prevState == DeviceState::EMPTY); + threat.shouldAlert = (level >= ALERT_CONFIRMED && + threat.firstDetection); + + 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: + DeviceTracker tracker; + uint32_t lastHeartbeatMs; +}; + +#endif 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/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/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/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/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino b/flipper-zero/dev-board-firmware/flocksquawk-flipper/flocksquawk-flipper.ino index 1f8dcba..ed1112e 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(); @@ -267,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; } @@ -303,118 +317,9 @@ unsigned long RadioScannerManager::lastBLEScan = 0; 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); -} +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() { @@ -454,7 +359,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 +410,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 +443,38 @@ 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); + 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 74% rename from flipper-zero/dev-board-firmware/src/RadioScanner.h rename to flipper-zero/dev-board-firmware/flocksquawk-flipper/src/RadioScanner.h index d86772c..9809558 100644 --- a/flipper-zero/dev-board-firmware/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 = 500; - static const uint8_t BLE_SCAN_SECONDS = 1; - 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/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 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_m5fire/flocksquawk_m5fire.ino b/m5stack/flocksquawk_m5fire/flocksquawk_m5fire.ino index d6ce947..c1cb705 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(); @@ -294,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; } @@ -328,118 +342,9 @@ 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); -} +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() { @@ -560,76 +465,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 +489,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 +1127,73 @@ void loop() { M5.update(); rfScanner.update(); 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); + 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); + } else if (threatCopy.alertLevel == ALERT_SUSPICIOUS && threatCopy.firstDetection) { + M5.Speaker.tone(1800, 60); + } + } + #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/RadioScanner.h b/m5stack/flocksquawk_m5fire/src/RadioScanner.h index 4c85869..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 = 500; - static const uint8_t BLE_SCAN_SECONDS = 1; - 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_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 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) diff --git a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino index 412428b..bb5cfcb 100644 --- a/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino +++ b/m5stack/flocksquawk_m5stick/flocksquawk_m5stick.ino @@ -13,39 +13,48 @@ #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 "BleTransport.h" +#include "BatterySmoothing.h" +#include "ConnectionStatus.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; 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; 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; @@ -61,28 +70,66 @@ 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; 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; 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; + // 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, + Debug, PowerSaveMessage, Off }; @@ -90,12 +137,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); @@ -106,132 +147,308 @@ 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; + // --- 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; + } + } - M5.Display.fillRect(0, top, width, height, TFT_BLACK); - M5.Display.drawRect(0, top, width, height, TFT_WHITE); + // 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; + } + } + } + } else { + displayDeviceCount++; + } - int16_t plotTop = top + 1; - int16_t plotBottom = bottom - 1; - int16_t plotHeight = plotBottom - plotTop; + 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]); + } + } - 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); + 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; } - lastX = x; - lastY = y; } } - 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); + uint8_t countActiveDevices() { + uint8_t count = 0; + for (uint8_t i = 0; i < displayDeviceCount; i++) { + if (displayDevices[i].active) count++; + } + return count; } - uint16_t batteryColor(uint8_t percent) { - if (percent <= 35) { - return TFT_RED; - } - if (percent <= 75) { - return TFT_BLUE; + 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, + bool bleConnected, uint8_t serialState, bool batteryRising) { + 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 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(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); + + 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, bool bleConn = false, + uint8_t serSt = 0, bool batRising = false) { 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, batteryFilter.smoothed, bleConn, serSt, batRising); + drawDeviceList(nowMs); displayState = DisplayState::Awake; displayStateMs = nowMs; } @@ -316,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); @@ -363,6 +606,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(); @@ -382,7 +633,9 @@ void RadioScannerManager::configureWiFiSniffer() { } void RadioScannerManager::configureBluetoothScanner() { - NimBLEDevice::init(""); + if (!NimBLEDevice::isInitialized()) { + NimBLEDevice::init(""); + } bleScanner = NimBLEDevice::getScan(); bleScanner->setActiveScan(true); bleScanner->setInterval(100); @@ -481,21 +734,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; } @@ -510,193 +764,18 @@ 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; +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; -// 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 +805,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) { @@ -738,33 +820,69 @@ 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"); Serial.println(); - initScanningUi(RadioScannerManager::getCurrentWifiChannel(), millis()); + // Seed battery smoothing buffer + batteryFilter.seed(M5.Power.getBatteryLevel()); + + initScanningUi(millis()); EventBus::publishSystemReady(); } void loop() { M5.update(); + RadioScannerManager::applyPendingDutyCycle(); 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 uint32_t lastDebugRefreshMs = 0; static bool wasAlertActive = false; static bool powerToggleHandled = false; static bool lastShouldPowerSave = false; - uint8_t channel = RadioScannerManager::getCurrentWifiChannel(); uint32_t now = millis(); + + // Update smoothed battery reading (gated by BATTERY_UPDATE_MS internally) + updateBattery(now); + + // 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); + } + prevBatteryRaw = batteryFilter.lastRaw; + lastBatteryTrendMs = now; + } + + // Connection indicators for header + uint8_t serialState = computeSerialState(lastSerialRxMs, now); + bool bleConn = bleTransport.isClientConnected(); + bool shouldPowerSave = powerSaverEnabled; if (wifiFramePending) { @@ -773,10 +891,20 @@ void loop() { frameCopy = pendingWiFiFrame; wifiFramePending = false; portEXIT_CRITICAL(&wifiMux); - latestRssi = 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); @@ -784,7 +912,33 @@ void loop() { threatPending = false; portEXIT_CRITICAL(&threatMux); reporter.handleThreatDetection(threatCopy); - triggerAlert(now); + 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 (main screen only) + if (!alertActive && !statusMessageActive && displayState == DisplayState::Awake) { + drawDeviceListHeader(dots, batteryFilter.smoothed, bleConn, serialState, batteryRising); + drawDeviceList(now); + } + } + + // Scroll buttons — main screen only + 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) { @@ -799,14 +953,23 @@ void loop() { powerToggleHandled = false; } - if (M5.BtnA.wasPressed() && shouldPowerSave) { - initScanningUi(channel, now); - lastChannel = channel; - lastDotMs = now; - lastBatteryMs = now; - lastSweepMs = now; - lastRssiMs = now; - lastSweepX = -1; + // 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); @@ -819,33 +982,21 @@ void loop() { setDisplayOff(); displayState = DisplayState::Off; } else { - initScanningUi(channel, now); - lastChannel = channel; + initScanningUi(now, bleConn, serialState, batteryRising); 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, bleConn, serialState, batteryRising); } if (!isAlerting && !statusMessageActive && shouldPowerSave != lastShouldPowerSave) { lastShouldPowerSave = shouldPowerSave; - if (shouldPowerSave) { - initScanningUi(channel, now); - } else { - initScanningUi(channel, now); - } + initScanningUi(now, bleConn, serialState, batteryRising); } if (!isAlerting && !statusMessageActive) { @@ -856,62 +1007,34 @@ void loop() { setDisplayOff(); displayState = DisplayState::Off; } - } else if (displayState != DisplayState::Awake) { - initScanningUi(channel, now); + } else if (displayState == DisplayState::Off) { + initScanningUi(now, bleConn, serialState, batteryRising); } } - if (!isAlerting && !statusMessageActive && displayState == DisplayState::Awake && channel != lastChannel) { - drawStatusText(channel, dots); - drawBatteryArea(M5.Power.getBatteryLevel(), detectionCount); - lastChannel = channel; + // Debug screen refresh — skip during status messages and alerts + if (displayState == DisplayState::Debug && !statusMessageActive && !isAlerting + && now - lastDebugRefreshMs >= DEBUG_REFRESH_MS) { + drawDebugScreen(); + lastDebugRefreshMs = now; } - if (!isAlerting && !statusMessageActive && displayState == DisplayState::Awake && now - lastDotMs >= DOT_UPDATE_MS) { + // 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; - drawStatusText(channel, dots); - drawBatteryArea(M5.Power.getBatteryLevel(), detectionCount); + drawDeviceListHeader(dots, batteryFilter.smoothed, bleConn, serialState, batteryRising); 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 — main screen only + if (!isAlerting && !statusMessageActive && now - lastListRefreshMs >= LIST_REFRESH_MS) { + ageDisplayDevices(now); if (displayState == DisplayState::Awake) { - drawRssiChart(); - drawGraphBox(); + drawDeviceListHeader(dots, batteryFilter.smoothed, bleConn, serialState, batteryRising); + 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); } diff --git a/m5stack/flocksquawk_m5stick/src/DeviceSignatures.h b/m5stack/flocksquawk_m5stick/src/DeviceSignatures.h deleted file mode 100644 index 78a105f..0000000 --- a/m5stack/flocksquawk_m5stick/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 = sizeof(NetworkNames) / sizeof(NetworkNames[0]); - - // 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 = 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 deleted file mode 100644 index 6fa14fa..0000000 --- a/m5stack/flocksquawk_m5stick/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/m5stack/flocksquawk_m5stick/src/RadioScanner.h b/m5stack/flocksquawk_m5stick/src/RadioScanner.h index 0d72b08..85c2019 100644 --- a/m5stack/flocksquawk_m5stick/src/RadioScanner.h +++ b/m5stack/flocksquawk_m5stick/src/RadioScanner.h @@ -13,27 +13,80 @@ 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 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 isBleScanning() { return isScanningBLE; } + + // Switch between battery-optimized and high-performance scanning + static void setPerformanceMode(bool highPerformance) { + _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 uint8_t currentWifiChannel; + 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 { + // Battery mode + BLE client — conservative (original defaults) + CHANNEL_SWITCH_MS = 300; + BLE_SCAN_SECONDS = 2; + BLE_SCAN_INTERVAL_MS = 5000; + } + } + + 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; diff --git a/m5stack/flocksquawk_m5stick/src/TelemetryReporter.h b/m5stack/flocksquawk_m5stick/src/TelemetryReporter.h deleted file mode 100644 index ca38c42..0000000 --- a/m5stack/flocksquawk_m5stick/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_m5stick/src/ThreatAnalyzer.h b/m5stack/flocksquawk_m5stick/src/ThreatAnalyzer.h deleted file mode 100644 index 12c95ae..0000000 --- a/m5stack/flocksquawk_m5stick/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/test/eventbus_impl.cpp b/test/eventbus_impl.cpp new file mode 100644 index 0000000..aa44824 --- /dev/null +++ b/test/eventbus_impl.cpp @@ -0,0 +1,47 @@ +// 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; +EventBus::AudioHandler EventBus::audioHandler; + +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; +} +void EventBus::publishAudioRequest(const AudioEvent& event) { + if (audioHandler) audioHandler(event); +} +void EventBus::subscribeAudioRequest(AudioHandler handler) { + audioHandler = handler; +} diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h new file mode 100644 index 0000000..883a475 --- /dev/null +++ b/test/mocks/Arduino.h @@ -0,0 +1,29 @@ +// 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 +#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_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); + } +} 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); +} 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..0630994 --- /dev/null +++ b/test/test_device_tracker.cpp @@ -0,0 +1,147 @@ +#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, ALERT_CONFIRMED); + 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, ALERT_CONFIRMED); + DeviceState prev = tracker.recordDetection(mac, 2000, ALERT_CONFIRMED); + 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, ALERT_CONFIRMED); + tracker.recordDetection(mac, 2000, ALERT_CONFIRMED); + DeviceState prev = tracker.recordDetection(mac, 3000, ALERT_CONFIRMED); + 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, 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()); +} + +TEST_CASE("DeviceTracker: hasHighConfidenceInRange") { + DeviceTracker tracker; + tracker.initialize(); + uint8_t mac[6]; + setMAC(mac, 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33); + + 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("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") { + tracker.recordDetection(mac, 1000, ALERT_CONFIRMED); + CHECK_FALSE(tracker.hasHighConfidenceInRange()); + } +} + +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, ALERT_NONE); // NEW_DETECT + tracker.recordDetection(mac, 2000, ALERT_SUSPICIOUS); // IN_RANGE, level bumped + 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, 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, ALERT_CONFIRMED); + 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, 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, ALERT_CONFIRMED); + 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, 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, ALERT_CONFIRMED); + 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..8fb4cb5 --- /dev/null +++ b/test/test_threat_analyzer.cpp @@ -0,0 +1,464 @@ +#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 basic detection +// ============================================================ + +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 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; + + analyzer.analyzeWiFiFrame(makeWiFiFrame("Flock-a1b2c3", -60)); + REQUIRE(threatCount == 1); + CHECK(lastThreat.alertLevel == ALERT_CONFIRMED); + CHECK(lastThreat.shouldAlert == true); + CHECK((lastThreat.matchFlags & DET_SSID_FORMAT) != 0); +} + +TEST_CASE("ThreatAnalyzer: SSID format without OUI is still CONFIRMED") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + // 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.alertLevel == ALERT_CONFIRMED); +} + +TEST_CASE("ThreatAnalyzer: SSID keyword + Lite-On OUI is CONFIRMED") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto frame = makeWiFiFrame("test_flck", -60); + frame.mac[0] = 0x58; frame.mac[1] = 0x8E; frame.mac[2] = 0x81; + frame.mac[3] = 0xD1; frame.mac[4] = 0xD2; frame.mac[5] = 0xD3; + analyzer.analyzeWiFiFrame(frame); + REQUIRE(threatCount == 1); + 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: Lite-On OUI + visible non-matching SSID is NONE") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + auto frame = makeWiFiFrame("SomeNetwork", -60); + frame.mac[0] = 0x58; frame.mac[1] = 0x8E; frame.mac[2] = 0x81; + frame.mac[3] = 0xC4; frame.mac[4] = 0xC4; frame.mac[5] = 0xC4; + analyzer.analyzeWiFiFrame(frame); + REQUIRE(threatCount == 1); + 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 / firstDetection logic +// ============================================================ + +TEST_CASE("ThreatAnalyzer: shouldAlert true on first CONFIRMED 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.firstDetection == true); + CHECK(lastThreat.alertLevel == ALERT_CONFIRMED); +} + +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); + CHECK(lastThreat.firstDetection == false); +} + +TEST_CASE("ThreatAnalyzer: shouldAlert false for SUSPICIOUS level") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 5000; + + // 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 alert levels +// ============================================================ + +TEST_CASE("ThreatAnalyzer: BLE Raven custom UUID is CONFIRMED") { + 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(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 match is CONFIRMED") { + 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(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(); + 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 confirmed device in range") { + ThreatAnalyzer analyzer; + analyzer.initialize(); + resetCapture(); + mock_millis_value = 1000; + + // Inject a CONFIRMED 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)); +} + +// ============================================================ +// 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); +} + +// ============================================================ +// 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); +} 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