mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Remove legacy RF modes and add SignalID route/tests
This commit is contained in:
@@ -2,40 +2,40 @@
|
||||
|
||||
def register_blueprints(app):
|
||||
"""Register all route blueprints with the Flask app."""
|
||||
from .pager import pager_bp
|
||||
from .sensor import sensor_bp
|
||||
from .rtlamr import rtlamr_bp
|
||||
from .wifi import wifi_bp
|
||||
from .wifi_v2 import wifi_v2_bp
|
||||
from .bluetooth import bluetooth_bp
|
||||
from .bluetooth_v2 import bluetooth_v2_bp
|
||||
from .acars import acars_bp
|
||||
from .adsb import adsb_bp
|
||||
from .ais import ais_bp
|
||||
from .dsc import dsc_bp
|
||||
from .acars import acars_bp
|
||||
from .vdl2 import vdl2_bp
|
||||
from .aprs import aprs_bp
|
||||
from .satellite import satellite_bp
|
||||
from .gps import gps_bp
|
||||
from .settings import settings_bp
|
||||
from .correlation import correlation_bp
|
||||
from .listening_post import listening_post_bp
|
||||
from .meshtastic import meshtastic_bp
|
||||
from .tscm import tscm_bp, init_tscm_state
|
||||
from .spy_stations import spy_stations_bp
|
||||
from .controller import controller_bp
|
||||
from .offline import offline_bp
|
||||
from .updater import updater_bp
|
||||
from .sstv import sstv_bp
|
||||
from .weather_sat import weather_sat_bp
|
||||
from .sstv_general import sstv_general_bp
|
||||
from .websdr import websdr_bp
|
||||
from .alerts import alerts_bp
|
||||
from .recordings import recordings_bp
|
||||
from .subghz import subghz_bp
|
||||
from .aprs import aprs_bp
|
||||
from .bluetooth import bluetooth_bp
|
||||
from .bluetooth_v2 import bluetooth_v2_bp
|
||||
from .bt_locate import bt_locate_bp
|
||||
from .controller import controller_bp
|
||||
from .correlation import correlation_bp
|
||||
from .dsc import dsc_bp
|
||||
from .gps import gps_bp
|
||||
from .listening_post import receiver_bp
|
||||
from .meshtastic import meshtastic_bp
|
||||
from .offline import offline_bp
|
||||
from .pager import pager_bp
|
||||
from .recordings import recordings_bp
|
||||
from .rtlamr import rtlamr_bp
|
||||
from .satellite import satellite_bp
|
||||
from .sensor import sensor_bp
|
||||
from .settings import settings_bp
|
||||
from .signalid import signalid_bp
|
||||
from .space_weather import space_weather_bp
|
||||
from .fingerprint import fingerprint_bp
|
||||
from .spy_stations import spy_stations_bp
|
||||
from .sstv import sstv_bp
|
||||
from .sstv_general import sstv_general_bp
|
||||
from .subghz import subghz_bp
|
||||
from .tscm import init_tscm_state, tscm_bp
|
||||
from .updater import updater_bp
|
||||
from .vdl2 import vdl2_bp
|
||||
from .weather_sat import weather_sat_bp
|
||||
from .websdr import websdr_bp
|
||||
from .wifi import wifi_bp
|
||||
from .wifi_v2 import wifi_v2_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -54,7 +54,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(gps_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(correlation_bp)
|
||||
app.register_blueprint(listening_post_bp)
|
||||
app.register_blueprint(receiver_bp)
|
||||
app.register_blueprint(meshtastic_bp)
|
||||
app.register_blueprint(tscm_bp)
|
||||
app.register_blueprint(spy_stations_bp)
|
||||
@@ -70,7 +70,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||
app.register_blueprint(fingerprint_bp) # RF fingerprinting
|
||||
app.register_blueprint(signalid_bp) # External signal ID enrichment
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"""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})
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Listening Post routes for radio monitoring and frequency scanning."""
|
||||
"""Receiver routes for radio monitoring and frequency scanning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -29,9 +29,9 @@ from utils.constants import (
|
||||
)
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
logger = get_logger('intercept.listening_post')
|
||||
logger = get_logger('intercept.receiver')
|
||||
|
||||
listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening')
|
||||
receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver')
|
||||
|
||||
# ============================================
|
||||
# GLOBAL STATE
|
||||
@@ -53,7 +53,7 @@ scanner_lock = threading.Lock()
|
||||
scanner_paused = False
|
||||
scanner_current_freq = 0.0
|
||||
scanner_active_device: Optional[int] = None
|
||||
listening_active_device: Optional[int] = None
|
||||
receiver_active_device: Optional[int] = None
|
||||
scanner_power_process: Optional[subprocess.Popen] = None
|
||||
scanner_config = {
|
||||
'start_freq': 88.0,
|
||||
@@ -941,7 +941,7 @@ def _stop_audio_stream_internal():
|
||||
# API ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@listening_post_bp.route('/tools')
|
||||
@receiver_bp.route('/tools')
|
||||
def check_tools() -> Response:
|
||||
"""Check for required tools."""
|
||||
rtl_fm = find_rtl_fm()
|
||||
@@ -967,10 +967,10 @@ def check_tools() -> Response:
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/start', methods=['POST'])
|
||||
@receiver_bp.route('/scanner/start', methods=['POST'])
|
||||
def start_scanner() -> Response:
|
||||
"""Start the frequency scanner."""
|
||||
global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device
|
||||
global scanner_thread, scanner_running, scanner_config, scanner_active_device, receiver_active_device
|
||||
|
||||
with scanner_lock:
|
||||
if scanner_running:
|
||||
@@ -1036,9 +1036,9 @@ def start_scanner() -> Response:
|
||||
'message': 'rtl_power not found. Install rtl-sdr tools.'
|
||||
}), 503
|
||||
# Release listening device if active
|
||||
if listening_active_device is not None:
|
||||
app_module.release_sdr_device(listening_active_device)
|
||||
listening_active_device = None
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
receiver_active_device = None
|
||||
# Claim device for scanner
|
||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
||||
if error:
|
||||
@@ -1064,9 +1064,9 @@ def start_scanner() -> Response:
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
||||
}), 503
|
||||
if listening_active_device is not None:
|
||||
app_module.release_sdr_device(listening_active_device)
|
||||
listening_active_device = None
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
receiver_active_device = None
|
||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
||||
if error:
|
||||
return jsonify({
|
||||
@@ -1086,7 +1086,7 @@ def start_scanner() -> Response:
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/stop', methods=['POST'])
|
||||
@receiver_bp.route('/scanner/stop', methods=['POST'])
|
||||
def stop_scanner() -> Response:
|
||||
"""Stop the frequency scanner."""
|
||||
global scanner_running, scanner_active_device, scanner_power_process
|
||||
@@ -1110,7 +1110,7 @@ def stop_scanner() -> Response:
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/pause', methods=['POST'])
|
||||
@receiver_bp.route('/scanner/pause', methods=['POST'])
|
||||
def pause_scanner() -> Response:
|
||||
"""Pause/resume the scanner."""
|
||||
global scanner_paused
|
||||
@@ -1132,7 +1132,7 @@ def pause_scanner() -> Response:
|
||||
scanner_skip_signal = False
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/skip', methods=['POST'])
|
||||
@receiver_bp.route('/scanner/skip', methods=['POST'])
|
||||
def skip_signal() -> Response:
|
||||
"""Skip current signal and continue scanning."""
|
||||
global scanner_skip_signal
|
||||
@@ -1152,7 +1152,7 @@ def skip_signal() -> Response:
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/config', methods=['POST'])
|
||||
@receiver_bp.route('/scanner/config', methods=['POST'])
|
||||
def update_scanner_config() -> Response:
|
||||
"""Update scanner config while running (step, squelch, gain, dwell)."""
|
||||
data = request.json or {}
|
||||
@@ -1194,7 +1194,7 @@ def update_scanner_config() -> Response:
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/status')
|
||||
@receiver_bp.route('/scanner/status')
|
||||
def scanner_status() -> Response:
|
||||
"""Get scanner status."""
|
||||
return jsonify({
|
||||
@@ -1207,16 +1207,16 @@ def scanner_status() -> Response:
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/stream')
|
||||
@receiver_bp.route('/scanner/stream')
|
||||
def stream_scanner_events() -> Response:
|
||||
"""SSE stream for scanner events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('listening_scanner', msg, msg.get('type'))
|
||||
process_event('receiver_scanner', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=scanner_queue,
|
||||
channel_key='listening_scanner',
|
||||
channel_key='receiver_scanner',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
on_message=_on_msg,
|
||||
@@ -1228,7 +1228,7 @@ def stream_scanner_events() -> Response:
|
||||
return response
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/log')
|
||||
@receiver_bp.route('/scanner/log')
|
||||
def get_activity_log() -> Response:
|
||||
"""Get activity log."""
|
||||
limit = request.args.get('limit', 100, type=int)
|
||||
@@ -1239,7 +1239,7 @@ def get_activity_log() -> Response:
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/log/clear', methods=['POST'])
|
||||
@receiver_bp.route('/scanner/log/clear', methods=['POST'])
|
||||
def clear_activity_log() -> Response:
|
||||
"""Clear activity log."""
|
||||
with activity_log_lock:
|
||||
@@ -1247,7 +1247,7 @@ def clear_activity_log() -> Response:
|
||||
return jsonify({'status': 'cleared'})
|
||||
|
||||
|
||||
@listening_post_bp.route('/presets')
|
||||
@receiver_bp.route('/presets')
|
||||
def get_presets() -> Response:
|
||||
"""Get scanner presets."""
|
||||
presets = [
|
||||
@@ -1267,10 +1267,10 @@ def get_presets() -> Response:
|
||||
# MANUAL AUDIO ENDPOINTS (for direct listening)
|
||||
# ============================================
|
||||
|
||||
@listening_post_bp.route('/audio/start', methods=['POST'])
|
||||
@receiver_bp.route('/audio/start', methods=['POST'])
|
||||
def start_audio() -> Response:
|
||||
"""Start audio at specific frequency (manual mode)."""
|
||||
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
|
||||
global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread
|
||||
global audio_running, audio_frequency, audio_modulation, audio_source
|
||||
|
||||
# Stop scanner if running
|
||||
@@ -1363,9 +1363,9 @@ def start_audio() -> Response:
|
||||
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
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
receiver_active_device = None
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
@@ -1385,15 +1385,15 @@ def start_audio() -> Response:
|
||||
# may still be tearing down its IQ capture process (thread join +
|
||||
# safe_terminate can take several seconds), so we retry with back-off
|
||||
# to give the USB device time to be fully released.
|
||||
if listening_active_device is None or listening_active_device != device:
|
||||
if listening_active_device is not None:
|
||||
app_module.release_sdr_device(listening_active_device)
|
||||
listening_active_device = None
|
||||
if receiver_active_device is None or receiver_active_device != device:
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
receiver_active_device = None
|
||||
|
||||
error = None
|
||||
max_claim_attempts = 6
|
||||
for attempt in range(max_claim_attempts):
|
||||
error = app_module.claim_sdr_device(device, 'listening')
|
||||
error = app_module.claim_sdr_device(device, 'receiver')
|
||||
if not error:
|
||||
break
|
||||
if attempt < max_claim_attempts - 1:
|
||||
@@ -1409,7 +1409,7 @@ def start_audio() -> Response:
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
listening_active_device = device
|
||||
receiver_active_device = device
|
||||
|
||||
_start_audio_stream(frequency, modulation)
|
||||
|
||||
@@ -1423,9 +1423,9 @@ def start_audio() -> Response:
|
||||
})
|
||||
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
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
receiver_active_device = None
|
||||
|
||||
start_error = ''
|
||||
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
|
||||
@@ -1447,18 +1447,18 @@ def start_audio() -> Response:
|
||||
}), 500
|
||||
|
||||
|
||||
@listening_post_bp.route('/audio/stop', methods=['POST'])
|
||||
@receiver_bp.route('/audio/stop', methods=['POST'])
|
||||
def stop_audio() -> Response:
|
||||
"""Stop audio."""
|
||||
global listening_active_device
|
||||
global receiver_active_device
|
||||
_stop_audio_stream()
|
||||
if listening_active_device is not None:
|
||||
app_module.release_sdr_device(listening_active_device)
|
||||
listening_active_device = None
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
receiver_active_device = None
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@listening_post_bp.route('/audio/status')
|
||||
@receiver_bp.route('/audio/status')
|
||||
def audio_status() -> Response:
|
||||
"""Get audio status."""
|
||||
running = audio_running
|
||||
@@ -1479,7 +1479,7 @@ def audio_status() -> Response:
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/audio/debug')
|
||||
@receiver_bp.route('/audio/debug')
|
||||
def audio_debug() -> Response:
|
||||
"""Get audio debug status and recent stderr logs."""
|
||||
rtl_log_path = '/tmp/rtl_fm_stderr.log'
|
||||
@@ -1519,7 +1519,7 @@ def audio_debug() -> Response:
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/audio/probe')
|
||||
@receiver_bp.route('/audio/probe')
|
||||
def audio_probe() -> Response:
|
||||
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
|
||||
global audio_process
|
||||
@@ -1559,7 +1559,7 @@ def audio_probe() -> Response:
|
||||
return jsonify({'status': 'ok', 'bytes': size})
|
||||
|
||||
|
||||
@listening_post_bp.route('/audio/stream')
|
||||
@receiver_bp.route('/audio/stream')
|
||||
def stream_audio() -> Response:
|
||||
"""Stream WAV audio."""
|
||||
if audio_source == 'waterfall':
|
||||
@@ -1682,7 +1682,7 @@ def stream_audio() -> Response:
|
||||
# SIGNAL IDENTIFICATION ENDPOINT
|
||||
# ============================================
|
||||
|
||||
@listening_post_bp.route('/signal/guess', methods=['POST'])
|
||||
@receiver_bp.route('/signal/guess', methods=['POST'])
|
||||
def guess_signal() -> Response:
|
||||
"""Identify a signal based on frequency, modulation, and other parameters."""
|
||||
data = request.json or {}
|
||||
@@ -1962,7 +1962,7 @@ def _stop_waterfall_internal() -> None:
|
||||
waterfall_active_device = None
|
||||
|
||||
|
||||
@listening_post_bp.route('/waterfall/start', methods=['POST'])
|
||||
@receiver_bp.route('/waterfall/start', methods=['POST'])
|
||||
def start_waterfall() -> Response:
|
||||
"""Start the waterfall/spectrogram display."""
|
||||
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device
|
||||
@@ -2023,7 +2023,7 @@ def start_waterfall() -> Response:
|
||||
return jsonify({'status': 'started', 'config': waterfall_config})
|
||||
|
||||
|
||||
@listening_post_bp.route('/waterfall/stop', methods=['POST'])
|
||||
@receiver_bp.route('/waterfall/stop', methods=['POST'])
|
||||
def stop_waterfall() -> Response:
|
||||
"""Stop the waterfall display."""
|
||||
_stop_waterfall_internal()
|
||||
@@ -2031,7 +2031,7 @@ def stop_waterfall() -> Response:
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@listening_post_bp.route('/waterfall/stream')
|
||||
@receiver_bp.route('/waterfall/stream')
|
||||
def stream_waterfall() -> Response:
|
||||
"""SSE stream for waterfall data."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
@@ -2040,7 +2040,7 @@ def stream_waterfall() -> Response:
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=waterfall_queue,
|
||||
channel_key='listening_waterfall',
|
||||
channel_key='receiver_waterfall',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
on_message=_on_msg,
|
||||
|
||||
352
routes/signalid.py
Normal file
352
routes/signalid.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""Signal identification enrichment routes (SigID Wiki proxy lookup)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.signalid')
|
||||
|
||||
signalid_bp = Blueprint('signalid', __name__, url_prefix='/signalid')
|
||||
|
||||
SIGID_API_URL = 'https://www.sigidwiki.com/api.php'
|
||||
SIGID_USER_AGENT = 'INTERCEPT-SignalID/1.0'
|
||||
SIGID_TIMEOUT_SECONDS = 12
|
||||
SIGID_CACHE_TTL_SECONDS = 600
|
||||
|
||||
_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _cache_get(key: str) -> Any | None:
|
||||
entry = _cache.get(key)
|
||||
if not entry:
|
||||
return None
|
||||
if time.time() >= entry['expires']:
|
||||
_cache.pop(key, None)
|
||||
return None
|
||||
return entry['data']
|
||||
|
||||
|
||||
def _cache_set(key: str, data: Any, ttl_seconds: int = SIGID_CACHE_TTL_SECONDS) -> None:
|
||||
_cache[key] = {
|
||||
'data': data,
|
||||
'expires': time.time() + ttl_seconds,
|
||||
}
|
||||
|
||||
|
||||
def _fetch_api_json(params: dict[str, str]) -> dict[str, Any] | None:
|
||||
query = urllib.parse.urlencode(params, doseq=True)
|
||||
url = f'{SIGID_API_URL}?{query}'
|
||||
req = urllib.request.Request(url, headers={'User-Agent': SIGID_USER_AGENT})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=SIGID_TIMEOUT_SECONDS) as resp:
|
||||
payload = resp.read().decode('utf-8', errors='replace')
|
||||
data = json.loads(payload)
|
||||
except Exception as exc:
|
||||
logger.warning('SigID API request failed: %s', exc)
|
||||
return None
|
||||
if isinstance(data, dict) and data.get('error'):
|
||||
logger.warning('SigID API returned error: %s', data.get('error'))
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _ask_query(query: str) -> dict[str, Any] | None:
|
||||
return _fetch_api_json({
|
||||
'action': 'ask',
|
||||
'query': query,
|
||||
'format': 'json',
|
||||
})
|
||||
|
||||
|
||||
def _search_query(search_text: str, limit: int) -> dict[str, Any] | None:
|
||||
return _fetch_api_json({
|
||||
'action': 'query',
|
||||
'list': 'search',
|
||||
'srsearch': search_text,
|
||||
'srlimit': str(limit),
|
||||
'format': 'json',
|
||||
})
|
||||
|
||||
|
||||
def _to_float_list(values: Any) -> list[float]:
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
out: list[float] = []
|
||||
for value in values:
|
||||
try:
|
||||
out.append(float(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _to_text_list(values: Any) -> list[str]:
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
out: list[str] = []
|
||||
for value in values:
|
||||
text = str(value or '').strip()
|
||||
if text:
|
||||
out.append(text)
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_modes(values: list[str]) -> list[str]:
|
||||
out: list[str] = []
|
||||
for value in values:
|
||||
for token in str(value).replace('/', ',').split(','):
|
||||
mode = token.strip().upper()
|
||||
if mode and mode not in out:
|
||||
out.append(mode)
|
||||
return out
|
||||
|
||||
|
||||
def _extract_matches_from_ask(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
results = data.get('query', {}).get('results', {})
|
||||
if not isinstance(results, dict):
|
||||
return []
|
||||
|
||||
matches: list[dict[str, Any]] = []
|
||||
for title, entry in results.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
printouts = entry.get('printouts', {})
|
||||
if not isinstance(printouts, dict):
|
||||
printouts = {}
|
||||
|
||||
frequencies_hz = _to_float_list(printouts.get('Frequencies'))
|
||||
frequencies_mhz = [round(v / 1e6, 6) for v in frequencies_hz if v > 0]
|
||||
|
||||
modes = _normalize_modes(_to_text_list(printouts.get('Mode')))
|
||||
modulations = _normalize_modes(_to_text_list(printouts.get('Modulation')))
|
||||
|
||||
match = {
|
||||
'title': str(entry.get('fulltext') or title),
|
||||
'url': str(entry.get('fullurl') or ''),
|
||||
'frequencies_mhz': frequencies_mhz,
|
||||
'modes': modes,
|
||||
'modulations': modulations,
|
||||
'source': 'SigID Wiki',
|
||||
}
|
||||
matches.append(match)
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def _dedupe_matches(matches: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
deduped: dict[str, dict[str, Any]] = {}
|
||||
for match in matches:
|
||||
key = f"{match.get('title', '')}|{match.get('url', '')}"
|
||||
if key not in deduped:
|
||||
deduped[key] = match
|
||||
continue
|
||||
|
||||
# Merge frequencies/modes/modulations from duplicates.
|
||||
existing = deduped[key]
|
||||
for field in ('frequencies_mhz', 'modes', 'modulations'):
|
||||
base = existing.get(field, [])
|
||||
extra = match.get(field, [])
|
||||
if not isinstance(base, list):
|
||||
base = []
|
||||
if not isinstance(extra, list):
|
||||
extra = []
|
||||
merged = list(base)
|
||||
for item in extra:
|
||||
if item not in merged:
|
||||
merged.append(item)
|
||||
existing[field] = merged
|
||||
return list(deduped.values())
|
||||
|
||||
|
||||
def _rank_matches(
|
||||
matches: list[dict[str, Any]],
|
||||
*,
|
||||
frequency_mhz: float,
|
||||
modulation: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
target_hz = frequency_mhz * 1e6
|
||||
wanted_mod = str(modulation or '').strip().upper()
|
||||
|
||||
def score(match: dict[str, Any]) -> tuple[int, float, str]:
|
||||
score_value = 0
|
||||
freqs_mhz = match.get('frequencies_mhz') or []
|
||||
distances_hz: list[float] = []
|
||||
for f_mhz in freqs_mhz:
|
||||
try:
|
||||
distances_hz.append(abs((float(f_mhz) * 1e6) - target_hz))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
min_distance_hz = min(distances_hz) if distances_hz else 1e12
|
||||
|
||||
if min_distance_hz <= 100:
|
||||
score_value += 120
|
||||
elif min_distance_hz <= 1_000:
|
||||
score_value += 90
|
||||
elif min_distance_hz <= 10_000:
|
||||
score_value += 70
|
||||
elif min_distance_hz <= 100_000:
|
||||
score_value += 40
|
||||
|
||||
if wanted_mod:
|
||||
modes = [str(v).upper() for v in (match.get('modes') or [])]
|
||||
modulations = [str(v).upper() for v in (match.get('modulations') or [])]
|
||||
if wanted_mod in modes:
|
||||
score_value += 25
|
||||
if wanted_mod in modulations:
|
||||
score_value += 25
|
||||
|
||||
title = str(match.get('title') or '')
|
||||
title_lower = title.lower()
|
||||
if 'unidentified' in title_lower or 'unknown' in title_lower:
|
||||
score_value -= 10
|
||||
|
||||
return (score_value, min_distance_hz, title.lower())
|
||||
|
||||
ranked = sorted(matches, key=score, reverse=True)
|
||||
for match in ranked:
|
||||
try:
|
||||
nearest = min(abs((float(f) * 1e6) - target_hz) for f in (match.get('frequencies_mhz') or []))
|
||||
match['distance_hz'] = int(round(nearest))
|
||||
except Exception:
|
||||
match['distance_hz'] = None
|
||||
return ranked
|
||||
|
||||
|
||||
def _format_freq_variants_mhz(freq_mhz: float) -> list[str]:
|
||||
variants = [
|
||||
f'{freq_mhz:.6f}'.rstrip('0').rstrip('.'),
|
||||
f'{freq_mhz:.4f}'.rstrip('0').rstrip('.'),
|
||||
f'{freq_mhz:.3f}'.rstrip('0').rstrip('.'),
|
||||
]
|
||||
out: list[str] = []
|
||||
for value in variants:
|
||||
if value and value not in out:
|
||||
out.append(value)
|
||||
return out
|
||||
|
||||
|
||||
def _lookup_sigidwiki_matches(frequency_mhz: float, modulation: str, limit: int) -> dict[str, Any]:
|
||||
all_matches: list[dict[str, Any]] = []
|
||||
exact_queries: list[str] = []
|
||||
|
||||
for freq_token in _format_freq_variants_mhz(frequency_mhz):
|
||||
query = (
|
||||
f'[[Category:Signal]][[Frequencies::{freq_token} MHz]]'
|
||||
f'|?Frequencies|?Mode|?Modulation|limit={max(10, limit * 2)}'
|
||||
)
|
||||
exact_queries.append(query)
|
||||
data = _ask_query(query)
|
||||
if data:
|
||||
all_matches.extend(_extract_matches_from_ask(data))
|
||||
if all_matches:
|
||||
break
|
||||
|
||||
search_used = False
|
||||
if not all_matches:
|
||||
search_used = True
|
||||
search_terms = [f'{frequency_mhz:.4f} MHz']
|
||||
if modulation:
|
||||
search_terms.insert(0, f'{frequency_mhz:.4f} MHz {modulation.upper()}')
|
||||
|
||||
seen_titles: set[str] = set()
|
||||
for term in search_terms:
|
||||
search_data = _search_query(term, max(5, min(limit * 2, 10)))
|
||||
search_results = search_data.get('query', {}).get('search', []) if isinstance(search_data, dict) else []
|
||||
if not isinstance(search_results, list) or not search_results:
|
||||
continue
|
||||
|
||||
for item in search_results:
|
||||
title = str(item.get('title') or '').strip()
|
||||
if not title or title in seen_titles:
|
||||
continue
|
||||
seen_titles.add(title)
|
||||
page_query = f'[[{title}]]|?Frequencies|?Mode|?Modulation|limit=1'
|
||||
page_data = _ask_query(page_query)
|
||||
if page_data:
|
||||
all_matches.extend(_extract_matches_from_ask(page_data))
|
||||
if len(all_matches) >= max(limit * 3, 12):
|
||||
break
|
||||
if all_matches:
|
||||
break
|
||||
|
||||
deduped = _dedupe_matches(all_matches)
|
||||
ranked = _rank_matches(deduped, frequency_mhz=frequency_mhz, modulation=modulation)
|
||||
return {
|
||||
'matches': ranked[:limit],
|
||||
'search_used': search_used,
|
||||
'exact_queries': exact_queries,
|
||||
}
|
||||
|
||||
|
||||
@signalid_bp.route('/sigidwiki', methods=['POST'])
|
||||
def sigidwiki_lookup() -> Response:
|
||||
"""Lookup likely signal types from SigID Wiki by tuned frequency."""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
|
||||
freq_raw = payload.get('frequency_mhz')
|
||||
if freq_raw is None:
|
||||
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
|
||||
|
||||
try:
|
||||
frequency_mhz = float(freq_raw)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
|
||||
|
||||
if frequency_mhz <= 0:
|
||||
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400
|
||||
|
||||
modulation = str(payload.get('modulation') or '').strip().upper()
|
||||
if modulation and len(modulation) > 16:
|
||||
modulation = modulation[:16]
|
||||
|
||||
limit_raw = payload.get('limit', 8)
|
||||
try:
|
||||
limit = int(limit_raw)
|
||||
except (TypeError, ValueError):
|
||||
limit = 8
|
||||
limit = max(1, min(limit, 20))
|
||||
|
||||
cache_key = f'{round(frequency_mhz, 6)}|{modulation}|{limit}'
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'source': 'sigidwiki',
|
||||
'frequency_mhz': round(frequency_mhz, 6),
|
||||
'modulation': modulation or None,
|
||||
'cached': True,
|
||||
**cached,
|
||||
})
|
||||
|
||||
try:
|
||||
lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit)
|
||||
except Exception as exc:
|
||||
logger.error('SigID lookup failed: %s', exc)
|
||||
return jsonify({'status': 'error', 'message': 'SigID lookup failed'}), 502
|
||||
|
||||
response_payload = {
|
||||
'matches': lookup.get('matches', []),
|
||||
'match_count': len(lookup.get('matches', [])),
|
||||
'search_used': bool(lookup.get('search_used')),
|
||||
'exact_queries': lookup.get('exact_queries', []),
|
||||
}
|
||||
_cache_set(cache_key, response_payload)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'source': 'sigidwiki',
|
||||
'frequency_mhz': round(frequency_mhz, 6),
|
||||
'modulation': modulation or None,
|
||||
'cached': False,
|
||||
**response_payload,
|
||||
})
|
||||
|
||||
@@ -893,6 +893,82 @@ body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.map-crosshair-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 650;
|
||||
--target-x: 50%;
|
||||
--target-y: 50%;
|
||||
}
|
||||
|
||||
.map-crosshair-line {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 10px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.map-crosshair-vertical {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
right: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.map-crosshair-horizontal {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
bottom: 0;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.map-crosshair-overlay.active .map-crosshair-vertical {
|
||||
animation: mapCrosshairSweepX 620ms cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||
}
|
||||
|
||||
.map-crosshair-overlay.active .map-crosshair-horizontal {
|
||||
animation: mapCrosshairSweepY 620ms cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes mapCrosshairSweepX {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
opacity: 0.95;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(calc(var(--target-x) - 100%));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mapCrosshairSweepY {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.95;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(calc(var(--target-y) - 100%));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.map-crosshair-overlay.active .map-crosshair-vertical,
|
||||
.map-crosshair-overlay.active .map-crosshair-horizontal {
|
||||
animation-duration: 120ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* Right sidebar - Mobile first */
|
||||
.sidebar {
|
||||
display: flex;
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/* 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%;
|
||||
}
|
||||
@@ -140,14 +140,65 @@
|
||||
}
|
||||
|
||||
.gps-skyview-canvas-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
display: block;
|
||||
width: min(100%, 430px);
|
||||
aspect-ratio: 1 / 1;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#gpsSkyCanvas {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#gpsSkyCanvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.gps-sky-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.gps-sky-label {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.2px;
|
||||
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gps-sky-label-cardinal {
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.gps-sky-label-sat {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gps-sky-label-sat.unused {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.gps-sky-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
/* Position info panel */
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/* 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; }
|
||||
}
|
||||
@@ -412,6 +412,11 @@
|
||||
background: rgba(74, 163, 255, 0.28);
|
||||
}
|
||||
|
||||
.wf-zoom-btn {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wf-freq-display-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -494,6 +499,142 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Band strip below spectrum */
|
||||
|
||||
.wf-band-strip {
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, rgba(9, 14, 26, 0.96) 0%, rgba(5, 10, 18, 0.98) 100%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.wf-band-block {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
bottom: 5px;
|
||||
border: 1px solid rgba(150, 203, 255, 0.46);
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 5px;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12), 0 0 8px rgba(3, 10, 25, 0.5);
|
||||
color: rgba(236, 247, 255, 0.96);
|
||||
}
|
||||
|
||||
.wf-band-name {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.wf-band-edge {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgba(209, 230, 255, 0.95);
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.wf-band-block.is-tight {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.wf-band-block.is-tight .wf-band-edge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-band-block.is-tight .wf-band-name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wf-band-block.is-mini {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.wf-band-block.is-mini .wf-band-edge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-band-block.is-mini .wf-band-name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wf-band-marker {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
width: 1px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.wf-band-marker::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 1px;
|
||||
background: rgba(166, 216, 255, 0.62);
|
||||
box-shadow: 0 0 5px rgba(71, 175, 255, 0.34);
|
||||
}
|
||||
|
||||
.wf-band-marker-label {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 84px;
|
||||
height: 12px;
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(152, 210, 255, 0.52);
|
||||
background: rgba(11, 24, 44, 0.95);
|
||||
color: rgba(220, 240, 255, 0.95);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wf-band-marker.lane-0 .wf-band-marker-label {
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.wf-band-marker.lane-1 .wf-band-marker-label {
|
||||
top: 19px;
|
||||
}
|
||||
|
||||
.wf-band-marker.is-overlap .wf-band-marker-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-band-strip-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Resize handle */
|
||||
|
||||
.wf-resize-handle {
|
||||
@@ -664,3 +805,378 @@
|
||||
width: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar controls */
|
||||
|
||||
.wf-side .section.wf-side-hero {
|
||||
background: linear-gradient(180deg, rgba(16, 26, 40, 0.95) 0%, rgba(9, 15, 24, 0.97) 100%);
|
||||
border-color: rgba(96, 171, 255, 0.34);
|
||||
box-shadow: 0 8px 24px rgba(0, 10, 26, 0.34), inset 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.wf-side-hero-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wf-side-hero-title {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.wf-side-chip {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
color: #9fd0ff;
|
||||
border: 1px solid rgba(88, 175, 255, 0.36);
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 73, 124, 0.32);
|
||||
padding: 2px 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wf-side-hero-subtext {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.wf-side-hero-stats {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wf-side-stat {
|
||||
border: 1px solid rgba(92, 163, 255, 0.22);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.26);
|
||||
padding: 6px 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wf-side-stat-label {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wf-side-stat-value {
|
||||
margin-top: 4px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wf-side-hero-actions {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.wf-side-status-line {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.wf-side-help {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wf-side-box {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(74, 163, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.wf-side-box-muted {
|
||||
border-color: rgba(74, 163, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.wf-side-kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wf-side-kv:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wf-side-kv-label {
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.wf-side-kv-value {
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono, monospace);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wf-side-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wf-side-grid-2.wf-side-grid-gap-top {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.wf-side-divider {
|
||||
margin: 9px 0;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.wf-bookmark-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wf-bookmark-row input,
|
||||
.wf-bookmark-row select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wf-bookmark-list,
|
||||
.wf-recent-list {
|
||||
margin-top: 8px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wf-bookmark-item,
|
||||
.wf-recent-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
border: 1px solid rgba(74, 163, 255, 0.16);
|
||||
border-radius: 5px;
|
||||
padding: 5px 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wf-recent-item {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.wf-bookmark-link,
|
||||
.wf-recent-link {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wf-bookmark-link:hover,
|
||||
.wf-recent-link:hover {
|
||||
color: #bce1ff;
|
||||
}
|
||||
|
||||
.wf-bookmark-mode {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.wf-bookmark-remove {
|
||||
border: 1px solid rgba(255, 126, 126, 0.35);
|
||||
border-radius: 4px;
|
||||
background: rgba(90, 16, 16, 0.45);
|
||||
color: #ffb3b3;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
width: 22px;
|
||||
height: 20px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wf-side-inline-label {
|
||||
margin-top: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.wf-inline-value {
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wf-empty {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.wf-scan-checkboxes {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.wf-scan-metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wf-scan-metric-card {
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
border: 1px solid rgba(74, 163, 255, 0.18);
|
||||
border-radius: 6px;
|
||||
padding: 7px 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wf-scan-metric-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wf-scan-metric-value {
|
||||
margin-top: 4px;
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wf-hit-table-wrap {
|
||||
margin-top: 8px;
|
||||
max-height: 145px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(74, 163, 255, 0.16);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.wf-hit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.wf-hit-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(15, 25, 38, 0.94);
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 5px 4px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wf-hit-table td {
|
||||
padding: 4px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wf-hit-table td:last-child {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.wf-hit-action {
|
||||
border: 1px solid rgba(93, 182, 255, 0.34);
|
||||
border-radius: 4px;
|
||||
background: rgba(21, 54, 95, 0.72);
|
||||
color: #b8e1ff;
|
||||
padding: 2px 6px;
|
||||
cursor: pointer;
|
||||
font-size: 9px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wf-hit-action:hover {
|
||||
background: rgba(29, 73, 128, 0.82);
|
||||
}
|
||||
|
||||
.wf-activity-log {
|
||||
margin-top: 8px;
|
||||
max-height: 130px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(74, 163, 255, 0.16);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.wf-log-entry {
|
||||
margin-bottom: 5px;
|
||||
padding: 4px 6px;
|
||||
border-left: 2px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.wf-log-entry:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wf-log-entry.is-signal {
|
||||
border-left-color: rgba(67, 232, 145, 0.75);
|
||||
}
|
||||
|
||||
.wf-log-entry.is-error {
|
||||
border-left-color: rgba(255, 111, 111, 0.75);
|
||||
}
|
||||
|
||||
.wf-log-time {
|
||||
color: var(--text-muted);
|
||||
margin-right: 6px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Activity Timeline Component
|
||||
* Reusable, configuration-driven timeline visualization for time-based metadata
|
||||
* Supports multiple modes: TSCM, Listening Post, Bluetooth, WiFi, Monitoring
|
||||
* Supports multiple modes: TSCM, RF Receiver, Bluetooth, WiFi, Monitoring
|
||||
*/
|
||||
|
||||
const ActivityTimeline = (function() {
|
||||
@@ -176,7 +176,7 @@ const ActivityTimeline = (function() {
|
||||
*/
|
||||
function categorizeById(id, mode) {
|
||||
// RF frequency categorization
|
||||
if (mode === 'rf' || mode === 'tscm' || mode === 'listening-post') {
|
||||
if (mode === 'rf' || mode === 'tscm' || mode === 'waterfall') {
|
||||
const f = parseFloat(id);
|
||||
if (!isNaN(f)) {
|
||||
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* RF Signal Timeline Adapter
|
||||
* Normalizes RF signal data for the Activity Timeline component
|
||||
* Used by: Listening Post, TSCM
|
||||
* Used by: Spectrum Waterfall, TSCM
|
||||
*/
|
||||
|
||||
const RFTimelineAdapter = (function() {
|
||||
@@ -158,12 +158,12 @@ const RFTimelineAdapter = (function() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timeline configuration for Listening Post mode
|
||||
* Create timeline configuration for spectrum waterfall mode.
|
||||
*/
|
||||
function getListeningPostConfig() {
|
||||
function getWaterfallConfig() {
|
||||
return {
|
||||
title: 'Signal Activity',
|
||||
mode: 'listening-post',
|
||||
title: 'Spectrum Activity',
|
||||
mode: 'waterfall',
|
||||
visualMode: 'enriched',
|
||||
collapsed: false,
|
||||
showAnnotations: true,
|
||||
@@ -188,6 +188,11 @@ const RFTimelineAdapter = (function() {
|
||||
};
|
||||
}
|
||||
|
||||
// Backward compatibility alias for legacy callers.
|
||||
function getListeningPostConfig() {
|
||||
return getWaterfallConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timeline configuration for TSCM mode
|
||||
*/
|
||||
@@ -224,6 +229,7 @@ const RFTimelineAdapter = (function() {
|
||||
categorizeFrequency: categorizeFrequency,
|
||||
|
||||
// Configuration presets
|
||||
getWaterfallConfig: getWaterfallConfig,
|
||||
getListeningPostConfig: getListeningPostConfig,
|
||||
getTscmConfig: getTscmConfig,
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ function switchMode(mode) {
|
||||
const modeMap = {
|
||||
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||
'listening': 'listening', 'meshtastic': 'meshtastic'
|
||||
'meshtastic': 'meshtastic'
|
||||
};
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||
const label = btn.querySelector('.nav-label');
|
||||
@@ -114,7 +114,6 @@ function switchMode(mode) {
|
||||
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
||||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||||
@@ -143,7 +142,6 @@ function switchMode(mode) {
|
||||
'satellite': 'SATELLITE',
|
||||
'wifi': 'WIFI',
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
'listening': 'LISTENING POST',
|
||||
'tscm': 'TSCM',
|
||||
'aprs': 'APRS',
|
||||
'meshtastic': 'MESHTASTIC'
|
||||
@@ -166,7 +164,6 @@ function switchMode(mode) {
|
||||
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
||||
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
||||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
|
||||
|
||||
// Update output panel title based on mode
|
||||
const titles = {
|
||||
@@ -176,7 +173,6 @@ function switchMode(mode) {
|
||||
'satellite': 'Satellite Monitor',
|
||||
'wifi': 'WiFi Scanner',
|
||||
'bluetooth': 'Bluetooth Scanner',
|
||||
'listening': 'Listening Post',
|
||||
'meshtastic': 'Meshtastic Mesh Monitor'
|
||||
};
|
||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||
@@ -184,7 +180,7 @@ function switchMode(mode) {
|
||||
// Show/hide Device Intelligence for modes that use it
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
|
||||
if (mode === 'satellite' || mode === 'aircraft') {
|
||||
document.getElementById('reconPanel').style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -198,7 +194,7 @@ function switchMode(mode) {
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
document.getElementById('rtlDeviceSection').style.display =
|
||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
|
||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft') ? 'block' : 'none';
|
||||
|
||||
// Toggle mode-specific tool status displays
|
||||
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
||||
@@ -207,7 +203,7 @@ function switchMode(mode) {
|
||||
|
||||
// Hide waterfall and output console for modes with their own visualizations
|
||||
document.querySelector('.waterfall-container').style.display =
|
||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
document.getElementById('output').style.display =
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
||||
@@ -226,11 +222,6 @@ function switchMode(mode) {
|
||||
} else if (mode === 'satellite') {
|
||||
if (typeof initPolarPlot === 'function') initPolarPlot();
|
||||
if (typeof initSatelliteList === 'function') initSatelliteList();
|
||||
} else if (mode === 'listening') {
|
||||
if (typeof checkScannerTools === 'function') checkScannerTools();
|
||||
if (typeof checkAudioTools === 'function') checkAudioTools();
|
||||
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
|
||||
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
|
||||
} else if (mode === 'meshtastic') {
|
||||
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
|
||||
}
|
||||
|
||||
@@ -16,17 +16,14 @@ const CheatSheets = (function () {
|
||||
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'] },
|
||||
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: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
|
||||
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'] },
|
||||
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 Spectrum Waterfall 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) {
|
||||
|
||||
@@ -12,8 +12,8 @@ const CommandPalette = (function() {
|
||||
{ mode: 'pager', label: 'Pager' },
|
||||
{ mode: 'sensor', label: '433MHz Sensors' },
|
||||
{ mode: 'rtlamr', label: 'Meters' },
|
||||
{ mode: 'listening', label: 'Listening Post' },
|
||||
{ mode: 'subghz', label: 'SubGHz' },
|
||||
{ mode: 'waterfall', label: 'Spectrum Waterfall' },
|
||||
{ mode: 'aprs', label: 'APRS' },
|
||||
{ mode: 'wifi', label: 'WiFi Scanner' },
|
||||
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },
|
||||
|
||||
@@ -130,7 +130,7 @@ const FirstRunSetup = (function() {
|
||||
['pager', 'Pager'],
|
||||
['sensor', '433MHz'],
|
||||
['rtlamr', 'Meters'],
|
||||
['listening', 'Listening Post'],
|
||||
['waterfall', 'Waterfall'],
|
||||
['wifi', 'WiFi'],
|
||||
['bluetooth', 'Bluetooth'],
|
||||
['bt_locate', 'BT Locate'],
|
||||
@@ -149,7 +149,11 @@ const FirstRunSetup = (function() {
|
||||
|
||||
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
|
||||
if (savedDefaultMode) {
|
||||
modeSelectEl.value = savedDefaultMode;
|
||||
const normalizedMode = savedDefaultMode === 'listening' ? 'waterfall' : savedDefaultMode;
|
||||
modeSelectEl.value = normalizedMode;
|
||||
if (normalizedMode !== savedDefaultMode) {
|
||||
localStorage.setItem(DEFAULT_MODE_KEY, normalizedMode);
|
||||
}
|
||||
}
|
||||
|
||||
actionsEl.appendChild(modeSelectEl);
|
||||
|
||||
@@ -11,8 +11,6 @@ const KeyboardShortcuts = (function () {
|
||||
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;
|
||||
|
||||
@@ -10,6 +10,10 @@ const VoiceAlerts = (function () {
|
||||
let _sources = {};
|
||||
const STORAGE_KEY = 'intercept-voice-muted';
|
||||
const CONFIG_KEY = 'intercept-voice-config';
|
||||
const RATE_MIN = 0.5;
|
||||
const RATE_MAX = 2.0;
|
||||
const PITCH_MIN = 0.5;
|
||||
const PITCH_MAX = 2.0;
|
||||
|
||||
// Default config
|
||||
let _config = {
|
||||
@@ -19,6 +23,28 @@ const VoiceAlerts = (function () {
|
||||
streams: { pager: true, tscm: true, bluetooth: true },
|
||||
};
|
||||
|
||||
function _toNumberInRange(value, fallback, min, max) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
function _normalizeConfig() {
|
||||
_config.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX);
|
||||
_config.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX);
|
||||
_config.voiceName = typeof _config.voiceName === 'string' ? _config.voiceName : '';
|
||||
}
|
||||
|
||||
function _isSpeechSupported() {
|
||||
return !!(window.speechSynthesis && typeof window.SpeechSynthesisUtterance !== 'undefined');
|
||||
}
|
||||
|
||||
function _showVoiceToast(title, message, type) {
|
||||
if (typeof window.showAppToast === 'function') {
|
||||
window.showAppToast(title, message, type || 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
function _loadConfig() {
|
||||
_muted = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
try {
|
||||
@@ -33,6 +59,7 @@ const VoiceAlerts = (function () {
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
_normalizeConfig();
|
||||
_updateMuteButton();
|
||||
}
|
||||
|
||||
@@ -50,6 +77,15 @@ const VoiceAlerts = (function () {
|
||||
return voices.find(v => v.name === _config.voiceName) || null;
|
||||
}
|
||||
|
||||
function _createUtterance(text) {
|
||||
const utt = new SpeechSynthesisUtterance(text);
|
||||
utt.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX);
|
||||
utt.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX);
|
||||
const voice = _getVoice();
|
||||
if (voice) utt.voice = voice;
|
||||
return utt;
|
||||
}
|
||||
|
||||
function speak(text, priority) {
|
||||
if (priority === undefined) priority = PRIORITY.MEDIUM;
|
||||
if (!_enabled || _muted) return;
|
||||
@@ -68,11 +104,7 @@ const VoiceAlerts = (function () {
|
||||
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;
|
||||
const utt = _createUtterance(item.text);
|
||||
utt.onend = () => { _speaking = false; _dequeue(); };
|
||||
utt.onerror = () => { _speaking = false; _dequeue(); };
|
||||
window.speechSynthesis.speak(utt);
|
||||
@@ -141,6 +173,10 @@ const VoiceAlerts = (function () {
|
||||
|
||||
function init() {
|
||||
_loadConfig();
|
||||
if (_isSpeechSupported()) {
|
||||
// Prime voices list early so user-triggered test calls are less likely to be silent.
|
||||
speechSynthesis.getVoices();
|
||||
}
|
||||
_startStreams();
|
||||
}
|
||||
|
||||
@@ -161,10 +197,11 @@ const VoiceAlerts = (function () {
|
||||
}
|
||||
|
||||
function setConfig(cfg) {
|
||||
if (cfg.rate !== undefined) _config.rate = cfg.rate;
|
||||
if (cfg.pitch !== undefined) _config.pitch = cfg.pitch;
|
||||
if (cfg.rate !== undefined) _config.rate = _toNumberInRange(cfg.rate, _config.rate, RATE_MIN, RATE_MAX);
|
||||
if (cfg.pitch !== undefined) _config.pitch = _toNumberInRange(cfg.pitch, _config.pitch, PITCH_MIN, PITCH_MAX);
|
||||
if (cfg.voiceName !== undefined) _config.voiceName = cfg.voiceName;
|
||||
if (cfg.streams) Object.assign(_config.streams, cfg.streams);
|
||||
_normalizeConfig();
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(_config));
|
||||
// Restart streams to apply per-stream toggle changes
|
||||
_stopStreams();
|
||||
@@ -185,13 +222,31 @@ const VoiceAlerts = (function () {
|
||||
}
|
||||
|
||||
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;
|
||||
if (!_isSpeechSupported()) {
|
||||
_showVoiceToast('Voice Unavailable', 'This browser does not support speech synthesis.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Make the test immediate and recover from a paused/stalled synthesis engine.
|
||||
try {
|
||||
speechSynthesis.getVoices();
|
||||
if (speechSynthesis.paused) speechSynthesis.resume();
|
||||
speechSynthesis.cancel();
|
||||
} catch (_) {}
|
||||
|
||||
const utt = _createUtterance(text || 'Voice alert test. All systems nominal.');
|
||||
let started = false;
|
||||
utt.onstart = () => { started = true; };
|
||||
utt.onerror = () => {
|
||||
_showVoiceToast('Voice Test Failed', 'Speech synthesis failed to start. Check browser audio output.', 'warning');
|
||||
};
|
||||
speechSynthesis.speak(utt);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!started && !speechSynthesis.speaking && !speechSynthesis.pending) {
|
||||
_showVoiceToast('No Voice Output', 'Test speech did not play. Verify browser audio and selected voice.', 'warning');
|
||||
}
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
/* 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;
|
||||
@@ -9,6 +9,9 @@ const GPS = (function() {
|
||||
let lastPosition = null;
|
||||
let lastSky = null;
|
||||
let skyPollTimer = null;
|
||||
let themeObserver = null;
|
||||
let skyRenderer = null;
|
||||
let skyRendererInitAttempted = false;
|
||||
|
||||
// Constellation color map
|
||||
const CONST_COLORS = {
|
||||
@@ -21,18 +24,43 @@ const GPS = (function() {
|
||||
};
|
||||
|
||||
function init() {
|
||||
initSkyRenderer();
|
||||
drawEmptySkyView();
|
||||
connect();
|
||||
if (!connected) connect();
|
||||
|
||||
// Redraw sky view when theme changes
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!themeObserver) {
|
||||
themeObserver = new MutationObserver(() => {
|
||||
if (skyRenderer && typeof skyRenderer.requestRender === 'function') {
|
||||
skyRenderer.requestRender();
|
||||
}
|
||||
if (lastSky) {
|
||||
drawSkyView(lastSky.satellites || []);
|
||||
} else {
|
||||
drawEmptySkyView();
|
||||
}
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
}
|
||||
|
||||
if (lastPosition) updatePositionUI(lastPosition);
|
||||
if (lastSky) updateSkyUI(lastSky);
|
||||
}
|
||||
|
||||
function initSkyRenderer() {
|
||||
if (skyRendererInitAttempted) return;
|
||||
skyRendererInitAttempted = true;
|
||||
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
const overlay = document.getElementById('gpsSkyOverlay');
|
||||
try {
|
||||
skyRenderer = createWebGlSkyRenderer(canvas, overlay);
|
||||
} catch (err) {
|
||||
skyRenderer = null;
|
||||
console.warn('GPS sky WebGL renderer failed, falling back to 2D', err);
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
@@ -253,41 +281,61 @@ const GPS = (function() {
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Sky View Polar Plot
|
||||
// Sky View Globe (WebGL with 2D fallback)
|
||||
// ========================
|
||||
|
||||
function drawEmptySkyView() {
|
||||
if (!skyRendererInitAttempted) {
|
||||
initSkyRenderer();
|
||||
}
|
||||
|
||||
if (skyRenderer) {
|
||||
skyRenderer.setSatellites([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
drawSkyViewBase(canvas);
|
||||
drawSkyViewBase2D(canvas);
|
||||
}
|
||||
|
||||
function drawSkyView(satellites) {
|
||||
if (!skyRendererInitAttempted) {
|
||||
initSkyRenderer();
|
||||
}
|
||||
|
||||
const sats = Array.isArray(satellites) ? satellites : [];
|
||||
|
||||
if (skyRenderer) {
|
||||
skyRenderer.setSatellites(sats);
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
drawSkyViewBase2D(canvas);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const r = Math.min(cx, cy) - 24;
|
||||
|
||||
drawSkyViewBase(canvas);
|
||||
|
||||
// Plot satellites
|
||||
satellites.forEach(sat => {
|
||||
sats.forEach(sat => {
|
||||
if (sat.elevation == null || sat.azimuth == null) return;
|
||||
|
||||
const elRad = (90 - sat.elevation) / 90;
|
||||
const azRad = (sat.azimuth - 90) * Math.PI / 180; // N = up
|
||||
const azRad = (sat.azimuth - 90) * Math.PI / 180;
|
||||
const px = cx + r * elRad * Math.cos(azRad);
|
||||
const py = cy + r * elRad * Math.sin(azRad);
|
||||
|
||||
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
|
||||
const color = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS;
|
||||
const dotSize = sat.used ? 6 : 4;
|
||||
|
||||
// Draw dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
||||
if (sat.used) {
|
||||
@@ -299,14 +347,12 @@ const GPS = (function() {
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// PRN label
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '8px Roboto Condensed, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
||||
|
||||
// SNR value
|
||||
if (sat.snr != null) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '7px Roboto Condensed, monospace';
|
||||
@@ -316,8 +362,10 @@ const GPS = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
function drawSkyViewBase(canvas) {
|
||||
function drawSkyViewBase2D(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const cx = w / 2;
|
||||
@@ -332,11 +380,9 @@ const GPS = (function() {
|
||||
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
|
||||
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Elevation rings (0, 30, 60, 90)
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 0.5;
|
||||
[90, 60, 30].forEach(el => {
|
||||
@@ -344,7 +390,7 @@ const GPS = (function() {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
// Label
|
||||
|
||||
ctx.fillStyle = dimColor;
|
||||
ctx.font = '9px Roboto Condensed, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
@@ -352,14 +398,12 @@ const GPS = (function() {
|
||||
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
||||
});
|
||||
|
||||
// Horizon circle
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Cardinal directions
|
||||
ctx.fillStyle = secondaryColor;
|
||||
ctx.font = 'bold 11px Roboto Condensed, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
@@ -369,7 +413,6 @@ const GPS = (function() {
|
||||
ctx.fillText('E', cx + r + 12, cy);
|
||||
ctx.fillText('W', cx - r - 12, cy);
|
||||
|
||||
// Crosshairs
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
@@ -379,13 +422,604 @@ const GPS = (function() {
|
||||
ctx.lineTo(cx + r, cy);
|
||||
ctx.stroke();
|
||||
|
||||
// Zenith dot
|
||||
ctx.fillStyle = dimColor;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function createWebGlSkyRenderer(canvas, overlay) {
|
||||
const gl = canvas.getContext('webgl', { antialias: true, alpha: false, depth: true });
|
||||
if (!gl) return null;
|
||||
|
||||
const lineProgram = createProgram(
|
||||
gl,
|
||||
[
|
||||
'attribute vec3 aPosition;',
|
||||
'uniform mat4 uMVP;',
|
||||
'void main(void) {',
|
||||
' gl_Position = uMVP * vec4(aPosition, 1.0);',
|
||||
'}',
|
||||
].join('\n'),
|
||||
[
|
||||
'precision mediump float;',
|
||||
'uniform vec4 uColor;',
|
||||
'void main(void) {',
|
||||
' gl_FragColor = uColor;',
|
||||
'}',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const pointProgram = createProgram(
|
||||
gl,
|
||||
[
|
||||
'attribute vec3 aPosition;',
|
||||
'attribute vec4 aColor;',
|
||||
'attribute float aSize;',
|
||||
'attribute float aUsed;',
|
||||
'uniform mat4 uMVP;',
|
||||
'uniform float uDevicePixelRatio;',
|
||||
'uniform vec3 uCameraDir;',
|
||||
'varying vec4 vColor;',
|
||||
'varying float vUsed;',
|
||||
'varying float vFacing;',
|
||||
'void main(void) {',
|
||||
' vec3 normPos = normalize(aPosition);',
|
||||
' vFacing = dot(normPos, normalize(uCameraDir));',
|
||||
' gl_Position = uMVP * vec4(aPosition, 1.0);',
|
||||
' gl_PointSize = aSize * uDevicePixelRatio;',
|
||||
' vColor = aColor;',
|
||||
' vUsed = aUsed;',
|
||||
'}',
|
||||
].join('\n'),
|
||||
[
|
||||
'precision mediump float;',
|
||||
'varying vec4 vColor;',
|
||||
'varying float vUsed;',
|
||||
'varying float vFacing;',
|
||||
'void main(void) {',
|
||||
' if (vFacing <= 0.0) discard;',
|
||||
' vec2 c = gl_PointCoord * 2.0 - 1.0;',
|
||||
' float d = dot(c, c);',
|
||||
' if (d > 1.0) discard;',
|
||||
' if (vUsed < 0.5 && d < 0.45) discard;',
|
||||
' float edge = smoothstep(1.0, 0.75, d);',
|
||||
' gl_FragColor = vec4(vColor.rgb, vColor.a * edge);',
|
||||
'}',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
if (!lineProgram || !pointProgram) return null;
|
||||
|
||||
const lineLoc = {
|
||||
position: gl.getAttribLocation(lineProgram, 'aPosition'),
|
||||
mvp: gl.getUniformLocation(lineProgram, 'uMVP'),
|
||||
color: gl.getUniformLocation(lineProgram, 'uColor'),
|
||||
};
|
||||
|
||||
const pointLoc = {
|
||||
position: gl.getAttribLocation(pointProgram, 'aPosition'),
|
||||
color: gl.getAttribLocation(pointProgram, 'aColor'),
|
||||
size: gl.getAttribLocation(pointProgram, 'aSize'),
|
||||
used: gl.getAttribLocation(pointProgram, 'aUsed'),
|
||||
mvp: gl.getUniformLocation(pointProgram, 'uMVP'),
|
||||
dpr: gl.getUniformLocation(pointProgram, 'uDevicePixelRatio'),
|
||||
cameraDir: gl.getUniformLocation(pointProgram, 'uCameraDir'),
|
||||
};
|
||||
|
||||
const gridVertices = buildSkyGridVertices();
|
||||
const horizonVertices = buildSkyRingVertices(0, 4);
|
||||
|
||||
const gridBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, gridVertices, gl.STATIC_DRAW);
|
||||
|
||||
const horizonBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, horizonBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, horizonVertices, gl.STATIC_DRAW);
|
||||
|
||||
const satPosBuffer = gl.createBuffer();
|
||||
const satColorBuffer = gl.createBuffer();
|
||||
const satSizeBuffer = gl.createBuffer();
|
||||
const satUsedBuffer = gl.createBuffer();
|
||||
|
||||
let satCount = 0;
|
||||
let satLabels = [];
|
||||
let cssWidth = 0;
|
||||
let cssHeight = 0;
|
||||
let devicePixelRatio = 1;
|
||||
let mvpMatrix = identityMat4();
|
||||
let cameraDir = [0, 1, 0];
|
||||
let yaw = 0.8;
|
||||
let pitch = 0.6;
|
||||
let distance = 2.7;
|
||||
let rafId = null;
|
||||
let destroyed = false;
|
||||
let activePointerId = null;
|
||||
let lastPointerX = 0;
|
||||
let lastPointerY = 0;
|
||||
|
||||
const resizeObserver = (typeof ResizeObserver !== 'undefined')
|
||||
? new ResizeObserver(() => {
|
||||
requestRender();
|
||||
})
|
||||
: null;
|
||||
if (resizeObserver) resizeObserver.observe(canvas);
|
||||
|
||||
canvas.addEventListener('pointerdown', onPointerDown);
|
||||
canvas.addEventListener('pointermove', onPointerMove);
|
||||
canvas.addEventListener('pointerup', onPointerUp);
|
||||
canvas.addEventListener('pointercancel', onPointerUp);
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||
|
||||
requestRender();
|
||||
|
||||
function onPointerDown(evt) {
|
||||
activePointerId = evt.pointerId;
|
||||
lastPointerX = evt.clientX;
|
||||
lastPointerY = evt.clientY;
|
||||
if (canvas.setPointerCapture) canvas.setPointerCapture(evt.pointerId);
|
||||
}
|
||||
|
||||
function onPointerMove(evt) {
|
||||
if (activePointerId == null || evt.pointerId !== activePointerId) return;
|
||||
|
||||
const dx = evt.clientX - lastPointerX;
|
||||
const dy = evt.clientY - lastPointerY;
|
||||
lastPointerX = evt.clientX;
|
||||
lastPointerY = evt.clientY;
|
||||
|
||||
yaw += dx * 0.01;
|
||||
pitch += dy * 0.01;
|
||||
pitch = Math.max(0.1, Math.min(1.45, pitch));
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function onPointerUp(evt) {
|
||||
if (activePointerId == null || evt.pointerId !== activePointerId) return;
|
||||
if (canvas.releasePointerCapture) {
|
||||
try {
|
||||
canvas.releasePointerCapture(evt.pointerId);
|
||||
} catch (_) {}
|
||||
}
|
||||
activePointerId = null;
|
||||
}
|
||||
|
||||
function onWheel(evt) {
|
||||
evt.preventDefault();
|
||||
distance += evt.deltaY * 0.002;
|
||||
distance = Math.max(2.0, Math.min(5.0, distance));
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function setSatellites(satellites) {
|
||||
const positions = [];
|
||||
const colors = [];
|
||||
const sizes = [];
|
||||
const usedFlags = [];
|
||||
const labels = [];
|
||||
|
||||
(satellites || []).forEach(sat => {
|
||||
if (sat.elevation == null || sat.azimuth == null) return;
|
||||
|
||||
const xyz = skyToCartesian(sat.azimuth, sat.elevation);
|
||||
const hex = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS;
|
||||
const rgb = hexToRgb01(hex);
|
||||
|
||||
positions.push(xyz[0], xyz[1], xyz[2]);
|
||||
colors.push(rgb[0], rgb[1], rgb[2], sat.used ? 1 : 0.85);
|
||||
sizes.push(sat.used ? 8 : 7);
|
||||
usedFlags.push(sat.used ? 1 : 0);
|
||||
|
||||
labels.push({
|
||||
text: String(sat.prn),
|
||||
point: xyz,
|
||||
color: hex,
|
||||
used: !!sat.used,
|
||||
});
|
||||
});
|
||||
|
||||
satLabels = labels;
|
||||
satCount = positions.length / 3;
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satPosBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.DYNAMIC_DRAW);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satColorBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.DYNAMIC_DRAW);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satSizeBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sizes), gl.DYNAMIC_DRAW);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satUsedBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(usedFlags), gl.DYNAMIC_DRAW);
|
||||
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function requestRender() {
|
||||
if (destroyed || rafId != null) return;
|
||||
rafId = requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
function render() {
|
||||
rafId = null;
|
||||
if (destroyed) return;
|
||||
|
||||
resizeCanvas();
|
||||
updateCameraMatrices();
|
||||
|
||||
const palette = getThemePalette();
|
||||
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.clearColor(palette.bg[0], palette.bg[1], palette.bg[2], 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
gl.depthFunc(gl.LEQUAL);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
gl.useProgram(lineProgram);
|
||||
gl.uniformMatrix4fv(lineLoc.mvp, false, mvpMatrix);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer);
|
||||
gl.enableVertexAttribArray(lineLoc.position);
|
||||
gl.vertexAttribPointer(lineLoc.position, 3, gl.FLOAT, false, 0, 0);
|
||||
gl.uniform4fv(lineLoc.color, palette.grid);
|
||||
gl.drawArrays(gl.LINES, 0, gridVertices.length / 3);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, horizonBuffer);
|
||||
gl.vertexAttribPointer(lineLoc.position, 3, gl.FLOAT, false, 0, 0);
|
||||
gl.uniform4fv(lineLoc.color, palette.horizon);
|
||||
gl.drawArrays(gl.LINES, 0, horizonVertices.length / 3);
|
||||
|
||||
if (satCount > 0) {
|
||||
gl.useProgram(pointProgram);
|
||||
gl.uniformMatrix4fv(pointLoc.mvp, false, mvpMatrix);
|
||||
gl.uniform1f(pointLoc.dpr, devicePixelRatio);
|
||||
gl.uniform3fv(pointLoc.cameraDir, new Float32Array(cameraDir));
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satPosBuffer);
|
||||
gl.enableVertexAttribArray(pointLoc.position);
|
||||
gl.vertexAttribPointer(pointLoc.position, 3, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satColorBuffer);
|
||||
gl.enableVertexAttribArray(pointLoc.color);
|
||||
gl.vertexAttribPointer(pointLoc.color, 4, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satSizeBuffer);
|
||||
gl.enableVertexAttribArray(pointLoc.size);
|
||||
gl.vertexAttribPointer(pointLoc.size, 1, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satUsedBuffer);
|
||||
gl.enableVertexAttribArray(pointLoc.used);
|
||||
gl.vertexAttribPointer(pointLoc.used, 1, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.drawArrays(gl.POINTS, 0, satCount);
|
||||
}
|
||||
|
||||
drawOverlayLabels();
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
cssWidth = Math.max(1, Math.floor(canvas.clientWidth || 400));
|
||||
cssHeight = Math.max(1, Math.floor(canvas.clientHeight || 400));
|
||||
devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
const renderWidth = Math.floor(cssWidth * devicePixelRatio);
|
||||
const renderHeight = Math.floor(cssHeight * devicePixelRatio);
|
||||
if (canvas.width !== renderWidth || canvas.height !== renderHeight) {
|
||||
canvas.width = renderWidth;
|
||||
canvas.height = renderHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function updateCameraMatrices() {
|
||||
const cosPitch = Math.cos(pitch);
|
||||
const eye = [
|
||||
distance * Math.sin(yaw) * cosPitch,
|
||||
distance * Math.sin(pitch),
|
||||
distance * Math.cos(yaw) * cosPitch,
|
||||
];
|
||||
|
||||
const eyeLen = Math.hypot(eye[0], eye[1], eye[2]) || 1;
|
||||
cameraDir = [eye[0] / eyeLen, eye[1] / eyeLen, eye[2] / eyeLen];
|
||||
|
||||
const view = mat4LookAt(eye, [0, 0, 0], [0, 1, 0]);
|
||||
const proj = mat4Perspective(degToRad(48), Math.max(cssWidth / cssHeight, 0.01), 0.1, 20);
|
||||
mvpMatrix = mat4Multiply(proj, view);
|
||||
}
|
||||
|
||||
function drawOverlayLabels() {
|
||||
if (!overlay) return;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
const cardinals = [
|
||||
{ text: 'N', point: [0, 0, 1] },
|
||||
{ text: 'E', point: [1, 0, 0] },
|
||||
{ text: 'S', point: [0, 0, -1] },
|
||||
{ text: 'W', point: [-1, 0, 0] },
|
||||
{ text: 'Z', point: [0, 1, 0] },
|
||||
];
|
||||
|
||||
cardinals.forEach(entry => {
|
||||
addLabel(fragment, entry.text, entry.point, 'gps-sky-label gps-sky-label-cardinal');
|
||||
});
|
||||
|
||||
satLabels.forEach(sat => {
|
||||
const cls = 'gps-sky-label gps-sky-label-sat' + (sat.used ? '' : ' unused');
|
||||
addLabel(fragment, sat.text, sat.point, cls, sat.color);
|
||||
});
|
||||
|
||||
overlay.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function addLabel(fragment, text, point, className, color) {
|
||||
const facing = point[0] * cameraDir[0] + point[1] * cameraDir[1] + point[2] * cameraDir[2];
|
||||
if (facing <= 0.02) return;
|
||||
|
||||
const projected = projectPoint(point, mvpMatrix, cssWidth, cssHeight);
|
||||
if (!projected) return;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = className;
|
||||
label.textContent = text;
|
||||
label.style.left = projected.x.toFixed(1) + 'px';
|
||||
label.style.top = projected.y.toFixed(1) + 'px';
|
||||
if (color) label.style.color = color;
|
||||
fragment.appendChild(label);
|
||||
}
|
||||
|
||||
function getThemePalette() {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const bg = parseCssColor(cs.getPropertyValue('--bg-card').trim(), '#0d1117');
|
||||
const grid = parseCssColor(cs.getPropertyValue('--border-color').trim(), '#3a4254');
|
||||
const accent = parseCssColor(cs.getPropertyValue('--accent-cyan').trim(), '#4aa3ff');
|
||||
|
||||
return {
|
||||
bg: bg,
|
||||
grid: [grid[0], grid[1], grid[2], 0.42],
|
||||
horizon: [accent[0], accent[1], accent[2], 0.56],
|
||||
};
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyed = true;
|
||||
if (rafId != null) cancelAnimationFrame(rafId);
|
||||
canvas.removeEventListener('pointerdown', onPointerDown);
|
||||
canvas.removeEventListener('pointermove', onPointerMove);
|
||||
canvas.removeEventListener('pointerup', onPointerUp);
|
||||
canvas.removeEventListener('pointercancel', onPointerUp);
|
||||
canvas.removeEventListener('wheel', onWheel);
|
||||
if (resizeObserver) {
|
||||
try {
|
||||
resizeObserver.disconnect();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (overlay) overlay.replaceChildren();
|
||||
}
|
||||
|
||||
return {
|
||||
setSatellites: setSatellites,
|
||||
requestRender: requestRender,
|
||||
destroy: destroy,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSkyGridVertices() {
|
||||
const vertices = [];
|
||||
|
||||
[15, 30, 45, 60, 75].forEach(el => {
|
||||
appendLineStrip(vertices, buildRingPoints(el, 6));
|
||||
});
|
||||
|
||||
for (let az = 0; az < 360; az += 30) {
|
||||
appendLineStrip(vertices, buildMeridianPoints(az, 5));
|
||||
}
|
||||
|
||||
return new Float32Array(vertices);
|
||||
}
|
||||
|
||||
function buildSkyRingVertices(elevation, stepAz) {
|
||||
const vertices = [];
|
||||
appendLineStrip(vertices, buildRingPoints(elevation, stepAz));
|
||||
return new Float32Array(vertices);
|
||||
}
|
||||
|
||||
function buildRingPoints(elevation, stepAz) {
|
||||
const points = [];
|
||||
for (let az = 0; az <= 360; az += stepAz) {
|
||||
points.push(skyToCartesian(az, elevation));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function buildMeridianPoints(azimuth, stepEl) {
|
||||
const points = [];
|
||||
for (let el = 0; el <= 90; el += stepEl) {
|
||||
points.push(skyToCartesian(azimuth, el));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function appendLineStrip(target, points) {
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const a = points[i - 1];
|
||||
const b = points[i];
|
||||
target.push(a[0], a[1], a[2], b[0], b[1], b[2]);
|
||||
}
|
||||
}
|
||||
|
||||
function skyToCartesian(azimuthDeg, elevationDeg) {
|
||||
const az = degToRad(azimuthDeg);
|
||||
const el = degToRad(elevationDeg);
|
||||
const cosEl = Math.cos(el);
|
||||
return [
|
||||
cosEl * Math.sin(az),
|
||||
Math.sin(el),
|
||||
cosEl * Math.cos(az),
|
||||
];
|
||||
}
|
||||
|
||||
function degToRad(deg) {
|
||||
return deg * Math.PI / 180;
|
||||
}
|
||||
|
||||
function createProgram(gl, vertexSource, fragmentSource) {
|
||||
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource);
|
||||
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
|
||||
if (!vertexShader || !fragmentShader) return null;
|
||||
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
gl.linkProgram(program);
|
||||
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
console.warn('WebGL program link failed:', gl.getProgramInfoLog(program));
|
||||
gl.deleteProgram(program);
|
||||
return null;
|
||||
}
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
function compileShader(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.warn('WebGL shader compile failed:', gl.getShaderInfoLog(shader));
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
|
||||
function identityMat4() {
|
||||
return new Float32Array([
|
||||
1, 0, 0, 0,
|
||||
0, 1, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1,
|
||||
]);
|
||||
}
|
||||
|
||||
function mat4Perspective(fovy, aspect, near, far) {
|
||||
const f = 1 / Math.tan(fovy / 2);
|
||||
const nf = 1 / (near - far);
|
||||
|
||||
return new Float32Array([
|
||||
f / aspect, 0, 0, 0,
|
||||
0, f, 0, 0,
|
||||
0, 0, (far + near) * nf, -1,
|
||||
0, 0, (2 * far * near) * nf, 0,
|
||||
]);
|
||||
}
|
||||
|
||||
function mat4LookAt(eye, center, up) {
|
||||
const zx = eye[0] - center[0];
|
||||
const zy = eye[1] - center[1];
|
||||
const zz = eye[2] - center[2];
|
||||
const zLen = Math.hypot(zx, zy, zz) || 1;
|
||||
const znx = zx / zLen;
|
||||
const zny = zy / zLen;
|
||||
const znz = zz / zLen;
|
||||
|
||||
const xx = up[1] * znz - up[2] * zny;
|
||||
const xy = up[2] * znx - up[0] * znz;
|
||||
const xz = up[0] * zny - up[1] * znx;
|
||||
const xLen = Math.hypot(xx, xy, xz) || 1;
|
||||
const xnx = xx / xLen;
|
||||
const xny = xy / xLen;
|
||||
const xnz = xz / xLen;
|
||||
|
||||
const ynx = zny * xnz - znz * xny;
|
||||
const yny = znz * xnx - znx * xnz;
|
||||
const ynz = znx * xny - zny * xnx;
|
||||
|
||||
return new Float32Array([
|
||||
xnx, ynx, znx, 0,
|
||||
xny, yny, zny, 0,
|
||||
xnz, ynz, znz, 0,
|
||||
-(xnx * eye[0] + xny * eye[1] + xnz * eye[2]),
|
||||
-(ynx * eye[0] + yny * eye[1] + ynz * eye[2]),
|
||||
-(znx * eye[0] + zny * eye[1] + znz * eye[2]),
|
||||
1,
|
||||
]);
|
||||
}
|
||||
|
||||
function mat4Multiply(a, b) {
|
||||
const out = new Float32Array(16);
|
||||
for (let col = 0; col < 4; col += 1) {
|
||||
for (let row = 0; row < 4; row += 1) {
|
||||
out[col * 4 + row] =
|
||||
a[row] * b[col * 4] +
|
||||
a[4 + row] * b[col * 4 + 1] +
|
||||
a[8 + row] * b[col * 4 + 2] +
|
||||
a[12 + row] * b[col * 4 + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function projectPoint(point, matrix, width, height) {
|
||||
const x = point[0];
|
||||
const y = point[1];
|
||||
const z = point[2];
|
||||
|
||||
const clipX = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12];
|
||||
const clipY = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13];
|
||||
const clipW = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15];
|
||||
if (clipW <= 0.0001) return null;
|
||||
|
||||
const ndcX = clipX / clipW;
|
||||
const ndcY = clipY / clipW;
|
||||
if (Math.abs(ndcX) > 1.2 || Math.abs(ndcY) > 1.2) return null;
|
||||
|
||||
return {
|
||||
x: (ndcX * 0.5 + 0.5) * width,
|
||||
y: (1 - (ndcY * 0.5 + 0.5)) * height,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCssColor(raw, fallbackHex) {
|
||||
const value = (raw || '').trim();
|
||||
|
||||
if (value.startsWith('#')) {
|
||||
return hexToRgb01(value);
|
||||
}
|
||||
|
||||
const match = value.match(/rgba?\(([^)]+)\)/i);
|
||||
if (match) {
|
||||
const parts = match[1].split(',').map(part => parseFloat(part.trim()));
|
||||
if (parts.length >= 3 && parts.every(n => Number.isFinite(n))) {
|
||||
return [parts[0] / 255, parts[1] / 255, parts[2] / 255];
|
||||
}
|
||||
}
|
||||
|
||||
return hexToRgb01(fallbackHex || '#0d1117');
|
||||
}
|
||||
|
||||
function hexToRgb01(hex) {
|
||||
let clean = (hex || '').trim().replace('#', '');
|
||||
if (clean.length === 3) {
|
||||
clean = clean.split('').map(ch => ch + ch).join('');
|
||||
}
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(clean)) {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
const num = parseInt(clean, 16);
|
||||
return [
|
||||
((num >> 16) & 255) / 255,
|
||||
((num >> 8) & 255) / 255,
|
||||
(num & 255) / 255,
|
||||
];
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Signal Strength Bars
|
||||
// ========================
|
||||
@@ -442,6 +1076,15 @@ const GPS = (function() {
|
||||
function destroy() {
|
||||
unsubscribeFromStream();
|
||||
stopSkyPolling();
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect();
|
||||
themeObserver = null;
|
||||
}
|
||||
if (skyRenderer) {
|
||||
skyRenderer.destroy();
|
||||
skyRenderer = null;
|
||||
}
|
||||
skyRendererInitAttempted = false;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,456 +0,0 @@
|
||||
/* 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;
|
||||
@@ -269,12 +269,10 @@ const SpyStations = (function() {
|
||||
*/
|
||||
function tuneToStation(stationId, freqKhz) {
|
||||
const freqMhz = freqKhz / 1000;
|
||||
sessionStorage.setItem('tuneFrequency', freqMhz.toString());
|
||||
|
||||
// Find the station and determine mode
|
||||
const station = stations.find(s => s.id === stationId);
|
||||
const tuneMode = station ? getModeFromStation(station.mode) : 'usb';
|
||||
sessionStorage.setItem('tuneMode', tuneMode);
|
||||
|
||||
const stationName = station ? station.name : 'Station';
|
||||
|
||||
@@ -282,12 +280,18 @@ const SpyStations = (function() {
|
||||
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
|
||||
}
|
||||
|
||||
// Switch to listening post mode
|
||||
if (typeof selectMode === 'function') {
|
||||
selectMode('listening');
|
||||
} else if (typeof switchMode === 'function') {
|
||||
switchMode('listening');
|
||||
// Switch to spectrum waterfall mode and tune after mode init.
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode('waterfall');
|
||||
} else if (typeof selectMode === 'function') {
|
||||
selectMode('waterfall');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') {
|
||||
Waterfall.quickTune(freqMhz, tuneMode);
|
||||
}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -305,7 +309,7 @@ const SpyStations = (function() {
|
||||
* Check if we arrived from another page with a tune request
|
||||
*/
|
||||
function checkTuneFrequency() {
|
||||
// This is for the listening post to check - spy stations sets, listening post reads
|
||||
// Reserved for cross-mode tune handoff behavior.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -445,7 +449,7 @@ const SpyStations = (function() {
|
||||
<div class="signal-details-section">
|
||||
<div class="signal-details-title">How to Listen</div>
|
||||
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
||||
Click "Tune In" on any station to open the Listening Post with the frequency pre-configured.
|
||||
Click "Tune In" on any station to open Spectrum Waterfall with the frequency pre-configured.
|
||||
Most number stations use USB (Upper Sideband) mode. You'll need an SDR capable of receiving
|
||||
HF frequencies (typically 3-30 MHz) and an appropriate antenna.
|
||||
</p>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,13 @@
|
||||
/* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */
|
||||
const CACHE_NAME = 'intercept-v1';
|
||||
const CACHE_NAME = 'intercept-v2';
|
||||
|
||||
const NETWORK_ONLY_PREFIXES = [
|
||||
'/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/',
|
||||
'/adsb/', '/ais/', '/acars/', '/aprs/', '/tscm/', '/satellite/',
|
||||
'/meshtastic/', '/bt_locate/', '/listening/', '/sensor/', '/pager/',
|
||||
'/meshtastic/', '/bt_locate/', '/receiver/', '/sensor/', '/pager/',
|
||||
'/sstv/', '/weather-sat/', '/subghz/', '/rtlamr/', '/dsc/', '/vdl2/',
|
||||
'/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/',
|
||||
'/recordings/', '/controller/', '/fingerprint/', '/ops/',
|
||||
'/recordings/', '/controller/', '/ops/',
|
||||
];
|
||||
|
||||
const STATIC_PREFIXES = [
|
||||
|
||||
@@ -248,6 +248,10 @@
|
||||
<div class="display-container">
|
||||
<div id="radarMap">
|
||||
</div>
|
||||
<div id="mapCrosshairOverlay" class="map-crosshair-overlay" aria-hidden="true">
|
||||
<div class="map-crosshair-line map-crosshair-vertical"></div>
|
||||
<div class="map-crosshair-line map-crosshair-horizontal"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -419,6 +423,10 @@
|
||||
let alertsEnabled = true;
|
||||
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
|
||||
let soundedAircraft = {}; // Track aircraft we've played detection sound for
|
||||
const MAP_CROSSHAIR_DURATION_MS = 620;
|
||||
let mapCrosshairResetTimer = null;
|
||||
let mapCrosshairFallbackTimer = null;
|
||||
let mapCrosshairRequestId = 0;
|
||||
// Watchlist - persisted to localStorage
|
||||
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
|
||||
|
||||
@@ -2610,7 +2618,7 @@ sudo make install</code>
|
||||
} else {
|
||||
markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color, iconType, isSelected) })
|
||||
.addTo(radarMap)
|
||||
.on('click', () => selectAircraft(icao));
|
||||
.on('click', () => selectAircraft(icao, 'map'));
|
||||
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
|
||||
permanent: false, direction: 'top', className: 'aircraft-tooltip'
|
||||
});
|
||||
@@ -2714,7 +2722,7 @@ sudo make install</code>
|
||||
const div = document.createElement('div');
|
||||
div.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''} ${isOnWatchlist(ac) ? 'watched' : ''}`;
|
||||
div.setAttribute('data-icao', ac.icao);
|
||||
div.onclick = () => selectAircraft(ac.icao);
|
||||
div.onclick = () => selectAircraft(ac.icao, 'panel');
|
||||
div.innerHTML = buildAircraftItemHTML(ac);
|
||||
fragment.appendChild(div);
|
||||
});
|
||||
@@ -2784,9 +2792,39 @@ sudo make install</code>
|
||||
`;
|
||||
}
|
||||
|
||||
function selectAircraft(icao) {
|
||||
function triggerMapCrosshairAnimation(lat, lon) {
|
||||
if (!radarMap) return;
|
||||
const overlay = document.getElementById('mapCrosshairOverlay');
|
||||
if (!overlay) return;
|
||||
|
||||
const point = radarMap.latLngToContainerPoint([lat, lon]);
|
||||
const size = radarMap.getSize();
|
||||
const targetX = Math.max(0, Math.min(size.x, point.x));
|
||||
const targetY = Math.max(0, Math.min(size.y, point.y));
|
||||
|
||||
overlay.style.setProperty('--target-x', `${targetX}px`);
|
||||
overlay.style.setProperty('--target-y', `${targetY}px`);
|
||||
overlay.classList.remove('active');
|
||||
void overlay.offsetWidth;
|
||||
overlay.classList.add('active');
|
||||
|
||||
if (mapCrosshairResetTimer) {
|
||||
clearTimeout(mapCrosshairResetTimer);
|
||||
}
|
||||
mapCrosshairResetTimer = setTimeout(() => {
|
||||
overlay.classList.remove('active');
|
||||
mapCrosshairResetTimer = null;
|
||||
}, MAP_CROSSHAIR_DURATION_MS + 40);
|
||||
}
|
||||
|
||||
function selectAircraft(icao, source = 'map') {
|
||||
const prevSelected = selectedIcao;
|
||||
selectedIcao = icao;
|
||||
mapCrosshairRequestId += 1;
|
||||
if (mapCrosshairFallbackTimer) {
|
||||
clearTimeout(mapCrosshairFallbackTimer);
|
||||
mapCrosshairFallbackTimer = null;
|
||||
}
|
||||
|
||||
// Update marker icons for both previous and new selection
|
||||
[prevSelected, icao].forEach(targetIcao => {
|
||||
@@ -2811,7 +2849,26 @@ sudo make install</code>
|
||||
|
||||
const ac = aircraft[icao];
|
||||
if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
|
||||
radarMap.setView([ac.lat, ac.lon], 10);
|
||||
const targetLat = ac.lat;
|
||||
const targetLon = ac.lon;
|
||||
|
||||
if (source === 'panel' && radarMap) {
|
||||
const requestId = mapCrosshairRequestId;
|
||||
let crosshairTriggered = false;
|
||||
const runCrosshair = () => {
|
||||
if (crosshairTriggered || requestId !== mapCrosshairRequestId) return;
|
||||
crosshairTriggered = true;
|
||||
if (mapCrosshairFallbackTimer) {
|
||||
clearTimeout(mapCrosshairFallbackTimer);
|
||||
mapCrosshairFallbackTimer = null;
|
||||
}
|
||||
triggerMapCrosshairAnimation(targetLat, targetLon);
|
||||
};
|
||||
radarMap.once('moveend', runCrosshair);
|
||||
mapCrosshairFallbackTimer = setTimeout(runCrosshair, 450);
|
||||
}
|
||||
|
||||
radarMap.setView([targetLat, targetLon], 10);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3081,7 +3138,7 @@ sudo make install</code>
|
||||
|
||||
function initAirband() {
|
||||
// Check if audio tools are available
|
||||
fetch('/listening/tools')
|
||||
fetch('/receiver/tools')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const missingTools = [];
|
||||
@@ -3231,7 +3288,7 @@ sudo make install</code>
|
||||
|
||||
try {
|
||||
// Start audio on backend
|
||||
const response = await fetch('/listening/audio/start', {
|
||||
const response = await fetch('/receiver/audio/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -3268,7 +3325,7 @@ sudo make install</code>
|
||||
audioPlayer.load();
|
||||
|
||||
// Connect to stream
|
||||
const streamUrl = `/listening/audio/stream?t=${Date.now()}`;
|
||||
const streamUrl = `/receiver/audio/stream?t=${Date.now()}`;
|
||||
console.log('[AIRBAND] Connecting to stream:', streamUrl);
|
||||
audioPlayer.src = streamUrl;
|
||||
|
||||
@@ -3312,7 +3369,7 @@ sudo make install</code>
|
||||
audioPlayer.pause();
|
||||
audioPlayer.src = '';
|
||||
|
||||
fetch('/listening/audio/stop', { method: 'POST' })
|
||||
fetch('/receiver/audio/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
isAirbandPlaying = false;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
document.write('<style id="disclaimer-gate">.welcome-overlay{display:none !important}</style>');
|
||||
window._showDisclaimerOnLoad = true;
|
||||
}
|
||||
// If navigating with a mode param (e.g. /?mode=listening), hide welcome immediately
|
||||
// If navigating with a mode param (e.g. /?mode=waterfall), hide welcome immediately
|
||||
// to prevent flash of welcome screen before JS applies the mode
|
||||
else if (new URLSearchParams(window.location.search).get('mode')) {
|
||||
document.write('<style id="mode-gate">.welcome-overlay{display:none !important}</style>');
|
||||
@@ -79,20 +79,34 @@
|
||||
subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9",
|
||||
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
|
||||
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
|
||||
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') }}"
|
||||
waterfall: "{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck19"
|
||||
};
|
||||
window.INTERCEPT_MODE_STYLE_LOADED = {};
|
||||
window.ensureModeStyles = function(mode) {
|
||||
const href = window.INTERCEPT_MODE_STYLE_MAP ? window.INTERCEPT_MODE_STYLE_MAP[mode] : null;
|
||||
if (!href) return;
|
||||
if (window.INTERCEPT_MODE_STYLE_LOADED[href]) return;
|
||||
window.INTERCEPT_MODE_STYLE_LOADED[href] = true;
|
||||
const absHref = new URL(href, window.location.href).href;
|
||||
const existing = Array.from(document.querySelectorAll('link[data-mode-style]'))
|
||||
.find((link) => link.href === absHref);
|
||||
if (existing) {
|
||||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
|
||||
return;
|
||||
}
|
||||
if (window.INTERCEPT_MODE_STYLE_LOADED[href] === 'loading') return;
|
||||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loading';
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
link.dataset.modeStyle = mode;
|
||||
link.onload = () => {
|
||||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
|
||||
};
|
||||
link.onerror = () => {
|
||||
delete window.INTERCEPT_MODE_STYLE_LOADED[href];
|
||||
try {
|
||||
link.remove();
|
||||
} catch (_) {}
|
||||
};
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
</script>
|
||||
@@ -196,10 +210,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"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span>
|
||||
<span class="mode-name">Meters</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('listening')">
|
||||
<span class="mode-icon icon"><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></span>
|
||||
<span class="mode-name">Listening Post</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('subghz')">
|
||||
<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 12h6l3-9 3 18 3-9h5"/></svg></span>
|
||||
<span class="mode-name">SubGHz</span>
|
||||
@@ -296,14 +306,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="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>
|
||||
<span class="mode-name">WebSDR</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('rfheatmap')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></span>
|
||||
<span class="mode-name">RF Heatmap</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('fingerprint')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12C2 6.5 6.5 2 12 2a10 10 0 0 1 8 4"/><path d="M5 19.5C5.5 18 6 15 6 12c0-.7.12-1.37.34-2"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/></svg></span>
|
||||
<span class="mode-name">RF Fingerprint</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -622,8 +624,6 @@
|
||||
|
||||
{% include 'partials/modes/space-weather.html' %}
|
||||
|
||||
{% include 'partials/modes/listening-post.html' %}
|
||||
|
||||
{% include 'partials/modes/tscm.html' %}
|
||||
|
||||
{% include 'partials/modes/ais.html' %}
|
||||
@@ -638,8 +638,6 @@
|
||||
|
||||
{% include 'partials/modes/bt_locate.html' %}
|
||||
{% include 'partials/modes/waterfall.html' %}
|
||||
{% include 'partials/modes/rfheatmap.html' %}
|
||||
{% include 'partials/modes/fingerprint.html' %}
|
||||
|
||||
|
||||
|
||||
@@ -1193,9 +1191,11 @@
|
||||
<!-- Sky View Polar Plot -->
|
||||
<div class="gps-skyview-panel">
|
||||
<h4>Satellite Sky View</h4>
|
||||
<div class="gps-skyview-canvas-wrap">
|
||||
<canvas id="gpsSkyCanvas" width="400" height="400"></canvas>
|
||||
<div class="gps-skyview-canvas-wrap" id="gpsSkyViewWrap">
|
||||
<canvas id="gpsSkyCanvas" width="400" height="400" aria-label="GPS satellite sky globe"></canvas>
|
||||
<div class="gps-sky-overlay" id="gpsSkyOverlay" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="gps-sky-hint">Drag to orbit | Scroll to zoom</div>
|
||||
<div class="gps-legend">
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> GPS</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00ff88;"></span> GLONASS</div>
|
||||
@@ -1248,442 +1248,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listening Post Visualizations - Professional Ham Radio Scanner -->
|
||||
<div class="wifi-visuals" id="listeningPostVisuals" style="display: none;">
|
||||
|
||||
<!-- WATERFALL FUNCTION BAR -->
|
||||
<div class="function-strip listening-strip" style="grid-column: span 4;">
|
||||
<div class="function-strip-inner">
|
||||
<span class="strip-title">WATERFALL</span>
|
||||
<div class="strip-divider"></div>
|
||||
<!-- Span display -->
|
||||
<div class="strip-stat">
|
||||
<span class="strip-value" id="waterfallZoomSpan">20.0 MHz</span>
|
||||
<span class="strip-label">SPAN</span>
|
||||
</div>
|
||||
<div class="strip-divider"></div>
|
||||
<!-- Frequency inputs -->
|
||||
<div class="strip-control">
|
||||
<span class="strip-input-label">START</span>
|
||||
<input type="number" id="waterfallStartFreq" class="strip-input wide" value="88" step="0.1">
|
||||
</div>
|
||||
<div class="strip-control">
|
||||
<span class="strip-input-label">END</span>
|
||||
<input type="number" id="waterfallEndFreq" class="strip-input wide" value="108" step="0.1">
|
||||
</div>
|
||||
<div class="strip-divider"></div>
|
||||
<!-- Zoom buttons -->
|
||||
<button type="button" class="strip-btn" onclick="zoomWaterfall('out')">−</button>
|
||||
<button type="button" class="strip-btn" onclick="zoomWaterfall('in')">+</button>
|
||||
<div class="strip-divider"></div>
|
||||
<!-- FFT Size -->
|
||||
<div class="strip-control">
|
||||
<span class="strip-input-label">FFT</span>
|
||||
<select id="waterfallFftSize" class="strip-select">
|
||||
<option value="512">512</option>
|
||||
<option value="1024" selected>1024</option>
|
||||
<option value="2048">2048</option>
|
||||
<option value="4096">4096</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Gain -->
|
||||
<div class="strip-control">
|
||||
<span class="strip-input-label">GAIN</span>
|
||||
<input type="number" id="waterfallGain" class="strip-input" value="40" min="0" max="50">
|
||||
</div>
|
||||
<div class="strip-divider"></div>
|
||||
<!-- Start / Stop -->
|
||||
<button type="button" class="strip-btn primary" id="startWaterfallBtn" onclick="startWaterfall()">▶ START</button>
|
||||
<button type="button" class="strip-btn stop" id="stopWaterfallBtn" onclick="stopWaterfall()" style="display: none;">◼ STOP</button>
|
||||
<!-- Status -->
|
||||
<div class="strip-status">
|
||||
<div class="status-dot inactive" id="waterfallStripDot"></div>
|
||||
<span id="waterfallFreqRange">STANDBY</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WATERFALL / SPECTROGRAM PANEL -->
|
||||
<div id="waterfallPanel" class="radio-module-box" style="grid-column: span 4; padding: 10px; display: none;">
|
||||
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
|
||||
<span>WATERFALL / SPECTROGRAM</span>
|
||||
<span id="waterfallFreqRangeHeader" style="font-size: 9px; color: var(--accent-cyan);"></span>
|
||||
</div>
|
||||
<canvas id="spectrumCanvas" width="800" height="120" style="width: 100%; height: 120px; border-radius: 4px; background: rgba(0,0,0,0.8);"></canvas>
|
||||
<canvas id="waterfallCanvas" width="800" height="400" style="width: 100%; height: 400px; border-radius: 4px; margin-top: 4px; background: rgba(0,0,0,0.9);"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- TOP: FREQUENCY DISPLAY PANEL -->
|
||||
<div class="radio-module-box scanner-main" style="grid-column: span 4; padding: 12px;">
|
||||
<div style="display: flex; gap: 15px; align-items: stretch;">
|
||||
<!-- Main Frequency Display -->
|
||||
<div
|
||||
style="flex: 1; text-align: center; padding: 15px 20px; background: rgba(0,0,0,0.6); border-radius: 6px; border: 1px solid var(--border-color);">
|
||||
<div class="freq-status" id="mainScannerModeLabel"
|
||||
style="font-size: 10px; color: var(--text-muted); margin-bottom: 4px; letter-spacing: 3px;">
|
||||
STOPPED</div>
|
||||
<div style="display: flex; justify-content: center; align-items: baseline; gap: 8px;">
|
||||
<div class="freq-digits" id="mainScannerFreq"
|
||||
style="font-size: 52px; font-weight: bold; color: var(--accent-cyan); text-shadow: 0 0 30px var(--accent-cyan); font-family: var(--font-mono); letter-spacing: 3px;">
|
||||
118.000</div>
|
||||
<span class="freq-unit"
|
||||
style="font-size: 20px; color: var(--text-secondary); font-weight: 500;">MHz</span>
|
||||
</div>
|
||||
<div
|
||||
style="display: flex; justify-content: center; align-items: center; margin-top: 6px;">
|
||||
<span class="freq-mode-badge" id="mainScannerMod"
|
||||
style="background: var(--accent-cyan); color: #000; padding: 3px 12px; border-radius: 4px; font-size: 12px; font-weight: bold;">AM</span>
|
||||
</div>
|
||||
<!-- Progress bar -->
|
||||
<div id="mainScannerProgress" style="display: none; margin-top: 12px;">
|
||||
<div
|
||||
style="display: flex; justify-content: space-between; font-size: 9px; color: var(--text-muted); margin-bottom: 3px;">
|
||||
<span id="mainRangeStart">--</span>
|
||||
<span id="mainRangeEnd">--</span>
|
||||
</div>
|
||||
<div
|
||||
style="height: 6px; background: rgba(0,0,0,0.5); border-radius: 3px; overflow: hidden;">
|
||||
<div class="scan-bar" id="mainProgressBar"
|
||||
style="height: 100%; background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); width: 0%; transition: width 0.1s;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Synthesizer + Audio Output Panel -->
|
||||
<div style="width: 320px; display: flex; flex-direction: column; gap: 8px;">
|
||||
<!-- Synthesizer Display -->
|
||||
<div
|
||||
style="flex: 1; padding: 10px; background: rgba(0,0,0,0.6); border-radius: 6px; border: 1px solid var(--border-color);">
|
||||
<div
|
||||
style="font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px;">
|
||||
Synthesizer</div>
|
||||
<canvas id="synthesizerCanvas"
|
||||
style="width: 100%; height: 60px; background: rgba(0,0,0,0.4); border-radius: 4px;"></canvas>
|
||||
</div>
|
||||
<!-- Audio Output -->
|
||||
<div
|
||||
style="padding: 10px; background: rgba(0,0,0,0.4); border-radius: 6px; border: 1px solid var(--border-color);">
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
|
||||
<div
|
||||
style="font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px;">
|
||||
Audio Output</div>
|
||||
</div>
|
||||
<audio id="scannerAudioPlayer" style="width: 100%; height: 28px;" controls></audio>
|
||||
<button id="audioUnlockBtn" type="button"
|
||||
style="display: none; margin-top: 8px; width: 100%; padding: 6px 10px; font-size: 10px; letter-spacing: 1px; background: var(--accent-cyan); color: #000; border: 1px solid var(--accent-cyan); border-radius: 4px; cursor: pointer;">
|
||||
CLICK TO ENABLE AUDIO
|
||||
</button>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
|
||||
<span style="font-size: 7px; color: var(--text-muted);">LEVEL</span>
|
||||
<div
|
||||
style="flex: 1; height: 8px; background: rgba(0,0,0,0.5); border-radius: 4px; overflow: hidden;">
|
||||
<div id="audioSignalBar"
|
||||
style="height: 100%; background: linear-gradient(90deg, var(--accent-green), var(--accent-cyan), var(--accent-orange), var(--accent-red)); width: 0%; transition: width 0.1s;">
|
||||
</div>
|
||||
</div>
|
||||
<span id="audioSignalDb"
|
||||
style="font-size: 8px; color: var(--text-muted); min-width: 40px; text-align: right;">--
|
||||
dB</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
|
||||
<span style="font-size: 7px; color: var(--text-muted); letter-spacing: 1px;">SNR THRESH</span>
|
||||
<input type="range" id="snrThresholdSlider" min="6" max="20" step="1" value="8"
|
||||
style="flex: 1;" />
|
||||
<span id="snrThresholdValue"
|
||||
style="font-size: 8px; color: var(--text-muted); min-width: 26px; text-align: right;">8</span>
|
||||
</div>
|
||||
<!-- Signal Alert inline -->
|
||||
<div id="mainSignalAlert"
|
||||
style="display: none; background: rgba(0, 255, 100, 0.2); border: 1px solid var(--accent-green); border-radius: 4px; padding: 5px; text-align: center; margin-top: 8px;">
|
||||
<span style="font-size: 9px; color: var(--accent-green); font-weight: bold;">●
|
||||
SIGNAL</span>
|
||||
<button class="tune-btn" onclick="skipSignal()"
|
||||
style="margin-left: 8px; padding: 2px 8px; font-size: 8px;">Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTROL PANEL: Tuning Section | Mode/Band | Action Buttons -->
|
||||
<div class="radio-module-box" style="grid-column: span 4; padding: 12px;">
|
||||
<div style="display: flex; gap: 15px; align-items: stretch;">
|
||||
|
||||
<!-- LEFT: Tuning Section -->
|
||||
<div
|
||||
style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 20px; padding: 10px 15px; background: rgba(0,0,0,0.3); border-radius: 8px; border: 1px solid var(--border-color);">
|
||||
|
||||
<!-- Fine Tune Buttons (Left of dial) -->
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<button class="tune-btn" onclick="tuneFreq(-1)"
|
||||
style="padding: 8px 12px; font-size: 11px;">-1</button>
|
||||
<button class="tune-btn" onclick="tuneFreq(-0.1)"
|
||||
style="padding: 8px 12px; font-size: 11px;">-.1</button>
|
||||
<button class="tune-btn" onclick="tuneFreq(-0.05)"
|
||||
style="padding: 8px 12px; font-size: 11px;">-.05</button>
|
||||
<button class="tune-btn" onclick="tuneFreq(-0.005)"
|
||||
style="padding: 8px 12px; font-size: 10px;">-.005</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Tuning Dial -->
|
||||
<div style="display: flex; flex-direction: column; align-items: center;">
|
||||
<div class="tuning-dial" id="mainTuningDial" data-value="118" data-min="24"
|
||||
data-max="1800" data-step="0.1" style="width: 100px; height: 100px;"></div>
|
||||
<div
|
||||
style="font-size: 9px; color: var(--text-muted); margin-top: 6px; text-transform: uppercase; letter-spacing: 1px;">
|
||||
Tune</div>
|
||||
<div
|
||||
style="font-size: 8px; color: var(--text-muted); margin-top: 3px; opacity: 0.6;"
|
||||
title="Arrow keys: ±0.05/0.1 MHz Shift+Arrow: ±0.005/1 MHz">
|
||||
⌨ arrow keys</div>
|
||||
</div>
|
||||
|
||||
<!-- Fine Tune Buttons (Right of dial) -->
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<button class="tune-btn" onclick="tuneFreq(1)"
|
||||
style="padding: 8px 12px; font-size: 11px;">+1</button>
|
||||
<button class="tune-btn" onclick="tuneFreq(0.1)"
|
||||
style="padding: 8px 12px; font-size: 11px;">+.1</button>
|
||||
<button class="tune-btn" onclick="tuneFreq(0.05)"
|
||||
style="padding: 8px 12px; font-size: 11px;">+.05</button>
|
||||
<button class="tune-btn" onclick="tuneFreq(0.005)"
|
||||
style="padding: 8px 12px; font-size: 10px;">+.005</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div style="width: 1px; height: 80px; background: var(--border-color);"></div>
|
||||
|
||||
<!-- Settings: Step & Dwell -->
|
||||
<div style="display: flex; flex-direction: column; gap: 8px; min-width: 90px;">
|
||||
<div
|
||||
style="display: flex; justify-content: space-between; align-items: center; font-size: 10px;">
|
||||
<span style="color: var(--text-muted);">Step</span>
|
||||
<select id="radioScanStep"
|
||||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 3px 5px; border-radius: 3px; font-size: 9px;">
|
||||
<option value="8.33">8.33k</option>
|
||||
<option value="12.5">12.5k</option>
|
||||
<option value="25" selected>25k</option>
|
||||
<option value="50">50k</option>
|
||||
<option value="100">100k</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
style="display: flex; justify-content: space-between; align-items: center; font-size: 10px;">
|
||||
<span style="color: var(--text-muted);">Dwell</span>
|
||||
<select id="radioScanDwell"
|
||||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 3px 5px; border-radius: 3px; font-size: 9px;">
|
||||
<option value="2">2s</option>
|
||||
<option value="5">5s</option>
|
||||
<option value="10" selected>10s</option>
|
||||
<option value="30">30s</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div style="width: 1px; height: 80px; background: var(--border-color);"></div>
|
||||
|
||||
<!-- SQL, Gain, Vol Knobs -->
|
||||
<div style="display: flex; gap: 15px;">
|
||||
<div class="knob-container">
|
||||
<div class="radio-knob" id="radioSquelchKnob" data-value="0" data-min="0"
|
||||
data-max="100" data-step="1"></div>
|
||||
<div class="knob-label">SQL</div>
|
||||
<div class="knob-value" id="radioSquelchValue">0</div>
|
||||
</div>
|
||||
<div class="knob-container">
|
||||
<div class="radio-knob" id="radioGainKnob" data-value="40" data-min="0"
|
||||
data-max="50" data-step="1"></div>
|
||||
<div class="knob-label">GAIN</div>
|
||||
<div class="knob-value" id="radioGainValue">40</div>
|
||||
</div>
|
||||
<div class="knob-container">
|
||||
<div class="radio-knob" id="radioVolumeKnob" data-value="80" data-min="0"
|
||||
data-max="100" data-step="1"></div>
|
||||
<div class="knob-label">VOL</div>
|
||||
<div class="knob-value" id="radioVolumeValue">80</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER: Mode & Band (Stacked) -->
|
||||
<div
|
||||
style="width: 130px; display: flex; flex-direction: column; gap: 10px; justify-content: center;">
|
||||
<div>
|
||||
<div
|
||||
style="font-size: 8px; color: var(--text-muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 1px;">
|
||||
Modulation</div>
|
||||
<div class="radio-button-bank compact" id="modBtnBank"
|
||||
style="flex-wrap: wrap; gap: 2px;">
|
||||
<button class="radio-btn active" data-mod="am" onclick="setModulation('am')"
|
||||
style="padding: 6px 10px; font-size: 10px;">AM</button>
|
||||
<button class="radio-btn" data-mod="fm" onclick="setModulation('fm')"
|
||||
style="padding: 6px 10px; font-size: 10px;">NFM</button>
|
||||
<button class="radio-btn" data-mod="wfm" onclick="setModulation('wfm')"
|
||||
style="padding: 6px 10px; font-size: 10px;">WFM</button>
|
||||
<button class="radio-btn" data-mod="usb" onclick="setModulation('usb')"
|
||||
style="padding: 6px 10px; font-size: 10px;">USB</button>
|
||||
<button class="radio-btn" data-mod="lsb" onclick="setModulation('lsb')"
|
||||
style="padding: 6px 10px; font-size: 10px;">LSB</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style="font-size: 8px; color: var(--text-muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 1px;">
|
||||
Band</div>
|
||||
<div class="radio-button-bank compact" id="bandBtnBank"
|
||||
style="flex-wrap: wrap; gap: 2px;">
|
||||
<button class="radio-btn" data-band="fm" onclick="setBand('fm')"
|
||||
style="padding: 6px 10px; font-size: 10px;">FM</button>
|
||||
<button class="radio-btn active" data-band="air" onclick="setBand('air')"
|
||||
style="padding: 6px 10px; font-size: 10px;">AIR</button>
|
||||
<button class="radio-btn" data-band="marine" onclick="setBand('marine')"
|
||||
style="padding: 6px 10px; font-size: 10px;">MAR</button>
|
||||
<button class="radio-btn" data-band="amateur2m" onclick="setBand('amateur2m')"
|
||||
style="padding: 6px 10px; font-size: 10px;">2M</button>
|
||||
<button class="radio-btn" data-band="amateur70cm"
|
||||
onclick="setBand('amateur70cm')"
|
||||
style="padding: 6px 10px; font-size: 10px;">70CM</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Scan Range + Action Buttons -->
|
||||
<div style="width: 175px; display: flex; flex-direction: column; gap: 8px;">
|
||||
<!-- Frequency Range - Prominent -->
|
||||
<div
|
||||
style="background: rgba(0,0,0,0.4); border: 1px solid var(--border-color); border-radius: 6px; padding: 10px;">
|
||||
<div
|
||||
style="font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; text-align: center;">
|
||||
Scan Range (MHz)</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
<div style="flex: 1;">
|
||||
<div style="font-size: 7px; color: var(--text-muted); margin-bottom: 2px;">
|
||||
START</div>
|
||||
<input type="number" id="radioScanStart" value="118" step="0.1"
|
||||
class="radio-input"
|
||||
style="width: 100%; font-size: 16px; padding: 8px 6px; text-align: center; font-family: var(--font-mono); font-weight: bold; color: var(--accent-cyan);">
|
||||
</div>
|
||||
<span
|
||||
style="color: var(--text-muted); font-size: 16px; padding-top: 12px;">→</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-size: 7px; color: var(--text-muted); margin-bottom: 2px;">
|
||||
END</div>
|
||||
<input type="number" id="radioScanEnd" value="137" step="0.1"
|
||||
class="radio-input"
|
||||
style="width: 100%; font-size: 16px; padding: 8px 6px; text-align: center; font-family: var(--font-mono); font-weight: bold; color: var(--accent-cyan);">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Action Buttons -->
|
||||
<button class="radio-action-btn scan" id="radioScanBtn" onclick="toggleScanner()">
|
||||
<span class="icon icon--sm" style="margin-right: 4px;"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span>SCAN
|
||||
</button>
|
||||
<button class="radio-action-btn listen" id="radioListenBtn" onclick="toggleDirectListen()">
|
||||
<span class="icon icon--sm" style="margin-right: 4px;"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg></span>LISTEN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QUICK TUNE BAR -->
|
||||
<div class="radio-module-box" style="grid-column: span 4; padding: 8px 12px;">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<span
|
||||
style="font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px;">Quick
|
||||
Tune:</span>
|
||||
<button class="preset-freq-btn" onclick="quickTune(121.5, 'am')">121.5 GUARD</button>
|
||||
<button class="preset-freq-btn" onclick="quickTune(156.8, 'fm')">156.8 CH16</button>
|
||||
<button class="preset-freq-btn" onclick="quickTune(145.5, 'fm')">145.5 2M</button>
|
||||
<button class="preset-freq-btn" onclick="quickTune(98.1, 'wfm')">98.1 FM</button>
|
||||
<button class="preset-freq-btn" onclick="quickTune(462.5625, 'fm')">462.56 FRS</button>
|
||||
<button class="preset-freq-btn" onclick="quickTune(446.0, 'fm')">446.0 PMR</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SIGNAL HITS -->
|
||||
<div class="radio-module-box" style="grid-column: span 2; padding: 10px;">
|
||||
<div class="module-header"
|
||||
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px;">
|
||||
<span>SIGNAL HITS</span>
|
||||
<span
|
||||
style="font-size: 10px; color: var(--accent-cyan); background: rgba(0,212,255,0.1); padding: 2px 8px; border-radius: 3px;"
|
||||
id="scannerHitCount">0 signals</span>
|
||||
</div>
|
||||
<div id="scannerHitsList" style="overflow-y: auto; max-height: 100px;">
|
||||
<table style="width: 100%; font-size: 10px; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="color: var(--text-muted); border-bottom: 1px solid var(--border-color);">
|
||||
<th style="text-align: left; padding: 4px;">Time</th>
|
||||
<th style="text-align: left; padding: 4px;">Frequency</th>
|
||||
<th style="text-align: left; padding: 4px;">SNR</th>
|
||||
<th style="text-align: left; padding: 4px;">Mod</th>
|
||||
<th style="text-align: center; padding: 4px; width: 60px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="scannerHitsBody">
|
||||
<tr style="color: var(--text-muted);">
|
||||
<td colspan="5" style="padding: 15px; text-align: center; font-size: 10px;">No
|
||||
signals detected</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STATS PANEL -->
|
||||
<div class="radio-module-box" style="grid-column: span 1; padding: 10px;">
|
||||
<div class="module-header" style="margin-bottom: 8px; font-size: 10px;">
|
||||
<span>STATS</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<span style="font-size: 9px; color: var(--text-muted);">SIGNALS</span>
|
||||
<span
|
||||
style="color: var(--accent-green); font-size: 18px; font-weight: bold; font-family: var(--font-mono);"
|
||||
id="mainSignalCount">0</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<span style="font-size: 9px; color: var(--text-muted);">SCANNED</span>
|
||||
<span
|
||||
style="color: var(--accent-cyan); font-size: 18px; font-weight: bold; font-family: var(--font-mono);"
|
||||
id="mainFreqsScanned">0</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<span style="font-size: 9px; color: var(--text-muted);">CYCLES</span>
|
||||
<span
|
||||
style="color: var(--accent-orange); font-size: 18px; font-weight: bold; font-family: var(--font-mono);"
|
||||
id="mainScanCycles">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ACTIVITY LOG -->
|
||||
<div class="radio-module-box" style="grid-column: span 1; padding: 10px;">
|
||||
<div class="module-header"
|
||||
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px;">
|
||||
<span>LOG</span>
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<button class="tune-btn" onclick="exportScannerLog()"
|
||||
style="padding: 2px 6px; font-size: 8px;">Export</button>
|
||||
<button class="tune-btn" onclick="clearScannerLog()"
|
||||
style="padding: 2px 6px; font-size: 8px;">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-content" id="scannerActivityLog"
|
||||
style="max-height: 100px; overflow-y: auto; font-size: 9px; background: rgba(0,0,0,0.2); border-radius: 3px; padding: 6px;">
|
||||
<div class="scanner-log-entry" style="color: var(--text-muted);">Ready</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SIGNAL ACTIVITY TIMELINE -->
|
||||
<div class="radio-module-box" style="grid-column: span 4; padding: 10px;">
|
||||
<div id="listeningPostTimelineContainer"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Satellite Dashboard (Embedded) -->
|
||||
<div id="satelliteVisuals" class="satellite-dashboard-embed" style="display: none;">
|
||||
<iframe id="satelliteDashboardFrame" src="/satellite/dashboard?embedded=true" frameborder="0"
|
||||
@@ -3200,6 +2764,10 @@
|
||||
<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">ZOOM</span>
|
||||
<button class="wf-step-btn wf-zoom-btn" onclick="Waterfall.zoomOut && Waterfall.zoomOut()" title="Zoom out (wider span)">-</button>
|
||||
<button class="wf-step-btn wf-zoom-btn" onclick="Waterfall.zoomIn && Waterfall.zoomIn()" title="Zoom in (narrower span)">+</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>
|
||||
@@ -3224,6 +2792,8 @@
|
||||
<div class="wf-tune-line" id="wfTuneLineSpec"></div>
|
||||
</div>
|
||||
|
||||
<div class="wf-band-strip" id="wfBandStrip"></div>
|
||||
|
||||
<!-- Drag handle to resize spectrum vs waterfall -->
|
||||
<div class="wf-resize-handle" id="wfResizeHandle">
|
||||
<div class="wf-resize-grip"></div>
|
||||
@@ -3241,20 +2811,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RF Heatmap Visuals -->
|
||||
<div id="rfheatmapVisuals" style="display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden;">
|
||||
<div class="rfhm-map-container" style="flex: 1; min-height: 0; position: relative;">
|
||||
<div id="rfheatmapMapEl" style="width: 100%; height: 100%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fingerprint Visuals -->
|
||||
<div id="fingerprintVisuals" style="display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; padding: 10px; gap: 10px;">
|
||||
<div class="fp-chart-container" style="flex: 1; min-height: 200px;">
|
||||
<canvas id="fpChartCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||||
<div class="recon-panel collapsed" id="reconPanel">
|
||||
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
||||
@@ -3369,7 +2925,6 @@
|
||||
<!-- WiFi v2 components -->
|
||||
<script src="{{ url_for('static', filename='js/components/channel-chart.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/wifi.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/meshtastic.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/sstv.js') }}"></script>
|
||||
@@ -3380,12 +2935,10 @@
|
||||
<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/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/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></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 src="{{ url_for('static', filename='js/modes/waterfall.js') }}?v={{ version }}&r=wfdeck20"></script>
|
||||
|
||||
<script>
|
||||
// ============================================
|
||||
@@ -3432,15 +2985,6 @@
|
||||
collapsed: true
|
||||
}
|
||||
},
|
||||
'listening': {
|
||||
container: 'listeningPostTimelineContainer',
|
||||
config: typeof RFTimelineAdapter !== 'undefined' ? RFTimelineAdapter.getListeningPostConfig() : {
|
||||
title: 'Signal Activity',
|
||||
mode: 'listening-post',
|
||||
visualMode: 'enriched',
|
||||
collapsed: false
|
||||
}
|
||||
},
|
||||
'bluetooth': {
|
||||
container: 'bluetoothTimelineContainer',
|
||||
config: typeof BluetoothTimelineAdapter !== 'undefined' ? BluetoothTimelineAdapter.getBluetoothConfig() : {
|
||||
@@ -3496,7 +3040,11 @@
|
||||
}
|
||||
|
||||
// Selected mode from welcome screen
|
||||
let selectedStartMode = localStorage.getItem('intercept.default_mode') || 'pager';
|
||||
const savedDefaultMode = localStorage.getItem('intercept.default_mode');
|
||||
let selectedStartMode = savedDefaultMode === 'listening' ? 'waterfall' : (savedDefaultMode || 'pager');
|
||||
if (savedDefaultMode === 'listening') {
|
||||
localStorage.setItem('intercept.default_mode', 'waterfall');
|
||||
}
|
||||
|
||||
// Mode selection from welcome page
|
||||
function selectMode(mode) {
|
||||
@@ -3522,7 +3070,6 @@
|
||||
pager: { label: 'Pager', indicator: 'PAGER', outputTitle: 'Pager Decoder', group: 'signals' },
|
||||
sensor: { label: '433MHz', indicator: '433MHZ', outputTitle: '433MHz Sensor Monitor', group: 'signals' },
|
||||
rtlamr: { label: 'Meters', indicator: 'METERS', outputTitle: 'Utility Meter Monitor', group: 'signals' },
|
||||
listening: { label: 'Listening Post', indicator: 'LISTENING POST', outputTitle: 'Listening Post', group: 'signals' },
|
||||
subghz: { label: 'SubGHz', indicator: 'SUBGHZ', outputTitle: 'SubGHz Transceiver', group: 'signals' },
|
||||
aprs: { label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking' },
|
||||
gps: { label: 'GPS', indicator: 'GPS', outputTitle: 'GPS Receiver', group: 'tracking' },
|
||||
@@ -3539,15 +3086,14 @@
|
||||
spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
|
||||
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
|
||||
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
|
||||
rfheatmap: { label: 'RF Heatmap', indicator: 'RF HEATMAP', outputTitle: 'RF Signal Heatmap', group: 'intel' },
|
||||
fingerprint: { label: 'Fingerprint', indicator: 'RF FINGERPRINT', outputTitle: 'Signal Fingerprinting', group: 'intel' },
|
||||
};
|
||||
const validModes = new Set(Object.keys(modeCatalog));
|
||||
window.interceptModeCatalog = Object.assign({}, modeCatalog);
|
||||
|
||||
function getModeFromQuery() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const mode = params.get('mode');
|
||||
const requestedMode = params.get('mode');
|
||||
const mode = requestedMode === 'listening' ? 'waterfall' : requestedMode;
|
||||
if (!mode || !validModes.has(mode)) return null;
|
||||
return mode;
|
||||
}
|
||||
@@ -4059,6 +3605,8 @@
|
||||
// Mode switching
|
||||
function switchMode(mode, options = {}) {
|
||||
const { updateUrl = true } = options;
|
||||
if (mode === 'listening') mode = 'waterfall';
|
||||
if (!validModes.has(mode)) mode = 'pager';
|
||||
// Only stop local scans if in local mode (not agent mode)
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
if (!isAgentMode) {
|
||||
@@ -4122,7 +3670,6 @@
|
||||
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
|
||||
document.getElementById('btLocateMode')?.classList.toggle('active', mode === 'bt_locate');
|
||||
document.getElementById('listeningPostMode')?.classList.toggle('active', mode === 'listening');
|
||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
|
||||
@@ -4132,8 +3679,6 @@
|
||||
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
|
||||
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');
|
||||
@@ -4161,7 +3706,6 @@
|
||||
const wifiLayoutContainer = document.getElementById('wifiLayoutContainer');
|
||||
const btLayoutContainer = document.getElementById('btLayoutContainer');
|
||||
const satelliteVisuals = document.getElementById('satelliteVisuals');
|
||||
const listeningPostVisuals = document.getElementById('listeningPostVisuals');
|
||||
const aprsVisuals = document.getElementById('aprsVisuals');
|
||||
const tscmVisuals = document.getElementById('tscmVisuals');
|
||||
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
|
||||
@@ -4175,12 +3719,9 @@
|
||||
const btLocateVisuals = document.getElementById('btLocateVisuals');
|
||||
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
||||
const waterfallVisuals = document.getElementById('waterfallVisuals');
|
||||
const rfheatmapVisuals = document.getElementById('rfheatmapVisuals');
|
||||
const fingerprintVisuals = document.getElementById('fingerprintVisuals');
|
||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
if (listeningPostVisuals) listeningPostVisuals.style.display = mode === 'listening' ? 'grid' : 'none';
|
||||
if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none';
|
||||
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
|
||||
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
|
||||
@@ -4193,9 +3734,7 @@
|
||||
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
|
||||
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
|
||||
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
||||
if (waterfallVisuals) waterfallVisuals.style.display = (mode === 'waterfall' || mode === 'listening') ? 'flex' : 'none';
|
||||
if (rfheatmapVisuals) rfheatmapVisuals.style.display = mode === 'rfheatmap' ? 'flex' : 'none';
|
||||
if (fingerprintVisuals) fingerprintVisuals.style.display = mode === 'fingerprint' ? 'flex' : 'none';
|
||||
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
|
||||
|
||||
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
|
||||
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
|
||||
@@ -4245,7 +3784,7 @@
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
const reconPanel = document.getElementById('reconPanel');
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'rfheatmap' || mode === 'fingerprint') {
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') {
|
||||
if (reconPanel) reconPanel.style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -4260,19 +3799,12 @@
|
||||
|
||||
// Show agent selector for modes that support remote agents
|
||||
const agentSection = document.getElementById('agentSection');
|
||||
const agentModes = ['pager', 'sensor', 'rtlamr', 'listening', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais'];
|
||||
const agentModes = ['pager', 'sensor', 'rtlamr', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais'];
|
||||
if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none';
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
||||
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general') ? 'block' : 'none';
|
||||
|
||||
// Show waterfall panel if running in listening mode
|
||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||
if (waterfallPanel) {
|
||||
const running = (typeof isWaterfallRunning !== 'undefined' && isWaterfallRunning);
|
||||
waterfallPanel.style.display = (mode === 'listening' && running) ? 'block' : 'none';
|
||||
}
|
||||
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general') ? 'block' : 'none';
|
||||
|
||||
// Toggle mode-specific tool status displays
|
||||
const toolStatusPager = document.getElementById('toolStatusPager');
|
||||
@@ -4283,7 +3815,7 @@
|
||||
// Hide output console for modes with their own visualizations
|
||||
const outputEl = document.getElementById('output');
|
||||
const statusBar = document.querySelector('.status-bar');
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'rfheatmap' || mode === 'fingerprint') ? 'none' : 'block';
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall') ? 'none' : 'block';
|
||||
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') ? 'none' : 'flex';
|
||||
|
||||
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
||||
@@ -4316,12 +3848,6 @@
|
||||
} else if (mode === 'satellite') {
|
||||
initPolarPlot();
|
||||
initSatelliteList();
|
||||
} else if (mode === 'listening') {
|
||||
// Check for incoming tune requests from Spy Stations
|
||||
if (typeof checkIncomingTuneRequest === 'function') {
|
||||
checkIncomingTuneRequest();
|
||||
}
|
||||
if (typeof Waterfall !== 'undefined') Waterfall.init();
|
||||
} else if (mode === 'spystations') {
|
||||
SpyStations.init();
|
||||
} else if (mode === 'meshtastic') {
|
||||
@@ -4360,17 +3886,10 @@
|
||||
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) {
|
||||
if (mode !== 'waterfall' && typeof Waterfall !== 'undefined' && Waterfall.destroy) {
|
||||
Promise.resolve(Waterfall.destroy()).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -10793,7 +10312,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE: Scanner and Audio Receiver code moved to static/js/modes/listening-post.js
|
||||
// Scanner and receiver logic are handled by Waterfall mode.
|
||||
|
||||
// ============================================
|
||||
// TSCM (Counter-Surveillance) Functions
|
||||
@@ -12376,7 +11895,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div style="font-size: 10px; color: var(--text-secondary); margin-top: 8px;">
|
||||
${protocol === 'rf' ? 'Listen buttons open Listening Post. ' : ''}Known devices are excluded from threat scoring in future sweeps.
|
||||
${protocol === 'rf' ? 'Listen buttons open Spectrum Waterfall. ' : ''}Known devices are excluded from threat scoring in future sweeps.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -13154,18 +12673,18 @@
|
||||
// Close the modal
|
||||
closeTscmDeviceModal();
|
||||
|
||||
// Switch to listening post mode
|
||||
switchMode('listening');
|
||||
// Switch to spectrum waterfall mode
|
||||
switchMode('waterfall');
|
||||
|
||||
// Wait a moment for the mode to switch, then tune to the frequency
|
||||
setTimeout(() => {
|
||||
if (typeof tuneToFrequency === 'function') {
|
||||
tuneToFrequency(frequency, modulation);
|
||||
if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') {
|
||||
Waterfall.quickTune(frequency, modulation);
|
||||
} else {
|
||||
// Fallback: manually update the frequency input
|
||||
const freqInput = document.getElementById('radioScanStart');
|
||||
// Fallback: update Waterfall center control directly
|
||||
const freqInput = document.getElementById('wfCenterFreq');
|
||||
if (freqInput) {
|
||||
freqInput.value = frequency.toFixed(1);
|
||||
freqInput.value = frequency.toFixed(4);
|
||||
}
|
||||
alert(`Tune to ${frequency.toFixed(3)} MHz (${modulation.toUpperCase()}) to listen`);
|
||||
}
|
||||
@@ -15107,8 +14626,6 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Scanner/Audio code moved to static/js/modes/listening-post.js -->
|
||||
|
||||
{% include 'partials/help-modal.html' %}
|
||||
|
||||
|
||||
@@ -15317,8 +14834,6 @@
|
||||
<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>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="desc">Aircraft - ADS-B tracking & history</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="desc">Vessels - AIS & VHF DSC distress</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="desc">APRS - Amateur radio tracking</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><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></span><span class="desc">Listening Post - SDR scanner</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><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></span><span class="desc">Waterfall - SDR receiver + signal ID</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><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="desc">Spy Stations - Number stations database</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><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"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="desc">Meshtastic - LoRa mesh networking</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><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="desc">WebSDR - Remote SDR receivers</span></div>
|
||||
@@ -114,7 +114,7 @@
|
||||
<li>Interactive map shows station positions in real-time</li>
|
||||
</ul>
|
||||
|
||||
<h3>Listening Post Mode</h3>
|
||||
<h3>Spectrum Waterfall Mode</h3>
|
||||
<ul class="tip-list">
|
||||
<li>Wideband SDR scanner with spectrum visualization</li>
|
||||
<li>Tune to any frequency supported by your SDR hardware</li>
|
||||
@@ -129,7 +129,7 @@
|
||||
<li>Browse stations from priyom.org with frequencies and schedules</li>
|
||||
<li>Filter by type (number/diplomatic), country, and mode</li>
|
||||
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
|
||||
<li>Click "Tune" to listen via Listening Post mode</li>
|
||||
<li>Click "Tune" to listen via Spectrum Waterfall mode</li>
|
||||
</ul>
|
||||
|
||||
<h3>Meshtastic Mode</h3>
|
||||
@@ -330,7 +330,7 @@
|
||||
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
|
||||
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
|
||||
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
|
||||
<li><strong>Listening Post:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
|
||||
<li><strong>Spectrum Waterfall:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
|
||||
<li><strong>Spy Stations:</strong> Internet connection (database lookup)</li>
|
||||
<li><strong>Meshtastic:</strong> Meshtastic LoRa device, <code>pip install meshtastic</code></li>
|
||||
<li><strong>WebSDR:</strong> Internet connection (remote receivers)</li>
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<!-- 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>
|
||||
@@ -1,68 +0,0 @@
|
||||
<!-- LISTENING POST MODE -->
|
||||
<div id="listeningPostMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Status</h3>
|
||||
|
||||
<!-- Dependency Warning -->
|
||||
<div id="scannerToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<p style="color: var(--accent-red); margin: 0; font-size: 0.85em;">
|
||||
<strong>Missing:</strong><br>
|
||||
<span id="scannerToolsWarningText"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Status -->
|
||||
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 12px;">
|
||||
<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;">Status</span>
|
||||
<span id="lpQuickStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
|
||||
</div>
|
||||
<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;">Frequency</span>
|
||||
<span id="lpQuickFreq" style="font-size: 14px; font-family: var(--font-mono); color: var(--text-primary);">---.--- MHz</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Signals</span>
|
||||
<span id="lpQuickSignals" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Bookmarks</h3>
|
||||
<div style="display: flex; gap: 4px; margin-bottom: 8px;">
|
||||
<input type="text" id="bookmarkFreqInput" placeholder="Freq (MHz)" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
||||
<button class="preset-btn" onclick="addFrequencyBookmark()" style="background: var(--accent-green); color: #000; padding: 6px 10px;">+</button>
|
||||
</div>
|
||||
<div id="bookmarksList" style="max-height: 150px; overflow-y: auto; font-size: 11px;">
|
||||
<div style="color: var(--text-muted); text-align: center; padding: 10px;">No bookmarks saved</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Recent Signals</h3>
|
||||
<div id="sidebarRecentSignals" style="max-height: 150px; overflow-y: auto; font-size: 11px;">
|
||||
<div style="color: var(--text-muted); text-align: center; padding: 10px;">No signals yet</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signal Identification -->
|
||||
<div class="section">
|
||||
<h3>Signal Identification</h3>
|
||||
<div style="display: flex; gap: 4px; margin-bottom: 8px;">
|
||||
<input type="text" id="signalGuessFreqInput" placeholder="Freq (MHz)" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
||||
<button class="preset-btn" onclick="manualSignalGuess()" style="background: var(--accent-cyan); color: #000; padding: 6px 10px; font-weight: 600;">ID</button>
|
||||
</div>
|
||||
<div id="signalGuessPanel" style="display: none; background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px; font-size: 11px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span id="signalGuessLabel" style="font-weight: bold; color: var(--text-primary);"></span>
|
||||
<span id="signalGuessBadge" style="padding: 2px 8px; border-radius: 3px; font-size: 9px; font-weight: bold;"></span>
|
||||
</div>
|
||||
<div id="signalGuessExplanation" style="color: var(--text-muted); font-size: 10px; margin-bottom: 6px;"></div>
|
||||
<div id="signalGuessTags" style="display: flex; flex-wrap: wrap; gap: 3px;"></div>
|
||||
<div id="signalGuessAlternatives" style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"></div>
|
||||
<div id="signalGuessSendTo" style="margin-top: 8px; display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1,126 +0,0 @@
|
||||
<!-- 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>
|
||||
@@ -1,10 +1,36 @@
|
||||
<!-- 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 id="waterfallMode" class="mode-content wf-side">
|
||||
<div class="section wf-side-hero">
|
||||
<div class="wf-side-hero-title-row">
|
||||
<div class="wf-side-hero-title">Spectrum Waterfall</div>
|
||||
<div class="wf-side-chip" id="wfHeroVisualStatus">CONNECTING</div>
|
||||
</div>
|
||||
<div class="wf-side-hero-subtext">
|
||||
Click spectrum/waterfall to tune. Scroll to step-tune. Ctrl/Cmd + scroll to zoom span.
|
||||
</div>
|
||||
<div class="wf-side-hero-stats">
|
||||
<div class="wf-side-stat">
|
||||
<div class="wf-side-stat-label">Tuned</div>
|
||||
<div class="wf-side-stat-value" id="wfHeroFreq">100.0000 MHz</div>
|
||||
</div>
|
||||
<div class="wf-side-stat">
|
||||
<div class="wf-side-stat-label">Mode</div>
|
||||
<div class="wf-side-stat-value" id="wfHeroMode">WFM</div>
|
||||
</div>
|
||||
<div class="wf-side-stat">
|
||||
<div class="wf-side-stat-label">Scan</div>
|
||||
<div class="wf-side-stat-value" id="wfHeroScan">Idle</div>
|
||||
</div>
|
||||
<div class="wf-side-stat">
|
||||
<div class="wf-side-stat-label">Hits</div>
|
||||
<div class="wf-side-stat-value" id="wfHeroHits">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wf-side-hero-actions">
|
||||
<button class="run-btn" id="wfStartBtn" onclick="Waterfall.start()">Start Waterfall</button>
|
||||
<button class="stop-btn" id="wfStopBtn" onclick="Waterfall.stop()" style="display:none;">Stop Waterfall</button>
|
||||
</div>
|
||||
<div id="wfStatus" class="wf-side-status-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
@@ -15,18 +41,18 @@
|
||||
<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 id="wfDeviceInfo" class="wf-side-box" style="display:none;">
|
||||
<div class="wf-side-kv">
|
||||
<span class="wf-side-kv-label">Type</span>
|
||||
<span id="wfDeviceType" class="wf-side-kv-value">--</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 class="wf-side-kv">
|
||||
<span class="wf-side-kv-label">Range</span>
|
||||
<span id="wfDeviceRange" class="wf-side-kv-value">--</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 class="wf-side-kv">
|
||||
<span class="wf-side-kv-label">Capture SR</span>
|
||||
<span id="wfDeviceBw" class="wf-side-kv-value">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +67,7 @@
|
||||
<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;">
|
||||
<div class="wf-side-grid-2">
|
||||
<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>
|
||||
@@ -49,10 +75,178 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Quick Tune & Bookmarks</h3>
|
||||
<div class="wf-side-grid-2">
|
||||
<button class="preset-btn" onclick="Waterfall.quickTune(121.5, 'am')">121.5 Guard</button>
|
||||
<button class="preset-btn" onclick="Waterfall.quickTune(156.8, 'fm')">156.8 CH16</button>
|
||||
<button class="preset-btn" onclick="Waterfall.quickTune(145.5, 'fm')">145.5 2m</button>
|
||||
<button class="preset-btn" onclick="Waterfall.quickTune(98.1, 'wfm')">98.1 FM</button>
|
||||
<button class="preset-btn" onclick="Waterfall.quickTune(462.5625, 'fm')">462.56 FRS</button>
|
||||
<button class="preset-btn" onclick="Waterfall.quickTune(446.0, 'fm')">446.0 PMR</button>
|
||||
</div>
|
||||
<div class="wf-side-divider"></div>
|
||||
<div class="wf-bookmark-row">
|
||||
<input type="number" id="wfBookmarkFreqInput" step="0.0001" min="0.001" max="6000" placeholder="Frequency MHz">
|
||||
<select id="wfBookmarkMode">
|
||||
<option value="auto" selected>Auto</option>
|
||||
<option value="wfm">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-side-grid-2">
|
||||
<button class="preset-btn" onclick="Waterfall.useTuneForBookmark()">Use Tuned</button>
|
||||
<button class="preset-btn" onclick="Waterfall.addBookmarkFromInput()">Save Bookmark</button>
|
||||
</div>
|
||||
<div id="wfBookmarkList" class="wf-bookmark-list">
|
||||
<div class="wf-empty">No bookmarks saved</div>
|
||||
</div>
|
||||
<div class="wf-side-inline-label">Recent Hits</div>
|
||||
<div id="wfRecentSignals" class="wf-recent-list">
|
||||
<div class="wf-empty">No recent signal hits</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Handoff</h3>
|
||||
<div class="wf-side-help">
|
||||
Send current tuned frequency to another decoder/workflow.
|
||||
</div>
|
||||
<div class="wf-side-grid-2">
|
||||
<button class="preset-btn" onclick="Waterfall.handoff('pager')">To Pager</button>
|
||||
<button class="preset-btn" onclick="Waterfall.handoff('subghz')">To SubGHz</button>
|
||||
<button class="preset-btn" onclick="Waterfall.handoff('subghz433')">433 Profile</button>
|
||||
<button class="preset-btn" onclick="Waterfall.handoff('signalid')">Signal ID</button>
|
||||
</div>
|
||||
<div id="wfHandoffStatus" class="wf-side-status-line">Ready</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Signal Identification</h3>
|
||||
<div class="wf-side-help">
|
||||
Identify current frequency using local catalog and SigID Wiki matches.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Frequency (MHz)</label>
|
||||
<input type="number" id="wfSigIdFreq" value="100.0000" step="0.0001" min="0.001" max="6000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Mode Hint</label>
|
||||
<select id="wfSigIdMode">
|
||||
<option value="auto" selected>Auto (Current Mode)</option>
|
||||
<option value="wfm">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-side-grid-2">
|
||||
<button class="preset-btn" onclick="Waterfall.useTuneForSignalId()">Use Tuned</button>
|
||||
<button class="preset-btn" onclick="Waterfall.identifySignal()">Identify</button>
|
||||
</div>
|
||||
<div id="wfSigIdStatus" class="wf-side-status-line">Ready</div>
|
||||
<div id="wfSigIdResult" class="wf-side-box" style="display:none;"></div>
|
||||
<div id="wfSigIdExternal" class="wf-side-box wf-side-box-muted" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Scan</h3>
|
||||
<div class="form-group">
|
||||
<label>Range Start (MHz)</label>
|
||||
<input type="number" id="wfScanStart" value="98.8000" step="0.001" min="0.001" max="6000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Range End (MHz)</label>
|
||||
<input type="number" id="wfScanEnd" value="101.2000" step="0.001" min="0.001" max="6000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Step (kHz)</label>
|
||||
<select id="wfScanStepKhz">
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="12.5">12.5</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100" selected>100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Dwell (ms)</label>
|
||||
<input type="number" id="wfScanDwellMs" value="450" min="60" max="10000" step="10">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Signal Threshold <span id="wfScanThresholdValue" class="wf-inline-value">170</span></label>
|
||||
<input type="range" id="wfScanThreshold" min="0" max="255" value="170">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Hold On Hit (ms)</label>
|
||||
<input type="number" id="wfScanHoldMs" value="2500" min="0" max="30000" step="100">
|
||||
</div>
|
||||
<div class="checkbox-group wf-scan-checkboxes">
|
||||
<label>
|
||||
<input type="checkbox" id="wfScanStopOnSignal" checked>
|
||||
Pause scan when signal is above threshold
|
||||
</label>
|
||||
</div>
|
||||
<div class="wf-side-grid-2 wf-side-grid-gap-top">
|
||||
<button class="preset-btn" onclick="Waterfall.setScanRangeFromView()">Use Current Span</button>
|
||||
<button class="preset-btn" id="wfScanStartBtn" onclick="Waterfall.startScan()">Start Scan</button>
|
||||
<button class="preset-btn" id="wfScanStopBtn" onclick="Waterfall.stopScan()" disabled>Stop Scan</button>
|
||||
</div>
|
||||
<div id="wfScanState" class="wf-side-status-line">Scan idle</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Scan Activity</h3>
|
||||
<div class="wf-scan-metric-grid">
|
||||
<div class="wf-scan-metric-card">
|
||||
<div class="wf-scan-metric-label">Signals</div>
|
||||
<div class="wf-scan-metric-value" id="wfScanSignalsCount">0</div>
|
||||
</div>
|
||||
<div class="wf-scan-metric-card">
|
||||
<div class="wf-scan-metric-label">Scanned</div>
|
||||
<div class="wf-scan-metric-value" id="wfScanStepsCount">0</div>
|
||||
</div>
|
||||
<div class="wf-scan-metric-card">
|
||||
<div class="wf-scan-metric-label">Cycles</div>
|
||||
<div class="wf-scan-metric-value" id="wfScanCyclesCount">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wf-side-grid-2 wf-side-grid-gap-top">
|
||||
<button class="preset-btn" onclick="Waterfall.exportScanLog()">Export Log</button>
|
||||
<button class="preset-btn" onclick="Waterfall.clearScanHistory()">Clear History</button>
|
||||
</div>
|
||||
<div class="wf-hit-table-wrap">
|
||||
<table class="wf-hit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Frequency</th>
|
||||
<th>Level</th>
|
||||
<th>Mode</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="wfSignalHitsBody">
|
||||
<tr><td colspan="5" class="wf-empty">No signals detected</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="wfSignalHitCount" class="wf-side-inline-label">0 signals found</div>
|
||||
<div id="wfActivityLog" class="wf-activity-log">
|
||||
<div class="wf-empty">Ready</div>
|
||||
</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>
|
||||
<label>Gain <span class="wf-inline-value">(dB or AUTO)</span></label>
|
||||
<input type="text" id="wfGain" value="AUTO" placeholder="AUTO or numeric">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -88,7 +282,7 @@
|
||||
<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;">
|
||||
<div class="checkbox-group wf-scan-checkboxes">
|
||||
<label>
|
||||
<input type="checkbox" id="wfBiasT">
|
||||
Bias-T (antenna power)
|
||||
@@ -115,7 +309,7 @@
|
||||
<label>Ceiling (dB)</label>
|
||||
<input type="number" id="wfDbMax" value="-20" step="5" disabled>
|
||||
</div>
|
||||
<div class="checkbox-group" style="margin-top:8px;">
|
||||
<div class="checkbox-group wf-scan-checkboxes">
|
||||
<label>
|
||||
<input type="checkbox" id="wfPeakHold" onchange="Waterfall.togglePeakHold(this.checked)">
|
||||
Peak Hold
|
||||
@@ -130,13 +324,4 @@
|
||||
</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>
|
||||
|
||||
@@ -65,7 +65,6 @@
|
||||
{{ mode_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
||||
{{ mode_item('sensor', '433MHz', '<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="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></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('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>
|
||||
@@ -136,8 +135,6 @@
|
||||
{{ 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('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
{{ mode_item('rfheatmap', 'RF Heatmap', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/><path d="M2 10h4M18 10h4" opacity="0.4"/></svg>') }}
|
||||
{{ mode_item('fingerprint', 'RF Fingerprint', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12C2 6.5 6.5 2 12 2a10 10 0 0 1 8 4"/><path d="M5 19.5C5.5 18 6 15 6 12c0-.7.12-1.37.34-2"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +196,6 @@
|
||||
{{ mobile_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
||||
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
||||
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
||||
{# Tracking #}
|
||||
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
||||
@@ -227,8 +223,6 @@
|
||||
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
{# New modes #}
|
||||
{{ mobile_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h4l3-8 3 16 3-8h4"/></svg>') }}
|
||||
{{ mobile_item('rfheatmap', 'RF Map', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>') }}
|
||||
{{ mobile_item('fingerprint', 'Fprint', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12C2 6.5 6.5 2 12 2a10 10 0 0 1 8 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/></svg>') }}
|
||||
</nav>
|
||||
|
||||
{# JavaScript stub for pages that don't have switchMode defined #}
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
<option value="tscm">TSCM</option>
|
||||
<option value="sstv">SSTV</option>
|
||||
<option value="sstv_general">SSTV General</option>
|
||||
<option value="listening_scanner">Listening Post</option>
|
||||
<option value="waterfall">Waterfall</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
99
tests/test_signalid_sigidwiki_api.py
Normal file
99
tests/test_signalid_sigidwiki_api.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Tests for the SigID Wiki lookup API endpoint."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import routes.signalid as signalid_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(client):
|
||||
"""Client with logged-in session."""
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
return client
|
||||
|
||||
|
||||
def test_sigidwiki_lookup_missing_frequency(auth_client):
|
||||
"""frequency_mhz is required."""
|
||||
resp = auth_client.post('/signalid/sigidwiki', json={})
|
||||
assert resp.status_code == 400
|
||||
data = resp.get_json()
|
||||
assert data['status'] == 'error'
|
||||
|
||||
|
||||
def test_sigidwiki_lookup_invalid_frequency(auth_client):
|
||||
"""frequency_mhz must be numeric and positive."""
|
||||
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': 'abc'})
|
||||
assert resp.status_code == 400
|
||||
|
||||
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': -1})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_sigidwiki_lookup_success(auth_client):
|
||||
"""Endpoint returns normalized SigID lookup structure."""
|
||||
signalid_module._cache.clear()
|
||||
fake_lookup = {
|
||||
'matches': [
|
||||
{
|
||||
'title': 'POCSAG',
|
||||
'url': 'https://www.sigidwiki.com/wiki/POCSAG',
|
||||
'frequencies_mhz': [929.6625],
|
||||
'modes': ['NFM'],
|
||||
'modulations': ['FSK'],
|
||||
'distance_hz': 0,
|
||||
'source': 'SigID Wiki',
|
||||
}
|
||||
],
|
||||
'search_used': False,
|
||||
'exact_queries': ['[[Category:Signal]][[Frequencies::929.6625 MHz]]|?Frequencies|?Mode|?Modulation|limit=10'],
|
||||
}
|
||||
|
||||
with patch('routes.signalid._lookup_sigidwiki_matches', return_value=fake_lookup) as lookup_mock:
|
||||
resp = auth_client.post('/signalid/sigidwiki', json={
|
||||
'frequency_mhz': 929.6625,
|
||||
'modulation': 'fm',
|
||||
'limit': 5,
|
||||
})
|
||||
|
||||
assert lookup_mock.call_count == 1
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data['source'] == 'sigidwiki'
|
||||
assert data['cached'] is False
|
||||
assert data['match_count'] == 1
|
||||
assert data['matches'][0]['title'] == 'POCSAG'
|
||||
|
||||
|
||||
def test_sigidwiki_lookup_cached_response(auth_client):
|
||||
"""Second identical lookup should be served from cache."""
|
||||
signalid_module._cache.clear()
|
||||
fake_lookup = {
|
||||
'matches': [{'title': 'Test Signal', 'url': 'https://www.sigidwiki.com/wiki/Test_Signal'}],
|
||||
'search_used': True,
|
||||
'exact_queries': [],
|
||||
}
|
||||
|
||||
payload = {'frequency_mhz': 433.92, 'modulation': 'nfm', 'limit': 5}
|
||||
with patch('routes.signalid._lookup_sigidwiki_matches', return_value=fake_lookup) as lookup_mock:
|
||||
first = auth_client.post('/signalid/sigidwiki', json=payload)
|
||||
second = auth_client.post('/signalid/sigidwiki', json=payload)
|
||||
|
||||
assert lookup_mock.call_count == 1
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert first.get_json()['cached'] is False
|
||||
assert second.get_json()['cached'] is True
|
||||
|
||||
|
||||
def test_sigidwiki_lookup_backend_failure(auth_client):
|
||||
"""Unexpected lookup failures should return 502."""
|
||||
signalid_module._cache.clear()
|
||||
with patch('routes.signalid._lookup_sigidwiki_matches', side_effect=RuntimeError('boom')):
|
||||
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': 433.92})
|
||||
assert resp.status_code == 502
|
||||
data = resp.get_json()
|
||||
assert data['status'] == 'error'
|
||||
@@ -1,210 +0,0 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user