diff --git a/routes/__init__.py b/routes/__init__.py index ce68b5d..8436739 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -24,6 +24,8 @@ def register_blueprints(app): from .spy_stations import spy_stations_bp from .controller import controller_bp from .offline import offline_bp + from .updater import updater_bp + from .sstv import sstv_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -47,6 +49,8 @@ def register_blueprints(app): app.register_blueprint(spy_stations_bp) app.register_blueprint(controller_bp) # Remote agent controller app.register_blueprint(offline_bp) # Offline mode settings + app.register_blueprint(updater_bp) # GitHub update checking + app.register_blueprint(sstv_bp) # ISS SSTV decoder # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/sstv.py b/routes/sstv.py new file mode 100644 index 0000000..9012b40 --- /dev/null +++ b/routes/sstv.py @@ -0,0 +1,354 @@ +"""ISS SSTV (Slow-Scan Television) decoder routes. + +Provides endpoints for decoding SSTV images from the International Space Station. +ISS SSTV events occur during special commemorations and typically transmit on 145.800 MHz FM. +""" + +from __future__ import annotations + +import queue +import time +from pathlib import Path +from typing import Generator + +from flask import Blueprint, jsonify, request, Response, send_file + +from utils.logging import get_logger +from utils.sse import format_sse +from utils.sstv import ( + get_sstv_decoder, + is_sstv_available, + ISS_SSTV_FREQ, + DecodeProgress, +) + +logger = get_logger('intercept.sstv') + +sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv') + +# Queue for SSE progress streaming +_sstv_queue: queue.Queue = queue.Queue(maxsize=100) + + +def _progress_callback(progress: DecodeProgress) -> None: + """Callback to queue progress updates for SSE stream.""" + try: + _sstv_queue.put_nowait(progress.to_dict()) + except queue.Full: + try: + _sstv_queue.get_nowait() + _sstv_queue.put_nowait(progress.to_dict()) + except queue.Empty: + pass + + +@sstv_bp.route('/status') +def get_status(): + """ + Get SSTV decoder status. + + Returns: + JSON with decoder availability and current status. + """ + available = is_sstv_available() + decoder = get_sstv_decoder() + + return jsonify({ + 'available': available, + 'decoder': decoder.decoder_available, + 'running': decoder.is_running, + 'iss_frequency': ISS_SSTV_FREQ, + 'image_count': len(decoder.get_images()), + }) + + +@sstv_bp.route('/start', methods=['POST']) +def start_decoder(): + """ + Start SSTV decoder. + + JSON body (optional): + { + "frequency": 145.800, // Frequency in MHz (default: ISS 145.800) + "device": 0 // RTL-SDR device index + } + + Returns: + JSON with start status. + """ + if not is_sstv_available(): + return jsonify({ + 'status': 'error', + 'message': 'SSTV decoder not available. Install slowrx: apt install slowrx' + }), 400 + + decoder = get_sstv_decoder() + + if decoder.is_running: + return jsonify({ + 'status': 'already_running', + 'frequency': ISS_SSTV_FREQ + }) + + # Clear queue + while not _sstv_queue.empty(): + try: + _sstv_queue.get_nowait() + except queue.Empty: + break + + # Get parameters + data = request.get_json(silent=True) or {} + frequency = data.get('frequency', ISS_SSTV_FREQ) + device_index = data.get('device', 0) + + # Validate frequency + try: + frequency = float(frequency) + if not (100 <= frequency <= 500): # VHF range + return jsonify({ + 'status': 'error', + 'message': 'Frequency must be between 100-500 MHz' + }), 400 + except (TypeError, ValueError): + return jsonify({ + 'status': 'error', + 'message': 'Invalid frequency' + }), 400 + + # Set callback and start + decoder.set_callback(_progress_callback) + success = decoder.start(frequency=frequency, device_index=device_index) + + if success: + return jsonify({ + 'status': 'started', + 'frequency': frequency, + 'device': device_index + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'Failed to start decoder' + }), 500 + + +@sstv_bp.route('/stop', methods=['POST']) +def stop_decoder(): + """ + Stop SSTV decoder. + + Returns: + JSON confirmation. + """ + decoder = get_sstv_decoder() + decoder.stop() + return jsonify({'status': 'stopped'}) + + +@sstv_bp.route('/images') +def list_images(): + """ + Get list of decoded SSTV images. + + Query parameters: + limit: Maximum number of images to return (default: all) + + Returns: + JSON with list of decoded images. + """ + decoder = get_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_bp.route('/images/') +def get_image(filename: str): + """ + Get a decoded SSTV image file. + + Args: + filename: Image filename + + Returns: + Image file or 404. + """ + decoder = get_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 + + # Find image in decoder's output directory + 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_bp.route('/stream') +def stream_progress(): + """ + SSE stream of SSTV decode progress. + + Provides real-time Server-Sent Events stream of decode progress. + + Event format: + data: {"type": "sstv_progress", "status": "decoding", "mode": "PD120", ...} + + Returns: + SSE stream (text/event-stream) + """ + def generate() -> Generator[str, None, None]: + last_keepalive = time.time() + keepalive_interval = 30.0 + + while True: + try: + progress = _sstv_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_bp.route('/iss-schedule') +def iss_schedule(): + """ + Get ISS pass schedule for SSTV reception. + + Uses the satellite prediction endpoint to find upcoming ISS passes. + + Query parameters: + latitude: Observer latitude (required) + longitude: Observer longitude (required) + hours: Hours to look ahead (default: 48) + + Returns: + JSON with ISS pass schedule. + """ + lat = request.args.get('latitude', type=float) + lon = request.args.get('longitude', type=float) + hours = request.args.get('hours', 48, type=int) + + if lat is None or lon is None: + return jsonify({ + 'status': 'error', + 'message': 'latitude and longitude parameters required' + }), 400 + + # Use satellite route to get ISS passes + try: + from flask import current_app + import requests + + # Call satellite predict endpoint + with current_app.test_client() as client: + response = client.post('/satellite/predict', json={ + 'latitude': lat, + 'longitude': lon, + 'hours': hours, + 'satellites': ['ISS'], + 'minEl': 10 + }) + data = response.get_json() + + if data.get('status') == 'success': + passes = data.get('passes', []) + return jsonify({ + 'status': 'ok', + 'passes': passes, + 'count': len(passes), + 'sstv_frequency': ISS_SSTV_FREQ, + 'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.' + }) + else: + return jsonify({ + 'status': 'error', + 'message': data.get('message', 'Failed to get ISS passes') + }), 500 + + except Exception as e: + logger.error(f"Error getting ISS schedule: {e}") + return jsonify({ + 'status': 'error', + 'message': str(e) + }), 500 + + +@sstv_bp.route('/decode-file', methods=['POST']) +def decode_file(): + """ + Decode SSTV from an uploaded audio file. + + Expects multipart/form-data with 'audio' file field. + + Returns: + JSON with decoded images. + """ + 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 + + # Save to temp file + import tempfile + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp: + audio_file.save(tmp.name) + tmp_path = tmp.name + + try: + decoder = get_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: + # Clean up temp file + try: + Path(tmp_path).unlink() + except Exception: + pass diff --git a/static/css/modes/sstv.css b/static/css/modes/sstv.css new file mode 100644 index 0000000..f433d6b --- /dev/null +++ b/static/css/modes/sstv.css @@ -0,0 +1,531 @@ +/** + * SSTV Mode Styles + * ISS Slow-Scan Television decoder interface + */ + +/* ============================================ + MODE VISIBILITY + ============================================ */ +#sstvMode.active { + display: block !important; +} + +/* ============================================ + VISUALS CONTAINER + ============================================ */ +.sstv-visuals-container { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + min-height: 0; + flex: 1; + overflow: hidden; +} + +/* ============================================ + MAIN ROW (Live Decode + Gallery) + ============================================ */ +.sstv-main-row { + display: flex; + flex-direction: row; + gap: 16px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ============================================ + STATS STRIP + ============================================ */ +.sstv-stats-strip { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + flex-wrap: wrap; +} + +.sstv-strip-group { + display: flex; + align-items: center; + gap: 12px; +} + +.sstv-strip-status { + display: flex; + align-items: center; + gap: 6px; +} + +.sstv-strip-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.sstv-strip-dot.idle { + background: var(--text-dim); +} + +.sstv-strip-dot.listening { + background: var(--accent-yellow); + animation: pulse 1s infinite; +} + +.sstv-strip-dot.decoding { + background: var(--accent-cyan); + box-shadow: 0 0 6px var(--accent-cyan); + animation: pulse 0.5s infinite; +} + +.sstv-strip-status-text { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-secondary); + text-transform: uppercase; +} + +.sstv-strip-btn { + font-family: 'JetBrains Mono', monospace; + 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-strip-btn.start { + background: var(--accent-cyan); + color: var(--bg-primary); +} + +.sstv-strip-btn.start:hover { + background: var(--accent-cyan-bright, #00d4ff); +} + +.sstv-strip-btn.stop { + background: var(--accent-red, #ff3366); + color: white; +} + +.sstv-strip-btn.stop:hover { + background: #ff1a53; +} + +.sstv-strip-divider { + width: 1px; + height: 24px; + background: var(--border-color); +} + +.sstv-strip-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + min-width: 50px; +} + +.sstv-strip-value { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.sstv-strip-value.accent-cyan { + color: var(--accent-cyan); +} + +.sstv-strip-label { + font-family: 'JetBrains Mono', monospace; + font-size: 8px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ============================================ + LIVE DECODE SECTION + ============================================ */ +.sstv-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: 340px; + max-width: 400px; +} + +.sstv-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-live-title { + display: flex; + align-items: center; + gap: 8px; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.sstv-live-title svg { + color: var(--accent-cyan); +} + +.sstv-live-content { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 16px; + min-height: 300px; +} + +.sstv-canvas-container { + position: relative; + background: #000; + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; +} + +#sstvCanvas { + display: block; + image-rendering: pixelated; +} + +.sstv-decode-info { + width: 100%; + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sstv-mode-label { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--accent-cyan); + text-align: center; +} + +.sstv-progress-bar { + width: 100%; + height: 4px; + background: var(--bg-secondary); + border-radius: 2px; + overflow: hidden; +} + +.sstv-progress-bar .progress { + height: 100%; + background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); + border-radius: 2px; + transition: width 0.3s ease; +} + +.sstv-status-message { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-dim); + text-align: center; +} + +/* Idle state */ +.sstv-idle-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 40px 20px; + color: var(--text-dim); +} + +.sstv-idle-state svg { + width: 64px; + height: 64px; + opacity: 0.3; + margin-bottom: 16px; +} + +.sstv-idle-state h4 { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.sstv-idle-state p { + font-size: 12px; + max-width: 250px; +} + +/* ============================================ + GALLERY SECTION + ============================================ */ +.sstv-gallery-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 2; + min-width: 0; +} + +.sstv-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-gallery-title { + display: flex; + align-items: center; + gap: 8px; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.sstv-gallery-count { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--accent-cyan); + background: var(--bg-secondary); + padding: 2px 8px; + border-radius: 10px; +} + +.sstv-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-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-image-card:hover { + border-color: var(--accent-cyan); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2); +} + +.sstv-image-preview { + width: 100%; + aspect-ratio: 4/3; + object-fit: cover; + background: #000; + display: block; +} + +.sstv-image-info { + padding: 8px 10px; + border-top: 1px solid var(--border-color); +} + +.sstv-image-mode { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 600; + color: var(--accent-cyan); + margin-bottom: 4px; +} + +.sstv-image-timestamp { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + color: var(--text-dim); +} + +/* Empty gallery state */ +.sstv-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-gallery-empty svg { + width: 48px; + height: 48px; + opacity: 0.3; + margin-bottom: 12px; +} + +/* ============================================ + ISS PASS INFO + ============================================ */ +.sstv-iss-info { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 14px; + background: rgba(0, 212, 255, 0.05); + border: 1px solid rgba(0, 212, 255, 0.2); + border-radius: 6px; + margin-bottom: 0; +} + +.sstv-iss-icon { + width: 32px; + height: 32px; + color: var(--accent-cyan); + flex-shrink: 0; +} + +.sstv-iss-details { + flex: 1; + min-width: 0; +} + +.sstv-iss-label { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.sstv-iss-value { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: var(--text-primary); +} + +.sstv-iss-note { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--accent-orange); +} + +/* ============================================ + IMAGE MODAL + ============================================ */ +.sstv-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-image-modal.show { + display: flex; +} + +.sstv-image-modal img { + max-width: 100%; + max-height: 100%; + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.sstv-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-modal-close:hover { + opacity: 1; +} + +/* ============================================ + RESPONSIVE + ============================================ */ +@media (max-width: 1024px) { + .sstv-main-row { + flex-direction: column; + overflow-y: auto; + } + + .sstv-live-section { + max-width: none; + min-height: 350px; + } + + .sstv-gallery-section { + min-height: 300px; + } +} + +@media (max-width: 768px) { + .sstv-stats-strip { + padding: 8px 12px; + gap: 8px; + } + + .sstv-strip-divider { + display: none; + } + + .sstv-gallery-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 8px; + padding: 8px; + } + + .sstv-iss-info { + flex-direction: column; + text-align: center; + } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} diff --git a/static/js/modes/sstv.js b/static/js/modes/sstv.js new file mode 100644 index 0000000..856c581 --- /dev/null +++ b/static/js/modes/sstv.js @@ -0,0 +1,454 @@ +/** + * SSTV Mode + * ISS Slow-Scan Television decoder interface + */ + +const SSTV = (function() { + // State + let isRunning = false; + let eventSource = null; + let images = []; + let currentMode = null; + let progress = 0; + + // ISS frequency + const ISS_FREQ = 145.800; + + /** + * Initialize the SSTV mode + */ + function init() { + checkStatus(); + loadImages(); + loadIssSchedule(); + } + + /** + * Check current decoder status + */ + async function checkStatus() { + try { + const response = await fetch('/sstv/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'); + } + + // Update image count + updateImageCount(data.image_count || 0); + } catch (err) { + console.error('Failed to check SSTV status:', err); + } + } + + /** + * Start SSTV decoder + */ + async function start() { + const freqInput = document.getElementById('sstvFrequency'); + const deviceSelect = document.getElementById('sstvDevice'); + + const frequency = parseFloat(freqInput?.value || ISS_FREQ); + const device = parseInt(deviceSelect?.value || '0', 10); + + updateStatusUI('connecting', 'Starting...'); + + try { + const response = await fetch('/sstv/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ frequency, device }) + }); + + const data = await response.json(); + + if (data.status === 'started' || data.status === 'already_running') { + isRunning = true; + updateStatusUI('listening', `${frequency} MHz`); + startStream(); + showNotification('SSTV', `Listening on ${frequency} MHz`); + } else { + updateStatusUI('idle', 'Start failed'); + showStatusMessage(data.message || 'Failed to start decoder', 'error'); + } + } catch (err) { + console.error('Failed to start SSTV:', err); + updateStatusUI('idle', 'Error'); + showStatusMessage('Connection error: ' + err.message, 'error'); + } + } + + /** + * Stop SSTV decoder + */ + async function stop() { + try { + await fetch('/sstv/stop', { method: 'POST' }); + isRunning = false; + stopStream(); + updateStatusUI('idle', 'Stopped'); + showNotification('SSTV', 'Decoder stopped'); + } catch (err) { + console.error('Failed to stop SSTV:', err); + } + } + + /** + * Update status UI elements + */ + function updateStatusUI(status, text) { + const dot = document.getElementById('sstvStripDot'); + const statusText = document.getElementById('sstvStripStatus'); + const startBtn = document.getElementById('sstvStartBtn'); + const stopBtn = document.getElementById('sstvStopBtn'); + + if (dot) { + dot.className = 'sstv-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('sstvLiveContent'); + if (liveContent) { + if (status === 'idle' || status === 'unavailable') { + liveContent.innerHTML = renderIdleState(); + } + } + } + + /** + * Render idle state HTML + */ + function renderIdleState() { + return ` +
+ + + + + +

