Merge upstream/main: add gsm_spy blueprint

This commit is contained in:
Mitch Ross
2026-02-08 13:15:20 -05:00
37 changed files with 8411 additions and 676 deletions
+31
View File
@@ -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 \
+82 -1
View File
@@ -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")
+6
View File
@@ -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(
+2
View File
@@ -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
+18 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -1305,11 +1305,20 @@ def start_audio() -> Response:
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 # Stop waterfall if it's using the same SDR (SSE path)
if waterfall_running and waterfall_active_device == device: if waterfall_running and waterfall_active_device == device:
_stop_waterfall_internal() _stop_waterfall_internal()
time.sleep(0.2) 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 # Claim device for listening audio
if listening_active_device is None or listening_active_device != device: 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:
+80 -2
View File
@@ -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
@@ -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:
+27
View File
@@ -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,
+4 -6
View File
@@ -21,8 +21,6 @@ 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
+4 -5
View File
@@ -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
+6
View File
@@ -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."""
+386
View File
@@ -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")
+146 -2
View File
@@ -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
} }
# ---------------------------- # ----------------------------
+11
View File
@@ -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;
+617 -54
View File
@@ -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;
+155
View File
@@ -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 = '';
+155
View File
@@ -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
View File
@@ -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);
+1
View File
@@ -67,6 +67,7 @@
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }} {{ mode_item('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>') }}
+332
View File
@@ -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']
+168
View File
@@ -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
View File
@@ -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")
+11
View File
@@ -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
+232
View File
@@ -148,6 +148,52 @@ def init_db() -> None:
) )
''') ''')
# 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('''
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
@@ -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
# ===================================================================== # =====================================================================
@@ -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
+32
View File
@@ -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'
}
}
}
} }
} }
+200
View File
@@ -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
+1
View File
@@ -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')
+37
View File
@@ -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
+35
View File
@@ -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:
+38
View File
@@ -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
+35
View File
@@ -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
+39
View File
@@ -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
+37
View File
@@ -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
+21 -3
View File
@@ -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.
+136
View 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