From 4c67307951e191040176247cf3153498c37edff8 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 6 Feb 2026 15:36:41 +0000 Subject: [PATCH] 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 @@ + + +