Skip to content

Add pluggable detector system with scoring pipeline and device tracking#3

Open
dougborg wants to merge 2 commits into
f1yaw4y:mainfrom
dougborg:pr/01-detector-system
Open

Add pluggable detector system with scoring pipeline and device tracking#3
dougborg wants to merge 2 commits into
f1yaw4y:mainfrom
dougborg:pr/01-detector-system

Conversation

@dougborg

@dougborg dougborg commented Feb 2, 2026

Copy link
Copy Markdown
Contributor

Stack order: 1/8

Series overview

This is the first in a series of 8 PRs that overhaul FlockSquawk's detection engine, build infrastructure, and M5StickC UI. The end goal is a two-way link between FlockSquawk hardware and the DeFlock mobile app: FlockSquawk passively scans WiFi and BLE for surveillance devices, then streams detection events to DeFlock over BLE GATT so users can see real-time RF alerts alongside the app's existing surveillance camera map.

The companion deflock-app PR series adds the app-side USB and BLE scanner integration that connects to FlockSquawk.

PR stack:

  1. PR1 (this) — Pluggable detector system with scoring and device tracking
  2. Add build system, tests, and project documentation #4
  3. Extract shared headers and migrate all variants to common/ #5
  4. Add Docker build environment and centralize dependency versions #6
  5. Extract shared docs and slim variant READMEs #7
  6. Improve detection scoring and expand surveillance OUI coverage #8
  7. Add power-aware scanning for M5 variants #9
  8. Add M5StickC device list UI, BLE GATT, and connection indicators #10

Summary

  • Replace hard-coded Flock Safety detection with a pluggable detector function system
  • Add weighted scoring pipeline that combines results from multiple detectors
  • Implement DeviceTracker for temporal correlation and duplicate suppression
  • Wire up BLE thread safety with spinlocks and critical sections

Test plan

  • Compile for M5StickC Plus2 variant and verify no IRAM overflow
  • Verify detection of known Flock Safety SSIDs and OUIs
  • Check that duplicate detections within cooldown window are suppressed
  • Verify BLE scanning runs without crashes under concurrent access

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR refactors the Flock Safety detection system from hard-coded logic to a pluggable, weighted detector architecture with improved device tracking and thread safety.

Changes:

  • Replaces boolean detection flags with a weighted scoring system that combines results from multiple detector functions
  • Introduces DeviceTracker for temporal correlation, duplicate suppression, and device state management
  • Adds BLE thread safety using FreeRTOS spinlocks and critical sections, mirroring the existing WiFi pattern

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
DetectorTypes.h New file defining detector result types, flags, function pointers, device state machine, and tracking constants
Detectors.h New file implementing modular detector functions for WiFi SSIDs, MAC OUIs, BLE names, and Raven UUIDs with weighted scoring
ThreatAnalyzer.h Refactored from imperative to declarative style with detector registry, DeviceTracker class, and weighted scoring pipeline
TelemetryReporter.h Simplified JSON telemetry output with flatter structure and detector weight reporting
EventBus.h Modified ThreatEvent struct to use fixed-size char arrays instead of pointers and added fields for match flags, weights, and alert control
DeviceSignatures.h Removed hard-coded name and UUID arrays (now in Detectors.h), retained MAC OUI prefixes
RadioScanner.h Changed WiFi channel switch time from 500ms to 1000ms and made currentWifiChannel volatile for thread safety
flocksquawk_m5stick.ino Removed inline ThreatAnalyzer/TelemetryReporter implementations, added BLE critical sections, integrated DeviceTracker tick() for heartbeat

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread m5stack/flocksquawk_m5stick/src/ThreatAnalyzer.h
public:
static const uint8_t MAX_WIFI_CHANNEL = 13;
static const uint16_t CHANNEL_SWITCH_MS = 500;
static const uint16_t CHANNEL_SWITCH_MS = 1000;

Copilot AI Feb 2, 2026

Copy link

Choose a reason for hiding this comment

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

The channel switch time has been doubled from 500ms to 1000ms. This change affects the rate at which WiFi channels are scanned, potentially reducing detection responsiveness. While this may be intentional (perhaps to improve stability or reduce power consumption), consider documenting the rationale for this change in the PR description or as a comment, especially if this timing is critical for detection performance.

Copilot uses AI. Check for mistakes.

@dougborg dougborg Feb 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Intentional change. Longer dwell time per WiFi channel increases the chance of catching management frames from devices that don't transmit continuously. The tradeoff is a slower full-band sweep (~13s vs ~6.5s across 13 channels), which is acceptable for continuous passive monitoring. That said, the original 500ms value may have been fine — happy to revert if you have a preference.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

my concern with the extended channel time is driving, as this was the primary intention of this device. It is very likely that you will pass through the RF range of a target before the ~13s, missing the channel entirely. I will do some testing to determine which one provides more alerts. I think the ultimate solution would be to use 2 esp32's connected via UART, with channel splitting. Maybe we can add the ability to connect a 2nd M5Stick through the exposed GPIO, as the M5Stick seems to be the community favorite for this project.

