mirror of
https://github.com/smittix/intercept.git
synced 2026-07-05 16:18:12 -07:00
Merge upstream/main: add gsm_spy blueprint
This commit is contained in:
+31
@@ -9,6 +9,9 @@ LABEL description="Signal Intelligence Platform for SDR monitoring"
|
|||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Pre-accept tshark non-root capture prompt for non-interactive install
|
||||||
|
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
|
||||||
|
|
||||||
# Install system dependencies for SDR tools
|
# Install system dependencies for SDR tools
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
# RTL-SDR tools
|
# RTL-SDR tools
|
||||||
@@ -54,11 +57,39 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
airspy \
|
airspy \
|
||||||
limesuite \
|
limesuite \
|
||||||
hackrf \
|
hackrf \
|
||||||
|
# GSM Intelligence (tshark for packet parsing)
|
||||||
|
tshark \
|
||||||
# Utilities
|
# Utilities
|
||||||
curl \
|
curl \
|
||||||
procps \
|
procps \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# GSM Intelligence: gr-gsm (grgsm_scanner, grgsm_livemon)
|
||||||
|
# Install from apt if available, otherwise build from source
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
gnuradio gr-osmosdr gr-gsm 2>/dev/null \
|
||||||
|
|| ( \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
gnuradio gnuradio-dev gr-osmosdr \
|
||||||
|
git cmake libboost-all-dev libcppunit-dev swig \
|
||||||
|
doxygen liblog4cpp5-dev python3-scipy python3-numpy \
|
||||||
|
libvolk-dev libfftw3-dev build-essential \
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/bkerler/gr-gsm.git \
|
||||||
|
&& cd gr-gsm \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make -j$(nproc) \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& rm -rf /tmp/gr-gsm \
|
||||||
|
&& apt-get remove -y gnuradio-dev libcppunit-dev swig doxygen \
|
||||||
|
liblog4cpp5-dev libvolk-dev build-essential git cmake \
|
||||||
|
&& apt-get autoremove -y \
|
||||||
|
) \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Build dump1090-fa and acarsdec from source (packages not available in slim repos)
|
# Build dump1090-fa and acarsdec from source (packages not available in slim repos)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from utils.constants import (
|
|||||||
MAX_VESSEL_AGE_SECONDS,
|
MAX_VESSEL_AGE_SECONDS,
|
||||||
MAX_DSC_MESSAGE_AGE_SECONDS,
|
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||||
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
||||||
|
MAX_GSM_AGE_SECONDS,
|
||||||
QUEUE_MAX_SIZE,
|
QUEUE_MAX_SIZE,
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
@@ -191,6 +192,16 @@ deauth_detector = None
|
|||||||
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
deauth_detector_lock = threading.Lock()
|
deauth_detector_lock = threading.Lock()
|
||||||
|
|
||||||
|
# GSM Spy
|
||||||
|
gsm_spy_scanner_running = False # Flag: scanner thread active
|
||||||
|
gsm_spy_livemon_process = None # For grgsm_livemon process
|
||||||
|
gsm_spy_monitor_process = None # For tshark monitoring process
|
||||||
|
gsm_spy_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
gsm_spy_lock = threading.Lock()
|
||||||
|
gsm_spy_active_device = None
|
||||||
|
gsm_spy_selected_arfcn = None
|
||||||
|
gsm_spy_region = 'Americas' # Default band
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# GLOBAL STATE DICTIONARIES
|
# GLOBAL STATE DICTIONARIES
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -223,6 +234,16 @@ dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_
|
|||||||
# Deauth alerts - using DataStore for automatic cleanup
|
# Deauth alerts - using DataStore for automatic cleanup
|
||||||
deauth_alerts = DataStore(max_age_seconds=MAX_DEAUTH_ALERTS_AGE_SECONDS, name='deauth_alerts')
|
deauth_alerts = DataStore(max_age_seconds=MAX_DEAUTH_ALERTS_AGE_SECONDS, name='deauth_alerts')
|
||||||
|
|
||||||
|
# GSM Spy data stores
|
||||||
|
gsm_spy_towers = DataStore(
|
||||||
|
max_age_seconds=MAX_GSM_AGE_SECONDS,
|
||||||
|
name='gsm_spy_towers'
|
||||||
|
)
|
||||||
|
gsm_spy_devices = DataStore(
|
||||||
|
max_age_seconds=MAX_GSM_AGE_SECONDS,
|
||||||
|
name='gsm_spy_devices'
|
||||||
|
)
|
||||||
|
|
||||||
# Satellite state
|
# Satellite state
|
||||||
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
||||||
|
|
||||||
@@ -235,6 +256,8 @@ cleanup_manager.register(adsb_aircraft)
|
|||||||
cleanup_manager.register(ais_vessels)
|
cleanup_manager.register(ais_vessels)
|
||||||
cleanup_manager.register(dsc_messages)
|
cleanup_manager.register(dsc_messages)
|
||||||
cleanup_manager.register(deauth_alerts)
|
cleanup_manager.register(deauth_alerts)
|
||||||
|
cleanup_manager.register(gsm_spy_towers)
|
||||||
|
cleanup_manager.register(gsm_spy_devices)
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# SDR DEVICE REGISTRY
|
# SDR DEVICE REGISTRY
|
||||||
@@ -296,6 +319,10 @@ def require_login():
|
|||||||
if request.path.startswith('/listening/audio/'):
|
if request.path.startswith('/listening/audio/'):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Allow WebSocket upgrade requests (page load already required auth)
|
||||||
|
if request.path.startswith('/ws/'):
|
||||||
|
return None
|
||||||
|
|
||||||
# Controller API endpoints use API key auth, not session auth
|
# Controller API endpoints use API key auth, not session auth
|
||||||
# Allow agent push/pull endpoints without session login
|
# Allow agent push/pull endpoints without session login
|
||||||
if request.path.startswith('/controller/'):
|
if request.path.startswith('/controller/'):
|
||||||
@@ -666,6 +693,8 @@ def kill_all() -> Response:
|
|||||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||||
global dmr_process, dmr_rtl_process
|
global dmr_process, dmr_rtl_process
|
||||||
|
global gsm_spy_livemon_process, gsm_spy_monitor_process
|
||||||
|
global gsm_spy_scanner_running, gsm_spy_active_device, gsm_spy_selected_arfcn, gsm_spy_region
|
||||||
|
|
||||||
# Import adsb and ais modules to reset their state
|
# Import adsb and ais modules to reset their state
|
||||||
from routes import adsb as adsb_module
|
from routes import adsb as adsb_module
|
||||||
@@ -679,6 +708,7 @@ def kill_all() -> Response:
|
|||||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
||||||
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
|
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
|
||||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||||
|
'grgsm_scanner', 'grgsm_livemon', 'tshark'
|
||||||
]
|
]
|
||||||
|
|
||||||
for proc in processes_to_kill:
|
for proc in processes_to_kill:
|
||||||
@@ -743,10 +773,33 @@ def kill_all() -> Response:
|
|||||||
# Reset Bluetooth v2 scanner
|
# Reset Bluetooth v2 scanner
|
||||||
try:
|
try:
|
||||||
reset_bluetooth_scanner()
|
reset_bluetooth_scanner()
|
||||||
killed.append('bluetooth_scanner')
|
killed.append('bluetooth')
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Reset GSM Spy state
|
||||||
|
with gsm_spy_lock:
|
||||||
|
gsm_spy_scanner_running = False
|
||||||
|
gsm_spy_active_device = None
|
||||||
|
gsm_spy_selected_arfcn = None
|
||||||
|
gsm_spy_region = 'Americas'
|
||||||
|
|
||||||
|
if gsm_spy_livemon_process:
|
||||||
|
try:
|
||||||
|
if safe_terminate(gsm_spy_livemon_process):
|
||||||
|
killed.append('grgsm_livemon')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
gsm_spy_livemon_process = None
|
||||||
|
|
||||||
|
if gsm_spy_monitor_process:
|
||||||
|
try:
|
||||||
|
if safe_terminate(gsm_spy_monitor_process):
|
||||||
|
killed.append('tshark')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
gsm_spy_monitor_process = None
|
||||||
|
|
||||||
# Clear SDR device registry
|
# Clear SDR device registry
|
||||||
with sdr_device_registry_lock:
|
with sdr_device_registry_lock:
|
||||||
sdr_device_registry.clear()
|
sdr_device_registry.clear()
|
||||||
@@ -836,6 +889,26 @@ def main() -> None:
|
|||||||
from utils.database import init_db
|
from utils.database import init_db
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
# Register database cleanup functions
|
||||||
|
from utils.database import (
|
||||||
|
cleanup_old_gsm_signals,
|
||||||
|
cleanup_old_gsm_tmsi_log,
|
||||||
|
cleanup_old_gsm_velocity_log,
|
||||||
|
cleanup_old_signal_history,
|
||||||
|
cleanup_old_timeline_entries,
|
||||||
|
cleanup_old_dsc_alerts,
|
||||||
|
cleanup_old_payloads
|
||||||
|
)
|
||||||
|
# GSM cleanups: signals (60 days), TMSI log (24 hours), velocity (1 hour)
|
||||||
|
# Interval multiplier: cleanup every N cycles (60s interval = 1 cleanup per hour at multiplier 60)
|
||||||
|
cleanup_manager.register_db_cleanup(cleanup_old_gsm_tmsi_log, interval_multiplier=60) # Every hour
|
||||||
|
cleanup_manager.register_db_cleanup(cleanup_old_gsm_velocity_log, interval_multiplier=60) # Every hour
|
||||||
|
cleanup_manager.register_db_cleanup(cleanup_old_gsm_signals, interval_multiplier=1440) # Every 24 hours
|
||||||
|
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440) # Every 24 hours
|
||||||
|
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440) # Every 24 hours
|
||||||
|
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440) # Every 24 hours
|
||||||
|
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440) # Every 24 hours
|
||||||
|
|
||||||
# Start automatic cleanup of stale data entries
|
# Start automatic cleanup of stale data entries
|
||||||
cleanup_manager.start()
|
cleanup_manager.start()
|
||||||
|
|
||||||
@@ -875,6 +948,14 @@ def main() -> None:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"KiwiSDR audio proxy disabled: {e}")
|
print(f"KiwiSDR audio proxy disabled: {e}")
|
||||||
|
|
||||||
|
# Initialize WebSocket for waterfall streaming
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import init_waterfall_websocket
|
||||||
|
init_waterfall_websocket(app)
|
||||||
|
print("WebSocket waterfall streaming enabled")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"WebSocket waterfall disabled: {e}")
|
||||||
|
|
||||||
print(f"Open http://localhost:{args.port} in your browser")
|
print(f"Open http://localhost:{args.port} in your browser")
|
||||||
print()
|
print()
|
||||||
print("Press Ctrl+C to stop")
|
print("Press Ctrl+C to stop")
|
||||||
|
|||||||
@@ -228,6 +228,12 @@ ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
|
|||||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||||
|
|
||||||
|
# GSM Spy settings
|
||||||
|
GSM_OPENCELLID_API_KEY = _get_env('GSM_OPENCELLID_API_KEY', '')
|
||||||
|
GSM_OPENCELLID_API_URL = _get_env('GSM_OPENCELLID_API_URL', 'https://opencellid.org/cell/get')
|
||||||
|
GSM_API_DAILY_LIMIT = _get_env_int('GSM_API_DAILY_LIMIT', 1000)
|
||||||
|
GSM_TA_METERS_PER_UNIT = _get_env_int('GSM_TA_METERS_PER_UNIT', 554)
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
"""Configure application logging."""
|
"""Configure application logging."""
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ def register_blueprints(app):
|
|||||||
from .websdr import websdr_bp
|
from .websdr import websdr_bp
|
||||||
from .alerts import alerts_bp
|
from .alerts import alerts_bp
|
||||||
from .recordings import recordings_bp
|
from .recordings import recordings_bp
|
||||||
|
from .gsm_spy import gsm_spy_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -63,6 +64,7 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
||||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||||
app.register_blueprint(recordings_bp) # Session recordings
|
app.register_blueprint(recordings_bp) # Session recordings
|
||||||
|
app.register_blueprint(gsm_spy_bp) # GSM cellular intelligence
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""WebSocket-based audio streaming for SDR."""
|
"""WebSocket-based audio streaming for SDR."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import shutil
|
|
||||||
import json
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
# Try to import flask-sock
|
# Try to import flask-sock
|
||||||
@@ -251,4 +252,19 @@ def init_audio_websocket(app: Flask):
|
|||||||
finally:
|
finally:
|
||||||
with process_lock:
|
with process_lock:
|
||||||
kill_audio_processes()
|
kill_audio_processes()
|
||||||
|
# Complete WebSocket close handshake, then shut down the
|
||||||
|
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||||
|
# on top of the WebSocket stream.
|
||||||
|
try:
|
||||||
|
ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws.sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("WebSocket audio client disconnected")
|
logger.info("WebSocket audio client disconnected")
|
||||||
|
|||||||
+1730
File diff suppressed because it is too large
Load Diff
+281
-272
@@ -19,8 +19,8 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
@@ -1181,13 +1181,13 @@ def stream_scanner_events() -> Response:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
try:
|
||||||
process_event('listening_scanner', msg, msg.get('type'))
|
process_event('listening_scanner', msg, msg.get('type'))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
@@ -1239,10 +1239,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'])
|
@listening_post_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, listening_active_device, scanner_power_process, scanner_thread
|
||||||
|
|
||||||
# Stop scanner if running
|
# Stop scanner if running
|
||||||
if scanner_running:
|
if scanner_running:
|
||||||
@@ -1271,7 +1271,7 @@ def start_audio() -> Response:
|
|||||||
pass
|
pass
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
frequency = float(data.get('frequency', 0))
|
frequency = float(data.get('frequency', 0))
|
||||||
@@ -1286,11 +1286,11 @@ def start_audio() -> Response:
|
|||||||
'message': f'Invalid parameter: {e}'
|
'message': f'Invalid parameter: {e}'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
if frequency <= 0:
|
if frequency <= 0:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'frequency is required'
|
'message': 'frequency is required'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
|
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
|
||||||
if sdr_type not in valid_sdr_types:
|
if sdr_type not in valid_sdr_types:
|
||||||
@@ -1299,19 +1299,28 @@ def start_audio() -> Response:
|
|||||||
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
|
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Update config for audio
|
# Update config for audio
|
||||||
scanner_config['squelch'] = squelch
|
scanner_config['squelch'] = squelch
|
||||||
scanner_config['gain'] = gain
|
scanner_config['gain'] = gain
|
||||||
scanner_config['device'] = device
|
scanner_config['device'] = device
|
||||||
scanner_config['sdr_type'] = sdr_type
|
scanner_config['sdr_type'] = sdr_type
|
||||||
|
|
||||||
# Stop waterfall if it's using the same SDR
|
|
||||||
if waterfall_running and waterfall_active_device == device:
|
|
||||||
_stop_waterfall_internal()
|
|
||||||
time.sleep(0.2)
|
|
||||||
|
|
||||||
# Claim device for listening audio
|
# Stop waterfall if it's using the same SDR (SSE path)
|
||||||
if listening_active_device is None or listening_active_device != device:
|
if waterfall_running and waterfall_active_device == device:
|
||||||
|
_stop_waterfall_internal()
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# Release waterfall device claim if the WebSocket waterfall is still
|
||||||
|
# holding it. The JS client sends a stop command and closes the
|
||||||
|
# WebSocket before requesting audio, but the backend handler may not
|
||||||
|
# have finished its cleanup yet.
|
||||||
|
device_status = app_module.get_sdr_device_status()
|
||||||
|
if device_status.get(device) == 'waterfall':
|
||||||
|
app_module.release_sdr_device(device)
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# Claim device for listening audio
|
||||||
|
if listening_active_device is None or listening_active_device != device:
|
||||||
if listening_active_device is not None:
|
if listening_active_device is not None:
|
||||||
app_module.release_sdr_device(listening_active_device)
|
app_module.release_sdr_device(listening_active_device)
|
||||||
error = app_module.claim_sdr_device(device, 'listening')
|
error = app_module.claim_sdr_device(device, 'listening')
|
||||||
@@ -1524,204 +1533,204 @@ waterfall_thread: Optional[threading.Thread] = None
|
|||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
waterfall_lock = threading.Lock()
|
waterfall_lock = threading.Lock()
|
||||||
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
|
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||||
waterfall_active_device: Optional[int] = None
|
waterfall_active_device: Optional[int] = None
|
||||||
waterfall_config = {
|
waterfall_config = {
|
||||||
'start_freq': 88.0,
|
'start_freq': 88.0,
|
||||||
'end_freq': 108.0,
|
'end_freq': 108.0,
|
||||||
'bin_size': 10000,
|
'bin_size': 10000,
|
||||||
'gain': 40,
|
'gain': 40,
|
||||||
'device': 0,
|
'device': 0,
|
||||||
'max_bins': 1024,
|
'max_bins': 1024,
|
||||||
'interval': 0.4,
|
'interval': 0.4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | None, list[float]]:
|
def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | None, list[float]]:
|
||||||
"""Parse a single rtl_power CSV line into bins."""
|
"""Parse a single rtl_power CSV line into bins."""
|
||||||
if not line or line.startswith('#'):
|
if not line or line.startswith('#'):
|
||||||
return None, None, None, []
|
return None, None, None, []
|
||||||
|
|
||||||
parts = [p.strip() for p in line.split(',')]
|
parts = [p.strip() for p in line.split(',')]
|
||||||
if len(parts) < 6:
|
if len(parts) < 6:
|
||||||
return None, None, None, []
|
return None, None, None, []
|
||||||
|
|
||||||
# Timestamp in first two fields (YYYY-MM-DD, HH:MM:SS)
|
# Timestamp in first two fields (YYYY-MM-DD, HH:MM:SS)
|
||||||
timestamp = f"{parts[0]} {parts[1]}" if len(parts) >= 2 else parts[0]
|
timestamp = f"{parts[0]} {parts[1]}" if len(parts) >= 2 else parts[0]
|
||||||
|
|
||||||
start_idx = None
|
start_idx = None
|
||||||
for i, tok in enumerate(parts):
|
for i, tok in enumerate(parts):
|
||||||
try:
|
try:
|
||||||
val = float(tok)
|
val = float(tok)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
if val > 1e5:
|
if val > 1e5:
|
||||||
start_idx = i
|
start_idx = i
|
||||||
break
|
break
|
||||||
if start_idx is None or len(parts) < start_idx + 4:
|
if start_idx is None or len(parts) < start_idx + 4:
|
||||||
return timestamp, None, None, []
|
return timestamp, None, None, []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
seg_start = float(parts[start_idx])
|
seg_start = float(parts[start_idx])
|
||||||
seg_end = float(parts[start_idx + 1])
|
seg_end = float(parts[start_idx + 1])
|
||||||
raw_values = []
|
raw_values = []
|
||||||
for v in parts[start_idx + 3:]:
|
for v in parts[start_idx + 3:]:
|
||||||
try:
|
try:
|
||||||
raw_values.append(float(v))
|
raw_values.append(float(v))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
||||||
raw_values = raw_values[1:]
|
raw_values = raw_values[1:]
|
||||||
return timestamp, seg_start, seg_end, raw_values
|
return timestamp, seg_start, seg_end, raw_values
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return timestamp, None, None, []
|
return timestamp, None, None, []
|
||||||
|
|
||||||
|
|
||||||
def _waterfall_loop():
|
def _waterfall_loop():
|
||||||
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
||||||
global waterfall_running, waterfall_process
|
global waterfall_running, waterfall_process
|
||||||
|
|
||||||
rtl_power_path = find_rtl_power()
|
rtl_power_path = find_rtl_power()
|
||||||
if not rtl_power_path:
|
if not rtl_power_path:
|
||||||
logger.error("rtl_power not found for waterfall")
|
logger.error("rtl_power not found for waterfall")
|
||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
return
|
return
|
||||||
|
|
||||||
start_hz = int(waterfall_config['start_freq'] * 1e6)
|
start_hz = int(waterfall_config['start_freq'] * 1e6)
|
||||||
end_hz = int(waterfall_config['end_freq'] * 1e6)
|
end_hz = int(waterfall_config['end_freq'] * 1e6)
|
||||||
bin_hz = int(waterfall_config['bin_size'])
|
bin_hz = int(waterfall_config['bin_size'])
|
||||||
gain = waterfall_config['gain']
|
gain = waterfall_config['gain']
|
||||||
device = waterfall_config['device']
|
device = waterfall_config['device']
|
||||||
interval = float(waterfall_config.get('interval', 0.4))
|
interval = float(waterfall_config.get('interval', 0.4))
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
rtl_power_path,
|
rtl_power_path,
|
||||||
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
||||||
'-i', str(interval),
|
'-i', str(interval),
|
||||||
'-g', str(gain),
|
'-g', str(gain),
|
||||||
'-d', str(device),
|
'-d', str(device),
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
waterfall_process = subprocess.Popen(
|
waterfall_process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
current_ts = None
|
current_ts = None
|
||||||
all_bins: list[float] = []
|
all_bins: list[float] = []
|
||||||
sweep_start_hz = start_hz
|
sweep_start_hz = start_hz
|
||||||
sweep_end_hz = end_hz
|
sweep_end_hz = end_hz
|
||||||
|
|
||||||
if not waterfall_process.stdout:
|
if not waterfall_process.stdout:
|
||||||
return
|
return
|
||||||
|
|
||||||
for line in waterfall_process.stdout:
|
for line in waterfall_process.stdout:
|
||||||
if not waterfall_running:
|
if not waterfall_running:
|
||||||
break
|
break
|
||||||
|
|
||||||
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
||||||
if ts is None or not bins:
|
if ts is None or not bins:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if current_ts is None:
|
if current_ts is None:
|
||||||
current_ts = ts
|
current_ts = ts
|
||||||
|
|
||||||
if ts != current_ts and all_bins:
|
if ts != current_ts and all_bins:
|
||||||
max_bins = int(waterfall_config.get('max_bins') or 0)
|
max_bins = int(waterfall_config.get('max_bins') or 0)
|
||||||
bins_to_send = all_bins
|
bins_to_send = all_bins
|
||||||
if max_bins > 0 and len(bins_to_send) > max_bins:
|
if max_bins > 0 and len(bins_to_send) > max_bins:
|
||||||
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
||||||
msg = {
|
msg = {
|
||||||
'type': 'waterfall_sweep',
|
'type': 'waterfall_sweep',
|
||||||
'start_freq': sweep_start_hz / 1e6,
|
'start_freq': sweep_start_hz / 1e6,
|
||||||
'end_freq': sweep_end_hz / 1e6,
|
'end_freq': sweep_end_hz / 1e6,
|
||||||
'bins': bins_to_send,
|
'bins': bins_to_send,
|
||||||
'timestamp': datetime.now().isoformat(),
|
'timestamp': datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
waterfall_queue.put_nowait(msg)
|
waterfall_queue.put_nowait(msg)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
try:
|
||||||
waterfall_queue.get_nowait()
|
waterfall_queue.get_nowait()
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
waterfall_queue.put_nowait(msg)
|
waterfall_queue.put_nowait(msg)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
all_bins = []
|
all_bins = []
|
||||||
sweep_start_hz = start_hz
|
sweep_start_hz = start_hz
|
||||||
sweep_end_hz = end_hz
|
sweep_end_hz = end_hz
|
||||||
current_ts = ts
|
current_ts = ts
|
||||||
|
|
||||||
all_bins.extend(bins)
|
all_bins.extend(bins)
|
||||||
if seg_start is not None:
|
if seg_start is not None:
|
||||||
sweep_start_hz = min(sweep_start_hz, seg_start)
|
sweep_start_hz = min(sweep_start_hz, seg_start)
|
||||||
if seg_end is not None:
|
if seg_end is not None:
|
||||||
sweep_end_hz = max(sweep_end_hz, seg_end)
|
sweep_end_hz = max(sweep_end_hz, seg_end)
|
||||||
|
|
||||||
# Flush any remaining bins
|
# Flush any remaining bins
|
||||||
if all_bins and waterfall_running:
|
if all_bins and waterfall_running:
|
||||||
max_bins = int(waterfall_config.get('max_bins') or 0)
|
max_bins = int(waterfall_config.get('max_bins') or 0)
|
||||||
bins_to_send = all_bins
|
bins_to_send = all_bins
|
||||||
if max_bins > 0 and len(bins_to_send) > max_bins:
|
if max_bins > 0 and len(bins_to_send) > max_bins:
|
||||||
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
||||||
msg = {
|
msg = {
|
||||||
'type': 'waterfall_sweep',
|
'type': 'waterfall_sweep',
|
||||||
'start_freq': sweep_start_hz / 1e6,
|
'start_freq': sweep_start_hz / 1e6,
|
||||||
'end_freq': sweep_end_hz / 1e6,
|
'end_freq': sweep_end_hz / 1e6,
|
||||||
'bins': bins_to_send,
|
'bins': bins_to_send,
|
||||||
'timestamp': datetime.now().isoformat(),
|
'timestamp': datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
waterfall_queue.put_nowait(msg)
|
waterfall_queue.put_nowait(msg)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Waterfall loop error: {e}")
|
logger.error(f"Waterfall loop error: {e}")
|
||||||
finally:
|
finally:
|
||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
if waterfall_process and waterfall_process.poll() is None:
|
if waterfall_process and waterfall_process.poll() is None:
|
||||||
try:
|
try:
|
||||||
waterfall_process.terminate()
|
waterfall_process.terminate()
|
||||||
waterfall_process.wait(timeout=1)
|
waterfall_process.wait(timeout=1)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
waterfall_process.kill()
|
waterfall_process.kill()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
waterfall_process = None
|
waterfall_process = None
|
||||||
logger.info("Waterfall loop stopped")
|
logger.info("Waterfall loop stopped")
|
||||||
|
|
||||||
|
|
||||||
def _stop_waterfall_internal() -> None:
|
def _stop_waterfall_internal() -> None:
|
||||||
"""Stop the waterfall display and release resources."""
|
"""Stop the waterfall display and release resources."""
|
||||||
global waterfall_running, waterfall_process, waterfall_active_device
|
global waterfall_running, waterfall_process, waterfall_active_device
|
||||||
|
|
||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
if waterfall_process and waterfall_process.poll() is None:
|
if waterfall_process and waterfall_process.poll() is None:
|
||||||
try:
|
try:
|
||||||
waterfall_process.terminate()
|
waterfall_process.terminate()
|
||||||
waterfall_process.wait(timeout=1)
|
waterfall_process.wait(timeout=1)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
waterfall_process.kill()
|
waterfall_process.kill()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
waterfall_process = None
|
waterfall_process = None
|
||||||
|
|
||||||
if waterfall_active_device is not None:
|
if waterfall_active_device is not None:
|
||||||
app_module.release_sdr_device(waterfall_active_device)
|
app_module.release_sdr_device(waterfall_active_device)
|
||||||
waterfall_active_device = None
|
waterfall_active_device = None
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/waterfall/start', methods=['POST'])
|
@listening_post_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
|
||||||
|
|
||||||
@@ -1734,24 +1743,24 @@ def start_waterfall() -> Response:
|
|||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
waterfall_config['start_freq'] = float(data.get('start_freq', 88.0))
|
waterfall_config['start_freq'] = float(data.get('start_freq', 88.0))
|
||||||
waterfall_config['end_freq'] = float(data.get('end_freq', 108.0))
|
waterfall_config['end_freq'] = float(data.get('end_freq', 108.0))
|
||||||
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
||||||
waterfall_config['gain'] = int(data.get('gain', 40))
|
waterfall_config['gain'] = int(data.get('gain', 40))
|
||||||
waterfall_config['device'] = int(data.get('device', 0))
|
waterfall_config['device'] = int(data.get('device', 0))
|
||||||
if data.get('interval') is not None:
|
if data.get('interval') is not None:
|
||||||
interval = float(data.get('interval', waterfall_config['interval']))
|
interval = float(data.get('interval', waterfall_config['interval']))
|
||||||
if interval < 0.1 or interval > 5:
|
if interval < 0.1 or interval > 5:
|
||||||
return jsonify({'status': 'error', 'message': 'interval must be between 0.1 and 5 seconds'}), 400
|
return jsonify({'status': 'error', 'message': 'interval must be between 0.1 and 5 seconds'}), 400
|
||||||
waterfall_config['interval'] = interval
|
waterfall_config['interval'] = interval
|
||||||
if data.get('max_bins') is not None:
|
if data.get('max_bins') is not None:
|
||||||
max_bins = int(data.get('max_bins', waterfall_config['max_bins']))
|
max_bins = int(data.get('max_bins', waterfall_config['max_bins']))
|
||||||
if max_bins < 64 or max_bins > 4096:
|
if max_bins < 64 or max_bins > 4096:
|
||||||
return jsonify({'status': 'error', 'message': 'max_bins must be between 64 and 4096'}), 400
|
return jsonify({'status': 'error', 'message': 'max_bins must be between 64 and 4096'}), 400
|
||||||
waterfall_config['max_bins'] = max_bins
|
waterfall_config['max_bins'] = max_bins
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
||||||
|
|
||||||
if waterfall_config['start_freq'] >= waterfall_config['end_freq']:
|
if waterfall_config['start_freq'] >= waterfall_config['end_freq']:
|
||||||
return jsonify({'status': 'error', 'message': 'start_freq must be less than end_freq'}), 400
|
return jsonify({'status': 'error', 'message': 'start_freq must be less than end_freq'}), 400
|
||||||
@@ -1777,11 +1786,11 @@ def start_waterfall() -> Response:
|
|||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/waterfall/stop', methods=['POST'])
|
@listening_post_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()
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/waterfall/stream')
|
@listening_post_bp.route('/waterfall/stream')
|
||||||
@@ -1790,14 +1799,14 @@ def stream_waterfall() -> Response:
|
|||||||
def generate() -> Generator[str, None, None]:
|
def generate() -> Generator[str, None, None]:
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
try:
|
||||||
process_event('waterfall', msg, msg.get('type'))
|
process_event('waterfall', msg, msg.get('type'))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
@@ -1808,20 +1817,20 @@ def stream_waterfall() -> Response:
|
|||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
def _downsample_bins(values: list[float], target: int) -> list[float]:
|
def _downsample_bins(values: list[float], target: int) -> list[float]:
|
||||||
"""Downsample bins to a target length using simple averaging."""
|
"""Downsample bins to a target length using simple averaging."""
|
||||||
if target <= 0 or len(values) <= target:
|
if target <= 0 or len(values) <= target:
|
||||||
return values
|
return values
|
||||||
|
|
||||||
out: list[float] = []
|
out: list[float] = []
|
||||||
step = len(values) / target
|
step = len(values) / target
|
||||||
for i in range(target):
|
for i in range(target):
|
||||||
start = int(i * step)
|
start = int(i * step)
|
||||||
end = int((i + 1) * step)
|
end = int((i + 1) * step)
|
||||||
if end <= start:
|
if end <= start:
|
||||||
end = min(start + 1, len(values))
|
end = min(start + 1, len(values))
|
||||||
chunk = values[start:end]
|
chunk = values[start:end]
|
||||||
if not chunk:
|
if not chunk:
|
||||||
continue
|
continue
|
||||||
out.append(sum(chunk) / len(chunk))
|
out.append(sum(chunk) / len(chunk))
|
||||||
return out
|
return out
|
||||||
|
|||||||
+90
-12
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import pty
|
import pty
|
||||||
import queue
|
import queue
|
||||||
import select
|
import select
|
||||||
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -22,8 +24,8 @@ from utils.validation import (
|
|||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||||
from utils.dependencies import get_tool_path
|
from utils.dependencies import get_tool_path
|
||||||
@@ -106,6 +108,62 @@ def log_message(msg: dict[str, Any]) -> None:
|
|||||||
logger.error(f"Failed to log message: {e}")
|
logger.error(f"Failed to log message: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def audio_relay_thread(
|
||||||
|
rtl_stdout,
|
||||||
|
multimon_stdin,
|
||||||
|
output_queue: queue.Queue,
|
||||||
|
stop_event: threading.Event,
|
||||||
|
) -> None:
|
||||||
|
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||||||
|
|
||||||
|
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||||
|
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||||
|
event onto *output_queue*.
|
||||||
|
"""
|
||||||
|
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||||
|
INTERVAL = 0.1 # seconds between scope updates
|
||||||
|
last_scope = time.monotonic()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not stop_event.is_set():
|
||||||
|
data = rtl_stdout.read(CHUNK)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Forward audio untouched
|
||||||
|
try:
|
||||||
|
multimon_stdin.write(data)
|
||||||
|
multimon_stdin.flush()
|
||||||
|
except (BrokenPipeError, OSError):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Compute scope levels every ~100 ms
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_scope >= INTERVAL:
|
||||||
|
last_scope = now
|
||||||
|
try:
|
||||||
|
n_samples = len(data) // 2
|
||||||
|
if n_samples == 0:
|
||||||
|
continue
|
||||||
|
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
||||||
|
peak = max(abs(s) for s in samples)
|
||||||
|
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||||
|
output_queue.put_nowait({
|
||||||
|
'type': 'scope',
|
||||||
|
'rms': rms,
|
||||||
|
'peak': peak,
|
||||||
|
})
|
||||||
|
except (struct.error, ValueError, queue.Full):
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Audio relay error: {e}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
multimon_stdin.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream decoder output to queue using PTY for unbuffered output."""
|
"""Stream decoder output to queue using PTY for unbuffered output."""
|
||||||
try:
|
try:
|
||||||
@@ -152,6 +210,11 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
# Signal relay thread to stop
|
||||||
|
with app_module.process_lock:
|
||||||
|
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
|
||||||
|
if stop_relay:
|
||||||
|
stop_relay.set()
|
||||||
# Cleanup companion rtl_fm process and decoder
|
# Cleanup companion rtl_fm process and decoder
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
|
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
|
||||||
@@ -319,7 +382,7 @@ def start_decoding() -> Response:
|
|||||||
|
|
||||||
multimon_process = subprocess.Popen(
|
multimon_process = subprocess.Popen(
|
||||||
multimon_cmd,
|
multimon_cmd,
|
||||||
stdin=rtl_process.stdout,
|
stdin=subprocess.PIPE,
|
||||||
stdout=slave_fd,
|
stdout=slave_fd,
|
||||||
stderr=slave_fd,
|
stderr=slave_fd,
|
||||||
close_fds=True
|
close_fds=True
|
||||||
@@ -327,11 +390,22 @@ def start_decoding() -> Response:
|
|||||||
register_process(multimon_process)
|
register_process(multimon_process)
|
||||||
|
|
||||||
os.close(slave_fd)
|
os.close(slave_fd)
|
||||||
rtl_process.stdout.close()
|
|
||||||
|
# Spawn audio relay thread between rtl_fm and multimon-ng
|
||||||
|
stop_relay = threading.Event()
|
||||||
|
relay = threading.Thread(
|
||||||
|
target=audio_relay_thread,
|
||||||
|
args=(rtl_process.stdout, multimon_process.stdin,
|
||||||
|
app_module.output_queue, stop_relay),
|
||||||
|
)
|
||||||
|
relay.daemon = True
|
||||||
|
relay.start()
|
||||||
|
|
||||||
app_module.current_process = multimon_process
|
app_module.current_process = multimon_process
|
||||||
app_module.current_process._rtl_process = rtl_process
|
app_module.current_process._rtl_process = rtl_process
|
||||||
app_module.current_process._master_fd = master_fd
|
app_module.current_process._master_fd = master_fd
|
||||||
|
app_module.current_process._stop_relay = stop_relay
|
||||||
|
app_module.current_process._relay_thread = relay
|
||||||
|
|
||||||
# Start output thread with PTY master fd
|
# Start output thread with PTY master fd
|
||||||
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
||||||
@@ -380,6 +454,10 @@ def stop_decoding() -> Response:
|
|||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
|
# Signal audio relay thread to stop
|
||||||
|
if hasattr(app_module.current_process, '_stop_relay'):
|
||||||
|
app_module.current_process._stop_relay.set()
|
||||||
|
|
||||||
# Kill rtl_fm process first
|
# Kill rtl_fm process first
|
||||||
if hasattr(app_module.current_process, '_rtl_process'):
|
if hasattr(app_module.current_process, '_rtl_process'):
|
||||||
try:
|
try:
|
||||||
@@ -469,14 +547,14 @@ def stream() -> Response:
|
|||||||
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
|
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = app_module.output_queue.get(timeout=1)
|
msg = app_module.output_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
try:
|
||||||
process_event('pager', msg, msg.get('type'))
|
process_event('pager', msg, msg.get('type'))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_keepalive >= keepalive_interval:
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
|||||||
+36
-9
@@ -18,8 +18,8 @@ from utils.validation import (
|
|||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
|
||||||
@@ -45,6 +45,21 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
data['type'] = 'sensor'
|
data['type'] = 'sensor'
|
||||||
app_module.sensor_queue.put(data)
|
app_module.sensor_queue.put(data)
|
||||||
|
|
||||||
|
# Push scope event when signal level data is present
|
||||||
|
rssi = data.get('rssi')
|
||||||
|
snr = data.get('snr')
|
||||||
|
noise = data.get('noise')
|
||||||
|
if rssi is not None or snr is not None:
|
||||||
|
try:
|
||||||
|
app_module.sensor_queue.put_nowait({
|
||||||
|
'type': 'scope',
|
||||||
|
'rssi': rssi if rssi is not None else 0,
|
||||||
|
'snr': snr if snr is not None else 0,
|
||||||
|
'noise': noise if noise is not None else 0,
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
try:
|
try:
|
||||||
@@ -80,6 +95,14 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
sensor_active_device = None
|
sensor_active_device = None
|
||||||
|
|
||||||
|
|
||||||
|
@sensor_bp.route('/sensor/status')
|
||||||
|
def sensor_status() -> Response:
|
||||||
|
"""Check if sensor decoder is currently running."""
|
||||||
|
with app_module.sensor_lock:
|
||||||
|
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
||||||
|
return jsonify({'running': running})
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||||
def start_sensor() -> Response:
|
def start_sensor() -> Response:
|
||||||
global sensor_active_device
|
global sensor_active_device
|
||||||
@@ -158,6 +181,10 @@ def start_sensor() -> Response:
|
|||||||
full_cmd = ' '.join(cmd)
|
full_cmd = ' '.join(cmd)
|
||||||
logger.info(f"Running: {full_cmd}")
|
logger.info(f"Running: {full_cmd}")
|
||||||
|
|
||||||
|
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
||||||
|
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
||||||
|
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_module.sensor_process = subprocess.Popen(
|
app_module.sensor_process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -232,13 +259,13 @@ def stream_sensor() -> Response:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = app_module.sensor_queue.get(timeout=1)
|
msg = app_module.sensor_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
try:
|
||||||
process_event('sensor', msg, msg.get('type'))
|
process_event('sensor', msg, msg.get('type'))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_keepalive >= keepalive_interval:
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
|||||||
+14
-16
@@ -15,14 +15,12 @@ from flask import Blueprint, jsonify, request, Response, send_file
|
|||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
get_sstv_decoder,
|
get_sstv_decoder,
|
||||||
is_sstv_available,
|
is_sstv_available,
|
||||||
ISS_SSTV_FREQ,
|
ISS_SSTV_FREQ,
|
||||||
DecodeProgress,
|
|
||||||
DopplerInfo,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger('intercept.sstv')
|
logger = get_logger('intercept.sstv')
|
||||||
@@ -36,14 +34,14 @@ _sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
|||||||
sstv_active_device: int | None = None
|
sstv_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def _progress_callback(progress: DecodeProgress) -> None:
|
def _progress_callback(data: dict) -> None:
|
||||||
"""Callback to queue progress updates for SSE stream."""
|
"""Callback to queue progress/scope updates for SSE stream."""
|
||||||
try:
|
try:
|
||||||
_sstv_queue.put_nowait(progress.to_dict())
|
_sstv_queue.put_nowait(data)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
try:
|
||||||
_sstv_queue.get_nowait()
|
_sstv_queue.get_nowait()
|
||||||
_sstv_queue.put_nowait(progress.to_dict())
|
_sstv_queue.put_nowait(data)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -399,14 +397,14 @@ def stream_progress():
|
|||||||
keepalive_interval = 30.0
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
progress = _sstv_queue.get(timeout=1)
|
progress = _sstv_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
try:
|
||||||
process_event('sstv', progress, progress.get('type'))
|
process_event('sstv', progress, progress.get('type'))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
yield format_sse(progress)
|
yield format_sse(progress)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_keepalive >= keepalive_interval:
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from utils.logging import get_logger
|
|||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
DecodeProgress,
|
|
||||||
get_general_sstv_decoder,
|
get_general_sstv_decoder,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,14 +48,14 @@ SSTV_FREQUENCIES = [
|
|||||||
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
|
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
|
||||||
|
|
||||||
|
|
||||||
def _progress_callback(progress: DecodeProgress) -> None:
|
def _progress_callback(data: dict) -> None:
|
||||||
"""Callback to queue progress updates for SSE stream."""
|
"""Callback to queue progress/scope updates for SSE stream."""
|
||||||
try:
|
try:
|
||||||
_sstv_general_queue.put_nowait(progress.to_dict())
|
_sstv_general_queue.put_nowait(data)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
try:
|
||||||
_sstv_general_queue.get_nowait()
|
_sstv_general_queue.get_nowait()
|
||||||
_sstv_general_queue.put_nowait(progress.to_dict())
|
_sstv_general_queue.put_nowait(data)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -551,6 +551,12 @@ def _start_sweep_internal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@tscm_bp.route('/status')
|
||||||
|
def tscm_status():
|
||||||
|
"""Check if any TSCM operation is currently running."""
|
||||||
|
return jsonify({'running': _sweep_running})
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/sweep/start', methods=['POST'])
|
@tscm_bp.route('/sweep/start', methods=['POST'])
|
||||||
def start_sweep():
|
def start_sweep():
|
||||||
"""Start a TSCM sweep."""
|
"""Start a TSCM sweep."""
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
try:
|
||||||
|
from flask_sock import Sock
|
||||||
|
WEBSOCKET_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
WEBSOCKET_AVAILABLE = False
|
||||||
|
Sock = None
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
|
from utils.waterfall_fft import (
|
||||||
|
build_binary_frame,
|
||||||
|
compute_power_spectrum,
|
||||||
|
cu8_to_complex,
|
||||||
|
quantize_to_uint8,
|
||||||
|
)
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sdr.base import SDRCapabilities, SDRDevice
|
||||||
|
|
||||||
|
logger = get_logger('intercept.waterfall_ws')
|
||||||
|
|
||||||
|
# Maximum bandwidth per SDR type (Hz)
|
||||||
|
MAX_BANDWIDTH = {
|
||||||
|
SDRType.RTL_SDR: 2400000,
|
||||||
|
SDRType.HACKRF: 20000000,
|
||||||
|
SDRType.LIME_SDR: 20000000,
|
||||||
|
SDRType.AIRSPY: 10000000,
|
||||||
|
SDRType.SDRPLAY: 2000000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
||||||
|
"""Convert client sdr_type string to SDRType enum."""
|
||||||
|
mapping = {
|
||||||
|
'rtlsdr': SDRType.RTL_SDR,
|
||||||
|
'rtl_sdr': SDRType.RTL_SDR,
|
||||||
|
'hackrf': SDRType.HACKRF,
|
||||||
|
'limesdr': SDRType.LIME_SDR,
|
||||||
|
'lime_sdr': SDRType.LIME_SDR,
|
||||||
|
'airspy': SDRType.AIRSPY,
|
||||||
|
'sdrplay': SDRType.SDRPLAY,
|
||||||
|
}
|
||||||
|
return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice:
|
||||||
|
"""Build a minimal SDRDevice for command building."""
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
caps = builder.get_capabilities()
|
||||||
|
return SDRDevice(
|
||||||
|
sdr_type=sdr_type,
|
||||||
|
index=device_index,
|
||||||
|
name=f'{sdr_type.value}-{device_index}',
|
||||||
|
serial='N/A',
|
||||||
|
driver=sdr_type.value,
|
||||||
|
capabilities=caps,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def init_waterfall_websocket(app: Flask):
|
||||||
|
"""Initialize WebSocket waterfall streaming."""
|
||||||
|
if not WEBSOCKET_AVAILABLE:
|
||||||
|
logger.warning("flask-sock not installed, WebSocket waterfall disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
sock = Sock(app)
|
||||||
|
|
||||||
|
@sock.route('/ws/waterfall')
|
||||||
|
def waterfall_stream(ws):
|
||||||
|
"""WebSocket endpoint for real-time waterfall streaming."""
|
||||||
|
logger.info("WebSocket waterfall client connected")
|
||||||
|
|
||||||
|
# Import app module for device claiming
|
||||||
|
import app as app_module
|
||||||
|
|
||||||
|
iq_process = None
|
||||||
|
reader_thread = None
|
||||||
|
stop_event = threading.Event()
|
||||||
|
claimed_device = None
|
||||||
|
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||||
|
send_queue = queue.Queue(maxsize=120)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Drain send queue first (non-blocking)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
outgoing = send_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
ws.send(outgoing)
|
||||||
|
except Exception:
|
||||||
|
stop_event.set()
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = ws.receive(timeout=0.1)
|
||||||
|
except Exception as e:
|
||||||
|
err = str(e).lower()
|
||||||
|
if "closed" in err:
|
||||||
|
break
|
||||||
|
if "timed out" not in err:
|
||||||
|
logger.error(f"WebSocket receive error: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if msg is None:
|
||||||
|
# simple-websocket returns None on timeout AND on
|
||||||
|
# close; check ws.connected to tell them apart.
|
||||||
|
if not ws.connected:
|
||||||
|
break
|
||||||
|
if stop_event.is_set():
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(msg)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cmd = data.get('cmd')
|
||||||
|
|
||||||
|
if cmd == 'start':
|
||||||
|
# Stop any existing capture
|
||||||
|
was_restarting = iq_process is not None
|
||||||
|
stop_event.set()
|
||||||
|
if reader_thread and reader_thread.is_alive():
|
||||||
|
reader_thread.join(timeout=2)
|
||||||
|
if iq_process:
|
||||||
|
safe_terminate(iq_process)
|
||||||
|
unregister_process(iq_process)
|
||||||
|
iq_process = None
|
||||||
|
if claimed_device is not None:
|
||||||
|
app_module.release_sdr_device(claimed_device)
|
||||||
|
claimed_device = None
|
||||||
|
stop_event.clear()
|
||||||
|
# Flush stale frames from previous capture
|
||||||
|
while not send_queue.empty():
|
||||||
|
try:
|
||||||
|
send_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
# Allow USB device to be released by the kernel
|
||||||
|
if was_restarting:
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Parse config
|
||||||
|
center_freq = float(data.get('center_freq', 100.0))
|
||||||
|
span_mhz = float(data.get('span_mhz', 2.0))
|
||||||
|
gain = data.get('gain')
|
||||||
|
if gain is not None:
|
||||||
|
gain = float(gain)
|
||||||
|
device_index = int(data.get('device', 0))
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
fft_size = int(data.get('fft_size', 1024))
|
||||||
|
fps = int(data.get('fps', 25))
|
||||||
|
avg_count = int(data.get('avg_count', 4))
|
||||||
|
ppm = data.get('ppm')
|
||||||
|
if ppm is not None:
|
||||||
|
ppm = int(ppm)
|
||||||
|
bias_t = bool(data.get('bias_t', False))
|
||||||
|
|
||||||
|
# Clamp FFT size to valid powers of 2
|
||||||
|
fft_size = max(256, min(8192, fft_size))
|
||||||
|
|
||||||
|
# Resolve SDR type and bandwidth
|
||||||
|
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||||
|
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
||||||
|
span_hz = int(span_mhz * 1e6)
|
||||||
|
sample_rate = min(span_hz, max_bw)
|
||||||
|
|
||||||
|
# Compute effective frequency range
|
||||||
|
effective_span_mhz = sample_rate / 1e6
|
||||||
|
start_freq = center_freq - effective_span_mhz / 2
|
||||||
|
end_freq = center_freq + effective_span_mhz / 2
|
||||||
|
|
||||||
|
# Claim the device
|
||||||
|
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
||||||
|
if claim_err:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': claim_err,
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
claimed_device = device_index
|
||||||
|
|
||||||
|
# Build I/Q capture command
|
||||||
|
try:
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
device = _build_dummy_device(device_index, sdr_type)
|
||||||
|
iq_cmd = builder.build_iq_capture_command(
|
||||||
|
device=device,
|
||||||
|
frequency_mhz=center_freq,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
gain=gain,
|
||||||
|
ppm=ppm,
|
||||||
|
bias_t=bias_t,
|
||||||
|
)
|
||||||
|
except NotImplementedError as e:
|
||||||
|
app_module.release_sdr_device(device_index)
|
||||||
|
claimed_device = None
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Spawn I/Q capture process (retry to handle USB release lag)
|
||||||
|
max_attempts = 3 if was_restarting else 1
|
||||||
|
try:
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
logger.info(
|
||||||
|
f"Starting I/Q capture: {center_freq} MHz, "
|
||||||
|
f"span={effective_span_mhz:.1f} MHz, "
|
||||||
|
f"sr={sample_rate}, fft={fft_size}"
|
||||||
|
)
|
||||||
|
iq_process = subprocess.Popen(
|
||||||
|
iq_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
bufsize=0,
|
||||||
|
)
|
||||||
|
register_process(iq_process)
|
||||||
|
|
||||||
|
# Brief check that process started
|
||||||
|
time.sleep(0.3)
|
||||||
|
if iq_process.poll() is not None:
|
||||||
|
unregister_process(iq_process)
|
||||||
|
iq_process = None
|
||||||
|
if attempt < max_attempts - 1:
|
||||||
|
logger.info(
|
||||||
|
f"I/Q process exited immediately, "
|
||||||
|
f"retrying ({attempt + 1}/{max_attempts})..."
|
||||||
|
)
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
raise RuntimeError(
|
||||||
|
"I/Q capture process exited immediately"
|
||||||
|
)
|
||||||
|
break # Process started successfully
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start I/Q capture: {e}")
|
||||||
|
if iq_process:
|
||||||
|
safe_terminate(iq_process)
|
||||||
|
unregister_process(iq_process)
|
||||||
|
iq_process = None
|
||||||
|
app_module.release_sdr_device(device_index)
|
||||||
|
claimed_device = None
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Failed to start I/Q capture: {e}',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Send started confirmation
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'started',
|
||||||
|
'start_freq': start_freq,
|
||||||
|
'end_freq': end_freq,
|
||||||
|
'fft_size': fft_size,
|
||||||
|
'sample_rate': sample_rate,
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||||
|
def fft_reader(
|
||||||
|
proc, _send_q, stop_evt,
|
||||||
|
_fft_size, _avg_count, _fps,
|
||||||
|
_start_freq, _end_freq,
|
||||||
|
):
|
||||||
|
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
||||||
|
bytes_per_frame = _fft_size * _avg_count * 2
|
||||||
|
frame_interval = 1.0 / _fps
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not stop_evt.is_set():
|
||||||
|
if proc.poll() is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
frame_start = time.monotonic()
|
||||||
|
|
||||||
|
# Read raw I/Q bytes
|
||||||
|
raw = b''
|
||||||
|
remaining = bytes_per_frame
|
||||||
|
while remaining > 0 and not stop_evt.is_set():
|
||||||
|
chunk = proc.stdout.read(min(remaining, 65536))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
raw += chunk
|
||||||
|
remaining -= len(chunk)
|
||||||
|
|
||||||
|
if len(raw) < _fft_size * 2:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Process FFT pipeline
|
||||||
|
samples = cu8_to_complex(raw)
|
||||||
|
power_db = compute_power_spectrum(
|
||||||
|
samples,
|
||||||
|
fft_size=_fft_size,
|
||||||
|
avg_count=_avg_count,
|
||||||
|
)
|
||||||
|
quantized = quantize_to_uint8(power_db)
|
||||||
|
frame = build_binary_frame(
|
||||||
|
_start_freq, _end_freq, quantized,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_send_q.put_nowait(frame)
|
||||||
|
except queue.Full:
|
||||||
|
# Drop frame if main loop can't keep up
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Pace to target FPS
|
||||||
|
elapsed = time.monotonic() - frame_start
|
||||||
|
sleep_time = frame_interval - elapsed
|
||||||
|
if sleep_time > 0:
|
||||||
|
stop_evt.wait(sleep_time)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"FFT reader stopped: {e}")
|
||||||
|
|
||||||
|
reader_thread = threading.Thread(
|
||||||
|
target=fft_reader,
|
||||||
|
args=(
|
||||||
|
iq_process, send_queue, stop_event,
|
||||||
|
fft_size, avg_count, fps,
|
||||||
|
start_freq, end_freq,
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
reader_thread.start()
|
||||||
|
|
||||||
|
elif cmd == 'stop':
|
||||||
|
stop_event.set()
|
||||||
|
if reader_thread and reader_thread.is_alive():
|
||||||
|
reader_thread.join(timeout=2)
|
||||||
|
reader_thread = None
|
||||||
|
if iq_process:
|
||||||
|
safe_terminate(iq_process)
|
||||||
|
unregister_process(iq_process)
|
||||||
|
iq_process = None
|
||||||
|
if claimed_device is not None:
|
||||||
|
app_module.release_sdr_device(claimed_device)
|
||||||
|
claimed_device = None
|
||||||
|
stop_event.clear()
|
||||||
|
ws.send(json.dumps({'status': 'stopped'}))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"WebSocket waterfall closed: {e}")
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
stop_event.set()
|
||||||
|
if reader_thread and reader_thread.is_alive():
|
||||||
|
reader_thread.join(timeout=2)
|
||||||
|
if iq_process:
|
||||||
|
safe_terminate(iq_process)
|
||||||
|
unregister_process(iq_process)
|
||||||
|
if claimed_device is not None:
|
||||||
|
app_module.release_sdr_device(claimed_device)
|
||||||
|
# Complete WebSocket close handshake, then shut down the
|
||||||
|
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||||
|
# on top of the WebSocket stream (which browsers see as
|
||||||
|
# "Invalid frame header").
|
||||||
|
try:
|
||||||
|
ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws.sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info("WebSocket waterfall client disconnected")
|
||||||
@@ -165,6 +165,7 @@ detect_dragonos() {
|
|||||||
# Required tool checks (with alternates)
|
# Required tool checks (with alternates)
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
missing_required=()
|
missing_required=()
|
||||||
|
missing_recommended=()
|
||||||
|
|
||||||
check_required() {
|
check_required() {
|
||||||
local label="$1"; shift
|
local label="$1"; shift
|
||||||
@@ -178,6 +179,18 @@ check_required() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_recommended() {
|
||||||
|
local label="$1"; shift
|
||||||
|
local desc="$1"; shift
|
||||||
|
|
||||||
|
if have_any "$@"; then
|
||||||
|
ok "${label} - ${desc}"
|
||||||
|
else
|
||||||
|
warn "${label} - ${desc} (missing, recommended)"
|
||||||
|
missing_recommended+=("$label")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
check_optional() {
|
check_optional() {
|
||||||
local label="$1"; shift
|
local label="$1"; shift
|
||||||
local desc="$1"; shift
|
local desc="$1"; shift
|
||||||
@@ -230,6 +243,12 @@ check_tools() {
|
|||||||
check_required "hcitool" "Bluetooth scan utility" hcitool
|
check_required "hcitool" "Bluetooth scan utility" hcitool
|
||||||
check_required "hciconfig" "Bluetooth adapter config" hciconfig
|
check_required "hciconfig" "Bluetooth adapter config" hciconfig
|
||||||
|
|
||||||
|
echo
|
||||||
|
info "GSM Intelligence:"
|
||||||
|
check_recommended "grgsm_scanner" "GSM tower scanner (gr-gsm)" grgsm_scanner
|
||||||
|
check_recommended "grgsm_livemon" "GSM live monitor (gr-gsm)" grgsm_livemon
|
||||||
|
check_recommended "tshark" "Packet analysis (Wireshark)" tshark
|
||||||
|
|
||||||
echo
|
echo
|
||||||
info "SoapySDR:"
|
info "SoapySDR:"
|
||||||
check_required "SoapySDRUtil" "SoapySDR CLI utility" SoapySDRUtil
|
check_required "SoapySDRUtil" "SoapySDR CLI utility" SoapySDRUtil
|
||||||
@@ -605,7 +624,7 @@ install_aiscatcher_from_source_macos() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
install_macos_packages() {
|
install_macos_packages() {
|
||||||
TOTAL_STEPS=17
|
TOTAL_STEPS=18
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -694,6 +713,47 @@ install_macos_packages() {
|
|||||||
progress "Installing gpsd"
|
progress "Installing gpsd"
|
||||||
brew_install gpsd
|
brew_install gpsd
|
||||||
|
|
||||||
|
# gr-gsm for GSM Intelligence
|
||||||
|
progress "Installing gr-gsm"
|
||||||
|
if ! cmd_exists grgsm_scanner; then
|
||||||
|
brew_install gnuradio
|
||||||
|
(brew_install gr-gsm) || {
|
||||||
|
warn "gr-gsm not available in Homebrew, building from source..."
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning gr-gsm repository..."
|
||||||
|
git clone --depth 1 https://github.com/bkerler/gr-gsm.git "$tmp_dir/gr-gsm" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone gr-gsm. GSM Spy feature will not work."; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/gr-gsm"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
info "Compiling gr-gsm (this may take several minutes)..."
|
||||||
|
if cmake .. >/dev/null 2>&1 && make -j$(sysctl -n hw.ncpu) >/dev/null 2>&1; then
|
||||||
|
if [[ -w /usr/local/lib ]]; then
|
||||||
|
make install >/dev/null 2>&1
|
||||||
|
else
|
||||||
|
sudo make install >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
ok "gr-gsm installed successfully from source"
|
||||||
|
else
|
||||||
|
warn "Failed to build gr-gsm. GSM Spy feature will not work."
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
ok "gr-gsm already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wireshark (tshark) for GSM packet analysis
|
||||||
|
progress "Installing tshark"
|
||||||
|
if ! cmd_exists tshark; then
|
||||||
|
brew_install wireshark
|
||||||
|
else
|
||||||
|
ok "tshark already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing Ubertooth tools (optional)"
|
progress "Installing Ubertooth tools (optional)"
|
||||||
if ! cmd_exists ubertooth-btle; then
|
if ! cmd_exists ubertooth-btle; then
|
||||||
echo
|
echo
|
||||||
@@ -979,7 +1039,7 @@ install_debian_packages() {
|
|||||||
export NEEDRESTART_MODE=a
|
export NEEDRESTART_MODE=a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=22
|
TOTAL_STEPS=25
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Updating APT package lists"
|
progress "Updating APT package lists"
|
||||||
@@ -1104,6 +1164,82 @@ install_debian_packages() {
|
|||||||
progress "Installing gpsd"
|
progress "Installing gpsd"
|
||||||
apt_install gpsd gpsd-clients || true
|
apt_install gpsd gpsd-clients || true
|
||||||
|
|
||||||
|
# gr-gsm for GSM Intelligence
|
||||||
|
progress "Installing GNU Radio and gr-gsm"
|
||||||
|
if ! cmd_exists grgsm_scanner; then
|
||||||
|
# Try to install gr-gsm directly from package repositories
|
||||||
|
apt_install gnuradio gnuradio-dev gr-osmosdr gr-gsm || {
|
||||||
|
warn "gr-gsm package not available in repositories. Attempting source build..."
|
||||||
|
|
||||||
|
# Fallback: Build from source
|
||||||
|
progress "Building gr-gsm from source"
|
||||||
|
apt_install git cmake libboost-all-dev libcppunit-dev swig \
|
||||||
|
doxygen liblog4cpp5-dev python3-scipy python3-numpy \
|
||||||
|
libvolk-dev libuhd-dev libfftw3-dev || true
|
||||||
|
|
||||||
|
info "Cloning gr-gsm repository..."
|
||||||
|
if [ -d /tmp/gr-gsm ]; then
|
||||||
|
rm -rf /tmp/gr-gsm
|
||||||
|
fi
|
||||||
|
|
||||||
|
git clone https://github.com/bkerler/gr-gsm.git /tmp/gr-gsm || {
|
||||||
|
warn "Failed to clone gr-gsm repository. GSM Spy will not be available."
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cd /tmp/gr-gsm
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
# Try to find GNU Radio cmake files
|
||||||
|
if [ -d /usr/lib/x86_64-linux-gnu/cmake/gnuradio ]; then
|
||||||
|
export CMAKE_PREFIX_PATH="/usr/lib/x86_64-linux-gnu/cmake/gnuradio:$CMAKE_PREFIX_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Running CMake configuration..."
|
||||||
|
if cmake .. 2>/dev/null; then
|
||||||
|
info "Compiling gr-gsm (this may take several minutes)..."
|
||||||
|
if make -j$(nproc) 2>/dev/null; then
|
||||||
|
$SUDO make install
|
||||||
|
$SUDO ldconfig
|
||||||
|
cd ~
|
||||||
|
rm -rf /tmp/gr-gsm
|
||||||
|
ok "gr-gsm built and installed successfully"
|
||||||
|
else
|
||||||
|
warn "gr-gsm compilation failed. GSM Spy feature will not work."
|
||||||
|
cd ~
|
||||||
|
rm -rf /tmp/gr-gsm
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "gr-gsm CMake configuration failed. GNU Radio 3.8+ may not be available."
|
||||||
|
cd ~
|
||||||
|
rm -rf /tmp/gr-gsm
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
if cmd_exists grgsm_scanner; then
|
||||||
|
ok "gr-gsm installed successfully"
|
||||||
|
else
|
||||||
|
warn "gr-gsm installation incomplete. GSM Spy feature will not work."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "gr-gsm already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wireshark (tshark) for GSM packet analysis
|
||||||
|
progress "Installing tshark"
|
||||||
|
if ! cmd_exists tshark; then
|
||||||
|
# Pre-accept non-root capture prompt for non-interactive install
|
||||||
|
echo 'wireshark-common wireshark-common/install-setuid boolean true' | $SUDO debconf-set-selections
|
||||||
|
apt_install tshark || true
|
||||||
|
# Allow non-root capture
|
||||||
|
$SUDO dpkg-reconfigure wireshark-common 2>/dev/null || true
|
||||||
|
$SUDO usermod -a -G wireshark $USER 2>/dev/null || true
|
||||||
|
ok "tshark installed. You may need to re-login for wireshark group permissions."
|
||||||
|
else
|
||||||
|
ok "tshark already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing Python packages"
|
progress "Installing Python packages"
|
||||||
apt_install python3-venv python3-pip || true
|
apt_install python3-venv python3-pip || true
|
||||||
# Install Python packages via apt (more reliable than pip on modern Debian/Ubuntu)
|
# Install Python packages via apt (more reliable than pip on modern Debian/Ubuntu)
|
||||||
@@ -1185,6 +1321,14 @@ final_summary_and_hard_fail() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "${#missing_recommended[@]}" -gt 0 ]]; then
|
||||||
|
echo
|
||||||
|
warn "Missing RECOMMENDED tools (some features will not work):"
|
||||||
|
for t in "${missing_recommended[@]}"; do echo " - $t"; done
|
||||||
|
echo
|
||||||
|
warn "Install these for full functionality (GSM Intelligence, etc.)"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
|
|||||||
@@ -19,6 +19,17 @@
|
|||||||
min-width: max-content;
|
min-width: max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Strip title badge */
|
||||||
|
.function-strip .strip-title {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Stats */
|
/* Stats */
|
||||||
.function-strip .strip-stat {
|
.function-strip .strip-stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -69,6 +69,24 @@ const scannerPresets = {
|
|||||||
amateur70cm: { start: 420, end: 450, step: 25, mod: 'fm' }
|
amateur70cm: { start: 420, end: 450, step: 25, mod: 'fm' }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest the appropriate modulation for a given frequency (in MHz).
|
||||||
|
* Uses standard band allocations to pick AM, NFM, WFM, or USB.
|
||||||
|
*/
|
||||||
|
function suggestModulation(freqMhz) {
|
||||||
|
if (freqMhz < 0.52) return 'am'; // LW/MW AM broadcast
|
||||||
|
if (freqMhz < 1.7) return 'am'; // MW AM broadcast
|
||||||
|
if (freqMhz < 30) return 'usb'; // HF/Shortwave
|
||||||
|
if (freqMhz < 88) return 'fm'; // VHF Low (public safety)
|
||||||
|
if (freqMhz < 108) return 'wfm'; // FM Broadcast
|
||||||
|
if (freqMhz < 137) return 'am'; // Airband
|
||||||
|
if (freqMhz < 174) return 'fm'; // VHF marine, 2m ham, pagers
|
||||||
|
if (freqMhz < 216) return 'wfm'; // VHF TV/DAB
|
||||||
|
if (freqMhz < 470) return 'fm'; // UHF various, 70cm, business/GMRS
|
||||||
|
if (freqMhz < 960) return 'wfm'; // UHF TV
|
||||||
|
return 'am'; // Microwave/ADS-B
|
||||||
|
}
|
||||||
|
|
||||||
const audioPresets = {
|
const audioPresets = {
|
||||||
fm: { freq: 98.1, mod: 'wfm' },
|
fm: { freq: 98.1, mod: 'wfm' },
|
||||||
airband: { freq: 121.5, mod: 'am' }, // Emergency/guard frequency
|
airband: { freq: 121.5, mod: 'am' }, // Emergency/guard frequency
|
||||||
@@ -1886,6 +1904,8 @@ function initListeningPost() {
|
|||||||
// Connect radio knobs to scanner controls
|
// Connect radio knobs to scanner controls
|
||||||
initRadioKnobControls();
|
initRadioKnobControls();
|
||||||
|
|
||||||
|
initWaterfallZoomControls();
|
||||||
|
|
||||||
// Step dropdown - sync with scanner when changed
|
// Step dropdown - sync with scanner when changed
|
||||||
const stepSelect = document.getElementById('radioScanStep');
|
const stepSelect = document.getElementById('radioScanStep');
|
||||||
if (stepSelect) {
|
if (stepSelect) {
|
||||||
@@ -2312,8 +2332,7 @@ async function _startDirectListenInternal() {
|
|||||||
isDirectListening = false;
|
isDirectListening = false;
|
||||||
updateDirectListenUI(false);
|
updateDirectListenUI(false);
|
||||||
if (resumeRfWaterfallAfterListening) {
|
if (resumeRfWaterfallAfterListening) {
|
||||||
resumeRfWaterfallAfterListening = false;
|
scheduleWaterfallResume();
|
||||||
setTimeout(() => startWaterfall(), 200);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2366,8 +2385,7 @@ async function _startDirectListenInternal() {
|
|||||||
isWaterfallRunning = true;
|
isWaterfallRunning = true;
|
||||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||||
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||||
document.getElementById('startWaterfallBtn').style.display = 'none';
|
setWaterfallControlButtons(true);
|
||||||
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
|
||||||
startAudioWaterfall();
|
startAudioWaterfall();
|
||||||
}
|
}
|
||||||
updateDirectListenUI(true, freq);
|
updateDirectListenUI(true, freq);
|
||||||
@@ -2379,8 +2397,7 @@ async function _startDirectListenInternal() {
|
|||||||
isDirectListening = false;
|
isDirectListening = false;
|
||||||
updateDirectListenUI(false);
|
updateDirectListenUI(false);
|
||||||
if (resumeRfWaterfallAfterListening) {
|
if (resumeRfWaterfallAfterListening) {
|
||||||
resumeRfWaterfallAfterListening = false;
|
scheduleWaterfallResume();
|
||||||
setTimeout(() => startWaterfall(), 200);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isRestarting = false;
|
isRestarting = false;
|
||||||
@@ -2537,7 +2554,7 @@ async function startWebSocketListen(config, audioPlayer) {
|
|||||||
/**
|
/**
|
||||||
* Stop direct listening
|
* Stop direct listening
|
||||||
*/
|
*/
|
||||||
function stopDirectListen() {
|
async function stopDirectListen() {
|
||||||
console.log('[LISTEN] Stopping');
|
console.log('[LISTEN] Stopping');
|
||||||
|
|
||||||
// Clear all pending state
|
// Clear all pending state
|
||||||
@@ -2572,7 +2589,7 @@ function stopDirectListen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Also stop via HTTP (fallback)
|
// Also stop via HTTP (fallback)
|
||||||
fetch('/listening/audio/stop', { method: 'POST' }).catch(() => {});
|
const audioStopPromise = fetch('/listening/audio/stop', { method: 'POST' }).catch(() => {});
|
||||||
|
|
||||||
isDirectListening = false;
|
isDirectListening = false;
|
||||||
currentSignalLevel = 0;
|
currentSignalLevel = 0;
|
||||||
@@ -2584,13 +2601,16 @@ function stopDirectListen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resumeRfWaterfallAfterListening) {
|
if (resumeRfWaterfallAfterListening) {
|
||||||
resumeRfWaterfallAfterListening = false;
|
|
||||||
isWaterfallRunning = false;
|
isWaterfallRunning = false;
|
||||||
setTimeout(() => startWaterfall(), 200);
|
setWaterfallControlButtons(false);
|
||||||
|
await Promise.race([
|
||||||
|
audioStopPromise,
|
||||||
|
new Promise(resolve => setTimeout(resolve, 400))
|
||||||
|
]);
|
||||||
|
scheduleWaterfallResume();
|
||||||
} else if (waterfallMode === 'audio' && isWaterfallRunning) {
|
} else if (waterfallMode === 'audio' && isWaterfallRunning) {
|
||||||
isWaterfallRunning = false;
|
isWaterfallRunning = false;
|
||||||
document.getElementById('startWaterfallBtn').style.display = 'block';
|
setWaterfallControlButtons(false);
|
||||||
document.getElementById('stopWaterfallBtn').style.display = 'none';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3067,6 +3087,17 @@ let waterfallMode = 'rf';
|
|||||||
let audioWaterfallAnimId = null;
|
let audioWaterfallAnimId = null;
|
||||||
let lastAudioWaterfallDraw = 0;
|
let lastAudioWaterfallDraw = 0;
|
||||||
let resumeRfWaterfallAfterListening = false;
|
let resumeRfWaterfallAfterListening = false;
|
||||||
|
let waterfallResumeTimer = null;
|
||||||
|
let waterfallResumeAttempts = 0;
|
||||||
|
const WATERFALL_RESUME_MAX_ATTEMPTS = 8;
|
||||||
|
const WATERFALL_RESUME_RETRY_MS = 350;
|
||||||
|
const WATERFALL_ZOOM_MIN_MHZ = 0.1;
|
||||||
|
const WATERFALL_ZOOM_MAX_MHZ = 500;
|
||||||
|
const WATERFALL_DEFAULT_SPAN_MHZ = 2.0;
|
||||||
|
|
||||||
|
// WebSocket waterfall state
|
||||||
|
let waterfallWebSocket = null;
|
||||||
|
let waterfallUseWebSocket = false;
|
||||||
|
|
||||||
function resizeCanvasToDisplaySize(canvas) {
|
function resizeCanvasToDisplaySize(canvas) {
|
||||||
if (!canvas) return false;
|
if (!canvas) return false;
|
||||||
@@ -3137,6 +3168,214 @@ function initWaterfallCanvas() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setWaterfallControlButtons(running) {
|
||||||
|
const startBtn = document.getElementById('startWaterfallBtn');
|
||||||
|
const stopBtn = document.getElementById('stopWaterfallBtn');
|
||||||
|
if (!startBtn || !stopBtn) return;
|
||||||
|
startBtn.style.display = running ? 'none' : 'inline-block';
|
||||||
|
stopBtn.style.display = running ? 'inline-block' : 'none';
|
||||||
|
const dot = document.getElementById('waterfallStripDot');
|
||||||
|
if (dot) {
|
||||||
|
dot.className = running ? 'status-dot sweeping' : 'status-dot inactive';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWaterfallRangeFromInputs() {
|
||||||
|
const startInput = document.getElementById('waterfallStartFreq');
|
||||||
|
const endInput = document.getElementById('waterfallEndFreq');
|
||||||
|
const startVal = parseFloat(startInput?.value);
|
||||||
|
const endVal = parseFloat(endInput?.value);
|
||||||
|
const start = Number.isFinite(startVal) ? startVal : waterfallStartFreq;
|
||||||
|
const end = Number.isFinite(endVal) ? endVal : waterfallEndFreq;
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWaterfallZoomLabel(start, end) {
|
||||||
|
const label = document.getElementById('waterfallZoomSpan');
|
||||||
|
if (!label) return;
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end)) return;
|
||||||
|
const span = Math.max(0, end - start);
|
||||||
|
if (span >= 1) {
|
||||||
|
label.textContent = `${span.toFixed(1)} MHz`;
|
||||||
|
} else {
|
||||||
|
label.textContent = `${Math.round(span * 1000)} kHz`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWaterfallRange(center, span) {
|
||||||
|
if (!Number.isFinite(center) || !Number.isFinite(span)) return;
|
||||||
|
const clampedSpan = Math.max(WATERFALL_ZOOM_MIN_MHZ, Math.min(WATERFALL_ZOOM_MAX_MHZ, span));
|
||||||
|
const half = clampedSpan / 2;
|
||||||
|
let start = center - half;
|
||||||
|
let end = center + half;
|
||||||
|
const minFreq = 0.01;
|
||||||
|
if (start < minFreq) {
|
||||||
|
end += (minFreq - start);
|
||||||
|
start = minFreq;
|
||||||
|
}
|
||||||
|
if (end <= start) {
|
||||||
|
end = start + WATERFALL_ZOOM_MIN_MHZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
waterfallStartFreq = start;
|
||||||
|
waterfallEndFreq = end;
|
||||||
|
|
||||||
|
const startInput = document.getElementById('waterfallStartFreq');
|
||||||
|
const endInput = document.getElementById('waterfallEndFreq');
|
||||||
|
if (startInput) startInput.value = start.toFixed(3);
|
||||||
|
if (endInput) endInput.value = end.toFixed(3);
|
||||||
|
|
||||||
|
const rangeLabel = document.getElementById('waterfallFreqRange');
|
||||||
|
if (rangeLabel && !isWaterfallRunning) {
|
||||||
|
rangeLabel.textContent = `${start.toFixed(1)} - ${end.toFixed(1)} MHz`;
|
||||||
|
}
|
||||||
|
updateWaterfallZoomLabel(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWaterfallCenterForZoom(start, end) {
|
||||||
|
const tuned = parseFloat(document.getElementById('radioScanStart')?.value || '');
|
||||||
|
if (Number.isFinite(tuned) && tuned > 0) return tuned;
|
||||||
|
return (start + end) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncWaterfallToFrequency(freq, options = {}) {
|
||||||
|
const { autoStart = false, restartIfRunning = true, silent = true } = options;
|
||||||
|
const numericFreq = parseFloat(freq);
|
||||||
|
if (!Number.isFinite(numericFreq) || numericFreq <= 0) return { started: false };
|
||||||
|
|
||||||
|
const { start, end } = getWaterfallRangeFromInputs();
|
||||||
|
const span = (Number.isFinite(start) && Number.isFinite(end) && end > start)
|
||||||
|
? (end - start)
|
||||||
|
: WATERFALL_DEFAULT_SPAN_MHZ;
|
||||||
|
|
||||||
|
setWaterfallRange(numericFreq, span);
|
||||||
|
|
||||||
|
if (!autoStart) return { started: false };
|
||||||
|
if (isDirectListening || waterfallMode === 'audio') return { started: false };
|
||||||
|
|
||||||
|
if (isWaterfallRunning && waterfallMode === 'rf' && restartIfRunning) {
|
||||||
|
// Reuse existing WebSocket to avoid USB device release race
|
||||||
|
if (waterfallUseWebSocket && waterfallWebSocket && waterfallWebSocket.readyState === WebSocket.OPEN) {
|
||||||
|
const sf = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
|
||||||
|
const ef = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
|
||||||
|
const fft = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024);
|
||||||
|
const g = parseInt(document.getElementById('waterfallGain')?.value || 40);
|
||||||
|
const dev = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||||
|
waterfallWebSocket.send(JSON.stringify({
|
||||||
|
cmd: 'start',
|
||||||
|
center_freq: (sf + ef) / 2,
|
||||||
|
span_mhz: Math.max(0.1, ef - sf),
|
||||||
|
gain: g,
|
||||||
|
device: dev,
|
||||||
|
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
|
||||||
|
fft_size: fft,
|
||||||
|
fps: 25,
|
||||||
|
avg_count: 4,
|
||||||
|
}));
|
||||||
|
return { started: true };
|
||||||
|
}
|
||||||
|
await stopWaterfall();
|
||||||
|
return await startWaterfall({ silent: silent });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWaterfallRunning) {
|
||||||
|
return await startWaterfall({ silent: silent });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { started: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function zoomWaterfall(direction) {
|
||||||
|
const { start, end } = getWaterfallRangeFromInputs();
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return;
|
||||||
|
|
||||||
|
const zoomIn = direction === 'in' || direction === '+';
|
||||||
|
const zoomOut = direction === 'out' || direction === '-';
|
||||||
|
if (!zoomIn && !zoomOut) return;
|
||||||
|
|
||||||
|
const span = end - start;
|
||||||
|
const newSpan = zoomIn ? span / 2 : span * 2;
|
||||||
|
const center = getWaterfallCenterForZoom(start, end);
|
||||||
|
setWaterfallRange(center, newSpan);
|
||||||
|
|
||||||
|
if (isWaterfallRunning && waterfallMode === 'rf' && !isDirectListening) {
|
||||||
|
// Reuse existing WebSocket to avoid USB device release race
|
||||||
|
if (waterfallUseWebSocket && waterfallWebSocket && waterfallWebSocket.readyState === WebSocket.OPEN) {
|
||||||
|
const sf = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
|
||||||
|
const ef = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
|
||||||
|
const fft = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024);
|
||||||
|
const g = parseInt(document.getElementById('waterfallGain')?.value || 40);
|
||||||
|
const dev = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||||
|
waterfallWebSocket.send(JSON.stringify({
|
||||||
|
cmd: 'start',
|
||||||
|
center_freq: (sf + ef) / 2,
|
||||||
|
span_mhz: Math.max(0.1, ef - sf),
|
||||||
|
gain: g,
|
||||||
|
device: dev,
|
||||||
|
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
|
||||||
|
fft_size: fft,
|
||||||
|
fps: 25,
|
||||||
|
avg_count: 4,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
await stopWaterfall();
|
||||||
|
await startWaterfall({ silent: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWaterfallZoomControls() {
|
||||||
|
const startInput = document.getElementById('waterfallStartFreq');
|
||||||
|
const endInput = document.getElementById('waterfallEndFreq');
|
||||||
|
if (!startInput && !endInput) return;
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
const { start, end } = getWaterfallRangeFromInputs();
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return;
|
||||||
|
waterfallStartFreq = start;
|
||||||
|
waterfallEndFreq = end;
|
||||||
|
updateWaterfallZoomLabel(start, end);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startInput) startInput.addEventListener('input', sync);
|
||||||
|
if (endInput) endInput.addEventListener('input', sync);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWaterfallResume() {
|
||||||
|
if (!resumeRfWaterfallAfterListening) return;
|
||||||
|
if (waterfallResumeTimer) {
|
||||||
|
clearTimeout(waterfallResumeTimer);
|
||||||
|
waterfallResumeTimer = null;
|
||||||
|
}
|
||||||
|
waterfallResumeAttempts = 0;
|
||||||
|
waterfallResumeTimer = setTimeout(attemptWaterfallResume, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attemptWaterfallResume() {
|
||||||
|
if (!resumeRfWaterfallAfterListening) return;
|
||||||
|
if (isDirectListening) {
|
||||||
|
waterfallResumeTimer = setTimeout(attemptWaterfallResume, WATERFALL_RESUME_RETRY_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await startWaterfall({ silent: true, resume: true });
|
||||||
|
if (result && result.started) {
|
||||||
|
waterfallResumeTimer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryable = result ? result.retryable : true;
|
||||||
|
if (retryable && waterfallResumeAttempts < WATERFALL_RESUME_MAX_ATTEMPTS) {
|
||||||
|
waterfallResumeAttempts += 1;
|
||||||
|
waterfallResumeTimer = setTimeout(attemptWaterfallResume, WATERFALL_RESUME_RETRY_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resumeRfWaterfallAfterListening = false;
|
||||||
|
waterfallResumeTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
function setWaterfallMode(mode) {
|
function setWaterfallMode(mode) {
|
||||||
waterfallMode = mode;
|
waterfallMode = mode;
|
||||||
const header = document.getElementById('waterfallFreqRange');
|
const header = document.getElementById('waterfallFreqRange');
|
||||||
@@ -3334,18 +3573,209 @@ function drawSpectrumLine(bins, startFreq, endFreq, labelUnit) {
|
|||||||
spectrumCtx.fill();
|
spectrumCtx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startWaterfall() {
|
function connectWaterfallWebSocket(config) {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/ws/waterfall`;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
ws.close();
|
||||||
|
reject(new Error('WebSocket connection timeout'));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.send(JSON.stringify({ cmd: 'start', ...config }));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.status === 'started') {
|
||||||
|
waterfallWebSocket = ws;
|
||||||
|
waterfallUseWebSocket = true;
|
||||||
|
if (typeof msg.start_freq === 'number') waterfallStartFreq = msg.start_freq;
|
||||||
|
if (typeof msg.end_freq === 'number') waterfallEndFreq = msg.end_freq;
|
||||||
|
const rangeLabel = document.getElementById('waterfallFreqRange');
|
||||||
|
if (rangeLabel) {
|
||||||
|
rangeLabel.textContent = `${waterfallStartFreq.toFixed(1)} - ${waterfallEndFreq.toFixed(1)} MHz`;
|
||||||
|
}
|
||||||
|
updateWaterfallZoomLabel(waterfallStartFreq, waterfallEndFreq);
|
||||||
|
resolve(ws);
|
||||||
|
} else if (msg.status === 'error') {
|
||||||
|
ws.close();
|
||||||
|
reject(new Error(msg.message || 'WebSocket waterfall error'));
|
||||||
|
} else if (msg.status === 'stopped') {
|
||||||
|
// Server confirmed stop
|
||||||
|
}
|
||||||
|
} else if (event.data instanceof ArrayBuffer) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastWaterfallDraw < WATERFALL_MIN_INTERVAL_MS) return;
|
||||||
|
lastWaterfallDraw = now;
|
||||||
|
parseBinaryWaterfallFrame(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error('WebSocket connection failed'));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (waterfallUseWebSocket && isWaterfallRunning) {
|
||||||
|
waterfallWebSocket = null;
|
||||||
|
waterfallUseWebSocket = false;
|
||||||
|
isWaterfallRunning = false;
|
||||||
|
setWaterfallControlButtons(false);
|
||||||
|
if (typeof releaseDevice === 'function') {
|
||||||
|
releaseDevice('waterfall');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBinaryWaterfallFrame(buffer) {
|
||||||
|
if (buffer.byteLength < 11) return;
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
const msgType = view.getUint8(0);
|
||||||
|
if (msgType !== 0x01) return;
|
||||||
|
|
||||||
|
const startFreq = view.getFloat32(1, true);
|
||||||
|
const endFreq = view.getFloat32(5, true);
|
||||||
|
const binCount = view.getUint16(9, true);
|
||||||
|
|
||||||
|
if (buffer.byteLength < 11 + binCount) return;
|
||||||
|
|
||||||
|
const bins = new Uint8Array(buffer, 11, binCount);
|
||||||
|
|
||||||
|
waterfallStartFreq = startFreq;
|
||||||
|
waterfallEndFreq = endFreq;
|
||||||
|
const rangeLabel = document.getElementById('waterfallFreqRange');
|
||||||
|
if (rangeLabel) {
|
||||||
|
rangeLabel.textContent = `${startFreq.toFixed(1)} - ${endFreq.toFixed(1)} MHz`;
|
||||||
|
}
|
||||||
|
updateWaterfallZoomLabel(startFreq, endFreq);
|
||||||
|
|
||||||
|
drawWaterfallRowBinary(bins);
|
||||||
|
drawSpectrumLineBinary(bins, startFreq, endFreq);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWaterfallRowBinary(bins) {
|
||||||
|
if (!waterfallCtx || !waterfallCanvas) return;
|
||||||
|
const w = waterfallCanvas.width;
|
||||||
|
const h = waterfallCanvas.height;
|
||||||
|
const rowHeight = waterfallRowImage ? waterfallRowImage.height : 1;
|
||||||
|
|
||||||
|
// Scroll existing content down
|
||||||
|
waterfallCtx.drawImage(waterfallCanvas, 0, 0, w, h - rowHeight, 0, rowHeight, w, h - rowHeight);
|
||||||
|
|
||||||
|
if (!waterfallRowImage || waterfallRowImage.width !== w || waterfallRowImage.height !== rowHeight) {
|
||||||
|
waterfallRowImage = waterfallCtx.createImageData(w, rowHeight);
|
||||||
|
}
|
||||||
|
const rowData = waterfallRowImage.data;
|
||||||
|
const palette = waterfallPalette || buildWaterfallPalette();
|
||||||
|
const binCount = bins.length;
|
||||||
|
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const pos = (x / (w - 1)) * (binCount - 1);
|
||||||
|
const i0 = Math.floor(pos);
|
||||||
|
const i1 = Math.min(binCount - 1, i0 + 1);
|
||||||
|
const t = pos - i0;
|
||||||
|
// Interpolate between bins (already uint8, 0-255)
|
||||||
|
const val = Math.round(bins[i0] * (1 - t) + bins[i1] * t);
|
||||||
|
const color = palette[Math.max(0, Math.min(255, val))] || [0, 0, 0];
|
||||||
|
for (let y = 0; y < rowHeight; y++) {
|
||||||
|
const offset = (y * w + x) * 4;
|
||||||
|
rowData[offset] = color[0];
|
||||||
|
rowData[offset + 1] = color[1];
|
||||||
|
rowData[offset + 2] = color[2];
|
||||||
|
rowData[offset + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waterfallCtx.putImageData(waterfallRowImage, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSpectrumLineBinary(bins, startFreq, endFreq) {
|
||||||
|
if (!spectrumCtx || !spectrumCanvas) return;
|
||||||
|
const w = spectrumCanvas.width;
|
||||||
|
const h = spectrumCanvas.height;
|
||||||
|
|
||||||
|
spectrumCtx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Background
|
||||||
|
spectrumCtx.fillStyle = 'rgba(0, 0, 0, 0.8)';
|
||||||
|
spectrumCtx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
spectrumCtx.strokeStyle = 'rgba(0, 200, 255, 0.1)';
|
||||||
|
spectrumCtx.lineWidth = 0.5;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const y = (h / 5) * i;
|
||||||
|
spectrumCtx.beginPath();
|
||||||
|
spectrumCtx.moveTo(0, y);
|
||||||
|
spectrumCtx.lineTo(w, y);
|
||||||
|
spectrumCtx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequency labels
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
spectrumCtx.fillStyle = 'rgba(0, 200, 255, 0.5)';
|
||||||
|
spectrumCtx.font = `${9 * dpr}px monospace`;
|
||||||
|
const freqRange = endFreq - startFreq;
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const freq = startFreq + (freqRange / 4) * i;
|
||||||
|
const x = (w / 4) * i;
|
||||||
|
spectrumCtx.fillText(freq.toFixed(1), x + 2, h - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bins.length === 0) return;
|
||||||
|
|
||||||
|
// Draw spectrum line — bins are pre-quantized 0-255
|
||||||
|
spectrumCtx.strokeStyle = 'rgba(0, 255, 255, 0.9)';
|
||||||
|
spectrumCtx.lineWidth = 1.5;
|
||||||
|
spectrumCtx.beginPath();
|
||||||
|
for (let i = 0; i < bins.length; i++) {
|
||||||
|
const x = (i / (bins.length - 1)) * w;
|
||||||
|
const normalized = bins[i] / 255;
|
||||||
|
const y = h - 12 - normalized * (h - 16);
|
||||||
|
if (i === 0) spectrumCtx.moveTo(x, y);
|
||||||
|
else spectrumCtx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
spectrumCtx.stroke();
|
||||||
|
|
||||||
|
// Fill under line
|
||||||
|
const lastX = w;
|
||||||
|
const lastY = h - 12 - (bins[bins.length - 1] / 255) * (h - 16);
|
||||||
|
spectrumCtx.lineTo(lastX, h);
|
||||||
|
spectrumCtx.lineTo(0, h);
|
||||||
|
spectrumCtx.closePath();
|
||||||
|
spectrumCtx.fillStyle = 'rgba(0, 255, 255, 0.08)';
|
||||||
|
spectrumCtx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWaterfall(options = {}) {
|
||||||
|
const { silent = false, resume = false } = options;
|
||||||
const startFreq = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
|
const startFreq = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
|
||||||
const endFreq = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
|
const endFreq = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
|
||||||
const binSize = parseInt(document.getElementById('waterfallBinSize')?.value || 10000);
|
const fftSize = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024);
|
||||||
const gain = parseInt(document.getElementById('waterfallGain')?.value || 40);
|
const gain = parseInt(document.getElementById('waterfallGain')?.value || 40);
|
||||||
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||||
initWaterfallCanvas();
|
initWaterfallCanvas();
|
||||||
const maxBins = Math.min(4096, Math.max(128, waterfallCanvas ? waterfallCanvas.width : 800));
|
const maxBins = Math.min(4096, Math.max(128, waterfallCanvas ? waterfallCanvas.width : 800));
|
||||||
|
|
||||||
if (startFreq >= endFreq) {
|
if (startFreq >= endFreq) {
|
||||||
if (typeof showNotification === 'function') showNotification('Error', 'End frequency must be greater than start');
|
if (!silent && typeof showNotification === 'function') {
|
||||||
return;
|
showNotification('Error', 'End frequency must be greater than start');
|
||||||
|
}
|
||||||
|
return { started: false, retryable: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
waterfallStartFreq = startFreq;
|
waterfallStartFreq = startFreq;
|
||||||
@@ -3354,69 +3784,165 @@ function startWaterfall() {
|
|||||||
if (rangeLabel) {
|
if (rangeLabel) {
|
||||||
rangeLabel.textContent = `${startFreq.toFixed(1)} - ${endFreq.toFixed(1)} MHz`;
|
rangeLabel.textContent = `${startFreq.toFixed(1)} - ${endFreq.toFixed(1)} MHz`;
|
||||||
}
|
}
|
||||||
|
updateWaterfallZoomLabel(startFreq, endFreq);
|
||||||
|
|
||||||
if (isDirectListening) {
|
if (isDirectListening && !resume) {
|
||||||
isWaterfallRunning = true;
|
isWaterfallRunning = true;
|
||||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||||
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||||
document.getElementById('startWaterfallBtn').style.display = 'none';
|
setWaterfallControlButtons(true);
|
||||||
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
|
||||||
startAudioWaterfall();
|
startAudioWaterfall();
|
||||||
return;
|
resumeRfWaterfallAfterListening = true;
|
||||||
|
return { started: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectListening && resume) {
|
||||||
|
return { started: false, retryable: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
setWaterfallMode('rf');
|
setWaterfallMode('rf');
|
||||||
const spanMhz = Math.max(0.1, waterfallEndFreq - waterfallStartFreq);
|
|
||||||
|
// Try WebSocket path first (I/Q + server-side FFT)
|
||||||
|
const centerFreq = (startFreq + endFreq) / 2;
|
||||||
|
const spanMhz = Math.max(0.1, endFreq - startFreq);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wsConfig = {
|
||||||
|
center_freq: centerFreq,
|
||||||
|
span_mhz: spanMhz,
|
||||||
|
gain: gain,
|
||||||
|
device: device,
|
||||||
|
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
|
||||||
|
fft_size: fftSize,
|
||||||
|
fps: 25,
|
||||||
|
avg_count: 4,
|
||||||
|
};
|
||||||
|
await connectWaterfallWebSocket(wsConfig);
|
||||||
|
|
||||||
|
isWaterfallRunning = true;
|
||||||
|
setWaterfallControlButtons(true);
|
||||||
|
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||||
|
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||||
|
lastWaterfallDraw = 0;
|
||||||
|
initWaterfallCanvas();
|
||||||
|
if (typeof reserveDevice === 'function') {
|
||||||
|
reserveDevice(parseInt(device), 'waterfall');
|
||||||
|
}
|
||||||
|
if (resume || resumeRfWaterfallAfterListening) {
|
||||||
|
resumeRfWaterfallAfterListening = false;
|
||||||
|
}
|
||||||
|
if (waterfallResumeTimer) {
|
||||||
|
clearTimeout(waterfallResumeTimer);
|
||||||
|
waterfallResumeTimer = null;
|
||||||
|
}
|
||||||
|
console.log('[WATERFALL] WebSocket connected');
|
||||||
|
return { started: true };
|
||||||
|
} catch (wsErr) {
|
||||||
|
console.log('[WATERFALL] WebSocket unavailable, falling back to SSE:', wsErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: SSE / rtl_power path
|
||||||
const segments = Math.max(1, Math.ceil(spanMhz / 2.4));
|
const segments = Math.max(1, Math.ceil(spanMhz / 2.4));
|
||||||
const targetSweepSeconds = 0.8;
|
const targetSweepSeconds = 0.8;
|
||||||
const interval = Math.max(0.1, Math.min(0.3, targetSweepSeconds / segments));
|
const interval = Math.max(0.1, Math.min(0.3, targetSweepSeconds / segments));
|
||||||
|
const binSize = fftSize;
|
||||||
|
|
||||||
fetch('/listening/waterfall/start', {
|
try {
|
||||||
method: 'POST',
|
const response = await fetch('/listening/waterfall/start', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
headers: { 'Content-Type': 'application/json' },
|
||||||
start_freq: startFreq,
|
body: JSON.stringify({
|
||||||
end_freq: endFreq,
|
start_freq: startFreq,
|
||||||
bin_size: binSize,
|
end_freq: endFreq,
|
||||||
gain: gain,
|
bin_size: binSize,
|
||||||
device: device,
|
gain: gain,
|
||||||
max_bins: maxBins,
|
device: device,
|
||||||
interval: interval,
|
max_bins: maxBins,
|
||||||
})
|
interval: interval,
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
});
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'started') {
|
let data = {};
|
||||||
isWaterfallRunning = true;
|
try {
|
||||||
document.getElementById('startWaterfallBtn').style.display = 'none';
|
data = await response.json();
|
||||||
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
} catch (e) {}
|
||||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
|
||||||
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
if (!response.ok || data.status !== 'started') {
|
||||||
lastWaterfallDraw = 0;
|
if (!silent && typeof showNotification === 'function') {
|
||||||
initWaterfallCanvas();
|
showNotification('Error', data.message || 'Failed to start waterfall');
|
||||||
connectWaterfallSSE();
|
}
|
||||||
} else {
|
return {
|
||||||
if (typeof showNotification === 'function') showNotification('Error', data.message || 'Failed to start waterfall');
|
started: false,
|
||||||
|
retryable: response.status === 409 || data.error_type === 'DEVICE_BUSY'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch(err => console.error('[WATERFALL] Start error:', err));
|
isWaterfallRunning = true;
|
||||||
|
setWaterfallControlButtons(true);
|
||||||
|
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||||
|
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||||
|
lastWaterfallDraw = 0;
|
||||||
|
initWaterfallCanvas();
|
||||||
|
connectWaterfallSSE();
|
||||||
|
if (typeof reserveDevice === 'function') {
|
||||||
|
reserveDevice(parseInt(device), 'waterfall');
|
||||||
|
}
|
||||||
|
if (resume || resumeRfWaterfallAfterListening) {
|
||||||
|
resumeRfWaterfallAfterListening = false;
|
||||||
|
}
|
||||||
|
if (waterfallResumeTimer) {
|
||||||
|
clearTimeout(waterfallResumeTimer);
|
||||||
|
waterfallResumeTimer = null;
|
||||||
|
}
|
||||||
|
return { started: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WATERFALL] Start error:', err);
|
||||||
|
if (!silent && typeof showNotification === 'function') {
|
||||||
|
showNotification('Error', 'Failed to start waterfall');
|
||||||
|
}
|
||||||
|
return { started: false, retryable: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopWaterfall() {
|
async function stopWaterfall() {
|
||||||
if (waterfallMode === 'audio') {
|
if (waterfallMode === 'audio') {
|
||||||
stopAudioWaterfall();
|
stopAudioWaterfall();
|
||||||
isWaterfallRunning = false;
|
isWaterfallRunning = false;
|
||||||
document.getElementById('startWaterfallBtn').style.display = 'block';
|
setWaterfallControlButtons(false);
|
||||||
document.getElementById('stopWaterfallBtn').style.display = 'none';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebSocket path
|
||||||
|
if (waterfallUseWebSocket && waterfallWebSocket) {
|
||||||
|
try {
|
||||||
|
if (waterfallWebSocket.readyState === WebSocket.OPEN) {
|
||||||
|
waterfallWebSocket.send(JSON.stringify({ cmd: 'stop' }));
|
||||||
|
}
|
||||||
|
waterfallWebSocket.close();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[WATERFALL] WebSocket stop error:', e);
|
||||||
|
}
|
||||||
|
waterfallWebSocket = null;
|
||||||
|
waterfallUseWebSocket = false;
|
||||||
|
isWaterfallRunning = false;
|
||||||
|
setWaterfallControlButtons(false);
|
||||||
|
if (typeof releaseDevice === 'function') {
|
||||||
|
releaseDevice('waterfall');
|
||||||
|
}
|
||||||
|
// Allow backend WebSocket handler to finish cleanup and release SDR
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE fallback path
|
||||||
try {
|
try {
|
||||||
await fetch('/listening/waterfall/stop', { method: 'POST' });
|
await fetch('/listening/waterfall/stop', { method: 'POST' });
|
||||||
isWaterfallRunning = false;
|
isWaterfallRunning = false;
|
||||||
if (waterfallEventSource) { waterfallEventSource.close(); waterfallEventSource = null; }
|
if (waterfallEventSource) { waterfallEventSource.close(); waterfallEventSource = null; }
|
||||||
document.getElementById('startWaterfallBtn').style.display = 'block';
|
setWaterfallControlButtons(false);
|
||||||
document.getElementById('stopWaterfallBtn').style.display = 'none';
|
if (typeof releaseDevice === 'function') {
|
||||||
|
releaseDevice('waterfall');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[WATERFALL] Stop error:', err);
|
console.error('[WATERFALL] Stop error:', err);
|
||||||
}
|
}
|
||||||
@@ -3436,6 +3962,7 @@ function connectWaterfallSSE() {
|
|||||||
if (rangeLabel) {
|
if (rangeLabel) {
|
||||||
rangeLabel.textContent = `${waterfallStartFreq.toFixed(1)} - ${waterfallEndFreq.toFixed(1)} MHz`;
|
rangeLabel.textContent = `${waterfallStartFreq.toFixed(1)} - ${waterfallEndFreq.toFixed(1)} MHz`;
|
||||||
}
|
}
|
||||||
|
updateWaterfallZoomLabel(waterfallStartFreq, waterfallEndFreq);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastWaterfallDraw < WATERFALL_MIN_INTERVAL_MS) return;
|
if (now - lastWaterfallDraw < WATERFALL_MIN_INTERVAL_MS) return;
|
||||||
lastWaterfallDraw = now;
|
lastWaterfallDraw = now;
|
||||||
@@ -3462,17 +3989,51 @@ function bindWaterfallInteraction() {
|
|||||||
const ratio = Math.max(0, Math.min(1, x / rect.width));
|
const ratio = Math.max(0, Math.min(1, x / rect.width));
|
||||||
const freq = waterfallStartFreq + ratio * (waterfallEndFreq - waterfallStartFreq);
|
const freq = waterfallStartFreq + ratio * (waterfallEndFreq - waterfallStartFreq);
|
||||||
if (typeof tuneToFrequency === 'function') {
|
if (typeof tuneToFrequency === 'function') {
|
||||||
tuneToFrequency(freq, typeof currentModulation !== 'undefined' ? currentModulation : undefined);
|
tuneToFrequency(freq, suggestModulation(freq));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tooltip for showing frequency + modulation on hover
|
||||||
|
let tooltip = document.getElementById('waterfallTooltip');
|
||||||
|
if (!tooltip) {
|
||||||
|
tooltip = document.createElement('div');
|
||||||
|
tooltip.id = 'waterfallTooltip';
|
||||||
|
tooltip.style.cssText = 'position:fixed;pointer-events:none;background:rgba(0,0,0,0.85);color:#0f0;padding:4px 8px;border-radius:4px;font-size:12px;font-family:monospace;z-index:9999;display:none;white-space:nowrap;border:1px solid #333;';
|
||||||
|
document.body.appendChild(tooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoverHandler = (event) => {
|
||||||
|
if (waterfallMode === 'audio') {
|
||||||
|
tooltip.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvas = event.currentTarget;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = event.clientX - rect.left;
|
||||||
|
const ratio = Math.max(0, Math.min(1, x / rect.width));
|
||||||
|
const freq = waterfallStartFreq + ratio * (waterfallEndFreq - waterfallStartFreq);
|
||||||
|
const mod = suggestModulation(freq);
|
||||||
|
tooltip.textContent = `${freq.toFixed(3)} MHz \u00b7 ${mod.toUpperCase()}`;
|
||||||
|
tooltip.style.left = (event.clientX + 12) + 'px';
|
||||||
|
tooltip.style.top = (event.clientY - 28) + 'px';
|
||||||
|
tooltip.style.display = 'block';
|
||||||
|
};
|
||||||
|
|
||||||
|
const leaveHandler = () => {
|
||||||
|
tooltip.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
if (waterfallCanvas) {
|
if (waterfallCanvas) {
|
||||||
waterfallCanvas.style.cursor = 'crosshair';
|
waterfallCanvas.style.cursor = 'crosshair';
|
||||||
waterfallCanvas.addEventListener('click', handler);
|
waterfallCanvas.addEventListener('click', handler);
|
||||||
|
waterfallCanvas.addEventListener('mousemove', hoverHandler);
|
||||||
|
waterfallCanvas.addEventListener('mouseleave', leaveHandler);
|
||||||
}
|
}
|
||||||
if (spectrumCanvas) {
|
if (spectrumCanvas) {
|
||||||
spectrumCanvas.style.cursor = 'crosshair';
|
spectrumCanvas.style.cursor = 'crosshair';
|
||||||
spectrumCanvas.addEventListener('click', handler);
|
spectrumCanvas.addEventListener('click', handler);
|
||||||
|
spectrumCanvas.addEventListener('mousemove', hoverHandler);
|
||||||
|
spectrumCanvas.addEventListener('mouseleave', leaveHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3497,3 +4058,5 @@ window.manualSignalGuess = manualSignalGuess;
|
|||||||
window.guessSignal = guessSignal;
|
window.guessSignal = guessSignal;
|
||||||
window.startWaterfall = startWaterfall;
|
window.startWaterfall = startWaterfall;
|
||||||
window.stopWaterfall = stopWaterfall;
|
window.stopWaterfall = stopWaterfall;
|
||||||
|
window.zoomWaterfall = zoomWaterfall;
|
||||||
|
window.syncWaterfallToFrequency = syncWaterfallToFrequency;
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ const SSTVGeneral = (function() {
|
|||||||
let currentMode = null;
|
let currentMode = null;
|
||||||
let progress = 0;
|
let progress = 0;
|
||||||
|
|
||||||
|
// Signal scope state
|
||||||
|
let sstvGeneralScopeCtx = null;
|
||||||
|
let sstvGeneralScopeAnim = null;
|
||||||
|
let sstvGeneralScopeHistory = [];
|
||||||
|
const SSTV_GENERAL_SCOPE_LEN = 200;
|
||||||
|
let sstvGeneralScopeRms = 0;
|
||||||
|
let sstvGeneralScopePeak = 0;
|
||||||
|
let sstvGeneralScopeTargetRms = 0;
|
||||||
|
let sstvGeneralScopeTargetPeak = 0;
|
||||||
|
let sstvGeneralScopeMsgBurst = 0;
|
||||||
|
let sstvGeneralScopeTone = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the SSTV General mode
|
* Initialize the SSTV General mode
|
||||||
*/
|
*/
|
||||||
@@ -190,6 +202,136 @@ const SSTVGeneral = (function() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize signal scope canvas
|
||||||
|
*/
|
||||||
|
function initSstvGeneralScope() {
|
||||||
|
const canvas = document.getElementById('sstvGeneralScopeCanvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
||||||
|
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
||||||
|
sstvGeneralScopeCtx = canvas.getContext('2d');
|
||||||
|
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
|
||||||
|
sstvGeneralScopeRms = 0;
|
||||||
|
sstvGeneralScopePeak = 0;
|
||||||
|
sstvGeneralScopeTargetRms = 0;
|
||||||
|
sstvGeneralScopeTargetPeak = 0;
|
||||||
|
sstvGeneralScopeMsgBurst = 0;
|
||||||
|
sstvGeneralScopeTone = null;
|
||||||
|
drawSstvGeneralScope();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw signal scope animation frame
|
||||||
|
*/
|
||||||
|
function drawSstvGeneralScope() {
|
||||||
|
const ctx = sstvGeneralScopeCtx;
|
||||||
|
if (!ctx) return;
|
||||||
|
const W = ctx.canvas.width;
|
||||||
|
const H = ctx.canvas.height;
|
||||||
|
const midY = H / 2;
|
||||||
|
|
||||||
|
// Phosphor persistence
|
||||||
|
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Smooth towards target
|
||||||
|
sstvGeneralScopeRms += (sstvGeneralScopeTargetRms - sstvGeneralScopeRms) * 0.25;
|
||||||
|
sstvGeneralScopePeak += (sstvGeneralScopeTargetPeak - sstvGeneralScopePeak) * 0.15;
|
||||||
|
|
||||||
|
// Push to history
|
||||||
|
sstvGeneralScopeHistory.push(Math.min(sstvGeneralScopeRms / 32768, 1.0));
|
||||||
|
if (sstvGeneralScopeHistory.length > SSTV_GENERAL_SCOPE_LEN) sstvGeneralScopeHistory.shift();
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
for (let i = 1; i < 4; i++) {
|
||||||
|
const y = (H / 4) * i;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let i = 1; i < 8; i++) {
|
||||||
|
const x = (W / 8) * i;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waveform
|
||||||
|
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
|
||||||
|
ctx.strokeStyle = '#c080ff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.shadowColor = '#c080ff';
|
||||||
|
ctx.shadowBlur = 4;
|
||||||
|
|
||||||
|
// Upper half
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
||||||
|
const y = midY - amp;
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Lower half (mirror)
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
||||||
|
const y = midY + amp;
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// Peak indicator
|
||||||
|
const peakNorm = Math.min(sstvGeneralScopePeak / 32768, 1.0);
|
||||||
|
if (peakNorm > 0.01) {
|
||||||
|
const peakY = midY - peakNorm * midY * 0.9;
|
||||||
|
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image decode flash
|
||||||
|
if (sstvGeneralScopeMsgBurst > 0.01) {
|
||||||
|
ctx.fillStyle = `rgba(0, 255, 100, ${sstvGeneralScopeMsgBurst * 0.15})`;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
sstvGeneralScopeMsgBurst *= 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update labels
|
||||||
|
const rmsLabel = document.getElementById('sstvGeneralScopeRmsLabel');
|
||||||
|
const peakLabel = document.getElementById('sstvGeneralScopePeakLabel');
|
||||||
|
const toneLabel = document.getElementById('sstvGeneralScopeToneLabel');
|
||||||
|
const statusLabel = document.getElementById('sstvGeneralScopeStatusLabel');
|
||||||
|
if (rmsLabel) rmsLabel.textContent = Math.round(sstvGeneralScopeRms);
|
||||||
|
if (peakLabel) peakLabel.textContent = Math.round(sstvGeneralScopePeak);
|
||||||
|
if (toneLabel) {
|
||||||
|
if (sstvGeneralScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
|
||||||
|
else if (sstvGeneralScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
|
||||||
|
else if (sstvGeneralScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
|
||||||
|
else if (sstvGeneralScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
|
||||||
|
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
||||||
|
}
|
||||||
|
if (statusLabel) {
|
||||||
|
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
|
||||||
|
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop signal scope
|
||||||
|
*/
|
||||||
|
function stopSstvGeneralScope() {
|
||||||
|
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
|
||||||
|
sstvGeneralScopeCtx = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start SSE stream
|
* Start SSE stream
|
||||||
*/
|
*/
|
||||||
@@ -198,6 +340,11 @@ const SSTVGeneral = (function() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show and init scope
|
||||||
|
const scopePanel = document.getElementById('sstvGeneralScopePanel');
|
||||||
|
if (scopePanel) scopePanel.style.display = 'block';
|
||||||
|
initSstvGeneralScope();
|
||||||
|
|
||||||
eventSource = new EventSource('/sstv-general/stream');
|
eventSource = new EventSource('/sstv-general/stream');
|
||||||
|
|
||||||
eventSource.onmessage = (e) => {
|
eventSource.onmessage = (e) => {
|
||||||
@@ -205,6 +352,10 @@ const SSTVGeneral = (function() {
|
|||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.type === 'sstv_progress') {
|
if (data.type === 'sstv_progress') {
|
||||||
handleProgress(data);
|
handleProgress(data);
|
||||||
|
} else if (data.type === 'sstv_scope') {
|
||||||
|
sstvGeneralScopeTargetRms = data.rms;
|
||||||
|
sstvGeneralScopeTargetPeak = data.peak;
|
||||||
|
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE message:', err);
|
console.error('Failed to parse SSE message:', err);
|
||||||
@@ -227,6 +378,9 @@ const SSTVGeneral = (function() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
|
stopSstvGeneralScope();
|
||||||
|
const scopePanel = document.getElementById('sstvGeneralScopePanel');
|
||||||
|
if (scopePanel) scopePanel.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -245,6 +399,7 @@ const SSTVGeneral = (function() {
|
|||||||
renderGallery();
|
renderGallery();
|
||||||
showNotification('SSTV', 'New image decoded!');
|
showNotification('SSTV', 'New image decoded!');
|
||||||
updateStatusUI('listening', 'Listening...');
|
updateStatusUI('listening', 'Listening...');
|
||||||
|
sstvGeneralScopeMsgBurst = 1.0;
|
||||||
// Clear decode progress so signal monitor can take over
|
// Clear decode progress so signal monitor can take over
|
||||||
const liveContent = document.getElementById('sstvGeneralLiveContent');
|
const liveContent = document.getElementById('sstvGeneralLiveContent');
|
||||||
if (liveContent) liveContent.innerHTML = '';
|
if (liveContent) liveContent.innerHTML = '';
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ const SSTV = (function() {
|
|||||||
// ISS frequency
|
// ISS frequency
|
||||||
const ISS_FREQ = 145.800;
|
const ISS_FREQ = 145.800;
|
||||||
|
|
||||||
|
// Signal scope state
|
||||||
|
let sstvScopeCtx = null;
|
||||||
|
let sstvScopeAnim = null;
|
||||||
|
let sstvScopeHistory = [];
|
||||||
|
const SSTV_SCOPE_LEN = 200;
|
||||||
|
let sstvScopeRms = 0;
|
||||||
|
let sstvScopePeak = 0;
|
||||||
|
let sstvScopeTargetRms = 0;
|
||||||
|
let sstvScopeTargetPeak = 0;
|
||||||
|
let sstvScopeMsgBurst = 0;
|
||||||
|
let sstvScopeTone = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the SSTV mode
|
* Initialize the SSTV mode
|
||||||
*/
|
*/
|
||||||
@@ -634,6 +646,136 @@ const SSTV = (function() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize signal scope canvas
|
||||||
|
*/
|
||||||
|
function initSstvScope() {
|
||||||
|
const canvas = document.getElementById('sstvScopeCanvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
||||||
|
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
||||||
|
sstvScopeCtx = canvas.getContext('2d');
|
||||||
|
sstvScopeHistory = new Array(SSTV_SCOPE_LEN).fill(0);
|
||||||
|
sstvScopeRms = 0;
|
||||||
|
sstvScopePeak = 0;
|
||||||
|
sstvScopeTargetRms = 0;
|
||||||
|
sstvScopeTargetPeak = 0;
|
||||||
|
sstvScopeMsgBurst = 0;
|
||||||
|
sstvScopeTone = null;
|
||||||
|
drawSstvScope();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw signal scope animation frame
|
||||||
|
*/
|
||||||
|
function drawSstvScope() {
|
||||||
|
const ctx = sstvScopeCtx;
|
||||||
|
if (!ctx) return;
|
||||||
|
const W = ctx.canvas.width;
|
||||||
|
const H = ctx.canvas.height;
|
||||||
|
const midY = H / 2;
|
||||||
|
|
||||||
|
// Phosphor persistence
|
||||||
|
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Smooth towards target
|
||||||
|
sstvScopeRms += (sstvScopeTargetRms - sstvScopeRms) * 0.25;
|
||||||
|
sstvScopePeak += (sstvScopeTargetPeak - sstvScopePeak) * 0.15;
|
||||||
|
|
||||||
|
// Push to history
|
||||||
|
sstvScopeHistory.push(Math.min(sstvScopeRms / 32768, 1.0));
|
||||||
|
if (sstvScopeHistory.length > SSTV_SCOPE_LEN) sstvScopeHistory.shift();
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
for (let i = 1; i < 4; i++) {
|
||||||
|
const y = (H / 4) * i;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let i = 1; i < 8; i++) {
|
||||||
|
const x = (W / 8) * i;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waveform
|
||||||
|
const stepX = W / (SSTV_SCOPE_LEN - 1);
|
||||||
|
ctx.strokeStyle = '#c080ff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.shadowColor = '#c080ff';
|
||||||
|
ctx.shadowBlur = 4;
|
||||||
|
|
||||||
|
// Upper half
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < sstvScopeHistory.length; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const amp = sstvScopeHistory[i] * midY * 0.9;
|
||||||
|
const y = midY - amp;
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Lower half (mirror)
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < sstvScopeHistory.length; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const amp = sstvScopeHistory[i] * midY * 0.9;
|
||||||
|
const y = midY + amp;
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// Peak indicator
|
||||||
|
const peakNorm = Math.min(sstvScopePeak / 32768, 1.0);
|
||||||
|
if (peakNorm > 0.01) {
|
||||||
|
const peakY = midY - peakNorm * midY * 0.9;
|
||||||
|
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image decode flash
|
||||||
|
if (sstvScopeMsgBurst > 0.01) {
|
||||||
|
ctx.fillStyle = `rgba(0, 255, 100, ${sstvScopeMsgBurst * 0.15})`;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
sstvScopeMsgBurst *= 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update labels
|
||||||
|
const rmsLabel = document.getElementById('sstvScopeRmsLabel');
|
||||||
|
const peakLabel = document.getElementById('sstvScopePeakLabel');
|
||||||
|
const toneLabel = document.getElementById('sstvScopeToneLabel');
|
||||||
|
const statusLabel = document.getElementById('sstvScopeStatusLabel');
|
||||||
|
if (rmsLabel) rmsLabel.textContent = Math.round(sstvScopeRms);
|
||||||
|
if (peakLabel) peakLabel.textContent = Math.round(sstvScopePeak);
|
||||||
|
if (toneLabel) {
|
||||||
|
if (sstvScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
|
||||||
|
else if (sstvScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
|
||||||
|
else if (sstvScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
|
||||||
|
else if (sstvScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
|
||||||
|
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
||||||
|
}
|
||||||
|
if (statusLabel) {
|
||||||
|
if (sstvScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
|
||||||
|
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
sstvScopeAnim = requestAnimationFrame(drawSstvScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop signal scope
|
||||||
|
*/
|
||||||
|
function stopSstvScope() {
|
||||||
|
if (sstvScopeAnim) { cancelAnimationFrame(sstvScopeAnim); sstvScopeAnim = null; }
|
||||||
|
sstvScopeCtx = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start SSE stream
|
* Start SSE stream
|
||||||
*/
|
*/
|
||||||
@@ -642,6 +784,11 @@ const SSTV = (function() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show and init scope
|
||||||
|
const scopePanel = document.getElementById('sstvScopePanel');
|
||||||
|
if (scopePanel) scopePanel.style.display = 'block';
|
||||||
|
initSstvScope();
|
||||||
|
|
||||||
eventSource = new EventSource('/sstv/stream');
|
eventSource = new EventSource('/sstv/stream');
|
||||||
|
|
||||||
eventSource.onmessage = (e) => {
|
eventSource.onmessage = (e) => {
|
||||||
@@ -649,6 +796,10 @@ const SSTV = (function() {
|
|||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.type === 'sstv_progress') {
|
if (data.type === 'sstv_progress') {
|
||||||
handleProgress(data);
|
handleProgress(data);
|
||||||
|
} else if (data.type === 'sstv_scope') {
|
||||||
|
sstvScopeTargetRms = data.rms;
|
||||||
|
sstvScopeTargetPeak = data.peak;
|
||||||
|
if (data.tone !== undefined) sstvScopeTone = data.tone;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE message:', err);
|
console.error('Failed to parse SSE message:', err);
|
||||||
@@ -671,6 +822,9 @@ const SSTV = (function() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
|
stopSstvScope();
|
||||||
|
const scopePanel = document.getElementById('sstvScopePanel');
|
||||||
|
if (scopePanel) scopePanel.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -691,6 +845,7 @@ const SSTV = (function() {
|
|||||||
renderGallery();
|
renderGallery();
|
||||||
showNotification('SSTV', 'New image decoded!');
|
showNotification('SSTV', 'New image decoded!');
|
||||||
updateStatusUI('listening', 'Listening...');
|
updateStatusUI('listening', 'Listening...');
|
||||||
|
sstvScopeMsgBurst = 1.0;
|
||||||
// Clear decode progress so signal monitor can take over
|
// Clear decode progress so signal monitor can take over
|
||||||
const liveContent = document.getElementById('sstvLiveContent');
|
const liveContent = document.getElementById('sstvLiveContent');
|
||||||
if (liveContent) liveContent.innerHTML = '';
|
if (liveContent) liveContent.innerHTML = '';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+473
-45
@@ -64,6 +64,7 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/weather-satellite.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/weather-satellite.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv-general.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv-general.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -173,6 +174,10 @@
|
|||||||
<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="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"/></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="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"/></svg></span>
|
||||||
<span class="mode-name">Vessels</span>
|
<span class="mode-name">Vessels</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/gsm_spy/dashboard" class="mode-card mode-card-sm" style="text-decoration: none;">
|
||||||
|
<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="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/><path d="M8 6h8M8 10h8M8 14h8"/></svg></span>
|
||||||
|
<span class="mode-name">GSM SPY</span>
|
||||||
|
</a>
|
||||||
<button class="mode-card mode-card-sm" onclick="selectMode('aprs')">
|
<button class="mode-card mode-card-sm" onclick="selectMode('aprs')">
|
||||||
<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="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="mode-icon icon"><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="mode-name">APRS</span>
|
<span class="mode-name">APRS</span>
|
||||||
@@ -512,34 +517,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shared Waterfall Controls -->
|
|
||||||
<div class="section" id="waterfallControlsSection" style="display: none;">
|
|
||||||
<h3>Waterfall</h3>
|
|
||||||
<div class="form-group" style="margin-bottom: 6px;">
|
|
||||||
<label style="font-size: 10px;">Start (MHz)</label>
|
|
||||||
<input type="number" id="waterfallStartFreq" value="88" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 6px;">
|
|
||||||
<label style="font-size: 10px;">End (MHz)</label>
|
|
||||||
<input type="number" id="waterfallEndFreq" value="108" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 6px;">
|
|
||||||
<label style="font-size: 10px;">Bin Size</label>
|
|
||||||
<select id="waterfallBinSize" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
<option value="5000">5 kHz</option>
|
|
||||||
<option value="10000" selected>10 kHz</option>
|
|
||||||
<option value="25000">25 kHz</option>
|
|
||||||
<option value="100000">100 kHz</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 8px;">
|
|
||||||
<label style="font-size: 10px;">Gain</label>
|
|
||||||
<input type="number" id="waterfallGain" value="40" min="0" max="50" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
</div>
|
|
||||||
<button class="run-btn" id="startWaterfallBtn" onclick="startWaterfall()" style="width: 100%; padding: 8px;">Start Waterfall</button>
|
|
||||||
<button class="stop-btn" id="stopWaterfallBtn" onclick="stopWaterfall()" style="display: none; width: 100%; padding: 8px; margin-top: 4px;">Stop Waterfall</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% include 'partials/modes/pager.html' %}
|
{% include 'partials/modes/pager.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/sensor.html' %}
|
{% include 'partials/modes/sensor.html' %}
|
||||||
@@ -608,16 +585,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- WATERFALL / SPECTROGRAM PANEL -->
|
|
||||||
<div id="waterfallPanel" class="radio-module-box" style="padding: 10px; display: none; margin-bottom: 12px;">
|
|
||||||
<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="waterfallFreqRange" 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>
|
|
||||||
|
|
||||||
<!-- WiFi Layout Container -->
|
<!-- WiFi Layout Container -->
|
||||||
<div class="wifi-layout-container" id="wifiLayoutContainer" style="display: none;">
|
<div class="wifi-layout-container" id="wifiLayoutContainer" style="display: none;">
|
||||||
<!-- Status Bar -->
|
<!-- Status Bar -->
|
||||||
@@ -1073,6 +1040,68 @@
|
|||||||
<!-- Listening Post Visualizations - Professional Ham Radio Scanner -->
|
<!-- Listening Post Visualizations - Professional Ham Radio Scanner -->
|
||||||
<div class="wifi-visuals" id="listeningPostVisuals" style="display: none;">
|
<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 -->
|
<!-- TOP: FREQUENCY DISPLAY PANEL -->
|
||||||
<div class="radio-module-box scanner-main" style="grid-column: span 4; padding: 12px;">
|
<div class="radio-module-box scanner-main" style="grid-column: span 4; padding: 12px;">
|
||||||
<div style="display: flex; gap: 15px; align-items: stretch;">
|
<div style="display: flex; gap: 15px; align-items: stretch;">
|
||||||
@@ -2040,6 +2069,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Signal Scope -->
|
||||||
|
<div id="sstvScopePanel" style="display: none; margin-bottom: 12px;">
|
||||||
|
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'JetBrains Mono', 'Fira Code', monospace;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||||
|
<span>Signal Scope</span>
|
||||||
|
<div style="display: flex; gap: 14px;">
|
||||||
|
<span>RMS: <span id="sstvScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||||||
|
<span>PEAK: <span id="sstvScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||||||
|
<span id="sstvScopeToneLabel" style="color: #444;">QUIET</span>
|
||||||
|
<span id="sstvScopeStatusLabel" style="color: #444;">IDLE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="sstvScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Row (Live + Gallery) -->
|
<!-- Main Row (Live + Gallery) -->
|
||||||
<div class="sstv-main-row">
|
<div class="sstv-main-row">
|
||||||
<!-- Live Decode Section -->
|
<!-- Live Decode Section -->
|
||||||
@@ -2291,6 +2336,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Signal Scope -->
|
||||||
|
<div id="sstvGeneralScopePanel" style="display: none; margin-bottom: 12px;">
|
||||||
|
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'JetBrains Mono', 'Fira Code', monospace;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||||
|
<span>Signal Scope</span>
|
||||||
|
<div style="display: flex; gap: 14px;">
|
||||||
|
<span>RMS: <span id="sstvGeneralScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||||||
|
<span>PEAK: <span id="sstvGeneralScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||||||
|
<span id="sstvGeneralScopeToneLabel" style="color: #444;">QUIET</span>
|
||||||
|
<span id="sstvGeneralScopeStatusLabel" style="color: #444;">IDLE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="sstvGeneralScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Row (Live + Gallery) -->
|
<!-- Main Row (Live + Gallery) -->
|
||||||
<div class="sstv-general-main-row">
|
<div class="sstv-general-main-row">
|
||||||
<!-- Live Decode Section -->
|
<!-- Live Decode Section -->
|
||||||
@@ -2367,8 +2428,39 @@
|
|||||||
<!-- Filter Bar Container (populated by JavaScript based on active mode) -->
|
<!-- Filter Bar Container (populated by JavaScript based on active mode) -->
|
||||||
<div id="filterBarContainer" style="display: none;"></div>
|
<div id="filterBarContainer" style="display: none;"></div>
|
||||||
|
|
||||||
|
<!-- Pager Signal Scope -->
|
||||||
|
<div id="pagerScopePanel" style="display: none; margin-bottom: 12px;">
|
||||||
|
<div style="background: #0a0a0a; border: 1px solid #1a1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'JetBrains Mono', 'Fira Code', monospace;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||||
|
<span>Signal Scope</span>
|
||||||
|
<div style="display: flex; gap: 14px;">
|
||||||
|
<span>RMS: <span id="scopeRmsLabel" style="color: #0ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||||||
|
<span>PEAK: <span id="scopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||||||
|
<span id="scopeStatusLabel" style="color: #444;">IDLE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="pagerScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Mode-specific Timeline Containers -->
|
<!-- Mode-specific Timeline Containers -->
|
||||||
<div id="pagerTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
<div id="pagerTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
||||||
|
|
||||||
|
<!-- Sensor Signal Scope -->
|
||||||
|
<div id="sensorScopePanel" style="display: none; margin-bottom: 12px;">
|
||||||
|
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'JetBrains Mono', 'Fira Code', monospace;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||||
|
<span>Signal Scope</span>
|
||||||
|
<div style="display: flex; gap: 14px;">
|
||||||
|
<span>RSSI: <span id="sensorScopeRssiLabel" style="color: #0f0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
|
||||||
|
<span>SNR: <span id="sensorScopeSnrLabel" style="color: #fa0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
|
||||||
|
<span id="sensorScopeStatusLabel" style="color: #444;">IDLE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="sensorScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="sensorTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
<div id="sensorTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
||||||
|
|
||||||
<div class="output-content signal-feed" id="output">
|
<div class="output-content signal-feed" id="output">
|
||||||
@@ -3232,15 +3324,11 @@
|
|||||||
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' || mode === 'dmr') ? 'block' : 'none';
|
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none';
|
||||||
|
|
||||||
// Show shared waterfall controls for supported modes
|
// Show waterfall panel if running in listening mode
|
||||||
const waterfallControlsSection = document.getElementById('waterfallControlsSection');
|
|
||||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||||
const waterfallModes = ['pager', 'sensor', 'rtlamr', 'dmr', 'sstv', 'sstv_general', 'listening'];
|
|
||||||
const waterfallSupported = waterfallModes.includes(mode);
|
|
||||||
if (waterfallControlsSection) waterfallControlsSection.style.display = waterfallSupported ? 'block' : 'none';
|
|
||||||
if (waterfallPanel) {
|
if (waterfallPanel) {
|
||||||
const running = (typeof isWaterfallRunning !== 'undefined' && isWaterfallRunning);
|
const running = (typeof isWaterfallRunning !== 'undefined' && isWaterfallRunning);
|
||||||
waterfallPanel.style.display = (waterfallSupported && running) ? 'block' : 'none';
|
waterfallPanel.style.display = (mode === 'listening' && running) ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle mode-specific tool status displays
|
// Toggle mode-specific tool status displays
|
||||||
@@ -3348,6 +3436,160 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Sensor Signal Scope ---
|
||||||
|
let sensorScopeCtx = null;
|
||||||
|
let sensorScopeAnim = null;
|
||||||
|
let sensorScopeHistory = [];
|
||||||
|
const SENSOR_SCOPE_LEN = 200;
|
||||||
|
let sensorScopeRssi = 0;
|
||||||
|
let sensorScopeSnr = 0;
|
||||||
|
let sensorScopeTargetRssi = 0;
|
||||||
|
let sensorScopeTargetSnr = 0;
|
||||||
|
let sensorScopeMsgBurst = 0;
|
||||||
|
let sensorScopeLastPulse = 0;
|
||||||
|
|
||||||
|
function initSensorScope() {
|
||||||
|
const canvas = document.getElementById('sensorScopeCanvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
||||||
|
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
||||||
|
sensorScopeCtx = canvas.getContext('2d');
|
||||||
|
sensorScopeHistory = new Array(SENSOR_SCOPE_LEN).fill(0);
|
||||||
|
sensorScopeRssi = 0;
|
||||||
|
sensorScopeSnr = 0;
|
||||||
|
sensorScopeTargetRssi = 0;
|
||||||
|
sensorScopeTargetSnr = 0;
|
||||||
|
sensorScopeMsgBurst = 0;
|
||||||
|
sensorScopeLastPulse = 0;
|
||||||
|
drawSensorScope();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSensorScope() {
|
||||||
|
const ctx = sensorScopeCtx;
|
||||||
|
if (!ctx) return;
|
||||||
|
const W = ctx.canvas.width;
|
||||||
|
const H = ctx.canvas.height;
|
||||||
|
const midY = H / 2;
|
||||||
|
|
||||||
|
// Phosphor persistence
|
||||||
|
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Smooth towards targets (decay when no new packets)
|
||||||
|
sensorScopeRssi += (sensorScopeTargetRssi - sensorScopeRssi) * 0.25;
|
||||||
|
sensorScopeSnr += (sensorScopeTargetSnr - sensorScopeSnr) * 0.15;
|
||||||
|
|
||||||
|
// Decay targets back to zero between packets
|
||||||
|
sensorScopeTargetRssi *= 0.97;
|
||||||
|
sensorScopeTargetSnr *= 0.97;
|
||||||
|
|
||||||
|
// RSSI is typically negative dBm (e.g. -0.1 to -30+)
|
||||||
|
// Normalize: map absolute RSSI to 0-1 range (0 dB = max, -40 dB = min)
|
||||||
|
const rssiNorm = Math.min(Math.max(Math.abs(sensorScopeRssi) / 40, 0), 1.0);
|
||||||
|
sensorScopeHistory.push(rssiNorm);
|
||||||
|
if (sensorScopeHistory.length > SENSOR_SCOPE_LEN) {
|
||||||
|
sensorScopeHistory.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
ctx.strokeStyle = 'rgba(40, 80, 40, 0.4)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let g = 0.25; g < 1; g += 0.25) {
|
||||||
|
const gy = midY - g * midY;
|
||||||
|
const gy2 = midY + g * midY;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, gy); ctx.lineTo(W, gy);
|
||||||
|
ctx.moveTo(0, gy2); ctx.lineTo(W, gy2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center baseline
|
||||||
|
ctx.strokeStyle = 'rgba(60, 100, 60, 0.5)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, midY);
|
||||||
|
ctx.lineTo(W, midY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Waveform (mirrored, green theme for 433)
|
||||||
|
const stepX = W / SENSOR_SCOPE_LEN;
|
||||||
|
ctx.strokeStyle = '#0f0';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.shadowColor = '#0f0';
|
||||||
|
ctx.shadowBlur = 4;
|
||||||
|
|
||||||
|
// Upper half
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < sensorScopeHistory.length; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const amp = sensorScopeHistory[i] * midY * 0.9;
|
||||||
|
const y = midY - amp;
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Lower half (mirror)
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < sensorScopeHistory.length; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const amp = sensorScopeHistory[i] * midY * 0.9;
|
||||||
|
const y = midY + amp;
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// SNR indicator (amber dashed line)
|
||||||
|
const snrNorm = Math.min(Math.max(Math.abs(sensorScopeSnr) / 40, 0), 1.0);
|
||||||
|
if (snrNorm > 0.01) {
|
||||||
|
const snrY = midY - snrNorm * midY * 0.9;
|
||||||
|
ctx.strokeStyle = 'rgba(255, 170, 0, 0.6)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, snrY);
|
||||||
|
ctx.lineTo(W, snrY);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sensor decode flash (green overlay)
|
||||||
|
if (sensorScopeMsgBurst > 0.01) {
|
||||||
|
ctx.fillStyle = `rgba(0, 255, 100, ${sensorScopeMsgBurst * 0.15})`;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
sensorScopeMsgBurst *= 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update labels
|
||||||
|
const rssiLabel = document.getElementById('sensorScopeRssiLabel');
|
||||||
|
const snrLabel = document.getElementById('sensorScopeSnrLabel');
|
||||||
|
const statusLabel = document.getElementById('sensorScopeStatusLabel');
|
||||||
|
if (rssiLabel) rssiLabel.textContent = sensorScopeRssi < -0.5 ? sensorScopeRssi.toFixed(1) : '--';
|
||||||
|
if (snrLabel) snrLabel.textContent = sensorScopeSnr > 0.5 ? sensorScopeSnr.toFixed(1) : '--';
|
||||||
|
if (statusLabel) {
|
||||||
|
if (Math.abs(sensorScopeRssi) > 1) {
|
||||||
|
statusLabel.textContent = 'SIGNAL';
|
||||||
|
statusLabel.style.color = '#0f0';
|
||||||
|
} else {
|
||||||
|
statusLabel.textContent = 'MONITORING';
|
||||||
|
statusLabel.style.color = '#555';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sensorScopeAnim = requestAnimationFrame(drawSensorScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSensorScope() {
|
||||||
|
if (sensorScopeAnim) {
|
||||||
|
cancelAnimationFrame(sensorScopeAnim);
|
||||||
|
sensorScopeAnim = null;
|
||||||
|
}
|
||||||
|
sensorScopeCtx = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Start sensor decoding
|
// Start sensor decoding
|
||||||
function startSensorDecoding() {
|
function startSensorDecoding() {
|
||||||
const freq = document.getElementById('sensorFrequency').value;
|
const freq = document.getElementById('sensorFrequency').value;
|
||||||
@@ -3537,6 +3779,18 @@
|
|||||||
document.getElementById('statusText').textContent = running ? 'Listening...' : 'Idle';
|
document.getElementById('statusText').textContent = running ? 'Listening...' : 'Idle';
|
||||||
document.getElementById('startSensorBtn').style.display = running ? 'none' : 'block';
|
document.getElementById('startSensorBtn').style.display = running ? 'none' : 'block';
|
||||||
document.getElementById('stopSensorBtn').style.display = running ? 'block' : 'none';
|
document.getElementById('stopSensorBtn').style.display = running ? 'block' : 'none';
|
||||||
|
|
||||||
|
// Signal scope
|
||||||
|
const scopePanel = document.getElementById('sensorScopePanel');
|
||||||
|
if (scopePanel) {
|
||||||
|
if (running) {
|
||||||
|
scopePanel.style.display = 'block';
|
||||||
|
initSensorScope();
|
||||||
|
} else {
|
||||||
|
stopSensorScope();
|
||||||
|
scopePanel.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startSensorStream() {
|
function startSensorStream() {
|
||||||
@@ -3554,6 +3808,9 @@
|
|||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.type === 'sensor') {
|
if (data.type === 'sensor') {
|
||||||
addSensorReading(data);
|
addSensorReading(data);
|
||||||
|
} else if (data.type === 'scope') {
|
||||||
|
sensorScopeTargetRssi = data.rssi;
|
||||||
|
sensorScopeTargetSnr = data.snr;
|
||||||
} else if (data.type === 'status') {
|
} else if (data.type === 'status') {
|
||||||
if (data.text === 'stopped') {
|
if (data.text === 'stopped') {
|
||||||
setSensorRunning(false);
|
setSensorRunning(false);
|
||||||
@@ -3578,6 +3835,9 @@
|
|||||||
playAlert();
|
playAlert();
|
||||||
pulseSignal();
|
pulseSignal();
|
||||||
|
|
||||||
|
// Flash sensor scope green on decode
|
||||||
|
sensorScopeMsgBurst = 1.0;
|
||||||
|
|
||||||
sensorCount++;
|
sensorCount++;
|
||||||
document.getElementById('sensorCount').textContent = sensorCount;
|
document.getElementById('sensorCount').textContent = sensorCount;
|
||||||
|
|
||||||
@@ -4443,6 +4703,153 @@
|
|||||||
// Pager mode polling timer for agent mode
|
// Pager mode polling timer for agent mode
|
||||||
let pagerPollTimer = null;
|
let pagerPollTimer = null;
|
||||||
|
|
||||||
|
// --- Pager Signal Scope ---
|
||||||
|
let pagerScopeCtx = null;
|
||||||
|
let pagerScopeAnim = null;
|
||||||
|
let pagerScopeHistory = [];
|
||||||
|
const SCOPE_HISTORY_LEN = 200;
|
||||||
|
let pagerScopeRms = 0;
|
||||||
|
let pagerScopePeak = 0;
|
||||||
|
let pagerScopeTargetRms = 0;
|
||||||
|
let pagerScopeTargetPeak = 0;
|
||||||
|
let pagerScopeMsgBurst = 0;
|
||||||
|
|
||||||
|
function initPagerScope() {
|
||||||
|
const canvas = document.getElementById('pagerScopeCanvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
// Set actual pixel resolution
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
||||||
|
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
||||||
|
pagerScopeCtx = canvas.getContext('2d');
|
||||||
|
pagerScopeHistory = new Array(SCOPE_HISTORY_LEN).fill(0);
|
||||||
|
pagerScopeRms = 0;
|
||||||
|
pagerScopePeak = 0;
|
||||||
|
pagerScopeTargetRms = 0;
|
||||||
|
pagerScopeTargetPeak = 0;
|
||||||
|
pagerScopeMsgBurst = 0;
|
||||||
|
drawPagerScope();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPagerScope() {
|
||||||
|
const ctx = pagerScopeCtx;
|
||||||
|
if (!ctx) return;
|
||||||
|
const W = ctx.canvas.width;
|
||||||
|
const H = ctx.canvas.height;
|
||||||
|
const midY = H / 2;
|
||||||
|
|
||||||
|
// Phosphor persistence: semi-transparent clear
|
||||||
|
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Smooth towards target values
|
||||||
|
pagerScopeRms += (pagerScopeTargetRms - pagerScopeRms) * 0.25;
|
||||||
|
pagerScopePeak += (pagerScopeTargetPeak - pagerScopePeak) * 0.15;
|
||||||
|
|
||||||
|
// Push current RMS into history (normalized 0-1 against 32768)
|
||||||
|
pagerScopeHistory.push(Math.min(pagerScopeRms / 32768, 1.0));
|
||||||
|
if (pagerScopeHistory.length > SCOPE_HISTORY_LEN) {
|
||||||
|
pagerScopeHistory.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
ctx.strokeStyle = 'rgba(40, 40, 80, 0.4)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let g = 0.25; g < 1; g += 0.25) {
|
||||||
|
const gy = midY - g * midY;
|
||||||
|
const gy2 = midY + g * midY;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, gy); ctx.lineTo(W, gy);
|
||||||
|
ctx.moveTo(0, gy2); ctx.lineTo(W, gy2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center baseline
|
||||||
|
ctx.strokeStyle = 'rgba(60, 60, 100, 0.5)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, midY);
|
||||||
|
ctx.lineTo(W, midY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Waveform (mirrored)
|
||||||
|
const stepX = W / SCOPE_HISTORY_LEN;
|
||||||
|
ctx.strokeStyle = '#0ff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.shadowColor = '#0ff';
|
||||||
|
ctx.shadowBlur = 4;
|
||||||
|
|
||||||
|
// Upper half
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < pagerScopeHistory.length; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const amp = pagerScopeHistory[i] * midY * 0.9;
|
||||||
|
const y = midY - amp;
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Lower half (mirror)
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < pagerScopeHistory.length; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const amp = pagerScopeHistory[i] * midY * 0.9;
|
||||||
|
const y = midY + amp;
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// Peak indicator (dashed red line)
|
||||||
|
const peakNorm = Math.min(pagerScopePeak / 32768, 1.0);
|
||||||
|
if (peakNorm > 0.01) {
|
||||||
|
const peakY = midY - peakNorm * midY * 0.9;
|
||||||
|
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, peakY);
|
||||||
|
ctx.lineTo(W, peakY);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message decode flash (green overlay)
|
||||||
|
if (pagerScopeMsgBurst > 0.01) {
|
||||||
|
ctx.fillStyle = `rgba(0, 255, 100, ${pagerScopeMsgBurst * 0.15})`;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
pagerScopeMsgBurst *= 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update labels
|
||||||
|
const rmsLabel = document.getElementById('scopeRmsLabel');
|
||||||
|
const peakLabel = document.getElementById('scopePeakLabel');
|
||||||
|
const statusLabel = document.getElementById('scopeStatusLabel');
|
||||||
|
if (rmsLabel) rmsLabel.textContent = Math.round(pagerScopeRms);
|
||||||
|
if (peakLabel) peakLabel.textContent = Math.round(pagerScopePeak);
|
||||||
|
if (statusLabel) {
|
||||||
|
if (pagerScopeRms > 500) {
|
||||||
|
statusLabel.textContent = 'SIGNAL';
|
||||||
|
statusLabel.style.color = '#0f0';
|
||||||
|
} else {
|
||||||
|
statusLabel.textContent = 'MONITORING';
|
||||||
|
statusLabel.style.color = '#555';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pagerScopeAnim = requestAnimationFrame(drawPagerScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPagerScope() {
|
||||||
|
if (pagerScopeAnim) {
|
||||||
|
cancelAnimationFrame(pagerScopeAnim);
|
||||||
|
pagerScopeAnim = null;
|
||||||
|
}
|
||||||
|
pagerScopeCtx = null;
|
||||||
|
}
|
||||||
|
|
||||||
function startDecoding() {
|
function startDecoding() {
|
||||||
const freq = document.getElementById('frequency').value;
|
const freq = document.getElementById('frequency').value;
|
||||||
const gain = document.getElementById('gain').value;
|
const gain = document.getElementById('gain').value;
|
||||||
@@ -4571,7 +4978,7 @@
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
showInfo('Killed all processes: ' + (data.processes.length ? data.processes.join(', ') : 'none running'));
|
showInfo('All processes stopped' + (data.processes.length ? ` (${data.processes.length} killed)` : ' (none were running)'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4622,6 +5029,18 @@
|
|||||||
document.getElementById('statusText').textContent = running ? 'Decoding...' : 'Idle';
|
document.getElementById('statusText').textContent = running ? 'Decoding...' : 'Idle';
|
||||||
document.getElementById('startBtn').style.display = running ? 'none' : 'block';
|
document.getElementById('startBtn').style.display = running ? 'none' : 'block';
|
||||||
document.getElementById('stopBtn').style.display = running ? 'block' : 'none';
|
document.getElementById('stopBtn').style.display = running ? 'block' : 'none';
|
||||||
|
|
||||||
|
// Signal scope
|
||||||
|
const scopePanel = document.getElementById('pagerScopePanel');
|
||||||
|
if (scopePanel) {
|
||||||
|
if (running) {
|
||||||
|
scopePanel.style.display = 'block';
|
||||||
|
initPagerScope();
|
||||||
|
} else {
|
||||||
|
stopPagerScope();
|
||||||
|
scopePanel.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startStream(isAgentMode = false) {
|
function startStream(isAgentMode = false) {
|
||||||
@@ -4657,6 +5076,9 @@
|
|||||||
}
|
}
|
||||||
} else if (payload.type === 'info') {
|
} else if (payload.type === 'info') {
|
||||||
showInfo(`[${data.agent_name}] ${payload.text}`);
|
showInfo(`[${data.agent_name}] ${payload.text}`);
|
||||||
|
} else if (payload.type === 'scope') {
|
||||||
|
pagerScopeTargetRms = payload.rms;
|
||||||
|
pagerScopeTargetPeak = payload.peak;
|
||||||
}
|
}
|
||||||
} else if (data.type === 'keepalive') {
|
} else if (data.type === 'keepalive') {
|
||||||
// Ignore keepalive messages
|
// Ignore keepalive messages
|
||||||
@@ -4675,6 +5097,9 @@
|
|||||||
showInfo(data.text);
|
showInfo(data.text);
|
||||||
} else if (data.type === 'raw') {
|
} else if (data.type === 'raw') {
|
||||||
showInfo(data.text);
|
showInfo(data.text);
|
||||||
|
} else if (data.type === 'scope') {
|
||||||
|
pagerScopeTargetRms = data.rms;
|
||||||
|
pagerScopeTargetPeak = data.peak;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -4782,6 +5207,9 @@
|
|||||||
// Update signal meter
|
// Update signal meter
|
||||||
pulseSignal();
|
pulseSignal();
|
||||||
|
|
||||||
|
// Flash signal scope green on decode
|
||||||
|
pagerScopeMsgBurst = 1.0;
|
||||||
|
|
||||||
// Use SignalCards component to create the message card (auto-detects status)
|
// Use SignalCards component to create the message card (auto-detects status)
|
||||||
const msgEl = SignalCards.createPagerCard(msg);
|
const msgEl = SignalCards.createPagerCard(msg);
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||||
{{ mode_item('adsb', 'Aircraft', '<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>', '/adsb/dashboard') }}
|
{{ mode_item('adsb', 'Aircraft', '<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>', '/adsb/dashboard') }}
|
||||||
{{ mode_item('ais', 'Vessels', '<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>', '/ais/dashboard') }}
|
{{ mode_item('ais', 'Vessels', '<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>', '/ais/dashboard') }}
|
||||||
|
{{ mode_item('gsm', 'GSM SPY', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/><path d="M8 6h8M8 10h8M8 14h8"/></svg>', '/gsm_spy/dashboard') }}
|
||||||
{{ mode_item('aprs', 'APRS', '<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>') }}
|
{{ mode_item('aprs', 'APRS', '<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>') }}
|
||||||
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
||||||
{{ mode_item('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>') }}
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
"""Unit tests for GSM Spy parsing and validation functions."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from routes.gsm_spy import (
|
||||||
|
parse_grgsm_scanner_output,
|
||||||
|
parse_tshark_output,
|
||||||
|
arfcn_to_frequency,
|
||||||
|
validate_band_names,
|
||||||
|
REGIONAL_BANDS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseGrgsmScannerOutput:
|
||||||
|
"""Tests for parse_grgsm_scanner_output()."""
|
||||||
|
|
||||||
|
def test_valid_output_line(self):
|
||||||
|
"""Test parsing a valid grgsm_scanner output line."""
|
||||||
|
line = "ARFCN: 23, Freq: 940.6M, CID: 31245, LAC: 1234, MCC: 214, MNC: 01, Pwr: -48"
|
||||||
|
result = parse_grgsm_scanner_output(line)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result['type'] == 'tower'
|
||||||
|
assert result['arfcn'] == 23
|
||||||
|
assert result['frequency'] == 940.6
|
||||||
|
assert result['cid'] == 31245
|
||||||
|
assert result['lac'] == 1234
|
||||||
|
assert result['mcc'] == 214
|
||||||
|
assert result['mnc'] == 1
|
||||||
|
assert result['signal_strength'] == -48.0
|
||||||
|
assert 'timestamp' in result
|
||||||
|
|
||||||
|
def test_freq_without_suffix(self):
|
||||||
|
"""Test parsing frequency without M suffix."""
|
||||||
|
line = "ARFCN: 975, Freq: 925.2, CID: 13522, LAC: 38722, MCC: 262, MNC: 1, Pwr: -58"
|
||||||
|
result = parse_grgsm_scanner_output(line)
|
||||||
|
assert result is not None
|
||||||
|
assert result['frequency'] == 925.2
|
||||||
|
|
||||||
|
def test_config_line(self):
|
||||||
|
"""Test that configuration lines are skipped."""
|
||||||
|
line = " Configuration: 1 CCCH, not combined"
|
||||||
|
result = parse_grgsm_scanner_output(line)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_neighbour_line(self):
|
||||||
|
"""Test that neighbour cell lines are skipped."""
|
||||||
|
line = " Neighbour Cells: 57, 61, 70, 71, 72, 86"
|
||||||
|
result = parse_grgsm_scanner_output(line)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_cell_arfcn_line(self):
|
||||||
|
"""Test that cell ARFCN lines are skipped."""
|
||||||
|
line = " Cell ARFCNs: 63, 76"
|
||||||
|
result = parse_grgsm_scanner_output(line)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_progress_line(self):
|
||||||
|
"""Test that progress/status lines are skipped."""
|
||||||
|
line = "Scanning GSM900 band..."
|
||||||
|
result = parse_grgsm_scanner_output(line)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_empty_line(self):
|
||||||
|
"""Test handling of empty lines."""
|
||||||
|
result = parse_grgsm_scanner_output("")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_invalid_data(self):
|
||||||
|
"""Test handling of non-numeric values."""
|
||||||
|
line = "ARFCN: abc, Freq: xyz, CID: bad, LAC: data, MCC: bad, MNC: bad, Pwr: bad"
|
||||||
|
result = parse_grgsm_scanner_output(line)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_no_identity_filtered(self):
|
||||||
|
"""Test that MCC=0/MNC=0 entries (no network identity) are filtered out."""
|
||||||
|
line = "ARFCN: 115, Freq: 925.0M, CID: 0, LAC: 0, MCC: 0, MNC: 0, Pwr: -100"
|
||||||
|
result = parse_grgsm_scanner_output(line)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_mcc_zero_mnc_zero_filtered(self):
|
||||||
|
"""Test that MCC=0/MNC=0 even with valid CID is filtered out."""
|
||||||
|
line = "ARFCN: 113, Freq: 924.6M, CID: 1234, LAC: 5678, MCC: 0, MNC: 0, Pwr: -90"
|
||||||
|
result = parse_grgsm_scanner_output(line)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_cid_zero_valid_mcc_passes(self):
|
||||||
|
"""Test that CID=0 with valid MCC/MNC passes (partially decoded cell)."""
|
||||||
|
line = "ARFCN: 115, Freq: 958.0M, CID: 0, LAC: 21864, MCC: 234, MNC: 10, Pwr: -51"
|
||||||
|
result = parse_grgsm_scanner_output(line)
|
||||||
|
assert result is not None
|
||||||
|
assert result['cid'] == 0
|
||||||
|
assert result['mcc'] == 234
|
||||||
|
assert result['signal_strength'] == -51.0
|
||||||
|
|
||||||
|
def test_valid_cid_nonzero(self):
|
||||||
|
"""Test that valid non-zero CID/MCC entries pass through."""
|
||||||
|
line = "ARFCN: 115, Freq: 925.0M, CID: 19088, LAC: 21864, MCC: 234, MNC: 10, Pwr: -58"
|
||||||
|
result = parse_grgsm_scanner_output(line)
|
||||||
|
assert result is not None
|
||||||
|
assert result['cid'] == 19088
|
||||||
|
assert result['signal_strength'] == -58.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseTsharkOutput:
|
||||||
|
"""Tests for parse_tshark_output()."""
|
||||||
|
|
||||||
|
def test_valid_full_output(self):
|
||||||
|
"""Test parsing tshark output with all fields."""
|
||||||
|
line = "5\t0xABCD1234\t123456789012345\t1234\t31245"
|
||||||
|
result = parse_tshark_output(line)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result['type'] == 'device'
|
||||||
|
assert result['ta_value'] == 5
|
||||||
|
assert result['tmsi'] == '0xABCD1234'
|
||||||
|
assert result['imsi'] == '123456789012345'
|
||||||
|
assert result['lac'] == 1234
|
||||||
|
assert result['cid'] == 31245
|
||||||
|
assert result['distance_meters'] == 5 * 554 # TA * 554 meters
|
||||||
|
assert 'timestamp' in result
|
||||||
|
|
||||||
|
def test_missing_optional_fields(self):
|
||||||
|
"""Test parsing with missing optional fields (empty tabs)."""
|
||||||
|
line = "3\t\t\t1234\t31245"
|
||||||
|
result = parse_tshark_output(line)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result['ta_value'] == 3
|
||||||
|
assert result['tmsi'] is None
|
||||||
|
assert result['imsi'] is None
|
||||||
|
assert result['lac'] == 1234
|
||||||
|
assert result['cid'] == 31245
|
||||||
|
|
||||||
|
def test_no_ta_value(self):
|
||||||
|
"""Test parsing without TA value (empty field)."""
|
||||||
|
# When TA is empty, int('') will fail, so the parse returns None
|
||||||
|
# This is the current behavior - the function expects valid integers or valid empty handling
|
||||||
|
line = "\t0xABCD1234\t123456789012345\t1234\t31245"
|
||||||
|
result = parse_tshark_output(line)
|
||||||
|
# Current implementation will fail to parse this due to int('') failing
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_invalid_line(self):
|
||||||
|
"""Test handling of invalid tshark output."""
|
||||||
|
line = "invalid data"
|
||||||
|
result = parse_tshark_output(line)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_empty_line(self):
|
||||||
|
"""Test handling of empty lines."""
|
||||||
|
result = parse_tshark_output("")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_partial_fields(self):
|
||||||
|
"""Test with fewer than 5 fields."""
|
||||||
|
line = "5\t0xABCD1234" # Only 2 fields
|
||||||
|
result = parse_tshark_output(line)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestArfcnToFrequency:
|
||||||
|
"""Tests for arfcn_to_frequency()."""
|
||||||
|
|
||||||
|
def test_gsm850_arfcn(self):
|
||||||
|
"""Test ARFCN in GSM850 band."""
|
||||||
|
# GSM850: ARFCN 128-251, 869-894 MHz
|
||||||
|
arfcn = 128
|
||||||
|
freq = arfcn_to_frequency(arfcn)
|
||||||
|
assert freq == 869000000 # 869 MHz
|
||||||
|
|
||||||
|
arfcn = 251
|
||||||
|
freq = arfcn_to_frequency(arfcn)
|
||||||
|
assert freq == 893600000 # 893.6 MHz
|
||||||
|
|
||||||
|
def test_egsm900_arfcn(self):
|
||||||
|
"""Test ARFCN in EGSM900 band."""
|
||||||
|
# EGSM900: ARFCN 0-124, 925-960 MHz
|
||||||
|
arfcn = 0
|
||||||
|
freq = arfcn_to_frequency(arfcn)
|
||||||
|
assert freq == 925000000 # 925 MHz
|
||||||
|
|
||||||
|
arfcn = 124
|
||||||
|
freq = arfcn_to_frequency(arfcn)
|
||||||
|
assert freq == 949800000 # 949.8 MHz
|
||||||
|
|
||||||
|
def test_dcs1800_arfcn(self):
|
||||||
|
"""Test ARFCN in DCS1800 band."""
|
||||||
|
# DCS1800: ARFCN 512-885, 1805-1880 MHz
|
||||||
|
# Note: ARFCN 512 also exists in PCS1900 and will match that first
|
||||||
|
# Use ARFCN 811+ which is only in DCS1800
|
||||||
|
arfcn = 811 # Beyond PCS1900 range (512-810)
|
||||||
|
freq = arfcn_to_frequency(arfcn)
|
||||||
|
# 811 is ARFCN offset from 512, so freq = 1805MHz + (811-512)*200kHz
|
||||||
|
expected = 1805000000 + (811 - 512) * 200000
|
||||||
|
assert freq == expected
|
||||||
|
|
||||||
|
arfcn = 885
|
||||||
|
freq = arfcn_to_frequency(arfcn)
|
||||||
|
assert freq == 1879600000 # 1879.6 MHz
|
||||||
|
|
||||||
|
def test_pcs1900_arfcn(self):
|
||||||
|
"""Test ARFCN in PCS1900 band."""
|
||||||
|
# PCS1900: ARFCN 512-810, 1930-1990 MHz
|
||||||
|
# Note: overlaps with DCS1800 ARFCN range, but different frequencies
|
||||||
|
arfcn = 512
|
||||||
|
freq = arfcn_to_frequency(arfcn)
|
||||||
|
# Will match first band (DCS1800 in Europe config)
|
||||||
|
assert freq > 0
|
||||||
|
|
||||||
|
def test_invalid_arfcn(self):
|
||||||
|
"""Test ARFCN outside known ranges."""
|
||||||
|
with pytest.raises(ValueError, match="not found in any known GSM band"):
|
||||||
|
arfcn_to_frequency(9999)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
arfcn_to_frequency(-1)
|
||||||
|
|
||||||
|
def test_arfcn_200khz_spacing(self):
|
||||||
|
"""Test that ARFCNs are 200kHz apart."""
|
||||||
|
arfcn1 = 128
|
||||||
|
arfcn2 = 129
|
||||||
|
freq1 = arfcn_to_frequency(arfcn1)
|
||||||
|
freq2 = arfcn_to_frequency(arfcn2)
|
||||||
|
assert freq2 - freq1 == 200000 # 200 kHz
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateBandNames:
|
||||||
|
"""Tests for validate_band_names()."""
|
||||||
|
|
||||||
|
def test_valid_americas_bands(self):
|
||||||
|
"""Test valid band names for Americas region."""
|
||||||
|
bands = ['GSM850', 'PCS1900']
|
||||||
|
result, error = validate_band_names(bands, 'Americas')
|
||||||
|
assert result == bands
|
||||||
|
assert error is None
|
||||||
|
|
||||||
|
def test_valid_europe_bands(self):
|
||||||
|
"""Test valid band names for Europe region."""
|
||||||
|
# Note: Europe uses EGSM900, not GSM900
|
||||||
|
bands = ['EGSM900', 'DCS1800', 'GSM850', 'GSM800']
|
||||||
|
result, error = validate_band_names(bands, 'Europe')
|
||||||
|
assert result == bands
|
||||||
|
assert error is None
|
||||||
|
|
||||||
|
def test_valid_asia_bands(self):
|
||||||
|
"""Test valid band names for Asia region."""
|
||||||
|
# Note: Asia uses EGSM900, not GSM900
|
||||||
|
bands = ['EGSM900', 'DCS1800']
|
||||||
|
result, error = validate_band_names(bands, 'Asia')
|
||||||
|
assert result == bands
|
||||||
|
assert error is None
|
||||||
|
|
||||||
|
def test_invalid_band_for_region(self):
|
||||||
|
"""Test invalid band name for a region."""
|
||||||
|
bands = ['GSM900', 'INVALID_BAND']
|
||||||
|
result, error = validate_band_names(bands, 'Americas')
|
||||||
|
assert result == []
|
||||||
|
assert error is not None
|
||||||
|
assert 'Invalid bands' in error
|
||||||
|
assert 'INVALID_BAND' in error
|
||||||
|
|
||||||
|
def test_invalid_region(self):
|
||||||
|
"""Test invalid region name."""
|
||||||
|
bands = ['GSM900']
|
||||||
|
result, error = validate_band_names(bands, 'InvalidRegion')
|
||||||
|
assert result == []
|
||||||
|
assert error is not None
|
||||||
|
assert 'Invalid region' in error
|
||||||
|
|
||||||
|
def test_empty_bands_list(self):
|
||||||
|
"""Test with empty bands list."""
|
||||||
|
result, error = validate_band_names([], 'Americas')
|
||||||
|
assert result == []
|
||||||
|
assert error is None
|
||||||
|
|
||||||
|
def test_single_valid_band(self):
|
||||||
|
"""Test with single valid band."""
|
||||||
|
bands = ['GSM850']
|
||||||
|
result, error = validate_band_names(bands, 'Americas')
|
||||||
|
assert result == ['GSM850']
|
||||||
|
assert error is None
|
||||||
|
|
||||||
|
def test_case_sensitive_band_names(self):
|
||||||
|
"""Test that band names are case-sensitive."""
|
||||||
|
bands = ['gsm850'] # lowercase
|
||||||
|
result, error = validate_band_names(bands, 'Americas')
|
||||||
|
assert result == []
|
||||||
|
assert error is not None
|
||||||
|
|
||||||
|
def test_multiple_invalid_bands(self):
|
||||||
|
"""Test with multiple invalid bands."""
|
||||||
|
bands = ['INVALID1', 'GSM850', 'INVALID2']
|
||||||
|
result, error = validate_band_names(bands, 'Americas')
|
||||||
|
assert result == []
|
||||||
|
assert error is not None
|
||||||
|
assert 'INVALID1' in error
|
||||||
|
assert 'INVALID2' in error
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegionalBandsConfig:
|
||||||
|
"""Tests for REGIONAL_BANDS configuration."""
|
||||||
|
|
||||||
|
def test_all_regions_defined(self):
|
||||||
|
"""Test that all expected regions are defined."""
|
||||||
|
assert 'Americas' in REGIONAL_BANDS
|
||||||
|
assert 'Europe' in REGIONAL_BANDS
|
||||||
|
assert 'Asia' in REGIONAL_BANDS
|
||||||
|
|
||||||
|
def test_all_bands_have_required_fields(self):
|
||||||
|
"""Test that all bands have required configuration fields."""
|
||||||
|
for region, bands in REGIONAL_BANDS.items():
|
||||||
|
for band_name, band_config in bands.items():
|
||||||
|
assert 'start' in band_config
|
||||||
|
assert 'end' in band_config
|
||||||
|
assert 'arfcn_start' in band_config
|
||||||
|
assert 'arfcn_end' in band_config
|
||||||
|
|
||||||
|
def test_frequency_ranges_valid(self):
|
||||||
|
"""Test that frequency ranges are positive and start < end."""
|
||||||
|
for region, bands in REGIONAL_BANDS.items():
|
||||||
|
for band_name, band_config in bands.items():
|
||||||
|
assert band_config['start'] > 0
|
||||||
|
assert band_config['end'] > 0
|
||||||
|
assert band_config['start'] < band_config['end']
|
||||||
|
|
||||||
|
def test_arfcn_ranges_valid(self):
|
||||||
|
"""Test that ARFCN ranges are valid."""
|
||||||
|
for region, bands in REGIONAL_BANDS.items():
|
||||||
|
for band_name, band_config in bands.items():
|
||||||
|
assert band_config['arfcn_start'] >= 0
|
||||||
|
assert band_config['arfcn_end'] >= 0
|
||||||
|
assert band_config['arfcn_start'] <= band_config['arfcn_end']
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Tests for the waterfall FFT pipeline."""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.waterfall_fft import (
|
||||||
|
build_binary_frame,
|
||||||
|
compute_power_spectrum,
|
||||||
|
cu8_to_complex,
|
||||||
|
quantize_to_uint8,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCu8ToComplex:
|
||||||
|
"""Tests for cu8_to_complex conversion."""
|
||||||
|
|
||||||
|
def test_zero_maps_to_negative_one(self):
|
||||||
|
# I=0, Q=0 -> approximately -1 - 1j
|
||||||
|
result = cu8_to_complex(bytes([0, 0]))
|
||||||
|
assert result[0].real == pytest.approx(-1.0, abs=0.01)
|
||||||
|
assert result[0].imag == pytest.approx(-1.0, abs=0.01)
|
||||||
|
|
||||||
|
def test_255_maps_to_positive_one(self):
|
||||||
|
# I=255, Q=255 -> approximately +1 + 1j
|
||||||
|
result = cu8_to_complex(bytes([255, 255]))
|
||||||
|
assert result[0].real == pytest.approx(1.0, abs=0.01)
|
||||||
|
assert result[0].imag == pytest.approx(1.0, abs=0.01)
|
||||||
|
|
||||||
|
def test_128_maps_to_near_zero(self):
|
||||||
|
# I=128, Q=128 -> approximately 0 + 0j
|
||||||
|
result = cu8_to_complex(bytes([128, 128]))
|
||||||
|
assert abs(result[0].real) < 0.01
|
||||||
|
assert abs(result[0].imag) < 0.01
|
||||||
|
|
||||||
|
def test_output_length(self):
|
||||||
|
raw = bytes(range(256)) * 4 # 1024 bytes -> 512 complex samples
|
||||||
|
result = cu8_to_complex(raw)
|
||||||
|
assert len(result) == 512
|
||||||
|
|
||||||
|
def test_output_dtype(self):
|
||||||
|
result = cu8_to_complex(bytes([100, 200, 50, 150]))
|
||||||
|
assert result.dtype == np.complex64 or np.issubdtype(result.dtype, np.complexfloating)
|
||||||
|
|
||||||
|
|
||||||
|
class TestComputePowerSpectrum:
|
||||||
|
"""Tests for compute_power_spectrum."""
|
||||||
|
|
||||||
|
def test_output_length_matches_fft_size(self):
|
||||||
|
samples = np.zeros(4096, dtype=np.complex64)
|
||||||
|
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
|
||||||
|
assert len(result) == 1024
|
||||||
|
|
||||||
|
def test_output_dtype(self):
|
||||||
|
samples = np.zeros(4096, dtype=np.complex64)
|
||||||
|
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
|
||||||
|
assert result.dtype == np.float32
|
||||||
|
|
||||||
|
def test_pure_tone_peak_at_correct_bin(self):
|
||||||
|
fft_size = 1024
|
||||||
|
avg_count = 4
|
||||||
|
n = fft_size * avg_count
|
||||||
|
# Generate a pure tone at bin 256 (1/4 of sample rate)
|
||||||
|
t = np.arange(n, dtype=np.float32)
|
||||||
|
freq_bin = 256
|
||||||
|
tone = np.exp(2j * np.pi * freq_bin / fft_size * t).astype(np.complex64)
|
||||||
|
result = compute_power_spectrum(tone, fft_size=fft_size, avg_count=avg_count)
|
||||||
|
# After fftshift, bin 256 maps to index 256 + 512 = 768
|
||||||
|
peak_idx = np.argmax(result)
|
||||||
|
expected_idx = fft_size // 2 + freq_bin
|
||||||
|
assert peak_idx == expected_idx
|
||||||
|
|
||||||
|
def test_insufficient_samples_returns_default(self):
|
||||||
|
# Not enough samples for even one segment
|
||||||
|
samples = np.zeros(100, dtype=np.complex64)
|
||||||
|
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
|
||||||
|
assert len(result) == 1024
|
||||||
|
assert np.all(result == -100.0)
|
||||||
|
|
||||||
|
def test_partial_avg_count(self):
|
||||||
|
# Only enough for 2 of 4 requested averages
|
||||||
|
fft_size = 1024
|
||||||
|
samples = np.random.randn(2048).astype(np.float32).view(np.complex64)
|
||||||
|
result = compute_power_spectrum(samples, fft_size=fft_size, avg_count=4)
|
||||||
|
assert len(result) == fft_size
|
||||||
|
# Should still return valid dB values (not -100 default)
|
||||||
|
assert np.any(result != -100.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuantizeToUint8:
|
||||||
|
"""Tests for quantize_to_uint8."""
|
||||||
|
|
||||||
|
def test_db_min_maps_to_zero(self):
|
||||||
|
power = np.array([-90.0], dtype=np.float32)
|
||||||
|
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
||||||
|
assert result[0] == 0
|
||||||
|
|
||||||
|
def test_db_max_maps_to_255(self):
|
||||||
|
power = np.array([-20.0], dtype=np.float32)
|
||||||
|
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
||||||
|
assert result[0] == 255
|
||||||
|
|
||||||
|
def test_below_min_clamped_to_zero(self):
|
||||||
|
power = np.array([-120.0], dtype=np.float32)
|
||||||
|
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
||||||
|
assert result[0] == 0
|
||||||
|
|
||||||
|
def test_above_max_clamped_to_255(self):
|
||||||
|
power = np.array([0.0], dtype=np.float32)
|
||||||
|
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
||||||
|
assert result[0] == 255
|
||||||
|
|
||||||
|
def test_midpoint(self):
|
||||||
|
# Midpoint between -90 and -20 is -55 -> ~127-128
|
||||||
|
power = np.array([-55.0], dtype=np.float32)
|
||||||
|
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
||||||
|
assert 125 <= result[0] <= 130
|
||||||
|
|
||||||
|
def test_output_length(self):
|
||||||
|
power = np.random.randn(1024).astype(np.float32) * 30 - 60
|
||||||
|
result = quantize_to_uint8(power)
|
||||||
|
assert len(result) == 1024
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildBinaryFrame:
|
||||||
|
"""Tests for build_binary_frame."""
|
||||||
|
|
||||||
|
def test_header_values(self):
|
||||||
|
bins = bytes([128] * 1024)
|
||||||
|
frame = build_binary_frame(100.0, 102.0, bins)
|
||||||
|
msg_type = frame[0]
|
||||||
|
start_freq, end_freq = struct.unpack_from('<ff', frame, 1)
|
||||||
|
bin_count = struct.unpack_from('<H', frame, 9)[0]
|
||||||
|
assert msg_type == 0x01
|
||||||
|
assert start_freq == pytest.approx(100.0, abs=0.01)
|
||||||
|
assert end_freq == pytest.approx(102.0, abs=0.01)
|
||||||
|
assert bin_count == 1024
|
||||||
|
|
||||||
|
def test_total_length(self):
|
||||||
|
bin_count = 1024
|
||||||
|
bins = bytes([0] * bin_count)
|
||||||
|
frame = build_binary_frame(88.0, 108.0, bins)
|
||||||
|
assert len(frame) == 11 + bin_count
|
||||||
|
|
||||||
|
def test_bins_in_payload(self):
|
||||||
|
bins = bytes(range(256))
|
||||||
|
frame = build_binary_frame(0.0, 1.0, bins)
|
||||||
|
payload = frame[11:]
|
||||||
|
assert payload == bins
|
||||||
|
|
||||||
|
def test_round_trip(self):
|
||||||
|
start = 433.0
|
||||||
|
end = 435.0
|
||||||
|
bins = bytes([i % 256 for i in range(2048)])
|
||||||
|
frame = build_binary_frame(start, end, bins)
|
||||||
|
|
||||||
|
# Parse it back
|
||||||
|
msg_type = frame[0]
|
||||||
|
parsed_start, parsed_end = struct.unpack_from('<ff', frame, 1)
|
||||||
|
parsed_count = struct.unpack_from('<H', frame, 9)[0]
|
||||||
|
parsed_bins = frame[11:]
|
||||||
|
|
||||||
|
assert msg_type == 0x01
|
||||||
|
assert parsed_start == pytest.approx(start, abs=0.01)
|
||||||
|
assert parsed_end == pytest.approx(end, abs=0.01)
|
||||||
|
assert parsed_count == 2048
|
||||||
|
assert parsed_bins == bins
|
||||||
+30
-2
@@ -142,7 +142,7 @@ class DataStore:
|
|||||||
|
|
||||||
|
|
||||||
class CleanupManager:
|
class CleanupManager:
|
||||||
"""Manages periodic cleanup of multiple data stores."""
|
"""Manages periodic cleanup of multiple data stores and database tables."""
|
||||||
|
|
||||||
def __init__(self, interval: float = 60.0):
|
def __init__(self, interval: float = 60.0):
|
||||||
"""
|
"""
|
||||||
@@ -152,9 +152,11 @@ class CleanupManager:
|
|||||||
interval: Cleanup interval in seconds
|
interval: Cleanup interval in seconds
|
||||||
"""
|
"""
|
||||||
self.stores: list[DataStore] = []
|
self.stores: list[DataStore] = []
|
||||||
|
self.db_cleanup_funcs: list[tuple[callable, int]] = [] # (func, interval_multiplier)
|
||||||
self.interval = interval
|
self.interval = interval
|
||||||
self._timer: threading.Timer | None = None
|
self._timer: threading.Timer | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._cleanup_count = 0
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def register(self, store: DataStore) -> None:
|
def register(self, store: DataStore) -> None:
|
||||||
@@ -169,6 +171,17 @@ class CleanupManager:
|
|||||||
if store in self.stores:
|
if store in self.stores:
|
||||||
self.stores.remove(store)
|
self.stores.remove(store)
|
||||||
|
|
||||||
|
def register_db_cleanup(self, func: callable, interval_multiplier: int = 60) -> None:
|
||||||
|
"""
|
||||||
|
Register a database cleanup function.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: Cleanup function to call (should return number of deleted rows)
|
||||||
|
interval_multiplier: How many cleanup cycles to wait between calls (default: 60 = 1 hour if interval is 60s)
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self.db_cleanup_funcs.append((func, interval_multiplier))
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
"""Start the cleanup timer."""
|
"""Start the cleanup timer."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -194,11 +207,15 @@ class CleanupManager:
|
|||||||
self._timer.start()
|
self._timer.start()
|
||||||
|
|
||||||
def _run_cleanup(self) -> None:
|
def _run_cleanup(self) -> None:
|
||||||
"""Run cleanup on all registered stores."""
|
"""Run cleanup on all registered stores and database tables."""
|
||||||
total_cleaned = 0
|
total_cleaned = 0
|
||||||
|
|
||||||
|
# Cleanup in-memory data stores
|
||||||
with self._lock:
|
with self._lock:
|
||||||
stores = list(self.stores)
|
stores = list(self.stores)
|
||||||
|
db_funcs = list(self.db_cleanup_funcs)
|
||||||
|
self._cleanup_count += 1
|
||||||
|
current_count = self._cleanup_count
|
||||||
|
|
||||||
for store in stores:
|
for store in stores:
|
||||||
try:
|
try:
|
||||||
@@ -206,6 +223,17 @@ class CleanupManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error cleaning up {store.name}: {e}")
|
logger.error(f"Error cleaning up {store.name}: {e}")
|
||||||
|
|
||||||
|
# Cleanup database tables (less frequently)
|
||||||
|
for func, interval_multiplier in db_funcs:
|
||||||
|
if current_count % interval_multiplier == 0:
|
||||||
|
try:
|
||||||
|
deleted = func()
|
||||||
|
if deleted > 0:
|
||||||
|
logger.info(f"Database cleanup: {func.__name__} removed {deleted} rows")
|
||||||
|
total_cleaned += deleted
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in database cleanup {func.__name__}: {e}")
|
||||||
|
|
||||||
if total_cleaned > 0:
|
if total_cleaned > 0:
|
||||||
logger.info(f"Cleanup complete: removed {total_cleaned} stale entries")
|
logger.info(f"Cleanup complete: removed {total_cleaned} stale entries")
|
||||||
|
|
||||||
|
|||||||
@@ -274,3 +274,14 @@ MAX_DEAUTH_ALERTS_AGE_SECONDS = 300 # 5 minutes
|
|||||||
|
|
||||||
# Deauth detector sniff timeout (seconds)
|
# Deauth detector sniff timeout (seconds)
|
||||||
DEAUTH_SNIFF_TIMEOUT = 0.5
|
DEAUTH_SNIFF_TIMEOUT = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GSM SPY (Cellular Intelligence)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Maximum age for GSM tower/device data in DataStore (seconds)
|
||||||
|
MAX_GSM_AGE_SECONDS = 300 # 5 minutes
|
||||||
|
|
||||||
|
# Timing Advance conversion to meters
|
||||||
|
GSM_TA_METERS_PER_UNIT = 554
|
||||||
|
|||||||
+485
-253
@@ -88,65 +88,111 @@ def init_db() -> None:
|
|||||||
ON signal_history(mode, device_id, timestamp)
|
ON signal_history(mode, device_id, timestamp)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
# Device correlation table
|
# Device correlation table
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS device_correlations (
|
CREATE TABLE IF NOT EXISTS device_correlations (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
wifi_mac TEXT,
|
wifi_mac TEXT,
|
||||||
bt_mac TEXT,
|
bt_mac TEXT,
|
||||||
confidence REAL,
|
confidence REAL,
|
||||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
metadata TEXT,
|
metadata TEXT,
|
||||||
UNIQUE(wifi_mac, bt_mac)
|
UNIQUE(wifi_mac, bt_mac)
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
# Alert rules
|
# Alert rules
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
mode TEXT,
|
mode TEXT,
|
||||||
event_type TEXT,
|
event_type TEXT,
|
||||||
match TEXT,
|
match TEXT,
|
||||||
severity TEXT DEFAULT 'medium',
|
severity TEXT DEFAULT 'medium',
|
||||||
enabled BOOLEAN DEFAULT 1,
|
enabled BOOLEAN DEFAULT 1,
|
||||||
notify TEXT,
|
notify TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
# Alert events
|
# Alert events
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS alert_events (
|
CREATE TABLE IF NOT EXISTS alert_events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
rule_id INTEGER,
|
rule_id INTEGER,
|
||||||
mode TEXT,
|
mode TEXT,
|
||||||
event_type TEXT,
|
event_type TEXT,
|
||||||
severity TEXT DEFAULT 'medium',
|
severity TEXT DEFAULT 'medium',
|
||||||
title TEXT,
|
title TEXT,
|
||||||
message TEXT,
|
message TEXT,
|
||||||
payload TEXT,
|
payload TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE SET NULL
|
FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE SET NULL
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
# Session recordings
|
# Session recordings
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS recording_sessions (
|
CREATE TABLE IF NOT EXISTS recording_sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
mode TEXT NOT NULL,
|
mode TEXT NOT NULL,
|
||||||
label TEXT,
|
label TEXT,
|
||||||
started_at TIMESTAMP NOT NULL,
|
started_at TIMESTAMP NOT NULL,
|
||||||
stopped_at TIMESTAMP,
|
stopped_at TIMESTAMP,
|
||||||
file_path TEXT NOT NULL,
|
file_path TEXT NOT NULL,
|
||||||
event_count INTEGER DEFAULT 0,
|
event_count INTEGER DEFAULT 0,
|
||||||
size_bytes INTEGER DEFAULT 0,
|
size_bytes INTEGER DEFAULT 0,
|
||||||
metadata TEXT
|
metadata TEXT
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# Alert rules
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
mode TEXT,
|
||||||
|
event_type TEXT,
|
||||||
|
match TEXT,
|
||||||
|
severity TEXT DEFAULT 'medium',
|
||||||
|
enabled BOOLEAN DEFAULT 1,
|
||||||
|
notify TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Alert events
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
rule_id INTEGER,
|
||||||
|
mode TEXT,
|
||||||
|
event_type TEXT,
|
||||||
|
severity TEXT DEFAULT 'medium',
|
||||||
|
title TEXT,
|
||||||
|
message TEXT,
|
||||||
|
payload TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Session recordings
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS recording_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
started_at TIMESTAMP NOT NULL,
|
||||||
|
stopped_at TIMESTAMP,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
event_count INTEGER DEFAULT 0,
|
||||||
|
size_bytes INTEGER DEFAULT 0,
|
||||||
|
metadata TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
# Users table for authentication
|
# Users table for authentication
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
@@ -177,29 +223,29 @@ def init_db() -> None:
|
|||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
||||||
# TSCM Baselines - Environment snapshots for comparison
|
# TSCM Baselines - Environment snapshots for comparison
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS tscm_baselines (
|
CREATE TABLE IF NOT EXISTS tscm_baselines (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
location TEXT,
|
location TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
wifi_networks TEXT,
|
wifi_networks TEXT,
|
||||||
wifi_clients TEXT,
|
wifi_clients TEXT,
|
||||||
bt_devices TEXT,
|
bt_devices TEXT,
|
||||||
rf_frequencies TEXT,
|
rf_frequencies TEXT,
|
||||||
gps_coords TEXT,
|
gps_coords TEXT,
|
||||||
is_active BOOLEAN DEFAULT 0
|
is_active BOOLEAN DEFAULT 0
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
# Ensure new columns exist for older databases
|
# Ensure new columns exist for older databases
|
||||||
try:
|
try:
|
||||||
columns = {row['name'] for row in conn.execute("PRAGMA table_info(tscm_baselines)")}
|
columns = {row['name'] for row in conn.execute("PRAGMA table_info(tscm_baselines)")}
|
||||||
if 'wifi_clients' not in columns:
|
if 'wifi_clients' not in columns:
|
||||||
conn.execute('ALTER TABLE tscm_baselines ADD COLUMN wifi_clients TEXT')
|
conn.execute('ALTER TABLE tscm_baselines ADD COLUMN wifi_clients TEXT')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Schema update skipped for tscm_baselines: {e}")
|
logger.debug(f"Schema update skipped for tscm_baselines: {e}")
|
||||||
|
|
||||||
# TSCM Sweeps - Individual sweep sessions
|
# TSCM Sweeps - Individual sweep sessions
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
@@ -407,6 +453,134 @@ def init_db() -> None:
|
|||||||
ON tscm_cases(status, created_at)
|
ON tscm_cases(status, created_at)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# GSM (Global System for Mobile) Intelligence Tables
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
# gsm_cells - Known cell towers (OpenCellID cache)
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS gsm_cells (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mcc INTEGER NOT NULL,
|
||||||
|
mnc INTEGER NOT NULL,
|
||||||
|
lac INTEGER NOT NULL,
|
||||||
|
cid INTEGER NOT NULL,
|
||||||
|
lat REAL,
|
||||||
|
lon REAL,
|
||||||
|
azimuth INTEGER,
|
||||||
|
range_meters INTEGER,
|
||||||
|
samples INTEGER,
|
||||||
|
radio TEXT,
|
||||||
|
operator TEXT,
|
||||||
|
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_verified TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
metadata TEXT,
|
||||||
|
UNIQUE(mcc, mnc, lac, cid)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# gsm_rogues - Detected rogue towers / IMSI catchers
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS gsm_rogues (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
arfcn INTEGER NOT NULL,
|
||||||
|
mcc INTEGER,
|
||||||
|
mnc INTEGER,
|
||||||
|
lac INTEGER,
|
||||||
|
cid INTEGER,
|
||||||
|
signal_strength REAL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
threat_level TEXT DEFAULT 'medium',
|
||||||
|
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
location_lat REAL,
|
||||||
|
location_lon REAL,
|
||||||
|
acknowledged BOOLEAN DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
metadata TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# gsm_signals - 60-day archive of signal observations
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS gsm_signals (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
imsi TEXT,
|
||||||
|
tmsi TEXT,
|
||||||
|
mcc INTEGER,
|
||||||
|
mnc INTEGER,
|
||||||
|
lac INTEGER,
|
||||||
|
cid INTEGER,
|
||||||
|
ta_value INTEGER,
|
||||||
|
signal_strength REAL,
|
||||||
|
arfcn INTEGER,
|
||||||
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
metadata TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# gsm_tmsi_log - 24-hour raw pings for crowd density
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS gsm_tmsi_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tmsi TEXT NOT NULL,
|
||||||
|
lac INTEGER,
|
||||||
|
cid INTEGER,
|
||||||
|
ta_value INTEGER,
|
||||||
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# gsm_velocity_log - 1-hour buffer for movement tracking
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS gsm_velocity_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
prev_ta INTEGER,
|
||||||
|
curr_ta INTEGER,
|
||||||
|
prev_cid INTEGER,
|
||||||
|
curr_cid INTEGER,
|
||||||
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
estimated_velocity REAL,
|
||||||
|
metadata TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# GSM indexes for performance
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gsm_cells_location
|
||||||
|
ON gsm_cells(lat, lon)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gsm_cells_identity
|
||||||
|
ON gsm_cells(mcc, mnc, lac, cid)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gsm_rogues_severity
|
||||||
|
ON gsm_rogues(threat_level, detected_at)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gsm_signals_cell_time
|
||||||
|
ON gsm_signals(cid, lac, timestamp)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gsm_signals_device
|
||||||
|
ON gsm_signals(imsi, tmsi, timestamp)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gsm_tmsi_log_time
|
||||||
|
ON gsm_tmsi_log(timestamp)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gsm_velocity_log_device
|
||||||
|
ON gsm_velocity_log(device_id, timestamp)
|
||||||
|
''')
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# DSC (Digital Selective Calling) Tables
|
# DSC (Digital Selective Calling) Tables
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
@@ -740,16 +914,16 @@ def get_correlations(min_confidence: float = 0.5) -> list[dict]:
|
|||||||
# TSCM Functions
|
# TSCM Functions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
def create_tscm_baseline(
|
def create_tscm_baseline(
|
||||||
name: str,
|
name: str,
|
||||||
location: str | None = None,
|
location: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
wifi_networks: list | None = None,
|
wifi_networks: list | None = None,
|
||||||
wifi_clients: list | None = None,
|
wifi_clients: list | None = None,
|
||||||
bt_devices: list | None = None,
|
bt_devices: list | None = None,
|
||||||
rf_frequencies: list | None = None,
|
rf_frequencies: list | None = None,
|
||||||
gps_coords: dict | None = None
|
gps_coords: dict | None = None
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Create a new TSCM baseline.
|
Create a new TSCM baseline.
|
||||||
|
|
||||||
@@ -757,20 +931,20 @@ def create_tscm_baseline(
|
|||||||
The ID of the created baseline
|
The ID of the created baseline
|
||||||
"""
|
"""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.execute('''
|
cursor = conn.execute('''
|
||||||
INSERT INTO tscm_baselines
|
INSERT INTO tscm_baselines
|
||||||
(name, location, description, wifi_networks, wifi_clients, bt_devices, rf_frequencies, gps_coords)
|
(name, location, description, wifi_networks, wifi_clients, bt_devices, rf_frequencies, gps_coords)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
''', (
|
''', (
|
||||||
name,
|
name,
|
||||||
location,
|
location,
|
||||||
description,
|
description,
|
||||||
json.dumps(wifi_networks) if wifi_networks else None,
|
json.dumps(wifi_networks) if wifi_networks else None,
|
||||||
json.dumps(wifi_clients) if wifi_clients else None,
|
json.dumps(wifi_clients) if wifi_clients else None,
|
||||||
json.dumps(bt_devices) if bt_devices else None,
|
json.dumps(bt_devices) if bt_devices else None,
|
||||||
json.dumps(rf_frequencies) if rf_frequencies else None,
|
json.dumps(rf_frequencies) if rf_frequencies else None,
|
||||||
json.dumps(gps_coords) if gps_coords else None
|
json.dumps(gps_coords) if gps_coords else None
|
||||||
))
|
))
|
||||||
return cursor.lastrowid
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
@@ -785,19 +959,19 @@ def get_tscm_baseline(baseline_id: int) -> dict | None:
|
|||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': row['id'],
|
'id': row['id'],
|
||||||
'name': row['name'],
|
'name': row['name'],
|
||||||
'location': row['location'],
|
'location': row['location'],
|
||||||
'description': row['description'],
|
'description': row['description'],
|
||||||
'created_at': row['created_at'],
|
'created_at': row['created_at'],
|
||||||
'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [],
|
'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [],
|
||||||
'wifi_clients': json.loads(row['wifi_clients']) if row['wifi_clients'] else [],
|
'wifi_clients': json.loads(row['wifi_clients']) if row['wifi_clients'] else [],
|
||||||
'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [],
|
'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [],
|
||||||
'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [],
|
'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [],
|
||||||
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None,
|
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None,
|
||||||
'is_active': bool(row['is_active'])
|
'is_active': bool(row['is_active'])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_all_tscm_baselines() -> list[dict]:
|
def get_all_tscm_baselines() -> list[dict]:
|
||||||
@@ -839,23 +1013,23 @@ def set_active_tscm_baseline(baseline_id: int) -> bool:
|
|||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
def update_tscm_baseline(
|
def update_tscm_baseline(
|
||||||
baseline_id: int,
|
baseline_id: int,
|
||||||
wifi_networks: list | None = None,
|
wifi_networks: list | None = None,
|
||||||
wifi_clients: list | None = None,
|
wifi_clients: list | None = None,
|
||||||
bt_devices: list | None = None,
|
bt_devices: list | None = None,
|
||||||
rf_frequencies: list | None = None
|
rf_frequencies: list | None = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Update baseline device lists."""
|
"""Update baseline device lists."""
|
||||||
updates = []
|
updates = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if wifi_networks is not None:
|
if wifi_networks is not None:
|
||||||
updates.append('wifi_networks = ?')
|
updates.append('wifi_networks = ?')
|
||||||
params.append(json.dumps(wifi_networks))
|
params.append(json.dumps(wifi_networks))
|
||||||
if wifi_clients is not None:
|
if wifi_clients is not None:
|
||||||
updates.append('wifi_clients = ?')
|
updates.append('wifi_clients = ?')
|
||||||
params.append(json.dumps(wifi_clients))
|
params.append(json.dumps(wifi_clients))
|
||||||
if bt_devices is not None:
|
if bt_devices is not None:
|
||||||
updates.append('bt_devices = ?')
|
updates.append('bt_devices = ?')
|
||||||
params.append(json.dumps(bt_devices))
|
params.append(json.dumps(bt_devices))
|
||||||
@@ -1267,127 +1441,127 @@ def get_all_known_devices(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def delete_known_device(identifier: str) -> bool:
|
def delete_known_device(identifier: str) -> bool:
|
||||||
"""Remove a device from the known-good registry."""
|
"""Remove a device from the known-good registry."""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
'DELETE FROM tscm_known_devices WHERE identifier = ?',
|
'DELETE FROM tscm_known_devices WHERE identifier = ?',
|
||||||
(identifier.upper(),)
|
(identifier.upper(),)
|
||||||
)
|
)
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# TSCM Schedule Functions
|
# TSCM Schedule Functions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
def create_tscm_schedule(
|
def create_tscm_schedule(
|
||||||
name: str,
|
name: str,
|
||||||
cron_expression: str,
|
cron_expression: str,
|
||||||
sweep_type: str = 'standard',
|
sweep_type: str = 'standard',
|
||||||
baseline_id: int | None = None,
|
baseline_id: int | None = None,
|
||||||
zone_name: str | None = None,
|
zone_name: str | None = None,
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
notify_on_threat: bool = True,
|
notify_on_threat: bool = True,
|
||||||
notify_email: str | None = None,
|
notify_email: str | None = None,
|
||||||
last_run: str | None = None,
|
last_run: str | None = None,
|
||||||
next_run: str | None = None,
|
next_run: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Create a new TSCM sweep schedule."""
|
"""Create a new TSCM sweep schedule."""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.execute('''
|
cursor = conn.execute('''
|
||||||
INSERT INTO tscm_schedules
|
INSERT INTO tscm_schedules
|
||||||
(name, baseline_id, zone_name, cron_expression, sweep_type,
|
(name, baseline_id, zone_name, cron_expression, sweep_type,
|
||||||
enabled, last_run, next_run, notify_on_threat, notify_email)
|
enabled, last_run, next_run, notify_on_threat, notify_email)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
''', (
|
''', (
|
||||||
name,
|
name,
|
||||||
baseline_id,
|
baseline_id,
|
||||||
zone_name,
|
zone_name,
|
||||||
cron_expression,
|
cron_expression,
|
||||||
sweep_type,
|
sweep_type,
|
||||||
1 if enabled else 0,
|
1 if enabled else 0,
|
||||||
last_run,
|
last_run,
|
||||||
next_run,
|
next_run,
|
||||||
1 if notify_on_threat else 0,
|
1 if notify_on_threat else 0,
|
||||||
notify_email,
|
notify_email,
|
||||||
))
|
))
|
||||||
return cursor.lastrowid
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
def get_tscm_schedule(schedule_id: int) -> dict | None:
|
def get_tscm_schedule(schedule_id: int) -> dict | None:
|
||||||
"""Get a TSCM schedule by ID."""
|
"""Get a TSCM schedule by ID."""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
'SELECT * FROM tscm_schedules WHERE id = ?',
|
'SELECT * FROM tscm_schedules WHERE id = ?',
|
||||||
(schedule_id,)
|
(schedule_id,)
|
||||||
)
|
)
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
def get_all_tscm_schedules(
|
def get_all_tscm_schedules(
|
||||||
enabled: bool | None = None,
|
enabled: bool | None = None,
|
||||||
limit: int = 200
|
limit: int = 200
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Get all TSCM schedules."""
|
"""Get all TSCM schedules."""
|
||||||
conditions = []
|
conditions = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if enabled is not None:
|
if enabled is not None:
|
||||||
conditions.append('enabled = ?')
|
conditions.append('enabled = ?')
|
||||||
params.append(1 if enabled else 0)
|
params.append(1 if enabled else 0)
|
||||||
|
|
||||||
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
|
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
|
||||||
params.append(limit)
|
params.append(limit)
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.execute(f'''
|
cursor = conn.execute(f'''
|
||||||
SELECT * FROM tscm_schedules
|
SELECT * FROM tscm_schedules
|
||||||
{where_clause}
|
{where_clause}
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
''', params)
|
''', params)
|
||||||
return [dict(row) for row in cursor]
|
return [dict(row) for row in cursor]
|
||||||
|
|
||||||
|
|
||||||
def update_tscm_schedule(schedule_id: int, **fields) -> bool:
|
def update_tscm_schedule(schedule_id: int, **fields) -> bool:
|
||||||
"""Update a TSCM schedule."""
|
"""Update a TSCM schedule."""
|
||||||
if not fields:
|
if not fields:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
updates = []
|
updates = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
for key, value in fields.items():
|
for key, value in fields.items():
|
||||||
updates.append(f'{key} = ?')
|
updates.append(f'{key} = ?')
|
||||||
params.append(value)
|
params.append(value)
|
||||||
|
|
||||||
params.append(schedule_id)
|
params.append(schedule_id)
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
f'UPDATE tscm_schedules SET {", ".join(updates)} WHERE id = ?',
|
f'UPDATE tscm_schedules SET {", ".join(updates)} WHERE id = ?',
|
||||||
params
|
params
|
||||||
)
|
)
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
def delete_tscm_schedule(schedule_id: int) -> bool:
|
def delete_tscm_schedule(schedule_id: int) -> bool:
|
||||||
"""Delete a TSCM schedule."""
|
"""Delete a TSCM schedule."""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
'DELETE FROM tscm_schedules WHERE id = ?',
|
'DELETE FROM tscm_schedules WHERE id = ?',
|
||||||
(schedule_id,)
|
(schedule_id,)
|
||||||
)
|
)
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
def is_known_good_device(identifier: str, location: str | None = None) -> dict | None:
|
def is_known_good_device(identifier: str, location: str | None = None) -> dict | None:
|
||||||
"""Check if a device is in the known-good registry for a location."""
|
"""Check if a device is in the known-good registry for a location."""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
if location:
|
if location:
|
||||||
cursor = conn.execute('''
|
cursor = conn.execute('''
|
||||||
SELECT * FROM tscm_known_devices
|
SELECT * FROM tscm_known_devices
|
||||||
WHERE identifier = ? AND (location = ? OR scope = 'global')
|
WHERE identifier = ? AND (location = ? OR scope = 'global')
|
||||||
''', (identifier.upper(), location))
|
''', (identifier.upper(), location))
|
||||||
@@ -2123,3 +2297,61 @@ def cleanup_old_payloads(max_age_hours: int = 24) -> int:
|
|||||||
WHERE received_at < datetime('now', ?)
|
WHERE received_at < datetime('now', ?)
|
||||||
''', (f'-{max_age_hours} hours',))
|
''', (f'-{max_age_hours} hours',))
|
||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GSM Cleanup Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def cleanup_old_gsm_signals(max_age_days: int = 60) -> int:
|
||||||
|
"""
|
||||||
|
Remove old GSM signal observations (60-day archive).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_age_days: Maximum age in days (default: 60)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of deleted entries
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
DELETE FROM gsm_signals
|
||||||
|
WHERE timestamp < datetime('now', ?)
|
||||||
|
''', (f'-{max_age_days} days',))
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_gsm_tmsi_log(max_age_hours: int = 24) -> int:
|
||||||
|
"""
|
||||||
|
Remove old TMSI log entries (24-hour buffer for crowd density).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_age_hours: Maximum age in hours (default: 24)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of deleted entries
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
DELETE FROM gsm_tmsi_log
|
||||||
|
WHERE timestamp < datetime('now', ?)
|
||||||
|
''', (f'-{max_age_hours} hours',))
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_gsm_velocity_log(max_age_hours: int = 1) -> int:
|
||||||
|
"""
|
||||||
|
Remove old velocity log entries (1-hour buffer for movement tracking).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_age_hours: Maximum age in hours (default: 1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of deleted entries
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
DELETE FROM gsm_velocity_log
|
||||||
|
WHERE timestamp < datetime('now', ?)
|
||||||
|
''', (f'-{max_age_hours} hours',))
|
||||||
|
return cursor.rowcount
|
||||||
|
|||||||
@@ -443,6 +443,38 @@ TOOL_DEPENDENCIES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
'gsm': {
|
||||||
|
'name': 'GSM Intelligence',
|
||||||
|
'tools': {
|
||||||
|
'grgsm_scanner': {
|
||||||
|
'required': True,
|
||||||
|
'description': 'gr-gsm scanner for finding GSM towers',
|
||||||
|
'install': {
|
||||||
|
'apt': 'Build gr-gsm from source: https://github.com/bkerler/gr-gsm',
|
||||||
|
'brew': 'brew install gr-gsm (may require manual build)',
|
||||||
|
'manual': 'https://github.com/bkerler/gr-gsm'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'grgsm_livemon': {
|
||||||
|
'required': True,
|
||||||
|
'description': 'gr-gsm live monitor for decoding GSM signals',
|
||||||
|
'install': {
|
||||||
|
'apt': 'Included with gr-gsm package',
|
||||||
|
'brew': 'Included with gr-gsm',
|
||||||
|
'manual': 'Included with gr-gsm'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'tshark': {
|
||||||
|
'required': True,
|
||||||
|
'description': 'Wireshark CLI for parsing GSM packets',
|
||||||
|
'install': {
|
||||||
|
'apt': 'sudo apt-get install tshark',
|
||||||
|
'brew': 'brew install wireshark',
|
||||||
|
'manual': 'https://www.wireshark.org/download.html'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""GSM Cell Tower Geocoding Service.
|
||||||
|
|
||||||
|
Provides hybrid cache-first geocoding with async API fallback for cell towers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
import config
|
||||||
|
from utils.database import get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.gsm_geocoding')
|
||||||
|
|
||||||
|
# Queue for pending geocoding requests
|
||||||
|
_geocoding_queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_cell_coordinates(mcc: int, mnc: int, lac: int, cid: int) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Lookup cell tower coordinates with cache-first strategy.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Check gsm_cells table (cache) - fast synchronous lookup
|
||||||
|
2. If not found, return None (caller decides whether to use API)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mcc: Mobile Country Code
|
||||||
|
mnc: Mobile Network Code
|
||||||
|
lac: Location Area Code
|
||||||
|
cid: Cell ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with keys: lat, lon, source='cache', azimuth (optional),
|
||||||
|
range_meters (optional), operator (optional), radio (optional)
|
||||||
|
Returns None if not found in cache.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
result = conn.execute('''
|
||||||
|
SELECT lat, lon, azimuth, range_meters, operator, radio
|
||||||
|
FROM gsm_cells
|
||||||
|
WHERE mcc = ? AND mnc = ? AND lac = ? AND cid = ?
|
||||||
|
''', (mcc, mnc, lac, cid)).fetchone()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return {
|
||||||
|
'lat': result['lat'],
|
||||||
|
'lon': result['lon'],
|
||||||
|
'source': 'cache',
|
||||||
|
'azimuth': result['azimuth'],
|
||||||
|
'range_meters': result['range_meters'],
|
||||||
|
'operator': result['operator'],
|
||||||
|
'radio': result['radio']
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error looking up coordinates from cache: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_cell_from_api(mcc: int, mnc: int, lac: int, cid: int) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Lookup cell tower from OpenCellID API and cache result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mcc: Mobile Country Code
|
||||||
|
mnc: Mobile Network Code
|
||||||
|
lac: Location Area Code
|
||||||
|
cid: Cell ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with keys: lat, lon, source='api', azimuth (optional),
|
||||||
|
range_meters (optional), operator (optional), radio (optional)
|
||||||
|
Returns None if API call fails or cell not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
api_url = config.GSM_OPENCELLID_API_URL
|
||||||
|
params = {
|
||||||
|
'key': config.GSM_OPENCELLID_API_KEY,
|
||||||
|
'mcc': mcc,
|
||||||
|
'mnc': mnc,
|
||||||
|
'lac': lac,
|
||||||
|
'cellid': cid,
|
||||||
|
'format': 'json'
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(api_url, params=params, timeout=10)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
cell_data = response.json()
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT OR REPLACE INTO gsm_cells
|
||||||
|
(mcc, mnc, lac, cid, lat, lon, azimuth, range_meters, samples, radio, operator, last_verified)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
''', (
|
||||||
|
mcc, mnc, lac, cid,
|
||||||
|
cell_data.get('lat'),
|
||||||
|
cell_data.get('lon'),
|
||||||
|
cell_data.get('azimuth'),
|
||||||
|
cell_data.get('range'),
|
||||||
|
cell_data.get('samples'),
|
||||||
|
cell_data.get('radio'),
|
||||||
|
cell_data.get('operator')
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info(f"Cached cell tower from API: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'lat': cell_data.get('lat'),
|
||||||
|
'lon': cell_data.get('lon'),
|
||||||
|
'source': 'api',
|
||||||
|
'azimuth': cell_data.get('azimuth'),
|
||||||
|
'range_meters': cell_data.get('range'),
|
||||||
|
'operator': cell_data.get('operator'),
|
||||||
|
'radio': cell_data.get('radio')
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.warning(f"OpenCellID API returned {response.status_code} for MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calling OpenCellID API: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_tower_data(tower_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Enrich tower data with coordinates using cache-first strategy.
|
||||||
|
|
||||||
|
If coordinates found in cache, adds them immediately.
|
||||||
|
If not found, marks as 'pending' and queues for background API lookup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tower_data: Dictionary with keys mcc, mnc, lac, cid (and other tower data)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Enriched tower_data dict with added fields:
|
||||||
|
- lat, lon (if found in cache)
|
||||||
|
- status='pending' (if needs API lookup)
|
||||||
|
- source='cache' (if from cache)
|
||||||
|
"""
|
||||||
|
mcc = tower_data.get('mcc')
|
||||||
|
mnc = tower_data.get('mnc')
|
||||||
|
lac = tower_data.get('lac')
|
||||||
|
cid = tower_data.get('cid')
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if not all([mcc is not None, mnc is not None, lac is not None, cid is not None]):
|
||||||
|
logger.warning(f"Tower data missing required fields: {tower_data}")
|
||||||
|
return tower_data
|
||||||
|
|
||||||
|
# Try cache lookup
|
||||||
|
coords = lookup_cell_coordinates(mcc, mnc, lac, cid)
|
||||||
|
|
||||||
|
if coords:
|
||||||
|
# Found in cache - add coordinates immediately
|
||||||
|
tower_data['lat'] = coords['lat']
|
||||||
|
tower_data['lon'] = coords['lon']
|
||||||
|
tower_data['source'] = 'cache'
|
||||||
|
|
||||||
|
# Add optional fields if available
|
||||||
|
if coords.get('azimuth') is not None:
|
||||||
|
tower_data['azimuth'] = coords['azimuth']
|
||||||
|
if coords.get('range_meters') is not None:
|
||||||
|
tower_data['range_meters'] = coords['range_meters']
|
||||||
|
if coords.get('operator'):
|
||||||
|
tower_data['operator'] = coords['operator']
|
||||||
|
if coords.get('radio'):
|
||||||
|
tower_data['radio'] = coords['radio']
|
||||||
|
|
||||||
|
logger.debug(f"Cache hit for tower: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
||||||
|
else:
|
||||||
|
# Not in cache - mark as pending and queue for API lookup
|
||||||
|
tower_data['status'] = 'pending'
|
||||||
|
tower_data['source'] = 'unknown'
|
||||||
|
|
||||||
|
# Queue for background geocoding (non-blocking)
|
||||||
|
try:
|
||||||
|
_geocoding_queue.put_nowait(tower_data.copy())
|
||||||
|
logger.debug(f"Queued tower for geocoding: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
||||||
|
except queue.Full:
|
||||||
|
logger.warning("Geocoding queue full, dropping tower")
|
||||||
|
|
||||||
|
return tower_data
|
||||||
|
|
||||||
|
|
||||||
|
def get_geocoding_queue() -> queue.Queue:
|
||||||
|
"""Get the geocoding queue for the background worker."""
|
||||||
|
return _geocoding_queue
|
||||||
@@ -28,3 +28,4 @@ wifi_logger = get_logger('intercept.wifi')
|
|||||||
bluetooth_logger = get_logger('intercept.bluetooth')
|
bluetooth_logger = get_logger('intercept.bluetooth')
|
||||||
adsb_logger = get_logger('intercept.adsb')
|
adsb_logger = get_logger('intercept.adsb')
|
||||||
satellite_logger = get_logger('intercept.satellite')
|
satellite_logger = get_logger('intercept.satellite')
|
||||||
|
gsm_spy_logger = get_logger('intercept.gsm_spy')
|
||||||
|
|||||||
@@ -185,6 +185,43 @@ class AirspyCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
def build_iq_capture_command(
|
||||||
|
self,
|
||||||
|
device: SDRDevice,
|
||||||
|
frequency_mhz: float,
|
||||||
|
sample_rate: int = 2048000,
|
||||||
|
gain: Optional[float] = None,
|
||||||
|
ppm: Optional[int] = None,
|
||||||
|
bias_t: bool = False,
|
||||||
|
output_format: str = 'cu8',
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build rx_sdr command for raw I/Q capture with Airspy.
|
||||||
|
|
||||||
|
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
|
||||||
|
"""
|
||||||
|
device_str = self._build_device_string(device)
|
||||||
|
freq_hz = int(frequency_mhz * 1e6)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'rx_sdr',
|
||||||
|
'-d', device_str,
|
||||||
|
'-f', str(freq_hz),
|
||||||
|
'-s', str(sample_rate),
|
||||||
|
'-F', 'CU8',
|
||||||
|
]
|
||||||
|
|
||||||
|
if gain is not None and gain > 0:
|
||||||
|
cmd.extend(['-g', self._format_gain(gain)])
|
||||||
|
|
||||||
|
if bias_t:
|
||||||
|
cmd.append('-T')
|
||||||
|
|
||||||
|
# Output to stdout
|
||||||
|
cmd.append('-')
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
def get_capabilities(self) -> SDRCapabilities:
|
def get_capabilities(self) -> SDRCapabilities:
|
||||||
"""Return Airspy capabilities."""
|
"""Return Airspy capabilities."""
|
||||||
return self.CAPABILITIES
|
return self.CAPABILITIES
|
||||||
|
|||||||
@@ -186,6 +186,41 @@ class CommandBuilder(ABC):
|
|||||||
"""Return hardware capabilities for this SDR type."""
|
"""Return hardware capabilities for this SDR type."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def build_iq_capture_command(
|
||||||
|
self,
|
||||||
|
device: SDRDevice,
|
||||||
|
frequency_mhz: float,
|
||||||
|
sample_rate: int = 2048000,
|
||||||
|
gain: Optional[float] = None,
|
||||||
|
ppm: Optional[int] = None,
|
||||||
|
bias_t: bool = False,
|
||||||
|
output_format: str = 'cu8',
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build raw I/Q capture command for streaming samples to stdout.
|
||||||
|
|
||||||
|
Used for real-time waterfall/spectrum display. Output is unsigned
|
||||||
|
8-bit I/Q pairs (cu8) written continuously to stdout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device: The SDR device to use
|
||||||
|
frequency_mhz: Center frequency in MHz
|
||||||
|
sample_rate: Sample rate in Hz (default 2048000)
|
||||||
|
gain: Gain in dB (None for auto)
|
||||||
|
ppm: PPM frequency correction
|
||||||
|
bias_t: Enable bias-T power (for active antennas)
|
||||||
|
output_format: Output sample format (default 'cu8')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Command as list of strings for subprocess
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotImplementedError: If the SDR type does not support I/Q capture.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"{self.__class__.__name__} does not support raw I/Q capture"
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_sdr_type(cls) -> SDRType:
|
def get_sdr_type(cls) -> SDRType:
|
||||||
|
|||||||
@@ -185,6 +185,44 @@ class HackRFCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
def build_iq_capture_command(
|
||||||
|
self,
|
||||||
|
device: SDRDevice,
|
||||||
|
frequency_mhz: float,
|
||||||
|
sample_rate: int = 2048000,
|
||||||
|
gain: Optional[float] = None,
|
||||||
|
ppm: Optional[int] = None,
|
||||||
|
bias_t: bool = False,
|
||||||
|
output_format: str = 'cu8',
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build rx_sdr command for raw I/Q capture with HackRF.
|
||||||
|
|
||||||
|
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
|
||||||
|
"""
|
||||||
|
device_str = self._build_device_string(device)
|
||||||
|
freq_hz = int(frequency_mhz * 1e6)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'rx_sdr',
|
||||||
|
'-d', device_str,
|
||||||
|
'-f', str(freq_hz),
|
||||||
|
'-s', str(sample_rate),
|
||||||
|
'-F', 'CU8',
|
||||||
|
]
|
||||||
|
|
||||||
|
if gain is not None and gain > 0:
|
||||||
|
lna, vga = self._split_gain(gain)
|
||||||
|
cmd.extend(['-g', f'LNA={lna},VGA={vga}'])
|
||||||
|
|
||||||
|
if bias_t:
|
||||||
|
cmd.append('-T')
|
||||||
|
|
||||||
|
# Output to stdout
|
||||||
|
cmd.append('-')
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
def get_capabilities(self) -> SDRCapabilities:
|
def get_capabilities(self) -> SDRCapabilities:
|
||||||
"""Return HackRF capabilities."""
|
"""Return HackRF capabilities."""
|
||||||
return self.CAPABILITIES
|
return self.CAPABILITIES
|
||||||
|
|||||||
@@ -162,6 +162,41 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
def build_iq_capture_command(
|
||||||
|
self,
|
||||||
|
device: SDRDevice,
|
||||||
|
frequency_mhz: float,
|
||||||
|
sample_rate: int = 2048000,
|
||||||
|
gain: Optional[float] = None,
|
||||||
|
ppm: Optional[int] = None,
|
||||||
|
bias_t: bool = False,
|
||||||
|
output_format: str = 'cu8',
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build rx_sdr command for raw I/Q capture with LimeSDR.
|
||||||
|
|
||||||
|
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
|
||||||
|
Note: LimeSDR does not support bias-T, parameter is ignored.
|
||||||
|
"""
|
||||||
|
device_str = self._build_device_string(device)
|
||||||
|
freq_hz = int(frequency_mhz * 1e6)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'rx_sdr',
|
||||||
|
'-d', device_str,
|
||||||
|
'-f', str(freq_hz),
|
||||||
|
'-s', str(sample_rate),
|
||||||
|
'-F', 'CU8',
|
||||||
|
]
|
||||||
|
|
||||||
|
if gain is not None and gain > 0:
|
||||||
|
cmd.extend(['-g', f'LNAH={int(gain)}'])
|
||||||
|
|
||||||
|
# Output to stdout
|
||||||
|
cmd.append('-')
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
def get_capabilities(self) -> SDRCapabilities:
|
def get_capabilities(self) -> SDRCapabilities:
|
||||||
"""Return LimeSDR capabilities."""
|
"""Return LimeSDR capabilities."""
|
||||||
return self.CAPABILITIES
|
return self.CAPABILITIES
|
||||||
|
|||||||
@@ -231,6 +231,45 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
def build_iq_capture_command(
|
||||||
|
self,
|
||||||
|
device: SDRDevice,
|
||||||
|
frequency_mhz: float,
|
||||||
|
sample_rate: int = 2048000,
|
||||||
|
gain: Optional[float] = None,
|
||||||
|
ppm: Optional[int] = None,
|
||||||
|
bias_t: bool = False,
|
||||||
|
output_format: str = 'cu8',
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build rtl_sdr command for raw I/Q capture.
|
||||||
|
|
||||||
|
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
|
||||||
|
"""
|
||||||
|
rtl_sdr_path = get_tool_path('rtl_sdr') or 'rtl_sdr'
|
||||||
|
freq_hz = int(frequency_mhz * 1e6)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
rtl_sdr_path,
|
||||||
|
'-d', self._get_device_arg(device),
|
||||||
|
'-f', str(freq_hz),
|
||||||
|
'-s', str(sample_rate),
|
||||||
|
]
|
||||||
|
|
||||||
|
if gain is not None and gain > 0:
|
||||||
|
cmd.extend(['-g', str(gain)])
|
||||||
|
|
||||||
|
if ppm is not None and ppm != 0:
|
||||||
|
cmd.extend(['-p', str(ppm)])
|
||||||
|
|
||||||
|
if bias_t:
|
||||||
|
cmd.append('-T')
|
||||||
|
|
||||||
|
# Output to stdout
|
||||||
|
cmd.append('-')
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
def get_capabilities(self) -> SDRCapabilities:
|
def get_capabilities(self) -> SDRCapabilities:
|
||||||
"""Return RTL-SDR capabilities."""
|
"""Return RTL-SDR capabilities."""
|
||||||
return self.CAPABILITIES
|
return self.CAPABILITIES
|
||||||
|
|||||||
@@ -163,6 +163,43 @@ class SDRPlayCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
def build_iq_capture_command(
|
||||||
|
self,
|
||||||
|
device: SDRDevice,
|
||||||
|
frequency_mhz: float,
|
||||||
|
sample_rate: int = 2048000,
|
||||||
|
gain: Optional[float] = None,
|
||||||
|
ppm: Optional[int] = None,
|
||||||
|
bias_t: bool = False,
|
||||||
|
output_format: str = 'cu8',
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build rx_sdr command for raw I/Q capture with SDRPlay.
|
||||||
|
|
||||||
|
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
|
||||||
|
"""
|
||||||
|
device_str = self._build_device_string(device)
|
||||||
|
freq_hz = int(frequency_mhz * 1e6)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'rx_sdr',
|
||||||
|
'-d', device_str,
|
||||||
|
'-f', str(freq_hz),
|
||||||
|
'-s', str(sample_rate),
|
||||||
|
'-F', 'CU8',
|
||||||
|
]
|
||||||
|
|
||||||
|
if gain is not None and gain > 0:
|
||||||
|
cmd.extend(['-g', f'IFGR={int(gain)}'])
|
||||||
|
|
||||||
|
if bias_t:
|
||||||
|
cmd.append('-T')
|
||||||
|
|
||||||
|
# Output to stdout
|
||||||
|
cmd.append('-')
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
def get_capabilities(self) -> SDRCapabilities:
|
def get_capabilities(self) -> SDRCapabilities:
|
||||||
"""Return SDRPlay capabilities."""
|
"""Return SDRPlay capabilities."""
|
||||||
return self.CAPABILITIES
|
return self.CAPABILITIES
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ class SSTVDecoder:
|
|||||||
self._rtl_process = None
|
self._rtl_process = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._callback: Callable[[DecodeProgress], None] | None = None
|
self._callback: Callable[[dict], None] | None = None
|
||||||
self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images')
|
self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images')
|
||||||
self._url_prefix = url_prefix
|
self._url_prefix = url_prefix
|
||||||
self._images: list[SSTVImage] = []
|
self._images: list[SSTVImage] = []
|
||||||
@@ -253,7 +253,7 @@ class SSTVDecoder:
|
|||||||
"""Return name of available decoder. Always available with pure Python."""
|
"""Return name of available decoder. Always available with pure Python."""
|
||||||
return 'python-sstv'
|
return 'python-sstv'
|
||||||
|
|
||||||
def set_callback(self, callback: Callable[[DecodeProgress], None]) -> None:
|
def set_callback(self, callback: Callable[[dict], None]) -> None:
|
||||||
"""Set callback for decode progress updates."""
|
"""Set callback for decode progress updates."""
|
||||||
self._callback = callback
|
self._callback = callback
|
||||||
|
|
||||||
@@ -420,6 +420,10 @@ class SSTVDecoder:
|
|||||||
|
|
||||||
chunk_counter += 1
|
chunk_counter += 1
|
||||||
|
|
||||||
|
# Scope: compute RMS/peak from raw int16 samples every chunk
|
||||||
|
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
|
||||||
|
peak_val = int(np.max(np.abs(raw_samples)))
|
||||||
|
|
||||||
if image_decoder is not None:
|
if image_decoder is not None:
|
||||||
# Currently decoding an image
|
# Currently decoding an image
|
||||||
complete = image_decoder.feed(samples)
|
complete = image_decoder.feed(samples)
|
||||||
@@ -447,6 +451,7 @@ class SSTVDecoder:
|
|||||||
message=f'Decoding {current_mode_name}: {pct}%',
|
message=f'Decoding {current_mode_name}: {pct}%',
|
||||||
partial_image=partial_url,
|
partial_image=partial_url,
|
||||||
))
|
))
|
||||||
|
self._emit_scope(rms_val, peak_val, 'decoding')
|
||||||
|
|
||||||
if complete:
|
if complete:
|
||||||
# Save image
|
# Save image
|
||||||
@@ -479,6 +484,7 @@ class SSTVDecoder:
|
|||||||
vis_detector.reset()
|
vis_detector.reset()
|
||||||
|
|
||||||
# Emit signal level metrics every ~500ms (every 5th 100ms chunk)
|
# Emit signal level metrics every ~500ms (every 5th 100ms chunk)
|
||||||
|
scope_tone: str | None = None
|
||||||
if chunk_counter % 5 == 0 and image_decoder is None:
|
if chunk_counter % 5 == 0 and image_decoder is None:
|
||||||
rms = float(np.sqrt(np.mean(samples ** 2)))
|
rms = float(np.sqrt(np.mean(samples ** 2)))
|
||||||
signal_level = min(100, int(rms * 500))
|
signal_level = min(100, int(rms * 500))
|
||||||
@@ -501,6 +507,8 @@ class SSTVDecoder:
|
|||||||
else:
|
else:
|
||||||
sstv_tone = None
|
sstv_tone = None
|
||||||
|
|
||||||
|
scope_tone = sstv_tone
|
||||||
|
|
||||||
self._emit_progress(DecodeProgress(
|
self._emit_progress(DecodeProgress(
|
||||||
status='detecting',
|
status='detecting',
|
||||||
message='Listening...',
|
message='Listening...',
|
||||||
@@ -509,6 +517,8 @@ class SSTVDecoder:
|
|||||||
vis_state=vis_detector.state.value,
|
vis_state=vis_detector.state.value,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
self._emit_scope(rms_val, peak_val, scope_tone)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in decode thread: {e}")
|
logger.error(f"Error in decode thread: {e}")
|
||||||
if not self._running:
|
if not self._running:
|
||||||
@@ -736,10 +746,18 @@ class SSTVDecoder:
|
|||||||
"""Emit progress update to callback."""
|
"""Emit progress update to callback."""
|
||||||
if self._callback:
|
if self._callback:
|
||||||
try:
|
try:
|
||||||
self._callback(progress)
|
self._callback(progress.to_dict())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in progress callback: {e}")
|
logger.error(f"Error in progress callback: {e}")
|
||||||
|
|
||||||
|
def _emit_scope(self, rms: int, peak: int, tone: str | None = None) -> None:
|
||||||
|
"""Emit scope signal levels to callback."""
|
||||||
|
if self._callback:
|
||||||
|
try:
|
||||||
|
self._callback({'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def decode_file(self, audio_path: str | Path) -> list[SSTVImage]:
|
def decode_file(self, audio_path: str | Path) -> list[SSTVImage]:
|
||||||
"""Decode SSTV image(s) from an audio file.
|
"""Decode SSTV image(s) from an audio file.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""FFT pipeline for real-time waterfall display.
|
||||||
|
|
||||||
|
Converts raw I/Q samples from SDR hardware into quantized power spectrum
|
||||||
|
frames suitable for binary WebSocket transmission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def cu8_to_complex(raw: bytes) -> np.ndarray:
|
||||||
|
"""Convert unsigned 8-bit I/Q bytes to complex64.
|
||||||
|
|
||||||
|
RTL-SDR (and rx_sdr with -F cu8) outputs interleaved unsigned 8-bit
|
||||||
|
I/Q pairs where 128 is the zero point.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw: Raw bytes, length must be even (I/Q pairs).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Complex64 array of length len(raw) // 2.
|
||||||
|
"""
|
||||||
|
iq = np.frombuffer(raw, dtype=np.uint8).astype(np.float32)
|
||||||
|
# Normalize: 0 -> -1.0, 128 -> ~0.0, 255 -> +1.0
|
||||||
|
iq = (iq - 127.5) / 127.5
|
||||||
|
return iq[0::2] + 1j * iq[1::2]
|
||||||
|
|
||||||
|
|
||||||
|
def compute_power_spectrum(
|
||||||
|
samples: np.ndarray,
|
||||||
|
fft_size: int = 1024,
|
||||||
|
avg_count: int = 4,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Compute averaged power spectrum in dBm.
|
||||||
|
|
||||||
|
Applies a Hann window, computes FFT, converts to power (dB),
|
||||||
|
and averages over multiple segments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
samples: Complex64 array, length >= fft_size * avg_count.
|
||||||
|
fft_size: Number of FFT bins.
|
||||||
|
avg_count: Number of segments to average.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Float32 array of length fft_size with power in dB (fftshift'd).
|
||||||
|
"""
|
||||||
|
window = np.hanning(fft_size).astype(np.float32)
|
||||||
|
accum = np.zeros(fft_size, dtype=np.float32)
|
||||||
|
actual_avg = 0
|
||||||
|
|
||||||
|
for i in range(avg_count):
|
||||||
|
offset = i * fft_size
|
||||||
|
if offset + fft_size > len(samples):
|
||||||
|
break
|
||||||
|
segment = samples[offset : offset + fft_size] * window
|
||||||
|
spectrum = np.fft.fft(segment)
|
||||||
|
power = np.real(spectrum * np.conj(spectrum))
|
||||||
|
# Avoid log10(0)
|
||||||
|
power = np.maximum(power, 1e-20)
|
||||||
|
accum += 10.0 * np.log10(power)
|
||||||
|
actual_avg += 1
|
||||||
|
|
||||||
|
if actual_avg == 0:
|
||||||
|
return np.full(fft_size, -100.0, dtype=np.float32)
|
||||||
|
|
||||||
|
accum /= actual_avg
|
||||||
|
return np.fft.fftshift(accum).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def quantize_to_uint8(
|
||||||
|
power_db: np.ndarray,
|
||||||
|
db_min: float | None = None,
|
||||||
|
db_max: float | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
"""Clamp and scale dB values to 0-255.
|
||||||
|
|
||||||
|
When *db_min* / *db_max* are ``None`` (the default) the range is
|
||||||
|
derived from the data so the full colour palette is always used.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
power_db: Float32 array of power values in dB.
|
||||||
|
db_min: Value mapped to 0 (auto if None).
|
||||||
|
db_max: Value mapped to 255 (auto if None).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Bytes of length len(power_db), each in [0, 255].
|
||||||
|
"""
|
||||||
|
if db_min is None or db_max is None:
|
||||||
|
actual_min = float(np.min(power_db))
|
||||||
|
actual_max = float(np.max(power_db))
|
||||||
|
# Guarantee at least 1 dB of dynamic range
|
||||||
|
if actual_max - actual_min < 1.0:
|
||||||
|
actual_max = actual_min + 1.0
|
||||||
|
if db_min is None:
|
||||||
|
db_min = actual_min
|
||||||
|
if db_max is None:
|
||||||
|
db_max = actual_max
|
||||||
|
|
||||||
|
db_range = db_max - db_min
|
||||||
|
if db_range <= 0:
|
||||||
|
db_range = 1.0
|
||||||
|
scaled = (power_db - db_min) / db_range * 255.0
|
||||||
|
clamped = np.clip(scaled, 0, 255).astype(np.uint8)
|
||||||
|
return clamped.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def build_binary_frame(
|
||||||
|
start_freq: float,
|
||||||
|
end_freq: float,
|
||||||
|
quantized_bins: bytes,
|
||||||
|
) -> bytes:
|
||||||
|
"""Pack a binary waterfall frame for WebSocket transmission.
|
||||||
|
|
||||||
|
Wire format (little-endian):
|
||||||
|
[uint8 msg_type=0x01]
|
||||||
|
[float32 start_freq]
|
||||||
|
[float32 end_freq]
|
||||||
|
[uint16 bin_count]
|
||||||
|
[uint8[] bins]
|
||||||
|
|
||||||
|
Total size = 11 + bin_count bytes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_freq: Start frequency in MHz.
|
||||||
|
end_freq: End frequency in MHz.
|
||||||
|
quantized_bins: Pre-quantized uint8 bin data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Binary frame bytes.
|
||||||
|
"""
|
||||||
|
bin_count = len(quantized_bins)
|
||||||
|
header = struct.pack('<BffH', 0x01, start_freq, end_freq, bin_count)
|
||||||
|
return header + quantized_bins
|
||||||
Reference in New Issue
Block a user