ISS SSTV Decoder

+

Click Start to listen for SSTV transmissions on 145.800 MHz

+
+ `; + } + + /** + * Start SSE stream + */ + function startStream() { + if (eventSource) { + eventSource.close(); + } + + eventSource = new EventSource('/sstv/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 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; + + // Update status based on decode state + if (data.status === 'decoding') { + updateStatusUI('decoding', `Decoding ${currentMode || 'image'}...`); + renderDecodeProgress(data); + } else if (data.status === 'complete' && data.image) { + // New image decoded + 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('sstvLiveContent'); + if (!liveContent) return; + + liveContent.innerHTML = ` +
+ +
+
+
${data.mode || 'Detecting mode...'}
+
+
+
+
${data.message || 'Decoding...'}
+
+ `; + } + + /** + * Load decoded images + */ + async function loadImages() { + try { + const response = await fetch('/sstv/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 images:', err); + } + } + + /** + * Update image count display + */ + function updateImageCount(count) { + const countEl = document.getElementById('sstvImageCount'); + const stripCount = document.getElementById('sstvStripImageCount'); + + if (countEl) countEl.textContent = count; + if (stripCount) stripCount.textContent = count; + } + + /** + * Render image gallery + */ + function renderGallery() { + const gallery = document.getElementById('sstvGallery'); + 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(''); + } + + /** + * Load ISS pass schedule + */ + async function loadIssSchedule() { + // Try to get user's location + const lat = localStorage.getItem('observerLat') || 51.5074; + const lon = localStorage.getItem('observerLon') || -0.1278; + + try { + const response = await fetch(`/sstv/iss-schedule?latitude=${lat}&longitude=${lon}&hours=48`); + const data = await response.json(); + + if (data.status === 'ok' && data.passes && data.passes.length > 0) { + renderIssInfo(data.passes[0]); + } else { + renderIssInfo(null); + } + } catch (err) { + console.error('Failed to load ISS schedule:', err); + renderIssInfo(null); + } + } + + /** + * Render ISS pass info + */ + function renderIssInfo(nextPass) { + const container = document.getElementById('sstvIssInfo'); + if (!container) return; + + if (!nextPass) { + container.innerHTML = ` +
+ + + + + +
+
Next ISS Pass
+
Unknown - Set location in settings
+
Check ARISS.org for SSTV event schedules
+
+
+ `; + return; + } + + container.innerHTML = ` +
+ + + + + +
+
Next ISS Pass
+
${nextPass.startTime} (${nextPass.maxEl}° max elevation)
+
Duration: ${nextPass.duration} min | Check ARISS.org for SSTV events
+
+
+ `; + } + + /** + * Show full-size image in modal + */ + function showImage(url) { + let modal = document.getElementById('sstvImageModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'sstvImageModal'; + modal.className = 'sstv-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('sstvImageModal'); + 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 ${type}] ${message}`); + } + } + + // Public API + return { + init, + start, + stop, + loadImages, + showImage, + closeImage + }; +})(); + +// Initialize when DOM is ready (will be called by selectMode) +document.addEventListener('DOMContentLoaded', function() { + // Initialization happens via selectMode when SSTV mode is activated +}); diff --git a/templates/index.html b/templates/index.html index 4186c3d..9b782a4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -46,7 +46,9 @@ + + @@ -345,7 +347,6 @@ Aircraft Vessels - @@ -372,6 +373,17 @@ +
+ +
+ + +
+