From b4742f205a451e10b5b633ef206d95ee71e07be3 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 6 Feb 2026 09:50:49 +0000 Subject: [PATCH 01/42] Update listening post handling --- routes/listening_post.py | 258 +++++++++++++++++++++++++-------------- 1 file changed, 164 insertions(+), 94 deletions(-) diff --git a/routes/listening_post.py b/routes/listening_post.py index 092f262..3633153 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -96,16 +96,27 @@ def find_rx_fm() -> str | None: return shutil.which('rx_fm') -def find_ffmpeg() -> str | None: - """Find ffmpeg for audio encoding.""" - return shutil.which('ffmpeg') - - - - -def add_activity_log(event_type: str, frequency: float, details: str = ''): - """Add entry to activity log.""" - with activity_log_lock: +def find_ffmpeg() -> str | None: + """Find ffmpeg for audio encoding.""" + return shutil.which('ffmpeg') + + +VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb'] + + +def normalize_modulation(value: str) -> str: + """Normalize and validate modulation string.""" + mod = str(value or '').lower().strip() + if mod not in VALID_MODULATIONS: + raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}') + return mod + + + + +def add_activity_log(event_type: str, frequency: float, details: str = ''): + """Add entry to activity log.""" + with activity_log_lock: entry = { 'timestamp': datetime.utcnow().isoformat() + 'Z', 'type': event_type, @@ -723,56 +734,106 @@ def _start_audio_stream(frequency: float, modulation: str): 'pipe:1' ] - try: - # Use shell pipe for reliable streaming - # Log stderr to temp files for error diagnosis - rtl_stderr_log = '/tmp/rtl_fm_stderr.log' - ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log' - shell_cmd = f"{' '.join(sdr_cmd)} 2>{rtl_stderr_log} | {' '.join(encoder_cmd)} 2>{ffmpeg_stderr_log}" - logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}") - - # Retry loop for USB device contention (device may not be - # released immediately after a previous process exits) - max_attempts = 3 - for attempt in range(max_attempts): - audio_rtl_process = None # Not used in shell mode - audio_process = subprocess.Popen( - shell_cmd, - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=0, - start_new_session=True # Create new process group for clean shutdown - ) - - # Brief delay to check if process started successfully - time.sleep(0.3) - - if audio_process.poll() is not None: - # Read stderr from temp files - rtl_stderr = '' - ffmpeg_stderr = '' - try: - with open(rtl_stderr_log, 'r') as f: - rtl_stderr = f.read().strip() - except Exception: - pass - try: - with open(ffmpeg_stderr_log, 'r') as f: - ffmpeg_stderr = f.read().strip() - except Exception: - pass - - if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1: - logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...") - time.sleep(1.0) - continue - - logger.error(f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}") - return - - # Pipeline started successfully - break + try: + # Use subprocess piping for reliable streaming. + # Log stderr to temp files for error diagnosis. + rtl_stderr_log = '/tmp/rtl_fm_stderr.log' + ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log' + logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}") + + # Retry loop for USB device contention (device may not be + # released immediately after a previous process exits) + max_attempts = 3 + for attempt in range(max_attempts): + audio_rtl_process = None + audio_process = None + rtl_err_handle = None + ffmpeg_err_handle = None + try: + rtl_err_handle = open(rtl_stderr_log, 'w') + ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w') + audio_rtl_process = subprocess.Popen( + sdr_cmd, + stdout=subprocess.PIPE, + stderr=rtl_err_handle, + bufsize=0, + start_new_session=True # Create new process group for clean shutdown + ) + audio_process = subprocess.Popen( + encoder_cmd, + stdin=audio_rtl_process.stdout, + stdout=subprocess.PIPE, + stderr=ffmpeg_err_handle, + bufsize=0, + start_new_session=True # Create new process group for clean shutdown + ) + if audio_rtl_process.stdout: + audio_rtl_process.stdout.close() + finally: + if rtl_err_handle: + rtl_err_handle.close() + if ffmpeg_err_handle: + ffmpeg_err_handle.close() + + # Brief delay to check if process started successfully + time.sleep(0.3) + + if (audio_rtl_process and audio_rtl_process.poll() is not None) or ( + audio_process and audio_process.poll() is not None + ): + # Read stderr from temp files + rtl_stderr = '' + ffmpeg_stderr = '' + try: + with open(rtl_stderr_log, 'r') as f: + rtl_stderr = f.read().strip() + except Exception: + pass + try: + with open(ffmpeg_stderr_log, 'r') as f: + ffmpeg_stderr = f.read().strip() + except Exception: + pass + + if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1: + logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...") + if audio_process: + try: + audio_process.terminate() + audio_process.wait(timeout=0.5) + except Exception: + pass + if audio_rtl_process: + try: + audio_rtl_process.terminate() + audio_rtl_process.wait(timeout=0.5) + except Exception: + pass + time.sleep(1.0) + continue + + if audio_process and audio_process.poll() is None: + try: + audio_process.terminate() + audio_process.wait(timeout=0.5) + except Exception: + pass + if audio_rtl_process and audio_rtl_process.poll() is None: + try: + audio_rtl_process.terminate() + audio_rtl_process.wait(timeout=0.5) + except Exception: + pass + audio_process = None + audio_rtl_process = None + + logger.error( + f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}" + ) + return + + # Pipeline started successfully + break # Validate that audio is producing data quickly try: @@ -797,28 +858,38 @@ def _stop_audio_stream(): _stop_audio_stream_internal() -def _stop_audio_stream_internal(): - """Internal stop (must hold lock).""" - global audio_process, audio_rtl_process, audio_running, audio_frequency - - # Set flag first to stop any streaming - audio_running = False - audio_frequency = 0.0 - - # Kill the shell process and its children - if audio_process: - try: - # Kill entire process group (rtl_fm, ffmpeg, shell) - try: - os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL) - except (ProcessLookupError, PermissionError): - audio_process.kill() - audio_process.wait(timeout=0.5) - except: - pass - - audio_process = None - audio_rtl_process = None +def _stop_audio_stream_internal(): + """Internal stop (must hold lock).""" + global audio_process, audio_rtl_process, audio_running, audio_frequency + + # Set flag first to stop any streaming + audio_running = False + audio_frequency = 0.0 + + # Kill the pipeline processes and their groups + if audio_process: + try: + # Kill entire process group (SDR demod + ffmpeg) + try: + os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + audio_process.kill() + audio_process.wait(timeout=0.5) + except Exception: + pass + + if audio_rtl_process: + try: + try: + os.killpg(os.getpgid(audio_rtl_process.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + audio_rtl_process.kill() + audio_rtl_process.wait(timeout=0.5) + except Exception: + pass + + audio_process = None + audio_rtl_process = None # Kill any orphaned rtl_fm, rtl_power, and ffmpeg processes for proc_pattern in ['rtl_fm', 'rtl_power']: @@ -891,7 +962,7 @@ def start_scanner() -> Response: scanner_config['start_freq'] = float(data.get('start_freq', 88.0)) scanner_config['end_freq'] = float(data.get('end_freq', 108.0)) scanner_config['step'] = float(data.get('step', 0.1)) - scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower() + scanner_config['modulation'] = normalize_modulation(data.get('modulation', 'wfm')) scanner_config['squelch'] = int(data.get('squelch', 0)) scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0)) scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5)) @@ -1073,9 +1144,15 @@ def update_scanner_config() -> Response: scanner_config['dwell_time'] = int(data['dwell_time']) updated.append(f"dwell={data['dwell_time']}s") - if 'modulation' in data: - scanner_config['modulation'] = str(data['modulation']).lower() - updated.append(f"mod={data['modulation']}") + if 'modulation' in data: + try: + scanner_config['modulation'] = normalize_modulation(data['modulation']) + updated.append(f"mod={data['modulation']}") + except (ValueError, TypeError) as e: + return jsonify({ + 'status': 'error', + 'message': str(e) + }), 400 if updated: logger.info(f"Scanner config updated: {', '.join(updated)}") @@ -1197,7 +1274,7 @@ def start_audio() -> Response: try: frequency = float(data.get('frequency', 0)) - modulation = str(data.get('modulation', 'wfm')).lower() + modulation = normalize_modulation(data.get('modulation', 'wfm')) squelch = int(data.get('squelch', 0)) gain = int(data.get('gain', 40)) device = int(data.get('device', 0)) @@ -1214,13 +1291,6 @@ def start_audio() -> Response: 'message': 'frequency is required' }), 400 - valid_mods = ['fm', 'wfm', 'am', 'usb', 'lsb'] - if modulation not in valid_mods: - return jsonify({ - 'status': 'error', - 'message': f'Invalid modulation. Use: {", ".join(valid_mods)}' - }), 400 - valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay'] if sdr_type not in valid_sdr_types: return jsonify({ From 8fca54e52339edd521dfb5fb744d88816474044d Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 6 Feb 2026 13:50:09 +0000 Subject: [PATCH 02/42] Fix APRS rtl_fm startup failure and SDR device conflicts (#122) Add SDR device reservation to prevent conflicts with other modes, and capture rtl_fm stderr so actual error messages are reported to the user instead of a generic exit code. Co-Authored-By: Claude Opus 4.6 --- routes/aprs.py | 64 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/routes/aprs.py b/routes/aprs.py index 41bb886..cf7fe61 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -13,7 +13,7 @@ import tempfile import threading import time from datetime import datetime -from subprocess import DEVNULL, PIPE, STDOUT +from subprocess import PIPE, STDOUT from typing import Generator, Optional from flask import Blueprint, jsonify, request, Response @@ -31,6 +31,9 @@ from utils.constants import ( aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs') +# Track which SDR device is being used +aprs_active_device: int | None = None + # APRS frequencies by region (MHz) APRS_FREQUENCIES = { 'north_america': '144.390', @@ -1301,7 +1304,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces This function reads from the decoder's stdout (text mode, line-buffered). The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks. - rtl_fm's stderr is sent to DEVNULL for the same reason. + rtl_fm's stderr is captured via PIPE with a monitor thread. Outputs two types of messages to the queue: - type='aprs': Decoded APRS packets @@ -1383,6 +1386,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces logger.error(f"APRS stream error: {e}") app_module.aprs_queue.put({'type': 'error', 'message': str(e)}) finally: + global aprs_active_device app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'}) # Cleanup processes for proc in [rtl_process, decoder_process]: @@ -1394,6 +1398,10 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces proc.kill() except Exception: pass + # Release SDR device + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None @aprs_bp.route('/tools') @@ -1441,6 +1449,7 @@ def get_stations() -> Response: def start_aprs() -> Response: """Start APRS decoder.""" global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations + global aprs_active_device with app_module.aprs_lock: if app_module.aprs_process and app_module.aprs_process.poll() is None: @@ -1477,6 +1486,16 @@ def start_aprs() -> Response: except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 + # Reserve SDR device to prevent conflicts with other modes + error = app_module.reserve_sdr_device(device, 'APRS') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + aprs_active_device = device + # Get frequency for region region = data.get('region', 'north_america') frequency = APRS_FREQUENCIES.get(region, '144.390') @@ -1552,15 +1571,25 @@ def start_aprs() -> Response: try: # Start rtl_fm with stdout piped to decoder. - # stderr goes to DEVNULL to prevent blocking (rtl_fm logs to stderr). + # stderr is captured via PIPE so errors are reported to the user. # NOTE: RTL-SDR Blog V4 may show offset-tuned frequency in logs - this is normal. rtl_process = subprocess.Popen( rtl_cmd, stdout=PIPE, - stderr=DEVNULL, + stderr=PIPE, start_new_session=True ) + # Start a thread to monitor rtl_fm stderr for errors + def monitor_rtl_stderr(): + for line in rtl_process.stderr: + err_text = line.decode('utf-8', errors='replace').strip() + if err_text: + logger.debug(f"[RTL_FM] {err_text}") + + rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True) + rtl_stderr_thread.start() + # Start decoder with stdin wired to rtl_fm's stdout. # Use text mode with line buffering for reliable line-by-line reading. # Merge stderr into stdout to avoid blocking on unbuffered stderr. @@ -1582,13 +1611,25 @@ def start_aprs() -> Response: time.sleep(PROCESS_START_WAIT) if rtl_process.poll() is not None: - # rtl_fm exited early - something went wrong + # rtl_fm exited early - capture stderr for diagnostics + stderr_output = '' + try: + remaining = rtl_process.stderr.read() + if remaining: + stderr_output = remaining.decode('utf-8', errors='replace').strip() + except Exception: + pass error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})' + if stderr_output: + error_msg += f': {stderr_output[:200]}' logger.error(error_msg) try: decoder_process.kill() except Exception: pass + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None return jsonify({'status': 'error', 'message': error_msg}), 500 if decoder_process.poll() is not None: @@ -1602,6 +1643,9 @@ def start_aprs() -> Response: rtl_process.kill() except Exception: pass + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None return jsonify({'status': 'error', 'message': error_msg}), 500 # Store references for status checks and cleanup @@ -1626,12 +1670,17 @@ def start_aprs() -> Response: except Exception as e: logger.error(f"Failed to start APRS decoder: {e}") + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None return jsonify({'status': 'error', 'message': str(e)}), 500 @aprs_bp.route('/stop', methods=['POST']) def stop_aprs() -> Response: """Stop APRS decoder.""" + global aprs_active_device + with app_module.aprs_lock: processes_to_stop = [] @@ -1660,6 +1709,11 @@ def stop_aprs() -> Response: if hasattr(app_module, 'aprs_rtl_process'): app_module.aprs_rtl_process = None + # Release SDR device + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None + return jsonify({'status': 'stopped'}) From 4c67307951e191040176247cf3153498c37edff8 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 6 Feb 2026 15:36:41 +0000 Subject: [PATCH 03/42] Add terrestrial HF SSTV mode with predefined frequencies and modulation support Adds a general-purpose SSTV decoder alongside the existing ISS SSTV mode, supporting USB/LSB/FM modulation on common amateur radio HF/VHF/UHF frequencies (14.230 MHz USB, 3.845 MHz LSB, etc.) with auto-detection of modulation from preset frequency table. Co-Authored-By: Claude Opus 4.6 --- routes/__init__.py | 6 + routes/sstv_general.py | 288 ++++++++++ static/css/modes/sstv-general.css | 477 ++++++++++++++++ static/js/modes/sstv-general.js | 410 ++++++++++++++ templates/index.html | 614 +++++++++++++++++++-- templates/partials/modes/sstv-general.html | 86 +++ templates/partials/nav.html | 6 + utils/sstv.py | 99 ++-- 8 files changed, 1897 insertions(+), 89 deletions(-) create mode 100644 routes/sstv_general.py create mode 100644 static/css/modes/sstv-general.css create mode 100644 static/js/modes/sstv-general.js create mode 100644 templates/partials/modes/sstv-general.html diff --git a/routes/__init__.py b/routes/__init__.py index 8436739..b4426bd 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -26,6 +26,9 @@ def register_blueprints(app): from .offline import offline_bp from .updater import updater_bp from .sstv import sstv_bp + from .sstv_general import sstv_general_bp + from .dmr import dmr_bp + from .websdr import websdr_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -51,6 +54,9 @@ def register_blueprints(app): app.register_blueprint(offline_bp) # Offline mode settings app.register_blueprint(updater_bp) # GitHub update checking app.register_blueprint(sstv_bp) # ISS SSTV decoder + app.register_blueprint(sstv_general_bp) # General terrestrial SSTV + app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice + app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/sstv_general.py b/routes/sstv_general.py new file mode 100644 index 0000000..4d8a8d7 --- /dev/null +++ b/routes/sstv_general.py @@ -0,0 +1,288 @@ +"""General SSTV (Slow-Scan Television) decoder routes. + +Provides endpoints for decoding terrestrial SSTV images on common HF/VHF/UHF +frequencies used by amateur radio operators worldwide. +""" + +from __future__ import annotations + +import queue +import time +from collections.abc import Generator +from pathlib import Path + +from flask import Blueprint, Response, jsonify, request, send_file + +from utils.logging import get_logger +from utils.sse import format_sse +from utils.sstv import ( + DecodeProgress, + get_general_sstv_decoder, +) + +logger = get_logger('intercept.sstv_general') + +sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general') + +# Queue for SSE progress streaming +_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100) + +# Predefined SSTV frequencies +SSTV_FREQUENCIES = [ + {'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'}, + {'band': '80 m', 'frequency': 3.730, 'modulation': 'lsb', 'notes': 'Europe primary (analog/digital variants)', 'type': 'Terrestrial HF'}, + {'band': '40 m', 'frequency': 7.171, 'modulation': 'lsb', 'notes': 'Common international/US/EU SSTV activity', 'type': 'Terrestrial HF'}, + {'band': '40 m', 'frequency': 7.040, 'modulation': 'lsb', 'notes': 'Alternative US/Europe calling', 'type': 'Terrestrial HF'}, + {'band': '30 m', 'frequency': 10.132, 'modulation': 'usb', 'notes': 'Narrowband SSTV (e.g., MP73-N digital)', 'type': 'Terrestrial HF'}, + {'band': '20 m', 'frequency': 14.230, 'modulation': 'usb', 'notes': 'Most popular international SSTV frequency', 'type': 'Terrestrial HF'}, + {'band': '20 m', 'frequency': 14.233, 'modulation': 'usb', 'notes': 'Digital SSTV calling / alternative activity', 'type': 'Terrestrial HF'}, + {'band': '20 m', 'frequency': 14.240, 'modulation': 'usb', 'notes': 'Europe alternative', 'type': 'Terrestrial HF'}, + {'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'}, + {'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'}, + {'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'}, + {'band': '2 m', 'frequency': 145.625, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'}, + {'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'}, +] + +# Build a lookup for auto-detecting modulation from frequency +_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES} + + +def _progress_callback(progress: DecodeProgress) -> None: + """Callback to queue progress updates for SSE stream.""" + try: + _sstv_general_queue.put_nowait(progress.to_dict()) + except queue.Full: + try: + _sstv_general_queue.get_nowait() + _sstv_general_queue.put_nowait(progress.to_dict()) + except queue.Empty: + pass + + +@sstv_general_bp.route('/frequencies') +def get_frequencies(): + """Return the predefined SSTV frequency table.""" + return jsonify({ + 'status': 'ok', + 'frequencies': SSTV_FREQUENCIES, + }) + + +@sstv_general_bp.route('/status') +def get_status(): + """Get general SSTV decoder status.""" + decoder = get_general_sstv_decoder() + + return jsonify({ + 'available': decoder.decoder_available is not None, + 'decoder': decoder.decoder_available, + 'running': decoder.is_running, + 'image_count': len(decoder.get_images()), + }) + + +@sstv_general_bp.route('/start', methods=['POST']) +def start_decoder(): + """ + Start general SSTV decoder. + + JSON body: + { + "frequency": 14.230, // Frequency in MHz (required) + "modulation": "usb", // fm, usb, or lsb (auto-detected from frequency table if omitted) + "device": 0 // RTL-SDR device index + } + """ + decoder = get_general_sstv_decoder() + + if decoder.decoder_available is None: + return jsonify({ + 'status': 'error', + 'message': 'SSTV decoder not available. Install slowrx: apt install slowrx', + }), 400 + + if decoder.is_running: + return jsonify({ + 'status': 'already_running', + }) + + # Clear queue + while not _sstv_general_queue.empty(): + try: + _sstv_general_queue.get_nowait() + except queue.Empty: + break + + data = request.get_json(silent=True) or {} + frequency = data.get('frequency') + modulation = data.get('modulation') + device_index = data.get('device', 0) + + # Validate frequency + if frequency is None: + return jsonify({ + 'status': 'error', + 'message': 'Frequency is required', + }), 400 + + try: + frequency = float(frequency) + if not (1 <= frequency <= 500): + return jsonify({ + 'status': 'error', + 'message': 'Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)', + }), 400 + except (TypeError, ValueError): + return jsonify({ + 'status': 'error', + 'message': 'Invalid frequency', + }), 400 + + # Auto-detect modulation from frequency table if not specified + if not modulation: + modulation = _FREQ_MODULATION_MAP.get(frequency, 'usb') + + # Validate modulation + if modulation not in ('fm', 'usb', 'lsb'): + return jsonify({ + 'status': 'error', + 'message': 'Modulation must be fm, usb, or lsb', + }), 400 + + # Set callback and start + decoder.set_callback(_progress_callback) + success = decoder.start( + frequency=frequency, + device_index=device_index, + modulation=modulation, + ) + + if success: + return jsonify({ + 'status': 'started', + 'frequency': frequency, + 'modulation': modulation, + 'device': device_index, + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'Failed to start decoder', + }), 500 + + +@sstv_general_bp.route('/stop', methods=['POST']) +def stop_decoder(): + """Stop general SSTV decoder.""" + decoder = get_general_sstv_decoder() + decoder.stop() + return jsonify({'status': 'stopped'}) + + +@sstv_general_bp.route('/images') +def list_images(): + """Get list of decoded SSTV images.""" + decoder = get_general_sstv_decoder() + images = decoder.get_images() + + limit = request.args.get('limit', type=int) + if limit and limit > 0: + images = images[-limit:] + + return jsonify({ + 'status': 'ok', + 'images': [img.to_dict() for img in images], + 'count': len(images), + }) + + +@sstv_general_bp.route('/images/') +def get_image(filename: str): + """Get a decoded SSTV image file.""" + decoder = get_general_sstv_decoder() + + # Security: only allow alphanumeric filenames with .png extension + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not filename.endswith('.png'): + return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 + + image_path = decoder._output_dir / filename + + if not image_path.exists(): + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + return send_file(image_path, mimetype='image/png') + + +@sstv_general_bp.route('/stream') +def stream_progress(): + """SSE stream of SSTV decode progress.""" + def generate() -> Generator[str, None, None]: + last_keepalive = time.time() + keepalive_interval = 30.0 + + while True: + try: + progress = _sstv_general_queue.get(timeout=1) + last_keepalive = time.time() + yield format_sse(progress) + except queue.Empty: + now = time.time() + if now - last_keepalive >= keepalive_interval: + yield format_sse({'type': 'keepalive'}) + last_keepalive = now + + response = Response(generate(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response + + +@sstv_general_bp.route('/decode-file', methods=['POST']) +def decode_file(): + """Decode SSTV from an uploaded audio file.""" + if 'audio' not in request.files: + return jsonify({ + 'status': 'error', + 'message': 'No audio file provided', + }), 400 + + audio_file = request.files['audio'] + + if not audio_file.filename: + return jsonify({ + 'status': 'error', + 'message': 'No file selected', + }), 400 + + import tempfile + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp: + audio_file.save(tmp.name) + tmp_path = tmp.name + + try: + decoder = get_general_sstv_decoder() + images = decoder.decode_file(tmp_path) + + return jsonify({ + 'status': 'ok', + 'images': [img.to_dict() for img in images], + 'count': len(images), + }) + + except Exception as e: + logger.error(f"Error decoding file: {e}") + return jsonify({ + 'status': 'error', + 'message': str(e), + }), 500 + + finally: + try: + Path(tmp_path).unlink() + except Exception: + pass diff --git a/static/css/modes/sstv-general.css b/static/css/modes/sstv-general.css new file mode 100644 index 0000000..9e2949d --- /dev/null +++ b/static/css/modes/sstv-general.css @@ -0,0 +1,477 @@ +/** + * SSTV General Mode Styles + * Terrestrial Slow-Scan Television decoder interface + */ + +/* ============================================ + MODE VISIBILITY + ============================================ */ +#sstvGeneralMode.active { + display: block !important; +} + +/* ============================================ + VISUALS CONTAINER + ============================================ */ +.sstv-general-visuals-container { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + min-height: 0; + flex: 1; + height: 100%; + overflow: hidden; +} + +/* ============================================ + STATS STRIP + ============================================ */ +.sstv-general-stats-strip { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + flex-wrap: wrap; + flex-shrink: 0; +} + +.sstv-general-strip-group { + display: flex; + align-items: center; + gap: 12px; +} + +.sstv-general-strip-status { + display: flex; + align-items: center; + gap: 6px; +} + +.sstv-general-strip-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.sstv-general-strip-dot.idle { + background: var(--text-dim); +} + +.sstv-general-strip-dot.listening { + background: var(--accent-yellow); + animation: sstv-general-pulse 1s infinite; +} + +.sstv-general-strip-dot.decoding { + background: var(--accent-cyan); + box-shadow: 0 0 6px var(--accent-cyan); + animation: sstv-general-pulse 0.5s infinite; +} + +.sstv-general-strip-status-text { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + text-transform: uppercase; +} + +.sstv-general-strip-btn { + font-family: var(--font-mono); + font-size: 10px; + padding: 5px 12px; + border: none; + border-radius: 4px; + cursor: pointer; + text-transform: uppercase; + font-weight: 600; + transition: all 0.15s ease; +} + +.sstv-general-strip-btn.start { + background: var(--accent-cyan); + color: var(--bg-primary); +} + +.sstv-general-strip-btn.start:hover { + background: var(--accent-cyan-bright, #00d4ff); +} + +.sstv-general-strip-btn.stop { + background: var(--accent-red, #ff3366); + color: white; +} + +.sstv-general-strip-btn.stop:hover { + background: #ff1a53; +} + +.sstv-general-strip-divider { + width: 1px; + height: 24px; + background: var(--border-color); +} + +.sstv-general-strip-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + min-width: 50px; +} + +.sstv-general-strip-value { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.sstv-general-strip-value.accent-cyan { + color: var(--accent-cyan); +} + +.sstv-general-strip-label { + font-family: var(--font-mono); + font-size: 8px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ============================================ + MAIN ROW (Live Decode + Gallery) + ============================================ */ +.sstv-general-main-row { + display: flex; + flex-direction: row; + gap: 12px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ============================================ + LIVE DECODE SECTION + ============================================ */ +.sstv-general-live-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1; + min-width: 300px; +} + +.sstv-general-live-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid var(--border-color); +} + +.sstv-general-live-title { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.sstv-general-live-title svg { + color: var(--accent-cyan); +} + +.sstv-general-live-content { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 16px; + min-height: 0; +} + +.sstv-general-canvas-container { + position: relative; + background: #000; + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; +} + +.sstv-general-decode-info { + width: 100%; + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sstv-general-mode-label { + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent-cyan); + text-align: center; +} + +.sstv-general-progress-bar { + width: 100%; + height: 4px; + background: var(--bg-secondary); + border-radius: 2px; + overflow: hidden; +} + +.sstv-general-progress-bar .progress { + height: 100%; + background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); + border-radius: 2px; + transition: width 0.3s ease; +} + +.sstv-general-status-message { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + text-align: center; +} + +/* Idle state */ +.sstv-general-idle-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 40px 20px; + color: var(--text-dim); +} + +.sstv-general-idle-state svg { + width: 64px; + height: 64px; + opacity: 0.3; + margin-bottom: 16px; +} + +.sstv-general-idle-state h4 { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.sstv-general-idle-state p { + font-size: 12px; + max-width: 250px; +} + +/* ============================================ + GALLERY SECTION + ============================================ */ +.sstv-general-gallery-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1.5; + min-width: 300px; +} + +.sstv-general-gallery-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid var(--border-color); +} + +.sstv-general-gallery-title { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.sstv-general-gallery-count { + font-family: var(--font-mono); + font-size: 10px; + color: var(--accent-cyan); + background: var(--bg-secondary); + padding: 2px 8px; + border-radius: 10px; +} + +.sstv-general-gallery-grid { + flex: 1; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + padding: 12px; + overflow-y: auto; + align-content: start; +} + +.sstv-general-image-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; + transition: all 0.15s ease; + cursor: pointer; +} + +.sstv-general-image-card:hover { + border-color: var(--accent-cyan); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2); +} + +.sstv-general-image-preview { + width: 100%; + aspect-ratio: 4/3; + object-fit: cover; + background: #000; + display: block; +} + +.sstv-general-image-info { + padding: 8px 10px; + border-top: 1px solid var(--border-color); +} + +.sstv-general-image-mode { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: var(--accent-cyan); + margin-bottom: 4px; +} + +.sstv-general-image-timestamp { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); +} + +/* Empty gallery state */ +.sstv-general-gallery-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + color: var(--text-dim); + grid-column: 1 / -1; +} + +.sstv-general-gallery-empty svg { + width: 48px; + height: 48px; + opacity: 0.3; + margin-bottom: 12px; +} + +/* ============================================ + IMAGE MODAL + ============================================ */ +.sstv-general-image-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.9); + display: none; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 40px; +} + +.sstv-general-image-modal.show { + display: flex; +} + +.sstv-general-image-modal img { + max-width: 100%; + max-height: 100%; + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.sstv-general-modal-close { + position: absolute; + top: 20px; + right: 20px; + background: none; + border: none; + color: white; + font-size: 32px; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s; +} + +.sstv-general-modal-close:hover { + opacity: 1; +} + +/* ============================================ + RESPONSIVE + ============================================ */ +@media (max-width: 1024px) { + .sstv-general-main-row { + flex-direction: column; + overflow-y: auto; + } + + .sstv-general-live-section { + max-width: none; + min-height: 350px; + } + + .sstv-general-gallery-section { + min-height: 300px; + } +} + +@media (max-width: 768px) { + .sstv-general-stats-strip { + padding: 8px 12px; + gap: 8px; + flex-wrap: wrap; + } + + .sstv-general-strip-divider { + display: none; + } + + .sstv-general-gallery-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 8px; + padding: 8px; + } +} + +@keyframes sstv-general-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} diff --git a/static/js/modes/sstv-general.js b/static/js/modes/sstv-general.js new file mode 100644 index 0000000..aa977a9 --- /dev/null +++ b/static/js/modes/sstv-general.js @@ -0,0 +1,410 @@ +/** + * SSTV General Mode + * Terrestrial Slow-Scan Television decoder interface + */ + +const SSTVGeneral = (function() { + // State + let isRunning = false; + let eventSource = null; + let images = []; + let currentMode = null; + let progress = 0; + + /** + * Initialize the SSTV General mode + */ + function init() { + checkStatus(); + loadImages(); + } + + /** + * Select a preset frequency from the dropdown + */ + function selectPreset(value) { + if (!value) return; + + const parts = value.split('|'); + const freq = parseFloat(parts[0]); + const mod = parts[1]; + + const freqInput = document.getElementById('sstvGeneralFrequency'); + const modSelect = document.getElementById('sstvGeneralModulation'); + + if (freqInput) freqInput.value = freq; + if (modSelect) modSelect.value = mod; + + // Update strip display + const stripFreq = document.getElementById('sstvGeneralStripFreq'); + const stripMod = document.getElementById('sstvGeneralStripMod'); + if (stripFreq) stripFreq.textContent = freq.toFixed(3); + if (stripMod) stripMod.textContent = mod.toUpperCase(); + } + + /** + * Check current decoder status + */ + async function checkStatus() { + try { + const response = await fetch('/sstv-general/status'); + const data = await response.json(); + + if (!data.available) { + updateStatusUI('unavailable', 'Decoder not installed'); + showStatusMessage('SSTV decoder not available. Install slowrx: apt install slowrx', 'warning'); + return; + } + + if (data.running) { + isRunning = true; + updateStatusUI('listening', 'Listening...'); + startStream(); + } else { + updateStatusUI('idle', 'Idle'); + } + + updateImageCount(data.image_count || 0); + } catch (err) { + console.error('Failed to check SSTV General status:', err); + } + } + + /** + * Start SSTV decoder + */ + async function start() { + const freqInput = document.getElementById('sstvGeneralFrequency'); + const modSelect = document.getElementById('sstvGeneralModulation'); + const deviceSelect = document.getElementById('deviceSelect'); + + const frequency = parseFloat(freqInput?.value || '14.230'); + const modulation = modSelect?.value || 'usb'; + const device = parseInt(deviceSelect?.value || '0', 10); + + updateStatusUI('connecting', 'Starting...'); + + try { + const response = await fetch('/sstv-general/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ frequency, modulation, device }) + }); + + const data = await response.json(); + + if (data.status === 'started' || data.status === 'already_running') { + isRunning = true; + updateStatusUI('listening', `${frequency} MHz ${modulation.toUpperCase()}`); + startStream(); + showNotification('SSTV', `Listening on ${frequency} MHz ${modulation.toUpperCase()}`); + + // Update strip + const stripFreq = document.getElementById('sstvGeneralStripFreq'); + const stripMod = document.getElementById('sstvGeneralStripMod'); + if (stripFreq) stripFreq.textContent = frequency.toFixed(3); + if (stripMod) stripMod.textContent = modulation.toUpperCase(); + } else { + updateStatusUI('idle', 'Start failed'); + showStatusMessage(data.message || 'Failed to start decoder', 'error'); + } + } catch (err) { + console.error('Failed to start SSTV General:', err); + updateStatusUI('idle', 'Error'); + showStatusMessage('Connection error: ' + err.message, 'error'); + } + } + + /** + * Stop SSTV decoder + */ + async function stop() { + try { + await fetch('/sstv-general/stop', { method: 'POST' }); + isRunning = false; + stopStream(); + updateStatusUI('idle', 'Stopped'); + showNotification('SSTV', 'Decoder stopped'); + } catch (err) { + console.error('Failed to stop SSTV General:', err); + } + } + + /** + * Update status UI elements + */ + function updateStatusUI(status, text) { + const dot = document.getElementById('sstvGeneralStripDot'); + const statusText = document.getElementById('sstvGeneralStripStatus'); + const startBtn = document.getElementById('sstvGeneralStartBtn'); + const stopBtn = document.getElementById('sstvGeneralStopBtn'); + + if (dot) { + dot.className = 'sstv-general-strip-dot'; + if (status === 'listening' || status === 'detecting') { + dot.classList.add('listening'); + } else if (status === 'decoding') { + dot.classList.add('decoding'); + } else { + dot.classList.add('idle'); + } + } + + if (statusText) { + statusText.textContent = text || status; + } + + if (startBtn && stopBtn) { + if (status === 'listening' || status === 'decoding') { + startBtn.style.display = 'none'; + stopBtn.style.display = 'inline-block'; + } else { + startBtn.style.display = 'inline-block'; + stopBtn.style.display = 'none'; + } + } + + // Update live content area + const liveContent = document.getElementById('sstvGeneralLiveContent'); + if (liveContent) { + if (status === 'idle' || status === 'unavailable') { + liveContent.innerHTML = renderIdleState(); + } + } + } + + /** + * Render idle state HTML + */ + function renderIdleState() { + return ` +
+ + + + + +

