Remove legacy RF modes and add SignalID route/tests

This commit is contained in:
Smittix
2026-02-23 13:34:00 +00:00
parent 5f480caa3f
commit 7ea06caaa2
35 changed files with 3883 additions and 6920 deletions

View File

@@ -2,40 +2,40 @@
def register_blueprints(app): def register_blueprints(app):
"""Register all route blueprints with the Flask app.""" """Register all route blueprints with the Flask app."""
from .pager import pager_bp from .acars import acars_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 .adsb import adsb_bp from .adsb import adsb_bp
from .ais import ais_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 .alerts import alerts_bp
from .recordings import recordings_bp from .aprs import aprs_bp
from .subghz import subghz_bp from .bluetooth import bluetooth_bp
from .bluetooth_v2 import bluetooth_v2_bp
from .bt_locate import bt_locate_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 .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(pager_bp)
app.register_blueprint(sensor_bp) app.register_blueprint(sensor_bp)
@@ -54,7 +54,7 @@ def register_blueprints(app):
app.register_blueprint(gps_bp) app.register_blueprint(gps_bp)
app.register_blueprint(settings_bp) app.register_blueprint(settings_bp)
app.register_blueprint(correlation_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(meshtastic_bp)
app.register_blueprint(tscm_bp) app.register_blueprint(tscm_bp)
app.register_blueprint(spy_stations_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(subghz_bp) # SubGHz transceiver (HackRF)
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
app.register_blueprint(space_weather_bp) # Space weather monitoring 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 # Initialize TSCM state with queue and lock from app
import app as app_module import app as app_module

View File

@@ -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})

View File