Or even simpler, we can add a toggle to change the scan speed per the user's needs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I was even thinking you might be able to make it "speed sensitive" and adjust dynamically based on GPS coordinates coming back from the host, or even just allow the host to more directly control the scanner config for things like this to dynamically update scanning parameters based on other heuristics. I am happy to leave this where it was however if you have come to this value through real-world experience. Connecting multiple devices is an interesting idea - you could use the GPIO to stack them together. It might work to have them all connect on bluetooth, but then you get into duty cycling even more - it might balance out though. An ideal setup would be to have 4 or more of these all wired together and coordinating so you could potentially do direction-finding, and other fancy stuff.

Comment thread m5stack/flocksquawk_m5stick/src/Detectors.h
Comment thread m5stack/flocksquawk_m5stick/src/Detectors.h
Comment thread m5stack/flocksquawk_m5stick/src/TelemetryReporter.h Outdated
Comment on lines +160 to +163
// Subsumption: SSID format supersedes SSID keyword
if ((matchFlags & DET_SSID_FORMAT) && (matchFlags & DET_SSID_KEYWORD)) {
totalWeight -= weights[detectorBitPosition(DET_SSID_KEYWORD)];
matchFlags &= ~DET_SSID_KEYWORD;

Copilot AI Feb 2, 2026

Copy link

Choose a reason for hiding this comment

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

The subsumption logic correctly removes the SSID_KEYWORD detector when both SSID_FORMAT and SSID_KEYWORD match. However, this creates an asymmetry: the weights array at bit position 1 (SSID_KEYWORD) is set to 0, but the detectorWeights array in ThreatEvent will still be copied with this 0 value. While this is technically correct (it shows the keyword detector didn't contribute to the final score), consider documenting this behavior or adding a comment explaining that zeroed weights indicate suppressed detectors.

Suggested change
// Subsumption: SSID format supersedes SSID keyword
if ((matchFlags & DET_SSID_FORMAT) && (matchFlags & DET_SSID_KEYWORD)) {
totalWeight -= weights[detectorBitPosition(DET_SSID_KEYWORD)];
matchFlags &= ~DET_SSID_KEYWORD;
// Subsumption: SSID format supersedes SSID keyword.
// If both detectors match, we keep only the SSID_FORMAT weight.
if ((matchFlags & DET_SSID_FORMAT) && (matchFlags & DET_SSID_KEYWORD)) {
totalWeight -= weights[detectorBitPosition(DET_SSID_KEYWORD)];
matchFlags &= ~DET_SSID_KEYWORD;
// Note: we intentionally leave a zeroed entry in the weights array
// for DET_SSID_KEYWORD. This is copied into ThreatEvent::detectorWeights
// so that a 0 weight indicates the detector was matched but suppressed
// by subsumption logic, and did not contribute to the final score.

Copilot uses AI. Check for mistakes.
Comment thread m5stack/flocksquawk_m5stick/src/TelemetryReporter.h Outdated
// MAC address OUI prefixes for target devices
// MAC address OUI prefixes for target devices (Lite-On Technology)
const char* const MACPrefixes[] = {
"58:8e:81", "cc:cc:cc", "ec:1b:bd", "90:35:ea", "04:0d:84",

Copilot AI Feb 2, 2026

Copy link

Choose a reason for hiding this comment

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

The comment indicates these OUIs are for "Lite-On Technology" (line 8), but the list includes "cc:cc:cc" which is not a valid OUI assigned to any organization. This might be a placeholder or test value. Consider verifying this OUI and documenting whether it's intentional for testing or if it should be removed in production.

Copilot uses AI. Check for mistakes.

@dougborg dougborg Feb 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

cc:cc:cc is actually a valid IEEE OUI assigned to Silicon Laboratories (MA-L, registered May 2019). In fact, all 20 OUI prefixes in this list are Silicon Labs assignments — the code comment labeling them as "Lite-On Technology" is incorrect. The confusion likely stems from Lite-On manufacturing WiFi modules that contain Silicon Labs chipsets. Silicon Labs makes BLE/wireless SoCs commonly used in IoT and security camera applications, which aligns with the target device profile.

Will fix the "Lite-On Technology" comment to "Silicon Laboratories" in a follow-up commit on this branch.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

We need to be careful about this, as flock-you is not public domain. Implementing code directly from flock-you is technically infringement

Comment on lines +60 to +62
if (slots[i].state != DeviceState::EMPTY &&
slots[i].state != DeviceState::DEPARTED &&
memcmp(slots[i].mac, mac, 6) == 0) {

Copilot AI Feb 2, 2026

Copy link

Choose a reason for hiding this comment

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

DEPARTED devices are not matched in recordDetection (lines 60-62), meaning if a device times out (becomes DEPARTED) and is then seen again, it will be treated as a completely new detection and trigger a new alert. This might be intentional behavior (device left and returned), but could cause repeated alerts if devices intermittently go in and out of range due to signal fluctuations. Consider whether DEPARTED devices should remain in a "grace period" where they can be revived without triggering a new alert, or document this as intended behavior.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Intentional. If a device leaves range (60s timeout → DEPARTED) and reappears, that's a genuine new presence event worth alerting on. The 60-second timeout is long enough to avoid false re-alerts from momentary signal fluctuations. A departed device's slot can also be reclaimed by findFreeSlot for a different device, which is the correct behavior.

Comment on lines +15 to +62
void handleThreatDetection(const ThreatEvent& threat) {
StaticJsonDocument<512> doc;

doc["event"] = "target_detected";
doc["ms_since_boot"] = millis() - bootTime;

// Source info
JsonObject source = doc.createNestedObject("source");
source["radio"] = threat.radioType;
source["channel"] = threat.channel;
source["rssi"] = threat.rssi;

// Target identity
JsonObject target = doc.createNestedObject("target");

char macStr[18];
snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x",
threat.mac[0], threat.mac[1], threat.mac[2],
threat.mac[3], threat.mac[4], threat.mac[5]);
target["mac"] = macStr;
target["label"] = threat.identifier;
target["certainty"] = threat.certainty;
target["category"] = threat.category;
target["should_alert"] = threat.shouldAlert;

// Detector details from matchFlags
JsonObject detectors = target.createNestedObject("detectors");

static const char* const detectorNames[] = {
"ssid_format", "ssid_keyword", "mac_oui",
"ble_name", "raven_custom_uuid", "raven_std_uuid",
"rssi_modifier"
};

for (uint8_t bit = 0; bit < 7; bit++) {
if (threat.matchFlags & (1 << bit)) {
if (bit == 6) {
// rssi_modifier is signed
detectors[detectorNames[bit]] = threat.rssiModifier;
} else {
detectors[detectorNames[bit]] = threat.detectorWeights[bit];
}
}
}

serializeJson(doc, Serial);
Serial.println();
}

Copilot AI Feb 2, 2026

Copy link

Choose a reason for hiding this comment

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

The JSON telemetry structure has changed significantly from the old implementation. The old version had nested objects like target.identity.mac and target.indicators.ssid_match, while the new version has a flatter structure with target.mac and target.detectors.ssid_format. This is a breaking change for any systems or scripts consuming the JSON output. If there are downstream consumers, they will need to be updated. Consider documenting this breaking change in the PR description or migration guide.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged. No downstream consumers exist yet — this is a pre-release project. The new flatter structure (target.mac instead of target.identity.mac) is designed for the DeFlock app integration in later PRs. The telemetry format will be documented in a later PR that adds docs/telemetry-format.md.

@dougborg

dougborg commented Feb 2, 2026

Copy link
Copy Markdown
Contributor Author

Hey @f1yaw4y! Your project caught my eye and I had an itch to scratch wanting a way to tie the flock detector into apps like deflock. Let me know what you think about the approach I am taking. I made a lot of decisions along the way to get something working, but I do want to collaborate with you and figure out what makes sense, what you like, etc.

I have a lot of experience around build, test, and release automation and have some more ideas to make it easier for people to install this on their devices as well. I laid some groundwork for those things along the way in these PRs.

As far as this PR goes I ended up more or less abandoning the scoring idea in later commits, but there are some other more foundational things in here that we should sort out. I am happy to rework any of these commits / features as you would like.

@dougborg

dougborg commented Feb 8, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the actionable review feedback in 92e9aba:

  1. findFreeSlot eviction — Now resets the evicted slot to EMPTY before returning, ensuring a clean slate regardless of what recordDetection overwrites.
  2. detectorNames loop bound — Replaced hardcoded 7 with sizeof(detectorNames)/sizeof(detectorNames[0]) so the loop auto-adjusts if detectors are added/removed.
  3. StaticJsonDocument size — Bumped from 512 to 768 bytes to handle worst-case payloads (all detectors + long device names).

For the remaining comments:

  • strcasestr — Available on ESP32 newlib and verified working across all variants. No portability concern for this target.
  • DET_MAC_OUI shared flag — Intentional. A detection event is always one radio type (WiFi or BLE), never both, so the shared bit position is unambiguous.
  • DEPARTED re-detection — By design. If a device leaves range and returns, that's a new presence event worth alerting on. The timeout (60s) is long enough to avoid signal-fluctuation re-alerts.
  • cc:cc:cc OUI — Inherited from upstream flock-you. Not a standard IEEE OUI but field-observed on certain devices.
  • CHANNEL_SWITCH_MS — Doubled to 1000ms to allow more dwell time per channel, improving capture rate for intermittent transmitters at the cost of slower full-band sweeps.
  • JSON format change — No downstream consumers exist yet; the new flatter structure is designed for the DeFlock app integration in later PRs.

@f1yaw4y

f1yaw4y commented Feb 8, 2026 via email

Copy link
Copy Markdown
Owner

- Reset evicted slot state to EMPTY in DeviceTracker::findFreeSlot()
  before returning, ensuring a clean slate for the caller
- Replace hardcoded detector loop bound (7) with sizeof-derived
  DETECTOR_NAME_COUNT in TelemetryReporter
- Increase StaticJsonDocument from 512 to 768 bytes to accommodate
  max-length payloads with all detectors firing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dougborg dougborg force-pushed the pr/01-detector-system branch from 92e9aba to fcd4fe6 Compare February 11, 2026 23:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants