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 .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
|
||||
|
||||
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/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/sstv.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>
|
||||
|
||||
<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="/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('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('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>
|
||||
@@ -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>
|
||||
</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">
|
||||
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn"
|
||||
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="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="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="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>
|
||||
@@ -560,6 +573,8 @@
|
||||
|
||||
{% include 'partials/modes/satellite.html' %}
|
||||
|
||||
{% include 'partials/modes/sstv.html' %}
|
||||
|
||||
{% include 'partials/modes/listening-post.html' %}
|
||||
|
||||
{% include 'partials/modes/tscm.html' %}
|
||||
@@ -1703,6 +1718,100 @@
|
||||
</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) -->
|
||||
<div class="recon-panel collapsed" id="reconPanel">
|
||||
<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/spy-stations.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>
|
||||
// ============================================
|
||||
@@ -2259,10 +2369,12 @@
|
||||
// Map modes to their dropdown groups
|
||||
const modeGroups = {
|
||||
'pager': 'sdr', 'sensor': 'sdr',
|
||||
'aprs': 'sdr', 'satellite': 'sdr', 'listening': 'sdr',
|
||||
'aprs': 'sdr', 'listening': 'sdr',
|
||||
'wifi': 'wireless', 'bluetooth': 'wireless',
|
||||
'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
|
||||
@@ -2328,6 +2440,7 @@
|
||||
document.getElementById('sensorMode')?.classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||||
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('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
|
||||
document.getElementById('listeningPostMode')?.classList.toggle('active', mode === 'listening');
|
||||
@@ -2361,6 +2474,7 @@
|
||||
'sensor': '433MHZ',
|
||||
'rtlamr': 'METERS',
|
||||
'satellite': 'SATELLITE',
|
||||
'sstv': 'ISS SSTV',
|
||||
'wifi': 'WIFI',
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
'listening': 'LISTENING POST',
|
||||
@@ -2380,6 +2494,7 @@
|
||||
const tscmVisuals = document.getElementById('tscmVisuals');
|
||||
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
|
||||
const meshtasticVisuals = document.getElementById('meshtasticVisuals');
|
||||
const sstvVisuals = document.getElementById('sstvVisuals');
|
||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
@@ -2388,6 +2503,7 @@
|
||||
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
|
||||
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? '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
|
||||
const mainContent = document.querySelector('.main-content');
|
||||
@@ -2411,6 +2527,7 @@
|
||||
'sensor': '433MHz Sensor Monitor',
|
||||
'rtlamr': 'Utility Meter Monitor',
|
||||
'satellite': 'Satellite Monitor',
|
||||
'sstv': 'ISS SSTV Decoder',
|
||||
'wifi': 'WiFi Scanner',
|
||||
'bluetooth': 'Bluetooth Scanner',
|
||||
'listening': 'Listening Post',
|
||||
@@ -2436,7 +2553,7 @@
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
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 (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -2513,6 +2630,8 @@
|
||||
setTimeout(() => {
|
||||
Meshtastic.invalidateMap();
|
||||
}, 100);
|
||||
} else if (mode === 'sstv') {
|
||||
SSTV.init();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11990,8 +12109,13 @@
|
||||
<!-- Settings Modal -->
|
||||
{% include 'partials/settings-modal.html' %}
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="toastContainer"></div>
|
||||
|
||||
<!-- Settings Manager -->
|
||||
<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>
|
||||
|
||||
</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