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:
Smittix
2026-01-29 14:51:06 +00:00
parent 40acca20b2
commit 8e204725b2
7 changed files with 1979 additions and 4 deletions

View File

@@ -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
View 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
View 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
View 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()">&times;</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
});

View File

@@ -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>

View 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
View 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