mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 15:20:00 -07:00
feat: ship waterfall receiver overhaul and platform mode updates
This commit is contained in:
@@ -1,230 +0,0 @@
|
||||
"""Cross-mode analytics: activity tracking, summaries, and emergency squawk detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
import app as app_module
|
||||
|
||||
|
||||
class ModeActivityTracker:
|
||||
"""Track device counts per mode in time-bucketed ring buffer for sparklines."""
|
||||
|
||||
def __init__(self, max_buckets: int = 60, bucket_interval: float = 5.0):
|
||||
self._max_buckets = max_buckets
|
||||
self._bucket_interval = bucket_interval
|
||||
self._history: dict[str, deque] = {}
|
||||
self._last_record_time = 0.0
|
||||
|
||||
def record(self) -> None:
|
||||
"""Snapshot current counts for all modes."""
|
||||
now = time.time()
|
||||
if now - self._last_record_time < self._bucket_interval:
|
||||
return
|
||||
self._last_record_time = now
|
||||
|
||||
counts = _get_mode_counts()
|
||||
for mode, count in counts.items():
|
||||
if mode not in self._history:
|
||||
self._history[mode] = deque(maxlen=self._max_buckets)
|
||||
self._history[mode].append(count)
|
||||
|
||||
def get_sparkline(self, mode: str) -> list[int]:
|
||||
"""Return sparkline array for a mode."""
|
||||
self.record()
|
||||
return list(self._history.get(mode, []))
|
||||
|
||||
def get_all_sparklines(self) -> dict[str, list[int]]:
|
||||
"""Return sparkline arrays for all tracked modes."""
|
||||
self.record()
|
||||
return {mode: list(values) for mode, values in self._history.items()}
|
||||
|
||||
|
||||
# Singleton
|
||||
_tracker: ModeActivityTracker | None = None
|
||||
|
||||
|
||||
def get_activity_tracker() -> ModeActivityTracker:
|
||||
global _tracker
|
||||
if _tracker is None:
|
||||
_tracker = ModeActivityTracker()
|
||||
return _tracker
|
||||
|
||||
|
||||
def _safe_len(attr_name: str) -> int:
|
||||
"""Safely get len() of an app_module attribute."""
|
||||
try:
|
||||
return len(getattr(app_module, attr_name))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _safe_route_attr(module_path: str, attr_name: str, default: int = 0) -> int:
|
||||
"""Safely read a module-level counter from a route file."""
|
||||
try:
|
||||
import importlib
|
||||
mod = importlib.import_module(module_path)
|
||||
return int(getattr(mod, attr_name, default))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _get_mode_counts() -> dict[str, int]:
|
||||
"""Read current entity counts from all available data sources."""
|
||||
counts: dict[str, int] = {}
|
||||
|
||||
# ADS-B aircraft (DataStore)
|
||||
counts['adsb'] = _safe_len('adsb_aircraft')
|
||||
|
||||
# AIS vessels (DataStore)
|
||||
counts['ais'] = _safe_len('ais_vessels')
|
||||
|
||||
# WiFi: prefer v2 scanner, fall back to legacy DataStore
|
||||
wifi_count = 0
|
||||
try:
|
||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||
if wifi_scanner is not None:
|
||||
wifi_count = len(wifi_scanner.access_points)
|
||||
except Exception:
|
||||
pass
|
||||
if wifi_count == 0:
|
||||
wifi_count = _safe_len('wifi_networks')
|
||||
counts['wifi'] = wifi_count
|
||||
|
||||
# Bluetooth: prefer v2 scanner, fall back to legacy DataStore
|
||||
bt_count = 0
|
||||
try:
|
||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||
if bt_scanner is not None:
|
||||
bt_count = len(bt_scanner.get_devices())
|
||||
except Exception:
|
||||
pass
|
||||
if bt_count == 0:
|
||||
bt_count = _safe_len('bt_devices')
|
||||
counts['bluetooth'] = bt_count
|
||||
|
||||
# DSC messages (DataStore)
|
||||
counts['dsc'] = _safe_len('dsc_messages')
|
||||
|
||||
# ACARS message count (route-level counter)
|
||||
counts['acars'] = _safe_route_attr('routes.acars', 'acars_message_count')
|
||||
|
||||
# VDL2 message count (route-level counter)
|
||||
counts['vdl2'] = _safe_route_attr('routes.vdl2', 'vdl2_message_count')
|
||||
|
||||
# APRS stations (route-level dict)
|
||||
try:
|
||||
import routes.aprs as aprs_mod
|
||||
counts['aprs'] = len(getattr(aprs_mod, 'aprs_stations', {}))
|
||||
except Exception:
|
||||
counts['aprs'] = 0
|
||||
|
||||
# Meshtastic recent messages (route-level list)
|
||||
try:
|
||||
import routes.meshtastic as mesh_route
|
||||
counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', []))
|
||||
except Exception:
|
||||
counts['meshtastic'] = 0
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def get_cross_mode_summary() -> dict[str, Any]:
|
||||
"""Return counts dict for all available data sources."""
|
||||
counts = _get_mode_counts()
|
||||
wifi_clients_count = 0
|
||||
try:
|
||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||
if wifi_scanner is not None:
|
||||
wifi_clients_count = len(wifi_scanner.clients)
|
||||
except Exception:
|
||||
pass
|
||||
if wifi_clients_count == 0:
|
||||
wifi_clients_count = _safe_len('wifi_clients')
|
||||
counts['wifi_clients'] = wifi_clients_count
|
||||
return counts
|
||||
|
||||
|
||||
def get_mode_health() -> dict[str, dict]:
|
||||
"""Check process refs and SDR status for each mode."""
|
||||
health: dict[str, dict] = {}
|
||||
|
||||
process_map = {
|
||||
'pager': 'current_process',
|
||||
'sensor': 'sensor_process',
|
||||
'adsb': 'adsb_process',
|
||||
'ais': 'ais_process',
|
||||
'acars': 'acars_process',
|
||||
'vdl2': 'vdl2_process',
|
||||
'aprs': 'aprs_process',
|
||||
'wifi': 'wifi_process',
|
||||
'bluetooth': 'bt_process',
|
||||
'dsc': 'dsc_process',
|
||||
'rtlamr': 'rtlamr_process',
|
||||
}
|
||||
|
||||
for mode, attr in process_map.items():
|
||||
proc = getattr(app_module, attr, None)
|
||||
running = proc is not None and (proc.poll() is None if proc else False)
|
||||
health[mode] = {'running': running}
|
||||
|
||||
# Override WiFi/BT health with v2 scanner status if available
|
||||
try:
|
||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||
if wifi_scanner is not None and wifi_scanner.is_scanning:
|
||||
health['wifi'] = {'running': True}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||
if bt_scanner is not None and bt_scanner.is_scanning:
|
||||
health['bluetooth'] = {'running': True}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Meshtastic: check client connection status
|
||||
try:
|
||||
from utils.meshtastic import get_meshtastic_client
|
||||
client = get_meshtastic_client()
|
||||
health['meshtastic'] = {'running': client._interface is not None}
|
||||
except Exception:
|
||||
health['meshtastic'] = {'running': False}
|
||||
|
||||
try:
|
||||
sdr_status = app_module.get_sdr_device_status()
|
||||
health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()}
|
||||
except Exception:
|
||||
health['sdr_devices'] = {}
|
||||
|
||||
return health
|
||||
|
||||
|
||||
EMERGENCY_SQUAWKS = {
|
||||
'7700': 'General Emergency',
|
||||
'7600': 'Comms Failure',
|
||||
'7500': 'Hijack',
|
||||
}
|
||||
|
||||
|
||||
def get_emergency_squawks() -> list[dict]:
|
||||
"""Iterate adsb_aircraft DataStore for emergency squawk codes."""
|
||||
emergencies: list[dict] = []
|
||||
try:
|
||||
for icao, aircraft in app_module.adsb_aircraft.items():
|
||||
sq = str(aircraft.get('squawk', '')).strip()
|
||||
if sq in EMERGENCY_SQUAWKS:
|
||||
emergencies.append({
|
||||
'icao': icao,
|
||||
'callsign': aircraft.get('callsign', ''),
|
||||
'squawk': sq,
|
||||
'meaning': EMERGENCY_SQUAWKS[sq],
|
||||
'altitude': aircraft.get('altitude'),
|
||||
'lat': aircraft.get('lat'),
|
||||
'lon': aircraft.get('lon'),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return emergencies
|
||||
210
utils/rf_fingerprint.py
Normal file
210
utils/rf_fingerprint.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""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]
|
||||
@@ -112,18 +112,21 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
||||
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
|
||||
current_ld = env.get('DYLD_LIBRARY_PATH', '')
|
||||
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
|
||||
result = subprocess.run(
|
||||
['rtl_test', '-t'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
env=env
|
||||
)
|
||||
output = result.stderr + result.stdout
|
||||
|
||||
# Parse device info from rtl_test output
|
||||
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
||||
device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$'
|
||||
result = subprocess.run(
|
||||
['rtl_test', '-t'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
timeout=5,
|
||||
env=env
|
||||
)
|
||||
output = result.stderr + result.stdout
|
||||
|
||||
# Parse device info from rtl_test output
|
||||
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
||||
# Require a non-empty serial to avoid matching malformed lines like "SN:".
|
||||
device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$'
|
||||
|
||||
from .rtlsdr import RTLSDRCommandBuilder
|
||||
|
||||
@@ -131,14 +134,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
||||
line = line.strip()
|
||||
match = re.match(device_pattern, line)
|
||||
if match:
|
||||
devices.append(SDRDevice(
|
||||
sdr_type=SDRType.RTL_SDR,
|
||||
index=int(match.group(1)),
|
||||
name=match.group(2).strip().rstrip(','),
|
||||
serial=match.group(3) or 'N/A',
|
||||
driver='rtlsdr',
|
||||
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
||||
))
|
||||
devices.append(SDRDevice(
|
||||
sdr_type=SDRType.RTL_SDR,
|
||||
index=int(match.group(1)),
|
||||
name=match.group(2).strip().rstrip(','),
|
||||
serial=match.group(3),
|
||||
driver='rtlsdr',
|
||||
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
||||
))
|
||||
|
||||
# Fallback: if we found devices but couldn't parse details
|
||||
if not devices:
|
||||
|
||||
Reference in New Issue
Block a user