feat: ship waterfall receiver overhaul and platform mode updates

This commit is contained in:
Smittix
2026-02-22 23:22:37 +00:00
parent 5d4b61b4c3
commit 5f480caa3f
41 changed files with 7635 additions and 3516 deletions

14
app.py
View File

@@ -25,7 +25,7 @@ import subprocess
from typing import Any
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session, send_from_directory
from werkzeug.security import check_password_hash
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
@@ -396,6 +396,18 @@ def favicon() -> Response:
return send_file('favicon.svg', mimetype='image/svg+xml')
@app.route('/sw.js')
def service_worker() -> Response:
resp = send_from_directory('static', 'sw.js', mimetype='application/javascript')
resp.headers['Service-Worker-Allowed'] = '/'
return resp
@app.route('/manifest.json')
def pwa_manifest() -> Response:
return send_from_directory('static', 'manifest.json', mimetype='application/manifest+json')
@app.route('/devices')
def get_devices() -> Response:
"""Get all detected SDR devices with hardware type info."""

View File

@@ -34,8 +34,8 @@ def register_blueprints(app):
from .recordings import recordings_bp
from .subghz import subghz_bp
from .bt_locate import bt_locate_bp
from .analytics import analytics_bp
from .space_weather import space_weather_bp
from .fingerprint import fingerprint_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -69,8 +69,8 @@ def register_blueprints(app):
app.register_blueprint(recordings_bp) # Session recordings
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard
app.register_blueprint(space_weather_bp) # Space weather monitoring
app.register_blueprint(fingerprint_bp) # RF fingerprinting
# Initialize TSCM state with queue and lock from app
import app as app_module

View File

@@ -1,528 +0,0 @@
"""Analytics dashboard: cross-mode summary, activity sparklines, export, geofence CRUD."""
from __future__ import annotations
import csv
import io
import json
from datetime import datetime, timezone
from typing import Any
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.analytics import (
get_activity_tracker,
get_cross_mode_summary,
get_emergency_squawks,
get_mode_health,
)
from utils.alerts import get_alert_manager
from utils.flight_correlator import get_flight_correlator
from utils.geofence import get_geofence_manager
from utils.temporal_patterns import get_pattern_detector
analytics_bp = Blueprint('analytics', __name__, url_prefix='/analytics')
# Map mode names to DataStore attribute(s)
MODE_STORES: dict[str, list[str]] = {
'adsb': ['adsb_aircraft'],
'ais': ['ais_vessels'],
'wifi': ['wifi_networks', 'wifi_clients'],
'bluetooth': ['bt_devices'],
'dsc': ['dsc_messages'],
}
@analytics_bp.route('/summary')
def analytics_summary():
"""Return cross-mode counts, health, and emergency squawks."""
return jsonify({
'status': 'success',
'counts': get_cross_mode_summary(),
'health': get_mode_health(),
'squawks': get_emergency_squawks(),
'flight_messages': {
'acars': get_flight_correlator().acars_count,
'vdl2': get_flight_correlator().vdl2_count,
},
})
@analytics_bp.route('/activity')
def analytics_activity():
"""Return sparkline arrays for each mode."""
tracker = get_activity_tracker()
return jsonify({
'status': 'success',
'sparklines': tracker.get_all_sparklines(),
})
@analytics_bp.route('/squawks')
def analytics_squawks():
"""Return current emergency squawk codes from ADS-B."""
return jsonify({
'status': 'success',
'squawks': get_emergency_squawks(),
})
@analytics_bp.route('/patterns')
def analytics_patterns():
"""Return detected temporal patterns."""
return jsonify({
'status': 'success',
'patterns': get_pattern_detector().get_all_patterns(),
})
@analytics_bp.route('/target')
def analytics_target():
"""Search entities across multiple modes for a target-centric view."""
query = (request.args.get('q') or '').strip()
requested_limit = request.args.get('limit', default=120, type=int) or 120
limit = max(1, min(500, requested_limit))
if not query:
return jsonify({
'status': 'success',
'query': '',
'results': [],
'mode_counts': {},
})
needle = query.lower()
results: list[dict[str, Any]] = []
mode_counts: dict[str, int] = {}
def push(mode: str, entity_id: str, title: str, subtitle: str, last_seen: str | None = None) -> None:
if len(results) >= limit:
return
results.append({
'mode': mode,
'id': entity_id,
'title': title,
'subtitle': subtitle,
'last_seen': last_seen,
})
mode_counts[mode] = mode_counts.get(mode, 0) + 1
# ADS-B
for icao, aircraft in app_module.adsb_aircraft.items():
if not isinstance(aircraft, dict):
continue
fields = [
icao,
aircraft.get('icao'),
aircraft.get('hex'),
aircraft.get('callsign'),
aircraft.get('registration'),
aircraft.get('flight'),
]
if not _matches_query(needle, fields):
continue
title = str(aircraft.get('callsign') or icao or 'Aircraft').strip()
subtitle = f"ICAO {aircraft.get('icao') or icao} | Alt {aircraft.get('altitude', '--')} | Speed {aircraft.get('speed', '--')}"
push('adsb', str(icao), title, subtitle, aircraft.get('lastSeen') or aircraft.get('last_seen'))
if len(results) >= limit:
break
# AIS
if len(results) < limit:
for mmsi, vessel in app_module.ais_vessels.items():
if not isinstance(vessel, dict):
continue
fields = [
mmsi,
vessel.get('mmsi'),
vessel.get('name'),
vessel.get('shipname'),
vessel.get('callsign'),
vessel.get('imo'),
]
if not _matches_query(needle, fields):
continue
vessel_name = vessel.get('name') or vessel.get('shipname') or mmsi or 'Vessel'
subtitle = f"MMSI {vessel.get('mmsi') or mmsi} | Type {vessel.get('ship_type') or vessel.get('type') or '--'}"
push('ais', str(mmsi), str(vessel_name), subtitle, vessel.get('lastSeen') or vessel.get('last_seen'))
if len(results) >= limit:
break
# WiFi networks and clients
if len(results) < limit:
for bssid, net in app_module.wifi_networks.items():
if not isinstance(net, dict):
continue
fields = [bssid, net.get('bssid'), net.get('ssid'), net.get('vendor')]
if not _matches_query(needle, fields):
continue
title = str(net.get('ssid') or net.get('bssid') or bssid or 'WiFi Network')
subtitle = f"BSSID {net.get('bssid') or bssid} | CH {net.get('channel', '--')} | RSSI {net.get('signal', '--')}"
push('wifi', str(bssid), title, subtitle, net.get('lastSeen') or net.get('last_seen'))
if len(results) >= limit:
break
if len(results) < limit:
for client_mac, client in app_module.wifi_clients.items():
if not isinstance(client, dict):
continue
fields = [client_mac, client.get('mac'), client.get('bssid'), client.get('ssid'), client.get('vendor')]
if not _matches_query(needle, fields):
continue
title = str(client.get('mac') or client_mac or 'WiFi Client')
subtitle = f"BSSID {client.get('bssid') or '--'} | Probe {client.get('ssid') or '--'}"
push('wifi', str(client_mac), title, subtitle, client.get('lastSeen') or client.get('last_seen'))
if len(results) >= limit:
break
# Bluetooth
if len(results) < limit:
for address, dev in app_module.bt_devices.items():
if not isinstance(dev, dict):
continue
fields = [
address,
dev.get('address'),
dev.get('mac'),
dev.get('name'),
dev.get('manufacturer'),
dev.get('vendor'),
]
if not _matches_query(needle, fields):
continue
title = str(dev.get('name') or dev.get('address') or address or 'Bluetooth Device')
subtitle = f"MAC {dev.get('address') or address} | RSSI {dev.get('rssi', '--')} | Vendor {dev.get('manufacturer') or dev.get('vendor') or '--'}"
push('bluetooth', str(address), title, subtitle, dev.get('lastSeen') or dev.get('last_seen'))
if len(results) >= limit:
break
# DSC recent messages
if len(results) < limit:
for msg_id, msg in app_module.dsc_messages.items():
if not isinstance(msg, dict):
continue
fields = [
msg_id,
msg.get('mmsi'),
msg.get('from_mmsi'),
msg.get('to_mmsi'),
msg.get('from_callsign'),
msg.get('to_callsign'),
msg.get('category'),
]
if not _matches_query(needle, fields):
continue
title = str(msg.get('from_mmsi') or msg.get('mmsi') or msg_id or 'DSC Message')
subtitle = f"To {msg.get('to_mmsi') or '--'} | Cat {msg.get('category') or '--'} | Freq {msg.get('frequency') or '--'}"
push('dsc', str(msg_id), title, subtitle, msg.get('timestamp') or msg.get('lastSeen') or msg.get('last_seen'))
if len(results) >= limit:
break
return jsonify({
'status': 'success',
'query': query,
'results': results,
'mode_counts': mode_counts,
})
@analytics_bp.route('/insights')
def analytics_insights():
"""Return actionable insight cards and top changes."""
counts = get_cross_mode_summary()
tracker = get_activity_tracker()
sparklines = tracker.get_all_sparklines()
squawks = get_emergency_squawks()
patterns = get_pattern_detector().get_all_patterns()
alerts = get_alert_manager().list_events(limit=120)
top_changes = _compute_mode_changes(sparklines)
busiest_mode, busiest_count = _get_busiest_mode(counts)
critical_1h = _count_recent_alerts(alerts, severities={'critical', 'high'}, max_age_seconds=3600)
recurring_emitters = sum(1 for p in patterns if float(p.get('confidence') or 0.0) >= 0.7)
cards = []
if top_changes:
lead = top_changes[0]
direction = 'up' if lead['delta'] >= 0 else 'down'
cards.append({
'id': 'fastest_change',
'title': 'Fastest Change',
'value': f"{lead['mode_label']} ({lead['signed_delta']})",
'label': 'last window vs prior',
'severity': 'high' if lead['delta'] > 0 else 'low',
'detail': f"Traffic is trending {direction} in {lead['mode_label']}.",
})
else:
cards.append({
'id': 'fastest_change',
'title': 'Fastest Change',
'value': 'Insufficient data',
'label': 'wait for activity history',
'severity': 'low',
'detail': 'Sparklines need more samples to score momentum.',
})
cards.append({
'id': 'busiest_mode',
'title': 'Busiest Mode',
'value': f"{busiest_mode} ({busiest_count})",
'label': 'current observed entities',
'severity': 'medium' if busiest_count > 0 else 'low',
'detail': 'Highest live entity count across monitoring modes.',
})
cards.append({
'id': 'critical_alerts',
'title': 'Critical Alerts (1h)',
'value': str(critical_1h),
'label': 'critical/high severities',
'severity': 'critical' if critical_1h > 0 else 'low',
'detail': 'Prioritize triage if this count is non-zero.',
})
cards.append({
'id': 'emergency_squawks',
'title': 'Emergency Squawks',
'value': str(len(squawks)),
'label': 'active ADS-B emergency codes',
'severity': 'critical' if squawks else 'low',
'detail': 'Immediate aviation anomalies currently visible.',
})
cards.append({
'id': 'recurring_emitters',
'title': 'Recurring Emitters',
'value': str(recurring_emitters),
'label': 'pattern confidence >= 0.70',
'severity': 'medium' if recurring_emitters > 0 else 'low',
'detail': 'Potentially stationary or periodic emitters detected.',
})
return jsonify({
'status': 'success',
'generated_at': datetime.now(timezone.utc).isoformat(),
'cards': cards,
'top_changes': top_changes[:5],
})
def _compute_mode_changes(sparklines: dict[str, list[int]]) -> list[dict]:
mode_labels = {
'adsb': 'ADS-B',
'ais': 'AIS',
'wifi': 'WiFi',
'bluetooth': 'Bluetooth',
'dsc': 'DSC',
'acars': 'ACARS',
'vdl2': 'VDL2',
'aprs': 'APRS',
'meshtastic': 'Meshtastic',
}
rows = []
for mode, samples in (sparklines or {}).items():
if not isinstance(samples, list) or len(samples) < 4:
continue
window = max(2, min(12, len(samples) // 2))
recent = samples[-window:]
previous = samples[-(window * 2):-window]
if not previous:
continue
recent_avg = sum(recent) / len(recent)
prev_avg = sum(previous) / len(previous)
delta = round(recent_avg - prev_avg, 1)
rows.append({
'mode': mode,
'mode_label': mode_labels.get(mode, mode.upper()),
'delta': delta,
'signed_delta': ('+' if delta >= 0 else '') + str(delta),
'recent_avg': round(recent_avg, 1),
'previous_avg': round(prev_avg, 1),
'direction': 'up' if delta > 0 else ('down' if delta < 0 else 'flat'),
})
rows.sort(key=lambda r: abs(r['delta']), reverse=True)
return rows
def _matches_query(needle: str, values: list[Any]) -> bool:
for value in values:
if value is None:
continue
if needle in str(value).lower():
return True
return False
def _count_recent_alerts(alerts: list[dict], severities: set[str], max_age_seconds: int) -> int:
now = datetime.now(timezone.utc)
count = 0
for event in alerts:
sev = str(event.get('severity') or '').lower()
if sev not in severities:
continue
created_raw = event.get('created_at')
if not created_raw:
continue
try:
created = datetime.fromisoformat(str(created_raw).replace('Z', '+00:00'))
except ValueError:
continue
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
age = (now - created).total_seconds()
if 0 <= age <= max_age_seconds:
count += 1
return count
def _get_busiest_mode(counts: dict[str, int]) -> tuple[str, int]:
mode_labels = {
'adsb': 'ADS-B',
'ais': 'AIS',
'wifi': 'WiFi',
'bluetooth': 'Bluetooth',
'dsc': 'DSC',
'acars': 'ACARS',
'vdl2': 'VDL2',
'aprs': 'APRS',
'meshtastic': 'Meshtastic',
}
filtered = {k: int(v or 0) for k, v in (counts or {}).items() if k in mode_labels}
if not filtered:
return ('None', 0)
mode = max(filtered, key=filtered.get)
return (mode_labels.get(mode, mode.upper()), filtered[mode])
@analytics_bp.route('/export/<mode>')
def analytics_export(mode: str):
"""Export current DataStore contents as JSON or CSV."""
fmt = request.args.get('format', 'json').lower()
if mode == 'sensor':
# Sensor doesn't use DataStore; return recent queue-based data
return jsonify({'status': 'success', 'data': [], 'message': 'Sensor data is stream-only'})
store_names = MODE_STORES.get(mode)
if not store_names:
return jsonify({'status': 'error', 'message': f'Unknown mode: {mode}'}), 400
all_items: list[dict] = []
# Try v2 scanners first for wifi/bluetooth
if mode == 'wifi':
try:
from utils.wifi.scanner import _scanner_instance as wifi_scanner
if wifi_scanner is not None:
for ap in wifi_scanner.access_points:
all_items.append(ap.to_dict())
for client in wifi_scanner.clients:
item = client.to_dict()
item['_store'] = 'wifi_clients'
all_items.append(item)
except Exception:
pass
elif mode == 'bluetooth':
try:
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
if bt_scanner is not None:
for dev in bt_scanner.get_devices():
all_items.append(dev.to_dict())
except Exception:
pass
# Fall back to legacy DataStores if v2 scanners yielded nothing
if not all_items:
for store_name in store_names:
store = getattr(app_module, store_name, None)
if store is None:
continue
for key, value in store.items():
item = dict(value) if isinstance(value, dict) else {'id': key, 'value': value}
item.setdefault('_store', store_name)
all_items.append(item)
if fmt == 'csv':
if not all_items:
output = ''
else:
# Collect all keys across items
fieldnames: list[str] = []
seen: set[str] = set()
for item in all_items:
for k in item:
if k not in seen:
fieldnames.append(k)
seen.add(k)
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction='ignore')
writer.writeheader()
for item in all_items:
# Serialize non-scalar values
row = {}
for k in fieldnames:
v = item.get(k)
if isinstance(v, (dict, list)):
row[k] = json.dumps(v)
else:
row[k] = v
writer.writerow(row)
output = buf.getvalue()
response = Response(output, mimetype='text/csv')
response.headers['Content-Disposition'] = f'attachment; filename={mode}_export.csv'
return response
# Default: JSON
return jsonify({'status': 'success', 'mode': mode, 'count': len(all_items), 'data': all_items})
# =========================================================================
# Geofence CRUD
# =========================================================================
@analytics_bp.route('/geofences')
def list_geofences():
return jsonify({
'status': 'success',
'zones': get_geofence_manager().list_zones(),
})
@analytics_bp.route('/geofences', methods=['POST'])
def create_geofence():
data = request.get_json() or {}
name = data.get('name')
lat = data.get('lat')
lon = data.get('lon')
radius_m = data.get('radius_m')
if not all([name, lat is not None, lon is not None, radius_m is not None]):
return jsonify({'status': 'error', 'message': 'name, lat, lon, radius_m are required'}), 400
try:
lat = float(lat)
lon = float(lon)
radius_m = float(radius_m)
except (TypeError, ValueError):
return jsonify({'status': 'error', 'message': 'lat, lon, radius_m must be numbers'}), 400
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
if radius_m <= 0:
return jsonify({'status': 'error', 'message': 'radius_m must be positive'}), 400
alert_on = data.get('alert_on', 'enter_exit')
zone_id = get_geofence_manager().add_zone(name, lat, lon, radius_m, alert_on)
return jsonify({'status': 'success', 'zone_id': zone_id})
@analytics_bp.route('/geofences/<int:zone_id>', methods=['DELETE'])
def delete_geofence(zone_id: int):
ok = get_geofence_manager().delete_zone(zone_id)
if not ok:
return jsonify({'status': 'error', 'message': 'Zone not found'}), 404
return jsonify({'status': 'success'})

113
routes/fingerprint.py Normal file
View File

@@ -0,0 +1,113 @@
"""RF Fingerprinting CRUD + compare API."""
from __future__ import annotations
import os
import threading
from flask import Blueprint, jsonify, request
fingerprint_bp = Blueprint("fingerprint", __name__, url_prefix="/fingerprint")
_fingerprinter = None
_fingerprinter_lock = threading.Lock()
_active_session_id: int | None = None
_session_lock = threading.Lock()
def _get_fingerprinter():
global _fingerprinter
if _fingerprinter is None:
with _fingerprinter_lock:
if _fingerprinter is None:
from utils.rf_fingerprint import RFFingerprinter
db_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "instance", "rf_fingerprints.db"
)
os.makedirs(os.path.dirname(db_path), exist_ok=True)
_fingerprinter = RFFingerprinter(db_path)
return _fingerprinter
@fingerprint_bp.route("/start", methods=["POST"])
def start_session():
global _active_session_id
data = request.get_json(force=True) or {}
name = data.get("name", "Unnamed Session")
location = data.get("location")
fp = _get_fingerprinter()
with _session_lock:
if _active_session_id is not None:
return jsonify({"error": "Session already active", "session_id": _active_session_id}), 409
session_id = fp.start_session(name, location)
_active_session_id = session_id
return jsonify({"session_id": session_id, "name": name})
@fingerprint_bp.route("/stop", methods=["POST"])
def stop_session():
global _active_session_id
fp = _get_fingerprinter()
with _session_lock:
if _active_session_id is None:
return jsonify({"error": "No active session"}), 400
session_id = _active_session_id
result = fp.finalize(session_id)
_active_session_id = None
return jsonify(result)
@fingerprint_bp.route("/observation", methods=["POST"])
def add_observation():
global _active_session_id
fp = _get_fingerprinter()
data = request.get_json(force=True) or {}
observations = data.get("observations", [])
with _session_lock:
session_id = _active_session_id
if session_id is None:
return jsonify({"error": "No active session"}), 400
if not observations:
return jsonify({"added": 0})
fp.add_observations_batch(session_id, observations)
return jsonify({"added": len(observations)})
@fingerprint_bp.route("/list", methods=["GET"])
def list_sessions():
fp = _get_fingerprinter()
sessions = fp.list_sessions()
with _session_lock:
active_id = _active_session_id
return jsonify({"sessions": sessions, "active_session_id": active_id})
@fingerprint_bp.route("/compare", methods=["POST"])
def compare():
fp = _get_fingerprinter()
data = request.get_json(force=True) or {}
baseline_id = data.get("baseline_id")
observations = data.get("observations", [])
if not baseline_id:
return jsonify({"error": "baseline_id required"}), 400
anomalies = fp.compare(int(baseline_id), observations)
bands = fp.get_baseline_bands(int(baseline_id))
return jsonify({"anomalies": anomalies, "baseline_bands": bands})
@fingerprint_bp.route("/<int:session_id>", methods=["DELETE"])
def delete_session(session_id: int):
global _active_session_id
fp = _get_fingerprinter()
with _session_lock:
if _active_session_id == session_id:
_active_session_id = None
fp.delete_session(session_id)
return jsonify({"deleted": session_id})
@fingerprint_bp.route("/status", methods=["GET"])
def session_status():
with _session_lock:
active_id = _active_session_id
return jsonify({"active_session_id": active_id})

View File

