mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
211 lines
8.5 KiB
Python
211 lines
8.5 KiB
Python
"""RF Fingerprinting engine using Welford online algorithm for statistics."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import threading
|
|
import math
|
|
from typing import Optional
|
|
|
|
|
|
class RFFingerprinter:
|
|
BAND_RESOLUTION_MHZ = 0.1 # 100 kHz buckets
|
|
|
|
def __init__(self, db_path: str):
|
|
self._lock = threading.Lock()
|
|
self.db = sqlite3.connect(db_path, check_same_thread=False)
|
|
self.db.row_factory = sqlite3.Row
|
|
self._init_schema()
|
|
|
|
def _init_schema(self):
|
|
with self._lock:
|
|
self.db.executescript("""
|
|
CREATE TABLE IF NOT EXISTS rf_fingerprints (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
location TEXT,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
finalized_at TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS rf_observations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
fp_id INTEGER NOT NULL REFERENCES rf_fingerprints(id) ON DELETE CASCADE,
|
|
band_center_mhz REAL NOT NULL,
|
|
power_dbm REAL NOT NULL,
|
|
recorded_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
CREATE TABLE IF NOT EXISTS rf_baselines (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
fp_id INTEGER NOT NULL REFERENCES rf_fingerprints(id) ON DELETE CASCADE,
|
|
band_center_mhz REAL NOT NULL,
|
|
mean_dbm REAL NOT NULL,
|
|
std_dbm REAL NOT NULL,
|
|
sample_count INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_obs_fp_id ON rf_observations(fp_id);
|
|
CREATE INDEX IF NOT EXISTS idx_baseline_fp_id ON rf_baselines(fp_id);
|
|
""")
|
|
self.db.commit()
|
|
|
|
def _snap_to_band(self, freq_mhz: float) -> float:
|
|
"""Snap frequency to nearest band center (100 kHz resolution)."""
|
|
return round(round(freq_mhz / self.BAND_RESOLUTION_MHZ) * self.BAND_RESOLUTION_MHZ, 3)
|
|
|
|
def start_session(self, name: str, location: Optional[str] = None) -> int:
|
|
with self._lock:
|
|
cur = self.db.execute(
|
|
"INSERT INTO rf_fingerprints (name, location) VALUES (?, ?)",
|
|
(name, location),
|
|
)
|
|
self.db.commit()
|
|
return cur.lastrowid
|
|
|
|
def add_observation(self, session_id: int, freq_mhz: float, power_dbm: float):
|
|
band = self._snap_to_band(freq_mhz)
|
|
with self._lock:
|
|
self.db.execute(
|
|
"INSERT INTO rf_observations (fp_id, band_center_mhz, power_dbm) VALUES (?, ?, ?)",
|
|
(session_id, band, power_dbm),
|
|
)
|
|
self.db.commit()
|
|
|
|
def add_observations_batch(self, session_id: int, observations: list[dict]):
|
|
rows = [
|
|
(session_id, self._snap_to_band(o["freq_mhz"]), o["power_dbm"])
|
|
for o in observations
|
|
]
|
|
with self._lock:
|
|
self.db.executemany(
|
|
"INSERT INTO rf_observations (fp_id, band_center_mhz, power_dbm) VALUES (?, ?, ?)",
|
|
rows,
|
|
)
|
|
self.db.commit()
|
|
|
|
def finalize(self, session_id: int) -> dict:
|
|
"""Compute statistics per band and store baselines."""
|
|
with self._lock:
|
|
rows = self.db.execute(
|
|
"SELECT band_center_mhz, power_dbm FROM rf_observations WHERE fp_id = ? ORDER BY band_center_mhz",
|
|
(session_id,),
|
|
).fetchall()
|
|
|
|
# Group by band
|
|
bands: dict[float, list[float]] = {}
|
|
for row in rows:
|
|
b = row["band_center_mhz"]
|
|
bands.setdefault(b, []).append(row["power_dbm"])
|
|
|
|
baselines = []
|
|
for band_mhz, powers in bands.items():
|
|
n = len(powers)
|
|
mean = sum(powers) / n
|
|
if n > 1:
|
|
variance = sum((p - mean) ** 2 for p in powers) / (n - 1)
|
|
std = math.sqrt(variance)
|
|
else:
|
|
std = 0.0
|
|
baselines.append((session_id, band_mhz, mean, std, n))
|
|
|
|
with self._lock:
|
|
self.db.executemany(
|
|
"INSERT INTO rf_baselines (fp_id, band_center_mhz, mean_dbm, std_dbm, sample_count) VALUES (?, ?, ?, ?, ?)",
|
|
baselines,
|
|
)
|
|
self.db.execute(
|
|
"UPDATE rf_fingerprints SET finalized_at = datetime('now') WHERE id = ?",
|
|
(session_id,),
|
|
)
|
|
self.db.commit()
|
|
|
|
return {"session_id": session_id, "bands_recorded": len(baselines)}
|
|
|
|
def compare(self, baseline_id: int, observations: list[dict]) -> list[dict]:
|
|
"""Compare observations against a stored baseline. Returns anomaly list."""
|
|
with self._lock:
|
|
baseline_rows = self.db.execute(
|
|
"SELECT band_center_mhz, mean_dbm, std_dbm, sample_count FROM rf_baselines WHERE fp_id = ?",
|
|
(baseline_id,),
|
|
).fetchall()
|
|
|
|
baseline_map: dict[float, dict] = {
|
|
row["band_center_mhz"]: dict(row) for row in baseline_rows
|
|
}
|
|
|
|
# Build current band map (average power per band)
|
|
current_bands: dict[float, list[float]] = {}
|
|
for obs in observations:
|
|
b = self._snap_to_band(obs["freq_mhz"])
|
|
current_bands.setdefault(b, []).append(obs["power_dbm"])
|
|
current_map = {b: sum(ps) / len(ps) for b, ps in current_bands.items()}
|
|
|
|
anomalies = []
|
|
|
|
# Check each baseline band
|
|
for band_mhz, bl in baseline_map.items():
|
|
if band_mhz in current_map:
|
|
current_power = current_map[band_mhz]
|
|
delta = current_power - bl["mean_dbm"]
|
|
std = bl["std_dbm"] if bl["std_dbm"] > 0 else 1.0
|
|
z_score = delta / std
|
|
if abs(z_score) >= 2.0:
|
|
anomalies.append({
|
|
"band_center_mhz": band_mhz,
|
|
"band_label": f"{band_mhz:.1f} MHz",
|
|
"baseline_mean": bl["mean_dbm"],
|
|
"baseline_std": bl["std_dbm"],
|
|
"current_power": current_power,
|
|
"z_score": z_score,
|
|
"anomaly_type": "power",
|
|
})
|
|
else:
|
|
anomalies.append({
|
|
"band_center_mhz": band_mhz,
|
|
"band_label": f"{band_mhz:.1f} MHz",
|
|
"baseline_mean": bl["mean_dbm"],
|
|
"baseline_std": bl["std_dbm"],
|
|
"current_power": None,
|
|
"z_score": None,
|
|
"anomaly_type": "missing",
|
|
})
|
|
|
|
# Check for new bands not in baseline
|
|
for band_mhz, current_power in current_map.items():
|
|
if band_mhz not in baseline_map:
|
|
anomalies.append({
|
|
"band_center_mhz": band_mhz,
|
|
"band_label": f"{band_mhz:.1f} MHz",
|
|
"baseline_mean": None,
|
|
"baseline_std": None,
|
|
"current_power": current_power,
|
|
"z_score": None,
|
|
"anomaly_type": "new",
|
|
})
|
|
|
|
anomalies.sort(
|
|
key=lambda a: abs(a["z_score"]) if a["z_score"] is not None else 0,
|
|
reverse=True,
|
|
)
|
|
return anomalies
|
|
|
|
def list_sessions(self) -> list[dict]:
|
|
with self._lock:
|
|
rows = self.db.execute(
|
|
"""SELECT id, name, location, created_at, finalized_at,
|
|
(SELECT COUNT(*) FROM rf_baselines WHERE fp_id = rf_fingerprints.id) AS band_count
|
|
FROM rf_fingerprints ORDER BY created_at DESC"""
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def delete_session(self, session_id: int):
|
|
with self._lock:
|
|
self.db.execute("DELETE FROM rf_fingerprints WHERE id = ?", (session_id,))
|
|
self.db.commit()
|
|
|
|
def get_baseline_bands(self, baseline_id: int) -> list[dict]:
|
|
with self._lock:
|
|
rows = self.db.execute(
|
|
"SELECT band_center_mhz, mean_dbm, std_dbm, sample_count FROM rf_baselines WHERE fp_id = ? ORDER BY band_center_mhz",
|
|
(baseline_id,),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|