@@ -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 from __future__ import annotations
@@ -29,9 +29,9 @@ from utils.constants import (
) )
from utils.sdr import SDRFactory, SDRType 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 # GLOBAL STATE
@@ -53,7 +53,7 @@ scanner_lock = threading.Lock()
scanner_paused = False scanner_paused = False
scanner_current_freq = 0.0 scanner_current_freq = 0.0
scanner_active_device: Optional[int] = None 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_power_process: Optional[subprocess.Popen] = None
scanner_config = { scanner_config = {
'start_freq': 88.0, 'start_freq': 88.0,
@@ -941,7 +941,7 @@ def _stop_audio_stream_internal():
# API ENDPOINTS # API ENDPOINTS
# ============================================ # ============================================
@listening_post_bp.route('/tools') @receiver_bp.route('/tools')
def check_tools() -> Response: def check_tools() -> Response:
"""Check for required tools.""" """Check for required tools."""
rtl_fm = find_rtl_fm() 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: def start_scanner() -> Response:
"""Start the frequency scanner.""" """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: with scanner_lock:
if scanner_running: if scanner_running:
@@ -1036,9 +1036,9 @@ def start_scanner() -> Response:
'message': 'rtl_power not found. Install rtl-sdr tools.' 'message': 'rtl_power not found. Install rtl-sdr tools.'
}), 503 }), 503
# Release listening device if active # Release listening device if active
if listening_active_device is not None: if receiver_active_device is not None:
app_module.release_sdr_device(listening_active_device) app_module.release_sdr_device(receiver_active_device)
listening_active_device = None receiver_active_device = None
# Claim device for scanner # Claim device for scanner
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner') error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
if error: if error:
@@ -1064,9 +1064,9 @@ def start_scanner() -> Response:
'status': 'error', 'status': 'error',
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.' 'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
}), 503 }), 503
if listening_active_device is not None: if receiver_active_device is not None:
app_module.release_sdr_device(listening_active_device) app_module.release_sdr_device(receiver_active_device)
listening_active_device = None receiver_active_device = None
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner') error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
if error: if error:
return jsonify({ 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: def stop_scanner() -> Response:
"""Stop the frequency scanner.""" """Stop the frequency scanner."""
global scanner_running, scanner_active_device, scanner_power_process global scanner_running, scanner_active_device, scanner_power_process
@@ -1110,7 +1110,7 @@ def stop_scanner() -> Response:
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@listening_post_bp.route('/scanner/pause', methods=['POST']) @receiver_bp.route('/scanner/pause', methods=['POST'])
def pause_scanner() -> Response: def pause_scanner() -> Response:
"""Pause/resume the scanner.""" """Pause/resume the scanner."""
global scanner_paused global scanner_paused
@@ -1132,7 +1132,7 @@ def pause_scanner() -> Response:
scanner_skip_signal = False scanner_skip_signal = False
@listening_post_bp.route('/scanner/skip', methods=['POST']) @receiver_bp.route('/scanner/skip', methods=['POST'])
def skip_signal() -> Response: def skip_signal() -> Response:
"""Skip current signal and continue scanning.""" """Skip current signal and continue scanning."""
global scanner_skip_signal 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: def update_scanner_config() -> Response:
"""Update scanner config while running (step, squelch, gain, dwell).""" """Update scanner config while running (step, squelch, gain, dwell)."""
data = request.json or {} 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: def scanner_status() -> Response:
"""Get scanner status.""" """Get scanner status."""
return jsonify({ 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: def stream_scanner_events() -> Response:
"""SSE stream for scanner events.""" """SSE stream for scanner events."""
def _on_msg(msg: dict[str, Any]) -> None: 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( response = Response(
sse_stream_fanout( sse_stream_fanout(
source_queue=scanner_queue, source_queue=scanner_queue,
channel_key='listening_scanner', channel_key='receiver_scanner',
timeout=SSE_QUEUE_TIMEOUT, timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL, keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg, on_message=_on_msg,
@@ -1228,7 +1228,7 @@ def stream_scanner_events() -> Response:
return response return response
@listening_post_bp.route('/scanner/log') @receiver_bp.route('/scanner/log')
def get_activity_log() -> Response: def get_activity_log() -> Response:
"""Get activity log.""" """Get activity log."""
limit = request.args.get('limit', 100, type=int) 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: def clear_activity_log() -> Response:
"""Clear activity log.""" """Clear activity log."""
with activity_log_lock: with activity_log_lock:
@@ -1247,7 +1247,7 @@ def clear_activity_log() -> Response:
return jsonify({'status': 'cleared'}) return jsonify({'status': 'cleared'})
@listening_post_bp.route('/presets') @receiver_bp.route('/presets')
def get_presets() -> Response: def get_presets() -> Response:
"""Get scanner presets.""" """Get scanner presets."""
presets = [ presets = [
@@ -1267,10 +1267,10 @@ def get_presets() -> Response:
# MANUAL AUDIO ENDPOINTS (for direct listening) # 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: def start_audio() -> Response:
"""Start audio at specific frequency (manual mode).""" """Start audio at specific frequency (manual mode)."""
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread
global audio_running, audio_frequency, audio_modulation, audio_source global audio_running, audio_frequency, audio_modulation, audio_source
# Stop scanner if running # Stop scanner if running
@@ -1363,9 +1363,9 @@ def start_audio() -> Response:
audio_modulation = modulation audio_modulation = modulation
audio_source = 'waterfall' audio_source = 'waterfall'
# Shared monitor uses the waterfall's existing SDR claim. # Shared monitor uses the waterfall's existing SDR claim.
if listening_active_device is not None: if receiver_active_device is not None:
app_module.release_sdr_device(listening_active_device) app_module.release_sdr_device(receiver_active_device)
listening_active_device = None receiver_active_device = None
return jsonify({ return jsonify({
'status': 'started', 'status': 'started',
'frequency': frequency, 'frequency': frequency,
@@ -1385,15 +1385,15 @@ def start_audio() -> Response:
# may still be tearing down its IQ capture process (thread join + # may still be tearing down its IQ capture process (thread join +
# safe_terminate can take several seconds), so we retry with back-off # safe_terminate can take several seconds), so we retry with back-off
# to give the USB device time to be fully released. # to give the USB device time to be fully released.
if listening_active_device is None or listening_active_device != device: if receiver_active_device is None or receiver_active_device != device:
if listening_active_device is not None: if receiver_active_device is not None:
app_module.release_sdr_device(listening_active_device) app_module.release_sdr_device(receiver_active_device)
listening_active_device = None receiver_active_device = None
error = None error = None
max_claim_attempts = 6 max_claim_attempts = 6
for attempt in range(max_claim_attempts): for attempt in range(max_claim_attempts):
error = app_module.claim_sdr_device(device, 'listening') error = app_module.claim_sdr_device(device, 'receiver')
if not error: if not error:
break break
if attempt < max_claim_attempts - 1: if attempt < max_claim_attempts - 1:
@@ -1409,7 +1409,7 @@ def start_audio() -> Response:
'error_type': 'DEVICE_BUSY', 'error_type': 'DEVICE_BUSY',
'message': error 'message': error
}), 409 }), 409
listening_active_device = device receiver_active_device = device
_start_audio_stream(frequency, modulation) _start_audio_stream(frequency, modulation)
@@ -1423,9 +1423,9 @@ def start_audio() -> Response:
}) })
else: else:
# Avoid leaving a stale device claim after startup failure. # Avoid leaving a stale device claim after startup failure.
if listening_active_device is not None: if receiver_active_device is not None:
app_module.release_sdr_device(listening_active_device) app_module.release_sdr_device(receiver_active_device)
listening_active_device = None receiver_active_device = None
start_error = '' start_error = ''
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'): for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
@@ -1447,18 +1447,18 @@ def start_audio() -> Response:
}), 500 }), 500
@listening_post_bp.route('/audio/stop', methods=['POST']) @receiver_bp.route('/audio/stop', methods=['POST'])
def stop_audio() -> Response: def stop_audio() -> Response:
"""Stop audio.""" """Stop audio."""
global listening_active_device global receiver_active_device
_stop_audio_stream() _stop_audio_stream()
if listening_active_device is not None: if receiver_active_device is not None:
app_module.release_sdr_device(listening_active_device) app_module.release_sdr_device(receiver_active_device)
listening_active_device = None receiver_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@listening_post_bp.route('/audio/status') @receiver_bp.route('/audio/status')
def audio_status() -> Response: def audio_status() -> Response:
"""Get audio status.""" """Get audio status."""
running = audio_running 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: def audio_debug() -> Response:
"""Get audio debug status and recent stderr logs.""" """Get audio debug status and recent stderr logs."""
rtl_log_path = '/tmp/rtl_fm_stderr.log' 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: def audio_probe() -> Response:
"""Grab a small chunk of audio bytes from the pipeline for debugging.""" """Grab a small chunk of audio bytes from the pipeline for debugging."""
global audio_process global audio_process
@@ -1559,7 +1559,7 @@ def audio_probe() -> Response:
return jsonify({'status': 'ok', 'bytes': size}) return jsonify({'status': 'ok', 'bytes': size})
@listening_post_bp.route('/audio/stream') @receiver_bp.route('/audio/stream')
def stream_audio() -> Response: def stream_audio() -> Response:
"""Stream WAV audio.""" """Stream WAV audio."""
if audio_source == 'waterfall': if audio_source == 'waterfall':
@@ -1682,7 +1682,7 @@ def stream_audio() -> Response:
# SIGNAL IDENTIFICATION ENDPOINT # SIGNAL IDENTIFICATION ENDPOINT
# ============================================ # ============================================
@listening_post_bp.route('/signal/guess', methods=['POST']) @receiver_bp.route('/signal/guess', methods=['POST'])
def guess_signal() -> Response: def guess_signal() -> Response:
"""Identify a signal based on frequency, modulation, and other parameters.""" """Identify a signal based on frequency, modulation, and other parameters."""
data = request.json or {} data = request.json or {}
@@ -1962,7 +1962,7 @@ def _stop_waterfall_internal() -> None:
waterfall_active_device = None waterfall_active_device = None
@listening_post_bp.route('/waterfall/start', methods=['POST']) @receiver_bp.route('/waterfall/start', methods=['POST'])
def start_waterfall() -> Response: def start_waterfall() -> Response:
"""Start the waterfall/spectrogram display.""" """Start the waterfall/spectrogram display."""
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device 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}) 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: def stop_waterfall() -> Response:
"""Stop the waterfall display.""" """Stop the waterfall display."""
_stop_waterfall_internal() _stop_waterfall_internal()
@@ -2031,7 +2031,7 @@ def stop_waterfall() -> Response:
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@listening_post_bp.route('/waterfall/stream') @receiver_bp.route('/waterfall/stream')
def stream_waterfall() -> Response: def stream_waterfall() -> Response:
"""SSE stream for waterfall data.""" """SSE stream for waterfall data."""
def _on_msg(msg: dict[str, Any]) -> None: def _on_msg(msg: dict[str, Any]) -> None:
@@ -2040,7 +2040,7 @@ def stream_waterfall() -> Response:
response = Response( response = Response(
sse_stream_fanout( sse_stream_fanout(
source_queue=waterfall_queue, source_queue=waterfall_queue,
channel_key='listening_waterfall', channel_key='receiver_waterfall',
timeout=SSE_QUEUE_TIMEOUT, timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL, keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg, on_message=_on_msg,

352
routes/signalid.py Normal file
View 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,
})

View File

@@ -893,6 +893,82 @@ body {
display: block; 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 */ /* Right sidebar - Mobile first */
.sidebar { .sidebar {
display: flex; display: flex;

View File

@@ -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%;
}

View File

@@ -140,14 +140,65 @@
} }
.gps-skyview-canvas-wrap { .gps-skyview-canvas-wrap {
display: flex; position: relative;
justify-content: center; display: block;
align-items: center; 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 { #gpsSkyCanvas {
max-width: 100%; display: block;
height: auto; 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 */ /* Position info panel */

View File

@@ -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; }
}

View File

@@ -412,6 +412,11 @@
background: rgba(74, 163, 255, 0.28); background: rgba(74, 163, 255, 0.28);
} }
.wf-zoom-btn {
font-size: 15px;
font-weight: 700;
}
.wf-freq-display-wrap { .wf-freq-display-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -494,6 +499,142 @@
display: block; 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 */ /* Resize handle */
.wf-resize-handle { .wf-resize-handle {
@@ -664,3 +805,378 @@
width: 96px; 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;
}

View File

@@ -1,7 +1,7 @@
/** /**
* Activity Timeline Component * Activity Timeline Component
* Reusable, configuration-driven timeline visualization for time-based metadata * 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() { const ActivityTimeline = (function() {
@@ -176,7 +176,7 @@ const ActivityTimeline = (function() {
*/ */
function categorizeById(id, mode) { function categorizeById(id, mode) {
// RF frequency categorization // RF frequency categorization
if (mode === 'rf' || mode === 'tscm' || mode === 'listening-post') { if (mode === 'rf' || mode === 'tscm' || mode === 'waterfall') {
const f = parseFloat(id); const f = parseFloat(id);
if (!isNaN(f)) { if (!isNaN(f)) {
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band'; if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';

View File

@@ -1,7 +1,7 @@
/** /**
* RF Signal Timeline Adapter * RF Signal Timeline Adapter
* Normalizes RF signal data for the Activity Timeline component * Normalizes RF signal data for the Activity Timeline component
* Used by: Listening Post, TSCM * Used by: Spectrum Waterfall, TSCM
*/ */
const RFTimelineAdapter = (function() { 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 { return {
title: 'Signal Activity', title: 'Spectrum Activity',
mode: 'listening-post', mode: 'waterfall',
visualMode: 'enriched', visualMode: 'enriched',
collapsed: false, collapsed: false,
showAnnotations: true, 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 * Create timeline configuration for TSCM mode
*/ */
@@ -224,6 +229,7 @@ const RFTimelineAdapter = (function() {
categorizeFrequency: categorizeFrequency, categorizeFrequency: categorizeFrequency,
// Configuration presets // Configuration presets
getWaterfallConfig: getWaterfallConfig,
getListeningPostConfig: getListeningPostConfig, getListeningPostConfig: getListeningPostConfig,
getTscmConfig: getTscmConfig, getTscmConfig: getTscmConfig,

View File

@@ -98,7 +98,7 @@ function switchMode(mode) {
const modeMap = { const modeMap = {
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft', 'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth', 'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
'listening': 'listening', 'meshtastic': 'meshtastic' 'meshtastic': 'meshtastic'
}; };
document.querySelectorAll('.mode-nav-btn').forEach(btn => { document.querySelectorAll('.mode-nav-btn').forEach(btn => {
const label = btn.querySelector('.nav-label'); const label = btn.querySelector('.nav-label');
@@ -114,7 +114,6 @@ function switchMode(mode) {
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite'); document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi'); document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth'); 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('aprsMode')?.classList.toggle('active', mode === 'aprs');
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm'); document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr'); document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
@@ -143,7 +142,6 @@ function switchMode(mode) {
'satellite': 'SATELLITE', 'satellite': 'SATELLITE',
'wifi': 'WIFI', 'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH', 'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST',
'tscm': 'TSCM', 'tscm': 'TSCM',
'aprs': 'APRS', 'aprs': 'APRS',
'meshtastic': 'MESHTASTIC' 'meshtastic': 'MESHTASTIC'
@@ -166,7 +164,6 @@ function switchMode(mode) {
const showRadar = document.getElementById('adsbEnableMap')?.checked; const showRadar = document.getElementById('adsbEnableMap')?.checked;
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none'; document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : '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 // Update output panel title based on mode
const titles = { const titles = {
@@ -176,7 +173,6 @@ function switchMode(mode) {
'satellite': 'Satellite Monitor', 'satellite': 'Satellite Monitor',
'wifi': 'WiFi Scanner', 'wifi': 'WiFi Scanner',
'bluetooth': 'Bluetooth Scanner', 'bluetooth': 'Bluetooth Scanner',
'listening': 'Listening Post',
'meshtastic': 'Meshtastic Mesh Monitor' 'meshtastic': 'Meshtastic Mesh Monitor'
}; };
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal 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 // Show/hide Device Intelligence for modes that use it
const reconBtn = document.getElementById('reconBtn'); const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]'); const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') { if (mode === 'satellite' || mode === 'aircraft') {
document.getElementById('reconPanel').style.display = 'none'; document.getElementById('reconPanel').style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none'; if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none'; if (intelBtn) intelBtn.style.display = 'none';
@@ -198,7 +194,7 @@ function switchMode(mode) {
// Show RTL-SDR device section for modes that use it // Show RTL-SDR device section for modes that use it
document.getElementById('rtlDeviceSection').style.display = 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 // Toggle mode-specific tool status displays
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none'; 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 // Hide waterfall and output console for modes with their own visualizations
document.querySelector('.waterfall-container').style.display = 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 = document.getElementById('output').style.display =
(mode === 'satellite' || 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.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex'; 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') { } else if (mode === 'satellite') {
if (typeof initPolarPlot === 'function') initPolarPlot(); if (typeof initPolarPlot === 'function') initPolarPlot();
if (typeof initSatelliteList === 'function') initSatelliteList(); 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') { } else if (mode === 'meshtastic') {
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init(); if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
} }

View File

@@ -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'] }, sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] },
weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] }, weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] },
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] }, 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'] }, spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
listening: { title: 'Listening Post', icon: '🎧', hardware: 'RTL-SDR dongle', description: 'Wideband scanner and audio receiver for AM/FM/USB/LSB/CW.', whatToExpect: 'Audio from any frequency, spectrum waterfall, squelch.', tips: ['VHF air band: 118136 MHz AM', 'Marine VHF: 156174 MHz FM', 'HF requires upconverter or direct-sampling SDR'] },
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] }, 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'] }, 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'] }, 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'] }, 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'] }, 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) { function show(mode) {

View File

@@ -12,8 +12,8 @@ const CommandPalette = (function() {
{ mode: 'pager', label: 'Pager' }, { mode: 'pager', label: 'Pager' },
{ mode: 'sensor', label: '433MHz Sensors' }, { mode: 'sensor', label: '433MHz Sensors' },
{ mode: 'rtlamr', label: 'Meters' }, { mode: 'rtlamr', label: 'Meters' },
{ mode: 'listening', label: 'Listening Post' },
{ mode: 'subghz', label: 'SubGHz' }, { mode: 'subghz', label: 'SubGHz' },
{ mode: 'waterfall', label: 'Spectrum Waterfall' },
{ mode: 'aprs', label: 'APRS' }, { mode: 'aprs', label: 'APRS' },
{ mode: 'wifi', label: 'WiFi Scanner' }, { mode: 'wifi', label: 'WiFi Scanner' },
{ mode: 'bluetooth', label: 'Bluetooth Scanner' }, { mode: 'bluetooth', label: 'Bluetooth Scanner' },

View File

@@ -130,7 +130,7 @@ const FirstRunSetup = (function() {
['pager', 'Pager'], ['pager', 'Pager'],
['sensor', '433MHz'], ['sensor', '433MHz'],
['rtlamr', 'Meters'], ['rtlamr', 'Meters'],
['listening', 'Listening Post'], ['waterfall', 'Waterfall'],
['wifi', 'WiFi'], ['wifi', 'WiFi'],
['bluetooth', 'Bluetooth'], ['bluetooth', 'Bluetooth'],
['bt_locate', 'BT Locate'], ['bt_locate', 'BT Locate'],
@@ -149,7 +149,11 @@ const FirstRunSetup = (function() {
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY); const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
if (savedDefaultMode) { 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); actionsEl.appendChild(modeSelectEl);

View File

@@ -11,8 +11,6 @@ const KeyboardShortcuts = (function () {
if (e.altKey) { if (e.altKey) {
switch (e.key.toLowerCase()) { switch (e.key.toLowerCase()) {
case 'w': e.preventDefault(); window.switchMode && switchMode('waterfall'); break; 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 'm': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break;
case 's': e.preventDefault(); _toggleSidebar(); break; case 's': e.preventDefault(); _toggleSidebar(); break;
case 'k': e.preventDefault(); showHelp(); break; case 'k': e.preventDefault(); showHelp(); break;

View File

@@ -10,6 +10,10 @@ const VoiceAlerts = (function () {
let _sources = {}; let _sources = {};
const STORAGE_KEY = 'intercept-voice-muted'; const STORAGE_KEY = 'intercept-voice-muted';
const CONFIG_KEY = 'intercept-voice-config'; 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 // Default config
let _config = { let _config = {
@@ -19,6 +23,28 @@ const VoiceAlerts = (function () {
streams: { pager: true, tscm: true, bluetooth: true }, 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() { function _loadConfig() {
_muted = localStorage.getItem(STORAGE_KEY) === 'true'; _muted = localStorage.getItem(STORAGE_KEY) === 'true';
try { try {
@@ -33,6 +59,7 @@ const VoiceAlerts = (function () {
} }
} }
} catch (_) {} } catch (_) {}
_normalizeConfig();
_updateMuteButton(); _updateMuteButton();
} }
@@ -50,6 +77,15 @@ const VoiceAlerts = (function () {
return voices.find(v => v.name === _config.voiceName) || null; 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) { function speak(text, priority) {
if (priority === undefined) priority = PRIORITY.MEDIUM; if (priority === undefined) priority = PRIORITY.MEDIUM;
if (!_enabled || _muted) return; if (!_enabled || _muted) return;
@@ -68,11 +104,7 @@ const VoiceAlerts = (function () {
if (_queue.length === 0) { _speaking = false; return; } if (_queue.length === 0) { _speaking = false; return; }
_speaking = true; _speaking = true;
const item = _queue.shift(); const item = _queue.shift();
const utt = new SpeechSynthesisUtterance(item.text); const utt = _createUtterance(item.text);
utt.rate = _config.rate;
utt.pitch = _config.pitch;
const voice = _getVoice();
if (voice) utt.voice = voice;
utt.onend = () => { _speaking = false; _dequeue(); }; utt.onend = () => { _speaking = false; _dequeue(); };
utt.onerror = () => { _speaking = false; _dequeue(); }; utt.onerror = () => { _speaking = false; _dequeue(); };
window.speechSynthesis.speak(utt); window.speechSynthesis.speak(utt);
@@ -141,6 +173,10 @@ const VoiceAlerts = (function () {
function init() { function init() {
_loadConfig(); _loadConfig();
if (_isSpeechSupported()) {
// Prime voices list early so user-triggered test calls are less likely to be silent.
speechSynthesis.getVoices();
}
_startStreams(); _startStreams();
} }
@@ -161,10 +197,11 @@ const VoiceAlerts = (function () {
} }
function setConfig(cfg) { function setConfig(cfg) {
if (cfg.rate !== undefined) _config.rate = cfg.rate; if (cfg.rate !== undefined) _config.rate = _toNumberInRange(cfg.rate, _config.rate, RATE_MIN, RATE_MAX);
if (cfg.pitch !== undefined) _config.pitch = cfg.pitch; 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.voiceName !== undefined) _config.voiceName = cfg.voiceName;
if (cfg.streams) Object.assign(_config.streams, cfg.streams); if (cfg.streams) Object.assign(_config.streams, cfg.streams);
_normalizeConfig();
localStorage.setItem(CONFIG_KEY, JSON.stringify(_config)); localStorage.setItem(CONFIG_KEY, JSON.stringify(_config));
// Restart streams to apply per-stream toggle changes // Restart streams to apply per-stream toggle changes
_stopStreams(); _stopStreams();
@@ -185,13 +222,31 @@ const VoiceAlerts = (function () {
} }
function testVoice(text) { function testVoice(text) {
if (!window.speechSynthesis) return; if (!_isSpeechSupported()) {
const utt = new SpeechSynthesisUtterance(text || 'Voice alert test. All systems nominal.'); _showVoiceToast('Voice Unavailable', 'This browser does not support speech synthesis.', 'warning');
utt.rate = _config.rate; return;
utt.pitch = _config.pitch; }
const voice = _getVoice();
if (voice) utt.voice = voice; // 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); 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 }; return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };

View File

@@ -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;

View File

@@ -9,6 +9,9 @@ const GPS = (function() {
let lastPosition = null; let lastPosition = null;
let lastSky = null; let lastSky = null;
let skyPollTimer = null; let skyPollTimer = null;
let themeObserver = null;
let skyRenderer = null;
let skyRendererInitAttempted = false;
// Constellation color map // Constellation color map
const CONST_COLORS = { const CONST_COLORS = {
@@ -21,18 +24,43 @@ const GPS = (function() {
}; };
function init() { function init() {
initSkyRenderer();
drawEmptySkyView(); drawEmptySkyView();
connect(); if (!connected) connect();
// Redraw sky view when theme changes // Redraw sky view when theme changes
const observer = new MutationObserver(() => { if (!themeObserver) {
if (lastSky) { themeObserver = new MutationObserver(() => {
drawSkyView(lastSky.satellites || []); if (skyRenderer && typeof skyRenderer.requestRender === 'function') {
} else { skyRenderer.requestRender();
drawEmptySkyView(); }
} if (lastSky) {
}); drawSkyView(lastSky.satellites || []);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); } else {
drawEmptySkyView();
}
});
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() { function connect() {
@@ -253,41 +281,61 @@ const GPS = (function() {
} }
// ======================== // ========================
// Sky View Polar Plot // Sky View Globe (WebGL with 2D fallback)
// ======================== // ========================
function drawEmptySkyView() { function drawEmptySkyView() {
if (!skyRendererInitAttempted) {
initSkyRenderer();
}
if (skyRenderer) {
skyRenderer.setSatellites([]);
return;
}
const canvas = document.getElementById('gpsSkyCanvas'); const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return; if (!canvas) return;
drawSkyViewBase(canvas); drawSkyViewBase2D(canvas);
} }
function drawSkyView(satellites) { function drawSkyView(satellites) {
if (!skyRendererInitAttempted) {
initSkyRenderer();
}
const sats = Array.isArray(satellites) ? satellites : [];
if (skyRenderer) {
skyRenderer.setSatellites(sats);
return;
}
const canvas = document.getElementById('gpsSkyCanvas'); const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return; if (!canvas) return;
drawSkyViewBase2D(canvas);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width; const w = canvas.width;
const h = canvas.height; const h = canvas.height;
const cx = w / 2; const cx = w / 2;
const cy = h / 2; const cy = h / 2;
const r = Math.min(cx, cy) - 24; const r = Math.min(cx, cy) - 24;
drawSkyViewBase(canvas); sats.forEach(sat => {
// Plot satellites
satellites.forEach(sat => {
if (sat.elevation == null || sat.azimuth == null) return; if (sat.elevation == null || sat.azimuth == null) return;
const elRad = (90 - sat.elevation) / 90; 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 px = cx + r * elRad * Math.cos(azRad);
const py = cy + r * elRad * Math.sin(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; const dotSize = sat.used ? 6 : 4;
// Draw dot
ctx.beginPath(); ctx.beginPath();
ctx.arc(px, py, dotSize, 0, Math.PI * 2); ctx.arc(px, py, dotSize, 0, Math.PI * 2);
if (sat.used) { if (sat.used) {
@@ -299,14 +347,12 @@ const GPS = (function() {
ctx.stroke(); ctx.stroke();
} }
// PRN label
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.font = '8px Roboto Condensed, monospace'; ctx.font = '8px Roboto Condensed, monospace';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'bottom'; ctx.textBaseline = 'bottom';
ctx.fillText(sat.prn, px, py - dotSize - 2); ctx.fillText(sat.prn, px, py - dotSize - 2);
// SNR value
if (sat.snr != null) { if (sat.snr != null) {
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '7px Roboto Condensed, monospace'; ctx.font = '7px Roboto Condensed, monospace';
@@ -316,8 +362,10 @@ const GPS = (function() {
}); });
} }
function drawSkyViewBase(canvas) { function drawSkyViewBase2D(canvas) {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width; const w = canvas.width;
const h = canvas.height; const h = canvas.height;
const cx = w / 2; const cx = w / 2;
@@ -332,11 +380,9 @@ const GPS = (function() {
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555'; const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888'; const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
// Background
ctx.fillStyle = bgColor; ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
// Elevation rings (0, 30, 60, 90)
ctx.strokeStyle = gridColor; ctx.strokeStyle = gridColor;
ctx.lineWidth = 0.5; ctx.lineWidth = 0.5;
[90, 60, 30].forEach(el => { [90, 60, 30].forEach(el => {
@@ -344,7 +390,7 @@ const GPS = (function() {
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, gr, 0, Math.PI * 2); ctx.arc(cx, cy, gr, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
// Label
ctx.fillStyle = dimColor; ctx.fillStyle = dimColor;
ctx.font = '9px Roboto Condensed, monospace'; ctx.font = '9px Roboto Condensed, monospace';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
@@ -352,14 +398,12 @@ const GPS = (function() {
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2); ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
}); });
// Horizon circle
ctx.strokeStyle = gridColor; ctx.strokeStyle = gridColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
// Cardinal directions
ctx.fillStyle = secondaryColor; ctx.fillStyle = secondaryColor;
ctx.font = 'bold 11px Roboto Condensed, monospace'; ctx.font = 'bold 11px Roboto Condensed, monospace';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
@@ -369,7 +413,6 @@ const GPS = (function() {
ctx.fillText('E', cx + r + 12, cy); ctx.fillText('E', cx + r + 12, cy);
ctx.fillText('W', cx - r - 12, cy); ctx.fillText('W', cx - r - 12, cy);
// Crosshairs
ctx.strokeStyle = gridColor; ctx.strokeStyle = gridColor;
ctx.lineWidth = 0.5; ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.beginPath();
@@ -379,13 +422,604 @@ const GPS = (function() {
ctx.lineTo(cx + r, cy); ctx.lineTo(cx + r, cy);
ctx.stroke(); ctx.stroke();
// Zenith dot
ctx.fillStyle = dimColor; ctx.fillStyle = dimColor;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, 2, 0, Math.PI * 2); ctx.arc(cx, cy, 2, 0, Math.PI * 2);
ctx.fill(); 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 // Signal Strength Bars
// ======================== // ========================
@@ -442,6 +1076,15 @@ const GPS = (function() {
function destroy() { function destroy() {
unsubscribeFromStream(); unsubscribeFromStream();
stopSkyPolling(); stopSkyPolling();
if (themeObserver) {
themeObserver.disconnect();
themeObserver = null;
}
if (skyRenderer) {
skyRenderer.destroy();
skyRenderer = null;
}
skyRendererInitAttempted = false;
} }
return { return {

File diff suppressed because it is too large Load Diff

View File

@@ -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 0100% for the bar
const pct = Math.round(_norm(rssi, _source) * 100);
if (barEl) barEl.style.width = pct + '%';
// Colour the value by strength
let color, label;
if (pct >= 66) { color = 'var(--accent-green, #00ff88)'; label = 'Strong'; }
else if (pct >= 33) { color = 'var(--accent-cyan, #4aa3ff)'; label = 'Moderate'; }
else { color = '#f59e0b'; label = 'Weak'; }
valEl.style.color = color;
if (barEl) barEl.style.background = color;
if (statusEl) {
statusEl.textContent = _isRecording
? `${label} — recording point every ${_minDist}m`
: `${label} — press Start Recording to begin`;
}
} else {
if (barEl) barEl.style.width = '0%';
valEl.style.color = 'var(--text-dim)';
if (statusEl) statusEl.textContent = 'No signal data received yet';
}
}
function setSource(src) {
_source = src;
if (_active) _startSignalStream();
}
function setMinDist(m) {
_minDist = m;
}
function startRecording() {
_isRecording = true;
_lastLat = null; _lastLng = null;
const startBtn = document.getElementById('rfhmRecordBtn');
const stopBtn = document.getElementById('rfhmStopBtn');
if (startBtn) startBtn.style.display = 'none';
if (stopBtn) { stopBtn.style.display = ''; stopBtn.classList.add('rfhm-recording-pulse'); }
}
function stopRecording() {
_isRecording = false;
const startBtn = document.getElementById('rfhmRecordBtn');
const stopBtn = document.getElementById('rfhmStopBtn');
if (startBtn) startBtn.style.display = '';
if (stopBtn) { stopBtn.style.display = 'none'; stopBtn.classList.remove('rfhm-recording-pulse'); }
}
function clearPoints() {
_heatPoints = [];
if (_heatLayer) {
const el = document.getElementById('rfheatmapMapEl');
if (el && el.offsetWidth > 0 && el.offsetHeight > 0) _heatLayer.setLatLngs([]);
}
_updateCount();
}
function exportGeoJSON() {
const features = _heatPoints.map(([lat, lng, intensity]) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lng, lat] },
properties: { intensity, source: _source },
}));
const geojson = { type: 'FeatureCollection', features };
const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `rf_heatmap_${Date.now()}.geojson`;
a.click();
}
function invalidateMap() {
if (!_map) return;
const el = document.getElementById('rfheatmapMapEl');
if (el && el.offsetWidth > 0 && el.offsetHeight > 0) {
_map.invalidateSize();
}
}
// ── Source lifecycle (start / stop / status) ──────────────────────
async function _checkSourceStatus() {
const src = _source;
let running = false;
let detail = null;
try {
if (src === 'wifi') {
const r = await fetch('/wifi/v2/scan/status');
if (r.ok) { const d = await r.json(); running = !!d.is_scanning; detail = d.interface || null; }
} else if (src === 'bluetooth') {
const r = await fetch('/api/bluetooth/scan/status');
if (r.ok) { const d = await r.json(); running = !!d.is_scanning; }
} else if (src === 'scanner') {
const r = await fetch('/listening/scanner/status');
if (r.ok) { const d = await r.json(); running = !!d.running; }
}
} catch (_) {}
return { running, detail };
}
async function startSource() {
const src = _source;
const btn = document.getElementById('rfhmSourceStartBtn');
const status = document.getElementById('rfhmSourceStatus');
if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; }
try {
let res;
if (src === 'wifi') {
// Try to find a monitor interface from the WiFi status first
let iface = null;
try {
const st = await fetch('/wifi/v2/scan/status');
if (st.ok) { const d = await st.json(); iface = d.interface || null; }
} catch (_) {}
if (!iface) {
// Ask the user to enter an interface name
const entered = prompt('Enter your monitor-mode WiFi interface name (e.g. wlan0mon):');
if (!entered) { _updateSourceStatusUI(); return; }
iface = entered.trim();
}
res = await fetch('/wifi/v2/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface }) });
} else if (src === 'bluetooth') {
res = await fetch('/api/bluetooth/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'auto' }) });
} else if (src === 'scanner') {
const deviceVal = document.getElementById('rfhmDevice')?.value || 'rtlsdr:0';
const [sdrType, idxStr] = deviceVal.includes(':') ? deviceVal.split(':') : ['rtlsdr', '0'];
res = await fetch('/listening/scanner/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ start_freq: 88, end_freq: 108, sdr_type: sdrType, device: parseInt(idxStr) || 0 }) });
}
if (res && res.ok) {
_ownedSource = true;
_startSignalStream();
}
} catch (_) {}
await _updateSourceStatusUI();
}
async function stopSource() {
if (!_ownedSource) return;
try {
if (_source === 'wifi') await fetch('/wifi/v2/scan/stop', { method: 'POST' });
else if (_source === 'bluetooth') await fetch('/api/bluetooth/scan/stop', { method: 'POST' });
else if (_source === 'scanner') await fetch('/listening/scanner/stop', { method: 'POST' });
} catch (_) {}
_ownedSource = false;
await _updateSourceStatusUI();
}
async function _updateSourceStatusUI() {
const { running, detail } = await _checkSourceStatus();
const row = document.getElementById('rfhmSourceStatusRow');
const dotEl = document.getElementById('rfhmSourceDot');
const textEl = document.getElementById('rfhmSourceStatusText');
const startB = document.getElementById('rfhmSourceStartBtn');
const stopB = document.getElementById('rfhmSourceStopBtn');
if (!row) return;
const SOURCE_NAMES = { wifi: 'WiFi Scanner', bluetooth: 'Bluetooth Scanner', scanner: 'SDR Scanner' };
const name = SOURCE_NAMES[_source] || _source;
if (dotEl) dotEl.style.background = running ? 'var(--accent-green)' : 'rgba(255,255,255,0.2)';
if (textEl) textEl.textContent = running
? `${name} running${detail ? ' · ' + detail : ''}`
: `${name} not running`;
if (startB) { startB.style.display = running ? 'none' : ''; startB.disabled = false; startB.textContent = `Start ${name}`; }
if (stopB) stopB.style.display = (running && _ownedSource) ? '' : 'none';
// Auto-subscribe to stream if source just became running
if (running && !_sigSource) _startSignalStream();
}
const SOURCE_HINTS = {
wifi: 'Walk with your device — stronger WiFi signals are plotted brighter on the map.',
bluetooth: 'Walk near Bluetooth devices — signal strength is mapped by RSSI.',
scanner: 'SDR scanner power levels are mapped by GPS position. Start the Listening Post scanner first.',
};
function onSourceChange() {
const src = document.getElementById('rfhmSource')?.value || 'wifi';
const hint = document.getElementById('rfhmSourceHint');
const dg = document.getElementById('rfhmDeviceGroup');
if (hint) hint.textContent = SOURCE_HINTS[src] || '';
if (dg) dg.style.display = src === 'scanner' ? '' : 'none';
_lastSignal = null;
_ownedSource = false;
_updateSignalDisplay(null);
_updateSourceStatusUI();
// Re-subscribe to correct stream
if (_sigSource) { _sigSource.close(); _sigSource = null; }
_startSignalStream();
}
function _loadDevices() {
const sel = document.getElementById('rfhmDevice');
if (!sel) return;
fetch('/devices').then(r => r.json()).then(devices => {
if (!devices || devices.length === 0) {
sel.innerHTML = '<option value="">No SDR devices detected</option>';
return;
}
sel.innerHTML = devices.map(d => {
const label = d.serial ? `${d.name} [${d.serial}]` : d.name;
return `<option value="${d.sdr_type}:${d.index}">${label}</option>`;
}).join('');
}).catch(() => { sel.innerHTML = '<option value="">Could not load devices</option>'; });
}
function _getFallbackPos() {
// Try observer location from localStorage (shared across all map modes)
try {
const stored = localStorage.getItem('observerLocation');
if (stored) {
const p = JSON.parse(stored);
if (p && typeof p.lat === 'number' && typeof p.lon === 'number') {
return { lat: p.lat, lng: p.lon };
}
}
} catch (_) {}
// Try manual coord inputs
const lat = parseFloat(document.getElementById('rfhmManualLat')?.value);
const lng = parseFloat(document.getElementById('rfhmManualLon')?.value);
if (!isNaN(lat) && !isNaN(lng)) return { lat, lng };
return null;
}
function setManualCoords() {
const lat = parseFloat(document.getElementById('rfhmManualLat')?.value);
const lng = parseFloat(document.getElementById('rfhmManualLon')?.value);
if (!isNaN(lat) && !isNaN(lng) && !_gpsPos && _map) {
_map.setView([lat, lng], _map.getZoom(), { animate: false });
}
}
function useObserverLocation() {
try {
const stored = localStorage.getItem('observerLocation');
if (stored) {
const p = JSON.parse(stored);
if (p && typeof p.lat === 'number' && typeof p.lon === 'number') {
const latEl = document.getElementById('rfhmManualLat');
const lonEl = document.getElementById('rfhmManualLon');
if (latEl) latEl.value = p.lat.toFixed(5);
if (lonEl) lonEl.value = p.lon.toFixed(5);
if (_map) _map.setView([p.lat, p.lon], _map.getZoom(), { animate: true });
return;
}
}
} catch (_) {}
}
function init() {
_active = true;
_loadDevices();
onSourceChange();
// Pre-fill manual coords from observer location if available
const fallback = _getFallbackPos();
if (fallback) {
const latEl = document.getElementById('rfhmManualLat');
const lonEl = document.getElementById('rfhmManualLon');
if (latEl && !latEl.value) latEl.value = fallback.lat.toFixed(5);
if (lonEl && !lonEl.value) lonEl.value = fallback.lng.toFixed(5);
}
_updateSignalDisplay(null);
_updateSourceStatusUI();
_ensureLeafletHeat(() => {
setTimeout(() => {
_initMap();
_startGPS();
_startSignalStream();
}, 50);
});
}
function destroy() {
_active = false;
if (_isRecording) stopRecording();
if (_ownedSource) stopSource();
if (_gpsSource) { _gpsSource.close(); _gpsSource = null; }
if (_sigSource) { _sigSource.close(); _sigSource = null; }
}
return { init, destroy, setSource, setMinDist, startRecording, stopRecording, clearPoints, exportGeoJSON, invalidateMap, onSourceChange, setManualCoords, useObserverLocation, startSource, stopSource };
})();
window.RFHeatmap = RFHeatmap;

View File

@@ -269,12 +269,10 @@ const SpyStations = (function() {
*/ */
function tuneToStation(stationId, freqKhz) { function tuneToStation(stationId, freqKhz) {
const freqMhz = freqKhz / 1000; const freqMhz = freqKhz / 1000;
sessionStorage.setItem('tuneFrequency', freqMhz.toString());
// Find the station and determine mode // Find the station and determine mode
const station = stations.find(s => s.id === stationId); const station = stations.find(s => s.id === stationId);
const tuneMode = station ? getModeFromStation(station.mode) : 'usb'; const tuneMode = station ? getModeFromStation(station.mode) : 'usb';
sessionStorage.setItem('tuneMode', tuneMode);
const stationName = station ? station.name : 'Station'; const stationName = station ? station.name : 'Station';
@@ -282,12 +280,18 @@ const SpyStations = (function() {
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')'); showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
} }
// Switch to listening post mode // Switch to spectrum waterfall mode and tune after mode init.
if (typeof selectMode === 'function') { if (typeof switchMode === 'function') {
selectMode('listening'); switchMode('waterfall');
} else if (typeof switchMode === 'function') { } else if (typeof selectMode === 'function') {
switchMode('listening'); 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 * Check if we arrived from another page with a tune request
*/ */
function checkTuneFrequency() { 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-section">
<div class="signal-details-title">How to Listen</div> <div class="signal-details-title">How to Listen</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;"> <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 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. HF frequencies (typically 3-30 MHz) and an appropriate antenna.
</p> </p>

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
/* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */ /* 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 = [ const NETWORK_ONLY_PREFIXES = [
'/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/', '/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/',
'/adsb/', '/ais/', '/acars/', '/aprs/', '/tscm/', '/satellite/', '/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/', '/sstv/', '/weather-sat/', '/subghz/', '/rtlamr/', '/dsc/', '/vdl2/',
'/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/', '/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/',
'/recordings/', '/controller/', '/fingerprint/', '/ops/', '/recordings/', '/controller/', '/ops/',
]; ];
const STATIC_PREFIXES = [ const STATIC_PREFIXES = [

View File

@@ -248,6 +248,10 @@
<div class="display-container"> <div class="display-container">
<div id="radarMap"> <div id="radarMap">
</div> </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>
</div> </div>
@@ -419,6 +423,10 @@
let alertsEnabled = true; let alertsEnabled = true;
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
let soundedAircraft = {}; // Track aircraft we've played detection sound for 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 // Watchlist - persisted to localStorage
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]'); let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
@@ -2610,7 +2618,7 @@ sudo make install</code>
} else { } else {
markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color, iconType, isSelected) }) markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color, iconType, isSelected) })
.addTo(radarMap) .addTo(radarMap)
.on('click', () => selectAircraft(icao)); .on('click', () => selectAircraft(icao, 'map'));
markers[icao].bindTooltip(`${callsign}<br>${alt}`, { markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
permanent: false, direction: 'top', className: 'aircraft-tooltip' permanent: false, direction: 'top', className: 'aircraft-tooltip'
}); });
@@ -2714,7 +2722,7 @@ sudo make install</code>
const div = document.createElement('div'); const div = document.createElement('div');
div.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''} ${isOnWatchlist(ac) ? 'watched' : ''}`; div.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''} ${isOnWatchlist(ac) ? 'watched' : ''}`;
div.setAttribute('data-icao', ac.icao); div.setAttribute('data-icao', ac.icao);
div.onclick = () => selectAircraft(ac.icao); div.onclick = () => selectAircraft(ac.icao, 'panel');
div.innerHTML = buildAircraftItemHTML(ac); div.innerHTML = buildAircraftItemHTML(ac);
fragment.appendChild(div); 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; const prevSelected = selectedIcao;
selectedIcao = icao; selectedIcao = icao;
mapCrosshairRequestId += 1;
if (mapCrosshairFallbackTimer) {
clearTimeout(mapCrosshairFallbackTimer);
mapCrosshairFallbackTimer = null;
}
// Update marker icons for both previous and new selection // Update marker icons for both previous and new selection
[prevSelected, icao].forEach(targetIcao => { [prevSelected, icao].forEach(targetIcao => {
@@ -2811,7 +2849,26 @@ sudo make install</code>
const ac = aircraft[icao]; const ac = aircraft[icao];
if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) { 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() { function initAirband() {
// Check if audio tools are available // Check if audio tools are available
fetch('/listening/tools') fetch('/receiver/tools')
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
const missingTools = []; const missingTools = [];
@@ -3231,7 +3288,7 @@ sudo make install</code>
try { try {
// Start audio on backend // Start audio on backend
const response = await fetch('/listening/audio/start', { const response = await fetch('/receiver/audio/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -3268,7 +3325,7 @@ sudo make install</code>
audioPlayer.load(); audioPlayer.load();
// Connect to stream // 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); console.log('[AIRBAND] Connecting to stream:', streamUrl);
audioPlayer.src = streamUrl; audioPlayer.src = streamUrl;
@@ -3312,7 +3369,7 @@ sudo make install</code>
audioPlayer.pause(); audioPlayer.pause();
audioPlayer.src = ''; audioPlayer.src = '';
fetch('/listening/audio/stop', { method: 'POST' }) fetch('/receiver/audio/stop', { method: 'POST' })
.then(r => r.json()) .then(r => r.json())
.then(() => { .then(() => {
isAirbandPlaying = false; isAirbandPlaying = false;

View File

@@ -18,7 +18,7 @@
document.write('<style id="disclaimer-gate">.welcome-overlay{display:none !important}</style>'); document.write('<style id="disclaimer-gate">.welcome-overlay{display:none !important}</style>');
window._showDisclaimerOnLoad = true; 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 // to prevent flash of welcome screen before JS applies the mode
else if (new URLSearchParams(window.location.search).get('mode')) { else if (new URLSearchParams(window.location.search).get('mode')) {
document.write('<style id="mode-gate">.welcome-overlay{display:none !important}</style>'); 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", subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9",
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4", bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}", spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
waterfall: "{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck10", waterfall: "{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck19"
rfheatmap: "{{ url_for('static', filename='css/modes/rfheatmap.css') }}",
fingerprint: "{{ url_for('static', filename='css/modes/fingerprint.css') }}"
}; };
window.INTERCEPT_MODE_STYLE_LOADED = {}; window.INTERCEPT_MODE_STYLE_LOADED = {};
window.ensureModeStyles = function(mode) { window.ensureModeStyles = function(mode) {
const href = window.INTERCEPT_MODE_STYLE_MAP ? window.INTERCEPT_MODE_STYLE_MAP[mode] : null; const href = window.INTERCEPT_MODE_STYLE_MAP ? window.INTERCEPT_MODE_STYLE_MAP[mode] : null;
if (!href) return; if (!href) return;
if (window.INTERCEPT_MODE_STYLE_LOADED[href]) return; const absHref = new URL(href, window.location.href).href;
window.INTERCEPT_MODE_STYLE_LOADED[href] = true; 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'); const link = document.createElement('link');
link.rel = 'stylesheet'; link.rel = 'stylesheet';
link.href = href; link.href = href;
link.dataset.modeStyle = mode; 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); document.head.appendChild(link);
}; };
</script> </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-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> <span class="mode-name">Meters</span>
</button> </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')"> <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-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> <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-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>
<span class="mode-name">WebSDR</span> <span class="mode-name">WebSDR</span>
</button> </button>
<button class="mode-card mode-card-sm" onclick="selectMode('rfheatmap')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></span>
<span class="mode-name">RF Heatmap</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('fingerprint')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12C2 6.5 6.5 2 12 2a10 10 0 0 1 8 4"/><path d="M5 19.5C5.5 18 6 15 6 12c0-.7.12-1.37.34-2"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/></svg></span>
<span class="mode-name">RF Fingerprint</span>
</button>
</div> </div>
</div> </div>
@@ -622,8 +624,6 @@
{% include 'partials/modes/space-weather.html' %} {% include 'partials/modes/space-weather.html' %}
{% include 'partials/modes/listening-post.html' %}
{% include 'partials/modes/tscm.html' %} {% include 'partials/modes/tscm.html' %}
{% include 'partials/modes/ais.html' %} {% include 'partials/modes/ais.html' %}
@@ -638,8 +638,6 @@
{% include 'partials/modes/bt_locate.html' %} {% include 'partials/modes/bt_locate.html' %}
{% include 'partials/modes/waterfall.html' %} {% include 'partials/modes/waterfall.html' %}
{% include 'partials/modes/rfheatmap.html' %}
{% include 'partials/modes/fingerprint.html' %}
@@ -1193,9 +1191,11 @@
<!-- Sky View Polar Plot --> <!-- Sky View Polar Plot -->
<div class="gps-skyview-panel"> <div class="gps-skyview-panel">
<h4>Satellite Sky View</h4> <h4>Satellite Sky View</h4>
<div class="gps-skyview-canvas-wrap"> <div class="gps-skyview-canvas-wrap" id="gpsSkyViewWrap">
<canvas id="gpsSkyCanvas" width="400" height="400"></canvas> <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>
<div class="gps-sky-hint">Drag to orbit | Scroll to zoom</div>
<div class="gps-legend"> <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:#00d4ff;"></span> GPS</div>
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00ff88;"></span> GLONASS</div> <div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00ff88;"></span> GLONASS</div>
@@ -1248,442 +1248,6 @@
</div> </div>
</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: &#177;0.05/0.1 MHz&#10;Shift+Arrow: &#177;0.005/1 MHz">
&#9000; 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) --> <!-- Satellite Dashboard (Embedded) -->
<div id="satelliteVisuals" class="satellite-dashboard-embed" style="display: none;"> <div id="satelliteVisuals" class="satellite-dashboard-embed" style="display: none;">
<iframe id="satelliteDashboardFrame" src="/satellite/dashboard?embedded=true" frameborder="0" <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(1)" title="Step up"></button>
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(10)" title="Step up ×10">»</button> <button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(10)" title="Step up ×10">»</button>
<div class="wf-freq-bar-sep"></div> <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> <span class="wf-freq-bar-label">STEP</span>
<select id="wfStepSize" class="wf-step-select"> <select id="wfStepSize" class="wf-step-select">
<option value="0.001">1 kHz</option> <option value="0.001">1 kHz</option>
@@ -3224,6 +2792,8 @@
<div class="wf-tune-line" id="wfTuneLineSpec"></div> <div class="wf-tune-line" id="wfTuneLineSpec"></div>
</div> </div>
<div class="wf-band-strip" id="wfBandStrip"></div>
<!-- Drag handle to resize spectrum vs waterfall --> <!-- Drag handle to resize spectrum vs waterfall -->
<div class="wf-resize-handle" id="wfResizeHandle"> <div class="wf-resize-handle" id="wfResizeHandle">
<div class="wf-resize-grip"></div> <div class="wf-resize-grip"></div>
@@ -3241,20 +2811,6 @@
</div> </div>
</div> </div>
<!-- RF Heatmap Visuals -->
<div id="rfheatmapVisuals" style="display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden;">
<div class="rfhm-map-container" style="flex: 1; min-height: 0; position: relative;">
<div id="rfheatmapMapEl" style="width: 100%; height: 100%;"></div>
</div>
</div>
<!-- Fingerprint Visuals -->
<div id="fingerprintVisuals" style="display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; padding: 10px; gap: 10px;">
<div class="fp-chart-container" style="flex: 1; min-height: 200px;">
<canvas id="fpChartCanvas"></canvas>
</div>
</div>
<!-- Device Intelligence Dashboard (above waterfall for prominence) --> <!-- Device Intelligence Dashboard (above waterfall for prominence) -->
<div class="recon-panel collapsed" id="reconPanel"> <div class="recon-panel collapsed" id="reconPanel">
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;"> <div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
@@ -3369,7 +2925,6 @@
<!-- WiFi v2 components --> <!-- WiFi v2 components -->
<script src="{{ url_for('static', filename='js/components/channel-chart.js') }}"></script> <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/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/spy-stations.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/meshtastic.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/meshtastic.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/sstv.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/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script> <script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix1"></script> <script src="{{ url_for('static', filename='js/core/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/keyboard-shortcuts.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/cheat-sheets.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/waterfall.js') }}?v={{ version }}&r=wfdeck20"></script>
<script src="{{ url_for('static', filename='js/modes/rfheatmap.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/fingerprint.js') }}"></script>
<script> <script>
// ============================================ // ============================================
@@ -3432,15 +2985,6 @@
collapsed: true collapsed: true
} }
}, },
'listening': {
container: 'listeningPostTimelineContainer',
config: typeof RFTimelineAdapter !== 'undefined' ? RFTimelineAdapter.getListeningPostConfig() : {
title: 'Signal Activity',
mode: 'listening-post',
visualMode: 'enriched',
collapsed: false
}
},
'bluetooth': { 'bluetooth': {
container: 'bluetoothTimelineContainer', container: 'bluetoothTimelineContainer',
config: typeof BluetoothTimelineAdapter !== 'undefined' ? BluetoothTimelineAdapter.getBluetoothConfig() : { config: typeof BluetoothTimelineAdapter !== 'undefined' ? BluetoothTimelineAdapter.getBluetoothConfig() : {
@@ -3496,7 +3040,11 @@
} }
// Selected mode from welcome screen // 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 // Mode selection from welcome page
function selectMode(mode) { function selectMode(mode) {
@@ -3522,7 +3070,6 @@
pager: { label: 'Pager', indicator: 'PAGER', outputTitle: 'Pager Decoder', group: 'signals' }, pager: { label: 'Pager', indicator: 'PAGER', outputTitle: 'Pager Decoder', group: 'signals' },
sensor: { label: '433MHz', indicator: '433MHZ', outputTitle: '433MHz Sensor Monitor', group: 'signals' }, sensor: { label: '433MHz', indicator: '433MHZ', outputTitle: '433MHz Sensor Monitor', group: 'signals' },
rtlamr: { label: 'Meters', indicator: 'METERS', outputTitle: 'Utility Meter 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' }, subghz: { label: 'SubGHz', indicator: 'SUBGHZ', outputTitle: 'SubGHz Transceiver', group: 'signals' },
aprs: { label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking' }, aprs: { label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking' },
gps: { label: 'GPS', indicator: 'GPS', outputTitle: 'GPS Receiver', 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' }, spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' }, websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' }, waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
rfheatmap: { label: 'RF Heatmap', indicator: 'RF HEATMAP', outputTitle: 'RF Signal Heatmap', group: 'intel' },
fingerprint: { label: 'Fingerprint', indicator: 'RF FINGERPRINT', outputTitle: 'Signal Fingerprinting', group: 'intel' },
}; };
const validModes = new Set(Object.keys(modeCatalog)); const validModes = new Set(Object.keys(modeCatalog));
window.interceptModeCatalog = Object.assign({}, modeCatalog); window.interceptModeCatalog = Object.assign({}, modeCatalog);
function getModeFromQuery() { function getModeFromQuery() {
const params = new URLSearchParams(window.location.search); 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; if (!mode || !validModes.has(mode)) return null;
return mode; return mode;
} }
@@ -4059,6 +3605,8 @@
// Mode switching // Mode switching
function switchMode(mode, options = {}) { function switchMode(mode, options = {}) {
const { updateUrl = true } = 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) // Only stop local scans if in local mode (not agent mode)
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
if (!isAgentMode) { if (!isAgentMode) {
@@ -4122,7 +3670,6 @@
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi'); document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth'); document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
document.getElementById('btLocateMode')?.classList.toggle('active', mode === 'bt_locate'); 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('aprsMode')?.classList.toggle('active', mode === 'aprs');
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm'); document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais'); document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
@@ -4132,8 +3679,6 @@
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz'); document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather'); document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall'); document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall');
document.getElementById('rfheatmapMode')?.classList.toggle('active', mode === 'rfheatmap');
document.getElementById('fingerprintMode')?.classList.toggle('active', mode === 'fingerprint');
const pagerStats = document.getElementById('pagerStats'); const pagerStats = document.getElementById('pagerStats');
@@ -4161,7 +3706,6 @@
const wifiLayoutContainer = document.getElementById('wifiLayoutContainer'); const wifiLayoutContainer = document.getElementById('wifiLayoutContainer');
const btLayoutContainer = document.getElementById('btLayoutContainer'); const btLayoutContainer = document.getElementById('btLayoutContainer');
const satelliteVisuals = document.getElementById('satelliteVisuals'); const satelliteVisuals = document.getElementById('satelliteVisuals');
const listeningPostVisuals = document.getElementById('listeningPostVisuals');
const aprsVisuals = document.getElementById('aprsVisuals'); const aprsVisuals = document.getElementById('aprsVisuals');
const tscmVisuals = document.getElementById('tscmVisuals'); const tscmVisuals = document.getElementById('tscmVisuals');
const spyStationsVisuals = document.getElementById('spyStationsVisuals'); const spyStationsVisuals = document.getElementById('spyStationsVisuals');
@@ -4175,12 +3719,9 @@
const btLocateVisuals = document.getElementById('btLocateVisuals'); const btLocateVisuals = document.getElementById('btLocateVisuals');
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals'); const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
const waterfallVisuals = document.getElementById('waterfallVisuals'); const waterfallVisuals = document.getElementById('waterfallVisuals');
const rfheatmapVisuals = document.getElementById('rfheatmapVisuals');
const fingerprintVisuals = document.getElementById('fingerprintVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none'; if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none'; if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none'; if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
if (listeningPostVisuals) listeningPostVisuals.style.display = mode === 'listening' ? 'grid' : 'none';
if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none'; if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none';
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none'; if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? '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 (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none'; if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none'; if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
if (waterfallVisuals) waterfallVisuals.style.display = (mode === 'waterfall' || mode === 'listening') ? 'flex' : 'none'; if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
if (rfheatmapVisuals) rfheatmapVisuals.style.display = mode === 'rfheatmap' ? 'flex' : 'none';
if (fingerprintVisuals) fingerprintVisuals.style.display = mode === 'fingerprint' ? 'flex' : 'none';
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers. // Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) { if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
@@ -4245,7 +3784,7 @@
const reconBtn = document.getElementById('reconBtn'); const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]'); const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel'); const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === '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 (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none'; if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none'; if (intelBtn) intelBtn.style.display = 'none';
@@ -4260,19 +3799,12 @@
// Show agent selector for modes that support remote agents // Show agent selector for modes that support remote agents
const agentSection = document.getElementById('agentSection'); const agentSection = document.getElementById('agentSection');
const agentModes = ['pager', 'sensor', 'rtlamr', 'listening', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais']; const agentModes = ['pager', 'sensor', 'rtlamr', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais'];
if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none'; if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none';
// Show RTL-SDR device section for modes that use it // Show RTL-SDR device section for modes that use it
const rtlDeviceSection = document.getElementById('rtlDeviceSection'); 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'; if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || 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';
}
// Toggle mode-specific tool status displays // Toggle mode-specific tool status displays
const toolStatusPager = document.getElementById('toolStatusPager'); const toolStatusPager = document.getElementById('toolStatusPager');
@@ -4283,7 +3815,7 @@
// Hide output console for modes with their own visualizations // Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output'); const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar'); const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === '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'; if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it) // Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
@@ -4316,12 +3848,6 @@
} else if (mode === 'satellite') { } else if (mode === 'satellite') {
initPolarPlot(); initPolarPlot();
initSatelliteList(); 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') { } else if (mode === 'spystations') {
SpyStations.init(); SpyStations.init();
} else if (mode === 'meshtastic') { } else if (mode === 'meshtastic') {
@@ -4360,17 +3886,10 @@
SpaceWeather.init(); SpaceWeather.init();
} else if (mode === 'waterfall') { } else if (mode === 'waterfall') {
if (typeof Waterfall !== 'undefined') Waterfall.init(); 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 // 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(() => {}); 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 // TSCM (Counter-Surveillance) Functions
@@ -12376,7 +11895,7 @@
</button> </button>
</div> </div>
<div style="font-size: 10px; color: var(--text-secondary); margin-top: 8px;"> <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>
</div> </div>
`; `;
@@ -13154,18 +12673,18 @@
// Close the modal // Close the modal
closeTscmDeviceModal(); closeTscmDeviceModal();
// Switch to listening post mode // Switch to spectrum waterfall mode
switchMode('listening'); switchMode('waterfall');
// Wait a moment for the mode to switch, then tune to the frequency // Wait a moment for the mode to switch, then tune to the frequency
setTimeout(() => { setTimeout(() => {
if (typeof tuneToFrequency === 'function') { if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') {
tuneToFrequency(frequency, modulation); Waterfall.quickTune(frequency, modulation);
} else { } else {
// Fallback: manually update the frequency input // Fallback: update Waterfall center control directly
const freqInput = document.getElementById('radioScanStart'); const freqInput = document.getElementById('wfCenterFreq');
if (freqInput) { if (freqInput) {
freqInput.value = frequency.toFixed(1); freqInput.value = frequency.toFixed(4);
} }
alert(`Tune to ${frequency.toFixed(3)} MHz (${modulation.toUpperCase()}) to listen`); alert(`Tune to ${frequency.toFixed(3)} MHz (${modulation.toUpperCase()}) to listen`);
} }
@@ -15107,8 +14626,6 @@
}); });
</script> </script>
<!-- Scanner/Audio code moved to static/js/modes/listening-post.js -->
{% include 'partials/help-modal.html' %} {% include 'partials/help-modal.html' %}
@@ -15317,8 +14834,6 @@
<table style="width:100%; border-collapse:collapse; font-family:var(--font-mono); font-size:12px;"> <table style="width:100%; border-collapse:collapse; font-family:var(--font-mono); font-size:12px;">
<tbody> <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+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+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+S</td><td style="padding:6px 8px; color:var(--text-secondary);">Toggle sidebar</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+K / ?</td><td style="padding:6px 8px; color:var(--text-secondary);">Show keyboard shortcuts</td></tr> <tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+K / ?</td><td style="padding:6px 8px; color:var(--text-secondary);">Show keyboard shortcuts</td></tr>

View File

@@ -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 &amp; 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="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 &amp; 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 &amp; 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="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 &amp; 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"><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"><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"/><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> <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> <li>Interactive map shows station positions in real-time</li>
</ul> </ul>
<h3>Listening Post Mode</h3> <h3>Spectrum Waterfall Mode</h3>
<ul class="tip-list"> <ul class="tip-list">
<li>Wideband SDR scanner with spectrum visualization</li> <li>Wideband SDR scanner with spectrum visualization</li>
<li>Tune to any frequency supported by your SDR hardware</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>Browse stations from priyom.org with frequencies and schedules</li>
<li>Filter by type (number/diplomatic), country, and mode</li> <li>Filter by type (number/diplomatic), country, and mode</li>
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</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> </ul>
<h3>Meshtastic Mode</h3> <h3>Meshtastic Mode</h3>
@@ -330,7 +330,7 @@
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li> <li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li> <li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</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>Spy Stations:</strong> Internet connection (database lookup)</li>
<li><strong>Meshtastic:</strong> Meshtastic LoRa device, <code>pip install meshtastic</code></li> <li><strong>Meshtastic:</strong> Meshtastic LoRa device, <code>pip install meshtastic</code></li>
<li><strong>WebSDR:</strong> Internet connection (remote receivers)</li> <li><strong>WebSDR:</strong> Internet connection (remote receivers)</li>

View File

@@ -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 &amp; Save</button>
</div>
</div>
<!-- Compare tab -->
<div id="fpComparePanel" style="display:none;">
<div class="section">
<h3>How It Works</h3>
<div style="font-size:11px; color:var(--text-dim); line-height:1.6;">
<div style="display:flex; gap:8px; align-items:flex-start; margin-bottom:6px;">
<span style="color:var(--accent-cyan); font-weight:700; flex-shrink:0;">1.</span>
<span>Ensure the scanner is running (switch to Record tab to start it).</span>
</div>
<div style="display:flex; gap:8px; align-items:flex-start; margin-bottom:6px;">
<span style="color:var(--accent-cyan); font-weight:700; flex-shrink:0;">2.</span>
<span>Select a previously recorded baseline below.</span>
</div>
<div style="display:flex; gap:8px; align-items:flex-start; margin-bottom:6px;">
<span style="color:var(--accent-cyan); font-weight:700; flex-shrink:0;">3.</span>
<span>Click <strong style="color:var(--text-secondary);">Compare Now</strong> — a 3-second live scan is collected.</span>
</div>
<div style="display:flex; gap:8px; align-items:flex-start;">
<span style="color:var(--accent-cyan); font-weight:700; flex-shrink:0;">4.</span>
<span>Anomalies are scored by z-score. <span style="color:#ef4444;">Red = strong deviation</span>, <span style="color:#a855f7;">purple = new signal</span>.</span>
</div>
</div>
</div>
<div class="section">
<h3>Baseline</h3>
<div class="form-group">
<label>Session</label>
<select id="fpBaselineSelect">
<option value="">No baselines saved yet</option>
</select>
</div>
<div id="fpCompareStatus" style="font-size:11px; color:var(--text-dim); margin-bottom:6px; min-height:14px;"></div>
<button class="run-btn" onclick="Fingerprint.compareNow()">Compare Now</button>
</div>
<div class="section" id="fpAnomalyList" style="display:none;">
<h3>Anomalies</h3>
<div id="fpAnomalyItems"></div>
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -1,10 +1,36 @@
<!-- WATERFALL MODE --> <!-- WATERFALL MODE -->
<div id="waterfallMode" class="mode-content"> <div id="waterfallMode" class="mode-content wf-side">
<div class="section"> <div class="section wf-side-hero">
<h3>Spectrum Waterfall</h3> <div class="wf-side-hero-title-row">
<div style="font-size:11px; color:var(--text-secondary); line-height:1.45;"> <div class="wf-side-hero-title">Spectrum Waterfall</div>
Click spectrum or waterfall to tune. Scroll to step-tune. Ctrl/Cmd + scroll to zoom span. <div class="wf-side-chip" id="wfHeroVisualStatus">CONNECTING</div>
</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>
<div class="section"> <div class="section">
@@ -15,18 +41,18 @@
<option value="">Loading devices...</option> <option value="">Loading devices...</option>
</select> </select>
</div> </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 id="wfDeviceInfo" class="wf-side-box" style="display:none;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;"> <div class="wf-side-kv">
<span style="color:var(--text-muted); text-transform:uppercase; font-size:10px; letter-spacing:.05em;">Type</span> <span class="wf-side-kv-label">Type</span>
<span id="wfDeviceType" style="color:var(--accent-cyan); font-family:var(--font-mono);">--</span> <span id="wfDeviceType" class="wf-side-kv-value">--</span>
</div> </div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;"> <div class="wf-side-kv">
<span style="color:var(--text-muted); text-transform:uppercase; font-size:10px; letter-spacing:.05em;">Range</span> <span class="wf-side-kv-label">Range</span>
<span id="wfDeviceRange" style="color:var(--text-secondary); font-family:var(--font-mono);">--</span> <span id="wfDeviceRange" class="wf-side-kv-value">--</span>
</div> </div>
<div style="display:flex; justify-content:space-between; align-items:center;"> <div class="wf-side-kv">
<span style="color:var(--text-muted); text-transform:uppercase; font-size:10px; letter-spacing:.05em;">Capture SR</span> <span class="wf-side-kv-label">Capture SR</span>
<span id="wfDeviceBw" style="color:var(--text-secondary); font-family:var(--font-mono);">--</span> <span id="wfDeviceBw" class="wf-side-kv-value">--</span>
</div> </div>
</div> </div>
</div> </div>
@@ -41,7 +67,7 @@
<label>Span (MHz)</label> <label>Span (MHz)</label>
<input type="number" id="wfSpanMhz" value="2.4" step="0.1" min="0.05" max="30"> <input type="number" id="wfSpanMhz" value="2.4" step="0.1" min="0.05" max="30">
</div> </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('fm')">FM Broadcast</button>
<button class="preset-btn" onclick="Waterfall.applyPreset('air')">Airband</button> <button class="preset-btn" onclick="Waterfall.applyPreset('air')">Airband</button>
<button class="preset-btn" onclick="Waterfall.applyPreset('marine')">Marine</button> <button class="preset-btn" onclick="Waterfall.applyPreset('marine')">Marine</button>
@@ -49,10 +75,178 @@
</div> </div>
</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"> <div class="section">
<h3>Capture</h3> <h3>Capture</h3>
<div class="form-group"> <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"> <input type="text" id="wfGain" value="AUTO" placeholder="AUTO or numeric">
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -88,7 +282,7 @@
<label>PPM Correction</label> <label>PPM Correction</label>
<input type="number" id="wfPpm" value="0" step="1" min="-200" max="200" placeholder="0"> <input type="number" id="wfPpm" value="0" step="1" min="-200" max="200" placeholder="0">
</div> </div>
<div class="checkbox-group" style="margin-top:8px;"> <div class="checkbox-group wf-scan-checkboxes">
<label> <label>
<input type="checkbox" id="wfBiasT"> <input type="checkbox" id="wfBiasT">
Bias-T (antenna power) Bias-T (antenna power)
@@ -115,7 +309,7 @@
<label>Ceiling (dB)</label> <label>Ceiling (dB)</label>
<input type="number" id="wfDbMax" value="-20" step="5" disabled> <input type="number" id="wfDbMax" value="-20" step="5" disabled>
</div> </div>
<div class="checkbox-group" style="margin-top:8px;"> <div class="checkbox-group wf-scan-checkboxes">
<label> <label>
<input type="checkbox" id="wfPeakHold" onchange="Waterfall.togglePeakHold(this.checked)"> <input type="checkbox" id="wfPeakHold" onchange="Waterfall.togglePeakHold(this.checked)">
Peak Hold Peak Hold
@@ -130,13 +324,4 @@
</label> </label>
</div> </div>
</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> </div>

View File

@@ -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('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('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('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('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>') }} {{ mode_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg>') }}
</div> </div>
@@ -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('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('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }} {{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mode_item('rfheatmap', 'RF Heatmap', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/><path d="M2 10h4M18 10h4" opacity="0.4"/></svg>') }}
{{ mode_item('fingerprint', 'RF Fingerprint', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12C2 6.5 6.5 2 12 2a10 10 0 0 1 8 4"/><path d="M5 19.5C5.5 18 6 15 6 12c0-.7.12-1.37.34-2"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/></svg>') }}
</div> </div>
</div> </div>
@@ -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('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('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('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>') }} {{ 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 #} {# 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') }} {{ 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>') }} {{ 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 #} {# 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('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h4l3-8 3 16 3-8h4"/></svg>') }}
{{ mobile_item('rfheatmap', 'RF Map', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>') }}
{{ mobile_item('fingerprint', 'Fprint', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12C2 6.5 6.5 2 12 2a10 10 0 0 1 8 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/></svg>') }}
</nav> </nav>
{# JavaScript stub for pages that don't have switchMode defined #} {# JavaScript stub for pages that don't have switchMode defined #}

View File

@@ -484,7 +484,6 @@
<option value="tscm">TSCM</option> <option value="tscm">TSCM</option>
<option value="sstv">SSTV</option> <option value="sstv">SSTV</option>
<option value="sstv_general">SSTV General</option> <option value="sstv_general">SSTV General</option>
<option value="listening_scanner">Listening Post</option>
<option value="waterfall">Waterfall</option> <option value="waterfall">Waterfall</option>
</select> </select>
</div> </div>

View 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'

View File

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