@@ -9,11 +9,12 @@ import queue
import select
import signal
import shutil
import struct
import subprocess
import threading
import time
from datetime import datetime
from typing import Generator, Optional, List, Dict
from typing import Any, Dict, Generator, List, Optional
from flask import Blueprint, jsonify, request, Response
@@ -43,6 +44,7 @@ audio_lock = threading.Lock()
audio_running = False
audio_frequency = 0.0
audio_modulation = 'fm'
audio_source = 'process'
# Scanner state
scanner_thread: Optional[threading.Thread] = None
@@ -119,6 +121,22 @@ def _rtl_fm_demod_mode(modulation: str) -> str:
return 'wbfm' if mod == 'wfm' else mod
def _wav_header(sample_rate: int = 48000, bits_per_sample: int = 16, channels: int = 1) -> bytes:
"""Create a streaming WAV header with unknown data length."""
bytes_per_sample = bits_per_sample // 8
byte_rate = sample_rate * channels * bytes_per_sample
block_align = channels * bytes_per_sample
return (
b'RIFF'
+ struct.pack('<I', 0xFFFFFFFF)
+ b'WAVE'
+ b'fmt '
+ struct.pack('<IHHIIHH', 16, 1, channels, sample_rate, byte_rate, block_align, bits_per_sample)
+ b'data'
+ struct.pack('<I', 0xFFFFFFFF)
)
def add_activity_log(event_type: str, frequency: float, details: str = ''):
@@ -697,8 +715,8 @@ def _start_audio_stream(frequency: float, modulation: str):
]
if scanner_config.get('bias_t', False):
sdr_cmd.append('-T')
# Explicitly output to stdout (some rtl_fm versions need this)
sdr_cmd.append('-')
# Omit explicit filename: rtl_fm defaults to stdout.
# (Some builds intermittently stall when '-' is passed explicitly.)
else:
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
rx_fm_path = find_rx_fm()
@@ -842,15 +860,15 @@ def _start_audio_stream(frequency: float, modulation: str):
# Pipeline started successfully
break
# Validate that audio is producing data quickly
try:
ready, _, _ = select.select([audio_process.stdout], [], [], 4.0)
if not ready:
logger.warning("Audio pipeline produced no data in startup window — killing stalled pipeline")
_stop_audio_stream_internal()
return
except Exception as e:
logger.warning(f"Audio startup check failed: {e}")
# Keep monitor startup tolerant: some demod chains can take
# several seconds before producing stream bytes.
if (
not audio_process
or not audio_rtl_process
or audio_process.poll() is not None
or audio_rtl_process.poll() is not None
):
logger.warning("Audio pipeline did not remain alive after startup")
_stop_audio_stream_internal()
return
@@ -871,11 +889,21 @@ def _stop_audio_stream():
def _stop_audio_stream_internal():
"""Internal stop (must hold lock)."""
global audio_process, audio_rtl_process, audio_running, audio_frequency
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_source
# Set flag first to stop any streaming
audio_running = False
audio_frequency = 0.0
previous_source = audio_source
audio_source = 'process'
if previous_source == 'waterfall':
try:
from routes.waterfall_websocket import stop_shared_monitor_from_capture
stop_shared_monitor_from_capture()
except Exception:
pass
had_processes = audio_process is not None or audio_rtl_process is not None
@@ -1243,6 +1271,7 @@ def get_presets() -> Response:
def start_audio() -> Response:
"""Start audio at specific frequency (manual mode)."""
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
global audio_running, audio_frequency, audio_modulation, audio_source
# Stop scanner if running
if scanner_running:
@@ -1280,6 +1309,11 @@ def start_audio() -> Response:
gain = int(data.get('gain', 40))
device = int(data.get('device', 0))
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False))
if isinstance(bias_t_raw, str):
bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'}
else:
bias_t = bool(bias_t_raw)
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
@@ -1304,6 +1338,43 @@ def start_audio() -> Response:
scanner_config['gain'] = gain
scanner_config['device'] = device
scanner_config['sdr_type'] = sdr_type
scanner_config['bias_t'] = bias_t
# Preferred path: when waterfall WebSocket is active on the same SDR,
# derive monitor audio from that IQ stream instead of spawning rtl_fm.
try:
from routes.waterfall_websocket import (
get_shared_capture_status,
start_shared_monitor_from_capture,
)
shared = get_shared_capture_status()
if shared.get('running') and shared.get('device') == device:
_stop_audio_stream()
ok, msg = start_shared_monitor_from_capture(
device=device,
frequency_mhz=frequency,
modulation=modulation,
squelch=squelch,
)
if ok:
audio_running = True
audio_frequency = frequency
audio_modulation = modulation
audio_source = 'waterfall'
# Shared monitor uses the waterfall's existing SDR claim.
if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device)
listening_active_device = None
return jsonify({
'status': 'started',
'frequency': frequency,
'modulation': modulation,
'source': 'waterfall',
})
logger.warning(f"Shared waterfall monitor unavailable: {msg}")
except Exception as e:
logger.debug(f"Shared waterfall monitor probe failed: {e}")
# Stop waterfall if it's using the same SDR (SSE path)
if waterfall_running and waterfall_active_device == device:
@@ -1322,13 +1393,6 @@ def start_audio() -> Response:
error = None
max_claim_attempts = 6
for attempt in range(max_claim_attempts):
# Force-release a stale waterfall registry entry on each
# attempt — the WebSocket handler may not have finished
# cleanup yet.
device_status = app_module.get_sdr_device_status()
if device_status.get(device) == 'waterfall':
app_module.release_sdr_device(device)
error = app_module.claim_sdr_device(device, 'listening')
if not error:
break
@@ -1350,15 +1414,36 @@ def start_audio() -> Response:
_start_audio_stream(frequency, modulation)
if audio_running:
audio_source = 'process'
return jsonify({
'status': 'started',
'frequency': frequency,
'modulation': modulation
'modulation': modulation,
'source': 'process',
})
else:
# Avoid leaving a stale device claim after startup failure.
if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device)
listening_active_device = None
start_error = ''
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
try:
with open(log_path, 'r') as handle:
content = handle.read().strip()
if content:
start_error = content.splitlines()[-1]
break
except Exception:
continue
message = 'Failed to start audio. Check SDR device.'
if start_error:
message = f'Failed to start audio: {start_error}'
return jsonify({
'status': 'error',
'message': 'Failed to start audio. Check SDR device.'
'message': message
}), 500
@@ -1376,10 +1461,21 @@ def stop_audio() -> Response:
@listening_post_bp.route('/audio/status')
def audio_status() -> Response:
"""Get audio status."""
running = audio_running
if audio_source == 'waterfall':
try:
from routes.waterfall_websocket import get_shared_capture_status
shared = get_shared_capture_status()
running = bool(shared.get('running') and shared.get('monitor_enabled'))
except Exception:
running = False
return jsonify({
'running': audio_running,
'running': running,
'frequency': audio_frequency,
'modulation': audio_modulation
'modulation': audio_modulation,
'source': audio_source,
})
@@ -1397,15 +1493,26 @@ def audio_debug() -> Response:
except Exception:
return ''
shared = {}
if audio_source == 'waterfall':
try:
from routes.waterfall_websocket import get_shared_capture_status
shared = get_shared_capture_status()
except Exception:
shared = {}
return jsonify({
'running': audio_running,
'frequency': audio_frequency,
'modulation': audio_modulation,
'source': audio_source,
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
'device': scanner_config.get('device', 0),
'gain': scanner_config.get('gain', 0),
'squelch': scanner_config.get('squelch', 0),
'audio_process_alive': bool(audio_process and audio_process.poll() is None),
'shared_capture': shared,
'rtl_fm_stderr': _read_log(rtl_log_path),
'ffmpeg_stderr': _read_log(ffmpeg_log_path),
'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
@@ -1417,6 +1524,20 @@ def audio_probe() -> Response:
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
global audio_process
if audio_source == 'waterfall':
try:
from routes.waterfall_websocket import read_shared_monitor_audio_chunk
data = read_shared_monitor_audio_chunk(timeout=2.0)
if not data:
return jsonify({'status': 'error', 'message': 'no shared audio data available'}), 504
sample_path = '/tmp/audio_probe.bin'
with open(sample_path, 'wb') as handle:
handle.write(data)
return jsonify({'status': 'ok', 'bytes': len(data), 'source': 'waterfall'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
if not audio_process or not audio_process.stdout:
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
@@ -1441,14 +1562,58 @@ def audio_probe() -> Response:
@listening_post_bp.route('/audio/stream')
def stream_audio() -> Response:
"""Stream WAV audio."""
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
if audio_source == 'waterfall':
for _ in range(40):
if audio_running:
break
time.sleep(0.05)
if not audio_running:
return Response(b'', mimetype='audio/wav', status=204)
def generate_shared():
global audio_running, audio_source
try:
from routes.waterfall_websocket import (
get_shared_capture_status,
read_shared_monitor_audio_chunk,
)
except Exception:
return
# Browser expects an immediate WAV header.
yield _wav_header(sample_rate=48000)
while audio_running and audio_source == 'waterfall':
chunk = read_shared_monitor_audio_chunk(timeout=1.0)
if chunk:
yield chunk
continue
shared = get_shared_capture_status()
if not shared.get('running') or not shared.get('monitor_enabled'):
audio_running = False
audio_source = 'process'
break
return Response(
generate_shared(),
mimetype='audio/wav',
headers={
'Content-Type': 'audio/wav',
'Cache-Control': 'no-cache, no-store',
'X-Accel-Buffering': 'no',
'Transfer-Encoding': 'chunked',
}
)
# Wait for audio process to be ready (up to 2 seconds).
for _ in range(40):
if audio_running and audio_process:
break
time.sleep(0.05)
if not audio_running or not audio_process:
return Response(b'', mimetype='audio/mpeg', status=204)
return Response(b'', mimetype='audio/wav', status=204)
def generate():
# Capture local reference to avoid race condition with stop
@@ -1474,21 +1639,25 @@ def stream_audio() -> Response:
yield header_chunk
# Stream real-time audio
first_chunk_deadline = time.time() + 3.0
first_chunk_deadline = time.time() + 20.0
warned_wait = False
while audio_running and proc.poll() is None:
# Use select to avoid blocking forever
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
if ready:
chunk = proc.stdout.read(8192)
if chunk:
warned_wait = False
yield chunk
else:
break
else:
# If no data arrives shortly after start, exit so caller can retry
# Keep connection open while demodulator settles.
if time.time() > first_chunk_deadline:
logger.warning("Audio stream timed out waiting for first chunk")
break
if not warned_wait:
logger.warning("Audio stream still waiting for first chunk")
warned_wait = True
continue
# Timeout - check if process died
if proc.poll() is not None:
break
@@ -1621,9 +1790,20 @@ def _waterfall_loop():
"""Continuous rtl_power sweep loop emitting waterfall data."""
global waterfall_running, waterfall_process
def _queue_waterfall_error(message: str) -> None:
try:
waterfall_queue.put_nowait({
'type': 'waterfall_error',
'message': message,
'timestamp': datetime.now().isoformat(),
})
except queue.Full:
pass
rtl_power_path = find_rtl_power()
if not rtl_power_path:
logger.error("rtl_power not found for waterfall")
_queue_waterfall_error('rtl_power not found')
waterfall_running = False
return
@@ -1646,17 +1826,33 @@ def _waterfall_loop():
waterfall_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
stderr=subprocess.PIPE,
bufsize=1,
text=True,
)
# Detect immediate startup failures (e.g. device busy / no device).
time.sleep(0.35)
if waterfall_process.poll() is not None:
stderr_text = ''
try:
if waterfall_process.stderr:
stderr_text = waterfall_process.stderr.read().strip()
except Exception:
stderr_text = ''
msg = stderr_text or f'rtl_power exited early (code {waterfall_process.returncode})'
logger.error(f"Waterfall startup failed: {msg}")
_queue_waterfall_error(msg)
return
current_ts = None
all_bins: list[float] = []
sweep_start_hz = start_hz
sweep_end_hz = end_hz
received_any = False
if not waterfall_process.stdout:
_queue_waterfall_error('rtl_power stdout unavailable')
return
for line in waterfall_process.stdout:
@@ -1666,6 +1862,7 @@ def _waterfall_loop():
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
if ts is None or not bins:
continue
received_any = True
if current_ts is None:
current_ts = ts
@@ -1723,8 +1920,12 @@ def _waterfall_loop():
except queue.Full:
pass
if waterfall_running and not received_any:
_queue_waterfall_error('No waterfall FFT data received from rtl_power')
except Exception as e:
logger.error(f"Waterfall loop error: {e}")
_queue_waterfall_error(f"Waterfall loop error: {e}")
finally:
waterfall_running = False
if waterfall_process and waterfall_process.poll() is None:
@@ -1768,7 +1969,12 @@ def start_waterfall() -> Response:
with waterfall_lock:
if waterfall_running:
return jsonify({'status': 'error', 'message': 'Waterfall already running'}), 409
return jsonify({
'status': 'started',
'already_running': True,
'message': 'Waterfall already running',
'config': waterfall_config,
})
if not find_rtl_power():
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503

View File

@@ -6,7 +6,10 @@ import socket
import subprocess
import threading
import time
from contextlib import suppress
from typing import Any
import numpy as np
from flask import Flask
try:
@@ -17,18 +20,33 @@ except ImportError:
Sock = None
from utils.logging import get_logger
from utils.process import safe_terminate, register_process, unregister_process
from utils.process import register_process, safe_terminate, unregister_process
from utils.sdr import SDRFactory, SDRType
from utils.sdr.base import SDRCapabilities, SDRDevice
from utils.waterfall_fft import (
build_binary_frame,
compute_power_spectrum,
cu8_to_complex,
quantize_to_uint8,
)
from utils.sdr import SDRFactory, SDRType
from utils.sdr.base import SDRCapabilities, SDRDevice
logger = get_logger('intercept.waterfall_ws')
AUDIO_SAMPLE_RATE = 48000
_shared_state_lock = threading.Lock()
_shared_audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=80)
_shared_state: dict[str, Any] = {
'running': False,
'device': None,
'center_mhz': 0.0,
'span_mhz': 0.0,
'sample_rate': 0,
'monitor_enabled': False,
'monitor_freq_mhz': 0.0,
'monitor_modulation': 'wfm',
'monitor_squelch': 0,
}
# Maximum bandwidth per SDR type (Hz)
MAX_BANDWIDTH = {
SDRType.RTL_SDR: 2400000,
@@ -39,6 +57,237 @@ MAX_BANDWIDTH = {
}
def _clear_shared_audio_queue() -> None:
while True:
try:
_shared_audio_queue.get_nowait()
except queue.Empty:
break
def _set_shared_capture_state(
*,
running: bool,
device: int | None = None,
center_mhz: float | None = None,
span_mhz: float | None = None,
sample_rate: int | None = None,
) -> None:
with _shared_state_lock:
_shared_state['running'] = bool(running)
_shared_state['device'] = device if running else None
if center_mhz is not None:
_shared_state['center_mhz'] = float(center_mhz)
if span_mhz is not None:
_shared_state['span_mhz'] = float(span_mhz)
if sample_rate is not None:
_shared_state['sample_rate'] = int(sample_rate)
if not running:
_shared_state['monitor_enabled'] = False
if not running:
_clear_shared_audio_queue()
def _set_shared_monitor(
*,
enabled: bool,
frequency_mhz: float | None = None,
modulation: str | None = None,
squelch: int | None = None,
) -> None:
was_enabled = False
with _shared_state_lock:
was_enabled = bool(_shared_state.get('monitor_enabled'))
_shared_state['monitor_enabled'] = bool(enabled)
if frequency_mhz is not None:
_shared_state['monitor_freq_mhz'] = float(frequency_mhz)
if modulation is not None:
_shared_state['monitor_modulation'] = str(modulation).lower().strip()
if squelch is not None:
_shared_state['monitor_squelch'] = max(0, min(100, int(squelch)))
if was_enabled and not enabled:
_clear_shared_audio_queue()
def get_shared_capture_status() -> dict[str, Any]:
with _shared_state_lock:
return {
'running': bool(_shared_state['running']),
'device': _shared_state['device'],
'center_mhz': float(_shared_state.get('center_mhz', 0.0) or 0.0),
'span_mhz': float(_shared_state.get('span_mhz', 0.0) or 0.0),
'sample_rate': int(_shared_state.get('sample_rate', 0) or 0),
'monitor_enabled': bool(_shared_state.get('monitor_enabled')),
'monitor_freq_mhz': float(_shared_state.get('monitor_freq_mhz', 0.0) or 0.0),
'monitor_modulation': str(_shared_state.get('monitor_modulation', 'wfm')),
'monitor_squelch': int(_shared_state.get('monitor_squelch', 0) or 0),
}
def start_shared_monitor_from_capture(
*,
device: int,
frequency_mhz: float,
modulation: str,
squelch: int,
) -> tuple[bool, str]:
with _shared_state_lock:
if not _shared_state['running']:
return False, 'Waterfall IQ stream not active'
if _shared_state['device'] != device:
return False, 'Waterfall stream is using a different SDR device'
_shared_state['monitor_enabled'] = True
_shared_state['monitor_freq_mhz'] = float(frequency_mhz)
_shared_state['monitor_modulation'] = str(modulation).lower().strip()
_shared_state['monitor_squelch'] = max(0, min(100, int(squelch)))
_clear_shared_audio_queue()
return True, 'started'
def stop_shared_monitor_from_capture() -> None:
_set_shared_monitor(enabled=False)
def read_shared_monitor_audio_chunk(timeout: float = 1.0) -> bytes | None:
with _shared_state_lock:
if not _shared_state['running'] or not _shared_state['monitor_enabled']:
return None
try:
return _shared_audio_queue.get(timeout=max(0.0, float(timeout)))
except queue.Empty:
return None
def _snapshot_monitor_config() -> dict[str, Any] | None:
with _shared_state_lock:
if not (_shared_state['running'] and _shared_state['monitor_enabled']):
return None
return {
'center_mhz': float(_shared_state['center_mhz']),
'monitor_freq_mhz': float(_shared_state['monitor_freq_mhz']),
'modulation': str(_shared_state['monitor_modulation']),
'squelch': int(_shared_state['monitor_squelch']),
}
def _push_shared_audio_chunk(chunk: bytes) -> None:
if not chunk:
return
if _shared_audio_queue.full():
with suppress(queue.Empty):
_shared_audio_queue.get_nowait()
with suppress(queue.Full):
_shared_audio_queue.put_nowait(chunk)
def _demodulate_monitor_audio(
samples: np.ndarray,
sample_rate: int,
center_mhz: float,
monitor_freq_mhz: float,
modulation: str,
squelch: int,
) -> bytes | None:
if samples.size < 32 or sample_rate <= 0:
return None
fs = float(sample_rate)
freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6
nyquist = fs * 0.5
if abs(freq_offset_hz) > nyquist * 0.98:
return None
n = np.arange(samples.size, dtype=np.float32)
rotator = np.exp(-1j * (2.0 * np.pi * freq_offset_hz / fs) * n)
shifted = samples * rotator
mod = str(modulation or 'wfm').lower().strip()
target_bb = 220000.0 if mod == 'wfm' else 48000.0
pre_decim = max(1, int(fs // target_bb))
if pre_decim > 1:
usable = (shifted.size // pre_decim) * pre_decim
if usable < pre_decim:
return None
shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1)
fs1 = fs / pre_decim
if shifted.size < 16:
return None
if mod in ('wfm', 'fm'):
audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32)
elif mod == 'am':
envelope = np.abs(shifted).astype(np.float32)
audio = envelope - float(np.mean(envelope))
elif mod == 'usb':
audio = np.real(shifted).astype(np.float32)
elif mod == 'lsb':
audio = -np.real(shifted).astype(np.float32)
else:
audio = np.real(shifted).astype(np.float32)
if audio.size < 8:
return None
audio = audio - float(np.mean(audio))
if mod in ('fm', 'am', 'usb', 'lsb'):
taps = int(max(1, min(31, fs1 / 12000.0)))
if taps > 1:
kernel = np.ones(taps, dtype=np.float32) / float(taps)
audio = np.convolve(audio, kernel, mode='same')
out_len = int(audio.size * AUDIO_SAMPLE_RATE / fs1)
if out_len < 32:
return None
x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float32)
x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float32)
audio = np.interp(x_new, x_old, audio).astype(np.float32)
rms = float(np.sqrt(np.mean(audio * audio) + 1e-12))
level = min(100.0, rms * 450.0)
if squelch > 0 and level < float(squelch):
audio.fill(0.0)
peak = float(np.max(np.abs(audio))) if audio.size else 0.0
if peak > 0:
audio = audio * min(20.0, 0.85 / peak)
pcm = np.clip(audio, -1.0, 1.0)
return (pcm * 32767.0).astype(np.int16).tobytes()
def _parse_center_freq_mhz(payload: dict[str, Any]) -> float:
"""Parse center frequency from mixed legacy/new payload formats."""
if payload.get('center_freq_mhz') is not None:
return float(payload['center_freq_mhz'])
if payload.get('center_freq_hz') is not None:
return float(payload['center_freq_hz']) / 1e6
raw = float(payload.get('center_freq', 100.0))
# Backward compatibility: some clients still send center_freq in Hz.
if raw > 100000:
return raw / 1e6
return raw
def _parse_span_mhz(payload: dict[str, Any]) -> float:
"""Parse display span in MHz from mixed payload formats."""
if payload.get('span_hz') is not None:
return float(payload['span_hz']) / 1e6
return float(payload.get('span_mhz', 2.0))
def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> int:
"""Pick a valid hardware sample rate nearest the requested span."""
valid_rates = sorted({int(r) for r in caps.sample_rates if int(r) > 0})
if valid_rates:
return min(valid_rates, key=lambda rate: abs(rate - span_hz))
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
return max(62500, min(span_hz, max_bw))
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
"""Convert client sdr_type string to SDRType enum."""
mapping = {
@@ -87,6 +336,10 @@ def init_waterfall_websocket(app: Flask):
reader_thread = None
stop_event = threading.Event()
claimed_device = None
capture_center_mhz = 0.0
capture_start_freq = 0.0
capture_end_freq = 0.0
capture_span_mhz = 0.0
# Queue for outgoing messages — only the main loop touches ws.send()
send_queue = queue.Queue(maxsize=120)
@@ -105,7 +358,7 @@ def init_waterfall_websocket(app: Flask):
break
try:
msg = ws.receive(timeout=0.1)
msg = ws.receive(timeout=0.01)
except Exception as e:
err = str(e).lower()
if "closed" in err:
@@ -143,6 +396,7 @@ def init_waterfall_websocket(app: Flask):
if claimed_device is not None:
app_module.release_sdr_device(claimed_device)
claimed_device = None
_set_shared_capture_state(running=False)
stop_event.clear()
# Flush stale frames from previous capture
while not send_queue.empty():
@@ -155,34 +409,58 @@ def init_waterfall_websocket(app: Flask):
time.sleep(0.5)
# Parse config
center_freq = float(data.get('center_freq', 100.0))
span_mhz = float(data.get('span_mhz', 2.0))
gain = data.get('gain')
if gain is not None:
gain = float(gain)
device_index = int(data.get('device', 0))
sdr_type_str = data.get('sdr_type', 'rtlsdr')
fft_size = int(data.get('fft_size', 1024))
fps = int(data.get('fps', 25))
avg_count = int(data.get('avg_count', 4))
ppm = data.get('ppm')
if ppm is not None:
ppm = int(ppm)
bias_t = bool(data.get('bias_t', False))
try:
center_freq_mhz = _parse_center_freq_mhz(data)
span_mhz = _parse_span_mhz(data)
gain_raw = data.get('gain')
if gain_raw is None or str(gain_raw).lower() == 'auto':
gain = None
else:
gain = float(gain_raw)
device_index = int(data.get('device', 0))
sdr_type_str = data.get('sdr_type', 'rtlsdr')
fft_size = int(data.get('fft_size', 1024))
fps = int(data.get('fps', 25))
avg_count = int(data.get('avg_count', 4))
ppm = data.get('ppm')
if ppm is not None:
ppm = int(ppm)
bias_t = bool(data.get('bias_t', False))
db_min = data.get('db_min')
db_max = data.get('db_max')
if db_min is not None:
db_min = float(db_min)
if db_max is not None:
db_max = float(db_max)
except (TypeError, ValueError) as exc:
ws.send(json.dumps({
'status': 'error',
'message': f'Invalid waterfall configuration: {exc}',
}))
continue
# Clamp FFT size to valid powers of 2
# Clamp and normalize runtime settings
fft_size = max(256, min(8192, fft_size))
fps = max(2, min(60, fps))
avg_count = max(1, min(32, avg_count))
if center_freq_mhz <= 0 or span_mhz <= 0:
ws.send(json.dumps({
'status': 'error',
'message': 'center_freq_mhz and span_mhz must be > 0',
}))
continue
# Resolve SDR type and bandwidth
# Resolve SDR type and choose a valid sample rate
sdr_type = _resolve_sdr_type(sdr_type_str)
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
span_hz = int(span_mhz * 1e6)
sample_rate = min(span_hz, max_bw)
builder = SDRFactory.get_builder(sdr_type)
caps = builder.get_capabilities()
requested_span_hz = max(1000, int(span_mhz * 1e6))
sample_rate = _pick_sample_rate(requested_span_hz, caps, sdr_type)
# Compute effective frequency range
effective_span_mhz = sample_rate / 1e6
start_freq = center_freq - effective_span_mhz / 2
end_freq = center_freq + effective_span_mhz / 2
start_freq = center_freq_mhz - effective_span_mhz / 2
end_freq = center_freq_mhz + effective_span_mhz / 2
# Claim the device
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
@@ -197,11 +475,10 @@ def init_waterfall_websocket(app: Flask):
# Build I/Q capture command
try:
builder = SDRFactory.get_builder(sdr_type)
device = _build_dummy_device(device_index, sdr_type)
iq_cmd = builder.build_iq_capture_command(
device=device,
frequency_mhz=center_freq,
frequency_mhz=center_freq_mhz,
sample_rate=sample_rate,
gain=gain,
ppm=ppm,
@@ -221,7 +498,7 @@ def init_waterfall_websocket(app: Flask):
try:
for attempt in range(max_attempts):
logger.info(
f"Starting I/Q capture: {center_freq} MHz, "
f"Starting I/Q capture: {center_freq_mhz:.6f} MHz, "
f"span={effective_span_mhz:.1f} MHz, "
f"sr={sample_rate}, fft={fft_size}"
)
@@ -263,23 +540,50 @@ def init_waterfall_websocket(app: Flask):
}))
continue
capture_center_mhz = center_freq_mhz
capture_start_freq = start_freq
capture_end_freq = end_freq
capture_span_mhz = effective_span_mhz
_set_shared_capture_state(
running=True,
device=device_index,
center_mhz=center_freq_mhz,
span_mhz=effective_span_mhz,
sample_rate=sample_rate,
)
_set_shared_monitor(
enabled=False,
frequency_mhz=center_freq_mhz,
modulation='wfm',
squelch=0,
)
# Send started confirmation
ws.send(json.dumps({
'status': 'started',
'center_mhz': center_freq_mhz,
'start_freq': start_freq,
'end_freq': end_freq,
'fft_size': fft_size,
'sample_rate': sample_rate,
'effective_span_mhz': effective_span_mhz,
'db_min': db_min,
'db_max': db_max,
'vfo_freq_mhz': center_freq_mhz,
}))
# Start reader thread — puts frames on queue, never calls ws.send()
def fft_reader(
proc, _send_q, stop_evt,
_fft_size, _avg_count, _fps,
_start_freq, _end_freq,
_fft_size, _avg_count, _fps, _sample_rate,
_start_freq, _end_freq, _center_mhz,
_db_min=None, _db_max=None,
):
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
bytes_per_frame = _fft_size * _avg_count * 2
required_fft_samples = _fft_size * _avg_count
timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps)))
bytes_per_frame = timeslice_samples * 2
frame_interval = 1.0 / _fps
try:
@@ -304,21 +608,37 @@ def init_waterfall_websocket(app: Flask):
# Process FFT pipeline
samples = cu8_to_complex(raw)
fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples
power_db = compute_power_spectrum(
samples,
fft_samples,
fft_size=_fft_size,
avg_count=_avg_count,
)
quantized = quantize_to_uint8(power_db)
quantized = quantize_to_uint8(
power_db,
db_min=_db_min,
db_max=_db_max,
)
frame = build_binary_frame(
_start_freq, _end_freq, quantized,
)
try:
# Drop frame if main loop cannot keep up.
with suppress(queue.Full):
_send_q.put_nowait(frame)
except queue.Full:
# Drop frame if main loop can't keep up
pass
monitor_cfg = _snapshot_monitor_config()
if monitor_cfg:
audio_chunk = _demodulate_monitor_audio(
samples=samples,
sample_rate=_sample_rate,
center_mhz=monitor_cfg.get('center_mhz', _center_mhz),
monitor_freq_mhz=monitor_cfg.get('monitor_freq_mhz', _center_mhz),
modulation=monitor_cfg.get('modulation', 'wfm'),
squelch=int(monitor_cfg.get('squelch', 0)),
)
if audio_chunk:
_push_shared_audio_chunk(audio_chunk)
# Pace to target FPS
elapsed = time.monotonic() - frame_start
@@ -333,13 +653,63 @@ def init_waterfall_websocket(app: Flask):
target=fft_reader,
args=(
iq_process, send_queue, stop_event,
fft_size, avg_count, fps,
start_freq, end_freq,
fft_size, avg_count, fps, sample_rate,
start_freq, end_freq, center_freq_mhz,
db_min, db_max,
),
daemon=True,
)
reader_thread.start()
elif cmd in ('tune', 'set_vfo'):
if not iq_process or claimed_device is None or iq_process.poll() is not None:
ws.send(json.dumps({
'status': 'error',
'message': 'Waterfall capture is not running',
}))
continue
try:
shared = get_shared_capture_status()
vfo_freq_mhz = float(
data.get(
'vfo_freq_mhz',
data.get('frequency_mhz', data.get('center_freq_mhz', capture_center_mhz)),
)
)
squelch = int(data.get('squelch', shared.get('monitor_squelch', 0)))
modulation = str(data.get('modulation', shared.get('monitor_modulation', 'wfm')))
except (TypeError, ValueError) as exc:
ws.send(json.dumps({
'status': 'error',
'message': f'Invalid tune request: {exc}',
}))
continue
if not (capture_start_freq <= vfo_freq_mhz <= capture_end_freq):
ws.send(json.dumps({
'status': 'retune_required',
'message': 'Frequency outside current capture span',
'capture_start_freq': capture_start_freq,
'capture_end_freq': capture_end_freq,
'vfo_freq_mhz': vfo_freq_mhz,
}))
continue
monitor_enabled = bool(shared.get('monitor_enabled'))
_set_shared_monitor(
enabled=monitor_enabled,
frequency_mhz=vfo_freq_mhz,
modulation=modulation,
squelch=squelch,
)
ws.send(json.dumps({
'status': 'tuned',
'vfo_freq_mhz': vfo_freq_mhz,
'start_freq': capture_start_freq,
'end_freq': capture_end_freq,
'center_mhz': capture_center_mhz,
}))
elif cmd == 'stop':
stop_event.set()
if reader_thread and reader_thread.is_alive():
@@ -352,6 +722,7 @@ def init_waterfall_websocket(app: Flask):
if claimed_device is not None:
app_module.release_sdr_device(claimed_device)
claimed_device = None
_set_shared_capture_state(running=False)
stop_event.clear()
ws.send(json.dumps({'status': 'stopped'}))
@@ -367,20 +738,15 @@ def init_waterfall_websocket(app: Flask):
unregister_process(iq_process)
if claimed_device is not None:
app_module.release_sdr_device(claimed_device)
_set_shared_capture_state(running=False)
# Complete WebSocket close handshake, then shut down the
# raw socket so Werkzeug cannot write its HTTP 200 response
# on top of the WebSocket stream (which browsers see as
# "Invalid frame header").
try:
with suppress(Exception):
ws.close()
except Exception:
pass
try:
with suppress(Exception):
ws.sock.shutdown(socket.SHUT_RDWR)
except Exception:
pass
try:
with suppress(Exception):
ws.sock.close()
except Exception:
pass
logger.info("WebSocket waterfall client disconnected")

View File

