mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
feat: ship waterfall receiver overhaul and platform mode updates
This commit is contained in:
14
app.py
14
app.py
@@ -25,7 +25,7 @@ import subprocess
|
|||||||
|
|
||||||
from typing import Any
|
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 werkzeug.security import check_password_hash
|
||||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
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')
|
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')
|
@app.route('/devices')
|
||||||
def get_devices() -> Response:
|
def get_devices() -> Response:
|
||||||
"""Get all detected SDR devices with hardware type info."""
|
"""Get all detected SDR devices with hardware type info."""
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ def register_blueprints(app):
|
|||||||
from .recordings import recordings_bp
|
from .recordings import recordings_bp
|
||||||
from .subghz import subghz_bp
|
from .subghz import subghz_bp
|
||||||
from .bt_locate import bt_locate_bp
|
from .bt_locate import bt_locate_bp
|
||||||
from .analytics import analytics_bp
|
|
||||||
from .space_weather import space_weather_bp
|
from .space_weather import space_weather_bp
|
||||||
|
from .fingerprint import fingerprint_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -69,8 +69,8 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(recordings_bp) # Session recordings
|
app.register_blueprint(recordings_bp) # Session recordings
|
||||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
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(space_weather_bp) # Space weather monitoring
|
||||||
|
app.register_blueprint(fingerprint_bp) # RF fingerprinting
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
@@ -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
113
routes/fingerprint.py
Normal 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})
|
||||||
@@ -9,11 +9,12 @@ import queue
|
|||||||
import select
|
import select
|
||||||
import signal
|
import signal
|
||||||
import shutil
|
import shutil
|
||||||
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
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
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ audio_lock = threading.Lock()
|
|||||||
audio_running = False
|
audio_running = False
|
||||||
audio_frequency = 0.0
|
audio_frequency = 0.0
|
||||||
audio_modulation = 'fm'
|
audio_modulation = 'fm'
|
||||||
|
audio_source = 'process'
|
||||||
|
|
||||||
# Scanner state
|
# Scanner state
|
||||||
scanner_thread: Optional[threading.Thread] = None
|
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
|
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 = ''):
|
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):
|
if scanner_config.get('bias_t', False):
|
||||||
sdr_cmd.append('-T')
|
sdr_cmd.append('-T')
|
||||||
# Explicitly output to stdout (some rtl_fm versions need this)
|
# Omit explicit filename: rtl_fm defaults to stdout.
|
||||||
sdr_cmd.append('-')
|
# (Some builds intermittently stall when '-' is passed explicitly.)
|
||||||
else:
|
else:
|
||||||
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
||||||
rx_fm_path = find_rx_fm()
|
rx_fm_path = find_rx_fm()
|
||||||
@@ -842,15 +860,15 @@ def _start_audio_stream(frequency: float, modulation: str):
|
|||||||
# Pipeline started successfully
|
# Pipeline started successfully
|
||||||
break
|
break
|
||||||
|
|
||||||
# Validate that audio is producing data quickly
|
# Keep monitor startup tolerant: some demod chains can take
|
||||||
try:
|
# several seconds before producing stream bytes.
|
||||||
ready, _, _ = select.select([audio_process.stdout], [], [], 4.0)
|
if (
|
||||||
if not ready:
|
not audio_process
|
||||||
logger.warning("Audio pipeline produced no data in startup window — killing stalled pipeline")
|
or not audio_rtl_process
|
||||||
_stop_audio_stream_internal()
|
or audio_process.poll() is not None
|
||||||
return
|
or audio_rtl_process.poll() is not None
|
||||||
except Exception as e:
|
):
|
||||||
logger.warning(f"Audio startup check failed: {e}")
|
logger.warning("Audio pipeline did not remain alive after startup")
|
||||||
_stop_audio_stream_internal()
|
_stop_audio_stream_internal()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -871,11 +889,21 @@ def _stop_audio_stream():
|
|||||||
|
|
||||||
def _stop_audio_stream_internal():
|
def _stop_audio_stream_internal():
|
||||||
"""Internal stop (must hold lock)."""
|
"""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
|
# Set flag first to stop any streaming
|
||||||
audio_running = False
|
audio_running = False
|
||||||
audio_frequency = 0.0
|
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
|
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:
|
def start_audio() -> Response:
|
||||||
"""Start audio at specific frequency (manual mode)."""
|
"""Start audio at specific frequency (manual mode)."""
|
||||||
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
|
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
|
# Stop scanner if running
|
||||||
if scanner_running:
|
if scanner_running:
|
||||||
@@ -1280,6 +1309,11 @@ def start_audio() -> Response:
|
|||||||
gain = int(data.get('gain', 40))
|
gain = int(data.get('gain', 40))
|
||||||
device = int(data.get('device', 0))
|
device = int(data.get('device', 0))
|
||||||
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
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:
|
except (ValueError, TypeError) as e:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -1304,6 +1338,43 @@ def start_audio() -> Response:
|
|||||||
scanner_config['gain'] = gain
|
scanner_config['gain'] = gain
|
||||||
scanner_config['device'] = device
|
scanner_config['device'] = device
|
||||||
scanner_config['sdr_type'] = sdr_type
|
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)
|
# Stop waterfall if it's using the same SDR (SSE path)
|
||||||
if waterfall_running and waterfall_active_device == device:
|
if waterfall_running and waterfall_active_device == device:
|
||||||
@@ -1322,13 +1393,6 @@ def start_audio() -> Response:
|
|||||||
error = None
|
error = None
|
||||||
max_claim_attempts = 6
|
max_claim_attempts = 6
|
||||||
for attempt in range(max_claim_attempts):
|
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')
|
error = app_module.claim_sdr_device(device, 'listening')
|
||||||
if not error:
|
if not error:
|
||||||
break
|
break
|
||||||
@@ -1350,15 +1414,36 @@ def start_audio() -> Response:
|
|||||||
_start_audio_stream(frequency, modulation)
|
_start_audio_stream(frequency, modulation)
|
||||||
|
|
||||||
if audio_running:
|
if audio_running:
|
||||||
|
audio_source = 'process'
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
'modulation': modulation
|
'modulation': modulation,
|
||||||
|
'source': 'process',
|
||||||
})
|
})
|
||||||
else:
|
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({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start audio. Check SDR device.'
|
'message': message
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@@ -1376,10 +1461,21 @@ def stop_audio() -> Response:
|
|||||||
@listening_post_bp.route('/audio/status')
|
@listening_post_bp.route('/audio/status')
|
||||||
def audio_status() -> Response:
|
def audio_status() -> Response:
|
||||||
"""Get audio status."""
|
"""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({
|
return jsonify({
|
||||||
'running': audio_running,
|
'running': running,
|
||||||
'frequency': audio_frequency,
|
'frequency': audio_frequency,
|
||||||
'modulation': audio_modulation
|
'modulation': audio_modulation,
|
||||||
|
'source': audio_source,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1397,15 +1493,26 @@ def audio_debug() -> Response:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return ''
|
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({
|
return jsonify({
|
||||||
'running': audio_running,
|
'running': audio_running,
|
||||||
'frequency': audio_frequency,
|
'frequency': audio_frequency,
|
||||||
'modulation': audio_modulation,
|
'modulation': audio_modulation,
|
||||||
|
'source': audio_source,
|
||||||
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
|
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
|
||||||
'device': scanner_config.get('device', 0),
|
'device': scanner_config.get('device', 0),
|
||||||
'gain': scanner_config.get('gain', 0),
|
'gain': scanner_config.get('gain', 0),
|
||||||
'squelch': scanner_config.get('squelch', 0),
|
'squelch': scanner_config.get('squelch', 0),
|
||||||
'audio_process_alive': bool(audio_process and audio_process.poll() is None),
|
'audio_process_alive': bool(audio_process and audio_process.poll() is None),
|
||||||
|
'shared_capture': shared,
|
||||||
'rtl_fm_stderr': _read_log(rtl_log_path),
|
'rtl_fm_stderr': _read_log(rtl_log_path),
|
||||||
'ffmpeg_stderr': _read_log(ffmpeg_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,
|
'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."""
|
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
|
||||||
global audio_process
|
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:
|
if not audio_process or not audio_process.stdout:
|
||||||
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
|
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
|
||||||
|
|
||||||
@@ -1441,14 +1562,58 @@ def audio_probe() -> Response:
|
|||||||
@listening_post_bp.route('/audio/stream')
|
@listening_post_bp.route('/audio/stream')
|
||||||
def stream_audio() -> Response:
|
def stream_audio() -> Response:
|
||||||
"""Stream WAV audio."""
|
"""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):
|
for _ in range(40):
|
||||||
if audio_running and audio_process:
|
if audio_running and audio_process:
|
||||||
break
|
break
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
if not audio_running or not audio_process:
|
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():
|
def generate():
|
||||||
# Capture local reference to avoid race condition with stop
|
# Capture local reference to avoid race condition with stop
|
||||||
@@ -1474,21 +1639,25 @@ def stream_audio() -> Response:
|
|||||||
yield header_chunk
|
yield header_chunk
|
||||||
|
|
||||||
# Stream real-time audio
|
# 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:
|
while audio_running and proc.poll() is None:
|
||||||
# Use select to avoid blocking forever
|
# Use select to avoid blocking forever
|
||||||
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
||||||
if ready:
|
if ready:
|
||||||
chunk = proc.stdout.read(8192)
|
chunk = proc.stdout.read(8192)
|
||||||
if chunk:
|
if chunk:
|
||||||
|
warned_wait = False
|
||||||
yield chunk
|
yield chunk
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
else:
|
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:
|
if time.time() > first_chunk_deadline:
|
||||||
logger.warning("Audio stream timed out waiting for first chunk")
|
if not warned_wait:
|
||||||
break
|
logger.warning("Audio stream still waiting for first chunk")
|
||||||
|
warned_wait = True
|
||||||
|
continue
|
||||||
# Timeout - check if process died
|
# Timeout - check if process died
|
||||||
if proc.poll() is not None:
|
if proc.poll() is not None:
|
||||||
break
|
break
|
||||||
@@ -1621,9 +1790,20 @@ def _waterfall_loop():
|
|||||||
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
||||||
global waterfall_running, waterfall_process
|
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()
|
rtl_power_path = find_rtl_power()
|
||||||
if not rtl_power_path:
|
if not rtl_power_path:
|
||||||
logger.error("rtl_power not found for waterfall")
|
logger.error("rtl_power not found for waterfall")
|
||||||
|
_queue_waterfall_error('rtl_power not found')
|
||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1646,17 +1826,33 @@ def _waterfall_loop():
|
|||||||
waterfall_process = subprocess.Popen(
|
waterfall_process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.PIPE,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
text=True,
|
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
|
current_ts = None
|
||||||
all_bins: list[float] = []
|
all_bins: list[float] = []
|
||||||
sweep_start_hz = start_hz
|
sweep_start_hz = start_hz
|
||||||
sweep_end_hz = end_hz
|
sweep_end_hz = end_hz
|
||||||
|
received_any = False
|
||||||
|
|
||||||
if not waterfall_process.stdout:
|
if not waterfall_process.stdout:
|
||||||
|
_queue_waterfall_error('rtl_power stdout unavailable')
|
||||||
return
|
return
|
||||||
|
|
||||||
for line in waterfall_process.stdout:
|
for line in waterfall_process.stdout:
|
||||||
@@ -1666,6 +1862,7 @@ def _waterfall_loop():
|
|||||||
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
||||||
if ts is None or not bins:
|
if ts is None or not bins:
|
||||||
continue
|
continue
|
||||||
|
received_any = True
|
||||||
|
|
||||||
if current_ts is None:
|
if current_ts is None:
|
||||||
current_ts = ts
|
current_ts = ts
|
||||||
@@ -1723,8 +1920,12 @@ def _waterfall_loop():
|
|||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if waterfall_running and not received_any:
|
||||||
|
_queue_waterfall_error('No waterfall FFT data received from rtl_power')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Waterfall loop error: {e}")
|
logger.error(f"Waterfall loop error: {e}")
|
||||||
|
_queue_waterfall_error(f"Waterfall loop error: {e}")
|
||||||
finally:
|
finally:
|
||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
if waterfall_process and waterfall_process.poll() is None:
|
if waterfall_process and waterfall_process.poll() is None:
|
||||||
@@ -1768,7 +1969,12 @@ def start_waterfall() -> Response:
|
|||||||
|
|
||||||
with waterfall_lock:
|
with waterfall_lock:
|
||||||
if waterfall_running:
|
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():
|
if not find_rtl_power():
|
||||||
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
|
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import socket
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -17,18 +20,33 @@ except ImportError:
|
|||||||
Sock = None
|
Sock = None
|
||||||
|
|
||||||
from utils.logging import get_logger
|
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 (
|
from utils.waterfall_fft import (
|
||||||
build_binary_frame,
|
build_binary_frame,
|
||||||
compute_power_spectrum,
|
compute_power_spectrum,
|
||||||
cu8_to_complex,
|
cu8_to_complex,
|
||||||
quantize_to_uint8,
|
quantize_to_uint8,
|
||||||
)
|
)
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.sdr.base import SDRCapabilities, SDRDevice
|
|
||||||
|
|
||||||
logger = get_logger('intercept.waterfall_ws')
|
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)
|
# Maximum bandwidth per SDR type (Hz)
|
||||||
MAX_BANDWIDTH = {
|
MAX_BANDWIDTH = {
|
||||||
SDRType.RTL_SDR: 2400000,
|
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:
|
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
||||||
"""Convert client sdr_type string to SDRType enum."""
|
"""Convert client sdr_type string to SDRType enum."""
|
||||||
mapping = {
|
mapping = {
|
||||||
@@ -87,6 +336,10 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
reader_thread = None
|
reader_thread = None
|
||||||
stop_event = threading.Event()
|
stop_event = threading.Event()
|
||||||
claimed_device = None
|
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()
|
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||||
send_queue = queue.Queue(maxsize=120)
|
send_queue = queue.Queue(maxsize=120)
|
||||||
|
|
||||||
@@ -105,7 +358,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = ws.receive(timeout=0.1)
|
msg = ws.receive(timeout=0.01)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
err = str(e).lower()
|
err = str(e).lower()
|
||||||
if "closed" in err:
|
if "closed" in err:
|
||||||
@@ -143,6 +396,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
_set_shared_capture_state(running=False)
|
||||||
stop_event.clear()
|
stop_event.clear()
|
||||||
# Flush stale frames from previous capture
|
# Flush stale frames from previous capture
|
||||||
while not send_queue.empty():
|
while not send_queue.empty():
|
||||||
@@ -155,34 +409,58 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
# Parse config
|
# Parse config
|
||||||
center_freq = float(data.get('center_freq', 100.0))
|
try:
|
||||||
span_mhz = float(data.get('span_mhz', 2.0))
|
center_freq_mhz = _parse_center_freq_mhz(data)
|
||||||
gain = data.get('gain')
|
span_mhz = _parse_span_mhz(data)
|
||||||
if gain is not None:
|
gain_raw = data.get('gain')
|
||||||
gain = float(gain)
|
if gain_raw is None or str(gain_raw).lower() == 'auto':
|
||||||
device_index = int(data.get('device', 0))
|
gain = None
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
else:
|
||||||
fft_size = int(data.get('fft_size', 1024))
|
gain = float(gain_raw)
|
||||||
fps = int(data.get('fps', 25))
|
device_index = int(data.get('device', 0))
|
||||||
avg_count = int(data.get('avg_count', 4))
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
ppm = data.get('ppm')
|
fft_size = int(data.get('fft_size', 1024))
|
||||||
if ppm is not None:
|
fps = int(data.get('fps', 25))
|
||||||
ppm = int(ppm)
|
avg_count = int(data.get('avg_count', 4))
|
||||||
bias_t = bool(data.get('bias_t', False))
|
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))
|
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)
|
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||||
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
span_hz = int(span_mhz * 1e6)
|
caps = builder.get_capabilities()
|
||||||
sample_rate = min(span_hz, max_bw)
|
requested_span_hz = max(1000, int(span_mhz * 1e6))
|
||||||
|
sample_rate = _pick_sample_rate(requested_span_hz, caps, sdr_type)
|
||||||
|
|
||||||
# Compute effective frequency range
|
# Compute effective frequency range
|
||||||
effective_span_mhz = sample_rate / 1e6
|
effective_span_mhz = sample_rate / 1e6
|
||||||
start_freq = center_freq - effective_span_mhz / 2
|
start_freq = center_freq_mhz - effective_span_mhz / 2
|
||||||
end_freq = center_freq + effective_span_mhz / 2
|
end_freq = center_freq_mhz + effective_span_mhz / 2
|
||||||
|
|
||||||
# Claim the device
|
# Claim the device
|
||||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
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
|
# Build I/Q capture command
|
||||||
try:
|
try:
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
|
||||||
device = _build_dummy_device(device_index, sdr_type)
|
device = _build_dummy_device(device_index, sdr_type)
|
||||||
iq_cmd = builder.build_iq_capture_command(
|
iq_cmd = builder.build_iq_capture_command(
|
||||||
device=device,
|
device=device,
|
||||||
frequency_mhz=center_freq,
|
frequency_mhz=center_freq_mhz,
|
||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
gain=gain,
|
gain=gain,
|
||||||
ppm=ppm,
|
ppm=ppm,
|
||||||
@@ -221,7 +498,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
try:
|
try:
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
logger.info(
|
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"span={effective_span_mhz:.1f} MHz, "
|
||||||
f"sr={sample_rate}, fft={fft_size}"
|
f"sr={sample_rate}, fft={fft_size}"
|
||||||
)
|
)
|
||||||
@@ -263,23 +540,50 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
}))
|
}))
|
||||||
continue
|
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
|
# Send started confirmation
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
|
'center_mhz': center_freq_mhz,
|
||||||
'start_freq': start_freq,
|
'start_freq': start_freq,
|
||||||
'end_freq': end_freq,
|
'end_freq': end_freq,
|
||||||
'fft_size': fft_size,
|
'fft_size': fft_size,
|
||||||
'sample_rate': sample_rate,
|
'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()
|
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||||
def fft_reader(
|
def fft_reader(
|
||||||
proc, _send_q, stop_evt,
|
proc, _send_q, stop_evt,
|
||||||
_fft_size, _avg_count, _fps,
|
_fft_size, _avg_count, _fps, _sample_rate,
|
||||||
_start_freq, _end_freq,
|
_start_freq, _end_freq, _center_mhz,
|
||||||
|
_db_min=None, _db_max=None,
|
||||||
):
|
):
|
||||||
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
"""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
|
frame_interval = 1.0 / _fps
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -304,21 +608,37 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
|
|
||||||
# Process FFT pipeline
|
# Process FFT pipeline
|
||||||
samples = cu8_to_complex(raw)
|
samples = cu8_to_complex(raw)
|
||||||
|
fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples
|
||||||
power_db = compute_power_spectrum(
|
power_db = compute_power_spectrum(
|
||||||
samples,
|
fft_samples,
|
||||||
fft_size=_fft_size,
|
fft_size=_fft_size,
|
||||||
avg_count=_avg_count,
|
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(
|
frame = build_binary_frame(
|
||||||
_start_freq, _end_freq, quantized,
|
_start_freq, _end_freq, quantized,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
# Drop frame if main loop cannot keep up.
|
||||||
|
with suppress(queue.Full):
|
||||||
_send_q.put_nowait(frame)
|
_send_q.put_nowait(frame)
|
||||||
except queue.Full:
|
|
||||||
# Drop frame if main loop can't keep up
|
monitor_cfg = _snapshot_monitor_config()
|
||||||
pass
|
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
|
# Pace to target FPS
|
||||||
elapsed = time.monotonic() - frame_start
|
elapsed = time.monotonic() - frame_start
|
||||||
@@ -333,13 +653,63 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
target=fft_reader,
|
target=fft_reader,
|
||||||
args=(
|
args=(
|
||||||
iq_process, send_queue, stop_event,
|
iq_process, send_queue, stop_event,
|
||||||
fft_size, avg_count, fps,
|
fft_size, avg_count, fps, sample_rate,
|
||||||
start_freq, end_freq,
|
start_freq, end_freq, center_freq_mhz,
|
||||||
|
db_min, db_max,
|
||||||
),
|
),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
reader_thread.start()
|
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':
|
elif cmd == 'stop':
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
if reader_thread and reader_thread.is_alive():
|
if reader_thread and reader_thread.is_alive():
|
||||||
@@ -352,6 +722,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
_set_shared_capture_state(running=False)
|
||||||
stop_event.clear()
|
stop_event.clear()
|
||||||
ws.send(json.dumps({'status': 'stopped'}))
|
ws.send(json.dumps({'status': 'stopped'}))
|
||||||
|
|
||||||
@@ -367,20 +738,15 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device)
|
||||||
|
_set_shared_capture_state(running=False)
|
||||||
# Complete WebSocket close handshake, then shut down the
|
# Complete WebSocket close handshake, then shut down the
|
||||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||||
# on top of the WebSocket stream (which browsers see as
|
# on top of the WebSocket stream (which browsers see as
|
||||||
# "Invalid frame header").
|
# "Invalid frame header").
|
||||||
try:
|
with suppress(Exception):
|
||||||
ws.close()
|
ws.close()
|
||||||
except Exception:
|
with suppress(Exception):
|
||||||
pass
|
|
||||||
try:
|
|
||||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||||
except Exception:
|
with suppress(Exception):
|
||||||
pass
|
|
||||||
try:
|
|
||||||
ws.sock.close()
|
ws.sock.close()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.info("WebSocket waterfall client disconnected")
|
logger.info("WebSocket waterfall client disconnected")
|
||||||
|
|||||||
@@ -1802,6 +1802,14 @@ header h1 .tagline {
|
|||||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
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 {
|
.output-panel {
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -266,7 +266,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,8 +282,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#btLocateMap {
|
#btLocateMap {
|
||||||
width: 100%;
|
position: absolute;
|
||||||
height: 100%;
|
inset: 0;
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
78
static/css/modes/fingerprint.css
Normal file
78
static/css/modes/fingerprint.css
Normal 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%;
|
||||||
|
}
|
||||||
44
static/css/modes/rfheatmap.css
Normal file
44
static/css/modes/rfheatmap.css
Normal 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; }
|
||||||
|
}
|
||||||
666
static/css/modes/waterfall.css
Normal file
666
static/css/modes/waterfall.css
Normal 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
21
static/icons/icon.svg
Normal 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 |
77
static/js/core/cheat-sheets.js
Normal file
77
static/js/core/cheat-sheets.js
Normal 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 38–45 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.1–137.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: 118–136 MHz AM', 'Marine VHF: 156–174 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;
|
||||||
@@ -25,7 +25,6 @@ const CommandPalette = (function() {
|
|||||||
{ mode: 'gps', label: 'GPS' },
|
{ mode: 'gps', label: 'GPS' },
|
||||||
{ mode: 'meshtastic', label: 'Meshtastic' },
|
{ mode: 'meshtastic', label: 'Meshtastic' },
|
||||||
{ mode: 'websdr', label: 'WebSDR' },
|
{ mode: 'websdr', label: 'WebSDR' },
|
||||||
{ mode: 'analytics', label: 'Analytics' },
|
|
||||||
{ mode: 'spaceweather', label: 'Space Weather' },
|
{ mode: 'spaceweather', label: 'Space Weather' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,6 @@ const FirstRunSetup = (function() {
|
|||||||
['sstv', 'ISS SSTV'],
|
['sstv', 'ISS SSTV'],
|
||||||
['weathersat', 'Weather Sat'],
|
['weathersat', 'Weather Sat'],
|
||||||
['sstv_general', 'HF SSTV'],
|
['sstv_general', 'HF SSTV'],
|
||||||
['analytics', 'Analytics'],
|
|
||||||
];
|
];
|
||||||
for (const [value, label] of modes) {
|
for (const [value, label] of modes) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
|
|||||||
74
static/js/core/keyboard-shortcuts.js
Normal file
74
static/js/core/keyboard-shortcuts.js
Normal 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;
|
||||||
@@ -114,13 +114,7 @@ const RecordingUI = (function() {
|
|||||||
|
|
||||||
function openReplay(sessionId) {
|
function openReplay(sessionId) {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
localStorage.setItem('analyticsReplaySession', sessionId);
|
window.open(`/recordings/${sessionId}/download`, '_blank');
|
||||||
if (typeof hideSettings === 'function') hideSettings();
|
|
||||||
if (typeof switchMode === 'function') {
|
|
||||||
switchMode('analytics', { updateUrl: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.href = '/?mode=analytics';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
|
|||||||
@@ -1265,6 +1265,7 @@ function switchSettingsTab(tabName) {
|
|||||||
} else if (tabName === 'location') {
|
} else if (tabName === 'location') {
|
||||||
loadObserverLocation();
|
loadObserverLocation();
|
||||||
} else if (tabName === 'alerts') {
|
} else if (tabName === 'alerts') {
|
||||||
|
loadVoiceAlertConfig();
|
||||||
if (typeof AlertCenter !== 'undefined') {
|
if (typeof AlertCenter !== 'undefined') {
|
||||||
AlertCenter.loadFeed();
|
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
|
* Load API key status into the API Keys settings tab
|
||||||
*/
|
*/
|
||||||
|
|||||||
200
static/js/core/voice-alerts.js
Normal file
200
static/js/core/voice-alerts.js
Normal 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;
|
||||||
@@ -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)">↔</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -226,11 +226,11 @@ const BtLocate = (function() {
|
|||||||
map.on('resize moveend zoomend', () => {
|
map.on('resize moveend zoomend', () => {
|
||||||
flushPendingHeatSync();
|
flushPendingHeatSync();
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
safeInvalidateMap();
|
safeInvalidateMap();
|
||||||
flushPendingHeatSync();
|
flushPendingHeatSync();
|
||||||
}, 100);
|
scheduleMapStabilization();
|
||||||
scheduleMapStabilization();
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init RSSI chart canvas
|
// Init RSSI chart canvas
|
||||||
|
|||||||
404
static/js/modes/fingerprint.js
Normal file
404
static/js/modes/fingerprint.js
Normal 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;
|
||||||
456
static/js/modes/rfheatmap.js
Normal file
456
static/js/modes/rfheatmap.js
Normal 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 0–100% 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
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
297
static/js/vendor/leaflet-heat.js
vendored
Normal 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
16
static/manifest.json
Normal 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
85
static/sw.js
Normal 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 }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -6,6 +6,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>iNTERCEPT // See the Invisible</title>
|
<title>iNTERCEPT // See the Invisible</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<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 -->
|
<!-- Disclaimer gate - must accept before seeing welcome page -->
|
||||||
<script>
|
<script>
|
||||||
// Check BEFORE page renders - if disclaimer not accepted, hide welcome page
|
// Check BEFORE page renders - if disclaimer not accepted, hide welcome page
|
||||||
@@ -65,7 +70,6 @@
|
|||||||
window.INTERCEPT_MODE_STYLE_MAP = {
|
window.INTERCEPT_MODE_STYLE_MAP = {
|
||||||
aprs: "{{ url_for('static', filename='css/modes/aprs.css') }}",
|
aprs: "{{ url_for('static', filename='css/modes/aprs.css') }}",
|
||||||
tscm: "{{ url_for('static', filename='css/modes/tscm.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') }}",
|
spystations: "{{ url_for('static', filename='css/modes/spy-stations.css') }}",
|
||||||
meshtastic: "{{ url_for('static', filename='css/modes/meshtastic.css') }}",
|
meshtastic: "{{ url_for('static', filename='css/modes/meshtastic.css') }}",
|
||||||
sstv: "{{ url_for('static', filename='css/modes/sstv.css') }}",
|
sstv: "{{ url_for('static', filename='css/modes/sstv.css') }}",
|
||||||
@@ -74,7 +78,10 @@
|
|||||||
gps: "{{ url_for('static', filename='css/modes/gps.css') }}",
|
gps: "{{ url_for('static', filename='css/modes/gps.css') }}",
|
||||||
subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9",
|
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",
|
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.INTERCEPT_MODE_STYLE_LOADED = {};
|
||||||
window.ensureModeStyles = function(mode) {
|
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-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>
|
<span class="mode-name">TSCM</span>
|
||||||
</button>
|
</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')">
|
<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-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>
|
<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-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>
|
<span class="mode-name">WebSDR</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -604,8 +626,6 @@
|
|||||||
|
|
||||||
{% include 'partials/modes/tscm.html' %}
|
{% include 'partials/modes/tscm.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/analytics.html' %}
|
|
||||||
|
|
||||||
{% include 'partials/modes/ais.html' %}
|
{% include 'partials/modes/ais.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/spy-stations.html' %}
|
{% include 'partials/modes/spy-stations.html' %}
|
||||||
@@ -617,6 +637,9 @@
|
|||||||
{% include 'partials/modes/subghz.html' %}
|
{% include 'partials/modes/subghz.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/bt_locate.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>
|
</div>
|
||||||
|
|
||||||
<!-- BT Locate SAR Dashboard -->
|
<!-- 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 -->
|
<!-- Proximity HUD -->
|
||||||
<div class="btl-hud" id="btLocateHud" style="display: none;">
|
<div class="btl-hud" id="btLocateHud" style="display: none;">
|
||||||
<div class="btl-hud-top">
|
<div class="btl-hud-top">
|
||||||
@@ -2248,8 +2271,8 @@
|
|||||||
<div id="btLocateDiag" class="btl-hud-diag"></div>
|
<div id="btLocateDiag" class="btl-hud-diag"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btl-map-container">
|
<div class="btl-map-container" style="flex: 1; min-height: 250px; position: relative; overflow: hidden;">
|
||||||
<div id="btLocateMap"></div>
|
<div id="btLocateMap" style="position: absolute; inset: 0;"></div>
|
||||||
<div class="btl-map-overlay-controls">
|
<div class="btl-map-overlay-controls">
|
||||||
<label class="btl-map-overlay-toggle">
|
<label class="btl-map-overlay-toggle">
|
||||||
<input type="checkbox" id="btLocateHeatmapEnable" onchange="BtLocate.toggleHeatmap()">
|
<input type="checkbox" id="btLocateHeatmapEnable" onchange="BtLocate.toggleHeatmap()">
|
||||||
@@ -3077,6 +3100,161 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||||||
<div class="recon-panel collapsed" id="reconPanel">
|
<div class="recon-panel collapsed" id="reconPanel">
|
||||||
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
<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/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/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/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/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>
|
<script>
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -3353,9 +3536,11 @@
|
|||||||
bt_locate: { label: 'BT Locate', indicator: 'BT LOCATE', outputTitle: 'BT Locate — SAR Tracker', group: 'wireless' },
|
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' },
|
meshtastic: { label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless' },
|
||||||
tscm: { label: 'TSCM', indicator: 'TSCM', outputTitle: 'TSCM Counter-Surveillance', group: 'intel' },
|
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' },
|
spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
|
||||||
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', 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));
|
const validModes = new Set(Object.keys(modeCatalog));
|
||||||
window.interceptModeCatalog = Object.assign({}, modeCatalog);
|
window.interceptModeCatalog = Object.assign({}, modeCatalog);
|
||||||
@@ -3945,8 +4130,10 @@
|
|||||||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||||||
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
||||||
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
|
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('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');
|
const pagerStats = document.getElementById('pagerStats');
|
||||||
@@ -3987,6 +4174,9 @@
|
|||||||
const subghzVisuals = document.getElementById('subghzVisuals');
|
const subghzVisuals = document.getElementById('subghzVisuals');
|
||||||
const btLocateVisuals = document.getElementById('btLocateVisuals');
|
const btLocateVisuals = document.getElementById('btLocateVisuals');
|
||||||
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
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 (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||||
@@ -4003,6 +4193,9 @@
|
|||||||
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
|
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
|
||||||
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
|
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
|
||||||
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? '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.
|
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
|
||||||
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
|
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
|
||||||
@@ -4017,8 +4210,6 @@
|
|||||||
} else {
|
} else {
|
||||||
mainContent.classList.remove('mesh-sidebar-hidden');
|
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
|
// Show/hide mode-specific timeline containers
|
||||||
@@ -4040,15 +4231,6 @@
|
|||||||
refreshTscmDevices();
|
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
|
// Initialize/destroy Space Weather mode
|
||||||
if (mode !== 'spaceweather') {
|
if (mode !== 'spaceweather') {
|
||||||
if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy();
|
if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy();
|
||||||
@@ -4063,7 +4245,7 @@
|
|||||||
const reconBtn = document.getElementById('reconBtn');
|
const reconBtn = document.getElementById('reconBtn');
|
||||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||||
const reconPanel = document.getElementById('reconPanel');
|
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 (reconPanel) reconPanel.style.display = 'none';
|
||||||
if (reconBtn) reconBtn.style.display = 'none';
|
if (reconBtn) reconBtn.style.display = 'none';
|
||||||
if (intelBtn) intelBtn.style.display = 'none';
|
if (intelBtn) intelBtn.style.display = 'none';
|
||||||
@@ -4078,7 +4260,7 @@
|
|||||||
|
|
||||||
// Show agent selector for modes that support remote agents
|
// Show agent selector for modes that support remote agents
|
||||||
const agentSection = document.getElementById('agentSection');
|
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';
|
if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none';
|
||||||
|
|
||||||
// Show RTL-SDR device section for modes that use it
|
// Show RTL-SDR device section for modes that use it
|
||||||
@@ -4101,8 +4283,8 @@
|
|||||||
// Hide output console for modes with their own visualizations
|
// Hide output console for modes with their own visualizations
|
||||||
const outputEl = document.getElementById('output');
|
const outputEl = document.getElementById('output');
|
||||||
const statusBar = document.querySelector('.status-bar');
|
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 (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') ? 'none' : 'flex';
|
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)
|
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
||||||
if (mode !== 'meshtastic') {
|
if (mode !== 'meshtastic') {
|
||||||
@@ -4139,6 +4321,7 @@
|
|||||||
if (typeof checkIncomingTuneRequest === 'function') {
|
if (typeof checkIncomingTuneRequest === 'function') {
|
||||||
checkIncomingTuneRequest();
|
checkIncomingTuneRequest();
|
||||||
}
|
}
|
||||||
|
if (typeof Waterfall !== 'undefined') Waterfall.init();
|
||||||
} else if (mode === 'spystations') {
|
} else if (mode === 'spystations') {
|
||||||
SpyStations.init();
|
SpyStations.init();
|
||||||
} else if (mode === 'meshtastic') {
|
} else if (mode === 'meshtastic') {
|
||||||
@@ -4175,6 +4358,20 @@
|
|||||||
}, 320);
|
}, 320);
|
||||||
} else if (mode === 'spaceweather') {
|
} else if (mode === 'spaceweather') {
|
||||||
SpaceWeather.init();
|
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/run-state.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/core/command-palette.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>
|
<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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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">▼</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">▼</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">▼</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">▼</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">▼</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">▼</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">▼</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">▼</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">▼</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">▼</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">▼</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>
|
|
||||||
115
templates/partials/modes/fingerprint.html
Normal file
115
templates/partials/modes/fingerprint.html
Normal 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 & 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>
|
||||||
126
templates/partials/modes/rfheatmap.html
Normal file
126
templates/partials/modes/rfheatmap.html
Normal 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>
|
||||||
142
templates/partials/modes/waterfall.html
Normal file
142
templates/partials/modes/waterfall.html
Normal 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>
|
||||||
@@ -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('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('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('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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,9 +134,10 @@
|
|||||||
|
|
||||||
<div class="mode-nav-dropdown-menu">
|
<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('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('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('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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -177,6 +179,12 @@
|
|||||||
<button type="button" class="nav-tool-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
|
<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>
|
<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>
|
||||||
|
<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="showHelp()" title="Help & Documentation" aria-label="Open help">?</button>
|
||||||
<button type="button" class="nav-tool-btn" onclick="logout(event)" title="Logout" aria-label="Logout">
|
<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>
|
<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>') }}
|
{{ 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 #}
|
{# 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('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('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>') }}
|
{{ 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>
|
</nav>
|
||||||
|
|
||||||
{# JavaScript stub for pages that don't have switchMode defined #}
|
{# JavaScript stub for pages that don't have switchMode defined #}
|
||||||
|
|||||||
@@ -284,6 +284,93 @@
|
|||||||
|
|
||||||
<!-- Alerts Section -->
|
<!-- Alerts Section -->
|
||||||
<div id="settings-alerts" class="settings-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">
|
||||||
<div class="settings-group-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
|
<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">
|
<div id="alertsFeedList" class="settings-feed">
|
||||||
@@ -316,7 +403,6 @@
|
|||||||
<option value="acars">ACARS</option>
|
<option value="acars">ACARS</option>
|
||||||
<option value="vdl2">VDL2</option>
|
<option value="vdl2">VDL2</option>
|
||||||
<option value="aprs">APRS</option>
|
<option value="aprs">APRS</option>
|
||||||
<option value="dsc">DSC</option>
|
|
||||||
<option value="meshtastic">Meshtastic</option>
|
<option value="meshtastic">Meshtastic</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,7 +478,6 @@
|
|||||||
<option value="bluetooth">Bluetooth</option>
|
<option value="bluetooth">Bluetooth</option>
|
||||||
<option value="adsb">ADS-B</option>
|
<option value="adsb">ADS-B</option>
|
||||||
<option value="ais">AIS</option>
|
<option value="ais">AIS</option>
|
||||||
<option value="dsc">DSC</option>
|
|
||||||
<option value="acars">ACARS</option>
|
<option value="acars">ACARS</option>
|
||||||
<option value="aprs">APRS</option>
|
<option value="aprs">APRS</option>
|
||||||
<option value="rtlamr">RTLAMR</option>
|
<option value="rtlamr">RTLAMR</option>
|
||||||
|
|||||||
@@ -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)
|
|
||||||
46
tests/test_sdr_detection.py
Normal file
46
tests/test_sdr_detection.py
Normal 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"
|
||||||
54
tests/test_waterfall_websocket.py
Normal file
54
tests/test_waterfall_websocket.py
Normal 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
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
"""Cross-mode analytics: activity tracking, summaries, and emergency squawk detection."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import time
|
|
||||||
from collections import deque
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import app as app_module
|
|
||||||
|
|
||||||
|
|
||||||
class ModeActivityTracker:
|
|
||||||
"""Track device counts per mode in time-bucketed ring buffer for sparklines."""
|
|
||||||
|
|
||||||
def __init__(self, max_buckets: int = 60, bucket_interval: float = 5.0):
|
|
||||||
self._max_buckets = max_buckets
|
|
||||||
self._bucket_interval = bucket_interval
|
|
||||||
self._history: dict[str, deque] = {}
|
|
||||||
self._last_record_time = 0.0
|
|
||||||
|
|
||||||
def record(self) -> None:
|
|
||||||
"""Snapshot current counts for all modes."""
|
|
||||||
now = time.time()
|
|
||||||
if now - self._last_record_time < self._bucket_interval:
|
|
||||||
return
|
|
||||||
self._last_record_time = now
|
|
||||||
|
|
||||||
counts = _get_mode_counts()
|
|
||||||
for mode, count in counts.items():
|
|
||||||
if mode not in self._history:
|
|
||||||
self._history[mode] = deque(maxlen=self._max_buckets)
|
|
||||||
self._history[mode].append(count)
|
|
||||||
|
|
||||||
def get_sparkline(self, mode: str) -> list[int]:
|
|
||||||
"""Return sparkline array for a mode."""
|
|
||||||
self.record()
|
|
||||||
return list(self._history.get(mode, []))
|
|
||||||
|
|
||||||
def get_all_sparklines(self) -> dict[str, list[int]]:
|
|
||||||
"""Return sparkline arrays for all tracked modes."""
|
|
||||||
self.record()
|
|
||||||
return {mode: list(values) for mode, values in self._history.items()}
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton
|
|
||||||
_tracker: ModeActivityTracker | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_tracker() -> ModeActivityTracker:
|
|
||||||
global _tracker
|
|
||||||
if _tracker is None:
|
|
||||||
_tracker = ModeActivityTracker()
|
|
||||||
return _tracker
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_len(attr_name: str) -> int:
|
|
||||||
"""Safely get len() of an app_module attribute."""
|
|
||||||
try:
|
|
||||||
return len(getattr(app_module, attr_name))
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_route_attr(module_path: str, attr_name: str, default: int = 0) -> int:
|
|
||||||
"""Safely read a module-level counter from a route file."""
|
|
||||||
try:
|
|
||||||
import importlib
|
|
||||||
mod = importlib.import_module(module_path)
|
|
||||||
return int(getattr(mod, attr_name, default))
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _get_mode_counts() -> dict[str, int]:
|
|
||||||
"""Read current entity counts from all available data sources."""
|
|
||||||
counts: dict[str, int] = {}
|
|
||||||
|
|
||||||
# ADS-B aircraft (DataStore)
|
|
||||||
counts['adsb'] = _safe_len('adsb_aircraft')
|
|
||||||
|
|
||||||
# AIS vessels (DataStore)
|
|
||||||
counts['ais'] = _safe_len('ais_vessels')
|
|
||||||
|
|
||||||
# WiFi: prefer v2 scanner, fall back to legacy DataStore
|
|
||||||
wifi_count = 0
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None:
|
|
||||||
wifi_count = len(wifi_scanner.access_points)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if wifi_count == 0:
|
|
||||||
wifi_count = _safe_len('wifi_networks')
|
|
||||||
counts['wifi'] = wifi_count
|
|
||||||
|
|
||||||
# Bluetooth: prefer v2 scanner, fall back to legacy DataStore
|
|
||||||
bt_count = 0
|
|
||||||
try:
|
|
||||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
|
||||||
if bt_scanner is not None:
|
|
||||||
bt_count = len(bt_scanner.get_devices())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if bt_count == 0:
|
|
||||||
bt_count = _safe_len('bt_devices')
|
|
||||||
counts['bluetooth'] = bt_count
|
|
||||||
|
|
||||||
# DSC messages (DataStore)
|
|
||||||
counts['dsc'] = _safe_len('dsc_messages')
|
|
||||||
|
|
||||||
# ACARS message count (route-level counter)
|
|
||||||
counts['acars'] = _safe_route_attr('routes.acars', 'acars_message_count')
|
|
||||||
|
|
||||||
# VDL2 message count (route-level counter)
|
|
||||||
counts['vdl2'] = _safe_route_attr('routes.vdl2', 'vdl2_message_count')
|
|
||||||
|
|
||||||
# APRS stations (route-level dict)
|
|
||||||
try:
|
|
||||||
import routes.aprs as aprs_mod
|
|
||||||
counts['aprs'] = len(getattr(aprs_mod, 'aprs_stations', {}))
|
|
||||||
except Exception:
|
|
||||||
counts['aprs'] = 0
|
|
||||||
|
|
||||||
# Meshtastic recent messages (route-level list)
|
|
||||||
try:
|
|
||||||
import routes.meshtastic as mesh_route
|
|
||||||
counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', []))
|
|
||||||
except Exception:
|
|
||||||
counts['meshtastic'] = 0
|
|
||||||
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
def get_cross_mode_summary() -> dict[str, Any]:
|
|
||||||
"""Return counts dict for all available data sources."""
|
|
||||||
counts = _get_mode_counts()
|
|
||||||
wifi_clients_count = 0
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None:
|
|
||||||
wifi_clients_count = len(wifi_scanner.clients)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if wifi_clients_count == 0:
|
|
||||||
wifi_clients_count = _safe_len('wifi_clients')
|
|
||||||
counts['wifi_clients'] = wifi_clients_count
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
def get_mode_health() -> dict[str, dict]:
|
|
||||||
"""Check process refs and SDR status for each mode."""
|
|
||||||
health: dict[str, dict] = {}
|
|
||||||
|
|
||||||
process_map = {
|
|
||||||
'pager': 'current_process',
|
|
||||||
'sensor': 'sensor_process',
|
|
||||||
'adsb': 'adsb_process',
|
|
||||||
'ais': 'ais_process',
|
|
||||||
'acars': 'acars_process',
|
|
||||||
'vdl2': 'vdl2_process',
|
|
||||||
'aprs': 'aprs_process',
|
|
||||||
'wifi': 'wifi_process',
|
|
||||||
'bluetooth': 'bt_process',
|
|
||||||
'dsc': 'dsc_process',
|
|
||||||
'rtlamr': 'rtlamr_process',
|
|
||||||
}
|
|
||||||
|
|
||||||
for mode, attr in process_map.items():
|
|
||||||
proc = getattr(app_module, attr, None)
|
|
||||||
running = proc is not None and (proc.poll() is None if proc else False)
|
|
||||||
health[mode] = {'running': running}
|
|
||||||
|
|
||||||
# Override WiFi/BT health with v2 scanner status if available
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None and wifi_scanner.is_scanning:
|
|
||||||
health['wifi'] = {'running': True}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
|
||||||
if bt_scanner is not None and bt_scanner.is_scanning:
|
|
||||||
health['bluetooth'] = {'running': True}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Meshtastic: check client connection status
|
|
||||||
try:
|
|
||||||
from utils.meshtastic import get_meshtastic_client
|
|
||||||
client = get_meshtastic_client()
|
|
||||||
health['meshtastic'] = {'running': client._interface is not None}
|
|
||||||
except Exception:
|
|
||||||
health['meshtastic'] = {'running': False}
|
|
||||||
|
|
||||||
try:
|
|
||||||
sdr_status = app_module.get_sdr_device_status()
|
|
||||||
health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()}
|
|
||||||
except Exception:
|
|
||||||
health['sdr_devices'] = {}
|
|
||||||
|
|
||||||
return health
|
|
||||||
|
|
||||||
|
|
||||||
EMERGENCY_SQUAWKS = {
|
|
||||||
'7700': 'General Emergency',
|
|
||||||
'7600': 'Comms Failure',
|
|
||||||
'7500': 'Hijack',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_emergency_squawks() -> list[dict]:
|
|
||||||
"""Iterate adsb_aircraft DataStore for emergency squawk codes."""
|
|
||||||
emergencies: list[dict] = []
|
|
||||||
try:
|
|
||||||
for icao, aircraft in app_module.adsb_aircraft.items():
|
|
||||||
sq = str(aircraft.get('squawk', '')).strip()
|
|
||||||
if sq in EMERGENCY_SQUAWKS:
|
|
||||||
emergencies.append({
|
|
||||||
'icao': icao,
|
|
||||||
'callsign': aircraft.get('callsign', ''),
|
|
||||||
'squawk': sq,
|
|
||||||
'meaning': EMERGENCY_SQUAWKS[sq],
|
|
||||||
'altitude': aircraft.get('altitude'),
|
|
||||||
'lat': aircraft.get('lat'),
|
|
||||||
'lon': aircraft.get('lon'),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return emergencies
|
|
||||||
210
utils/rf_fingerprint.py
Normal file
210
utils/rf_fingerprint.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""RF Fingerprinting engine using Welford online algorithm for statistics."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import math
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class RFFingerprinter:
|
||||||
|
BAND_RESOLUTION_MHZ = 0.1 # 100 kHz buckets
|
||||||
|
|
||||||
|
def __init__(self, db_path: str):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.db = sqlite3.connect(db_path, check_same_thread=False)
|
||||||
|
self.db.row_factory = sqlite3.Row
|
||||||
|
self._init_schema()
|
||||||
|
|
||||||
|
def _init_schema(self):
|
||||||
|
with self._lock:
|
||||||
|
self.db.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS rf_fingerprints (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
location TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
finalized_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS rf_observations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
fp_id INTEGER NOT NULL REFERENCES rf_fingerprints(id) ON DELETE CASCADE,
|
||||||
|
band_center_mhz REAL NOT NULL,
|
||||||
|
power_dbm REAL NOT NULL,
|
||||||
|
recorded_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS rf_baselines (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
fp_id INTEGER NOT NULL REFERENCES rf_fingerprints(id) ON DELETE CASCADE,
|
||||||
|
band_center_mhz REAL NOT NULL,
|
||||||
|
mean_dbm REAL NOT NULL,
|
||||||
|
std_dbm REAL NOT NULL,
|
||||||
|
sample_count INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_obs_fp_id ON rf_observations(fp_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_baseline_fp_id ON rf_baselines(fp_id);
|
||||||
|
""")
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def _snap_to_band(self, freq_mhz: float) -> float:
|
||||||
|
"""Snap frequency to nearest band center (100 kHz resolution)."""
|
||||||
|
return round(round(freq_mhz / self.BAND_RESOLUTION_MHZ) * self.BAND_RESOLUTION_MHZ, 3)
|
||||||
|
|
||||||
|
def start_session(self, name: str, location: Optional[str] = None) -> int:
|
||||||
|
with self._lock:
|
||||||
|
cur = self.db.execute(
|
||||||
|
"INSERT INTO rf_fingerprints (name, location) VALUES (?, ?)",
|
||||||
|
(name, location),
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
def add_observation(self, session_id: int, freq_mhz: float, power_dbm: float):
|
||||||
|
band = self._snap_to_band(freq_mhz)
|
||||||
|
with self._lock:
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO rf_observations (fp_id, band_center_mhz, power_dbm) VALUES (?, ?, ?)",
|
||||||
|
(session_id, band, power_dbm),
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def add_observations_batch(self, session_id: int, observations: list[dict]):
|
||||||
|
rows = [
|
||||||
|
(session_id, self._snap_to_band(o["freq_mhz"]), o["power_dbm"])
|
||||||
|
for o in observations
|
||||||
|
]
|
||||||
|
with self._lock:
|
||||||
|
self.db.executemany(
|
||||||
|
"INSERT INTO rf_observations (fp_id, band_center_mhz, power_dbm) VALUES (?, ?, ?)",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def finalize(self, session_id: int) -> dict:
|
||||||
|
"""Compute statistics per band and store baselines."""
|
||||||
|
with self._lock:
|
||||||
|
rows = self.db.execute(
|
||||||
|
"SELECT band_center_mhz, power_dbm FROM rf_observations WHERE fp_id = ? ORDER BY band_center_mhz",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Group by band
|
||||||
|
bands: dict[float, list[float]] = {}
|
||||||
|
for row in rows:
|
||||||
|
b = row["band_center_mhz"]
|
||||||
|
bands.setdefault(b, []).append(row["power_dbm"])
|
||||||
|
|
||||||
|
baselines = []
|
||||||
|
for band_mhz, powers in bands.items():
|
||||||
|
n = len(powers)
|
||||||
|
mean = sum(powers) / n
|
||||||
|
if n > 1:
|
||||||
|
variance = sum((p - mean) ** 2 for p in powers) / (n - 1)
|
||||||
|
std = math.sqrt(variance)
|
||||||
|
else:
|
||||||
|
std = 0.0
|
||||||
|
baselines.append((session_id, band_mhz, mean, std, n))
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self.db.executemany(
|
||||||
|
"INSERT INTO rf_baselines (fp_id, band_center_mhz, mean_dbm, std_dbm, sample_count) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
baselines,
|
||||||
|
)
|
||||||
|
self.db.execute(
|
||||||
|
"UPDATE rf_fingerprints SET finalized_at = datetime('now') WHERE id = ?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return {"session_id": session_id, "bands_recorded": len(baselines)}
|
||||||
|
|
||||||
|
def compare(self, baseline_id: int, observations: list[dict]) -> list[dict]:
|
||||||
|
"""Compare observations against a stored baseline. Returns anomaly list."""
|
||||||
|
with self._lock:
|
||||||
|
baseline_rows = self.db.execute(
|
||||||
|
"SELECT band_center_mhz, mean_dbm, std_dbm, sample_count FROM rf_baselines WHERE fp_id = ?",
|
||||||
|
(baseline_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
baseline_map: dict[float, dict] = {
|
||||||
|
row["band_center_mhz"]: dict(row) for row in baseline_rows
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build current band map (average power per band)
|
||||||
|
current_bands: dict[float, list[float]] = {}
|
||||||
|
for obs in observations:
|
||||||
|
b = self._snap_to_band(obs["freq_mhz"])
|
||||||
|
current_bands.setdefault(b, []).append(obs["power_dbm"])
|
||||||
|
current_map = {b: sum(ps) / len(ps) for b, ps in current_bands.items()}
|
||||||
|
|
||||||
|
anomalies = []
|
||||||
|
|
||||||
|
# Check each baseline band
|
||||||
|
for band_mhz, bl in baseline_map.items():
|
||||||
|
if band_mhz in current_map:
|
||||||
|
current_power = current_map[band_mhz]
|
||||||
|
delta = current_power - bl["mean_dbm"]
|
||||||
|
std = bl["std_dbm"] if bl["std_dbm"] > 0 else 1.0
|
||||||
|
z_score = delta / std
|
||||||
|
if abs(z_score) >= 2.0:
|
||||||
|
anomalies.append({
|
||||||
|
"band_center_mhz": band_mhz,
|
||||||
|
"band_label": f"{band_mhz:.1f} MHz",
|
||||||
|
"baseline_mean": bl["mean_dbm"],
|
||||||
|
"baseline_std": bl["std_dbm"],
|
||||||
|
"current_power": current_power,
|
||||||
|
"z_score": z_score,
|
||||||
|
"anomaly_type": "power",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
anomalies.append({
|
||||||
|
"band_center_mhz": band_mhz,
|
||||||
|
"band_label": f"{band_mhz:.1f} MHz",
|
||||||
|
"baseline_mean": bl["mean_dbm"],
|
||||||
|
"baseline_std": bl["std_dbm"],
|
||||||
|
"current_power": None,
|
||||||
|
"z_score": None,
|
||||||
|
"anomaly_type": "missing",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for new bands not in baseline
|
||||||
|
for band_mhz, current_power in current_map.items():
|
||||||
|
if band_mhz not in baseline_map:
|
||||||
|
anomalies.append({
|
||||||
|
"band_center_mhz": band_mhz,
|
||||||
|
"band_label": f"{band_mhz:.1f} MHz",
|
||||||
|
"baseline_mean": None,
|
||||||
|
"baseline_std": None,
|
||||||
|
"current_power": current_power,
|
||||||
|
"z_score": None,
|
||||||
|
"anomaly_type": "new",
|
||||||
|
})
|
||||||
|
|
||||||
|
anomalies.sort(
|
||||||
|
key=lambda a: abs(a["z_score"]) if a["z_score"] is not None else 0,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return anomalies
|
||||||
|
|
||||||
|
def list_sessions(self) -> list[dict]:
|
||||||
|
with self._lock:
|
||||||
|
rows = self.db.execute(
|
||||||
|
"""SELECT id, name, location, created_at, finalized_at,
|
||||||
|
(SELECT COUNT(*) FROM rf_baselines WHERE fp_id = rf_fingerprints.id) AS band_count
|
||||||
|
FROM rf_fingerprints ORDER BY created_at DESC"""
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def delete_session(self, session_id: int):
|
||||||
|
with self._lock:
|
||||||
|
self.db.execute("DELETE FROM rf_fingerprints WHERE id = ?", (session_id,))
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def get_baseline_bands(self, baseline_id: int) -> list[dict]:
|
||||||
|
with self._lock:
|
||||||
|
rows = self.db.execute(
|
||||||
|
"SELECT band_center_mhz, mean_dbm, std_dbm, sample_count FROM rf_baselines WHERE fp_id = ? ORDER BY band_center_mhz",
|
||||||
|
(baseline_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
@@ -116,6 +116,8 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
['rtl_test', '-t'],
|
['rtl_test', '-t'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding='utf-8',
|
||||||
|
errors='replace',
|
||||||
timeout=5,
|
timeout=5,
|
||||||
env=env
|
env=env
|
||||||
)
|
)
|
||||||
@@ -123,7 +125,8 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
|
|
||||||
# Parse device info from rtl_test output
|
# Parse device info from rtl_test output
|
||||||
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
# 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
|
from .rtlsdr import RTLSDRCommandBuilder
|
||||||
|
|
||||||
@@ -135,7 +138,7 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
sdr_type=SDRType.RTL_SDR,
|
sdr_type=SDRType.RTL_SDR,
|
||||||
index=int(match.group(1)),
|
index=int(match.group(1)),
|
||||||
name=match.group(2).strip().rstrip(','),
|
name=match.group(2).strip().rstrip(','),
|
||||||
serial=match.group(3) or 'N/A',
|
serial=match.group(3),
|
||||||
driver='rtlsdr',
|
driver='rtlsdr',
|
||||||
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
||||||
))
|
))
|
||||||
|
|||||||
Reference in New Issue
Block a user