mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
feat: Add ISS SSTV decoder mode
Add slow-scan television decoder for receiving images from ISS. Includes new Space dropdown in navigation grouping Satellite and SSTV modes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,8 @@ def register_blueprints(app):
|
|||||||
from .spy_stations import spy_stations_bp
|
from .spy_stations import spy_stations_bp
|
||||||
from .controller import controller_bp
|
from .controller import controller_bp
|
||||||
from .offline import offline_bp
|
from .offline import offline_bp
|
||||||
|
from .updater import updater_bp
|
||||||
|
from .sstv import sstv_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -47,6 +49,8 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(spy_stations_bp)
|
app.register_blueprint(spy_stations_bp)
|
||||||
app.register_blueprint(controller_bp) # Remote agent controller
|
app.register_blueprint(controller_bp) # Remote agent controller
|
||||||
app.register_blueprint(offline_bp) # Offline mode settings
|
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
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
354
routes/sstv.py
Normal file
354
routes/sstv.py
Normal file
@@ -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/<filename>')
|
||||||
|
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
|
||||||
531
static/css/modes/sstv.css
Normal file
531
static/css/modes/sstv.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
454
static/js/modes/sstv.js
Normal file
454
static/js/modes/sstv.js
Normal file
@@ -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 `
|
||||||
|
<div class="sstv-idle-state">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M3 9h2M19 9h2M3 15h2M19 15h2"/>
|
||||||
|
</svg>
|
||||||
|
<h4>ISS SSTV Decoder</h4>
|
||||||
|
<p>Click Start to listen for SSTV transmissions on 145.800 MHz</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = `
|
||||||
|
<div class="sstv-canvas-container">
|
||||||
|
<canvas id="sstvCanvas" width="320" height="256"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="sstv-decode-info">
|
||||||
|
<div class="sstv-mode-label">${data.mode || 'Detecting mode...'}</div>
|
||||||
|
<div class="sstv-progress-bar">
|
||||||
|
<div class="progress" style="width: ${data.progress || 0}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sstv-status-message">${data.message || 'Decoding...'}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = `
|
||||||
|
<div class="sstv-gallery-empty">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
<p>No images decoded yet</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gallery.innerHTML = images.map(img => `
|
||||||
|
<div class="sstv-image-card" onclick="SSTV.showImage('${escapeHtml(img.url)}')">
|
||||||
|
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
|
||||||
|
<div class="sstv-image-info">
|
||||||
|
<div class="sstv-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
|
||||||
|
<div class="sstv-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).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 = `
|
||||||
|
<div class="sstv-iss-info">
|
||||||
|
<svg class="sstv-iss-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M13 7L9 3 5 7l4 4"/>
|
||||||
|
<path d="m17 11 4 4-4 4-4-4"/>
|
||||||
|
<path d="m8 12 4 4 6-6-4-4-6 6"/>
|
||||||
|
</svg>
|
||||||
|
<div class="sstv-iss-details">
|
||||||
|
<div class="sstv-iss-label">Next ISS Pass</div>
|
||||||
|
<div class="sstv-iss-value">Unknown - Set location in settings</div>
|
||||||
|
<div class="sstv-iss-note">Check ARISS.org for SSTV event schedules</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="sstv-iss-info">
|
||||||
|
<svg class="sstv-iss-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M13 7L9 3 5 7l4 4"/>
|
||||||
|
<path d="m17 11 4 4-4 4-4-4"/>
|
||||||
|
<path d="m8 12 4 4 6-6-4-4-6 6"/>
|
||||||
|
</svg>
|
||||||
|
<div class="sstv-iss-details">
|
||||||
|
<div class="sstv-iss-label">Next ISS Pass</div>
|
||||||
|
<div class="sstv-iss-value">${nextPass.startTime} (${nextPass.maxEl}° max elevation)</div>
|
||||||
|
<div class="sstv-iss-note">Duration: ${nextPass.duration} min | Check ARISS.org for SSTV events</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = `
|
||||||
|
<button class="sstv-modal-close" onclick="SSTV.closeImage()">×</button>
|
||||||
|
<img src="" alt="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
|
||||||
|
});
|
||||||
@@ -46,7 +46,9 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/proximity-viz.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/proximity-viz.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/spy-stations.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/spy-stations.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/meshtastic.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/meshtastic.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -345,7 +347,6 @@
|
|||||||
<a href="/adsb/dashboard" class="mode-nav-btn" style="text-decoration: none;"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="nav-label">Aircraft</span></a>
|
<a href="/adsb/dashboard" class="mode-nav-btn" style="text-decoration: none;"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="nav-label">Aircraft</span></a>
|
||||||
<a href="/ais/dashboard" class="mode-nav-btn" style="text-decoration: none;"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="nav-label">Vessels</span></a>
|
<a href="/ais/dashboard" class="mode-nav-btn" style="text-decoration: none;"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="nav-label">Vessels</span></a>
|
||||||
<button class="mode-nav-btn" onclick="switchMode('aprs')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="nav-label">APRS</span></button>
|
<button class="mode-nav-btn" onclick="switchMode('aprs')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="nav-label">APRS</span></button>
|
||||||
<button class="mode-nav-btn" onclick="switchMode('satellite')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span><span class="nav-label">Satellite</span></button>
|
|
||||||
<button class="mode-nav-btn" onclick="switchMode('listening')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span><span class="nav-label">Listening Post</span></button>
|
<button class="mode-nav-btn" onclick="switchMode('listening')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span><span class="nav-label">Listening Post</span></button>
|
||||||
<button class="mode-nav-btn" onclick="switchMode('spystations')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="nav-label">Spy Stations</span></button>
|
<button class="mode-nav-btn" onclick="switchMode('spystations')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="nav-label">Spy Stations</span></button>
|
||||||
<button class="mode-nav-btn" onclick="switchMode('meshtastic')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="nav-label">Meshtastic</span></button>
|
<button class="mode-nav-btn" onclick="switchMode('meshtastic')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="nav-label">Meshtastic</span></button>
|
||||||
@@ -372,6 +373,17 @@
|
|||||||
<button class="mode-nav-btn" onclick="switchMode('tscm')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span><span class="nav-label">TSCM</span></button>
|
<button class="mode-nav-btn" onclick="switchMode('tscm')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span><span class="nav-label">TSCM</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mode-nav-dropdown" data-group="space">
|
||||||
|
<button class="mode-nav-dropdown-btn" onclick="toggleNavDropdown('space')">
|
||||||
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span>
|
||||||
|
<span class="nav-label">Space</span>
|
||||||
|
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
<div class="mode-nav-dropdown-menu">
|
||||||
|
<button class="mode-nav-btn" onclick="switchMode('satellite')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span><span class="nav-label">Satellite</span></button>
|
||||||
|
<button class="mode-nav-btn" onclick="switchMode('sstv')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span><span class="nav-label">ISS SSTV</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mode-nav-actions">
|
<div class="mode-nav-actions">
|
||||||
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn"
|
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn"
|
||||||
style="display: none;">
|
style="display: none;">
|
||||||
@@ -416,6 +428,7 @@
|
|||||||
<button class="mobile-nav-btn" data-mode="bluetooth" onclick="switchMode('bluetooth')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span> BT</button>
|
<button class="mobile-nav-btn" data-mode="bluetooth" onclick="switchMode('bluetooth')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span> BT</button>
|
||||||
<button class="mobile-nav-btn" data-mode="tscm" onclick="switchMode('tscm')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span> TSCM</button>
|
<button class="mobile-nav-btn" data-mode="tscm" onclick="switchMode('tscm')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span> TSCM</button>
|
||||||
<button class="mobile-nav-btn" data-mode="satellite" onclick="switchMode('satellite')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span> Sat</button>
|
<button class="mobile-nav-btn" data-mode="satellite" onclick="switchMode('satellite')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span> Sat</button>
|
||||||
|
<button class="mobile-nav-btn" data-mode="sstv" onclick="switchMode('sstv')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg></span> SSTV</button>
|
||||||
<button class="mobile-nav-btn" data-mode="listening" onclick="switchMode('listening')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span> Scanner</button>
|
<button class="mobile-nav-btn" data-mode="listening" onclick="switchMode('listening')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span> Scanner</button>
|
||||||
<button class="mobile-nav-btn" data-mode="spystations" onclick="switchMode('spystations')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span> Spy</button>
|
<button class="mobile-nav-btn" data-mode="spystations" onclick="switchMode('spystations')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span> Spy</button>
|
||||||
<button class="mobile-nav-btn" data-mode="meshtastic" onclick="switchMode('meshtastic')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span> Mesh</button>
|
<button class="mobile-nav-btn" data-mode="meshtastic" onclick="switchMode('meshtastic')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span> Mesh</button>
|
||||||
@@ -560,6 +573,8 @@
|
|||||||
|
|
||||||
{% include 'partials/modes/satellite.html' %}
|
{% include 'partials/modes/satellite.html' %}
|
||||||
|
|
||||||
|
{% include 'partials/modes/sstv.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/listening-post.html' %}
|
{% include 'partials/modes/listening-post.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/tscm.html' %}
|
{% include 'partials/modes/tscm.html' %}
|
||||||
@@ -1703,6 +1718,100 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- SSTV Decoder Dashboard -->
|
||||||
|
<div id="sstvVisuals" class="sstv-visuals-container" style="display: none;">
|
||||||
|
<!-- Status Strip -->
|
||||||
|
<div class="sstv-stats-strip">
|
||||||
|
<div class="sstv-strip-group">
|
||||||
|
<div class="sstv-strip-status">
|
||||||
|
<span class="sstv-strip-dot idle" id="sstvStripDot"></span>
|
||||||
|
<span class="sstv-strip-status-text" id="sstvStripStatus">Idle</span>
|
||||||
|
</div>
|
||||||
|
<button class="sstv-strip-btn start" id="sstvStartBtn" onclick="SSTV.start()">Start</button>
|
||||||
|
<button class="sstv-strip-btn stop" id="sstvStopBtn" onclick="SSTV.stop()" style="display: none;">Stop</button>
|
||||||
|
</div>
|
||||||
|
<div class="sstv-strip-divider"></div>
|
||||||
|
<div class="sstv-strip-group">
|
||||||
|
<div class="sstv-strip-stat">
|
||||||
|
<span class="sstv-strip-value accent-cyan">145.800</span>
|
||||||
|
<span class="sstv-strip-label">MHZ</span>
|
||||||
|
</div>
|
||||||
|
<div class="sstv-strip-stat">
|
||||||
|
<span class="sstv-strip-value" id="sstvStripImageCount">0</span>
|
||||||
|
<span class="sstv-strip-label">IMAGES</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ISS Pass Info -->
|
||||||
|
<div id="sstvIssInfo">
|
||||||
|
<div class="sstv-iss-info">
|
||||||
|
<svg class="sstv-iss-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M13 7L9 3 5 7l4 4"/>
|
||||||
|
<path d="m17 11 4 4-4 4-4-4"/>
|
||||||
|
<path d="m8 12 4 4 6-6-4-4-6 6"/>
|
||||||
|
</svg>
|
||||||
|
<div class="sstv-iss-details">
|
||||||
|
<div class="sstv-iss-label">Next ISS Pass</div>
|
||||||
|
<div class="sstv-iss-value">Loading...</div>
|
||||||
|
<div class="sstv-iss-note">Check ARISS.org for SSTV event schedules</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Row (Live + Gallery) -->
|
||||||
|
<div class="sstv-main-row">
|
||||||
|
<!-- Live Decode Section -->
|
||||||
|
<div class="sstv-live-section">
|
||||||
|
<div class="sstv-live-header">
|
||||||
|
<div class="sstv-live-title">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
Live Decode
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sstv-live-content" id="sstvLiveContent">
|
||||||
|
<div class="sstv-idle-state">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M3 9h2M19 9h2M3 15h2M19 15h2"/>
|
||||||
|
</svg>
|
||||||
|
<h4>ISS SSTV Decoder</h4>
|
||||||
|
<p>Click Start to listen for SSTV transmissions on 145.800 MHz</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gallery Section -->
|
||||||
|
<div class="sstv-gallery-section">
|
||||||
|
<div class="sstv-gallery-header">
|
||||||
|
<div class="sstv-gallery-title">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
Decoded Images
|
||||||
|
</div>
|
||||||
|
<span class="sstv-gallery-count" id="sstvImageCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="sstv-gallery-grid" id="sstvGallery">
|
||||||
|
<div class="sstv-gallery-empty">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
<p>No images decoded yet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||||||
<div class="recon-panel collapsed" id="reconPanel">
|
<div class="recon-panel collapsed" id="reconPanel">
|
||||||
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
||||||
@@ -1789,6 +1898,7 @@
|
|||||||
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/modes/meshtastic.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/modes/meshtastic.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modes/sstv.js') }}"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -2259,10 +2369,12 @@
|
|||||||
// Map modes to their dropdown groups
|
// Map modes to their dropdown groups
|
||||||
const modeGroups = {
|
const modeGroups = {
|
||||||
'pager': 'sdr', 'sensor': 'sdr',
|
'pager': 'sdr', 'sensor': 'sdr',
|
||||||
'aprs': 'sdr', 'satellite': 'sdr', 'listening': 'sdr',
|
'aprs': 'sdr', 'listening': 'sdr',
|
||||||
'wifi': 'wireless', 'bluetooth': 'wireless',
|
'wifi': 'wireless', 'bluetooth': 'wireless',
|
||||||
'tscm': 'security',
|
'tscm': 'security',
|
||||||
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr'
|
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
|
||||||
|
'meshtastic': 'sdr',
|
||||||
|
'satellite': 'space', 'sstv': 'space'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove has-active from all dropdowns
|
// Remove has-active from all dropdowns
|
||||||
@@ -2328,6 +2440,7 @@
|
|||||||
document.getElementById('sensorMode')?.classList.toggle('active', mode === 'sensor');
|
document.getElementById('sensorMode')?.classList.toggle('active', mode === 'sensor');
|
||||||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||||||
document.getElementById('satelliteMode')?.classList.toggle('active', mode === 'satellite');
|
document.getElementById('satelliteMode')?.classList.toggle('active', mode === 'satellite');
|
||||||
|
document.getElementById('sstvMode')?.classList.toggle('active', mode === 'sstv');
|
||||||
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
|
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
|
||||||
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
|
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
|
||||||
document.getElementById('listeningPostMode')?.classList.toggle('active', mode === 'listening');
|
document.getElementById('listeningPostMode')?.classList.toggle('active', mode === 'listening');
|
||||||
@@ -2361,6 +2474,7 @@
|
|||||||
'sensor': '433MHZ',
|
'sensor': '433MHZ',
|
||||||
'rtlamr': 'METERS',
|
'rtlamr': 'METERS',
|
||||||
'satellite': 'SATELLITE',
|
'satellite': 'SATELLITE',
|
||||||
|
'sstv': 'ISS SSTV',
|
||||||
'wifi': 'WIFI',
|
'wifi': 'WIFI',
|
||||||
'bluetooth': 'BLUETOOTH',
|
'bluetooth': 'BLUETOOTH',
|
||||||
'listening': 'LISTENING POST',
|
'listening': 'LISTENING POST',
|
||||||
@@ -2380,6 +2494,7 @@
|
|||||||
const tscmVisuals = document.getElementById('tscmVisuals');
|
const tscmVisuals = document.getElementById('tscmVisuals');
|
||||||
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
|
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
|
||||||
const meshtasticVisuals = document.getElementById('meshtasticVisuals');
|
const meshtasticVisuals = document.getElementById('meshtasticVisuals');
|
||||||
|
const sstvVisuals = document.getElementById('sstvVisuals');
|
||||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||||
@@ -2388,6 +2503,7 @@
|
|||||||
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
|
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
|
||||||
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
|
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
|
||||||
if (meshtasticVisuals) meshtasticVisuals.style.display = mode === 'meshtastic' ? 'flex' : 'none';
|
if (meshtasticVisuals) meshtasticVisuals.style.display = mode === 'meshtastic' ? 'flex' : 'none';
|
||||||
|
if (sstvVisuals) sstvVisuals.style.display = mode === 'sstv' ? 'flex' : 'none';
|
||||||
|
|
||||||
// Hide sidebar by default for Meshtastic mode, show for others
|
// Hide sidebar by default for Meshtastic mode, show for others
|
||||||
const mainContent = document.querySelector('.main-content');
|
const mainContent = document.querySelector('.main-content');
|
||||||
@@ -2411,6 +2527,7 @@
|
|||||||
'sensor': '433MHz Sensor Monitor',
|
'sensor': '433MHz Sensor Monitor',
|
||||||
'rtlamr': 'Utility Meter Monitor',
|
'rtlamr': 'Utility Meter Monitor',
|
||||||
'satellite': 'Satellite Monitor',
|
'satellite': 'Satellite Monitor',
|
||||||
|
'sstv': 'ISS SSTV Decoder',
|
||||||
'wifi': 'WiFi Scanner',
|
'wifi': 'WiFi Scanner',
|
||||||
'bluetooth': 'Bluetooth Scanner',
|
'bluetooth': 'Bluetooth Scanner',
|
||||||
'listening': 'Listening Post',
|
'listening': 'Listening Post',
|
||||||
@@ -2436,7 +2553,7 @@
|
|||||||
const reconBtn = document.getElementById('reconBtn');
|
const reconBtn = document.getElementById('reconBtn');
|
||||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||||
const reconPanel = document.getElementById('reconPanel');
|
const reconPanel = document.getElementById('reconPanel');
|
||||||
if (mode === 'satellite' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') {
|
if (mode === 'satellite' || mode === 'sstv' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') {
|
||||||
if (reconPanel) reconPanel.style.display = 'none';
|
if (reconPanel) reconPanel.style.display = 'none';
|
||||||
if (reconBtn) reconBtn.style.display = 'none';
|
if (reconBtn) reconBtn.style.display = 'none';
|
||||||
if (intelBtn) intelBtn.style.display = 'none';
|
if (intelBtn) intelBtn.style.display = 'none';
|
||||||
@@ -2513,6 +2630,8 @@
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
Meshtastic.invalidateMap();
|
Meshtastic.invalidateMap();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
} else if (mode === 'sstv') {
|
||||||
|
SSTV.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11990,8 +12109,13 @@
|
|||||||
<!-- Settings Modal -->
|
<!-- Settings Modal -->
|
||||||
{% include 'partials/settings-modal.html' %}
|
{% include 'partials/settings-modal.html' %}
|
||||||
|
|
||||||
|
<!-- Toast Container -->
|
||||||
|
<div id="toastContainer"></div>
|
||||||
|
|
||||||
<!-- Settings Manager -->
|
<!-- Settings Manager -->
|
||||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
||||||
|
<!-- Updater -->
|
||||||
|
<script src="{{ url_for('static', filename='js/core/updater.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
48
templates/partials/modes/sstv.html
Normal file
48
templates/partials/modes/sstv.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<!-- SSTV MODE -->
|
||||||
|
<div id="sstvMode" class="mode-content">
|
||||||
|
<div class="section">
|
||||||
|
<h3>ISS SSTV Decoder</h3>
|
||||||
|
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||||
|
Decode Slow-Scan Television images from the International Space Station.
|
||||||
|
ISS SSTV transmits on 145.800 MHz FM during special events.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Decoder Settings</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Frequency (MHz)</label>
|
||||||
|
<input type="number" id="sstvFrequency" value="145.800" step="0.001" min="100" max="500">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SDR Device</label>
|
||||||
|
<select id="sstvDevice">
|
||||||
|
<option value="0">Device 0</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Resources</h3>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
|
<a href="https://ariss.org/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||||
|
ARISS.org (Event Schedule)
|
||||||
|
</a>
|
||||||
|
<a href="https://www.amsat.org/sstv-from-iss/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||||
|
AMSAT SSTV Guide
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>About SSTV</h3>
|
||||||
|
<p class="info-text" style="font-size: 11px; color: var(--text-dim);">
|
||||||
|
SSTV (Slow-Scan Television) is a method for transmitting images via radio.
|
||||||
|
The ISS periodically transmits commemorative images during special events
|
||||||
|
which can be received with an RTL-SDR and appropriate software.
|
||||||
|
</p>
|
||||||
|
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: 8px;">
|
||||||
|
Common modes: PD120, PD180, Martin1, Scottie1
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
460
utils/sstv.py
Normal file
460
utils/sstv.py
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
"""SSTV (Slow-Scan Television) decoder for ISS transmissions.
|
||||||
|
|
||||||
|
This module provides SSTV decoding capabilities for receiving images
|
||||||
|
from the International Space Station during special events.
|
||||||
|
|
||||||
|
ISS SSTV typically transmits on 145.800 MHz FM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.sstv')
|
||||||
|
|
||||||
|
# ISS SSTV frequency
|
||||||
|
ISS_SSTV_FREQ = 145.800 # MHz
|
||||||
|
|
||||||
|
# Common SSTV modes used by ISS
|
||||||
|
SSTV_MODES = ['PD120', 'PD180', 'Martin1', 'Martin2', 'Scottie1', 'Scottie2', 'Robot36']
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SSTVImage:
|
||||||
|
"""Decoded SSTV image."""
|
||||||
|
filename: str
|
||||||
|
path: Path
|
||||||
|
mode: str
|
||||||
|
timestamp: datetime
|
||||||
|
frequency: float
|
||||||
|
size_bytes: int = 0
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
'filename': self.filename,
|
||||||
|
'path': str(self.path),
|
||||||
|
'mode': self.mode,
|
||||||
|
'timestamp': self.timestamp.isoformat(),
|
||||||
|
'frequency': self.frequency,
|
||||||
|
'size_bytes': self.size_bytes,
|
||||||
|
'url': f'/sstv/images/{self.filename}'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DecodeProgress:
|
||||||
|
"""SSTV decode progress update."""
|
||||||
|
status: str # 'detecting', 'decoding', 'complete', 'error'
|
||||||
|
mode: str | None = None
|
||||||
|
progress_percent: int = 0
|
||||||
|
message: str | None = None
|
||||||
|
image: SSTVImage | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
result = {
|
||||||
|
'type': 'sstv_progress',
|
||||||
|
'status': self.status,
|
||||||
|
'progress': self.progress_percent,
|
||||||
|
}
|
||||||
|
if self.mode:
|
||||||
|
result['mode'] = self.mode
|
||||||
|
if self.message:
|
||||||
|
result['message'] = self.message
|
||||||
|
if self.image:
|
||||||
|
result['image'] = self.image.to_dict()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class SSTVDecoder:
|
||||||
|
"""SSTV decoder using external tools (slowrx or qsstv)."""
|
||||||
|
|
||||||
|
def __init__(self, output_dir: str | Path | None = None):
|
||||||
|
self._process = None
|
||||||
|
self._running = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._callback: Callable[[DecodeProgress], None] | None = None
|
||||||
|
self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images')
|
||||||
|
self._images: list[SSTVImage] = []
|
||||||
|
self._reader_thread = None
|
||||||
|
self._frequency = ISS_SSTV_FREQ
|
||||||
|
self._device_index = 0
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
self._output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Detect available decoder
|
||||||
|
self._decoder = self._detect_decoder()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def decoder_available(self) -> str | None:
|
||||||
|
"""Return name of available decoder or None."""
|
||||||
|
return self._decoder
|
||||||
|
|
||||||
|
def _detect_decoder(self) -> str | None:
|
||||||
|
"""Detect which SSTV decoder is available."""
|
||||||
|
# Check for slowrx (command-line SSTV decoder)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['which', 'slowrx'], capture_output=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return 'slowrx'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for qsstv (if available as CLI)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['which', 'qsstv'], capture_output=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return 'qsstv'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for Python sstv package
|
||||||
|
try:
|
||||||
|
import sstv
|
||||||
|
return 'python-sstv'
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.warning("No SSTV decoder found. Install slowrx or python sstv package.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_callback(self, callback: Callable[[DecodeProgress], None]) -> None:
|
||||||
|
"""Set callback for decode progress updates."""
|
||||||
|
self._callback = callback
|
||||||
|
|
||||||
|
def start(self, frequency: float = ISS_SSTV_FREQ, device_index: int = 0) -> bool:
|
||||||
|
"""
|
||||||
|
Start SSTV decoder listening on specified frequency.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frequency: Frequency in MHz (default: 145.800 for ISS)
|
||||||
|
device_index: RTL-SDR device index
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if started successfully
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if self._running:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not self._decoder:
|
||||||
|
logger.error("No SSTV decoder available")
|
||||||
|
self._emit_progress(DecodeProgress(
|
||||||
|
status='error',
|
||||||
|
message='No SSTV decoder installed. Install slowrx: apt install slowrx'
|
||||||
|
))
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._frequency = frequency
|
||||||
|
self._device_index = device_index
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._decoder == 'slowrx':
|
||||||
|
self._start_slowrx()
|
||||||
|
elif self._decoder == 'python-sstv':
|
||||||
|
self._start_python_sstv()
|
||||||
|
else:
|
||||||
|
logger.error(f"Unsupported decoder: {self._decoder}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
logger.info(f"SSTV decoder started on {frequency} MHz")
|
||||||
|
self._emit_progress(DecodeProgress(
|
||||||
|
status='detecting',
|
||||||
|
message=f'Listening on {frequency} MHz...'
|
||||||
|
))
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start SSTV decoder: {e}")
|
||||||
|
self._emit_progress(DecodeProgress(
|
||||||
|
status='error',
|
||||||
|
message=str(e)
|
||||||
|
))
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _start_slowrx(self) -> None:
|
||||||
|
"""Start slowrx decoder with rtl_fm piped input."""
|
||||||
|
# Convert frequency to Hz
|
||||||
|
freq_hz = int(self._frequency * 1_000_000)
|
||||||
|
|
||||||
|
# Build rtl_fm command for FM demodulation
|
||||||
|
rtl_cmd = [
|
||||||
|
'rtl_fm',
|
||||||
|
'-d', str(self._device_index),
|
||||||
|
'-f', str(freq_hz),
|
||||||
|
'-M', 'fm',
|
||||||
|
'-s', '48000',
|
||||||
|
'-r', '48000',
|
||||||
|
'-l', '0', # No squelch
|
||||||
|
'-'
|
||||||
|
]
|
||||||
|
|
||||||
|
# slowrx reads from stdin and outputs images to directory
|
||||||
|
slowrx_cmd = [
|
||||||
|
'slowrx',
|
||||||
|
'-o', str(self._output_dir),
|
||||||
|
'-'
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(f"Starting rtl_fm: {' '.join(rtl_cmd)}")
|
||||||
|
logger.info(f"Piping to slowrx: {' '.join(slowrx_cmd)}")
|
||||||
|
|
||||||
|
# Start rtl_fm
|
||||||
|
self._rtl_process = subprocess.Popen(
|
||||||
|
rtl_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start slowrx reading from rtl_fm
|
||||||
|
self._process = subprocess.Popen(
|
||||||
|
slowrx_cmd,
|
||||||
|
stdin=self._rtl_process.stdout,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start reader thread to monitor output
|
||||||
|
self._reader_thread = threading.Thread(target=self._read_slowrx_output, daemon=True)
|
||||||
|
self._reader_thread.start()
|
||||||
|
|
||||||
|
# Start image watcher thread
|
||||||
|
self._watcher_thread = threading.Thread(target=self._watch_images, daemon=True)
|
||||||
|
self._watcher_thread.start()
|
||||||
|
|
||||||
|
def _start_python_sstv(self) -> None:
|
||||||
|
"""Start Python SSTV decoder (requires audio file input)."""
|
||||||
|
# Python sstv package typically works with audio files
|
||||||
|
# For real-time decoding, we'd need to record audio first
|
||||||
|
# This is a simplified implementation
|
||||||
|
logger.warning("Python SSTV package requires audio file input")
|
||||||
|
self._emit_progress(DecodeProgress(
|
||||||
|
status='error',
|
||||||
|
message='Python SSTV decoder requires audio files. Use slowrx for real-time decoding.'
|
||||||
|
))
|
||||||
|
raise NotImplementedError("Real-time Python SSTV not implemented")
|
||||||
|
|
||||||
|
def _read_slowrx_output(self) -> None:
|
||||||
|
"""Read slowrx stderr for progress updates."""
|
||||||
|
if not self._process:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
for line in iter(self._process.stderr.readline, b''):
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
line_str = line.decode('utf-8', errors='ignore').strip()
|
||||||
|
if not line_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.debug(f"slowrx: {line_str}")
|
||||||
|
|
||||||
|
# Parse slowrx output for mode detection and progress
|
||||||
|
if 'Detected' in line_str or 'mode' in line_str.lower():
|
||||||
|
for mode in SSTV_MODES:
|
||||||
|
if mode.lower() in line_str.lower():
|
||||||
|
self._emit_progress(DecodeProgress(
|
||||||
|
status='decoding',
|
||||||
|
mode=mode,
|
||||||
|
message=f'Decoding {mode} image...'
|
||||||
|
))
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading slowrx output: {e}")
|
||||||
|
|
||||||
|
def _watch_images(self) -> None:
|
||||||
|
"""Watch output directory for new images."""
|
||||||
|
known_files = set(f.name for f in self._output_dir.glob('*.png'))
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_files = set(f.name for f in self._output_dir.glob('*.png'))
|
||||||
|
new_files = current_files - known_files
|
||||||
|
|
||||||
|
for filename in new_files:
|
||||||
|
filepath = self._output_dir / filename
|
||||||
|
if filepath.exists():
|
||||||
|
# New image detected
|
||||||
|
image = SSTVImage(
|
||||||
|
filename=filename,
|
||||||
|
path=filepath,
|
||||||
|
mode='Unknown', # Would need to parse from slowrx output
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
frequency=self._frequency,
|
||||||
|
size_bytes=filepath.stat().st_size
|
||||||
|
)
|
||||||
|
self._images.append(image)
|
||||||
|
|
||||||
|
logger.info(f"New SSTV image: {filename}")
|
||||||
|
self._emit_progress(DecodeProgress(
|
||||||
|
status='complete',
|
||||||
|
message='Image decoded',
|
||||||
|
image=image
|
||||||
|
))
|
||||||
|
|
||||||
|
known_files = current_files
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error watching images: {e}")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop SSTV decoder."""
|
||||||
|
with self._lock:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
if hasattr(self, '_rtl_process') and self._rtl_process:
|
||||||
|
try:
|
||||||
|
self._rtl_process.terminate()
|
||||||
|
self._rtl_process.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
self._rtl_process.kill()
|
||||||
|
self._rtl_process = None
|
||||||
|
|
||||||
|
if self._process:
|
||||||
|
try:
|
||||||
|
self._process.terminate()
|
||||||
|
self._process.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
self._process.kill()
|
||||||
|
self._process = None
|
||||||
|
|
||||||
|
logger.info("SSTV decoder stopped")
|
||||||
|
|
||||||
|
def get_images(self) -> list[SSTVImage]:
|
||||||
|
"""Get list of decoded images."""
|
||||||
|
# Also scan directory for any images we might have missed
|
||||||
|
self._scan_images()
|
||||||
|
return list(self._images)
|
||||||
|
|
||||||
|
def _scan_images(self) -> None:
|
||||||
|
"""Scan output directory for images."""
|
||||||
|
known_filenames = {img.filename for img in self._images}
|
||||||
|
|
||||||
|
for filepath in self._output_dir.glob('*.png'):
|
||||||
|
if filepath.name not in known_filenames:
|
||||||
|
try:
|
||||||
|
stat = filepath.stat()
|
||||||
|
image = SSTVImage(
|
||||||
|
filename=filepath.name,
|
||||||
|
path=filepath,
|
||||||
|
mode='Unknown',
|
||||||
|
timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||||
|
frequency=ISS_SSTV_FREQ,
|
||||||
|
size_bytes=stat.st_size
|
||||||
|
)
|
||||||
|
self._images.append(image)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error scanning image {filepath}: {e}")
|
||||||
|
|
||||||
|
def _emit_progress(self, progress: DecodeProgress) -> None:
|
||||||
|
"""Emit progress update to callback."""
|
||||||
|
if self._callback:
|
||||||
|
try:
|
||||||
|
self._callback(progress)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in progress callback: {e}")
|
||||||
|
|
||||||
|
def decode_file(self, audio_path: str | Path) -> list[SSTVImage]:
|
||||||
|
"""
|
||||||
|
Decode SSTV image from audio file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_path: Path to WAV audio file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of decoded images
|
||||||
|
"""
|
||||||
|
audio_path = Path(audio_path)
|
||||||
|
if not audio_path.exists():
|
||||||
|
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
||||||
|
|
||||||
|
images = []
|
||||||
|
|
||||||
|
if self._decoder == 'slowrx':
|
||||||
|
# Use slowrx with file input
|
||||||
|
output_file = self._output_dir / f"sstv_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
||||||
|
|
||||||
|
cmd = ['slowrx', '-o', str(self._output_dir), str(audio_path)]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, timeout=300)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Check for new images
|
||||||
|
for filepath in self._output_dir.glob('*.png'):
|
||||||
|
stat = filepath.stat()
|
||||||
|
if stat.st_mtime > time.time() - 60: # Created in last minute
|
||||||
|
image = SSTVImage(
|
||||||
|
filename=filepath.name,
|
||||||
|
path=filepath,
|
||||||
|
mode='Unknown',
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
frequency=0,
|
||||||
|
size_bytes=stat.st_size
|
||||||
|
)
|
||||||
|
images.append(image)
|
||||||
|
|
||||||
|
elif self._decoder == 'python-sstv':
|
||||||
|
# Use Python sstv library
|
||||||
|
try:
|
||||||
|
from sstv.decode import SSTVDecoder as PythonSSTVDecoder
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
decoder = PythonSSTVDecoder(str(audio_path))
|
||||||
|
img = decoder.decode()
|
||||||
|
|
||||||
|
if img:
|
||||||
|
output_file = self._output_dir / f"sstv_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
||||||
|
img.save(output_file)
|
||||||
|
|
||||||
|
image = SSTVImage(
|
||||||
|
filename=output_file.name,
|
||||||
|
path=output_file,
|
||||||
|
mode=decoder.mode or 'Unknown',
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
frequency=0,
|
||||||
|
size_bytes=output_file.stat().st_size
|
||||||
|
)
|
||||||
|
images.append(image)
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.error("Python sstv package not properly installed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error decoding with Python sstv: {e}")
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
# Global decoder instance
|
||||||
|
_decoder: SSTVDecoder | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_sstv_decoder() -> SSTVDecoder:
|
||||||
|
"""Get or create the global SSTV decoder instance."""
|
||||||
|
global _decoder
|
||||||
|
if _decoder is None:
|
||||||
|
_decoder = SSTVDecoder()
|
||||||
|
return _decoder
|
||||||
|
|
||||||
|
|
||||||
|
def is_sstv_available() -> bool:
|
||||||
|
"""Check if SSTV decoding is available."""
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
return decoder.decoder_available is not None
|
||||||
Reference in New Issue
Block a user