@@ -1802,6 +1802,14 @@ header h1 .tagline {
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
@keyframes stop-btn-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(239,68,68,0); }
50% { opacity: 0.75; box-shadow: 0 0 8px 2px rgba(239,68,68,0.45); }
}
.stop-btn {
animation: stop-btn-pulse 1.2s ease-in-out infinite;
}
.output-panel {
background: var(--bg-primary);
display: flex;

View File

@@ -1,500 +0,0 @@
/* Analytics Dashboard Styles */
/* Analytics is a sidebar-only mode — hide the output panel and expand the sidebar */
@media (min-width: 1024px) {
.main-content.analytics-active {
grid-template-columns: 1fr !important;
}
.main-content.analytics-active > .output-panel {
display: none !important;
}
.main-content.analytics-active > .sidebar {
max-width: 100% !important;
width: 100% !important;
}
.main-content.analytics-active .sidebar-collapse-btn {
display: none !important;
}
}
@media (max-width: 1023px) {
.main-content.analytics-active > .output-panel {
display: none !important;
}
}
.analytics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-3, 12px);
margin-bottom: var(--space-4, 16px);
}
.analytics-insight-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: var(--space-3, 12px);
}
.analytics-insight-card {
background: var(--bg-card, #151f2b);
border: 1px solid var(--border-color, #1e2d3d);
border-radius: var(--radius-md, 8px);
padding: 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.analytics-insight-card.low {
border-color: rgba(90, 106, 122, 0.5);
}
.analytics-insight-card.medium {
border-color: rgba(74, 163, 255, 0.45);
}
.analytics-insight-card.high {
border-color: rgba(214, 168, 94, 0.55);
}
.analytics-insight-card.critical {
border-color: rgba(226, 93, 93, 0.65);
}
.analytics-insight-card .insight-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim, #5a6a7a);
}
.analytics-insight-card .insight-value {
font-size: 16px;
font-weight: 700;
color: var(--text-primary, #e0e6ed);
line-height: 1.2;
}
.analytics-insight-card .insight-label {
font-size: 10px;
color: var(--text-secondary, #9aabba);
}
.analytics-insight-card .insight-detail {
font-size: 10px;
color: var(--text-dim, #5a6a7a);
}
.analytics-top-changes {
margin-top: 12px;
}
.analytics-change-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid var(--border-color, #1e2d3d);
font-size: 10px;
}
.analytics-change-row:last-child {
border-bottom: none;
}
.analytics-change-row .mode {
min-width: 84px;
color: var(--text-primary, #e0e6ed);
font-weight: 600;
}
.analytics-change-row .delta {
min-width: 48px;
font-family: var(--font-mono, monospace);
}
.analytics-change-row .delta.up {
color: var(--accent-green, #38c180);
}
.analytics-change-row .delta.down {
color: var(--accent-red, #e25d5d);
}
.analytics-change-row .delta.flat {
color: var(--text-dim, #5a6a7a);
}
.analytics-change-row .avg {
color: var(--text-dim, #5a6a7a);
}
.analytics-card {
background: var(--bg-card, #151f2b);
border: 1px solid var(--border-color, #1e2d3d);
border-radius: var(--radius-md, 8px);
padding: var(--space-3, 12px);
text-align: center;
transition: var(--transition-fast, 150ms ease);
}
.analytics-card:hover {
border-color: var(--accent-cyan, #4aa3ff);
}
.analytics-card .card-count {
font-size: var(--text-2xl, 24px);
font-weight: 700;
color: var(--text-primary, #e0e6ed);
line-height: 1.2;
}
.analytics-card .card-label {
font-size: var(--text-xs, 10px);
color: var(--text-dim, #5a6a7a);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: var(--space-1, 4px);
}
.analytics-card .card-sparkline {
height: 24px;
margin-top: var(--space-2, 8px);
}
.analytics-card .card-sparkline svg {
width: 100%;
height: 100%;
}
.analytics-card .card-sparkline polyline {
fill: none;
stroke: var(--accent-cyan, #4aa3ff);
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Health indicators */
.analytics-health {
display: flex;
flex-wrap: wrap;
gap: var(--space-2, 8px);
margin-bottom: var(--space-4, 16px);
}
.health-item {
display: flex;
align-items: center;
gap: var(--space-1, 4px);
font-size: var(--text-xs, 10px);
color: var(--text-dim, #5a6a7a);
text-transform: uppercase;
}
.health-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-red, #e25d5d);
}
.health-dot.running {
background: var(--accent-green, #38c180);
}
/* Emergency squawk panel */
.squawk-emergency {
background: rgba(226, 93, 93, 0.1);
border: 1px solid var(--accent-red, #e25d5d);
border-radius: var(--radius-md, 8px);
padding: var(--space-3, 12px);
margin-bottom: var(--space-3, 12px);
}
.squawk-emergency .squawk-title {
color: var(--accent-red, #e25d5d);
font-weight: 700;
font-size: var(--text-sm, 12px);
text-transform: uppercase;
margin-bottom: var(--space-2, 8px);
}
.squawk-emergency .squawk-item {
font-size: var(--text-sm, 12px);
color: var(--text-primary, #e0e6ed);
padding: var(--space-1, 4px) 0;
border-bottom: 1px solid rgba(226, 93, 93, 0.2);
}
.squawk-emergency .squawk-item:last-child {
border-bottom: none;
}
/* Alert feed */
.analytics-alert-feed {
max-height: 200px;
overflow-y: auto;
margin-bottom: var(--space-4, 16px);
}
.analytics-alert-item {
display: flex;
align-items: flex-start;
gap: var(--space-2, 8px);
padding: var(--space-2, 8px);
border-bottom: 1px solid var(--border-color, #1e2d3d);
font-size: var(--text-xs, 10px);
}
.analytics-alert-item .alert-severity {
padding: 1px 6px;
border-radius: var(--radius-sm, 4px);
font-weight: 600;
text-transform: uppercase;
font-size: 9px;
white-space: nowrap;
}
.alert-severity.critical { background: var(--accent-red, #e25d5d); color: #fff; }
.alert-severity.high { background: var(--accent-orange, #d6a85e); color: #000; }
.alert-severity.medium { background: var(--accent-cyan, #4aa3ff); color: #fff; }
.alert-severity.low { background: var(--border-color, #1e2d3d); color: var(--text-dim, #5a6a7a); }
/* Correlation panel */
.analytics-correlation-pair {
display: flex;
align-items: center;
gap: var(--space-2, 8px);
padding: var(--space-2, 8px);
border-bottom: 1px solid var(--border-color, #1e2d3d);
font-size: var(--text-xs, 10px);
}
.analytics-correlation-pair .confidence-bar {
height: 4px;
background: var(--bg-secondary, #101823);
border-radius: 2px;
flex: 1;
max-width: 60px;
}
.analytics-correlation-pair .confidence-fill {
height: 100%;
background: var(--accent-green, #38c180);
border-radius: 2px;
}
.analytics-pattern-item {
padding: 8px;
border-bottom: 1px solid var(--border-color, #1e2d3d);
display: flex;
flex-direction: column;
gap: 4px;
}
.analytics-pattern-item:last-child {
border-bottom: none;
}
.analytics-pattern-item .pattern-main {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.analytics-pattern-item .pattern-mode {
font-size: 10px;
font-weight: 600;
color: var(--text-primary, #e0e6ed);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.analytics-pattern-item .pattern-device {
font-size: 10px;
color: var(--text-dim, #5a6a7a);
font-family: var(--font-mono, monospace);
}
.analytics-pattern-item .pattern-meta {
display: flex;
gap: 10px;
font-size: 10px;
color: var(--text-dim, #5a6a7a);
flex-wrap: wrap;
}
.analytics-pattern-item .pattern-confidence {
color: var(--accent-green, #38c180);
font-weight: 600;
}
/* Geofence zone list */
.geofence-zone-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2, 8px);
border-bottom: 1px solid var(--border-color, #1e2d3d);
font-size: var(--text-xs, 10px);
}
.geofence-zone-item .zone-name {
font-weight: 600;
color: var(--text-primary, #e0e6ed);
}
.geofence-zone-item .zone-radius {
color: var(--text-dim, #5a6a7a);
}
.geofence-zone-item .zone-delete {
cursor: pointer;
color: var(--accent-red, #e25d5d);
padding: 2px 6px;
border: 1px solid var(--accent-red, #e25d5d);
border-radius: var(--radius-sm, 4px);
background: transparent;
font-size: 9px;
}
/* Export controls */
.export-controls {
display: flex;
gap: var(--space-2, 8px);
align-items: center;
flex-wrap: wrap;
}
.export-controls select,
.export-controls button {
font-size: var(--text-xs, 10px);
padding: var(--space-1, 4px) var(--space-2, 8px);
background: var(--bg-card, #151f2b);
color: var(--text-primary, #e0e6ed);
border: 1px solid var(--border-color, #1e2d3d);
border-radius: var(--radius-sm, 4px);
}
.export-controls button {
cursor: pointer;
background: var(--accent-cyan, #4aa3ff);
color: #fff;
border-color: var(--accent-cyan, #4aa3ff);
}
.export-controls button:hover {
opacity: 0.9;
}
/* Section headers */
.analytics-section-header {
font-size: var(--text-xs, 10px);
font-weight: 600;
color: var(--text-dim, #5a6a7a);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-2, 8px);
padding-bottom: var(--space-1, 4px);
border-bottom: 1px solid var(--border-color, #1e2d3d);
}
/* Empty state */
.analytics-empty {
text-align: center;
color: var(--text-dim, #5a6a7a);
font-size: var(--text-xs, 10px);
padding: var(--space-4, 16px);
font-style: italic;
}
.analytics-target-toolbar,
.analytics-replay-toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 10px;
}
.analytics-target-toolbar input {
flex: 1;
min-width: 220px;
background: var(--bg-card, #151f2b);
color: var(--text-primary, #e0e6ed);
border: 1px solid var(--border-color, #1e2d3d);
border-radius: 4px;
padding: 6px 8px;
font-size: 11px;
}
.analytics-target-toolbar button,
.analytics-replay-toolbar button,
.analytics-replay-toolbar select {
font-size: 10px;
padding: 5px 9px;
border-radius: 4px;
border: 1px solid var(--border-color, #1e2d3d);
background: var(--bg-card, #151f2b);
color: var(--text-primary, #e0e6ed);
}
.analytics-target-toolbar button,
.analytics-replay-toolbar button {
cursor: pointer;
background: rgba(74, 163, 255, 0.2);
border-color: rgba(74, 163, 255, 0.45);
}
.analytics-target-summary {
font-size: 10px;
color: var(--text-dim, #5a6a7a);
margin-bottom: 8px;
}
.analytics-target-item,
.analytics-replay-item {
border-bottom: 1px solid var(--border-color, #1e2d3d);
padding: 7px 0;
display: grid;
gap: 4px;
}
.analytics-target-item:last-child,
.analytics-replay-item:last-child {
border-bottom: none;
}
.analytics-target-item .title,
.analytics-replay-item .title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 11px;
color: var(--text-primary, #e0e6ed);
font-weight: 600;
}
.analytics-target-item .mode,
.analytics-replay-item .mode {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
border: 1px solid rgba(74, 163, 255, 0.35);
color: var(--accent-cyan, #4aa3ff);
border-radius: 4px;
padding: 1px 6px;
}
.analytics-target-item .meta,
.analytics-replay-item .meta {
font-size: 10px;
color: var(--text-dim, #5a6a7a);
display: flex;
gap: 10px;
flex-wrap: wrap;
}

View File

@@ -266,7 +266,9 @@
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
padding: 8px;
}
@@ -280,8 +282,8 @@
}
#btLocateMap {
width: 100%;
height: 100%;
position: absolute;
inset: 0;
background: #1a1a2e;
}

View File

@@ -0,0 +1,78 @@
/* Signal Fingerprinting Mode Styles */
.fp-tab-btn {
flex: 1;
padding: 5px 10px;
font-family: var(--font-mono, monospace);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 4px;
color: var(--text-secondary, #aaa);
cursor: pointer;
transition: all 0.15s;
}
.fp-tab-btn.active {
background: rgba(74,163,255,0.15);
border-color: var(--accent-cyan, #4aa3ff);
color: var(--accent-cyan, #4aa3ff);
}
.fp-anomaly-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
border-radius: 4px;
border: 1px solid rgba(255,255,255,0.08);
margin-bottom: 4px;
font-family: var(--font-mono, monospace);
font-size: 10px;
}
.fp-anomaly-item.severity-alert {
background: rgba(239,68,68,0.12);
border-color: rgba(239,68,68,0.4);
}
.fp-anomaly-item.severity-warn {
background: rgba(251,191,36,0.1);
border-color: rgba(251,191,36,0.4);
}
.fp-anomaly-item.severity-new {
background: rgba(168,85,247,0.12);
border-color: rgba(168,85,247,0.4);
}
.fp-anomaly-band {
font-weight: 700;
color: var(--text-primary, #fff);
font-size: 12px;
}
.fp-anomaly-type-badge {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 1px 5px;
border-radius: 3px;
display: inline-block;
}
.fp-chart-container {
flex: 1;
min-height: 0;
padding: 10px;
overflow: hidden;
}
#fpChartCanvas {
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,44 @@
/* RF Heatmap Mode Styles */
.rfhm-map-container {
flex: 1;
min-height: 0;
position: relative;
}
#rfheatmapMapEl {
width: 100%;
height: 100%;
}
.rfhm-overlay {
position: absolute;
top: 8px;
right: 8px;
z-index: 450;
display: flex;
flex-direction: column;
gap: 4px;
pointer-events: none;
}
.rfhm-stat-chip {
background: rgba(0,0,0,0.75);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
padding: 4px 8px;
font-family: var(--font-mono, monospace);
font-size: 10px;
color: var(--accent-cyan, #4aa3ff);
pointer-events: none;
backdrop-filter: blur(4px);
}
.rfhm-recording-pulse {
animation: rfhm-rec 1s ease-in-out infinite;
}
@keyframes rfhm-rec {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}

View File

@@ -0,0 +1,666 @@
/* Spectrum Waterfall Mode Styles */
.wf-container {
--wf-border: rgba(92, 153, 255, 0.24);
--wf-surface: linear-gradient(180deg, rgba(12, 19, 31, 0.97) 0%, rgba(5, 9, 17, 0.98) 100%);
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
background: radial-gradient(circle at 14% -18%, rgba(36, 129, 255, 0.2) 0%, rgba(36, 129, 255, 0) 38%),
radial-gradient(circle at 86% -26%, rgba(255, 161, 54, 0.2) 0%, rgba(255, 161, 54, 0) 36%),
#03070f;
border: 1px solid var(--wf-border);
border-radius: 10px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03), 0 10px 34px rgba(2, 8, 22, 0.55);
position: relative;
}
.wf-headline {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: rgba(8, 14, 25, 0.86);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.wf-headline-left,
.wf-headline-right {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.wf-headline-tag {
border-radius: 999px;
padding: 1px 8px;
border: 1px solid rgba(74, 163, 255, 0.45);
background: rgba(74, 163, 255, 0.13);
color: #8ec5ff;
font-size: 10px;
font-family: var(--font-mono, monospace);
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
}
.wf-headline-sub {
font-size: 11px;
color: var(--text-dim);
font-family: var(--font-mono, monospace);
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.wf-range-text,
.wf-tune-text {
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
}
.wf-tune-text {
color: #ffd782;
}
.wf-monitor-strip {
display: grid;
grid-template-columns: minmax(240px, 1.5fr) minmax(220px, 1fr) minmax(230px, 1.2fr) minmax(130px, 0.7fr) minmax(220px, 1fr);
gap: 10px;
align-items: stretch;
padding: 8px 12px;
background: var(--wf-surface);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.wf-rx-vfo {
border: 1px solid rgba(102, 171, 255, 0.27);
border-radius: 8px;
background: linear-gradient(180deg, rgba(8, 16, 31, 0.92) 0%, rgba(4, 9, 18, 0.95) 100%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03);
padding: 7px 10px;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 72px;
}
.wf-rx-vfo-top,
.wf-rx-vfo-bottom {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.wf-rx-vfo-name {
font-family: var(--font-mono, monospace);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
}
.wf-rx-vfo-status {
font-family: var(--font-mono, monospace);
font-size: 10px;
color: #a6cbff;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.wf-rx-vfo-readout {
display: flex;
align-items: baseline;
gap: 7px;
font-family: var(--font-mono, monospace);
color: #7bc4ff;
line-height: 1;
}
#wfRxFreqReadout {
font-size: 32px;
letter-spacing: 0.03em;
font-variant-numeric: tabular-nums;
text-shadow: 0 0 16px rgba(44, 153, 255, 0.28);
}
.wf-rx-vfo-unit {
font-size: 13px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.wf-rx-vfo-bottom {
font-family: var(--font-mono, monospace);
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.wf-rx-modebank {
border: 1px solid rgba(92, 153, 255, 0.24);
border-radius: 8px;
padding: 8px;
background: rgba(4, 10, 20, 0.86);
display: grid;
grid-template-columns: repeat(5, minmax(42px, 1fr));
gap: 6px;
align-content: center;
}
.wf-mode-btn {
border: 1px solid rgba(118, 176, 255, 0.26);
border-radius: 6px;
background: linear-gradient(180deg, rgba(20, 37, 66, 0.95) 0%, rgba(13, 26, 49, 0.95) 100%);
color: #d1e5ff;
font-family: var(--font-mono, monospace);
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
height: 32px;
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
}
.wf-mode-btn:hover {
transform: translateY(-1px);
border-color: rgba(143, 196, 255, 0.52);
}
.wf-mode-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.wf-mode-btn.is-active,
.wf-mode-btn.active {
border-color: rgba(97, 198, 255, 0.62);
background: linear-gradient(180deg, rgba(23, 85, 146, 0.92) 0%, rgba(18, 57, 104, 0.95) 100%);
color: #f3fbff;
box-shadow: 0 0 14px rgba(53, 152, 255, 0.28);
}
.wf-monitor-select-hidden {
display: none;
}
.wf-rx-levels {
border: 1px solid rgba(92, 153, 255, 0.22);
border-radius: 8px;
background: rgba(4, 10, 20, 0.85);
padding: 7px 10px;
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.wf-monitor-group {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.wf-monitor-label {
color: var(--text-muted);
font-family: var(--font-mono, monospace);
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.wf-monitor-select {
width: 100%;
height: 30px;
border-radius: 6px;
border: 1px solid rgba(92, 153, 255, 0.28);
background: rgba(4, 8, 16, 0.8);
color: var(--text-primary);
font-family: var(--font-mono, monospace);
font-size: 12px;
padding: 0 8px;
}
.wf-monitor-slider-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.wf-monitor-slider-wrap input[type="range"] {
flex: 1;
accent-color: var(--accent-cyan);
}
.wf-monitor-value {
min-width: 28px;
text-align: right;
color: var(--text-secondary);
font-family: var(--font-mono, monospace);
font-size: 11px;
}
.wf-rx-meter-wrap {
border: 1px solid rgba(92, 153, 255, 0.22);
border-radius: 8px;
background: rgba(4, 10, 20, 0.85);
padding: 7px 10px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
}
.wf-rx-smeter {
position: relative;
width: 100%;
height: 12px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(18, 44, 22, 0.95) 0%, rgba(46, 67, 20, 0.95) 55%, rgba(78, 28, 24, 0.95) 100%);
border: 1px solid rgba(255, 255, 255, 0.09);
overflow: hidden;
}
.wf-rx-smeter-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0%;
background: linear-gradient(90deg, rgba(86, 243, 146, 0.75) 0%, rgba(255, 208, 94, 0.78) 64%, rgba(255, 118, 118, 0.82) 100%);
box-shadow: 0 0 10px rgba(97, 229, 255, 0.35);
transition: width 90ms linear;
}
.wf-rx-smeter-text {
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--text-secondary);
}
.wf-rx-actions {
border: 1px solid rgba(92, 153, 255, 0.22);
border-radius: 8px;
background: rgba(4, 10, 20, 0.85);
padding: 7px 10px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
}
.wf-rx-action-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.wf-monitor-btn {
height: 32px;
min-width: 90px;
border-radius: 6px;
border: 1px solid rgba(86, 195, 124, 0.5);
background: linear-gradient(180deg, rgba(33, 125, 67, 0.95) 0%, rgba(21, 88, 47, 0.95) 100%);
color: #d2ffe2;
font-family: var(--font-mono, monospace);
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
transition: filter 140ms ease, transform 140ms ease;
}
.wf-monitor-btn:hover {
filter: brightness(1.07);
transform: translateY(-1px);
}
.wf-monitor-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
filter: saturate(0.6);
transform: none;
}
.wf-monitor-btn-secondary {
border-color: rgba(92, 153, 255, 0.5);
background: linear-gradient(180deg, rgba(34, 66, 121, 0.95) 0%, rgba(19, 41, 84, 0.95) 100%);
color: #d4e7ff;
}
.wf-monitor-btn-unlock {
border-color: rgba(214, 168, 94, 0.55);
background: linear-gradient(180deg, rgba(134, 93, 31, 0.95) 0%, rgba(98, 65, 19, 0.95) 100%);
color: #ffe8bd;
}
.wf-monitor-btn.is-active {
border-color: rgba(255, 129, 129, 0.55);
background: linear-gradient(180deg, rgba(127, 36, 48, 0.95) 0%, rgba(84, 21, 31, 0.95) 100%);
color: #ffd9de;
}
.wf-monitor-state {
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--text-secondary);
line-height: 1.35;
}
#wfAudioPlayer {
display: none;
}
/* Frequency control bar */
.wf-freq-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: rgba(8, 13, 24, 0.78);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
min-height: 38px;
user-select: none;
}
.wf-freq-bar-label {
font-family: var(--font-mono, monospace);
font-size: 9px;
color: var(--text-muted, #555);
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
}
.wf-step-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--accent-cyan, #4aa3ff);
font-size: 14px;
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
line-height: 1;
flex-shrink: 0;
transition: background 0.1s, border-color 0.1s;
}
.wf-step-btn:hover {
background: rgba(74, 163, 255, 0.17);
border-color: rgba(74, 163, 255, 0.45);
}
.wf-step-btn:active {
background: rgba(74, 163, 255, 0.28);
}
.wf-freq-display-wrap {
display: flex;
align-items: center;
gap: 5px;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(74, 163, 255, 0.28);
border-radius: 5px;
padding: 3px 8px;
flex-shrink: 0;
}
.wf-freq-center-input {
background: transparent;
border: none;
outline: none;
color: var(--accent-cyan, #4aa3ff);
font-family: var(--font-mono, monospace);
font-size: 17px;
font-weight: 700;
width: 110px;
text-align: right;
padding: 0;
cursor: text;
letter-spacing: 0.02em;
}
.wf-freq-center-input:focus {
color: #fff;
}
.wf-freq-bar-unit {
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--text-dim, #555);
letter-spacing: 0.05em;
}
.wf-step-select {
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.14);
color: var(--text-secondary, #aaa);
font-family: var(--font-mono, monospace);
font-size: 11px;
border-radius: 4px;
padding: 2px 4px;
height: 26px;
cursor: pointer;
flex-shrink: 0;
}
.wf-freq-bar-sep {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.09);
margin: 0 2px;
flex-shrink: 0;
}
.wf-span-display {
font-family: var(--font-mono, monospace);
font-size: 12px;
color: var(--text-secondary, #888);
min-width: 60px;
white-space: nowrap;
}
/* Spectrum canvas */
.wf-spectrum-canvas-wrap {
height: 108px;
flex-shrink: 0;
position: relative;
border-bottom: 1px solid rgba(255, 255, 255, 0.09);
background: radial-gradient(circle at 50% -120%, rgba(84, 140, 237, 0.18) 0%, rgba(84, 140, 237, 0) 65%);
}
#wfSpectrumCanvas {
width: 100%;
height: 100%;
display: block;
}
/* Resize handle */
.wf-resize-handle {
height: 7px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.03);
cursor: ns-resize;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
position: relative;
z-index: 10;
}
.wf-resize-handle:hover,
.wf-resize-handle.dragging {
background: rgba(74, 163, 255, 0.14);
}
.wf-resize-grip {
width: 40px;
height: 2px;
background: rgba(255, 255, 255, 0.2);
border-radius: 1px;
transition: background 0.15s;
}
.wf-resize-handle:hover .wf-resize-grip,
.wf-resize-handle.dragging .wf-resize-grip {
background: rgba(74, 163, 255, 0.6);
}
/* Waterfall canvas */
.wf-waterfall-canvas-wrap {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
background-size: 44px 100%;
}
#wfWaterfallCanvas {
width: 100%;
height: 100%;
display: block;
}
/* Center/tune lines */
.wf-center-line,
.wf-tune-line {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
pointer-events: none;
z-index: 5;
}
.wf-center-line {
left: calc(50% - 0.5px);
background: rgba(255, 215, 0, 0.38);
}
.wf-tune-line {
left: calc(50% - 0.5px);
background: rgba(130, 220, 255, 0.75);
box-shadow: 0 0 8px rgba(74, 163, 255, 0.4);
opacity: 0;
transition: opacity 140ms ease;
}
.wf-tune-line.is-visible {
opacity: 1;
}
/* Frequency axis */
.wf-freq-axis {
height: 21px;
flex-shrink: 0;
position: relative;
background: rgba(8, 13, 24, 0.86);
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.wf-freq-tick {
position: absolute;
top: 0;
font-family: var(--font-mono, monospace);
font-size: 9px;
color: var(--text-dim, #555);
transform: translateX(-50%);
white-space: nowrap;
padding-top: 3px;
}
.wf-freq-tick::before {
content: '';
position: absolute;
top: 0;
left: 50%;
width: 1px;
height: 3px;
background: rgba(255, 255, 255, 0.2);
}
/* Hover tooltip */
.wf-tooltip {
position: absolute;
top: 4px;
background: rgba(0, 0, 0, 0.84);
color: var(--accent-cyan, #4aa3ff);
font-family: var(--font-mono, monospace);
font-size: 11px;
padding: 2px 7px;
border-radius: 4px;
pointer-events: none;
display: none;
z-index: 10;
white-space: nowrap;
border: 1px solid rgba(74, 163, 255, 0.22);
}
@media (max-width: 1100px) {
.wf-monitor-strip {
grid-template-columns: repeat(2, minmax(220px, 1fr));
grid-auto-rows: minmax(70px, auto);
}
.wf-rx-actions {
grid-column: span 2;
}
.wf-rx-action-row {
justify-content: flex-start;
}
}
@media (max-width: 720px) {
.wf-headline {
flex-direction: column;
align-items: flex-start;
}
.wf-headline-right {
flex-wrap: wrap;
}
.wf-monitor-strip {
grid-template-columns: 1fr;
}
.wf-rx-actions {
grid-column: auto;
}
.wf-freq-bar {
flex-wrap: wrap;
row-gap: 8px;
}
.wf-freq-center-input {
width: 96px;
}
}

21
static/icons/icon.svg Normal file
View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#0b1118" rx="80"/>
<!-- Signal wave arcs radiating from center-left -->
<g fill="none" stroke="#4aa3ff" stroke-linecap="round">
<!-- Inner arc -->
<path stroke-width="22" d="M 160 256 Q 192 210 192 256 Q 192 302 160 256" opacity="0.5"/>
<!-- Small arc -->
<path stroke-width="22" d="M 130 256 Q 180 185 180 256 Q 180 327 130 256" opacity="0.65"/>
<!-- Medium arc -->
<path stroke-width="24" d="M 100 256 Q 175 155 175 256 Q 175 357 100 256" opacity="0.8"/>
<!-- Large arc -->
<path stroke-width="26" d="M 68 256 Q 170 120 170 256 Q 170 392 68 256" opacity="0.95"/>
</g>
<!-- Horizontal beam line -->
<line x1="190" y1="256" x2="420" y2="256" stroke="#4aa3ff" stroke-width="20" stroke-linecap="round"/>
<!-- Signal dot at origin -->
<circle cx="190" cy="256" r="18" fill="#4aa3ff"/>
<!-- Target reticle at end -->
<circle cx="420" cy="256" r="28" fill="none" stroke="#4aa3ff" stroke-width="14"/>
<circle cx="420" cy="256" r="8" fill="#4aa3ff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,77 @@
/* INTERCEPT Per-Mode Cheat Sheets */
const CheatSheets = (function () {
'use strict';
const CONTENT = {
pager: { title: 'Pager Decoder', icon: '📟', hardware: 'RTL-SDR dongle', description: 'Decodes POCSAG and FLEX pager protocols via rtl_fm + multimon-ng.', whatToExpect: 'Numeric and alphanumeric pager messages with address codes.', tips: ['Try frequencies 152.240, 157.450, 462.9625 MHz', 'Gain 3845 dB works well for most dongles', 'POCSAG 512/1200/2400 baud are common'] },
sensor: { title: '433MHz Sensors', icon: '🌡️', hardware: 'RTL-SDR dongle', description: 'Decodes 433MHz IoT sensors via rtl_433.', whatToExpect: 'JSON events from weather stations, door sensors, car key fobs.', tips: ['Leave gain on AUTO', 'Walk around to discover hidden sensors', 'Protocol filter narrows false positives'] },
wifi: { title: 'WiFi Scanner', icon: '📡', hardware: 'WiFi adapter (monitor mode)', description: 'Scans WiFi networks and clients via airodump-ng or nmcli.', whatToExpect: 'SSIDs, BSSIDs, channel, signal strength, encryption type.', tips: ['Run airmon-ng check kill before monitoring', 'Proximity radar shows signal strength', 'TSCM baseline detects rogue APs'] },
bluetooth: { title: 'Bluetooth Scanner', icon: '🔵', hardware: 'Built-in or USB Bluetooth adapter', description: 'Scans BLE and classic Bluetooth devices. Identifies trackers.', whatToExpect: 'Device names, MACs, RSSI, manufacturer, tracker type.', tips: ['Proximity radar shows device distance', 'Known tracker DB has 47K+ fingerprints', 'Use BT Locate to physically find a tracker'] },
bt_locate: { title: 'BT Locate (SAR)', icon: '🎯', hardware: 'Bluetooth adapter + optional GPS', description: 'SAR Bluetooth locator. Tracks RSSI over time to triangulate position.', whatToExpect: 'RSSI chart, proximity band (IMMEDIATE/NEAR/FAR), GPS trail.', tips: ['Handoff from Bluetooth mode to lock onto a device', 'Indoor n=3.0 gives better distance estimates', 'Follow the heat trail toward stronger signal'] },
meshtastic: { title: 'Meshtastic', icon: '🕸️', hardware: 'Meshtastic LoRa node (USB)', description: 'Monitors Meshtastic LoRa mesh network messages and positions.', whatToExpect: 'Text messages, node map, telemetry.', tips: ['Default channel must match your mesh', 'Long-Fast has best range', 'GPS nodes appear on map automatically'] },
adsb: { title: 'ADS-B Aircraft', icon: '✈️', hardware: 'RTL-SDR + 1090MHz antenna', description: 'Tracks aircraft via ADS-B Mode S transponders using dump1090.', whatToExpect: 'Flight numbers, positions, altitude, speed, squawk codes.', tips: ['1090MHz — use a dedicated antenna', 'Emergency squawks: 7500 hijack, 7600 radio fail, 7700 emergency', 'Full Dashboard shows map view'] },
ais: { title: 'AIS Vessels', icon: '🚢', hardware: 'RTL-SDR + VHF antenna (162 MHz)', description: 'Tracks marine vessels via AIS using AIS-catcher.', whatToExpect: 'MMSI, vessel names, positions, speed, heading, cargo type.', tips: ['VHF antenna centered at 162MHz works best', 'DSC distress alerts appear in red', 'Coastline range ~40 nautical miles'] },
aprs: { title: 'APRS', icon: '📻', hardware: 'RTL-SDR + VHF + direwolf', description: 'Decodes APRS amateur packet radio via direwolf TNC modem.', whatToExpect: 'Station positions, weather reports, messages, telemetry.', tips: ['Primary APRS frequency: 144.390 MHz (North America)', 'direwolf must be running', 'Positions appear on the map'] },
satellite: { title: 'Satellite Tracker', icon: '🛰️', hardware: 'None (pass prediction only)', description: 'Predicts satellite pass times using TLE data from CelesTrak.', whatToExpect: 'Pass windows with AOS/LOS times, max elevation, bearing.', tips: ['Set observer location in Settings', 'Plan ISS SSTV using pass times', 'TLEs auto-update every 24 hours'] },
sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] },
weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] },
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] },
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['GPS feeds into RF Heatmap', 'BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction'] },
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
listening: { title: 'Listening Post', icon: '🎧', hardware: 'RTL-SDR dongle', description: 'Wideband scanner and audio receiver for AM/FM/USB/LSB/CW.', whatToExpect: 'Audio from any frequency, spectrum waterfall, squelch.', tips: ['VHF air band: 118136 MHz AM', 'Marine VHF: 156174 MHz FM', 'HF requires upconverter or direct-sampling SDR'] },
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Listening Post to tune directly', 'STANAG and HF mil signals are common'] },
websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] },
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },
rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] },
waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] },
rfheatmap: { title: 'RF Heatmap', icon: '🗺️', hardware: 'GPS receiver + WiFi/BT/SDR', description: 'GPS-tagged signal strength heatmap. Walk to build coverage maps.', whatToExpect: 'Leaflet map with heat overlay showing signal by location.', tips: ['Connect GPS first, wait for fix', 'Set min sample distance to avoid duplicates', 'Export GeoJSON for use in QGIS'] },
fingerprint: { title: 'RF Fingerprinting', icon: '🔬', hardware: 'RTL-SDR + Listening Post scanner', description: 'Records RF baselines and detects anomalies via statistical comparison.', whatToExpect: 'Band-by-band power comparison, z-score anomaly detection.', tips: ['Take baseline in a clean RF environment', 'Z-score ≥3 = statistically significant anomaly', 'New bands highlighted in purple'] },
};
function show(mode) {
const data = CONTENT[mode];
const modal = document.getElementById('cheatSheetModal');
const content = document.getElementById('cheatSheetContent');
if (!modal || !content) return;
if (!data) {
content.innerHTML = `<p style="color:var(--text-dim); font-family:var(--font-mono);">No cheat sheet for: ${mode}</p>`;
} else {
content.innerHTML = `
<div style="font-family:var(--font-mono, monospace);">
<div style="font-size:24px; margin-bottom:4px;">${data.icon}</div>
<h2 style="margin:0 0 8px; font-size:16px; color:var(--accent-cyan, #4aa3ff);">${data.title}</h2>
<div style="font-size:11px; color:var(--text-dim); margin-bottom:12px; border-bottom:1px solid rgba(255,255,255,0.08); padding-bottom:8px;">
Hardware: <span style="color:var(--text-secondary);">${data.hardware}</span>
</div>
<p style="font-size:12px; color:var(--text-secondary); margin:0 0 12px;">${data.description}</p>
<div style="margin-bottom:12px;">
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:4px;">What to expect</div>
<p style="font-size:12px; color:var(--text-secondary); margin:0;">${data.whatToExpect}</p>
</div>
<div>
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:6px;">Tips</div>
<ul style="margin:0; padding-left:16px; display:flex; flex-direction:column; gap:4px;">
${data.tips.map(t => `<li style="font-size:11px; color:var(--text-secondary);">${t}</li>`).join('')}
</ul>
</div>
</div>`;
}
modal.style.display = 'flex';
}
function hide() {
const modal = document.getElementById('cheatSheetModal');
if (modal) modal.style.display = 'none';
}
function showForCurrentMode() {
const mode = document.body.getAttribute('data-mode');
if (mode) show(mode);
}
return { show, hide, showForCurrentMode };
})();
window.CheatSheets = CheatSheets;

View File

@@ -25,7 +25,6 @@ const CommandPalette = (function() {
{ mode: 'gps', label: 'GPS' },
{ mode: 'meshtastic', label: 'Meshtastic' },
{ mode: 'websdr', label: 'WebSDR' },
{ mode: 'analytics', label: 'Analytics' },
{ mode: 'spaceweather', label: 'Space Weather' },
];

View File

@@ -139,7 +139,6 @@ const FirstRunSetup = (function() {
['sstv', 'ISS SSTV'],
['weathersat', 'Weather Sat'],
['sstv_general', 'HF SSTV'],
['analytics', 'Analytics'],
];
for (const [value, label] of modes) {
const opt = document.createElement('option');

View File

@@ -0,0 +1,74 @@
/* INTERCEPT Keyboard Shortcuts — global hotkey handler + help modal */
const KeyboardShortcuts = (function () {
'use strict';
const GUARD_SELECTOR = 'input, textarea, select, [contenteditable], .CodeMirror *';
let _handler = null;
function _handle(e) {
if (e.target.matches(GUARD_SELECTOR)) return;
if (e.altKey) {
switch (e.key.toLowerCase()) {
case 'w': e.preventDefault(); window.switchMode && switchMode('waterfall'); break;
case 'h': e.preventDefault(); window.switchMode && switchMode('rfheatmap'); break;
case 'n': e.preventDefault(); window.switchMode && switchMode('fingerprint'); break;
case 'm': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break;
case 's': e.preventDefault(); _toggleSidebar(); break;
case 'k': e.preventDefault(); showHelp(); break;
case 'c': e.preventDefault(); window.CheatSheets && CheatSheets.showForCurrentMode(); break;
default:
if (e.key >= '1' && e.key <= '9') {
e.preventDefault();
_switchToNthMode(parseInt(e.key) - 1);
}
}
} else if (!e.ctrlKey && !e.metaKey) {
if (e.key === '?') { showHelp(); }
if (e.key === 'Escape') {
const kbModal = document.getElementById('kbShortcutsModal');
if (kbModal && kbModal.style.display !== 'none') { hideHelp(); return; }
const csModal = document.getElementById('cheatSheetModal');
if (csModal && csModal.style.display !== 'none') {
window.CheatSheets && CheatSheets.hide(); return;
}
}
}
}
function _toggleSidebar() {
const mc = document.querySelector('.main-content');
if (mc) mc.classList.toggle('sidebar-collapsed');
}
function _switchToNthMode(n) {
if (!window.interceptModeCatalog) return;
const mode = document.body.getAttribute('data-mode');
if (!mode) return;
const catalog = window.interceptModeCatalog;
const entry = catalog[mode];
if (!entry) return;
const groupModes = Object.keys(catalog).filter(k => catalog[k].group === entry.group);
if (groupModes[n]) window.switchMode && switchMode(groupModes[n]);
}
function showHelp() {
const modal = document.getElementById('kbShortcutsModal');
if (modal) modal.style.display = 'flex';
}
function hideHelp() {
const modal = document.getElementById('kbShortcutsModal');
if (modal) modal.style.display = 'none';
}
function init() {
if (_handler) document.removeEventListener('keydown', _handler);
_handler = _handle;
document.addEventListener('keydown', _handler);
}
return { init, showHelp, hideHelp };
})();
window.KeyboardShortcuts = KeyboardShortcuts;

View File

@@ -114,13 +114,7 @@ const RecordingUI = (function() {
function openReplay(sessionId) {
if (!sessionId) return;
localStorage.setItem('analyticsReplaySession', sessionId);
if (typeof hideSettings === 'function') hideSettings();
if (typeof switchMode === 'function') {
switchMode('analytics', { updateUrl: true });
return;
}
window.location.href = '/?mode=analytics';
window.open(`/recordings/${sessionId}/download`, '_blank');
}
function escapeHtml(str) {

View File

@@ -1265,6 +1265,7 @@ function switchSettingsTab(tabName) {
} else if (tabName === 'location') {
loadObserverLocation();
} else if (tabName === 'alerts') {
loadVoiceAlertConfig();
if (typeof AlertCenter !== 'undefined') {
AlertCenter.loadFeed();
}
@@ -1277,6 +1278,61 @@ function switchSettingsTab(tabName) {
}
}
/**
* Load voice alert configuration into Settings > Alerts tab
*/
function loadVoiceAlertConfig() {
if (typeof VoiceAlerts === 'undefined') return;
const cfg = VoiceAlerts.getConfig();
const pager = document.getElementById('voiceCfgPager');
const tscm = document.getElementById('voiceCfgTscm');
const tracker = document.getElementById('voiceCfgTracker');
const squawk = document.getElementById('voiceCfgSquawk');
const rate = document.getElementById('voiceCfgRate');
const pitch = document.getElementById('voiceCfgPitch');
const rateVal = document.getElementById('voiceCfgRateVal');
const pitchVal = document.getElementById('voiceCfgPitchVal');
if (pager) pager.checked = cfg.streams.pager !== false;
if (tscm) tscm.checked = cfg.streams.tscm !== false;
if (tracker) tracker.checked = cfg.streams.bluetooth !== false;
if (squawk) squawk.checked = cfg.streams.squawks !== false;
if (rate) rate.value = cfg.rate;
if (pitch) pitch.value = cfg.pitch;
if (rateVal) rateVal.textContent = cfg.rate;
if (pitchVal) pitchVal.textContent = cfg.pitch;
// Populate voice dropdown
VoiceAlerts.getAvailableVoices().then(function (voices) {
var sel = document.getElementById('voiceCfgVoice');
if (!sel) return;
sel.innerHTML = '<option value="">Default</option>' +
voices.filter(function (v) { return v.lang.startsWith('en'); }).map(function (v) {
return '<option value="' + v.name + '"' + (v.name === cfg.voiceName ? ' selected' : '') + '>' + v.name + '</option>';
}).join('');
});
}
function saveVoiceAlertConfig() {
if (typeof VoiceAlerts === 'undefined') return;
VoiceAlerts.setConfig({
rate: parseFloat(document.getElementById('voiceCfgRate')?.value) || 1.1,
pitch: parseFloat(document.getElementById('voiceCfgPitch')?.value) || 0.9,
voiceName: document.getElementById('voiceCfgVoice')?.value || '',
streams: {
pager: !!document.getElementById('voiceCfgPager')?.checked,
tscm: !!document.getElementById('voiceCfgTscm')?.checked,
bluetooth: !!document.getElementById('voiceCfgTracker')?.checked,
squawks: !!document.getElementById('voiceCfgSquawk')?.checked,
},
});
}
function testVoiceAlert() {
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.testVoice();
}
/**
* Load API key status into the API Keys settings tab
*/

View File

@@ -0,0 +1,200 @@
/* INTERCEPT Voice Alerts — Web Speech API queue with priority system */
const VoiceAlerts = (function () {
'use strict';
const PRIORITY = { LOW: 0, MEDIUM: 1, HIGH: 2 };
let _enabled = true;
let _muted = false;
let _queue = [];
let _speaking = false;
let _sources = {};
const STORAGE_KEY = 'intercept-voice-muted';
const CONFIG_KEY = 'intercept-voice-config';
// Default config
let _config = {
rate: 1.1,
pitch: 0.9,
voiceName: '',
streams: { pager: true, tscm: true, bluetooth: true },
};
function _loadConfig() {
_muted = localStorage.getItem(STORAGE_KEY) === 'true';
try {
const stored = localStorage.getItem(CONFIG_KEY);
if (stored) {
const parsed = JSON.parse(stored);
_config.rate = parsed.rate ?? _config.rate;
_config.pitch = parsed.pitch ?? _config.pitch;
_config.voiceName = parsed.voiceName ?? _config.voiceName;
if (parsed.streams) {
Object.assign(_config.streams, parsed.streams);
}
}
} catch (_) {}
_updateMuteButton();
}
function _updateMuteButton() {
const btn = document.getElementById('voiceMuteBtn');
if (!btn) return;
btn.classList.toggle('voice-muted', _muted);
btn.title = _muted ? 'Unmute voice alerts' : 'Mute voice alerts';
btn.style.opacity = _muted ? '0.4' : '1';
}
function _getVoice() {
if (!_config.voiceName) return null;
const voices = window.speechSynthesis ? speechSynthesis.getVoices() : [];
return voices.find(v => v.name === _config.voiceName) || null;
}
function speak(text, priority) {
if (priority === undefined) priority = PRIORITY.MEDIUM;
if (!_enabled || _muted) return;
if (!window.speechSynthesis) return;
if (priority === PRIORITY.LOW && _speaking) return;
if (priority === PRIORITY.HIGH && _speaking) {
window.speechSynthesis.cancel();
_queue = [];
_speaking = false;
}
_queue.push({ text, priority });
if (!_speaking) _dequeue();
}
function _dequeue() {
if (_queue.length === 0) { _speaking = false; return; }
_speaking = true;
const item = _queue.shift();
const utt = new SpeechSynthesisUtterance(item.text);
utt.rate = _config.rate;
utt.pitch = _config.pitch;
const voice = _getVoice();
if (voice) utt.voice = voice;
utt.onend = () => { _speaking = false; _dequeue(); };
utt.onerror = () => { _speaking = false; _dequeue(); };
window.speechSynthesis.speak(utt);
}
function toggleMute() {
_muted = !_muted;
localStorage.setItem(STORAGE_KEY, _muted ? 'true' : 'false');
_updateMuteButton();
if (_muted && window.speechSynthesis) window.speechSynthesis.cancel();
}
function _openStream(url, handler, key) {
if (_sources[key]) return;
const es = new EventSource(url);
es.onmessage = handler;
es.onerror = () => { es.close(); delete _sources[key]; };
_sources[key] = es;
}
function _startStreams() {
if (!_enabled) return;
// Pager stream
if (_config.streams.pager) {
_openStream('/stream', (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.address && d.message) {
speak(`Pager message to ${d.address}: ${String(d.message).slice(0, 60)}`, PRIORITY.MEDIUM);
}
} catch (_) {}
}, 'pager');
}
// TSCM stream
if (_config.streams.tscm) {
_openStream('/tscm/sweep/stream', (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.threat_level && d.description) {
speak(`TSCM alert: ${d.threat_level}${d.description}`, PRIORITY.HIGH);
}
} catch (_) {}
}, 'tscm');
}
// Bluetooth stream — tracker detection only
if (_config.streams.bluetooth) {
_openStream('/api/bluetooth/stream', (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.service_data && d.service_data.tracker_type) {
speak(`Tracker detected: ${d.service_data.tracker_type}`, PRIORITY.HIGH);
}
} catch (_) {}
}, 'bluetooth');
}
}
function _stopStreams() {
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
_sources = {};
}
function init() {
_loadConfig();
_startStreams();
}
function setEnabled(val) {
_enabled = val;
if (!val) {
_stopStreams();
if (window.speechSynthesis) window.speechSynthesis.cancel();
} else {
_startStreams();
}
}
// ── Config API (used by Ops Center voice config panel) ─────────────
function getConfig() {
return JSON.parse(JSON.stringify(_config));
}
function setConfig(cfg) {
if (cfg.rate !== undefined) _config.rate = cfg.rate;
if (cfg.pitch !== undefined) _config.pitch = cfg.pitch;
if (cfg.voiceName !== undefined) _config.voiceName = cfg.voiceName;
if (cfg.streams) Object.assign(_config.streams, cfg.streams);
localStorage.setItem(CONFIG_KEY, JSON.stringify(_config));
// Restart streams to apply per-stream toggle changes
_stopStreams();
_startStreams();
}
function getAvailableVoices() {
return new Promise(resolve => {
if (!window.speechSynthesis) { resolve([]); return; }
let voices = speechSynthesis.getVoices();
if (voices.length > 0) { resolve(voices); return; }
speechSynthesis.onvoiceschanged = () => {
resolve(speechSynthesis.getVoices());
};
// Timeout fallback
setTimeout(() => resolve(speechSynthesis.getVoices()), 500);
});
}
function testVoice(text) {
if (!window.speechSynthesis) return;
const utt = new SpeechSynthesisUtterance(text || 'Voice alert test. All systems nominal.');
utt.rate = _config.rate;
utt.pitch = _config.pitch;
const voice = _getVoice();
if (voice) utt.voice = voice;
speechSynthesis.speak(utt);
}
return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
})();
window.VoiceAlerts = VoiceAlerts;

View File

@@ -1,549 +0,0 @@
/**
* Analytics Dashboard Module
* Cross-mode summary, sparklines, alerts, correlations, target view, and replay.
*/
const Analytics = (function () {
'use strict';
let refreshTimer = null;
let replayTimer = null;
let replaySessions = [];
let replayEvents = [];
let replayIndex = 0;
function init() {
refresh();
loadReplaySessions();
if (!refreshTimer) {
refreshTimer = setInterval(refresh, 5000);
}
}
function destroy() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
pauseReplay();
}
function refresh() {
Promise.all([
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
fetch('/analytics/insights').then(r => r.json()).catch(() => null),
fetch('/analytics/patterns').then(r => r.json()).catch(() => null),
fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null),
fetch('/correlation').then(r => r.json()).catch(() => null),
fetch('/analytics/geofences').then(r => r.json()).catch(() => null),
]).then(([summary, activity, insights, patterns, alerts, correlations, geofences]) => {
if (summary) renderSummary(summary);
if (activity) renderSparklines(activity.sparklines || {});
if (insights) renderInsights(insights);
if (patterns) renderPatterns(patterns.patterns || []);
if (alerts) renderAlerts(alerts.events || []);
if (correlations) renderCorrelations(correlations);
if (geofences) renderGeofences(geofences.zones || []);
});
}
function renderSummary(data) {
const counts = data.counts || {};
_setText('analyticsCountAdsb', counts.adsb || 0);
_setText('analyticsCountAis', counts.ais || 0);
_setText('analyticsCountWifi', counts.wifi || 0);
_setText('analyticsCountBt', counts.bluetooth || 0);
_setText('analyticsCountDsc', counts.dsc || 0);
_setText('analyticsCountAcars', counts.acars || 0);
_setText('analyticsCountVdl2', counts.vdl2 || 0);
_setText('analyticsCountAprs', counts.aprs || 0);
_setText('analyticsCountMesh', counts.meshtastic || 0);
const health = data.health || {};
const container = document.getElementById('analyticsHealth');
if (container) {
let html = '';
const modeLabels = {
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
bluetooth: 'BT', dsc: 'DSC', meshtastic: 'Mesh'
};
for (const [mode, info] of Object.entries(health)) {
if (mode === 'sdr_devices') continue;
const running = info && info.running;
const label = modeLabels[mode] || mode;
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
}
container.innerHTML = html;
}
const squawks = data.squawks || [];
const sqSection = document.getElementById('analyticsSquawkSection');
const sqList = document.getElementById('analyticsSquawkList');
if (sqSection && sqList) {
if (squawks.length > 0) {
sqSection.style.display = '';
sqList.innerHTML = squawks.map(s =>
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
_esc(s.meaning) + ' - ' + _esc(s.callsign || s.icao) + '</div>'
).join('');
} else {
sqSection.style.display = 'none';
}
}
}
function renderSparklines(sparklines) {
const map = {
adsb: 'analyticsSparkAdsb',
ais: 'analyticsSparkAis',
wifi: 'analyticsSparkWifi',
bluetooth: 'analyticsSparkBt',
dsc: 'analyticsSparkDsc',
acars: 'analyticsSparkAcars',
vdl2: 'analyticsSparkVdl2',
aprs: 'analyticsSparkAprs',
meshtastic: 'analyticsSparkMesh',
};
for (const [mode, elId] of Object.entries(map)) {
const el = document.getElementById(elId);
if (!el) continue;
const data = sparklines[mode] || [];
if (data.length < 2) {
el.innerHTML = '';
continue;
}
const max = Math.max(...data, 1);
const w = 100;
const h = 24;
const step = w / (data.length - 1);
const points = data.map((v, i) =>
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
).join(' ');
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
}
}
function renderInsights(data) {
const cards = data.cards || [];
const topChanges = data.top_changes || [];
const cardsEl = document.getElementById('analyticsInsights');
const changesEl = document.getElementById('analyticsTopChanges');
if (cardsEl) {
if (!cards.length) {
cardsEl.innerHTML = '<div class="analytics-empty">No insight data available</div>';
} else {
cardsEl.innerHTML = cards.map(c => {
const sev = _esc(c.severity || 'low');
const title = _esc(c.title || 'Insight');
const value = _esc(c.value || '--');
const label = _esc(c.label || '');
const detail = _esc(c.detail || '');
return '<div class="analytics-insight-card ' + sev + '">' +
'<div class="insight-title">' + title + '</div>' +
'<div class="insight-value">' + value + '</div>' +
'<div class="insight-label">' + label + '</div>' +
'<div class="insight-detail">' + detail + '</div>' +
'</div>';
}).join('');
}
}
if (changesEl) {
if (!topChanges.length) {
changesEl.innerHTML = '<div class="analytics-empty">No change signals yet</div>';
} else {
changesEl.innerHTML = topChanges.map(item => {
const mode = _esc(item.mode_label || item.mode || '');
const deltaRaw = Number(item.delta || 0);
const trendClass = deltaRaw > 0 ? 'up' : (deltaRaw < 0 ? 'down' : 'flat');
const delta = _esc(item.signed_delta || String(deltaRaw));
const recentAvg = _esc(item.recent_avg);
const prevAvg = _esc(item.previous_avg);
return '<div class="analytics-change-row">' +
'<span class="mode">' + mode + '</span>' +
'<span class="delta ' + trendClass + '">' + delta + '</span>' +
'<span class="avg">avg ' + recentAvg + ' vs ' + prevAvg + '</span>' +
'</div>';
}).join('');
}
}
}
function renderPatterns(patterns) {
const container = document.getElementById('analyticsPatternList');
if (!container) return;
if (!patterns || patterns.length === 0) {
container.innerHTML = '<div class="analytics-empty">No recurring patterns detected</div>';
return;
}
const modeLabels = {
adsb: 'ADS-B', ais: 'AIS', wifi: 'WiFi', bluetooth: 'Bluetooth',
dsc: 'DSC', acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', meshtastic: 'Meshtastic',
};
const sorted = patterns
.slice()
.sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
.slice(0, 20);
container.innerHTML = sorted.map(p => {
const confidencePct = Math.round((Number(p.confidence || 0)) * 100);
const mode = modeLabels[p.mode] || (p.mode || '--').toUpperCase();
const period = _humanPeriod(Number(p.period_seconds || 0));
const occurrences = Number(p.occurrences || 0);
const deviceId = _shortId(p.device_id || '--');
return '<div class="analytics-pattern-item">' +
'<div class="pattern-main">' +
'<span class="pattern-mode">' + _esc(mode) + '</span>' +
'<span class="pattern-device">' + _esc(deviceId) + '</span>' +
'</div>' +
'<div class="pattern-meta">' +
'<span>Period: ' + _esc(period) + '</span>' +
'<span>Hits: ' + _esc(occurrences) + '</span>' +
'<span class="pattern-confidence">' + _esc(confidencePct) + '%</span>' +
'</div>' +
'</div>';
}).join('');
}
function renderAlerts(events) {
const container = document.getElementById('analyticsAlertFeed');
if (!container) return;
if (!events || events.length === 0) {
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
return;
}
container.innerHTML = events.slice(0, 20).map(e => {
const sev = e.severity || 'medium';
const title = e.title || e.event_type || 'Alert';
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
return '<div class="analytics-alert-item">' +
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
'<span>' + _esc(title) + '</span>' +
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
'</div>';
}).join('');
}
function renderCorrelations(data) {
const container = document.getElementById('analyticsCorrelations');
if (!container) return;
const pairs = (data && data.correlations) || [];
if (pairs.length === 0) {
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
return;
}
container.innerHTML = pairs.slice(0, 20).map(p => {
const conf = Math.round((p.confidence || 0) * 100);
return '<div class="analytics-correlation-pair">' +
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
'<span style="color:var(--text-dim)">&#8596;</span>' +
'<span>' + _esc(p.bt_mac || '') + '</span>' +
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
'</div>';
}).join('');
}
function renderGeofences(zones) {
const container = document.getElementById('analyticsGeofenceList');
if (!container) return;
if (!zones || zones.length === 0) {
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
return;
}
container.innerHTML = zones.map(z =>
'<div class="geofence-zone-item">' +
'<span class="zone-name">' + _esc(z.name) + '</span>' +
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
'</div>'
).join('');
}
function addGeofence() {
const name = prompt('Zone name:');
if (!name) return;
const lat = parseFloat(prompt('Latitude:', '0'));
const lon = parseFloat(prompt('Longitude:', '0'));
const radius = parseFloat(prompt('Radius (meters):', '1000'));
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
alert('Invalid input');
return;
}
fetch('/analytics/geofences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
})
.then(r => r.json())
.then(() => refresh());
}
function deleteGeofence(id) {
if (!confirm('Delete this geofence zone?')) return;
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
.then(r => r.json())
.then(() => refresh());
}
function exportData(mode) {
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
const f = (document.getElementById('exportFormat') || {}).value || 'json';
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
}
function searchTarget() {
const input = document.getElementById('analyticsTargetQuery');
const summaryEl = document.getElementById('analyticsTargetSummary');
const q = (input && input.value || '').trim();
if (!q) {
if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities';
renderTargetResults([]);
return;
}
fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120')
.then((r) => r.json())
.then((data) => {
const results = data.results || [];
if (summaryEl) {
const modeCounts = data.mode_counts || {};
const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | ');
summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`;
}
renderTargetResults(results);
})
.catch((err) => {
if (summaryEl) summaryEl.textContent = 'Search failed';
if (typeof reportActionableError === 'function') {
reportActionableError('Target View Search', err, { onRetry: searchTarget });
}
});
}
function renderTargetResults(results) {
const container = document.getElementById('analyticsTargetResults');
if (!container) return;
if (!results || !results.length) {
container.innerHTML = '<div class="analytics-empty">No matching entities</div>';
return;
}
container.innerHTML = results.map((item) => {
const title = _esc(item.title || item.id || 'Entity');
const subtitle = _esc(item.subtitle || '');
const mode = _esc(item.mode || 'unknown');
const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : '';
const lastSeen = _esc(item.last_seen || '');
return '<div class="analytics-target-item">' +
'<div class="title"><span class="mode">' + mode + '</span><span>' + title + '</span></div>' +
'<div class="meta"><span>' + subtitle + '</span>' +
(lastSeen ? '<span>Last seen ' + lastSeen + '</span>' : '') +
(confidence ? '<span>' + confidence + '</span>' : '') +
'</div>' +
'</div>';
}).join('');
}
function loadReplaySessions() {
const select = document.getElementById('analyticsReplaySelect');
if (!select) return;
fetch('/recordings?limit=60')
.then((r) => r.json())
.then((data) => {
replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0);
if (!replaySessions.length) {
select.innerHTML = '<option value="">No recordings</option>';
return;
}
select.innerHTML = replaySessions.map((rec) => {
const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`;
return `<option value="${_esc(rec.id)}">${_esc(label)}</option>`;
}).join('');
const pendingReplay = localStorage.getItem('analyticsReplaySession');
if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) {
select.value = pendingReplay;
localStorage.removeItem('analyticsReplaySession');
loadReplay();
}
})
.catch((err) => {
if (typeof reportActionableError === 'function') {
reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions });
}
});
}
function loadReplay() {
pauseReplay();
replayEvents = [];
replayIndex = 0;
const select = document.getElementById('analyticsReplaySelect');
const meta = document.getElementById('analyticsReplayMeta');
const timeline = document.getElementById('analyticsReplayTimeline');
if (!select || !meta || !timeline) return;
const id = select.value;
if (!id) {
meta.textContent = 'Select a recording';
timeline.innerHTML = '<div class="analytics-empty">No recording selected</div>';
return;
}
meta.textContent = 'Loading replay events...';
fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600')
.then((r) => r.json())
.then((data) => {
replayEvents = data.events || [];
replayIndex = 0;
if (!replayEvents.length) {
meta.textContent = 'No events found in selected recording';
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
return;
}
const rec = replaySessions.find((s) => s.id === id);
const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown';
meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`;
renderReplayWindow();
})
.catch((err) => {
meta.textContent = 'Replay load failed';
if (typeof reportActionableError === 'function') {
reportActionableError('Load Replay', err, { onRetry: loadReplay });
}
});
}
function playReplay() {
if (!replayEvents.length) {
loadReplay();
return;
}
if (replayTimer) return;
replayTimer = setInterval(() => {
if (replayIndex >= replayEvents.length - 1) {
pauseReplay();
return;
}
replayIndex += 1;
renderReplayWindow();
}, 260);
}
function pauseReplay() {
if (replayTimer) {
clearInterval(replayTimer);
replayTimer = null;
}
}
function stepReplay() {
if (!replayEvents.length) {
loadReplay();
return;
}
pauseReplay();
replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1);
renderReplayWindow();
}
function renderReplayWindow() {
const timeline = document.getElementById('analyticsReplayTimeline');
const meta = document.getElementById('analyticsReplayMeta');
if (!timeline || !meta) return;
const total = replayEvents.length;
if (!total) {
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
return;
}
const start = Math.max(0, replayIndex - 15);
const end = Math.min(total, replayIndex + 20);
const windowed = replayEvents.slice(start, end);
timeline.innerHTML = windowed.map((row, i) => {
const absolute = start + i;
const active = absolute === replayIndex;
const eventType = _esc(row.event_type || 'event');
const mode = _esc(row.mode || '--');
const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--');
const detail = summarizeReplayEvent(row.event || {});
return '<div class="analytics-replay-item" style="opacity:' + (active ? '1' : '0.65') + ';">' +
'<div class="title"><span class="mode">' + mode + '</span><span>' + eventType + '</span></div>' +
'<div class="meta"><span>' + ts + '</span><span>' + _esc(detail) + '</span></div>' +
'</div>';
}).join('');
meta.textContent = `Event ${replayIndex + 1}/${total}`;
}
function summarizeReplayEvent(event) {
if (!event || typeof event !== 'object') return 'No details';
if (event.callsign) return `Callsign ${event.callsign}`;
if (event.icao) return `ICAO ${event.icao}`;
if (event.ssid) return `SSID ${event.ssid}`;
if (event.bssid) return `BSSID ${event.bssid}`;
if (event.address) return `Address ${event.address}`;
if (event.name) return `Name ${event.name}`;
const keys = Object.keys(event);
if (!keys.length) return 'No fields';
return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`;
}
function _setText(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
function _esc(s) {
if (typeof s !== 'string') s = String(s == null ? '' : s);
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _shortId(value) {
const text = String(value || '');
if (text.length <= 18) return text;
return text.slice(0, 8) + '...' + text.slice(-6);
}
function _humanPeriod(seconds) {
if (!isFinite(seconds) || seconds <= 0) return '--';
if (seconds < 60) return Math.round(seconds) + 's';
const mins = seconds / 60;
if (mins < 60) return mins.toFixed(mins < 10 ? 1 : 0) + 'm';
const hours = mins / 60;
return hours.toFixed(hours < 10 ? 1 : 0) + 'h';
}
return {
init,
destroy,
refresh,
addGeofence,
deleteGeofence,
exportData,
searchTarget,
loadReplay,
playReplay,
pauseReplay,
stepReplay,
loadReplaySessions,
};
})();

View File

@@ -226,11 +226,11 @@ const BtLocate = (function() {
map.on('resize moveend zoomend', () => {
flushPendingHeatSync();
});
setTimeout(() => {
requestAnimationFrame(() => {
safeInvalidateMap();
flushPendingHeatSync();
}, 100);
scheduleMapStabilization();
scheduleMapStabilization();
});
}
// Init RSSI chart canvas

View File

@@ -0,0 +1,404 @@
/* Signal Fingerprinting — RF baseline recorder + anomaly comparator */
const Fingerprint = (function () {
'use strict';
let _active = false;
let _recording = false;
let _scannerSource = null;
let _pendingObs = [];
let _flushTimer = null;
let _currentTab = 'record';
let _chartInstance = null;
let _ownedScanner = false;
let _obsCount = 0;
function _flushObservations() {
if (!_recording || _pendingObs.length === 0) return;
const batch = _pendingObs.splice(0);
fetch('/fingerprint/observation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ observations: batch }),
}).catch(() => {});
}
function _startScannerStream() {
if (_scannerSource) { _scannerSource.close(); _scannerSource = null; }
_scannerSource = new EventSource('/listening/scanner/stream');
_scannerSource.onmessage = (ev) => {
try {
const d = JSON.parse(ev.data);
// Only collect meaningful signal events (signal_found has SNR)
if (d.type && d.type !== 'signal_found' && d.type !== 'scan_update') return;
const freq = d.frequency ?? d.freq_mhz ?? null;
if (freq === null) return;
// Prefer SNR (dB) from signal_found events; fall back to level for scan_update
let power = null;
if (d.snr !== undefined && d.snr !== null) {
power = d.snr;
} else if (d.level !== undefined && d.level !== null) {
// level is RMS audio — skip scan_update noise floor readings
if (d.type === 'signal_found') {
power = d.level;
} else {
return; // scan_update with no SNR — skip
}
} else if (d.power_dbm !== undefined) {
power = d.power_dbm;
}
if (power === null) return;
if (_recording) {
_pendingObs.push({ freq_mhz: parseFloat(freq), power_dbm: parseFloat(power) });
_obsCount++;
_updateObsCounter();
}
} catch (_) {}
};
}
function _updateObsCounter() {
const el = document.getElementById('fpObsCount');
if (el) el.textContent = _obsCount;
}
function _setStatus(msg) {
const el = document.getElementById('fpRecordStatus');
if (el) el.textContent = msg;
}
// ── Scanner lifecycle (standalone control) ─────────────────────────
async function _checkScannerStatus() {
try {
const r = await fetch('/listening/scanner/status');
if (r.ok) {
const d = await r.json();
return !!d.running;
}
} catch (_) {}
return false;
}
async function _updateScannerStatusUI() {
const running = await _checkScannerStatus();
const dotEl = document.getElementById('fpScannerDot');
const textEl = document.getElementById('fpScannerStatusText');
const startB = document.getElementById('fpScannerStartBtn');
const stopB = document.getElementById('fpScannerStopBtn');
if (dotEl) dotEl.style.background = running ? 'var(--accent-green, #00ff88)' : 'rgba(255,255,255,0.2)';
if (textEl) textEl.textContent = running ? 'Scanner running' : 'Scanner not running';
if (startB) startB.style.display = running ? 'none' : '';
if (stopB) stopB.style.display = (running && _ownedScanner) ? '' : 'none';
// Auto-connect to stream if scanner is running
if (running && !_scannerSource) _startScannerStream();
}
async function startScanner() {
const deviceVal = document.getElementById('fpDevice')?.value || 'rtlsdr:0';
const [sdrType, idxStr] = deviceVal.includes(':') ? deviceVal.split(':') : ['rtlsdr', '0'];
const startB = document.getElementById('fpScannerStartBtn');
if (startB) { startB.disabled = true; startB.textContent = 'Starting…'; }
try {
const res = await fetch('/listening/scanner/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start_freq: 24, end_freq: 1700, sdr_type: sdrType, device: parseInt(idxStr) || 0 }),
});
if (res.ok) {
_ownedScanner = true;
_startScannerStream();
}
} catch (_) {}
if (startB) { startB.disabled = false; startB.textContent = 'Start Scanner'; }
await _updateScannerStatusUI();
}
async function stopScanner() {
if (!_ownedScanner) return;
try {
await fetch('/listening/scanner/stop', { method: 'POST' });
} catch (_) {}
_ownedScanner = false;
if (_scannerSource) { _scannerSource.close(); _scannerSource = null; }
await _updateScannerStatusUI();
}
// ── Recording ──────────────────────────────────────────────────────
async function startRecording() {
// Check scanner is running first
const running = await _checkScannerStatus();
if (!running) {
_setStatus('Scanner not running — start it first (Step 2)');
return;
}
const name = document.getElementById('fpSessionName')?.value.trim() || 'Session ' + new Date().toLocaleString();
const location = document.getElementById('fpSessionLocation')?.value.trim() || null;
try {
const res = await fetch('/fingerprint/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, location }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Start failed');
_recording = true;
_pendingObs = [];
_obsCount = 0;
_updateObsCounter();
_flushTimer = setInterval(_flushObservations, 5000);
if (!_scannerSource) _startScannerStream();
const startBtn = document.getElementById('fpStartBtn');
const stopBtn = document.getElementById('fpStopBtn');
if (startBtn) startBtn.style.display = 'none';
if (stopBtn) stopBtn.style.display = '';
_setStatus('Recording… session #' + data.session_id);
} catch (e) {
_setStatus('Error: ' + e.message);
}
}
async function stopRecording() {
_recording = false;
_flushObservations();
if (_flushTimer) { clearInterval(_flushTimer); _flushTimer = null; }
if (_scannerSource) { _scannerSource.close(); _scannerSource = null; }
try {
const res = await fetch('/fingerprint/stop', { method: 'POST' });
const data = await res.json();
_setStatus(`Saved: ${data.bands_recorded} bands recorded (${_obsCount} observations)`);
} catch (e) {
_setStatus('Error saving: ' + e.message);
}
const startBtn = document.getElementById('fpStartBtn');
const stopBtn = document.getElementById('fpStopBtn');
if (startBtn) startBtn.style.display = '';
if (stopBtn) stopBtn.style.display = 'none';
_loadSessions();
}
async function _loadSessions() {
try {
const res = await fetch('/fingerprint/list');
const data = await res.json();
const sel = document.getElementById('fpBaselineSelect');
if (!sel) return;
const sessions = (data.sessions || []).filter(s => s.finalized_at);
sel.innerHTML = sessions.length
? sessions.map(s => `<option value="${s.id}">[${s.id}] ${s.name} (${s.band_count || 0} bands)</option>`).join('')
: '<option value="">No saved baselines</option>';
} catch (_) {}
}
// ── Compare ────────────────────────────────────────────────────────
async function compareNow() {
const baselineId = document.getElementById('fpBaselineSelect')?.value;
if (!baselineId) return;
// Check scanner is running
const running = await _checkScannerStatus();
if (!running) {
const statusEl = document.getElementById('fpCompareStatus');
if (statusEl) statusEl.textContent = 'Scanner not running — start it first';
return;
}
const statusEl = document.getElementById('fpCompareStatus');
const compareBtn = document.querySelector('#fpComparePanel .run-btn');
if (statusEl) statusEl.textContent = 'Collecting observations…';
if (compareBtn) { compareBtn.disabled = true; compareBtn.textContent = 'Scanning…'; }
// Collect live observations for ~3 seconds
const obs = [];
const tmpSrc = new EventSource('/listening/scanner/stream');
const deadline = Date.now() + 3000;
await new Promise(resolve => {
tmpSrc.onmessage = (ev) => {
if (Date.now() > deadline) { tmpSrc.close(); resolve(); return; }
try {
const d = JSON.parse(ev.data);
if (d.type && d.type !== 'signal_found' && d.type !== 'scan_update') return;
const freq = d.frequency ?? d.freq_mhz ?? null;
let power = null;
if (d.snr !== undefined && d.snr !== null) power = d.snr;
else if (d.type === 'signal_found' && d.level !== undefined) power = d.level;
else if (d.power_dbm !== undefined) power = d.power_dbm;
if (freq !== null && power !== null) obs.push({ freq_mhz: parseFloat(freq), power_dbm: parseFloat(power) });
if (statusEl) statusEl.textContent = `Collecting… ${obs.length} observations`;
} catch (_) {}
};
tmpSrc.onerror = () => { tmpSrc.close(); resolve(); };
setTimeout(() => { tmpSrc.close(); resolve(); }, 3500);
});
if (statusEl) statusEl.textContent = `Comparing ${obs.length} observations against baseline…`;
try {
const res = await fetch('/fingerprint/compare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseline_id: parseInt(baselineId), observations: obs }),
});
const data = await res.json();
_renderAnomalies(data.anomalies || []);
_renderChart(data.baseline_bands || [], data.anomalies || []);
if (statusEl) statusEl.textContent = `Done — ${obs.length} observations, ${(data.anomalies || []).length} anomalies`;
} catch (e) {
console.error('Compare failed:', e);
if (statusEl) statusEl.textContent = 'Compare failed: ' + e.message;
}
if (compareBtn) { compareBtn.disabled = false; compareBtn.textContent = 'Compare Now'; }
}
function _renderAnomalies(anomalies) {
const panel = document.getElementById('fpAnomalyList');
const items = document.getElementById('fpAnomalyItems');
if (!panel || !items) return;
if (anomalies.length === 0) {
items.innerHTML = '<div style="font-size:11px; color:var(--text-dim); padding:8px;">No significant anomalies detected.</div>';
panel.style.display = 'block';
return;
}
items.innerHTML = anomalies.map(a => {
const z = a.z_score !== null ? Math.abs(a.z_score) : 999;
let cls = 'severity-warn', badge = 'POWER';
if (a.anomaly_type === 'new') { cls = 'severity-new'; badge = 'NEW'; }
else if (a.anomaly_type === 'missing') { cls = 'severity-warn'; badge = 'MISSING'; }
else if (z >= 3) { cls = 'severity-alert'; }
const zText = a.z_score !== null ? `z=${a.z_score.toFixed(1)}` : '';
const powerText = a.current_power !== null ? `${a.current_power.toFixed(1)} dBm` : 'absent';
const baseText = a.baseline_mean !== null ? `baseline: ${a.baseline_mean.toFixed(1)} dBm` : '';
return `<div class="fp-anomaly-item ${cls}">
<div style="display:flex; align-items:center; gap:6px;">
<span class="fp-anomaly-band">${a.band_label}</span>
<span class="fp-anomaly-type-badge" style="background:rgba(255,255,255,0.1);">${badge}</span>
${z >= 3 ? '<span style="color:#ef4444; font-size:9px; font-weight:700;">ALERT</span>' : ''}
</div>
<div style="color:var(--text-secondary);">${powerText} ${baseText} ${zText}</div>
</div>`;
}).join('');
panel.style.display = 'block';
// Voice alert for high-severity anomalies
const highZ = anomalies.find(a => (a.z_score !== null && Math.abs(a.z_score) >= 3) || a.anomaly_type === 'new');
if (highZ && window.VoiceAlerts) {
VoiceAlerts.speak(`RF anomaly detected: ${highZ.band_label}${highZ.anomaly_type}`, 2);
}
}
function _renderChart(baselineBands, anomalies) {
const canvas = document.getElementById('fpChartCanvas');
if (!canvas || typeof Chart === 'undefined') return;
const anomalyMap = {};
anomalies.forEach(a => { anomalyMap[a.band_center_mhz] = a; });
const bands = baselineBands.slice(0, 40);
const labels = bands.map(b => b.band_center_mhz.toFixed(1));
const means = bands.map(b => b.mean_dbm);
const currentPowers = bands.map(b => {
const a = anomalyMap[b.band_center_mhz];
return a ? a.current_power : b.mean_dbm;
});
const barColors = bands.map(b => {
const a = anomalyMap[b.band_center_mhz];
if (!a) return 'rgba(74,163,255,0.6)';
if (a.anomaly_type === 'new') return 'rgba(168,85,247,0.8)';
if (a.z_score !== null && Math.abs(a.z_score) >= 3) return 'rgba(239,68,68,0.8)';
return 'rgba(251,191,36,0.7)';
});
if (_chartInstance) { _chartInstance.destroy(); _chartInstance = null; }
_chartInstance = new Chart(canvas, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Baseline Mean', data: means, backgroundColor: 'rgba(74,163,255,0.3)', borderColor: 'rgba(74,163,255,0.8)', borderWidth: 1 },
{ label: 'Current', data: currentPowers, backgroundColor: barColors, borderColor: barColors, borderWidth: 1 },
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { labels: { color: '#aaa', font: { size: 10 } } } },
scales: {
x: { ticks: { color: '#666', font: { size: 9 }, maxRotation: 90 }, grid: { color: 'rgba(255,255,255,0.05)' } },
y: { ticks: { color: '#666', font: { size: 10 } }, grid: { color: 'rgba(255,255,255,0.05)' }, title: { display: true, text: 'Power (dBm)', color: '#666' } },
},
},
});
}
function showTab(tab) {
_currentTab = tab;
const recordPanel = document.getElementById('fpRecordPanel');
const comparePanel = document.getElementById('fpComparePanel');
if (recordPanel) recordPanel.style.display = tab === 'record' ? '' : 'none';
if (comparePanel) comparePanel.style.display = tab === 'compare' ? '' : 'none';
document.querySelectorAll('.fp-tab-btn').forEach(b => b.classList.remove('active'));
const activeBtn = tab === 'record'
? document.getElementById('fpTabRecord')
: document.getElementById('fpTabCompare');
if (activeBtn) activeBtn.classList.add('active');
const hintEl = document.getElementById('fpTabHint');
if (hintEl) hintEl.innerHTML = TAB_HINTS[tab] || '';
if (tab === 'compare') _loadSessions();
}
function _loadDevices() {
const sel = document.getElementById('fpDevice');
if (!sel) return;
fetch('/devices').then(r => r.json()).then(devices => {
if (!devices || devices.length === 0) {
sel.innerHTML = '<option value="">No SDR devices detected</option>';
return;
}
sel.innerHTML = devices.map(d => {
const label = d.serial ? `${d.name} [${d.serial}]` : d.name;
return `<option value="${d.sdr_type}:${d.index}">${label}</option>`;
}).join('');
}).catch(() => { sel.innerHTML = '<option value="">Could not load devices</option>'; });
}
const TAB_HINTS = {
record: 'Record a <strong style="color:var(--text-secondary);">baseline</strong> in a known-clean RF environment, then use <strong style="color:var(--text-secondary);">Compare</strong> later to detect new or anomalous signals.',
compare: 'Select a saved baseline and click <strong style="color:var(--text-secondary);">Compare Now</strong> to scan for deviations. Anomalies are flagged by statistical z-score.',
};
function init() {
_active = true;
_loadDevices();
_loadSessions();
_updateScannerStatusUI();
}
function destroy() {
_active = false;
if (_recording) stopRecording();
if (_scannerSource) { _scannerSource.close(); _scannerSource = null; }
if (_chartInstance) { _chartInstance.destroy(); _chartInstance = null; }
if (_ownedScanner) stopScanner();
}
return { init, destroy, showTab, startRecording, stopRecording, compareNow, startScanner, stopScanner };
})();
window.Fingerprint = Fingerprint;

View File

@@ -0,0 +1,456 @@
/* RF Heatmap — GPS + signal strength Leaflet heatmap */
const RFHeatmap = (function () {
'use strict';
let _map = null;
let _heatLayer = null;
let _gpsSource = null;
let _sigSource = null;
let _heatPoints = [];
let _isRecording = false;
let _lastLat = null, _lastLng = null;
let _minDist = 5;
let _source = 'wifi';
let _gpsPos = null;
let _lastSignal = null;
let _active = false;
let _ownedSource = false; // true if heatmap started the source itself
const RSSI_RANGES = {
wifi: { min: -90, max: -30 },
bluetooth: { min: -100, max: -40 },
scanner: { min: -120, max: -20 },
};
function _norm(val, src) {
const r = RSSI_RANGES[src] || RSSI_RANGES.wifi;
return Math.max(0, Math.min(1, (val - r.min) / (r.max - r.min)));
}
function _haversineM(lat1, lng1, lat2, lng2) {
const R = 6371000;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function _ensureLeafletHeat(cb) {
if (window.L && L.heatLayer) { cb(); return; }
const s = document.createElement('script');
s.src = '/static/js/vendor/leaflet-heat.js';
s.onload = cb;
s.onerror = () => console.warn('RF Heatmap: leaflet-heat.js failed to load');
document.head.appendChild(s);
}
function _initMap() {
if (_map) return;
const el = document.getElementById('rfheatmapMapEl');
if (!el) return;
// Defer map creation until container has non-zero dimensions (prevents leaflet-heat IndexSizeError)
if (el.offsetWidth === 0 || el.offsetHeight === 0) {
setTimeout(_initMap, 200);
return;
}
const fallback = _getFallbackPos();
const lat = _gpsPos ? _gpsPos.lat : (fallback ? fallback.lat : 37.7749);
const lng = _gpsPos ? _gpsPos.lng : (fallback ? fallback.lng : -122.4194);
_map = L.map(el, { zoomControl: true }).setView([lat, lng], 16);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
subdomains: 'abcd',
maxZoom: 20,
}).addTo(_map);
_heatLayer = L.heatLayer([], { radius: 25, blur: 15, maxZoom: 17 }).addTo(_map);
}
function _startGPS() {
if (_gpsSource) { _gpsSource.close(); _gpsSource = null; }
_gpsSource = new EventSource('/gps/stream');
_gpsSource.onmessage = (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.lat && d.lng && d.fix) {
_gpsPos = { lat: parseFloat(d.lat), lng: parseFloat(d.lng) };
_updateGpsPill(true, _gpsPos.lat, _gpsPos.lng);
if (_map) _map.setView([_gpsPos.lat, _gpsPos.lng], _map.getZoom(), { animate: false });
} else {
_updateGpsPill(false);
}
} catch (_) {}
};
_gpsSource.onerror = () => _updateGpsPill(false);
}
function _updateGpsPill(fix, lat, lng) {
const pill = document.getElementById('rfhmGpsPill');
if (!pill) return;
if (fix && lat !== undefined) {
pill.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
pill.style.color = 'var(--accent-green, #00ff88)';
} else {
const fallback = _getFallbackPos();
pill.textContent = fallback ? 'No Fix (using fallback)' : 'No Fix';
pill.style.color = fallback ? 'var(--accent-yellow, #f59e0b)' : 'var(--text-dim, #555)';
}
}
function _startSignalStream() {
if (_sigSource) { _sigSource.close(); _sigSource = null; }
let url;
if (_source === 'wifi') url = '/wifi/stream';
else if (_source === 'bluetooth') url = '/api/bluetooth/stream';
else url = '/listening/scanner/stream';
_sigSource = new EventSource(url);
_sigSource.onmessage = (ev) => {
try {
const d = JSON.parse(ev.data);
let rssi = null;
if (_source === 'wifi') rssi = d.signal_level ?? d.signal ?? null;
else if (_source === 'bluetooth') rssi = d.rssi ?? null;
else rssi = d.power_level ?? d.power ?? null;
if (rssi !== null) {
_lastSignal = parseFloat(rssi);
_updateSignalDisplay(_lastSignal);
}
_maybeSample();
} catch (_) {}
};
}
function _maybeSample() {
if (!_isRecording || _lastSignal === null) return;
if (!_gpsPos) {
const fb = _getFallbackPos();
if (fb) _gpsPos = fb;
else return;
}
const { lat, lng } = _gpsPos;
if (_lastLat !== null) {
const dist = _haversineM(_lastLat, _lastLng, lat, lng);
if (dist < _minDist) return;
}
const intensity = _norm(_lastSignal, _source);
_heatPoints.push([lat, lng, intensity]);
_lastLat = lat;
_lastLng = lng;
if (_heatLayer) {
const el = document.getElementById('rfheatmapMapEl');
if (el && el.offsetWidth > 0 && el.offsetHeight > 0) _heatLayer.setLatLngs(_heatPoints);
}
_updateCount();
}
function _updateCount() {
const el = document.getElementById('rfhmPointCount');
if (el) el.textContent = _heatPoints.length;
}
function _updateSignalDisplay(rssi) {
const valEl = document.getElementById('rfhmLiveSignal');
const barEl = document.getElementById('rfhmSignalBar');
const statusEl = document.getElementById('rfhmSignalStatus');
if (!valEl) return;
valEl.textContent = rssi !== null ? `${rssi.toFixed(1)} dBm` : '— dBm';
if (rssi !== null) {
// Normalise to 0100% for the bar
const pct = Math.round(_norm(rssi, _source) * 100);
if (barEl) barEl.style.width = pct + '%';
// Colour the value by strength
let color, label;
if (pct >= 66) { color = 'var(--accent-green, #00ff88)'; label = 'Strong'; }
else if (pct >= 33) { color = 'var(--accent-cyan, #4aa3ff)'; label = 'Moderate'; }
else { color = '#f59e0b'; label = 'Weak'; }
valEl.style.color = color;
if (barEl) barEl.style.background = color;
if (statusEl) {
statusEl.textContent = _isRecording
? `${label} — recording point every ${_minDist}m`
: `${label} — press Start Recording to begin`;
}
} else {
if (barEl) barEl.style.width = '0%';
valEl.style.color = 'var(--text-dim)';
if (statusEl) statusEl.textContent = 'No signal data received yet';
}
}
function setSource(src) {
_source = src;
if (_active) _startSignalStream();
}
function setMinDist(m) {
_minDist = m;
}
function startRecording() {
_isRecording = true;
_lastLat = null; _lastLng = null;
const startBtn = document.getElementById('rfhmRecordBtn');
const stopBtn = document.getElementById('rfhmStopBtn');
if (startBtn) startBtn.style.display = 'none';
if (stopBtn) { stopBtn.style.display = ''; stopBtn.classList.add('rfhm-recording-pulse'); }
}
function stopRecording() {
_isRecording = false;
const startBtn = document.getElementById('rfhmRecordBtn');
const stopBtn = document.getElementById('rfhmStopBtn');
if (startBtn) startBtn.style.display = '';
if (stopBtn) { stopBtn.style.display = 'none'; stopBtn.classList.remove('rfhm-recording-pulse'); }
}
function clearPoints() {
_heatPoints = [];
if (_heatLayer) {
const el = document.getElementById('rfheatmapMapEl');
if (el && el.offsetWidth > 0 && el.offsetHeight > 0) _heatLayer.setLatLngs([]);
}
_updateCount();
}
function exportGeoJSON() {
const features = _heatPoints.map(([lat, lng, intensity]) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lng, lat] },
properties: { intensity, source: _source },
}));
const geojson = { type: 'FeatureCollection', features };
const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `rf_heatmap_${Date.now()}.geojson`;
a.click();
}
function invalidateMap() {
if (!_map) return;
const el = document.getElementById('rfheatmapMapEl');
if (el && el.offsetWidth > 0 && el.offsetHeight > 0) {
_map.invalidateSize();
}
}
// ── Source lifecycle (start / stop / status) ──────────────────────
async function _checkSourceStatus() {
const src = _source;
let running = false;
let detail = null;
try {
if (src === 'wifi') {
const r = await fetch('/wifi/v2/scan/status');
if (r.ok) { const d = await r.json(); running = !!d.is_scanning; detail = d.interface || null; }
} else if (src === 'bluetooth') {
const r = await fetch('/api/bluetooth/scan/status');
if (r.ok) { const d = await r.json(); running = !!d.is_scanning; }
} else if (src === 'scanner') {
const r = await fetch('/listening/scanner/status');
if (r.ok) { const d = await r.json(); running = !!d.running; }
}
} catch (_) {}
return { running, detail };
}
async function startSource() {
const src = _source;
const btn = document.getElementById('rfhmSourceStartBtn');
const status = document.getElementById('rfhmSourceStatus');
if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; }
try {
let res;
if (src === 'wifi') {
// Try to find a monitor interface from the WiFi status first
let iface = null;
try {
const st = await fetch('/wifi/v2/scan/status');
if (st.ok) { const d = await st.json(); iface = d.interface || null; }
} catch (_) {}
if (!iface) {
// Ask the user to enter an interface name
const entered = prompt('Enter your monitor-mode WiFi interface name (e.g. wlan0mon):');
if (!entered) { _updateSourceStatusUI(); return; }
iface = entered.trim();
}
res = await fetch('/wifi/v2/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface }) });
} else if (src === 'bluetooth') {
res = await fetch('/api/bluetooth/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'auto' }) });
} else if (src === 'scanner') {
const deviceVal = document.getElementById('rfhmDevice')?.value || 'rtlsdr:0';
const [sdrType, idxStr] = deviceVal.includes(':') ? deviceVal.split(':') : ['rtlsdr', '0'];
res = await fetch('/listening/scanner/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ start_freq: 88, end_freq: 108, sdr_type: sdrType, device: parseInt(idxStr) || 0 }) });
}
if (res && res.ok) {
_ownedSource = true;
_startSignalStream();
}
} catch (_) {}
await _updateSourceStatusUI();
}
async function stopSource() {
if (!_ownedSource) return;
try {
if (_source === 'wifi') await fetch('/wifi/v2/scan/stop', { method: 'POST' });
else if (_source === 'bluetooth') await fetch('/api/bluetooth/scan/stop', { method: 'POST' });
else if (_source === 'scanner') await fetch('/listening/scanner/stop', { method: 'POST' });
} catch (_) {}
_ownedSource = false;
await _updateSourceStatusUI();
}
async function _updateSourceStatusUI() {
const { running, detail } = await _checkSourceStatus();
const row = document.getElementById('rfhmSourceStatusRow');
const dotEl = document.getElementById('rfhmSourceDot');
const textEl = document.getElementById('rfhmSourceStatusText');
const startB = document.getElementById('rfhmSourceStartBtn');
const stopB = document.getElementById('rfhmSourceStopBtn');
if (!row) return;
const SOURCE_NAMES = { wifi: 'WiFi Scanner', bluetooth: 'Bluetooth Scanner', scanner: 'SDR Scanner' };
const name = SOURCE_NAMES[_source] || _source;
if (dotEl) dotEl.style.background = running ? 'var(--accent-green)' : 'rgba(255,255,255,0.2)';
if (textEl) textEl.textContent = running
? `${name} running${detail ? ' · ' + detail : ''}`
: `${name} not running`;
if (startB) { startB.style.display = running ? 'none' : ''; startB.disabled = false; startB.textContent = `Start ${name}`; }
if (stopB) stopB.style.display = (running && _ownedSource) ? '' : 'none';
// Auto-subscribe to stream if source just became running
if (running && !_sigSource) _startSignalStream();
}
const SOURCE_HINTS = {
wifi: 'Walk with your device — stronger WiFi signals are plotted brighter on the map.',
bluetooth: 'Walk near Bluetooth devices — signal strength is mapped by RSSI.',
scanner: 'SDR scanner power levels are mapped by GPS position. Start the Listening Post scanner first.',
};
function onSourceChange() {
const src = document.getElementById('rfhmSource')?.value || 'wifi';
const hint = document.getElementById('rfhmSourceHint');
const dg = document.getElementById('rfhmDeviceGroup');
if (hint) hint.textContent = SOURCE_HINTS[src] || '';
if (dg) dg.style.display = src === 'scanner' ? '' : 'none';
_lastSignal = null;
_ownedSource = false;
_updateSignalDisplay(null);
_updateSourceStatusUI();
// Re-subscribe to correct stream
if (_sigSource) { _sigSource.close(); _sigSource = null; }
_startSignalStream();
}
function _loadDevices() {
const sel = document.getElementById('rfhmDevice');
if (!sel) return;
fetch('/devices').then(r => r.json()).then(devices => {
if (!devices || devices.length === 0) {
sel.innerHTML = '<option value="">No SDR devices detected</option>';
return;
}
sel.innerHTML = devices.map(d => {
const label = d.serial ? `${d.name} [${d.serial}]` : d.name;
return `<option value="${d.sdr_type}:${d.index}">${label}</option>`;
}).join('');
}).catch(() => { sel.innerHTML = '<option value="">Could not load devices</option>'; });
}
function _getFallbackPos() {
// Try observer location from localStorage (shared across all map modes)
try {
const stored = localStorage.getItem('observerLocation');
if (stored) {
const p = JSON.parse(stored);
if (p && typeof p.lat === 'number' && typeof p.lon === 'number') {
return { lat: p.lat, lng: p.lon };
}
}
} catch (_) {}
// Try manual coord inputs
const lat = parseFloat(document.getElementById('rfhmManualLat')?.value);
const lng = parseFloat(document.getElementById('rfhmManualLon')?.value);
if (!isNaN(lat) && !isNaN(lng)) return { lat, lng };
return null;
}
function setManualCoords() {
const lat = parseFloat(document.getElementById('rfhmManualLat')?.value);
const lng = parseFloat(document.getElementById('rfhmManualLon')?.value);
if (!isNaN(lat) && !isNaN(lng) && !_gpsPos && _map) {
_map.setView([lat, lng], _map.getZoom(), { animate: false });
}
}
function useObserverLocation() {
try {
const stored = localStorage.getItem('observerLocation');
if (stored) {
const p = JSON.parse(stored);
if (p && typeof p.lat === 'number' && typeof p.lon === 'number') {
const latEl = document.getElementById('rfhmManualLat');
const lonEl = document.getElementById('rfhmManualLon');
if (latEl) latEl.value = p.lat.toFixed(5);
if (lonEl) lonEl.value = p.lon.toFixed(5);
if (_map) _map.setView([p.lat, p.lon], _map.getZoom(), { animate: true });
return;
}
}
} catch (_) {}
}
function init() {
_active = true;
_loadDevices();
onSourceChange();
// Pre-fill manual coords from observer location if available
const fallback = _getFallbackPos();
if (fallback) {
const latEl = document.getElementById('rfhmManualLat');
const lonEl = document.getElementById('rfhmManualLon');
if (latEl && !latEl.value) latEl.value = fallback.lat.toFixed(5);
if (lonEl && !lonEl.value) lonEl.value = fallback.lng.toFixed(5);
}
_updateSignalDisplay(null);
_updateSourceStatusUI();
_ensureLeafletHeat(() => {
setTimeout(() => {
_initMap();
_startGPS();
_startSignalStream();
}, 50);
});
}
function destroy() {
_active = false;
if (_isRecording) stopRecording();
if (_ownedSource) stopSource();
if (_gpsSource) { _gpsSource.close(); _gpsSource = null; }
if (_sigSource) { _sigSource.close(); _sigSource = null; }
}
return { init, destroy, setSource, setMinDist, startRecording, stopRecording, clearPoints, exportGeoJSON, invalidateMap, onSourceChange, setManualCoords, useObserverLocation, startSource, stopSource };
})();
window.RFHeatmap = RFHeatmap;

2134
static/js/modes/waterfall.js Normal file

File diff suppressed because it is too large Load Diff

297
static/js/vendor/leaflet-heat.js vendored Normal file
View File

@@ -0,0 +1,297 @@
/*
* Leaflet.heat — a tiny, fast Leaflet heatmap plugin
* https://github.com/Leaflet/Leaflet.heat
* (c) 2014, Vladimir Agafonkin
* MIT License
*
* Bundled local copy for INTERCEPT — avoids CDN dependency.
* Includes simpleheat (https://github.com/mourner/simpleheat), MIT License.
*/
// ---- simpleheat ----
(function (global, factory) {
typeof define === 'function' && define.amd ? define(factory) :
typeof exports !== 'undefined' ? module.exports = factory() :
global.simpleheat = factory();
}(this, function () {
'use strict';
function simpleheat(canvas) {
if (!(this instanceof simpleheat)) return new simpleheat(canvas);
this._canvas = canvas = typeof canvas === 'string' ? document.getElementById(canvas) : canvas;
this._ctx = canvas.getContext('2d');
this._width = canvas.width;
this._height = canvas.height;
this._max = 1;
this._data = [];
}
simpleheat.prototype = {
defaultRadius: 25,
defaultGradient: {
0.4: 'blue',
0.6: 'cyan',
0.7: 'lime',
0.8: 'yellow',
1.0: 'red'
},
data: function (data) {
this._data = data;
return this;
},
max: function (max) {
this._max = max;
return this;
},
add: function (point) {
this._data.push(point);
return this;
},
clear: function () {
this._data = [];
return this;
},
radius: function (r, blur) {
blur = blur === undefined ? 15 : blur;
var circle = this._circle = this._createCanvas(),
ctx = circle.getContext('2d'),
r2 = this._r = r + blur;
circle.width = circle.height = r2 * 2;
ctx.shadowOffsetX = ctx.shadowOffsetY = r2 * 2;
ctx.shadowBlur = blur;
ctx.shadowColor = 'black';
ctx.beginPath();
ctx.arc(-r2, -r2, r, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fill();
return this;
},
resize: function () {
this._width = this._canvas.width;
this._height = this._canvas.height;
},
gradient: function (grad) {
var canvas = this._createCanvas(),
ctx = canvas.getContext('2d'),
gradient = ctx.createLinearGradient(0, 0, 0, 256);
canvas.width = 1;
canvas.height = 256;
for (var i in grad) {
gradient.addColorStop(+i, grad[i]);
}
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 1, 256);
this._grad = ctx.getImageData(0, 0, 1, 256).data;
return this;
},
draw: function (minOpacity) {
if (!this._circle) this.radius(this.defaultRadius);
if (!this._grad) this.gradient(this.defaultGradient);
var ctx = this._ctx;
ctx.clearRect(0, 0, this._width, this._height);
for (var i = 0, len = this._data.length, p; i < len; i++) {
p = this._data[i];
ctx.globalAlpha = Math.min(Math.max(p[2] / this._max, minOpacity === undefined ? 0.05 : minOpacity), 1);
ctx.drawImage(this._circle, p[0] - this._r, p[1] - this._r);
}
var colored = ctx.getImageData(0, 0, this._width, this._height);
this._colorize(colored.data, this._grad);
ctx.putImageData(colored, 0, 0);
return this;
},
_colorize: function (pixels, gradient) {
for (var i = 3, len = pixels.length, j; i < len; i += 4) {
j = pixels[i] * 4;
if (j) {
pixels[i - 3] = gradient[j];
pixels[i - 2] = gradient[j + 1];
pixels[i - 1] = gradient[j + 2];
}
}
},
_createCanvas: function () {
if (typeof document !== 'undefined') {
return document.createElement('canvas');
}
return { getContext: function () {} };
}
};
return simpleheat;
}));
// ---- Leaflet.heat plugin ----
(function () {
if (typeof L === 'undefined') return;
L.HeatLayer = (L.Layer ? L.Layer : L.Class).extend({
initialize: function (latlngs, options) {
this._latlngs = latlngs;
L.setOptions(this, options);
},
setLatLngs: function (latlngs) {
this._latlngs = latlngs;
return this.redraw();
},
addLatLng: function (latlng) {
this._latlngs.push(latlng);
return this.redraw();
},
setOptions: function (options) {
L.setOptions(this, options);
if (this._heat) this._updateOptions();
return this.redraw();
},
redraw: function () {
if (this._heat && !this._frame && this._map && !this._map._animating) {
this._frame = L.Util.requestAnimFrame(this._redraw, this);
}
return this;
},
onAdd: function (map) {
this._map = map;
if (!this._canvas) this._initCanvas();
if (this.options.pane) this.getPane().appendChild(this._canvas);
else map._panes.overlayPane.appendChild(this._canvas);
map.on('moveend', this._reset, this);
if (map.options.zoomAnimation && L.Browser.any3d) {
map.on('zoomanim', this._animateZoom, this);
}
this._reset();
},
onRemove: function (map) {
if (this.options.pane) this.getPane().removeChild(this._canvas);
else map.getPanes().overlayPane.removeChild(this._canvas);
map.off('moveend', this._reset, this);
if (map.options.zoomAnimation) {
map.off('zoomanim', this._animateZoom, this);
}
},
addTo: function (map) {
map.addLayer(this);
return this;
},
_initCanvas: function () {
var canvas = this._canvas = L.DomUtil.create('canvas', 'leaflet-heatmap-layer leaflet-layer');
var originProp = L.DomUtil.testProp(['transformOrigin', 'WebkitTransformOrigin', 'msTransformOrigin']);
canvas.style[originProp] = '50% 50%';
var size = this._map.getSize();
canvas.width = size.x;
canvas.height = size.y;
var animated = this._map.options.zoomAnimation && L.Browser.any3d;
L.DomUtil.addClass(canvas, 'leaflet-zoom-' + (animated ? 'animated' : 'hide'));
this._heat = simpleheat(canvas);
this._updateOptions();
},
_updateOptions: function () {
this._heat.radius(this.options.radius || this._heat.defaultRadius, this.options.blur);
if (this.options.gradient) this._heat.gradient(this.options.gradient);
if (this.options.minOpacity) this._heat.minOpacity = this.options.minOpacity;
},
_reset: function () {
var topLeft = this._map.containerPointToLayerPoint([0, 0]);
L.DomUtil.setPosition(this._canvas, topLeft);
var size = this._map.getSize();
if (this._heat._width !== size.x) {
this._canvas.width = this._heat._width = size.x;
}
if (this._heat._height !== size.y) {
this._canvas.height = this._heat._height = size.y;
}
this._redraw();
},
_redraw: function () {
this._frame = null;
if (!this._map) return;
var data = [],
r = this._heat._r,
size = this._map.getSize(),
bounds = new L.Bounds(L.point([-r, -r]), size.add([r, r])),
max = this.options.max === undefined ? 1 : this.options.max,
maxZoom = this.options.maxZoom === undefined ? this._map.getMaxZoom() : this.options.maxZoom,
v = 1 / Math.pow(2, Math.max(0, Math.min(maxZoom - this._map.getZoom(), 12))),
cellSize = r / 2,
grid = [],
panePos = this._map._getMapPanePos(),
offsetX = panePos.x % cellSize,
offsetY = panePos.y % cellSize,
i, len, p, cell, x, y, j, len2, k;
for (i = 0, len = this._latlngs.length; i < len; i++) {
p = this._map.latLngToContainerPoint(this._latlngs[i]);
if (bounds.contains(p)) {
x = Math.floor((p.x - offsetX) / cellSize) + 2;
y = Math.floor((p.y - offsetY) / cellSize) + 2;
var alt = this._latlngs[i].alt !== undefined ? this._latlngs[i].alt :
this._latlngs[i][2] !== undefined ? +this._latlngs[i][2] : 1;
k = alt * v;
grid[y] = grid[y] || [];
cell = grid[y][x];
if (!cell) {
grid[y][x] = [p.x, p.y, k];
} else {
cell[0] = (cell[0] * cell[2] + p.x * k) / (cell[2] + k);
cell[1] = (cell[1] * cell[2] + p.y * k) / (cell[2] + k);
cell[2] += k;
}
}
}
for (i = 0, len = grid.length; i < len; i++) {
if (grid[i]) {
for (j = 0, len2 = grid[i].length; j < len2; j++) {
cell = grid[i][j];
if (cell) {
data.push([
Math.round(cell[0]),
Math.round(cell[1]),
Math.min(cell[2], max)
]);
}
}
}
}
this._heat.data(data).draw(this.options.minOpacity);
},
_animateZoom: function (e) {
var scale = this._map.getZoomScale(e.zoom),
offset = this._map._getCenterOffset(e.center)._multiplyBy(-scale).subtract(this._map._getMapPanePos());
if (L.DomUtil.setTransform) {
L.DomUtil.setTransform(this._canvas, offset, scale);
} else {
this._canvas.style[L.DomUtil.TRANSFORM] = L.DomUtil.getTranslateString(offset) + ' scale(' + scale + ')';
}
}
});
L.heatLayer = function (latlngs, options) {
return new L.HeatLayer(latlngs, options);
};
}());

16
static/manifest.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "INTERCEPT Signal Intelligence",
"short_name": "INTERCEPT",
"description": "Unified SIGINT platform for software-defined radio analysis",
"start_url": "/",
"display": "standalone",
"background_color": "#0b1118",
"theme_color": "#0b1118",
"icons": [
{
"src": "/static/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}

85
static/sw.js Normal file
View File

@@ -0,0 +1,85 @@
/* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */
const CACHE_NAME = 'intercept-v1';
const NETWORK_ONLY_PREFIXES = [
'/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/',
'/adsb/', '/ais/', '/acars/', '/aprs/', '/tscm/', '/satellite/',
'/meshtastic/', '/bt_locate/', '/listening/', '/sensor/', '/pager/',
'/sstv/', '/weather-sat/', '/subghz/', '/rtlamr/', '/dsc/', '/vdl2/',
'/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/',
'/recordings/', '/controller/', '/fingerprint/', '/ops/',
];
const STATIC_PREFIXES = [
'/static/css/',
'/static/js/',
'/static/icons/',
'/static/fonts/',
];
const CACHE_EXACT = ['/manifest.json'];
function isNetworkOnly(req) {
if (req.method !== 'GET') return true;
const accept = req.headers.get('Accept') || '';
if (accept.includes('text/event-stream')) return true;
const url = new URL(req.url);
return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p));
}
function isStaticAsset(req) {
const url = new URL(req.url);
if (CACHE_EXACT.includes(url.pathname)) return true;
return STATIC_PREFIXES.some(p => url.pathname.startsWith(p));
}
self.addEventListener('install', (e) => {
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
const req = e.request;
// Always bypass service worker for non-GET and streaming routes
if (isNetworkOnly(req)) {
e.respondWith(fetch(req));
return;
}
// Cache-first for static assets
if (isStaticAsset(req)) {
e.respondWith(
caches.open(CACHE_NAME).then(cache =>
cache.match(req).then(cached => {
if (cached) {
// Revalidate in background
fetch(req).then(res => {
if (res && res.status === 200) cache.put(req, res.clone());
}).catch(() => {});
return cached;
}
return fetch(req).then(res => {
if (res && res.status === 200) cache.put(req, res.clone());
return res;
});
})
)
);
return;
}
// Network-first for HTML pages
e.respondWith(
fetch(req).catch(() =>
caches.match(req).then(cached => cached || new Response('Offline', { status: 503 }))
)
);
});

View File

@@ -6,6 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iNTERCEPT // See the Invisible</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#0b1118">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/static/icons/icon.svg">
<!-- Disclaimer gate - must accept before seeing welcome page -->
<script>
// Check BEFORE page renders - if disclaimer not accepted, hide welcome page
@@ -65,7 +70,6 @@
window.INTERCEPT_MODE_STYLE_MAP = {
aprs: "{{ url_for('static', filename='css/modes/aprs.css') }}",
tscm: "{{ url_for('static', filename='css/modes/tscm.css') }}",
analytics: "{{ url_for('static', filename='css/modes/analytics.css') }}",
spystations: "{{ url_for('static', filename='css/modes/spy-stations.css') }}",
meshtastic: "{{ url_for('static', filename='css/modes/meshtastic.css') }}",
sstv: "{{ url_for('static', filename='css/modes/sstv.css') }}",
@@ -74,7 +78,10 @@
gps: "{{ url_for('static', filename='css/modes/gps.css') }}",
subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9",
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}"
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
waterfall: "{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck10",
rfheatmap: "{{ url_for('static', filename='css/modes/rfheatmap.css') }}",
fingerprint: "{{ url_for('static', filename='css/modes/fingerprint.css') }}"
};
window.INTERCEPT_MODE_STYLE_LOADED = {};
window.ensureModeStyles = function(mode) {
@@ -281,10 +288,6 @@
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
<span class="mode-name">TSCM</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('analytics')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg></span>
<span class="mode-name">Analytics</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('spystations')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span>
<span class="mode-name">Spy Stations</span>
@@ -293,6 +296,25 @@
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>
<span class="mode-name">WebSDR</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('rfheatmap')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></span>
<span class="mode-name">RF Heatmap</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('fingerprint')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12C2 6.5 6.5 2 12 2a10 10 0 0 1 8 4"/><path d="M5 19.5C5.5 18 6 15 6 12c0-.7.12-1.37.34-2"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/></svg></span>
<span class="mode-name">RF Fingerprint</span>
</button>
</div>
</div>
<!-- Signals (extended) -->
<div class="mode-category">
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/></svg></span> Spectrum</h3>
<div class="mode-grid mode-grid-compact">
<button class="mode-card mode-card-sm" onclick="selectMode('waterfall')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.5"/><path d="M2 21h20" opacity="0.3"/></svg></span>
<span class="mode-name">Waterfall</span>
</button>
</div>
</div>
</div>
@@ -604,8 +626,6 @@
{% include 'partials/modes/tscm.html' %}
{% include 'partials/modes/analytics.html' %}
{% include 'partials/modes/ais.html' %}
{% include 'partials/modes/spy-stations.html' %}
@@ -617,6 +637,9 @@
{% include 'partials/modes/subghz.html' %}
{% include 'partials/modes/bt_locate.html' %}
{% include 'partials/modes/waterfall.html' %}
{% include 'partials/modes/rfheatmap.html' %}
{% include 'partials/modes/fingerprint.html' %}
@@ -2177,7 +2200,7 @@
</div>
<!-- BT Locate SAR Dashboard -->
<div id="btLocateVisuals" class="btl-visuals-container" style="display: none;">
<div id="btLocateVisuals" class="btl-visuals-container" style="display: none; flex-direction: column; gap: 8px; flex: 1; min-height: 0; overflow: hidden; padding: 8px;">
<!-- Proximity HUD -->
<div class="btl-hud" id="btLocateHud" style="display: none;">
<div class="btl-hud-top">
@@ -2248,8 +2271,8 @@
<div id="btLocateDiag" class="btl-hud-diag"></div>
</div>
</div>
<div class="btl-map-container">
<div id="btLocateMap"></div>
<div class="btl-map-container" style="flex: 1; min-height: 250px; position: relative; overflow: hidden;">
<div id="btLocateMap" style="position: absolute; inset: 0;"></div>
<div class="btl-map-overlay-controls">
<label class="btl-map-overlay-toggle">
<input type="checkbox" id="btLocateHeatmapEnable" onchange="BtLocate.toggleHeatmap()">
@@ -3077,6 +3100,161 @@
</div>
</div>
<!-- Waterfall Visuals -->
<div id="waterfallVisuals" style="display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden;">
<div class="wf-container">
<div class="wf-headline">
<div class="wf-headline-left">
<span class="wf-headline-tag">SPECTRUM RECEIVER</span>
<span class="wf-headline-sub">Local SDR</span>
</div>
<div class="wf-headline-right">
<span class="wf-range-text" id="wfRangeDisplay">98.8000 - 101.2000 MHz</span>
<span class="wf-tune-text" id="wfTuneDisplay">Tune 100.0000 MHz</span>
</div>
</div>
<div class="wf-monitor-strip">
<div class="wf-rx-vfo">
<div class="wf-rx-vfo-top">
<span class="wf-rx-vfo-name">VFO-A</span>
<span class="wf-rx-vfo-status" id="wfVisualStatus">IDLE</span>
</div>
<div class="wf-rx-vfo-readout">
<span id="wfRxFreqReadout">100.0000</span>
<span class="wf-rx-vfo-unit">MHz</span>
</div>
<div class="wf-rx-vfo-bottom">
<span id="wfRxModeReadout">WFM</span>
<span id="wfRxStepReadout">STEP 100 kHz</span>
</div>
</div>
<div class="wf-rx-modebank" id="wfModeBank">
<button class="wf-mode-btn is-active" data-mode="wfm">WFM</button>
<button class="wf-mode-btn" data-mode="fm">NFM</button>
<button class="wf-mode-btn" data-mode="am">AM</button>
<button class="wf-mode-btn" data-mode="usb">USB</button>
<button class="wf-mode-btn" data-mode="lsb">LSB</button>
<select id="wfMonitorMode" class="wf-monitor-select wf-monitor-select-hidden">
<option value="wfm" selected>WFM</option>
<option value="fm">NFM</option>
<option value="am">AM</option>
<option value="usb">USB</option>
<option value="lsb">LSB</option>
</select>
</div>
<div class="wf-rx-levels">
<div class="wf-monitor-group">
<span class="wf-monitor-label">Squelch</span>
<div class="wf-monitor-slider-wrap">
<input type="range" id="wfMonitorSquelch" min="0" max="100" value="0">
<span id="wfMonitorSquelchValue" class="wf-monitor-value">0</span>
</div>
</div>
<div class="wf-monitor-group">
<span class="wf-monitor-label">Gain</span>
<div class="wf-monitor-slider-wrap">
<input type="range" id="wfMonitorGain" min="0" max="60" value="40">
<span id="wfMonitorGainValue" class="wf-monitor-value">40</span>
</div>
</div>
<div class="wf-monitor-group">
<span class="wf-monitor-label">Volume</span>
<div class="wf-monitor-slider-wrap">
<input type="range" id="wfMonitorVolume" min="0" max="100" value="82">
<span id="wfMonitorVolumeValue" class="wf-monitor-value">82</span>
</div>
</div>
</div>
<div class="wf-rx-meter-wrap">
<span class="wf-monitor-label">S-Meter</span>
<div class="wf-rx-smeter">
<div class="wf-rx-smeter-fill" id="wfSmeterBar"></div>
</div>
<div class="wf-rx-smeter-text" id="wfSmeterText">S0</div>
</div>
<div class="wf-rx-actions">
<div class="wf-rx-action-row">
<button class="wf-monitor-btn" id="wfMonitorBtn" onclick="Waterfall.toggleMonitor()">Monitor</button>
<button class="wf-monitor-btn wf-monitor-btn-secondary" id="wfMuteBtn" onclick="Waterfall.toggleMute()">Mute</button>
<button class="wf-monitor-btn wf-monitor-btn-unlock" id="wfAudioUnlockBtn" onclick="Waterfall.unlockAudio()" style="display:none;">Unlock Audio</button>
</div>
<div class="wf-monitor-state" id="wfMonitorState">No audio monitor</div>
</div>
<audio id="wfAudioPlayer" autoplay playsinline></audio>
</div>
<!-- Frequency control bar -->
<div class="wf-freq-bar">
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(-10)" title="Step down ×10">«</button>
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(-1)" title="Step down"></button>
<div class="wf-freq-display-wrap">
<span class="wf-freq-bar-label">CENTER</span>
<input type="text" id="wfFreqCenterDisplay" class="wf-freq-center-input" value="100.0000" inputmode="decimal" autocomplete="off" spellcheck="false">
<span class="wf-freq-bar-unit">MHz</span>
</div>
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(1)" title="Step up"></button>
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(10)" title="Step up ×10">»</button>
<div class="wf-freq-bar-sep"></div>
<span class="wf-freq-bar-label">STEP</span>
<select id="wfStepSize" class="wf-step-select">
<option value="0.001">1 kHz</option>
<option value="0.005">5 kHz</option>
<option value="0.01">10 kHz</option>
<option value="0.025">25 kHz</option>
<option value="0.05">50 kHz</option>
<option value="0.1" selected>100 kHz</option>
<option value="0.5">500 kHz</option>
<option value="1">1 MHz</option>
<option value="5">5 MHz</option>
</select>
<div class="wf-freq-bar-sep"></div>
<span class="wf-freq-bar-label">SPAN</span>
<span id="wfSpanDisplay" class="wf-span-display">2.4 MHz</span>
</div>
<!-- Spectrum canvas -->
<div class="wf-spectrum-canvas-wrap">
<canvas id="wfSpectrumCanvas"></canvas>
<div class="wf-center-line"></div>
<div class="wf-tune-line" id="wfTuneLineSpec"></div>
</div>
<!-- Drag handle to resize spectrum vs waterfall -->
<div class="wf-resize-handle" id="wfResizeHandle">
<div class="wf-resize-grip"></div>
</div>
<!-- Waterfall canvas -->
<div class="wf-waterfall-canvas-wrap">
<canvas id="wfWaterfallCanvas"></canvas>
<div class="wf-tooltip" id="wfTooltip"></div>
<div class="wf-center-line"></div>
<div class="wf-tune-line" id="wfTuneLineWf"></div>
</div>
<div class="wf-freq-axis" id="wfFreqAxis"></div>
</div>
</div>
<!-- RF Heatmap Visuals -->
<div id="rfheatmapVisuals" style="display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden;">
<div class="rfhm-map-container" style="flex: 1; min-height: 0; position: relative;">
<div id="rfheatmapMapEl" style="width: 100%; height: 100%;"></div>
</div>
</div>
<!-- Fingerprint Visuals -->
<div id="fingerprintVisuals" style="display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; padding: 10px; gap: 10px;">
<div class="fp-chart-container" style="flex: 1; min-height: 200px;">
<canvas id="fpChartCanvas"></canvas>
</div>
</div>
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
<div class="recon-panel collapsed" id="reconPanel">
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
@@ -3201,8 +3379,13 @@
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
<script src="{{ url_for('static', filename='js/modes/analytics.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix1"></script>
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/waterfall.js') }}?v={{ version }}&r=wfdeck10"></script>
<script src="{{ url_for('static', filename='js/modes/rfheatmap.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/fingerprint.js') }}"></script>
<script>
// ============================================
@@ -3353,9 +3536,11 @@
bt_locate: { label: 'BT Locate', indicator: 'BT LOCATE', outputTitle: 'BT Locate — SAR Tracker', group: 'wireless' },
meshtastic: { label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless' },
tscm: { label: 'TSCM', indicator: 'TSCM', outputTitle: 'TSCM Counter-Surveillance', group: 'intel' },
analytics: { label: 'Analytics', indicator: 'ANALYTICS', outputTitle: 'Cross-Mode Analytics', group: 'intel' },
spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
rfheatmap: { label: 'RF Heatmap', indicator: 'RF HEATMAP', outputTitle: 'RF Signal Heatmap', group: 'intel' },
fingerprint: { label: 'Fingerprint', indicator: 'RF FINGERPRINT', outputTitle: 'Signal Fingerprinting', group: 'intel' },
};
const validModes = new Set(Object.keys(modeCatalog));
window.interceptModeCatalog = Object.assign({}, modeCatalog);
@@ -3945,8 +4130,10 @@
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
document.getElementById('analyticsMode')?.classList.toggle('active', mode === 'analytics');
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall');
document.getElementById('rfheatmapMode')?.classList.toggle('active', mode === 'rfheatmap');
document.getElementById('fingerprintMode')?.classList.toggle('active', mode === 'fingerprint');
const pagerStats = document.getElementById('pagerStats');
@@ -3987,6 +4174,9 @@
const subghzVisuals = document.getElementById('subghzVisuals');
const btLocateVisuals = document.getElementById('btLocateVisuals');
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
const waterfallVisuals = document.getElementById('waterfallVisuals');
const rfheatmapVisuals = document.getElementById('rfheatmapVisuals');
const fingerprintVisuals = document.getElementById('fingerprintVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
@@ -4003,6 +4193,9 @@
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
if (waterfallVisuals) waterfallVisuals.style.display = (mode === 'waterfall' || mode === 'listening') ? 'flex' : 'none';
if (rfheatmapVisuals) rfheatmapVisuals.style.display = mode === 'rfheatmap' ? 'flex' : 'none';
if (fingerprintVisuals) fingerprintVisuals.style.display = mode === 'fingerprint' ? 'flex' : 'none';
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
@@ -4017,8 +4210,6 @@
} else {
mainContent.classList.remove('mesh-sidebar-hidden');
}
// Analytics is sidebar-only — hide output panel and expand sidebar
mainContent.classList.toggle('analytics-active', mode === 'analytics');
}
// Show/hide mode-specific timeline containers
@@ -4040,15 +4231,6 @@
refreshTscmDevices();
}
// Initialize/destroy Analytics mode
if (mode === 'analytics') {
// Expand all analytics sections (sidebar sections default to collapsed)
document.querySelectorAll('#analyticsMode .section.collapsed').forEach(s => s.classList.remove('collapsed'));
if (typeof Analytics !== 'undefined') Analytics.init();
} else {
if (typeof Analytics !== 'undefined' && Analytics.destroy) Analytics.destroy();
}
// Initialize/destroy Space Weather mode
if (mode !== 'spaceweather') {
if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy();
@@ -4063,7 +4245,7 @@
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') {
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'rfheatmap' || mode === 'fingerprint') {
if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -4078,7 +4260,7 @@
// Show agent selector for modes that support remote agents
const agentSection = document.getElementById('agentSection');
const agentModes = ['pager', 'sensor', 'rtlamr', 'listening', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais', 'dsc'];
const agentModes = ['pager', 'sensor', 'rtlamr', 'listening', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais'];
if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none';
// Show RTL-SDR device section for modes that use it
@@ -4101,8 +4283,8 @@
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather') ? 'none' : 'flex';
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'rfheatmap' || mode === 'fingerprint') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
if (mode !== 'meshtastic') {
@@ -4139,6 +4321,7 @@
if (typeof checkIncomingTuneRequest === 'function') {
checkIncomingTuneRequest();
}
if (typeof Waterfall !== 'undefined') Waterfall.init();
} else if (mode === 'spystations') {
SpyStations.init();
} else if (mode === 'meshtastic') {
@@ -4175,6 +4358,20 @@
}, 320);
} else if (mode === 'spaceweather') {
SpaceWeather.init();
} else if (mode === 'waterfall') {
if (typeof Waterfall !== 'undefined') Waterfall.init();
} else if (mode === 'rfheatmap') {
if (typeof RFHeatmap !== 'undefined') {
RFHeatmap.init();
setTimeout(() => RFHeatmap.invalidateMap(), 100);
}
} else if (mode === 'fingerprint') {
if (typeof Fingerprint !== 'undefined') Fingerprint.init();
}
// Destroy Waterfall WebSocket when leaving SDR receiver modes
if (mode !== 'waterfall' && mode !== 'listening' && typeof Waterfall !== 'undefined' && Waterfall.destroy) {
Promise.resolve(Waterfall.destroy()).catch(() => {});
}
}
@@ -15103,6 +15300,49 @@
<script src="{{ url_for('static', filename='js/core/run-state.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/command-palette.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/first-run-setup.js') }}"></script>
<!-- Cheat Sheet Modal -->
<div id="cheatSheetModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:10000; align-items:center; justify-content:center; padding:20px;" onclick="if(event.target===this)CheatSheets.hide()">
<div style="background:var(--bg-card, #1a1f2e); border:1px solid rgba(255,255,255,0.15); border-radius:12px; max-width:480px; width:100%; max-height:80vh; overflow-y:auto; padding:20px; position:relative;">
<button onclick="CheatSheets.hide()" style="position:absolute; top:12px; right:12px; background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:18px; line-height:1;"></button>
<div id="cheatSheetContent"></div>
</div>
</div>
<!-- Keyboard Shortcuts Modal -->
<div id="kbShortcutsModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:10000; align-items:center; justify-content:center; padding:20px;" onclick="if(event.target===this)KeyboardShortcuts.hideHelp()">
<div style="background:var(--bg-card, #1a1f2e); border:1px solid rgba(255,255,255,0.15); border-radius:12px; max-width:520px; width:100%; max-height:80vh; overflow-y:auto; padding:20px; position:relative;">
<button onclick="KeyboardShortcuts.hideHelp()" style="position:absolute; top:12px; right:12px; background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:18px; line-height:1;"></button>
<h2 style="margin:0 0 16px; font-size:16px; color:var(--accent-cyan, #4aa3ff); font-family:var(--font-mono);">Keyboard Shortcuts</h2>
<table style="width:100%; border-collapse:collapse; font-family:var(--font-mono); font-size:12px;">
<tbody>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+W</td><td style="padding:6px 8px; color:var(--text-secondary);">Switch to Waterfall</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+H</td><td style="padding:6px 8px; color:var(--text-secondary);">Switch to RF Heatmap</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+N</td><td style="padding:6px 8px; color:var(--text-secondary);">Switch to Fingerprint</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+M</td><td style="padding:6px 8px; color:var(--text-secondary);">Toggle voice mute</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+S</td><td style="padding:6px 8px; color:var(--text-secondary);">Toggle sidebar</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+K / ?</td><td style="padding:6px 8px; color:var(--text-secondary);">Show keyboard shortcuts</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+C</td><td style="padding:6px 8px; color:var(--text-secondary);">Show cheat sheet for current mode</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+1..9</td><td style="padding:6px 8px; color:var(--text-secondary);">Switch to Nth mode in current group</td></tr>
<tr><td style="padding:6px 8px; color:var(--accent-cyan);">Escape</td><td style="padding:6px 8px; color:var(--text-secondary);">Close modal / Return to welcome</td></tr>
</tbody>
</table>
</div>
</div>
<!-- PWA Service Worker Registration -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/sw.js').catch(() => {});
});
}
// Initialize global core modules after page load
window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
});
</script>
</body>
</html>

View File

@@ -1,211 +0,0 @@
<!-- ANALYTICS MODE -->
<div id="analyticsMode" class="mode-content">
{# Analytics Dashboard Sidebar Panel #}
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Summary</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-grid" id="analyticsSummaryCards">
<div class="analytics-card" data-mode="adsb">
<div class="card-count" id="analyticsCountAdsb">0</div>
<div class="card-label">Aircraft</div>
<div class="card-sparkline" id="analyticsSparkAdsb"></div>
</div>
<div class="analytics-card" data-mode="ais">
<div class="card-count" id="analyticsCountAis">0</div>
<div class="card-label">Vessels</div>
<div class="card-sparkline" id="analyticsSparkAis"></div>
</div>
<div class="analytics-card" data-mode="wifi">
<div class="card-count" id="analyticsCountWifi">0</div>
<div class="card-label">WiFi</div>
<div class="card-sparkline" id="analyticsSparkWifi"></div>
</div>
<div class="analytics-card" data-mode="bluetooth">
<div class="card-count" id="analyticsCountBt">0</div>
<div class="card-label">Bluetooth</div>
<div class="card-sparkline" id="analyticsSparkBt"></div>
</div>
<div class="analytics-card" data-mode="dsc">
<div class="card-count" id="analyticsCountDsc">0</div>
<div class="card-label">DSC</div>
<div class="card-sparkline" id="analyticsSparkDsc"></div>
</div>
<div class="analytics-card" data-mode="acars">
<div class="card-count" id="analyticsCountAcars">0</div>
<div class="card-label">ACARS</div>
<div class="card-sparkline" id="analyticsSparkAcars"></div>
</div>
<div class="analytics-card" data-mode="vdl2">
<div class="card-count" id="analyticsCountVdl2">0</div>
<div class="card-label">VDL2</div>
<div class="card-sparkline" id="analyticsSparkVdl2"></div>
</div>
<div class="analytics-card" data-mode="aprs">
<div class="card-count" id="analyticsCountAprs">0</div>
<div class="card-label">APRS</div>
<div class="card-sparkline" id="analyticsSparkAprs"></div>
</div>
<div class="analytics-card" data-mode="meshtastic">
<div class="card-count" id="analyticsCountMesh">0</div>
<div class="card-label">Mesh</div>
<div class="card-sparkline" id="analyticsSparkMesh"></div>
</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Operational Insights</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-insight-grid" id="analyticsInsights">
<div class="analytics-empty">Insights loading...</div>
</div>
<div class="analytics-top-changes">
<div class="analytics-section-header">Top Changes</div>
<div id="analyticsTopChanges">
<div class="analytics-empty">No change signals yet</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Mode Health</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-health" id="analyticsHealth"></div>
</div>
</div>
<div class="section" id="analyticsSquawkSection" style="display:none;">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Emergency Squawks</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="squawk-emergency" id="analyticsSquawkPanel">
<div class="squawk-title">Active Emergency Codes</div>
<div id="analyticsSquawkList"></div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Temporal Patterns</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div id="analyticsPatternList">
<div class="analytics-empty">No recurring patterns detected</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Recent Alerts</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-alert-feed" id="analyticsAlertFeed">
<div class="analytics-empty">No recent alerts</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Correlations</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div id="analyticsCorrelations">
<div class="analytics-empty">No correlations detected</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Geofences</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div id="analyticsGeofenceList"></div>
<button class="btn btn-sm" onclick="Analytics.addGeofence()" style="margin-top:8px; font-size:10px; padding:4px 10px; background:var(--accent-cyan); color:#fff; border:none; border-radius:4px; cursor:pointer;">
+ Add Zone
</button>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Target View</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-target-toolbar">
<input id="analyticsTargetQuery" type="text" placeholder="Search callsign, ICAO, MMSI, MAC, SSID, node..." onkeydown="if(event.key==='Enter'){Analytics.searchTarget();}">
<button onclick="Analytics.searchTarget()">Search</button>
</div>
<div id="analyticsTargetSummary" class="analytics-target-summary">Search to correlate entities across modes</div>
<div id="analyticsTargetResults">
<div class="analytics-empty">No target selected</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Session Replay</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-replay-toolbar">
<select id="analyticsReplaySelect"></select>
<button onclick="Analytics.loadReplay()">Load</button>
<button onclick="Analytics.playReplay()">Play</button>
<button onclick="Analytics.pauseReplay()">Pause</button>
<button onclick="Analytics.stepReplay()">Step</button>
</div>
<div id="analyticsReplayMeta" class="analytics-target-summary">No replay loaded</div>
<div id="analyticsReplayTimeline">
<div class="analytics-empty">Select a recording to replay key events</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Export Data</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="export-controls">
<select id="exportMode">
<option value="adsb">ADS-B</option>
<option value="ais">AIS</option>
<option value="wifi">WiFi</option>
<option value="bluetooth">Bluetooth</option>
<option value="dsc">DSC</option>
</select>
<select id="exportFormat">
<option value="json">JSON</option>
<option value="csv">CSV</option>
</select>
<button onclick="Analytics.exportData()">Export</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,115 @@
<!-- FINGERPRINT MODE -->
<div id="fingerprintMode" class="mode-content">
<!-- Intro -->
<div class="section">
<div style="font-size:11px; color:var(--text-dim); line-height:1.6;">
RF Fingerprinting captures the baseline radio environment at a location.
Record a baseline when the environment is "clean", then compare later to
detect new transmitters, surveillance devices, or signal anomalies.
</div>
</div>
<!-- Workflow tab selector -->
<div class="section">
<h3>Workflow</h3>
<div style="display:flex; gap:4px;">
<button class="fp-tab-btn active" id="fpTabRecord" onclick="Fingerprint.showTab('record')">
1 — Record
</button>
<button class="fp-tab-btn" id="fpTabCompare" onclick="Fingerprint.showTab('compare')">
2 — Compare
</button>
</div>
<div id="fpTabHint" style="margin-top:8px; font-size:11px; color:var(--text-dim); line-height:1.5;">
Record a <strong style="color:var(--text-secondary);">baseline</strong> in a known-clean RF environment, then use <strong style="color:var(--text-secondary);">Compare</strong> later to detect new or anomalous signals.
</div>
</div>
<!-- Record tab -->
<div id="fpRecordPanel">
<div class="section">
<h3>Step 1 — Select Device</h3>
<div class="form-group">
<label>SDR Device</label>
<select id="fpDevice">
<option value="">Loading…</option>
</select>
</div>
</div>
<div class="section">
<h3>Step 2 — Scanner Status</h3>
<div style="display:flex; align-items:center; gap:8px; padding:6px 0;">
<span id="fpScannerDot" style="width:8px; height:8px; border-radius:50%; background:rgba(255,255,255,0.2); flex-shrink:0;"></span>
<span id="fpScannerStatusText" style="font-size:11px; color:var(--text-secondary); flex:1;">Checking…</span>
</div>
<div style="display:flex; gap:6px;">
<button class="run-btn" id="fpScannerStartBtn" onclick="Fingerprint.startScanner()" style="flex:1;">Start Scanner</button>
<button class="stop-btn" id="fpScannerStopBtn" onclick="Fingerprint.stopScanner()" style="flex:1; display:none;">Stop Scanner</button>
</div>
</div>
<div class="section">
<h3>Step 3 — Record Baseline</h3>
<div class="form-group">
<label>Session Name</label>
<input type="text" id="fpSessionName" placeholder="e.g. Office — Mon morning">
</div>
<div class="form-group">
<label>Location <span style="color:var(--text-dim); font-weight:normal;">(optional)</span></label>
<input type="text" id="fpSessionLocation" placeholder="e.g. 3rd floor, room 301">
</div>
<div style="display:flex; align-items:center; gap:10px; margin:6px 0;">
<span style="font-size:10px; color:var(--text-dim); text-transform:uppercase; letter-spacing:.05em;">Observations</span>
<span id="fpObsCount" style="font-size:14px; font-family:var(--font-mono); color:var(--accent-cyan, #4aa3ff);">0</span>
</div>
<div id="fpRecordStatus" style="font-size:11px; color:var(--text-dim); margin-bottom:6px; min-height:14px;"></div>
<button class="run-btn" id="fpStartBtn" onclick="Fingerprint.startRecording()">Start Recording</button>
<button class="stop-btn" id="fpStopBtn" style="display:none;" onclick="Fingerprint.stopRecording()">Stop &amp; Save</button>
</div>
</div>
<!-- Compare tab -->
<div id="fpComparePanel" style="display:none;">
<div class="section">
<h3>How It Works</h3>
<div style="font-size:11px; color:var(--text-dim); line-height:1.6;">
<div style="display:flex; gap:8px; align-items:flex-start; margin-bottom:6px;">
<span style="color:var(--accent-cyan); font-weight:700; flex-shrink:0;">1.</span>
<span>Ensure the scanner is running (switch to Record tab to start it).</span>
</div>
<div style="display:flex; gap:8px; align-items:flex-start; margin-bottom:6px;">
<span style="color:var(--accent-cyan); font-weight:700; flex-shrink:0;">2.</span>
<span>Select a previously recorded baseline below.</span>
</div>
<div style="display:flex; gap:8px; align-items:flex-start; margin-bottom:6px;">
<span style="color:var(--accent-cyan); font-weight:700; flex-shrink:0;">3.</span>
<span>Click <strong style="color:var(--text-secondary);">Compare Now</strong> — a 3-second live scan is collected.</span>
</div>
<div style="display:flex; gap:8px; align-items:flex-start;">
<span style="color:var(--accent-cyan); font-weight:700; flex-shrink:0;">4.</span>
<span>Anomalies are scored by z-score. <span style="color:#ef4444;">Red = strong deviation</span>, <span style="color:#a855f7;">purple = new signal</span>.</span>
</div>
</div>
</div>
<div class="section">
<h3>Baseline</h3>
<div class="form-group">
<label>Session</label>
<select id="fpBaselineSelect">
<option value="">No baselines saved yet</option>
</select>
</div>
<div id="fpCompareStatus" style="font-size:11px; color:var(--text-dim); margin-bottom:6px; min-height:14px;"></div>
<button class="run-btn" onclick="Fingerprint.compareNow()">Compare Now</button>
</div>
<div class="section" id="fpAnomalyList" style="display:none;">
<h3>Anomalies</h3>
<div id="fpAnomalyItems"></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,126 @@
<!-- RF HEATMAP MODE -->
<div id="rfheatmapMode" class="mode-content">
<!-- What is this? -->
<div class="section">
<h3>RF Heatmap</h3>
<div style="background:rgba(74,163,255,0.07); border:1px solid rgba(74,163,255,0.2); border-radius:6px; padding:10px; font-size:11px; color:var(--text-secondary); line-height:1.6;">
Walk around with INTERCEPT running. Your GPS position and the current signal strength are saved as a point on the map every few metres. The result is a <strong style="color:var(--accent-cyan);">coverage heatmap</strong> — bright areas have strong signal, dark areas are weak or absent.
</div>
</div>
<!-- Step 1 — Signal source -->
<div class="section">
<h3><span style="color:var(--accent-cyan); margin-right:6px;">1</span>What to Map</h3>
<div class="form-group">
<label>Signal Source</label>
<select id="rfhmSource" onchange="RFHeatmap.setSource(this.value); RFHeatmap.onSourceChange()">
<option value="wifi">WiFi — RSSI of nearby networks</option>
<option value="bluetooth">Bluetooth — RSSI of nearby devices</option>
<option value="scanner">SDR Scanner — broadband RF power</option>
</select>
</div>
<!-- SDR device picker — only shown for Scanner source -->
<div id="rfhmDeviceGroup" style="display:none;">
<div class="form-group">
<label>SDR Device</label>
<select id="rfhmDevice">
<option value="">Loading…</option>
</select>
</div>
</div>
<div id="rfhmSourceHint" style="font-size:11px; color:var(--text-dim); margin-top:4px; line-height:1.5;">
Walk near WiFi access points — their signal strength at each location is recorded.
</div>
<!-- Source running status + inline start/stop -->
<div id="rfhmSourceStatusRow" style="margin-top:10px; padding:8px 10px; background:rgba(0,0,0,0.3); border-radius:6px;">
<div style="display:flex; align-items:center; gap:7px; margin-bottom:6px;">
<span id="rfhmSourceDot" style="width:7px; height:7px; border-radius:50%; background:rgba(255,255,255,0.2); flex-shrink:0;"></span>
<span id="rfhmSourceStatusText" style="font-size:11px; color:var(--text-dim);">Checking…</span>
</div>
<button id="rfhmSourceStartBtn" class="run-btn" style="padding:6px; font-size:11px;" onclick="RFHeatmap.startSource()">Start Scanner</button>
<button id="rfhmSourceStopBtn" class="stop-btn" style="display:none; padding:6px; font-size:11px;" onclick="RFHeatmap.stopSource()">Stop Scanner</button>
</div>
</div>
<!-- Step 2 — Location -->
<div class="section">
<h3><span style="color:var(--accent-cyan); margin-right:6px;">2</span>Your Location</h3>
<div style="display:flex; justify-content:space-between; align-items:center; padding:6px 0; border-bottom:1px solid rgba(255,255,255,0.06);">
<span style="font-size:10px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.05em;">GPS</span>
<span id="rfhmGpsPill" style="font-family:var(--font-mono); font-size:11px; color:var(--text-dim);">No Fix</span>
</div>
<div style="margin-top:8px;">
<div style="font-size:10px; color:var(--text-muted); margin-bottom:6px; line-height:1.5;">
No GPS? Enter a fixed location to map signals from a stationary point.
</div>
<div style="display:flex; gap:6px;">
<div class="form-group" style="flex:1; margin-bottom:0;">
<label>Latitude</label>
<input type="number" id="rfhmManualLat" step="0.0001" placeholder="37.7749" oninput="RFHeatmap.setManualCoords()">
</div>
<div class="form-group" style="flex:1; margin-bottom:0;">
<label>Longitude</label>
<input type="number" id="rfhmManualLon" step="0.0001" placeholder="-122.4194" oninput="RFHeatmap.setManualCoords()">
</div>
</div>
<button class="preset-btn" onclick="RFHeatmap.useObserverLocation()" style="font-size:10px; margin-top:5px;">
Use Saved Observer Location
</button>
</div>
</div>
<!-- Step 3 — Verify live signal -->
<div class="section">
<h3><span style="color:var(--accent-cyan); margin-right:6px;">3</span>Live Signal</h3>
<div style="background:rgba(0,0,0,0.3); border-radius:6px; padding:10px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span style="font-size:10px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.05em;">Current</span>
<span id="rfhmLiveSignal" style="font-family:var(--font-mono); font-size:16px; color:var(--text-dim);">— dBm</span>
</div>
<!-- Signal strength bar -->
<div style="height:4px; background:rgba(255,255,255,0.08); border-radius:2px; overflow:hidden;">
<div id="rfhmSignalBar" style="height:100%; width:0%; background:var(--accent-cyan); border-radius:2px; transition:width 0.3s ease;"></div>
</div>
<div id="rfhmSignalStatus" style="font-size:10px; color:var(--text-dim); margin-top:5px;">Waiting for signal data…</div>
</div>
</div>
<!-- Step 4 — Record -->
<div class="section">
<h3><span style="color:var(--accent-cyan); margin-right:6px;">4</span>Record</h3>
<div class="form-group">
<label>Sample Every</label>
<div style="display:flex; align-items:center; gap:8px;">
<input type="range" id="rfhmMinDist" min="1" max="50" value="5" step="1" style="flex:1;"
oninput="document.getElementById('rfhmMinDistVal').textContent=this.value+'m'; RFHeatmap.setMinDist(parseInt(this.value))">
<span id="rfhmMinDistVal" style="font-family:var(--font-mono); font-size:11px; color:var(--accent-cyan); min-width:28px; text-align:right;">5m</span>
</div>
<div style="font-size:10px; color:var(--text-dim); margin-top:3px;">A new point is added after you move this distance.</div>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; padding:6px 0; margin-bottom:4px; border-top:1px solid rgba(255,255,255,0.06);">
<span style="font-size:10px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.05em;">Points Captured</span>
<span id="rfhmPointCount" style="font-family:var(--font-mono); font-size:14px; color:var(--accent-cyan);">0</span>
</div>
<button class="run-btn" id="rfhmRecordBtn" onclick="RFHeatmap.startRecording()">Start Recording</button>
<button class="stop-btn" id="rfhmStopBtn" style="display:none;" onclick="RFHeatmap.stopRecording()">Stop Recording</button>
</div>
<!-- Map actions -->
<div class="section">
<h3>Map</h3>
<div style="display:flex; gap:6px;">
<button class="preset-btn" style="flex:1;" onclick="RFHeatmap.clearPoints()">Clear</button>
<button class="preset-btn" style="flex:1;" onclick="RFHeatmap.exportGeoJSON()">Export GeoJSON</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,142 @@
<!-- WATERFALL MODE -->
<div id="waterfallMode" class="mode-content">
<div class="section">
<h3>Spectrum Waterfall</h3>
<div style="font-size:11px; color:var(--text-secondary); line-height:1.45;">
Click spectrum or waterfall to tune. Scroll to step-tune. Ctrl/Cmd + scroll to zoom span.
</div>
</div>
<div class="section">
<h3>Device</h3>
<div class="form-group">
<label>SDR Device</label>
<select id="wfDevice" onchange="Waterfall && Waterfall.onDeviceChange && Waterfall.onDeviceChange()">
<option value="">Loading devices...</option>
</select>
</div>
<div id="wfDeviceInfo" style="display:none; background:rgba(0,0,0,0.32); border:1px solid rgba(74,163,255,0.22); border-radius:6px; padding:8px; margin-top:6px; font-size:11px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
<span style="color:var(--text-muted); text-transform:uppercase; font-size:10px; letter-spacing:.05em;">Type</span>
<span id="wfDeviceType" style="color:var(--accent-cyan); font-family:var(--font-mono);">--</span>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
<span style="color:var(--text-muted); text-transform:uppercase; font-size:10px; letter-spacing:.05em;">Range</span>
<span id="wfDeviceRange" style="color:var(--text-secondary); font-family:var(--font-mono);">--</span>
</div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<span style="color:var(--text-muted); text-transform:uppercase; font-size:10px; letter-spacing:.05em;">Capture SR</span>
<span id="wfDeviceBw" style="color:var(--text-secondary); font-family:var(--font-mono);">--</span>
</div>
</div>
</div>
<div class="section">
<h3>Tuning</h3>
<div class="form-group">
<label>Center Frequency (MHz)</label>
<input type="number" id="wfCenterFreq" value="100.0000" step="0.001" min="0.001" max="6000">
</div>
<div class="form-group">
<label>Span (MHz)</label>
<input type="number" id="wfSpanMhz" value="2.4" step="0.1" min="0.05" max="30">
</div>
<div class="button-group" style="display:grid; grid-template-columns:1fr 1fr; gap:6px;">
<button class="preset-btn" onclick="Waterfall.applyPreset('fm')">FM Broadcast</button>
<button class="preset-btn" onclick="Waterfall.applyPreset('air')">Airband</button>
<button class="preset-btn" onclick="Waterfall.applyPreset('marine')">Marine</button>
<button class="preset-btn" onclick="Waterfall.applyPreset('ham2m')">2m Ham</button>
</div>
</div>
<div class="section">
<h3>Capture</h3>
<div class="form-group">
<label>Gain <span style="color:var(--text-dim); font-weight:normal;">(dB or AUTO)</span></label>
<input type="text" id="wfGain" value="AUTO" placeholder="AUTO or numeric">
</div>
<div class="form-group">
<label>FFT Size</label>
<select id="wfFftSize">
<option value="256">256</option>
<option value="512">512</option>
<option value="1024" selected>1024</option>
<option value="2048">2048</option>
<option value="4096">4096</option>
</select>
</div>
<div class="form-group">
<label>Frame Rate</label>
<select id="wfFps">
<option value="10">10 fps</option>
<option value="20" selected>20 fps</option>
<option value="30">30 fps</option>
<option value="40">40 fps</option>
</select>
</div>
<div class="form-group">
<label>FFT Averaging</label>
<select id="wfAvgCount">
<option value="1">1 (none)</option>
<option value="2">2</option>
<option value="4" selected>4</option>
<option value="8">8</option>
<option value="16">16</option>
</select>
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="number" id="wfPpm" value="0" step="1" min="-200" max="200" placeholder="0">
</div>
<div class="checkbox-group" style="margin-top:8px;">
<label>
<input type="checkbox" id="wfBiasT">
Bias-T (antenna power)
</label>
</div>
</div>
<div class="section">
<h3>Display</h3>
<div class="form-group">
<label>Color Palette</label>
<select id="wfPalette" onchange="Waterfall.setPalette(this.value)">
<option value="turbo" selected>Turbo</option>
<option value="plasma">Plasma</option>
<option value="inferno">Inferno</option>
<option value="viridis">Viridis</option>
</select>
</div>
<div class="form-group">
<label>Noise Floor (dB)</label>
<input type="number" id="wfDbMin" value="-100" step="5" disabled>
</div>
<div class="form-group">
<label>Ceiling (dB)</label>
<input type="number" id="wfDbMax" value="-20" step="5" disabled>
</div>
<div class="checkbox-group" style="margin-top:8px;">
<label>
<input type="checkbox" id="wfPeakHold" onchange="Waterfall.togglePeakHold(this.checked)">
Peak Hold
</label>
<label>
<input type="checkbox" id="wfBandAnnotations" checked onchange="Waterfall.toggleAnnotations(this.checked)">
Band Labels
</label>
<label>
<input type="checkbox" id="wfAutoRange" checked onchange="Waterfall.toggleAutoRange(this.checked)">
Auto Range
</label>
</div>
</div>
<div class="section">
<button class="run-btn" id="wfStartBtn" onclick="Waterfall.start()">Start Waterfall</button>
<button class="stop-btn" id="wfStopBtn" style="display:none;" onclick="Waterfall.stop()">Stop Waterfall</button>
<div id="wfStatus" style="margin-top:8px; font-size:11px; color:var(--text-dim);"></div>
<div style="margin-top:6px; font-size:10px; color:var(--text-muted);">
Tune with click. Use Monitor in the top strip for audio listen.
</div>
</div>
</div>

View File

@@ -67,6 +67,7 @@
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
{{ mode_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg>') }}
</div>
</div>
@@ -133,9 +134,10 @@
<div class="mode-nav-dropdown-menu">
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
{{ mode_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mode_item('rfheatmap', 'RF Heatmap', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/><path d="M2 10h4M18 10h4" opacity="0.4"/></svg>') }}
{{ mode_item('fingerprint', 'RF Fingerprint', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12C2 6.5 6.5 2 12 2a10 10 0 0 1 8 4"/><path d="M5 19.5C5.5 18 6 15 6 12c0-.7.12-1.37.34-2"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/></svg>') }}
</div>
</div>
@@ -177,6 +179,12 @@
<button type="button" class="nav-tool-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
</button>
<button type="button" class="nav-tool-btn" id="voiceMuteBtn" onclick="window.VoiceAlerts && VoiceAlerts.toggleMute()" title="Toggle voice alerts" aria-label="Toggle voice alerts">
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg></span>
</button>
<button type="button" class="nav-tool-btn" onclick="window.KeyboardShortcuts && KeyboardShortcuts.showHelp()" title="Keyboard shortcuts (Alt+K)" aria-label="Keyboard shortcuts">
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10"/></svg></span>
</button>
<button type="button" class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation" aria-label="Open help">?</button>
<button type="button" class="nav-tool-btn" onclick="logout(event)" title="Logout" aria-label="Logout">
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
@@ -215,9 +223,12 @@
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{# Intel #}
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
{{ mobile_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{# New modes #}
{{ mobile_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h4l3-8 3 16 3-8h4"/></svg>') }}
{{ mobile_item('rfheatmap', 'RF Map', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>') }}
{{ mobile_item('fingerprint', 'Fprint', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12C2 6.5 6.5 2 12 2a10 10 0 0 1 8 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/></svg>') }}
</nav>
{# JavaScript stub for pages that don't have switchMode defined #}

View File

@@ -284,6 +284,93 @@
<!-- Alerts Section -->
<div id="settings-alerts" class="settings-section">
<div class="settings-group">
<div class="settings-group-title">Voice Alerts</div>
<p style="color: var(--text-dim); margin-bottom: 10px; font-size: 12px;">
Configure which events trigger spoken alerts and adjust voice settings.
</p>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Pager Messages</span>
<span class="settings-label-desc">Speak decoded pager messages</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="voiceCfgPager" checked onchange="saveVoiceAlertConfig()">
<span class="toggle-slider"></span>
</label>
</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">TSCM Alerts</span>
<span class="settings-label-desc">Speak counter-surveillance detections</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="voiceCfgTscm" checked onchange="saveVoiceAlertConfig()">
<span class="toggle-slider"></span>
</label>
</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Tracker Detection</span>
<span class="settings-label-desc">Speak when AirTag, Tile, or SmartTag found</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="voiceCfgTracker" checked onchange="saveVoiceAlertConfig()">
<span class="toggle-slider"></span>
</label>
</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Emergency Squawks</span>
<span class="settings-label-desc">Speak aircraft emergency transponder codes</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="voiceCfgSquawk" checked onchange="saveVoiceAlertConfig()">
<span class="toggle-slider"></span>
</label>
</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Voice</span>
<span class="settings-label-desc">Speech synthesis voice</span>
</div>
<select id="voiceCfgVoice" class="settings-select" style="width: 200px;" onchange="saveVoiceAlertConfig()">
<option value="">Default</option>
</select>
</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Rate</span>
<span class="settings-label-desc">Speech speed (0.5 2.0)</span>
</div>
<div style="display:flex; align-items:center; gap:8px; width:200px;">
<input type="range" id="voiceCfgRate" min="0.5" max="2.0" step="0.1" value="1.1" style="flex:1;" oninput="document.getElementById('voiceCfgRateVal').textContent=this.value; saveVoiceAlertConfig();">
<span id="voiceCfgRateVal" style="font-family:var(--font-mono); font-size:11px; color:var(--text-dim); min-width:28px; text-align:right;">1.1</span>
</div>
</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Pitch</span>
<span class="settings-label-desc">Voice pitch (0.5 2.0)</span>
</div>
<div style="display:flex; align-items:center; gap:8px; width:200px;">
<input type="range" id="voiceCfgPitch" min="0.5" max="2.0" step="0.1" value="0.9" style="flex:1;" oninput="document.getElementById('voiceCfgPitchVal').textContent=this.value; saveVoiceAlertConfig();">
<span id="voiceCfgPitchVal" style="font-family:var(--font-mono); font-size:11px; color:var(--text-dim); min-width:28px; text-align:right;">0.9</span>
</div>
</div>
<div style="margin-top: 8px;">
<button class="check-assets-btn" onclick="testVoiceAlert()">Test Voice</button>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
<div id="alertsFeedList" class="settings-feed">
@@ -316,7 +403,6 @@
<option value="acars">ACARS</option>
<option value="vdl2">VDL2</option>
<option value="aprs">APRS</option>
<option value="dsc">DSC</option>
<option value="meshtastic">Meshtastic</option>
</select>
</div>
@@ -392,7 +478,6 @@
<option value="bluetooth">Bluetooth</option>
<option value="adsb">ADS-B</option>
<option value="ais">AIS</option>
<option value="dsc">DSC</option>
<option value="acars">ACARS</option>
<option value="aprs">APRS</option>
<option value="rtlamr">RTLAMR</option>

View File

@@ -1,202 +0,0 @@
"""Tests for analytics endpoints, export, and squawk detection."""
import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(scope='session')
def app():
"""Create application for testing."""
import app as app_module
import utils.database as db_mod
from routes import register_blueprints
app_module.app.config['TESTING'] = True
# Use temp directory for test database
tmp_dir = Path(tempfile.mkdtemp())
db_mod.DB_DIR = tmp_dir
db_mod.DB_PATH = tmp_dir / 'test_intercept.db'
# Reset thread-local connection so it picks up new path
if hasattr(db_mod._local, 'connection') and db_mod._local.connection:
db_mod._local.connection.close()
db_mod._local.connection = None
db_mod.init_db()
if 'pager' not in app_module.app.blueprints:
register_blueprints(app_module.app)
return app_module.app
@pytest.fixture
def client(app):
client = app.test_client()
# Set session login to bypass require_login before_request hook
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
class TestAnalyticsSummary:
"""Tests for /analytics/summary endpoint."""
def test_summary_returns_json(self, client):
response = client.get('/analytics/summary')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'counts' in data
assert 'health' in data
assert 'squawks' in data
def test_summary_counts_structure(self, client):
response = client.get('/analytics/summary')
data = json.loads(response.data)
counts = data['counts']
assert 'adsb' in counts
assert 'ais' in counts
assert 'wifi' in counts
assert 'bluetooth' in counts
assert 'dsc' in counts
# All should be integers
for val in counts.values():
assert isinstance(val, int)
def test_summary_health_structure(self, client):
response = client.get('/analytics/summary')
data = json.loads(response.data)
health = data['health']
# Should have process statuses
assert 'pager' in health
assert 'sensor' in health
assert 'adsb' in health
# Each should have a running flag
for mode_info in health.values():
if isinstance(mode_info, dict) and 'running' in mode_info:
assert isinstance(mode_info['running'], bool)
class TestAnalyticsExport:
"""Tests for /analytics/export/<mode> endpoint."""
def test_export_adsb_json(self, client):
response = client.get('/analytics/export/adsb?format=json')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['mode'] == 'adsb'
assert 'data' in data
assert isinstance(data['data'], list)
def test_export_adsb_csv(self, client):
response = client.get('/analytics/export/adsb?format=csv')
assert response.status_code == 200
assert response.content_type.startswith('text/csv')
assert 'Content-Disposition' in response.headers
def test_export_invalid_mode(self, client):
response = client.get('/analytics/export/invalid_mode')
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
def test_export_wifi_json(self, client):
response = client.get('/analytics/export/wifi?format=json')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['mode'] == 'wifi'
class TestAnalyticsSquawks:
"""Tests for squawk detection."""
def test_squawks_endpoint(self, client):
response = client.get('/analytics/squawks')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert isinstance(data['squawks'], list)
def test_get_emergency_squawks_detects_7700(self):
from utils.analytics import get_emergency_squawks
# Mock the adsb_aircraft DataStore
mock_store = MagicMock()
mock_store.items.return_value = [
('ABC123', {'squawk': '7700', 'callsign': 'TEST01', 'altitude': 35000}),
('DEF456', {'squawk': '1200', 'callsign': 'TEST02'}),
]
with patch('utils.analytics.app_module') as mock_app:
mock_app.adsb_aircraft = mock_store
squawks = get_emergency_squawks()
assert len(squawks) == 1
assert squawks[0]['squawk'] == '7700'
assert squawks[0]['meaning'] == 'General Emergency'
assert squawks[0]['icao'] == 'ABC123'
class TestGeofenceCRUD:
"""Tests for geofence CRUD endpoints."""
def test_list_geofences(self, client):
response = client.get('/analytics/geofences')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert isinstance(data['zones'], list)
def test_create_geofence(self, client):
response = client.post('/analytics/geofences',
data=json.dumps({
'name': 'Test Zone',
'lat': 51.5074,
'lon': -0.1278,
'radius_m': 500,
}),
content_type='application/json')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'zone_id' in data
def test_create_geofence_missing_fields(self, client):
response = client.post('/analytics/geofences',
data=json.dumps({'name': 'No coords'}),
content_type='application/json')
assert response.status_code == 400
def test_create_geofence_invalid_coords(self, client):
response = client.post('/analytics/geofences',
data=json.dumps({
'name': 'Bad',
'lat': 100,
'lon': 0,
'radius_m': 100,
}),
content_type='application/json')
assert response.status_code == 400
def test_delete_geofence_not_found(self, client):
response = client.delete('/analytics/geofences/99999')
assert response.status_code == 404
class TestAnalyticsActivity:
"""Tests for /analytics/activity endpoint."""
def test_activity_returns_sparklines(self, client):
response = client.get('/analytics/activity')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'sparklines' in data
assert isinstance(data['sparklines'], dict)

View File

@@ -0,0 +1,46 @@
"""Tests for RTL-SDR detection parsing."""
from unittest.mock import MagicMock, patch
from utils.sdr.base import SDRType
from utils.sdr.detection import detect_rtlsdr_devices
@patch('utils.sdr.detection._check_tool', return_value=True)
@patch('utils.sdr.detection.subprocess.run')
def test_detect_rtlsdr_devices_filters_empty_serial_entries(mock_run, _mock_check_tool):
"""Ignore malformed rtl_test rows that have an empty SN field."""
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = (
"Found 3 device(s):\n"
" 0: ??C?, , SN:\n"
" 1: ??C?, , SN:\n"
" 2: RTLSDRBlog, Blog V4, SN: 1\n"
)
mock_run.return_value = mock_result
devices = detect_rtlsdr_devices()
assert len(devices) == 1
assert devices[0].sdr_type == SDRType.RTL_SDR
assert devices[0].index == 2
assert devices[0].name == "RTLSDRBlog, Blog V4"
assert devices[0].serial == "1"
@patch('utils.sdr.detection._check_tool', return_value=True)
@patch('utils.sdr.detection.subprocess.run')
def test_detect_rtlsdr_devices_uses_replace_decode_mode(mock_run, _mock_check_tool):
"""Run rtl_test with tolerant decoding for malformed output bytes."""
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = "Found 0 device(s):"
mock_run.return_value = mock_result
detect_rtlsdr_devices()
_, kwargs = mock_run.call_args
assert kwargs["text"] is True
assert kwargs["encoding"] == "utf-8"
assert kwargs["errors"] == "replace"

View File

@@ -0,0 +1,54 @@
"""Tests for waterfall WebSocket configuration helpers."""
from routes.waterfall_websocket import (
_parse_center_freq_mhz,
_parse_span_mhz,
_pick_sample_rate,
)
from utils.sdr import SDRType
from utils.sdr.base import SDRCapabilities
def _caps(sample_rates):
return SDRCapabilities(
sdr_type=SDRType.RTL_SDR,
freq_min_mhz=24.0,
freq_max_mhz=1766.0,
gain_min=0.0,
gain_max=49.6,
sample_rates=sample_rates,
supports_bias_t=True,
supports_ppm=True,
tx_capable=False,
)
def test_parse_center_prefers_center_freq_mhz():
assert _parse_center_freq_mhz({'center_freq_mhz': 162.55, 'center_freq': 144000000}) == 162.55
def test_parse_center_supports_center_freq_hz():
assert _parse_center_freq_mhz({'center_freq_hz': 915000000}) == 915.0
def test_parse_center_supports_legacy_hz_payload():
assert _parse_center_freq_mhz({'center_freq': 109000000}) == 109.0
def test_parse_center_supports_legacy_mhz_payload():
assert _parse_center_freq_mhz({'center_freq': 433.92}) == 433.92
def test_parse_span_from_hz_and_mhz():
assert _parse_span_mhz({'span_hz': 2400000}) == 2.4
assert _parse_span_mhz({'span_mhz': 10.0}) == 10.0
def test_pick_sample_rate_chooses_nearest_declared_rate():
caps = _caps([250000, 1024000, 1800000, 2048000, 2400000])
assert _pick_sample_rate(700000, caps, SDRType.RTL_SDR) == 1024000
def test_pick_sample_rate_falls_back_to_max_bandwidth():
caps = _caps([])
assert _pick_sample_rate(10_000_000, caps, SDRType.RTL_SDR) == 2_400_000

View File

@@ -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
View 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]

View File

@@ -116,6 +116,8 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
['rtl_test', '-t'],
capture_output=True,
text=True,
encoding='utf-8',
errors='replace',
timeout=5,
env=env
)
@@ -123,7 +125,8 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
# Parse device info from rtl_test output
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$'
# 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
@@ -135,7 +138,7 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
sdr_type=SDRType.RTL_SDR,
index=int(match.group(1)),
name=match.group(2).strip().rstrip(','),
serial=match.group(3) or 'N/A',
serial=match.group(3),
driver='rtlsdr',
capabilities=RTLSDRCommandBuilder.CAPABILITIES
))