Add OVERWATCH v0.1.0 — full detection engine + polish
Phase 1 (BLE), Phase 2 (WiFi BSSID/SSID), Phase 3 (DeFlock map proximity), Phase 4 (Waze live POLICE alerts), and Phase 5 polish all wired through one DetectionStore. Confidence engine scores 0-100; UI maps to 4-tier circle. Polish: - Stylized two-line app title: [DЯΣΛMMΛKΣЯ] // 0VΣЯW4TCH - Modal bottom sheet for source drill-down (tap circle) - Settings screen: per-source toggles, proximity sliders, theme select - SharedPreferences-backed Settings with StateFlow exposure - DetectionService respects per-source toggles at start time - Scanners read proximity overrides via supplier lambdas README documents all sources, architecture, build steps, permissions, and the legal disclaimer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
package org.soulstone.overwatch.fusion
|
||||
|
||||
/**
|
||||
* Confidence scoring — port of flock-detection's algorithm with weights from the OVERWATCH plan.
|
||||
*
|
||||
* One [BleObservation] (a single ScanResult) → one score. Multi-method bonus and RSSI bonuses
|
||||
* apply within a single observation. Cross-source corroboration is handled at the [DetectionStore]
|
||||
* level (multiple sources hitting the same area push the global max upward).
|
||||
*/
|
||||
object ConfidenceEngine {
|
||||
|
||||
// Single-method base weights (BLE)
|
||||
const val W_BLE_OUI = 40
|
||||
const val W_BLE_OUI_AXON = 80
|
||||
const val W_BLE_NAME = 45
|
||||
const val W_BLE_NAME_PENGUIN_NUMERIC = 15
|
||||
const val W_BLE_MFG_XUNTONG = 60
|
||||
const val W_BLE_TN_SERIAL_BONUS = 20 // added on top of mfg
|
||||
const val W_BLE_RAVEN_UUID = 70
|
||||
const val W_BLE_RAVEN_UUID_MULTI = 90 // 3+ UUIDs
|
||||
|
||||
// Single-method base weights (WiFi — wired in Phase 2)
|
||||
const val W_WIFI_OUI = 40
|
||||
const val W_WIFI_SSID_GENERIC = 50
|
||||
const val W_WIFI_SSID_FLOCK_FMT = 65
|
||||
|
||||
// Map / Waze (Phase 3 + 4)
|
||||
const val W_DEFLOCK_NEAR = 60 // <= 200m
|
||||
const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m
|
||||
const val W_WAZE_POLICE = 55
|
||||
|
||||
// Bonuses
|
||||
const val B_MULTI_METHOD = 20
|
||||
const val B_STRONG_RSSI = 10 // > -50 dBm
|
||||
const val B_STATIONARY = 15 // RSSI rise-peak-fall
|
||||
|
||||
/** What we observed about one BLE device on a single scan callback. */
|
||||
data class BleObservation(
|
||||
val mac: String,
|
||||
val rssi: Int,
|
||||
val deviceName: String?,
|
||||
val advertisedUuids: List<java.util.UUID>?,
|
||||
val manufacturerCompanyId: Int?,
|
||||
val manufacturerPayload: ByteArray?,
|
||||
val isStationary: Boolean = false
|
||||
)
|
||||
|
||||
/** What we observed about one WiFi AP on a single scan result. */
|
||||
data class WifiObservation(
|
||||
val bssid: String,
|
||||
val ssid: String?,
|
||||
val rssi: Int,
|
||||
val isStationary: Boolean = false
|
||||
)
|
||||
|
||||
/** A DeFlock map ALPR observed within proximity threshold. */
|
||||
data class DeflockObservation(
|
||||
val osmId: Long,
|
||||
val distanceMeters: Float,
|
||||
val operator: String?,
|
||||
val manufacturer: String?
|
||||
)
|
||||
|
||||
/** A Waze POLICE alert observed within proximity + freshness thresholds. */
|
||||
data class WazeObservation(
|
||||
val uuid: String,
|
||||
val distanceMeters: Float,
|
||||
val ageMs: Long,
|
||||
val confidence: Int, // raw 0-5
|
||||
val reliability: Int, // raw 0-10
|
||||
val subtype: String?
|
||||
)
|
||||
|
||||
data class Scored(
|
||||
val score: Int,
|
||||
val methods: String,
|
||||
val label: String,
|
||||
/** True if the BLE OUI specifically matched Axon (drives the "Axon body cam" labeling). */
|
||||
val isAxon: Boolean
|
||||
)
|
||||
|
||||
fun scoreBle(obs: BleObservation): Scored {
|
||||
var score = 0
|
||||
val methods = StringBuilder()
|
||||
var methodCount = 0
|
||||
var ouiHit = false
|
||||
var nameHit = false
|
||||
var mfgHit = false
|
||||
var ravenHit = false
|
||||
var isAxon = false
|
||||
|
||||
// OUI prefix
|
||||
if (org.soulstone.overwatch.data.targets.BleOuis.isAxon(obs.mac)) {
|
||||
score += W_BLE_OUI_AXON
|
||||
methods.append("axon_oui ")
|
||||
ouiHit = true; isAxon = true
|
||||
} else if (org.soulstone.overwatch.data.targets.BleOuis.matches(obs.mac)) {
|
||||
score += W_BLE_OUI
|
||||
methods.append("oui ")
|
||||
ouiHit = true
|
||||
}
|
||||
if (ouiHit) methodCount++
|
||||
|
||||
// Device name patterns
|
||||
if (org.soulstone.overwatch.data.targets.Patterns.bleNameMatch(obs.deviceName)) {
|
||||
score += W_BLE_NAME
|
||||
methods.append("name ")
|
||||
nameHit = true
|
||||
} else if (org.soulstone.overwatch.data.targets.Patterns.isPenguinNumeric(obs.deviceName)) {
|
||||
score += W_BLE_NAME_PENGUIN_NUMERIC
|
||||
methods.append("penguin_num ")
|
||||
nameHit = true
|
||||
}
|
||||
if (nameHit) methodCount++
|
||||
|
||||
// Manufacturer-data signature
|
||||
if (obs.manufacturerCompanyId == org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID) {
|
||||
score += W_BLE_MFG_XUNTONG
|
||||
methods.append("mfg_0x09C8 ")
|
||||
mfgHit = true
|
||||
if (org.soulstone.overwatch.data.targets.Manufacturers.hasTnSerial(obs.manufacturerPayload)) {
|
||||
score += W_BLE_TN_SERIAL_BONUS
|
||||
methods.append("tn_serial ")
|
||||
}
|
||||
}
|
||||
if (mfgHit) methodCount++
|
||||
|
||||
// Raven service UUIDs
|
||||
val ravenCount = org.soulstone.overwatch.data.targets.RavenUuids.countMatches(obs.advertisedUuids)
|
||||
if (ravenCount > 0) {
|
||||
if (ravenCount >= 3) {
|
||||
score += W_BLE_RAVEN_UUID_MULTI
|
||||
methods.append("raven_multi ")
|
||||
} else {
|
||||
score += W_BLE_RAVEN_UUID
|
||||
methods.append("raven_uuid ")
|
||||
}
|
||||
ravenHit = true
|
||||
methodCount++
|
||||
}
|
||||
|
||||
// Multi-method corroboration bonus
|
||||
if (methodCount >= 2) {
|
||||
score += B_MULTI_METHOD
|
||||
methods.append("multi ")
|
||||
}
|
||||
|
||||
// Strong RSSI (very close)
|
||||
if (obs.rssi > -50) {
|
||||
score += B_STRONG_RSSI
|
||||
methods.append("strong_rssi ")
|
||||
}
|
||||
|
||||
// Stationary RSSI trend
|
||||
if (obs.isStationary) {
|
||||
score += B_STATIONARY
|
||||
methods.append("stationary ")
|
||||
}
|
||||
|
||||
score = score.coerceAtMost(100)
|
||||
|
||||
val label = when {
|
||||
isAxon -> "Axon body cam (${obs.mac})"
|
||||
ravenHit -> "Raven gunshot detector (${obs.mac})"
|
||||
!obs.deviceName.isNullOrBlank() -> "${obs.deviceName} (${obs.mac})"
|
||||
else -> "Surveillance BLE (${obs.mac})"
|
||||
}
|
||||
|
||||
return Scored(score, methods.toString().trim(), label, isAxon)
|
||||
}
|
||||
|
||||
fun scoreWaze(obs: WazeObservation): Scored {
|
||||
// Plan baseline: 55 for any POLICE alert ≤500m & <10min old.
|
||||
// Caller is responsible for applying the proximity + age gate before scoring.
|
||||
var score = W_WAZE_POLICE
|
||||
// Lightweight crowd-trust nudge: high reliability & high confidence each add a few points,
|
||||
// capped well under the multi-method bonus so a corroborating BLE/WiFi hit still dominates.
|
||||
if (obs.reliability >= 7) score += 5
|
||||
if (obs.confidence >= 4) score += 5
|
||||
score = score.coerceAtMost(100)
|
||||
val methods = "waze_police rel=${obs.reliability} conf=${obs.confidence}"
|
||||
val ageMin = (obs.ageMs / 60_000L).toInt()
|
||||
val sub = obs.subtype?.let { " ($it)" } ?: ""
|
||||
val label = "Police report$sub @ ${obs.distanceMeters.toInt()}m, ${ageMin}min ago"
|
||||
return Scored(score, methods, label, isAxon = false)
|
||||
}
|
||||
|
||||
fun scoreDeflock(obs: DeflockObservation): Scored {
|
||||
val score = if (obs.distanceMeters <= 50f) W_DEFLOCK_VERY_NEAR else W_DEFLOCK_NEAR
|
||||
val rangeTag = if (obs.distanceMeters <= 50f) "deflock<=50m" else "deflock<=200m"
|
||||
val descriptor = listOfNotNull(obs.manufacturer, obs.operator)
|
||||
.joinToString(" / ").ifBlank { "ALPR" }
|
||||
val label = "%s @ %dm (osm:%d)".format(descriptor, obs.distanceMeters.toInt(), obs.osmId)
|
||||
return Scored(score, rangeTag, label, isAxon = false)
|
||||
}
|
||||
|
||||
fun scoreWifi(obs: WifiObservation): Scored {
|
||||
var score = 0
|
||||
val methods = StringBuilder()
|
||||
var methodCount = 0
|
||||
|
||||
val ouiHit = org.soulstone.overwatch.data.targets.WifiOuis.matches(obs.bssid)
|
||||
if (ouiHit) {
|
||||
score += W_WIFI_OUI
|
||||
methods.append("oui ")
|
||||
methodCount++
|
||||
}
|
||||
|
||||
var ssidHit = false
|
||||
if (org.soulstone.overwatch.data.targets.Patterns.ssidFlockFormat(obs.ssid)) {
|
||||
score += W_WIFI_SSID_FLOCK_FMT
|
||||
methods.append("ssid_flock ")
|
||||
ssidHit = true
|
||||
} else if (org.soulstone.overwatch.data.targets.Patterns.ssidGenericMatch(obs.ssid)) {
|
||||
score += W_WIFI_SSID_GENERIC
|
||||
methods.append("ssid_generic ")
|
||||
ssidHit = true
|
||||
}
|
||||
if (ssidHit) methodCount++
|
||||
|
||||
if (methodCount >= 2) {
|
||||
score += B_MULTI_METHOD
|
||||
methods.append("multi ")
|
||||
}
|
||||
if (obs.rssi > -50) {
|
||||
score += B_STRONG_RSSI
|
||||
methods.append("strong_rssi ")
|
||||
}
|
||||
if (obs.isStationary) {
|
||||
score += B_STATIONARY
|
||||
methods.append("stationary ")
|
||||
}
|
||||
|
||||
score = score.coerceAtMost(100)
|
||||
|
||||
val label = if (!obs.ssid.isNullOrBlank()) "${obs.ssid} (${obs.bssid})"
|
||||
else "Surveillance WiFi (${obs.bssid})"
|
||||
|
||||
return Scored(score, methods.toString().trim(), label, isAxon = false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.soulstone.overwatch.fusion
|
||||
|
||||
/**
|
||||
* One observation from one source at one moment.
|
||||
*
|
||||
* @param source which scanner produced this
|
||||
* @param key stable per-device identifier (MAC for BLE/WiFi, OSM id for DeFlock, uuid for Waze)
|
||||
* @param label short human-readable description shown in the drill-down ("Axon body cam", "FS-1A2B")
|
||||
* @param score 0-100 confidence assigned by the engine
|
||||
* @param matchedMethods space-separated short tags for what triggered ("axon_oui mfg_0x09C8 tn_serial")
|
||||
* @param rssi signal strength if applicable (BLE/WiFi); null for map/Waze sources
|
||||
* @param timestampMs wall-clock millis when this event was produced
|
||||
*/
|
||||
data class DetectionEvent(
|
||||
val source: DetectionSource,
|
||||
val key: String,
|
||||
val label: String,
|
||||
val score: Int,
|
||||
val matchedMethods: String,
|
||||
val rssi: Int? = null,
|
||||
val timestampMs: Long = System.currentTimeMillis()
|
||||
) {
|
||||
val level: ThreatLevel get() = ThreatLevel.fromScore(score)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.soulstone.overwatch.fusion
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* In-memory hub for detection events.
|
||||
*
|
||||
* - Keeps the most recent event per (source, key) — newer overwrites older.
|
||||
* - Drops events older than [retentionMs] (default 5 min, mirrors flock-detection's dedup window).
|
||||
* - Exposes [threatLevel] = the worst tier across all live events.
|
||||
* - No persistence: per the user's spec, no detection-history DB.
|
||||
*/
|
||||
class DetectionStore(
|
||||
private val retentionMs: Long = 5 * 60 * 1000L,
|
||||
private val nowMs: () -> Long = System::currentTimeMillis
|
||||
) {
|
||||
private val _events = MutableStateFlow<List<DetectionEvent>>(emptyList())
|
||||
val events: StateFlow<List<DetectionEvent>> = _events.asStateFlow()
|
||||
|
||||
private val _threatLevel = MutableStateFlow(ThreatLevel.GREEN)
|
||||
val threatLevel: StateFlow<ThreatLevel> = _threatLevel.asStateFlow()
|
||||
|
||||
private val _maxScore = MutableStateFlow(0)
|
||||
val maxScore: StateFlow<Int> = _maxScore.asStateFlow()
|
||||
|
||||
@Synchronized
|
||||
fun submit(event: DetectionEvent) {
|
||||
val cutoff = nowMs() - retentionMs
|
||||
val merged = (_events.value + event)
|
||||
.filter { it.timestampMs >= cutoff }
|
||||
.groupBy { it.source to it.key }
|
||||
.map { (_, list) -> list.maxByOrNull { it.timestampMs }!! }
|
||||
.sortedByDescending { it.score }
|
||||
_events.value = merged
|
||||
recompute(merged)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
_events.value = emptyList()
|
||||
_threatLevel.value = ThreatLevel.GREEN
|
||||
_maxScore.value = 0
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun pruneExpired() {
|
||||
val cutoff = nowMs() - retentionMs
|
||||
val live = _events.value.filter { it.timestampMs >= cutoff }
|
||||
if (live.size != _events.value.size) {
|
||||
_events.value = live
|
||||
recompute(live)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recompute(live: List<DetectionEvent>) {
|
||||
val max = live.maxOfOrNull { it.score } ?: 0
|
||||
_maxScore.value = max
|
||||
_threatLevel.value = ThreatLevel.fromScore(max)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.soulstone.overwatch.fusion
|
||||
|
||||
/**
|
||||
* Tracks RSSI samples per device to detect a stationary signature.
|
||||
*
|
||||
* Ported from flock-detection (rssi_track_update / rssi_track_is_stationary):
|
||||
* a fixed-installation camera produces a rise → peak → fall pattern as the
|
||||
* observer walks past it. A handheld emitter (phone, etc.) does not.
|
||||
*
|
||||
* Algorithm:
|
||||
* - Keep up to [windowSize] recent samples per key.
|
||||
* - Find peak index. Stationary if peak is NOT at the edge AND
|
||||
* range (peak - min(first, last)) >= [minRangeDb].
|
||||
*/
|
||||
class RssiTracker(
|
||||
private val windowSize: Int = 15,
|
||||
private val minRangeDb: Int = 6
|
||||
) {
|
||||
private val samples: MutableMap<String, ArrayDeque<Int>> = mutableMapOf()
|
||||
|
||||
@Synchronized
|
||||
fun update(key: String, rssi: Int) {
|
||||
val deque = samples.getOrPut(key) { ArrayDeque() }
|
||||
deque.addLast(rssi)
|
||||
while (deque.size > windowSize) deque.removeFirst()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun isStationary(key: String): Boolean {
|
||||
val s = samples[key] ?: return false
|
||||
if (s.size < 3) return false
|
||||
val list = s.toList()
|
||||
val peakIdx = list.indices.maxByOrNull { list[it] } ?: return false
|
||||
if (peakIdx == 0 || peakIdx == list.lastIndex) return false
|
||||
val edgeMin = minOf(list.first(), list.last())
|
||||
return (list[peakIdx] - edgeMin) >= minRangeDb
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() = samples.clear()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.soulstone.overwatch.fusion
|
||||
|
||||
/**
|
||||
* 4-tier threat classification. Maps directly to the green/yellow/orange/red UI circle.
|
||||
* Thresholds ported from flock-detection's CONFIDENCE_* constants.
|
||||
*/
|
||||
enum class ThreatLevel(val minScore: Int) {
|
||||
GREEN(0), // < 40 — nothing credible
|
||||
YELLOW(40), // 40-69 — single weak indicator
|
||||
ORANGE(70), // 70-84 — high confidence
|
||||
RED(85); // 85+ — certain
|
||||
|
||||
companion object {
|
||||
fun fromScore(score: Int): ThreatLevel = when {
|
||||
score >= RED.minScore -> RED
|
||||
score >= ORANGE.minScore -> ORANGE
|
||||
score >= YELLOW.minScore -> YELLOW
|
||||
else -> GREEN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Logical signal channel — used in the drill-down UI. */
|
||||
enum class DetectionSource { BLE, WIFI, DEFLOCK, WAZE }
|
||||
Reference in New Issue
Block a user