SSTV Decoder

+

Select a frequency and click Start to listen for SSTV transmissions

+
+ `; + } + + /** + * Start SSE stream + */ + function startStream() { + if (eventSource) { + eventSource.close(); + } + + eventSource = new EventSource('/sstv-general/stream'); + + eventSource.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + if (data.type === 'sstv_progress') { + handleProgress(data); + } + } catch (err) { + console.error('Failed to parse SSE message:', err); + } + }; + + eventSource.onerror = () => { + console.warn('SSTV General SSE error, will reconnect...'); + setTimeout(() => { + if (isRunning) startStream(); + }, 3000); + }; + } + + /** + * Stop SSE stream + */ + function stopStream() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + } + + /** + * Handle progress update + */ + function handleProgress(data) { + currentMode = data.mode || currentMode; + progress = data.progress || 0; + + if (data.status === 'decoding') { + updateStatusUI('decoding', `Decoding ${currentMode || 'image'}...`); + renderDecodeProgress(data); + } else if (data.status === 'complete' && data.image) { + images.unshift(data.image); + updateImageCount(images.length); + renderGallery(); + showNotification('SSTV', 'New image decoded!'); + updateStatusUI('listening', 'Listening...'); + } else if (data.status === 'detecting') { + updateStatusUI('listening', data.message || 'Listening...'); + } + } + + /** + * Render decode progress in live area + */ + function renderDecodeProgress(data) { + const liveContent = document.getElementById('sstvGeneralLiveContent'); + if (!liveContent) return; + + liveContent.innerHTML = ` +
+ +
+
+
${data.mode || 'Detecting mode...'}
+
+
+
+
${data.message || 'Decoding...'}
+
+ `; + } + + /** + * Load decoded images + */ + async function loadImages() { + try { + const response = await fetch('/sstv-general/images'); + const data = await response.json(); + + if (data.status === 'ok') { + images = data.images || []; + updateImageCount(images.length); + renderGallery(); + } + } catch (err) { + console.error('Failed to load SSTV General images:', err); + } + } + + /** + * Update image count display + */ + function updateImageCount(count) { + const countEl = document.getElementById('sstvGeneralImageCount'); + const stripCount = document.getElementById('sstvGeneralStripImageCount'); + + if (countEl) countEl.textContent = count; + if (stripCount) stripCount.textContent = count; + } + + /** + * Render image gallery + */ + function renderGallery() { + const gallery = document.getElementById('sstvGeneralGallery'); + if (!gallery) return; + + if (images.length === 0) { + gallery.innerHTML = ` + + `; + return; + } + + gallery.innerHTML = images.map(img => ` +
+ SSTV Image +
+
${escapeHtml(img.mode || 'Unknown')}
+
${formatTimestamp(img.timestamp)}
+
+
+ `).join(''); + } + + /** + * Show full-size image in modal + */ + function showImage(url) { + let modal = document.getElementById('sstvGeneralImageModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'sstvGeneralImageModal'; + modal.className = 'sstv-general-image-modal'; + modal.innerHTML = ` + + SSTV Image + `; + modal.addEventListener('click', (e) => { + if (e.target === modal) closeImage(); + }); + document.body.appendChild(modal); + } + + modal.querySelector('img').src = url; + modal.classList.add('show'); + } + + /** + * Close image modal + */ + function closeImage() { + const modal = document.getElementById('sstvGeneralImageModal'); + if (modal) modal.classList.remove('show'); + } + + /** + * Format timestamp for display + */ + function formatTimestamp(isoString) { + if (!isoString) return '--'; + try { + const date = new Date(isoString); + return date.toLocaleString(); + } catch { + return isoString; + } + } + + /** + * Escape HTML for safe display + */ + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + /** + * Show status message + */ + function showStatusMessage(message, type) { + if (typeof showNotification === 'function') { + showNotification('SSTV', message); + } else { + console.log(`[SSTV General ${type}] ${message}`); + } + } + + // Public API + return { + init, + start, + stop, + loadImages, + showImage, + closeImage, + selectPreset + }; +})(); diff --git a/templates/index.html b/templates/index.html index 6693a39..4aa7e43 100644 --- a/templates/index.html +++ b/templates/index.html @@ -43,6 +43,8 @@ {% else %} {% endif %} + + @@ -57,6 +59,7 @@ + @@ -183,6 +186,14 @@ Meshtastic + + @@ -224,6 +235,10 @@ ISS SSTV + @@ -506,6 +521,8 @@ {% include 'partials/modes/sstv.html' %} + {% include 'partials/modes/sstv-general.html' %} + {% include 'partials/modes/listening-post.html' %} {% include 'partials/modes/tscm.html' %} @@ -516,6 +533,10 @@ {% include 'partials/modes/meshtastic.html' %} + {% include 'partials/modes/dmr.html' %} + + {% include 'partials/modes/websdr.html' %} + + +
+
Run a sweep to see device timelines
+
+ + + + + + + + @@ -1880,6 +2035,88 @@ + + + diff --git a/templates/partials/settings-modal.html b/templates/partials/settings-modal.html index 3448563..b9c951b 100644 --- a/templates/partials/settings-modal.html +++ b/templates/partials/settings-modal.html @@ -15,6 +15,8 @@ + + @@ -280,6 +282,83 @@ + +
+
+
Alert Feed
+
+
No alerts yet
+
+
+ +
+
Quick Rules
+
+ + +
+
+ Use Bluetooth device details to add specific device watchlist alerts. +
+
+
+ + +
+
+
Start Recording
+
+
+ Mode + Record live events for a mode +
+ +
+
+
+ Label + Optional note for the session +
+ +
+
+ + +
+
+ +
+
Active Sessions
+
+
No active recordings
+
+
+ +
+
Recent Recordings
+
+
No recordings yet
+
+
+
+
diff --git a/utils/alerts.py b/utils/alerts.py new file mode 100644 index 0000000..1f52eed --- /dev/null +++ b/utils/alerts.py @@ -0,0 +1,443 @@ +"""Alerting engine for cross-mode events.""" + +from __future__ import annotations + +import json +import logging +import queue +import re +import threading +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Generator + +from config import ALERT_WEBHOOK_URL, ALERT_WEBHOOK_TIMEOUT, ALERT_WEBHOOK_SECRET +from utils.database import get_db + +logger = logging.getLogger('intercept.alerts') + + +@dataclass +class AlertRule: + id: int + name: str + mode: str | None + event_type: str | None + match: dict + severity: str + enabled: bool + notify: dict + created_at: str | None = None + + +class AlertManager: + def __init__(self) -> None: + self._queue: queue.Queue = queue.Queue(maxsize=1000) + self._rules_cache: list[AlertRule] = [] + self._rules_loaded_at = 0.0 + self._cache_lock = threading.Lock() + + # ------------------------------------------------------------------ + # Rule management + # ------------------------------------------------------------------ + + def invalidate_cache(self) -> None: + with self._cache_lock: + self._rules_loaded_at = 0.0 + + def _load_rules(self) -> None: + with get_db() as conn: + cursor = conn.execute(''' + SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at + FROM alert_rules + WHERE enabled = 1 + ORDER BY id ASC + ''') + rules: list[AlertRule] = [] + for row in cursor: + match = {} + notify = {} + try: + match = json.loads(row['match']) if row['match'] else {} + except json.JSONDecodeError: + match = {} + try: + notify = json.loads(row['notify']) if row['notify'] else {} + except json.JSONDecodeError: + notify = {} + rules.append(AlertRule( + id=row['id'], + name=row['name'], + mode=row['mode'], + event_type=row['event_type'], + match=match, + severity=row['severity'] or 'medium', + enabled=bool(row['enabled']), + notify=notify, + created_at=row['created_at'], + )) + with self._cache_lock: + self._rules_cache = rules + self._rules_loaded_at = time.time() + + def _get_rules(self) -> list[AlertRule]: + with self._cache_lock: + stale = (time.time() - self._rules_loaded_at) > 10 + if stale: + self._load_rules() + with self._cache_lock: + return list(self._rules_cache) + + def list_rules(self, include_disabled: bool = False) -> list[dict]: + with get_db() as conn: + if include_disabled: + cursor = conn.execute(''' + SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at + FROM alert_rules + ORDER BY id DESC + ''') + else: + cursor = conn.execute(''' + SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at + FROM alert_rules + WHERE enabled = 1 + ORDER BY id DESC + ''') + + return [ + { + 'id': row['id'], + 'name': row['name'], + 'mode': row['mode'], + 'event_type': row['event_type'], + 'match': json.loads(row['match']) if row['match'] else {}, + 'severity': row['severity'], + 'enabled': bool(row['enabled']), + 'notify': json.loads(row['notify']) if row['notify'] else {}, + 'created_at': row['created_at'], + } + for row in cursor + ] + + def add_rule(self, rule: dict) -> int: + with get_db() as conn: + cursor = conn.execute(''' + INSERT INTO alert_rules (name, mode, event_type, match, severity, enabled, notify) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ( + rule.get('name') or 'Alert Rule', + rule.get('mode'), + rule.get('event_type'), + json.dumps(rule.get('match') or {}), + rule.get('severity') or 'medium', + 1 if rule.get('enabled', True) else 0, + json.dumps(rule.get('notify') or {}), + )) + rule_id = cursor.lastrowid + self.invalidate_cache() + return int(rule_id) + + def update_rule(self, rule_id: int, updates: dict) -> bool: + fields = [] + params = [] + for key in ('name', 'mode', 'event_type', 'severity'): + if key in updates: + fields.append(f"{key} = ?") + params.append(updates[key]) + if 'enabled' in updates: + fields.append('enabled = ?') + params.append(1 if updates['enabled'] else 0) + if 'match' in updates: + fields.append('match = ?') + params.append(json.dumps(updates['match'] or {})) + if 'notify' in updates: + fields.append('notify = ?') + params.append(json.dumps(updates['notify'] or {})) + + if not fields: + return False + + params.append(rule_id) + with get_db() as conn: + cursor = conn.execute( + f"UPDATE alert_rules SET {', '.join(fields)} WHERE id = ?", + params + ) + updated = cursor.rowcount > 0 + + if updated: + self.invalidate_cache() + return updated + + def delete_rule(self, rule_id: int) -> bool: + with get_db() as conn: + cursor = conn.execute('DELETE FROM alert_rules WHERE id = ?', (rule_id,)) + deleted = cursor.rowcount > 0 + if deleted: + self.invalidate_cache() + return deleted + + def list_events(self, limit: int = 100, mode: str | None = None, severity: str | None = None) -> list[dict]: + query = 'SELECT id, rule_id, mode, event_type, severity, title, message, payload, created_at FROM alert_events' + clauses = [] + params: list[Any] = [] + if mode: + clauses.append('mode = ?') + params.append(mode) + if severity: + clauses.append('severity = ?') + params.append(severity) + if clauses: + query += ' WHERE ' + ' AND '.join(clauses) + query += ' ORDER BY id DESC LIMIT ?' + params.append(limit) + + with get_db() as conn: + cursor = conn.execute(query, params) + events = [] + for row in cursor: + events.append({ + 'id': row['id'], + 'rule_id': row['rule_id'], + 'mode': row['mode'], + 'event_type': row['event_type'], + 'severity': row['severity'], + 'title': row['title'], + 'message': row['message'], + 'payload': json.loads(row['payload']) if row['payload'] else {}, + 'created_at': row['created_at'], + }) + return events + + # ------------------------------------------------------------------ + # Event processing + # ------------------------------------------------------------------ + + def process_event(self, mode: str, event: dict, event_type: str | None = None) -> None: + if not isinstance(event, dict): + return + + if event_type in ('keepalive', 'ping', 'status'): + return + + rules = self._get_rules() + if not rules: + return + + for rule in rules: + if rule.mode and rule.mode != mode: + continue + if rule.event_type and event_type and rule.event_type != event_type: + continue + if rule.event_type and not event_type: + continue + if not self._match_rule(rule.match, event): + continue + + title = rule.name or 'Alert' + message = self._build_message(rule, event, event_type) + payload = { + 'mode': mode, + 'event_type': event_type, + 'event': event, + 'rule': { + 'id': rule.id, + 'name': rule.name, + }, + } + event_id = self._store_event(rule.id, mode, event_type, rule.severity, title, message, payload) + alert_payload = { + 'id': event_id, + 'rule_id': rule.id, + 'mode': mode, + 'event_type': event_type, + 'severity': rule.severity, + 'title': title, + 'message': message, + 'payload': payload, + 'created_at': datetime.now(timezone.utc).isoformat(), + } + self._queue_event(alert_payload) + self._maybe_send_webhook(alert_payload, rule.notify) + + def _build_message(self, rule: AlertRule, event: dict, event_type: str | None) -> str: + if isinstance(rule.notify, dict) and rule.notify.get('message'): + return str(rule.notify.get('message')) + summary_bits = [] + if event_type: + summary_bits.append(event_type) + if 'name' in event: + summary_bits.append(str(event.get('name'))) + if 'ssid' in event: + summary_bits.append(str(event.get('ssid'))) + if 'bssid' in event: + summary_bits.append(str(event.get('bssid'))) + if 'address' in event: + summary_bits.append(str(event.get('address'))) + if 'mac' in event: + summary_bits.append(str(event.get('mac'))) + summary = ' | '.join(summary_bits) if summary_bits else 'Alert triggered' + return summary + + def _store_event( + self, + rule_id: int, + mode: str, + event_type: str | None, + severity: str, + title: str, + message: str, + payload: dict, + ) -> int: + with get_db() as conn: + cursor = conn.execute(''' + INSERT INTO alert_events (rule_id, mode, event_type, severity, title, message, payload) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ( + rule_id, + mode, + event_type, + severity, + title, + message, + json.dumps(payload), + )) + return int(cursor.lastrowid) + + def _queue_event(self, alert_payload: dict) -> None: + try: + self._queue.put_nowait(alert_payload) + except queue.Full: + try: + self._queue.get_nowait() + self._queue.put_nowait(alert_payload) + except queue.Empty: + pass + + def _maybe_send_webhook(self, payload: dict, notify: dict) -> None: + if not ALERT_WEBHOOK_URL: + return + if isinstance(notify, dict) and notify.get('webhook') is False: + return + + try: + import urllib.request + req = urllib.request.Request( + ALERT_WEBHOOK_URL, + data=json.dumps(payload).encode('utf-8'), + headers={ + 'Content-Type': 'application/json', + 'User-Agent': 'Intercept-Alert', + 'X-Alert-Token': ALERT_WEBHOOK_SECRET or '', + }, + method='POST' + ) + with urllib.request.urlopen(req, timeout=ALERT_WEBHOOK_TIMEOUT) as _: + pass + except Exception as e: + logger.debug(f"Alert webhook failed: {e}") + + # ------------------------------------------------------------------ + # Matching + # ------------------------------------------------------------------ + + def _match_rule(self, rule_match: dict, event: dict) -> bool: + if not rule_match: + return True + + for key, expected in rule_match.items(): + actual = self._extract_value(event, key) + if not self._match_value(actual, expected): + return False + return True + + def _extract_value(self, event: dict, key: str) -> Any: + if '.' not in key: + return event.get(key) + current: Any = event + for part in key.split('.'): + if isinstance(current, dict): + current = current.get(part) + else: + return None + return current + + def _match_value(self, actual: Any, expected: Any) -> bool: + if isinstance(expected, dict) and 'op' in expected: + op = expected.get('op') + value = expected.get('value') + return self._apply_op(op, actual, value) + + if isinstance(expected, list): + return actual in expected + + if isinstance(expected, str): + if actual is None: + return False + return str(actual).lower() == expected.lower() + + return actual == expected + + def _apply_op(self, op: str, actual: Any, value: Any) -> bool: + if op == 'exists': + return actual is not None + if op == 'eq': + return actual == value + if op == 'neq': + return actual != value + if op == 'gt': + return _safe_number(actual) is not None and _safe_number(actual) > _safe_number(value) + if op == 'gte': + return _safe_number(actual) is not None and _safe_number(actual) >= _safe_number(value) + if op == 'lt': + return _safe_number(actual) is not None and _safe_number(actual) < _safe_number(value) + if op == 'lte': + return _safe_number(actual) is not None and _safe_number(actual) <= _safe_number(value) + if op == 'in': + return actual in (value or []) + if op == 'contains': + if actual is None: + return False + if isinstance(actual, list): + return any(str(value).lower() in str(item).lower() for item in actual) + return str(value).lower() in str(actual).lower() + if op == 'regex': + if actual is None or value is None: + return False + try: + return re.search(str(value), str(actual)) is not None + except re.error: + return False + return False + + # ------------------------------------------------------------------ + # Streaming + # ------------------------------------------------------------------ + + def stream_events(self, timeout: float = 1.0) -> Generator[dict, None, None]: + while True: + try: + event = self._queue.get(timeout=timeout) + yield event + except queue.Empty: + yield {'type': 'keepalive'} + + +_alert_manager: AlertManager | None = None +_alert_lock = threading.Lock() + + +def get_alert_manager() -> AlertManager: + global _alert_manager + with _alert_lock: + if _alert_manager is None: + _alert_manager = AlertManager() + return _alert_manager + + +def _safe_number(value: Any) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/utils/bluetooth/models.py b/utils/bluetooth/models.py index 932342a..2810819 100644 --- a/utils/bluetooth/models.py +++ b/utils/bluetooth/models.py @@ -148,9 +148,10 @@ class BTDeviceAggregate: is_strong_stable: bool = False has_random_address: bool = False - # Baseline tracking - in_baseline: bool = False - baseline_id: Optional[int] = None + # Baseline tracking + in_baseline: bool = False + baseline_id: Optional[int] = None + seen_before: bool = False # Tracker detection fields is_tracker: bool = False @@ -274,9 +275,10 @@ class BTDeviceAggregate: }, 'heuristic_flags': self.heuristic_flags, - # Baseline - 'in_baseline': self.in_baseline, - 'baseline_id': self.baseline_id, + # Baseline + 'in_baseline': self.in_baseline, + 'baseline_id': self.baseline_id, + 'seen_before': self.seen_before, # Tracker detection 'tracker': { @@ -325,10 +327,11 @@ class BTDeviceAggregate: 'last_seen': self.last_seen.isoformat(), 'age_seconds': self.age_seconds, 'seen_count': self.seen_count, - 'heuristic_flags': self.heuristic_flags, - 'in_baseline': self.in_baseline, - # Tracker info for list view - 'is_tracker': self.is_tracker, + 'heuristic_flags': self.heuristic_flags, + 'in_baseline': self.in_baseline, + 'seen_before': self.seen_before, + # Tracker info for list view + 'is_tracker': self.is_tracker, 'tracker_type': self.tracker_type, 'tracker_name': self.tracker_name, 'tracker_confidence': self.tracker_confidence, diff --git a/utils/database.py b/utils/database.py index 6467c27..92b62cc 100644 --- a/utils/database.py +++ b/utils/database.py @@ -88,19 +88,65 @@ def init_db() -> None: ON signal_history(mode, device_id, timestamp) ''') - # Device correlation table - conn.execute(''' - CREATE TABLE IF NOT EXISTS device_correlations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - wifi_mac TEXT, - bt_mac TEXT, - confidence REAL, - first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - metadata TEXT, - UNIQUE(wifi_mac, bt_mac) - ) - ''') + # Device correlation table + conn.execute(''' + CREATE TABLE IF NOT EXISTS device_correlations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wifi_mac TEXT, + bt_mac TEXT, + confidence REAL, + first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + metadata TEXT, + UNIQUE(wifi_mac, bt_mac) + ) + ''') + + # 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 conn.execute(''' @@ -131,20 +177,29 @@ def init_db() -> None: # ===================================================================== # TSCM Baselines - Environment snapshots for comparison - conn.execute(''' - CREATE TABLE IF NOT EXISTS tscm_baselines ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - location TEXT, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - wifi_networks TEXT, - bt_devices TEXT, - rf_frequencies TEXT, - gps_coords TEXT, - is_active BOOLEAN DEFAULT 0 - ) - ''') + conn.execute(''' + CREATE TABLE IF NOT EXISTS tscm_baselines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + location TEXT, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + wifi_networks TEXT, + wifi_clients TEXT, + bt_devices TEXT, + rf_frequencies TEXT, + gps_coords TEXT, + is_active BOOLEAN DEFAULT 0 + ) + ''') + + # Ensure new columns exist for older databases + try: + columns = {row['name'] for row in conn.execute("PRAGMA table_info(tscm_baselines)")} + if 'wifi_clients' not in columns: + conn.execute('ALTER TABLE tscm_baselines ADD COLUMN wifi_clients TEXT') + except Exception as e: + logger.debug(f"Schema update skipped for tscm_baselines: {e}") # TSCM Sweeps - Individual sweep sessions conn.execute(''' @@ -685,15 +740,16 @@ def get_correlations(min_confidence: float = 0.5) -> list[dict]: # TSCM Functions # ============================================================================= -def create_tscm_baseline( - name: str, - location: str | None = None, - description: str | None = None, - wifi_networks: list | None = None, - bt_devices: list | None = None, - rf_frequencies: list | None = None, - gps_coords: dict | None = None -) -> int: +def create_tscm_baseline( + name: str, + location: str | None = None, + description: str | None = None, + wifi_networks: list | None = None, + wifi_clients: list | None = None, + bt_devices: list | None = None, + rf_frequencies: list | None = None, + gps_coords: dict | None = None +) -> int: """ Create a new TSCM baseline. @@ -701,19 +757,20 @@ def create_tscm_baseline( The ID of the created baseline """ with get_db() as conn: - cursor = conn.execute(''' - INSERT INTO tscm_baselines - (name, location, description, wifi_networks, bt_devices, rf_frequencies, gps_coords) - VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ( - name, - location, - description, - json.dumps(wifi_networks) if wifi_networks else None, - json.dumps(bt_devices) if bt_devices else None, - json.dumps(rf_frequencies) if rf_frequencies else None, - json.dumps(gps_coords) if gps_coords else None - )) + cursor = conn.execute(''' + INSERT INTO tscm_baselines + (name, location, description, wifi_networks, wifi_clients, bt_devices, rf_frequencies, gps_coords) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + name, + location, + description, + json.dumps(wifi_networks) if wifi_networks else None, + json.dumps(wifi_clients) if wifi_clients else None, + json.dumps(bt_devices) if bt_devices else None, + json.dumps(rf_frequencies) if rf_frequencies else None, + json.dumps(gps_coords) if gps_coords else None + )) return cursor.lastrowid @@ -728,18 +785,19 @@ def get_tscm_baseline(baseline_id: int) -> dict | None: if row is None: return None - return { - 'id': row['id'], - 'name': row['name'], - 'location': row['location'], - 'description': row['description'], - 'created_at': row['created_at'], - 'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [], - 'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [], - 'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [], - 'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None, - 'is_active': bool(row['is_active']) - } + return { + 'id': row['id'], + 'name': row['name'], + 'location': row['location'], + 'description': row['description'], + 'created_at': row['created_at'], + 'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [], + 'wifi_clients': json.loads(row['wifi_clients']) if row['wifi_clients'] else [], + 'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [], + 'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [], + 'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None, + 'is_active': bool(row['is_active']) + } def get_all_tscm_baselines() -> list[dict]: @@ -781,19 +839,23 @@ def set_active_tscm_baseline(baseline_id: int) -> bool: return cursor.rowcount > 0 -def update_tscm_baseline( - baseline_id: int, - wifi_networks: list | None = None, - bt_devices: list | None = None, - rf_frequencies: list | None = None -) -> bool: +def update_tscm_baseline( + baseline_id: int, + wifi_networks: list | None = None, + wifi_clients: list | None = None, + bt_devices: list | None = None, + rf_frequencies: list | None = None +) -> bool: """Update baseline device lists.""" updates = [] params = [] - if wifi_networks is not None: - updates.append('wifi_networks = ?') - params.append(json.dumps(wifi_networks)) + if wifi_networks is not None: + updates.append('wifi_networks = ?') + params.append(json.dumps(wifi_networks)) + if wifi_clients is not None: + updates.append('wifi_clients = ?') + params.append(json.dumps(wifi_clients)) if bt_devices is not None: updates.append('bt_devices = ?') params.append(json.dumps(bt_devices)) diff --git a/utils/event_pipeline.py b/utils/event_pipeline.py new file mode 100644 index 0000000..cbab8bb --- /dev/null +++ b/utils/event_pipeline.py @@ -0,0 +1,29 @@ +"""Shared event pipeline for alerts and recordings.""" + +from __future__ import annotations + +from typing import Any + +from utils.alerts import get_alert_manager +from utils.recording import get_recording_manager + +IGNORE_TYPES = {'keepalive', 'ping'} + + +def process_event(mode: str, event: dict | Any, event_type: str | None = None) -> None: + if event_type in IGNORE_TYPES: + return + if not isinstance(event, dict): + return + + try: + get_recording_manager().record_event(mode, event, event_type) + except Exception: + # Recording failures should never break streaming + pass + + try: + get_alert_manager().process_event(mode, event, event_type) + except Exception: + # Alert failures should never break streaming + pass diff --git a/utils/recording.py b/utils/recording.py new file mode 100644 index 0000000..dc8ca79 --- /dev/null +++ b/utils/recording.py @@ -0,0 +1,222 @@ +"""Session recording utilities for SSE/event streams.""" + +from __future__ import annotations + +import json +import logging +import threading +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from utils.database import get_db + +logger = logging.getLogger('intercept.recording') + +RECORDING_ROOT = Path(__file__).parent.parent / 'instance' / 'recordings' + + +@dataclass +class RecordingSession: + id: str + mode: str + label: str | None + file_path: Path + started_at: datetime + stopped_at: datetime | None = None + event_count: int = 0 + size_bytes: int = 0 + metadata: dict | None = None + + _file_handle: Any | None = None + _lock: threading.Lock = threading.Lock() + + def open(self) -> None: + self.file_path.parent.mkdir(parents=True, exist_ok=True) + self._file_handle = self.file_path.open('a', encoding='utf-8') + + def close(self) -> None: + if self._file_handle: + self._file_handle.flush() + self._file_handle.close() + self._file_handle = None + + def write_event(self, record: dict) -> None: + if not self._file_handle: + self.open() + line = json.dumps(record, ensure_ascii=True) + '\n' + with self._lock: + self._file_handle.write(line) + self._file_handle.flush() + self.event_count += 1 + self.size_bytes += len(line.encode('utf-8')) + + +class RecordingManager: + def __init__(self) -> None: + self._active_by_mode: dict[str, RecordingSession] = {} + self._active_by_id: dict[str, RecordingSession] = {} + self._lock = threading.Lock() + + def start_recording(self, mode: str, label: str | None = None, metadata: dict | None = None) -> RecordingSession: + with self._lock: + existing = self._active_by_mode.get(mode) + if existing: + return existing + + session_id = str(uuid.uuid4()) + started_at = datetime.now(timezone.utc) + filename = f"{mode}_{started_at.strftime('%Y%m%d_%H%M%S')}_{session_id}.jsonl" + file_path = RECORDING_ROOT / mode / filename + + session = RecordingSession( + id=session_id, + mode=mode, + label=label, + file_path=file_path, + started_at=started_at, + metadata=metadata or {}, + ) + session.open() + + self._active_by_mode[mode] = session + self._active_by_id[session_id] = session + + with get_db() as conn: + conn.execute(''' + INSERT INTO recording_sessions + (id, mode, label, started_at, file_path, event_count, size_bytes, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + session.id, + session.mode, + session.label, + session.started_at.isoformat(), + str(session.file_path), + session.event_count, + session.size_bytes, + json.dumps(session.metadata or {}), + )) + + return session + + def stop_recording(self, mode: str | None = None, session_id: str | None = None) -> RecordingSession | None: + with self._lock: + session = None + if session_id: + session = self._active_by_id.get(session_id) + elif mode: + session = self._active_by_mode.get(mode) + + if not session: + return None + + session.stopped_at = datetime.now(timezone.utc) + session.close() + + self._active_by_mode.pop(session.mode, None) + self._active_by_id.pop(session.id, None) + + with get_db() as conn: + conn.execute(''' + UPDATE recording_sessions + SET stopped_at = ?, event_count = ?, size_bytes = ? + WHERE id = ? + ''', ( + session.stopped_at.isoformat(), + session.event_count, + session.size_bytes, + session.id, + )) + + return session + + def record_event(self, mode: str, event: dict, event_type: str | None = None) -> None: + if event_type in ('keepalive', 'ping'): + return + session = self._active_by_mode.get(mode) + if not session: + return + record = { + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'mode': mode, + 'event_type': event_type, + 'event': event, + } + try: + session.write_event(record) + except Exception as e: + logger.debug(f"Recording write failed: {e}") + + def list_recordings(self, limit: int = 50) -> list[dict]: + with get_db() as conn: + cursor = conn.execute(''' + SELECT id, mode, label, started_at, stopped_at, file_path, event_count, size_bytes, metadata + FROM recording_sessions + ORDER BY started_at DESC + LIMIT ? + ''', (limit,)) + rows = [] + for row in cursor: + rows.append({ + 'id': row['id'], + 'mode': row['mode'], + 'label': row['label'], + 'started_at': row['started_at'], + 'stopped_at': row['stopped_at'], + 'file_path': row['file_path'], + 'event_count': row['event_count'], + 'size_bytes': row['size_bytes'], + 'metadata': json.loads(row['metadata']) if row['metadata'] else {}, + }) + return rows + + def get_recording(self, session_id: str) -> dict | None: + with get_db() as conn: + cursor = conn.execute(''' + SELECT id, mode, label, started_at, stopped_at, file_path, event_count, size_bytes, metadata + FROM recording_sessions + WHERE id = ? + ''', (session_id,)) + row = cursor.fetchone() + if not row: + return None + return { + 'id': row['id'], + 'mode': row['mode'], + 'label': row['label'], + 'started_at': row['started_at'], + 'stopped_at': row['stopped_at'], + 'file_path': row['file_path'], + 'event_count': row['event_count'], + 'size_bytes': row['size_bytes'], + 'metadata': json.loads(row['metadata']) if row['metadata'] else {}, + } + + def get_active(self) -> list[dict]: + with self._lock: + sessions = [] + for session in self._active_by_mode.values(): + sessions.append({ + 'id': session.id, + 'mode': session.mode, + 'label': session.label, + 'started_at': session.started_at.isoformat(), + 'event_count': session.event_count, + 'size_bytes': session.size_bytes, + }) + return sessions + + +_recording_manager: RecordingManager | None = None +_recording_lock = threading.Lock() + + +def get_recording_manager() -> RecordingManager: + global _recording_manager + with _recording_lock: + if _recording_manager is None: + _recording_manager = RecordingManager() + return _recording_manager diff --git a/utils/tscm/advanced.py b/utils/tscm/advanced.py index b36efd1..91d17cb 100644 --- a/utils/tscm/advanced.py +++ b/utils/tscm/advanced.py @@ -523,20 +523,22 @@ class BaselineDiff: } -def calculate_baseline_diff( - baseline: dict, - current_wifi: list[dict], - current_bt: list[dict], - current_rf: list[dict], - sweep_id: int -) -> BaselineDiff: +def calculate_baseline_diff( + baseline: dict, + current_wifi: list[dict], + current_wifi_clients: list[dict], + current_bt: list[dict], + current_rf: list[dict], + sweep_id: int +) -> BaselineDiff: """ Calculate comprehensive diff between baseline and current scan. Args: baseline: Baseline dict from database current_wifi: Current WiFi devices - current_bt: Current Bluetooth devices + current_wifi_clients: Current WiFi clients + current_bt: Current Bluetooth devices current_rf: Current RF signals sweep_id: Current sweep ID @@ -564,11 +566,16 @@ def calculate_baseline_diff( diff.is_stale = diff.baseline_age_hours > 72 # Build baseline lookup dicts - baseline_wifi = { - d.get('bssid', d.get('mac', '')).upper(): d - for d in baseline.get('wifi_networks', []) - if d.get('bssid') or d.get('mac') - } + baseline_wifi = { + d.get('bssid', d.get('mac', '')).upper(): d + for d in baseline.get('wifi_networks', []) + if d.get('bssid') or d.get('mac') + } + baseline_wifi_clients = { + d.get('mac', d.get('address', '')).upper(): d + for d in baseline.get('wifi_clients', []) + if d.get('mac') or d.get('address') + } baseline_bt = { d.get('mac', d.get('address', '')).upper(): d for d in baseline.get('bt_devices', []) @@ -580,8 +587,11 @@ def calculate_baseline_diff( if d.get('frequency') } - # Compare WiFi - _compare_wifi(diff, baseline_wifi, current_wifi) + # Compare WiFi + _compare_wifi(diff, baseline_wifi, current_wifi) + + # Compare WiFi clients + _compare_wifi_clients(diff, baseline_wifi_clients, current_wifi_clients) # Compare Bluetooth _compare_bluetooth(diff, baseline_bt, current_bt) @@ -607,7 +617,7 @@ def calculate_baseline_diff( return diff -def _compare_wifi(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: +def _compare_wifi(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: """Compare WiFi devices between baseline and current.""" current_macs = { d.get('bssid', d.get('mac', '')).upper(): d @@ -630,7 +640,48 @@ def _compare_wifi(diff: BaselineDiff, baseline: dict, current: list[dict]) -> No 'channel': device.get('channel'), 'rssi': device.get('power', device.get('signal')), } - )) + )) + + +def _compare_wifi_clients(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: + """Compare WiFi clients between baseline and current.""" + current_macs = { + d.get('mac', d.get('address', '')).upper(): d + for d in current + if d.get('mac') or d.get('address') + } + + # Find new clients + for mac, device in current_macs.items(): + if mac not in baseline: + name = device.get('vendor', 'WiFi Client') + diff.new_devices.append(DeviceChange( + identifier=mac, + protocol='wifi_client', + change_type='new', + description=f'New WiFi client: {name}', + expected=False, + details={ + 'vendor': name, + 'rssi': device.get('rssi'), + 'associated_bssid': device.get('associated_bssid'), + } + )) + + # Find missing clients + for mac, device in baseline.items(): + if mac not in current_macs: + name = device.get('vendor', 'WiFi Client') + diff.missing_devices.append(DeviceChange( + identifier=mac, + protocol='wifi_client', + change_type='missing', + description=f'Missing WiFi client: {name}', + expected=True, + details={ + 'vendor': name, + } + )) else: # Check for changes baseline_dev = baseline[mac] @@ -796,11 +847,12 @@ def _calculate_baseline_health(diff: BaselineDiff, baseline: dict) -> None: reasons.append(f"Baseline is {diff.baseline_age_hours:.0f} hours old") # Device churn penalty - total_baseline = ( - len(baseline.get('wifi_networks', [])) + - len(baseline.get('bt_devices', [])) + - len(baseline.get('rf_frequencies', [])) - ) + total_baseline = ( + len(baseline.get('wifi_networks', [])) + + len(baseline.get('wifi_clients', [])) + + len(baseline.get('bt_devices', [])) + + len(baseline.get('rf_frequencies', [])) + ) if total_baseline > 0: churn_rate = (diff.total_new + diff.total_missing) / total_baseline diff --git a/utils/tscm/baseline.py b/utils/tscm/baseline.py index 4cb0462..facbd02 100644 --- a/utils/tscm/baseline.py +++ b/utils/tscm/baseline.py @@ -26,12 +26,13 @@ class BaselineRecorder: Records and manages TSCM environment baselines. """ - def __init__(self): - self.recording = False - self.current_baseline_id: int | None = None - self.wifi_networks: dict[str, dict] = {} # BSSID -> network info - self.bt_devices: dict[str, dict] = {} # MAC -> device info - self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info + def __init__(self): + self.recording = False + self.current_baseline_id: int | None = None + self.wifi_networks: dict[str, dict] = {} # BSSID -> network info + self.wifi_clients: dict[str, dict] = {} # MAC -> client info + self.bt_devices: dict[str, dict] = {} # MAC -> device info + self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info def start_recording( self, @@ -50,10 +51,11 @@ class BaselineRecorder: Returns: Baseline ID """ - self.recording = True - self.wifi_networks = {} - self.bt_devices = {} - self.rf_frequencies = {} + self.recording = True + self.wifi_networks = {} + self.wifi_clients = {} + self.bt_devices = {} + self.rf_frequencies = {} # Create baseline in database self.current_baseline_id = create_tscm_baseline( @@ -78,24 +80,27 @@ class BaselineRecorder: self.recording = False # Convert to lists for storage - wifi_list = list(self.wifi_networks.values()) - bt_list = list(self.bt_devices.values()) - rf_list = list(self.rf_frequencies.values()) + wifi_list = list(self.wifi_networks.values()) + wifi_client_list = list(self.wifi_clients.values()) + bt_list = list(self.bt_devices.values()) + rf_list = list(self.rf_frequencies.values()) # Update database - update_tscm_baseline( - self.current_baseline_id, - wifi_networks=wifi_list, - bt_devices=bt_list, - rf_frequencies=rf_list - ) + update_tscm_baseline( + self.current_baseline_id, + wifi_networks=wifi_list, + wifi_clients=wifi_client_list, + bt_devices=bt_list, + rf_frequencies=rf_list + ) - summary = { - 'baseline_id': self.current_baseline_id, - 'wifi_count': len(wifi_list), - 'bt_count': len(bt_list), - 'rf_count': len(rf_list), - } + summary = { + 'baseline_id': self.current_baseline_id, + 'wifi_count': len(wifi_list), + 'wifi_client_count': len(wifi_client_list), + 'bt_count': len(bt_list), + 'rf_count': len(rf_list), + } logger.info( f"Baseline recording complete: {summary['wifi_count']} WiFi, " @@ -135,8 +140,8 @@ class BaselineRecorder: 'last_seen': datetime.now().isoformat(), } - def add_bt_device(self, device: dict) -> None: - """Add a Bluetooth device to the current baseline.""" + def add_bt_device(self, device: dict) -> None: + """Add a Bluetooth device to the current baseline.""" if not self.recording: return @@ -150,7 +155,7 @@ class BaselineRecorder: 'rssi': device.get('rssi', self.bt_devices[mac].get('rssi')), }) else: - self.bt_devices[mac] = { + self.bt_devices[mac] = { 'mac': mac, 'name': device.get('name', ''), 'rssi': device.get('rssi', device.get('signal')), @@ -158,10 +163,37 @@ class BaselineRecorder: 'type': device.get('type', ''), 'first_seen': datetime.now().isoformat(), 'last_seen': datetime.now().isoformat(), - } - - def add_rf_signal(self, signal: dict) -> None: - """Add an RF signal to the current baseline.""" + } + + def add_wifi_client(self, client: dict) -> None: + """Add a WiFi client to the current baseline.""" + if not self.recording: + return + + mac = client.get('mac', client.get('address', '')).upper() + if not mac: + return + + if mac in self.wifi_clients: + self.wifi_clients[mac].update({ + 'last_seen': datetime.now().isoformat(), + 'rssi': client.get('rssi', self.wifi_clients[mac].get('rssi')), + 'associated_bssid': client.get('associated_bssid', self.wifi_clients[mac].get('associated_bssid')), + }) + else: + self.wifi_clients[mac] = { + 'mac': mac, + 'vendor': client.get('vendor', ''), + 'rssi': client.get('rssi'), + 'associated_bssid': client.get('associated_bssid'), + 'probed_ssids': client.get('probed_ssids', []), + 'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))), + 'first_seen': datetime.now().isoformat(), + 'last_seen': datetime.now().isoformat(), + } + + def add_rf_signal(self, signal: dict) -> None: + """Add an RF signal to the current baseline.""" if not self.recording: return @@ -191,15 +223,16 @@ class BaselineRecorder: 'hit_count': 1, } - def get_recording_status(self) -> dict: - """Get current recording status and counts.""" - return { - 'recording': self.recording, - 'baseline_id': self.current_baseline_id, - 'wifi_count': len(self.wifi_networks), - 'bt_count': len(self.bt_devices), - 'rf_count': len(self.rf_frequencies), - } + def get_recording_status(self) -> dict: + """Get current recording status and counts.""" + return { + 'recording': self.recording, + 'baseline_id': self.current_baseline_id, + 'wifi_count': len(self.wifi_networks), + 'wifi_client_count': len(self.wifi_clients), + 'bt_count': len(self.bt_devices), + 'rf_count': len(self.rf_frequencies), + } class BaselineComparator: @@ -220,11 +253,16 @@ class BaselineComparator: for d in baseline.get('wifi_networks', []) if d.get('bssid') or d.get('mac') } - self.baseline_bt = { - d.get('mac', d.get('address', '')).upper(): d - for d in baseline.get('bt_devices', []) - if d.get('mac') or d.get('address') - } + self.baseline_bt = { + d.get('mac', d.get('address', '')).upper(): d + for d in baseline.get('bt_devices', []) + if d.get('mac') or d.get('address') + } + self.baseline_wifi_clients = { + d.get('mac', d.get('address', '')).upper(): d + for d in baseline.get('wifi_clients', []) + if d.get('mac') or d.get('address') + } self.baseline_rf = { round(d.get('frequency', 0), 1): d for d in baseline.get('rf_frequencies', []) @@ -269,8 +307,8 @@ class BaselineComparator: 'matching_count': len(matching_devices), } - def compare_bluetooth(self, current_devices: list[dict]) -> dict: - """Compare current Bluetooth devices against baseline.""" + def compare_bluetooth(self, current_devices: list[dict]) -> dict: + """Compare current Bluetooth devices against baseline.""" current_macs = { d.get('mac', d.get('address', '')).upper(): d for d in current_devices @@ -291,14 +329,45 @@ class BaselineComparator: if mac not in current_macs: missing_devices.append(device) - return { - 'new': new_devices, - 'missing': missing_devices, - 'matching': matching_devices, - 'new_count': len(new_devices), - 'missing_count': len(missing_devices), - 'matching_count': len(matching_devices), - } + return { + 'new': new_devices, + 'missing': missing_devices, + 'matching': matching_devices, + 'new_count': len(new_devices), + 'missing_count': len(missing_devices), + 'matching_count': len(matching_devices), + } + + def compare_wifi_clients(self, current_devices: list[dict]) -> dict: + """Compare current WiFi clients against baseline.""" + current_macs = { + d.get('mac', d.get('address', '')).upper(): d + for d in current_devices + if d.get('mac') or d.get('address') + } + + new_devices = [] + missing_devices = [] + matching_devices = [] + + for mac, device in current_macs.items(): + if mac not in self.baseline_wifi_clients: + new_devices.append(device) + else: + matching_devices.append(device) + + for mac, device in self.baseline_wifi_clients.items(): + if mac not in current_macs: + missing_devices.append(device) + + return { + 'new': new_devices, + 'missing': missing_devices, + 'matching': matching_devices, + 'new_count': len(new_devices), + 'missing_count': len(missing_devices), + 'matching_count': len(matching_devices), + } def compare_rf(self, current_signals: list[dict]) -> dict: """Compare current RF signals against baseline.""" @@ -331,35 +400,42 @@ class BaselineComparator: 'matching_count': len(matching_signals), } - def compare_all( - self, - wifi_devices: list[dict] | None = None, - bt_devices: list[dict] | None = None, - rf_signals: list[dict] | None = None - ) -> dict: + def compare_all( + self, + wifi_devices: list[dict] | None = None, + wifi_clients: list[dict] | None = None, + bt_devices: list[dict] | None = None, + rf_signals: list[dict] | None = None + ) -> dict: """ Compare all current data against baseline. Returns: Dict with comparison results for each category """ - results = { - 'wifi': None, - 'bluetooth': None, - 'rf': None, - 'total_new': 0, - 'total_missing': 0, - } + results = { + 'wifi': None, + 'wifi_clients': None, + 'bluetooth': None, + 'rf': None, + 'total_new': 0, + 'total_missing': 0, + } - if wifi_devices is not None: - results['wifi'] = self.compare_wifi(wifi_devices) - results['total_new'] += results['wifi']['new_count'] - results['total_missing'] += results['wifi']['missing_count'] - - if bt_devices is not None: - results['bluetooth'] = self.compare_bluetooth(bt_devices) - results['total_new'] += results['bluetooth']['new_count'] - results['total_missing'] += results['bluetooth']['missing_count'] + if wifi_devices is not None: + results['wifi'] = self.compare_wifi(wifi_devices) + results['total_new'] += results['wifi']['new_count'] + results['total_missing'] += results['wifi']['missing_count'] + + if wifi_clients is not None: + results['wifi_clients'] = self.compare_wifi_clients(wifi_clients) + results['total_new'] += results['wifi_clients']['new_count'] + results['total_missing'] += results['wifi_clients']['missing_count'] + + if bt_devices is not None: + results['bluetooth'] = self.compare_bluetooth(bt_devices) + results['total_new'] += results['bluetooth']['new_count'] + results['total_missing'] += results['bluetooth']['missing_count'] if rf_signals is not None: results['rf'] = self.compare_rf(rf_signals) @@ -369,11 +445,12 @@ class BaselineComparator: return results -def get_comparison_for_active_baseline( - wifi_devices: list[dict] | None = None, - bt_devices: list[dict] | None = None, - rf_signals: list[dict] | None = None -) -> dict | None: +def get_comparison_for_active_baseline( + wifi_devices: list[dict] | None = None, + wifi_clients: list[dict] | None = None, + bt_devices: list[dict] | None = None, + rf_signals: list[dict] | None = None +) -> dict | None: """ Convenience function to compare against the active baseline. @@ -385,4 +462,4 @@ def get_comparison_for_active_baseline( return None comparator = BaselineComparator(baseline) - return comparator.compare_all(wifi_devices, bt_devices, rf_signals) + return comparator.compare_all(wifi_devices, wifi_clients, bt_devices, rf_signals) diff --git a/utils/tscm/detector.py b/utils/tscm/detector.py index 4245706..fa94518 100644 --- a/utils/tscm/detector.py +++ b/utils/tscm/detector.py @@ -113,14 +113,18 @@ class ThreatDetector: def _load_baseline(self, baseline: dict) -> None: """Load baseline device identifiers for comparison.""" - # WiFi networks and clients - for network in baseline.get('wifi_networks', []): - if 'bssid' in network: - self.baseline_wifi_macs.add(network['bssid'].upper()) - if 'clients' in network: - for client in network['clients']: - if 'mac' in client: - self.baseline_wifi_macs.add(client['mac'].upper()) + # WiFi networks and clients + for network in baseline.get('wifi_networks', []): + if 'bssid' in network: + self.baseline_wifi_macs.add(network['bssid'].upper()) + if 'clients' in network: + for client in network['clients']: + if 'mac' in client: + self.baseline_wifi_macs.add(client['mac'].upper()) + + for client in baseline.get('wifi_clients', []): + if 'mac' in client: + self.baseline_wifi_macs.add(client['mac'].upper()) # Bluetooth devices for device in baseline.get('bt_devices', []): diff --git a/utils/wifi/scanner.py b/utils/wifi/scanner.py index d9e6032..9951107 100644 --- a/utils/wifi/scanner.py +++ b/utils/wifi/scanner.py @@ -662,12 +662,13 @@ class UnifiedWiFiScanner: # Deep Scan (airodump-ng) # ========================================================================= - def start_deep_scan( - self, - interface: Optional[str] = None, - band: str = 'all', - channel: Optional[int] = None, - ) -> bool: + def start_deep_scan( + self, + interface: Optional[str] = None, + band: str = 'all', + channel: Optional[int] = None, + channels: Optional[list[int]] = None, + ) -> bool: """ Start continuous deep scan with airodump-ng. @@ -700,11 +701,11 @@ class UnifiedWiFiScanner: # Start airodump-ng in background thread self._deep_scan_stop_event.clear() - self._deep_scan_thread = threading.Thread( - target=self._run_deep_scan, - args=(iface, band, channel), - daemon=True, - ) + self._deep_scan_thread = threading.Thread( + target=self._run_deep_scan, + args=(iface, band, channel, channels), + daemon=True, + ) self._deep_scan_thread.start() self._status = WiFiScanStatus( @@ -766,8 +767,14 @@ class UnifiedWiFiScanner: return True - def _run_deep_scan(self, interface: str, band: str, channel: Optional[int]): - """Background thread for running airodump-ng.""" + def _run_deep_scan( + self, + interface: str, + band: str, + channel: Optional[int], + channels: Optional[list[int]], + ): + """Background thread for running airodump-ng.""" from .parsers.airodump import parse_airodump_csv import tempfile @@ -779,12 +786,14 @@ class UnifiedWiFiScanner: # Build command cmd = ['airodump-ng', '-w', output_prefix, '--output-format', 'csv'] - if channel: - cmd.extend(['-c', str(channel)]) - elif band == '2.4': - cmd.extend(['--band', 'bg']) - elif band == '5': - cmd.extend(['--band', 'a']) + if channels: + cmd.extend(['-c', ','.join(str(c) for c in channels)]) + elif channel: + cmd.extend(['-c', str(channel)]) + elif band == '2.4': + cmd.extend(['--band', 'bg']) + elif band == '5': + cmd.extend(['--band', 'a']) cmd.append(interface) From 75bd3228e5ed4a8d74731525c86cf67b7fb8adcb Mon Sep 17 00:00:00 2001 From: Smittix Date: Sat, 7 Feb 2026 18:36:14 +0000 Subject: [PATCH 39/42] Improve waterfall rendering and add click-to-tune --- static/js/modes/listening-post.js | 137 ++++++++++++++++++++++++++---- 1 file changed, 119 insertions(+), 18 deletions(-) diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index bd8253c..81262be 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -3024,16 +3024,76 @@ let waterfallEndFreq = 108; let waterfallRowImage = null; let waterfallPalette = null; let lastWaterfallDraw = 0; -const WATERFALL_MIN_INTERVAL_MS = 80; +const WATERFALL_MIN_INTERVAL_MS = 50; +let waterfallInteractionBound = false; +let waterfallResizeObserver = null; + +function resizeCanvasToDisplaySize(canvas) { + if (!canvas) return false; + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return false; + const width = Math.max(1, Math.round(rect.width * dpr)); + const height = Math.max(1, Math.round(rect.height * dpr)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + return true; + } + return false; +} + +function getWaterfallRowHeight() { + const dpr = window.devicePixelRatio || 1; + return Math.max(1, Math.round(dpr)); +} function initWaterfallCanvas() { waterfallCanvas = document.getElementById('waterfallCanvas'); spectrumCanvas = document.getElementById('spectrumCanvas'); - if (waterfallCanvas) waterfallCtx = waterfallCanvas.getContext('2d'); - if (spectrumCanvas) spectrumCtx = spectrumCanvas.getContext('2d'); - if (waterfallCtx && waterfallCanvas) { - waterfallRowImage = waterfallCtx.createImageData(waterfallCanvas.width, 1); - if (!waterfallPalette) waterfallPalette = buildWaterfallPalette(); + if (waterfallCanvas) { + resizeCanvasToDisplaySize(waterfallCanvas); + waterfallCtx = waterfallCanvas.getContext('2d'); + if (waterfallCtx) { + waterfallCtx.imageSmoothingEnabled = false; + waterfallRowImage = waterfallCtx.createImageData( + waterfallCanvas.width, + getWaterfallRowHeight() + ); + } + } + if (spectrumCanvas) { + resizeCanvasToDisplaySize(spectrumCanvas); + spectrumCtx = spectrumCanvas.getContext('2d'); + if (spectrumCtx) { + spectrumCtx.imageSmoothingEnabled = false; + } + } + if (!waterfallPalette) waterfallPalette = buildWaterfallPalette(); + + if (!waterfallInteractionBound) { + bindWaterfallInteraction(); + waterfallInteractionBound = true; + } + + if (!waterfallResizeObserver && waterfallCanvas) { + const observerTarget = waterfallCanvas.parentElement; + if (observerTarget && typeof ResizeObserver !== 'undefined') { + waterfallResizeObserver = new ResizeObserver(() => { + const resizedWaterfall = resizeCanvasToDisplaySize(waterfallCanvas); + const resizedSpectrum = spectrumCanvas ? resizeCanvasToDisplaySize(spectrumCanvas) : false; + if (resizedWaterfall && waterfallCtx) { + waterfallRowImage = waterfallCtx.createImageData( + waterfallCanvas.width, + getWaterfallRowHeight() + ); + } + if (resizedWaterfall || resizedSpectrum) { + lastWaterfallDraw = 0; + } + }); + waterfallResizeObserver.observe(observerTarget); + } } } @@ -3077,9 +3137,10 @@ function drawWaterfallRow(bins) { if (!waterfallCtx || !waterfallCanvas) return; const w = waterfallCanvas.width; const h = waterfallCanvas.height; + const rowHeight = waterfallRowImage ? waterfallRowImage.height : 1; // Scroll existing content down by 1 pixel (GPU-accelerated) - waterfallCtx.drawImage(waterfallCanvas, 0, 0, w, h - 1, 0, 1, w, h - 1); + waterfallCtx.drawImage(waterfallCanvas, 0, 0, w, h - rowHeight, 0, rowHeight, w, h - rowHeight); // Find min/max for normalization let minVal = Infinity, maxVal = -Infinity; @@ -3090,21 +3151,27 @@ function drawWaterfallRow(bins) { const range = maxVal - minVal || 1; // Draw new row at top using ImageData - if (!waterfallRowImage || waterfallRowImage.width !== w) { - waterfallRowImage = waterfallCtx.createImageData(w, 1); + 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 idx = Math.min(binCount - 1, Math.floor((x / w) * binCount)); - const normalized = (bins[idx] - minVal) / range; + const pos = (x / (w - 1)) * (binCount - 1); + const i0 = Math.floor(pos); + const i1 = Math.min(binCount - 1, i0 + 1); + const t = pos - i0; + const val = (bins[i0] * (1 - t)) + (bins[i1] * t); + const normalized = (val - minVal) / range; const color = palette[Math.max(0, Math.min(255, Math.floor(normalized * 255)))] || [0, 0, 0]; - const offset = x * 4; - rowData[offset] = color[0]; - rowData[offset + 1] = color[1]; - rowData[offset + 2] = color[2]; - rowData[offset + 3] = 255; + 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); } @@ -3132,8 +3199,9 @@ function drawSpectrumLine(bins, startFreq, endFreq) { } // Frequency labels + const dpr = window.devicePixelRatio || 1; spectrumCtx.fillStyle = 'rgba(0, 200, 255, 0.5)'; - spectrumCtx.font = '9px monospace'; + spectrumCtx.font = `${9 * dpr}px monospace`; const freqRange = endFreq - startFreq; for (let i = 0; i <= 4; i++) { const freq = startFreq + (freqRange / 4) * i; @@ -3180,7 +3248,8 @@ function startWaterfall() { const binSize = parseInt(document.getElementById('waterfallBinSize')?.value || 10000); const gain = parseInt(document.getElementById('waterfallGain')?.value || 40); const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; - const maxBins = document.getElementById('waterfallCanvas')?.width || 800; + initWaterfallCanvas(); + const maxBins = Math.min(4096, Math.max(128, waterfallCanvas ? waterfallCanvas.width : 800)); if (startFreq >= endFreq) { if (typeof showNotification === 'function') showNotification('Error', 'End frequency must be greater than start'); @@ -3189,6 +3258,10 @@ function startWaterfall() { waterfallStartFreq = startFreq; waterfallEndFreq = endFreq; + const rangeLabel = document.getElementById('waterfallFreqRange'); + if (rangeLabel) { + rangeLabel.textContent = `${startFreq.toFixed(1)} - ${endFreq.toFixed(1)} MHz`; + } fetch('/listening/waterfall/start', { method: 'POST', @@ -3239,6 +3312,12 @@ function connectWaterfallSSE() { waterfallEventSource.onmessage = function(event) { const msg = JSON.parse(event.data); if (msg.type === 'waterfall_sweep') { + 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`; + } const now = Date.now(); if (now - lastWaterfallDraw < WATERFALL_MIN_INTERVAL_MS) return; lastWaterfallDraw = now; @@ -3254,6 +3333,28 @@ function connectWaterfallSSE() { }; } +function bindWaterfallInteraction() { + const handler = (event) => { + 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); + if (typeof tuneToFrequency === 'function') { + tuneToFrequency(freq, typeof currentModulation !== 'undefined' ? currentModulation : undefined); + } + }; + + if (waterfallCanvas) { + waterfallCanvas.style.cursor = 'crosshair'; + waterfallCanvas.addEventListener('click', handler); + } + if (spectrumCanvas) { + spectrumCanvas.style.cursor = 'crosshair'; + spectrumCanvas.addEventListener('click', handler); + } +} + window.stopDirectListen = stopDirectListen; window.toggleScanner = toggleScanner; From 51ea558e19eb905d380be4b6d285cea5a71e6d49 Mon Sep 17 00:00:00 2001 From: Smittix Date: Sat, 7 Feb 2026 18:49:48 +0000 Subject: [PATCH 40/42] Allow listening with waterfall and speed up updates --- routes/listening_post.py | 357 ++++++++++++++++++------------ static/js/modes/listening-post.js | 119 +++++++++- 2 files changed, 328 insertions(+), 148 deletions(-) diff --git a/routes/listening_post.py b/routes/listening_post.py index 5912a8c..658acdb 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -1239,10 +1239,10 @@ def get_presets() -> Response: # MANUAL AUDIO ENDPOINTS (for direct listening) # ============================================ -@listening_post_bp.route('/audio/start', methods=['POST']) -def start_audio() -> Response: - """Start audio at specific frequency (manual mode).""" - global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread +@listening_post_bp.route('/audio/start', methods=['POST']) +def start_audio() -> Response: + """Start audio at specific frequency (manual mode).""" + global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread # Stop scanner if running if scanner_running: @@ -1271,7 +1271,7 @@ def start_audio() -> Response: pass time.sleep(0.5) - data = request.json or {} + data = request.json or {} try: frequency = float(data.get('frequency', 0)) @@ -1286,11 +1286,11 @@ def start_audio() -> Response: 'message': f'Invalid parameter: {e}' }), 400 - if frequency <= 0: - return jsonify({ - 'status': 'error', - 'message': 'frequency is required' - }), 400 + if frequency <= 0: + return jsonify({ + 'status': 'error', + 'message': 'frequency is required' + }), 400 valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay'] if sdr_type not in valid_sdr_types: @@ -1299,14 +1299,19 @@ def start_audio() -> Response: 'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}' }), 400 - # Update config for audio - scanner_config['squelch'] = squelch - scanner_config['gain'] = gain - scanner_config['device'] = device - scanner_config['sdr_type'] = sdr_type + # Update config for audio + scanner_config['squelch'] = squelch + scanner_config['gain'] = gain + scanner_config['device'] = device + scanner_config['sdr_type'] = sdr_type + + # Stop waterfall if it's using the same SDR + if waterfall_running and waterfall_active_device == device: + _stop_waterfall_internal() + time.sleep(0.2) - # Claim device for listening audio - if listening_active_device is None or listening_active_device != device: + # Claim device for listening audio + if listening_active_device is None or listening_active_device != device: if listening_active_device is not None: app_module.release_sdr_device(listening_active_device) error = app_module.claim_sdr_device(device, 'listening') @@ -1527,125 +1532,196 @@ waterfall_config = { 'gain': 40, 'device': 0, 'max_bins': 1024, + 'interval': 0.4, } -def _waterfall_loop(): - """Continuous rtl_power sweep loop emitting waterfall data.""" - global waterfall_running, waterfall_process - - rtl_power_path = find_rtl_power() - if not rtl_power_path: - logger.error("rtl_power not found for waterfall") - waterfall_running = False - return - - try: - while waterfall_running: - start_hz = int(waterfall_config['start_freq'] * 1e6) - end_hz = int(waterfall_config['end_freq'] * 1e6) - bin_hz = int(waterfall_config['bin_size']) - gain = waterfall_config['gain'] - device = waterfall_config['device'] - - cmd = [ - rtl_power_path, - '-f', f'{start_hz}:{end_hz}:{bin_hz}', - '-i', '0.5', - '-1', - '-g', str(gain), - '-d', str(device), - ] - - try: - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) - waterfall_process = proc - stdout, _ = proc.communicate(timeout=15) - except subprocess.TimeoutExpired: - proc.kill() - stdout = b'' - finally: - waterfall_process = None - - if not waterfall_running: - break - - if not stdout: - time.sleep(0.2) - continue - - # Parse rtl_power CSV output - all_bins = [] - sweep_start_hz = start_hz - sweep_end_hz = end_hz - - for line in stdout.decode(errors='ignore').splitlines(): - if not line or line.startswith('#'): - continue - parts = [p.strip() for p in line.split(',')] - start_idx = None - for i, tok in enumerate(parts): - try: - val = float(tok) - except ValueError: - continue - if val > 1e5: - start_idx = i - break - if start_idx is None or len(parts) < start_idx + 4: - continue - try: - seg_start = float(parts[start_idx]) - seg_end = float(parts[start_idx + 1]) - seg_bin = float(parts[start_idx + 2]) - raw_values = [] - for v in parts[start_idx + 3:]: - try: - raw_values.append(float(v)) - except ValueError: - continue - if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]): - raw_values = raw_values[1:] - all_bins.extend(raw_values) - sweep_start_hz = min(sweep_start_hz, seg_start) - sweep_end_hz = max(sweep_end_hz, seg_end) - except ValueError: - continue - - if all_bins: +def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | None, list[float]]: + """Parse a single rtl_power CSV line into bins.""" + if not line or line.startswith('#'): + return None, None, None, [] + + parts = [p.strip() for p in line.split(',')] + if len(parts) < 6: + return None, None, None, [] + + # Timestamp in first two fields (YYYY-MM-DD, HH:MM:SS) + timestamp = f"{parts[0]} {parts[1]}" if len(parts) >= 2 else parts[0] + + start_idx = None + for i, tok in enumerate(parts): + try: + val = float(tok) + except ValueError: + continue + if val > 1e5: + start_idx = i + break + if start_idx is None or len(parts) < start_idx + 4: + return timestamp, None, None, [] + + try: + seg_start = float(parts[start_idx]) + seg_end = float(parts[start_idx + 1]) + raw_values = [] + for v in parts[start_idx + 3:]: + try: + raw_values.append(float(v)) + except ValueError: + continue + if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]): + raw_values = raw_values[1:] + return timestamp, seg_start, seg_end, raw_values + except ValueError: + return timestamp, None, None, [] + + +def _waterfall_loop(): + """Continuous rtl_power sweep loop emitting waterfall data.""" + global waterfall_running, waterfall_process + + rtl_power_path = find_rtl_power() + if not rtl_power_path: + logger.error("rtl_power not found for waterfall") + waterfall_running = False + return + + start_hz = int(waterfall_config['start_freq'] * 1e6) + end_hz = int(waterfall_config['end_freq'] * 1e6) + bin_hz = int(waterfall_config['bin_size']) + gain = waterfall_config['gain'] + device = waterfall_config['device'] + interval = float(waterfall_config.get('interval', 0.4)) + + cmd = [ + rtl_power_path, + '-f', f'{start_hz}:{end_hz}:{bin_hz}', + '-i', str(interval), + '-g', str(gain), + '-d', str(device), + ] + + try: + waterfall_process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=1, + text=True, + ) + + current_ts = None + all_bins: list[float] = [] + sweep_start_hz = start_hz + sweep_end_hz = end_hz + + if not waterfall_process.stdout: + return + + for line in waterfall_process.stdout: + if not waterfall_running: + break + + ts, seg_start, seg_end, bins = _parse_rtl_power_line(line) + if ts is None or not bins: + continue + + if current_ts is None: + current_ts = ts + + if ts != current_ts and all_bins: max_bins = int(waterfall_config.get('max_bins') or 0) - if max_bins > 0 and len(all_bins) > max_bins: - all_bins = _downsample_bins(all_bins, max_bins) + bins_to_send = all_bins + if max_bins > 0 and len(bins_to_send) > max_bins: + bins_to_send = _downsample_bins(bins_to_send, max_bins) msg = { 'type': 'waterfall_sweep', 'start_freq': sweep_start_hz / 1e6, 'end_freq': sweep_end_hz / 1e6, - 'bins': all_bins, - 'timestamp': datetime.now().isoformat(), - } - try: - waterfall_queue.put_nowait(msg) - except queue.Full: - try: - waterfall_queue.get_nowait() - except queue.Empty: - pass - try: - waterfall_queue.put_nowait(msg) - except queue.Full: - pass - - time.sleep(0.1) - - except Exception as e: - logger.error(f"Waterfall loop error: {e}") - finally: - waterfall_running = False - logger.info("Waterfall loop stopped") + 'bins': bins_to_send, + 'timestamp': datetime.now().isoformat(), + } + try: + waterfall_queue.put_nowait(msg) + except queue.Full: + try: + waterfall_queue.get_nowait() + except queue.Empty: + pass + try: + waterfall_queue.put_nowait(msg) + except queue.Full: + pass + + all_bins = [] + sweep_start_hz = start_hz + sweep_end_hz = end_hz + current_ts = ts + + all_bins.extend(bins) + if seg_start is not None: + sweep_start_hz = min(sweep_start_hz, seg_start) + if seg_end is not None: + sweep_end_hz = max(sweep_end_hz, seg_end) + + # Flush any remaining bins + if all_bins and waterfall_running: + max_bins = int(waterfall_config.get('max_bins') or 0) + bins_to_send = all_bins + if max_bins > 0 and len(bins_to_send) > max_bins: + bins_to_send = _downsample_bins(bins_to_send, max_bins) + msg = { + 'type': 'waterfall_sweep', + 'start_freq': sweep_start_hz / 1e6, + 'end_freq': sweep_end_hz / 1e6, + 'bins': bins_to_send, + 'timestamp': datetime.now().isoformat(), + } + try: + waterfall_queue.put_nowait(msg) + except queue.Full: + pass + + except Exception as e: + logger.error(f"Waterfall loop error: {e}") + finally: + waterfall_running = False + if waterfall_process and waterfall_process.poll() is None: + try: + waterfall_process.terminate() + waterfall_process.wait(timeout=1) + except Exception: + try: + waterfall_process.kill() + except Exception: + pass + waterfall_process = None + logger.info("Waterfall loop stopped") + + +def _stop_waterfall_internal() -> None: + """Stop the waterfall display and release resources.""" + global waterfall_running, waterfall_process, waterfall_active_device + + waterfall_running = False + if waterfall_process and waterfall_process.poll() is None: + try: + waterfall_process.terminate() + waterfall_process.wait(timeout=1) + except Exception: + try: + waterfall_process.kill() + except Exception: + pass + waterfall_process = None + + if waterfall_active_device is not None: + app_module.release_sdr_device(waterfall_active_device) + waterfall_active_device = None @listening_post_bp.route('/waterfall/start', methods=['POST']) -def start_waterfall() -> Response: +def start_waterfall() -> Response: """Start the waterfall/spectrogram display.""" global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device @@ -1664,6 +1740,11 @@ def start_waterfall() -> Response: waterfall_config['bin_size'] = int(data.get('bin_size', 10000)) waterfall_config['gain'] = int(data.get('gain', 40)) waterfall_config['device'] = int(data.get('device', 0)) + if data.get('interval') is not None: + interval = float(data.get('interval', waterfall_config['interval'])) + if interval < 0.1 or interval > 5: + return jsonify({'status': 'error', 'message': 'interval must be between 0.1 and 5 seconds'}), 400 + waterfall_config['interval'] = interval if data.get('max_bins') is not None: max_bins = int(data.get('max_bins', waterfall_config['max_bins'])) if max_bins < 64 or max_bins > 4096: @@ -1696,27 +1777,11 @@ def start_waterfall() -> Response: @listening_post_bp.route('/waterfall/stop', methods=['POST']) -def stop_waterfall() -> Response: - """Stop the waterfall display.""" - global waterfall_running, waterfall_process, waterfall_active_device - - waterfall_running = False - if waterfall_process and waterfall_process.poll() is None: - try: - waterfall_process.terminate() - waterfall_process.wait(timeout=1) - except Exception: - try: - waterfall_process.kill() - except Exception: - pass - waterfall_process = None - - if waterfall_active_device is not None: - app_module.release_sdr_device(waterfall_active_device) - waterfall_active_device = None - - return jsonify({'status': 'stopped'}) +def stop_waterfall() -> Response: + """Stop the waterfall display.""" + _stop_waterfall_internal() + + return jsonify({'status': 'stopped'}) @listening_post_bp.route('/waterfall/stream') diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index 81262be..7f28b64 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -2248,6 +2248,11 @@ async function _startDirectListenInternal() { await stopScanner(); } + if (isWaterfallRunning && waterfallMode === 'rf') { + resumeRfWaterfallAfterListening = true; + stopWaterfall(); + } + const freqInput = document.getElementById('radioScanStart'); const freq = freqInput ? parseFloat(freqInput.value) : 118.0; const squelchValue = parseInt(document.getElementById('radioSquelchValue')?.textContent); @@ -2306,6 +2311,10 @@ async function _startDirectListenInternal() { addScannerLogEntry('Failed: ' + (result.message || 'Unknown error'), '', 'error'); isDirectListening = false; updateDirectListenUI(false); + if (resumeRfWaterfallAfterListening) { + resumeRfWaterfallAfterListening = false; + setTimeout(() => startWaterfall(), 200); + } return; } @@ -2352,6 +2361,15 @@ async function _startDirectListenInternal() { initAudioVisualizer(); isDirectListening = true; + + if (resumeRfWaterfallAfterListening) { + isWaterfallRunning = true; + const waterfallPanel = document.getElementById('waterfallPanel'); + if (waterfallPanel) waterfallPanel.style.display = 'block'; + document.getElementById('startWaterfallBtn').style.display = 'none'; + document.getElementById('stopWaterfallBtn').style.display = 'block'; + startAudioWaterfall(); + } updateDirectListenUI(true, freq); addScannerLogEntry(`${freq.toFixed(3)} MHz (${currentModulation.toUpperCase()})`, '', 'signal'); @@ -2360,6 +2378,10 @@ async function _startDirectListenInternal() { addScannerLogEntry('Error: ' + e.message, '', 'error'); isDirectListening = false; updateDirectListenUI(false); + if (resumeRfWaterfallAfterListening) { + resumeRfWaterfallAfterListening = false; + setTimeout(() => startWaterfall(), 200); + } } finally { isRestarting = false; } @@ -2556,6 +2578,20 @@ function stopDirectListen() { currentSignalLevel = 0; updateDirectListenUI(false); addScannerLogEntry('Listening stopped'); + + if (waterfallMode === 'audio') { + stopAudioWaterfall(); + } + + if (resumeRfWaterfallAfterListening) { + resumeRfWaterfallAfterListening = false; + isWaterfallRunning = false; + setTimeout(() => startWaterfall(), 200); + } else if (waterfallMode === 'audio' && isWaterfallRunning) { + isWaterfallRunning = false; + document.getElementById('startWaterfallBtn').style.display = 'block'; + document.getElementById('stopWaterfallBtn').style.display = 'none'; + } } /** @@ -3027,6 +3063,10 @@ let lastWaterfallDraw = 0; const WATERFALL_MIN_INTERVAL_MS = 50; let waterfallInteractionBound = false; let waterfallResizeObserver = null; +let waterfallMode = 'rf'; +let audioWaterfallAnimId = null; +let lastAudioWaterfallDraw = 0; +let resumeRfWaterfallAfterListening = false; function resizeCanvasToDisplaySize(canvas) { if (!canvas) return false; @@ -3097,6 +3137,57 @@ function initWaterfallCanvas() { } } +function setWaterfallMode(mode) { + waterfallMode = mode; + const header = document.getElementById('waterfallFreqRange'); + if (!header) return; + if (mode === 'audio') { + header.textContent = 'Audio Spectrum (0 - 22 kHz)'; + } +} + +function startAudioWaterfall() { + if (audioWaterfallAnimId) return; + if (!visualizerAnalyser) { + initAudioVisualizer(); + } + if (!visualizerAnalyser) return; + + setWaterfallMode('audio'); + initWaterfallCanvas(); + + const sampleRate = visualizerContext ? visualizerContext.sampleRate : 44100; + const maxFreqKhz = (sampleRate / 2) / 1000; + const dataArray = new Uint8Array(visualizerAnalyser.frequencyBinCount); + + const drawFrame = (ts) => { + if (!isDirectListening || waterfallMode !== 'audio') { + stopAudioWaterfall(); + return; + } + if (ts - lastAudioWaterfallDraw >= WATERFALL_MIN_INTERVAL_MS) { + lastAudioWaterfallDraw = ts; + visualizerAnalyser.getByteFrequencyData(dataArray); + const bins = Array.from(dataArray, v => v); + drawWaterfallRow(bins); + drawSpectrumLine(bins, 0, maxFreqKhz, 'kHz'); + } + audioWaterfallAnimId = requestAnimationFrame(drawFrame); + }; + + audioWaterfallAnimId = requestAnimationFrame(drawFrame); +} + +function stopAudioWaterfall() { + if (audioWaterfallAnimId) { + cancelAnimationFrame(audioWaterfallAnimId); + audioWaterfallAnimId = null; + } + if (waterfallMode === 'audio') { + waterfallMode = 'rf'; + } +} + function dBmToRgb(normalized) { // Viridis-inspired: dark blue -> cyan -> green -> yellow const n = Math.max(0, Math.min(1, normalized)); @@ -3176,7 +3267,7 @@ function drawWaterfallRow(bins) { waterfallCtx.putImageData(waterfallRowImage, 0, 0); } -function drawSpectrumLine(bins, startFreq, endFreq) { +function drawSpectrumLine(bins, startFreq, endFreq, labelUnit) { if (!spectrumCtx || !spectrumCanvas) return; const w = spectrumCanvas.width; const h = spectrumCanvas.height; @@ -3206,7 +3297,8 @@ function drawSpectrumLine(bins, startFreq, endFreq) { 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); + const label = labelUnit === 'kHz' ? freq.toFixed(0) : freq.toFixed(1); + spectrumCtx.fillText(label, x + 2, h - 2); } if (bins.length === 0) return; @@ -3263,6 +3355,16 @@ function startWaterfall() { rangeLabel.textContent = `${startFreq.toFixed(1)} - ${endFreq.toFixed(1)} MHz`; } + if (isDirectListening) { + isWaterfallRunning = true; + const waterfallPanel = document.getElementById('waterfallPanel'); + if (waterfallPanel) waterfallPanel.style.display = 'block'; + document.getElementById('startWaterfallBtn').style.display = 'none'; + document.getElementById('stopWaterfallBtn').style.display = 'block'; + startAudioWaterfall(); + return; + } + fetch('/listening/waterfall/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -3273,6 +3375,7 @@ function startWaterfall() { gain: gain, device: device, max_bins: maxBins, + interval: 0.4, }) }) .then(r => r.json()) @@ -3294,6 +3397,14 @@ function startWaterfall() { } function stopWaterfall() { + if (waterfallMode === 'audio') { + stopAudioWaterfall(); + isWaterfallRunning = false; + document.getElementById('startWaterfallBtn').style.display = 'block'; + document.getElementById('stopWaterfallBtn').style.display = 'none'; + return; + } + fetch('/listening/waterfall/stop', { method: 'POST' }) .then(r => r.json()) .then(() => { @@ -3308,6 +3419,7 @@ function stopWaterfall() { function connectWaterfallSSE() { if (waterfallEventSource) waterfallEventSource.close(); waterfallEventSource = new EventSource('/listening/waterfall/stream'); + waterfallMode = 'rf'; waterfallEventSource.onmessage = function(event) { const msg = JSON.parse(event.data); @@ -3335,6 +3447,9 @@ function connectWaterfallSSE() { function bindWaterfallInteraction() { const handler = (event) => { + if (waterfallMode === 'audio') { + return; + } const canvas = event.currentTarget; const rect = canvas.getBoundingClientRect(); const x = event.clientX - rect.left; From 7e42e004499ba9bbc54d1e4f5416d8abae165d02 Mon Sep 17 00:00:00 2001 From: Smittix Date: Sat, 7 Feb 2026 19:06:06 +0000 Subject: [PATCH 41/42] Fix waterfall stop before direct listen --- static/js/modes/listening-post.js | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index 7f28b64..8a676c6 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -2248,10 +2248,10 @@ async function _startDirectListenInternal() { await stopScanner(); } - if (isWaterfallRunning && waterfallMode === 'rf') { - resumeRfWaterfallAfterListening = true; - stopWaterfall(); - } + if (isWaterfallRunning && waterfallMode === 'rf') { + resumeRfWaterfallAfterListening = true; + await stopWaterfall(); + } const freqInput = document.getElementById('radioScanStart'); const freq = freqInput ? parseFloat(freqInput.value) : 118.0; @@ -3365,6 +3365,12 @@ function startWaterfall() { return; } + setWaterfallMode('rf'); + const spanMhz = Math.max(0.1, waterfallEndFreq - waterfallStartFreq); + const segments = Math.max(1, Math.ceil(spanMhz / 2.4)); + const targetSweepSeconds = 0.8; + const interval = Math.max(0.05, Math.min(0.3, targetSweepSeconds / segments)); + fetch('/listening/waterfall/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -3375,7 +3381,7 @@ function startWaterfall() { gain: gain, device: device, max_bins: maxBins, - interval: 0.4, + interval: interval, }) }) .then(r => r.json()) @@ -3396,7 +3402,7 @@ function startWaterfall() { .catch(err => console.error('[WATERFALL] Start error:', err)); } -function stopWaterfall() { +async function stopWaterfall() { if (waterfallMode === 'audio') { stopAudioWaterfall(); isWaterfallRunning = false; @@ -3405,15 +3411,15 @@ function stopWaterfall() { return; } - fetch('/listening/waterfall/stop', { method: 'POST' }) - .then(r => r.json()) - .then(() => { + try { + await fetch('/listening/waterfall/stop', { method: 'POST' }); isWaterfallRunning = false; if (waterfallEventSource) { waterfallEventSource.close(); waterfallEventSource = null; } document.getElementById('startWaterfallBtn').style.display = 'block'; document.getElementById('stopWaterfallBtn').style.display = 'none'; - }) - .catch(err => console.error('[WATERFALL] Stop error:', err)); + } catch (err) { + console.error('[WATERFALL] Stop error:', err); + } } function connectWaterfallSSE() { From 3ab1501a90ec4f31809537d62177e41e9dd6e9b4 Mon Sep 17 00:00:00 2001 From: Smittix Date: Sat, 7 Feb 2026 19:08:28 +0000 Subject: [PATCH 42/42] Clamp waterfall interval to server minimum --- static/js/modes/listening-post.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index 8a676c6..afcea11 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -3369,7 +3369,7 @@ function startWaterfall() { const spanMhz = Math.max(0.1, waterfallEndFreq - waterfallStartFreq); const segments = Math.max(1, Math.ceil(spanMhz / 2.4)); const targetSweepSeconds = 0.8; - const interval = Math.max(0.05, Math.min(0.3, targetSweepSeconds / segments)); + const interval = Math.max(0.1, Math.min(0.3, targetSweepSeconds / segments)); fetch('/listening/waterfall/start', { method: 'POST',