Add terrestrial HF SSTV mode with predefined frequencies and modulation support

Adds a general-purpose SSTV decoder alongside the existing ISS SSTV mode,
supporting USB/LSB/FM modulation on common amateur radio HF/VHF/UHF
frequencies (14.230 MHz USB, 3.845 MHz LSB, etc.) with auto-detection
of modulation from preset frequency table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-06 15:36:41 +00:00
parent 8fca54e523
commit 4c67307951
8 changed files with 1897 additions and 89 deletions

View File

@@ -26,6 +26,9 @@ def register_blueprints(app):
from .offline import offline_bp
from .updater import updater_bp
from .sstv import sstv_bp
from .sstv_general import sstv_general_bp
from .dmr import dmr_bp
from .websdr import websdr_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -51,6 +54,9 @@ def register_blueprints(app):
app.register_blueprint(offline_bp) # Offline mode settings
app.register_blueprint(updater_bp) # GitHub update checking
app.register_blueprint(sstv_bp) # ISS SSTV decoder
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
# Initialize TSCM state with queue and lock from app
import app as app_module

288
routes/sstv_general.py Normal file
View File

@@ -0,0 +1,288 @@
"""General SSTV (Slow-Scan Television) decoder routes.
Provides endpoints for decoding terrestrial SSTV images on common HF/VHF/UHF
frequencies used by amateur radio operators worldwide.
"""
from __future__ import annotations
import queue
import time
from collections.abc import Generator
from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file
from utils.logging import get_logger
from utils.sse import format_sse
from utils.sstv import (
DecodeProgress,
get_general_sstv_decoder,
)
logger = get_logger('intercept.sstv_general')
sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general')
# Queue for SSE progress streaming
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
# Predefined SSTV frequencies
SSTV_FREQUENCIES = [
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
{'band': '80 m', 'frequency': 3.730, 'modulation': 'lsb', 'notes': 'Europe primary (analog/digital variants)', 'type': 'Terrestrial HF'},
{'band': '40 m', 'frequency': 7.171, 'modulation': 'lsb', 'notes': 'Common international/US/EU SSTV activity', 'type': 'Terrestrial HF'},
{'band': '40 m', 'frequency': 7.040, 'modulation': 'lsb', 'notes': 'Alternative US/Europe calling', 'type': 'Terrestrial HF'},
{'band': '30 m', 'frequency': 10.132, 'modulation': 'usb', 'notes': 'Narrowband SSTV (e.g., MP73-N digital)', 'type': 'Terrestrial HF'},
{'band': '20 m', 'frequency': 14.230, 'modulation': 'usb', 'notes': 'Most popular international SSTV frequency', 'type': 'Terrestrial HF'},
{'band': '20 m', 'frequency': 14.233, 'modulation': 'usb', 'notes': 'Digital SSTV calling / alternative activity', 'type': 'Terrestrial HF'},
{'band': '20 m', 'frequency': 14.240, 'modulation': 'usb', 'notes': 'Europe alternative', 'type': 'Terrestrial HF'},
{'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
{'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
{'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
{'band': '2 m', 'frequency': 145.625, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
]
# Build a lookup for auto-detecting modulation from frequency
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
def _progress_callback(progress: DecodeProgress) -> None:
"""Callback to queue progress updates for SSE stream."""
try:
_sstv_general_queue.put_nowait(progress.to_dict())
except queue.Full:
try:
_sstv_general_queue.get_nowait()
_sstv_general_queue.put_nowait(progress.to_dict())
except queue.Empty:
pass
@sstv_general_bp.route('/frequencies')
def get_frequencies():
"""Return the predefined SSTV frequency table."""
return jsonify({
'status': 'ok',
'frequencies': SSTV_FREQUENCIES,
})
@sstv_general_bp.route('/status')
def get_status():
"""Get general SSTV decoder status."""
decoder = get_general_sstv_decoder()
return jsonify({
'available': decoder.decoder_available is not None,
'decoder': decoder.decoder_available,
'running': decoder.is_running,
'image_count': len(decoder.get_images()),
})
@sstv_general_bp.route('/start', methods=['POST'])
def start_decoder():
"""
Start general SSTV decoder.
JSON body:
{
"frequency": 14.230, // Frequency in MHz (required)
"modulation": "usb", // fm, usb, or lsb (auto-detected from frequency table if omitted)
"device": 0 // RTL-SDR device index
}
"""
decoder = get_general_sstv_decoder()
if decoder.decoder_available is None:
return jsonify({
'status': 'error',
'message': 'SSTV decoder not available. Install slowrx: apt install slowrx',
}), 400
if decoder.is_running:
return jsonify({
'status': 'already_running',
})
# Clear queue
while not _sstv_general_queue.empty():
try:
_sstv_general_queue.get_nowait()
except queue.Empty:
break
data = request.get_json(silent=True) or {}
frequency = data.get('frequency')
modulation = data.get('modulation')
device_index = data.get('device', 0)
# Validate frequency
if frequency is None:
return jsonify({
'status': 'error',
'message': 'Frequency is required',
}), 400
try:
frequency = float(frequency)
if not (1 <= frequency <= 500):
return jsonify({
'status': 'error',
'message': 'Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)',
}), 400
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid frequency',
}), 400
# Auto-detect modulation from frequency table if not specified
if not modulation:
modulation = _FREQ_MODULATION_MAP.get(frequency, 'usb')
# Validate modulation
if modulation not in ('fm', 'usb', 'lsb'):
return jsonify({
'status': 'error',
'message': 'Modulation must be fm, usb, or lsb',
}), 400
# Set callback and start
decoder.set_callback(_progress_callback)
success = decoder.start(
frequency=frequency,
device_index=device_index,
modulation=modulation,
)
if success:
return jsonify({
'status': 'started',
'frequency': frequency,
'modulation': modulation,
'device': device_index,
})
else:
return jsonify({
'status': 'error',
'message': 'Failed to start decoder',
}), 500
@sstv_general_bp.route('/stop', methods=['POST'])
def stop_decoder():
"""Stop general SSTV decoder."""
decoder = get_general_sstv_decoder()
decoder.stop()
return jsonify({'status': 'stopped'})
@sstv_general_bp.route('/images')
def list_images():
"""Get list of decoded SSTV images."""
decoder = get_general_sstv_decoder()
images = decoder.get_images()
limit = request.args.get('limit', type=int)
if limit and limit > 0:
images = images[-limit:]
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images),
})
@sstv_general_bp.route('/images/<filename>')
def get_image(filename: str):
"""Get a decoded SSTV image file."""
decoder = get_general_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return send_file(image_path, mimetype='image/png')
@sstv_general_bp.route('/stream')
def stream_progress():
"""SSE stream of SSTV decode progress."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
progress = _sstv_general_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(progress)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@sstv_general_bp.route('/decode-file', methods=['POST'])
def decode_file():
"""Decode SSTV from an uploaded audio file."""
if 'audio' not in request.files:
return jsonify({
'status': 'error',
'message': 'No audio file provided',
}), 400
audio_file = request.files['audio']
if not audio_file.filename:
return jsonify({
'status': 'error',
'message': 'No file selected',
}), 400
import tempfile
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
audio_file.save(tmp.name)
tmp_path = tmp.name
try:
decoder = get_general_sstv_decoder()
images = decoder.decode_file(tmp_path)
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images),
})
except Exception as e:
logger.error(f"Error decoding file: {e}")
return jsonify({
'status': 'error',
'message': str(e),
}), 500
finally:
try:
Path(tmp_path).unlink()
except Exception:
pass

View File

@@ -0,0 +1,477 @@
/**
* SSTV General Mode Styles
* Terrestrial Slow-Scan Television decoder interface
*/
/* ============================================
MODE VISIBILITY
============================================ */
#sstvGeneralMode.active {
display: block !important;
}
/* ============================================
VISUALS CONTAINER
============================================ */
.sstv-general-visuals-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
min-height: 0;
flex: 1;
height: 100%;
overflow: hidden;
}
/* ============================================
STATS STRIP
============================================ */
.sstv-general-stats-strip {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
flex-wrap: wrap;
flex-shrink: 0;
}
.sstv-general-strip-group {
display: flex;
align-items: center;
gap: 12px;
}
.sstv-general-strip-status {
display: flex;
align-items: center;
gap: 6px;
}
.sstv-general-strip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.sstv-general-strip-dot.idle {
background: var(--text-dim);
}
.sstv-general-strip-dot.listening {
background: var(--accent-yellow);
animation: sstv-general-pulse 1s infinite;
}
.sstv-general-strip-dot.decoding {
background: var(--accent-cyan);
box-shadow: 0 0 6px var(--accent-cyan);
animation: sstv-general-pulse 0.5s infinite;
}
.sstv-general-strip-status-text {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
}
.sstv-general-strip-btn {
font-family: var(--font-mono);
font-size: 10px;
padding: 5px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
text-transform: uppercase;
font-weight: 600;
transition: all 0.15s ease;
}
.sstv-general-strip-btn.start {
background: var(--accent-cyan);
color: var(--bg-primary);
}
.sstv-general-strip-btn.start:hover {
background: var(--accent-cyan-bright, #00d4ff);
}
.sstv-general-strip-btn.stop {
background: var(--accent-red, #ff3366);
color: white;
}
.sstv-general-strip-btn.stop:hover {
background: #ff1a53;
}
.sstv-general-strip-divider {
width: 1px;
height: 24px;
background: var(--border-color);
}
.sstv-general-strip-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 50px;
}
.sstv-general-strip-value {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.sstv-general-strip-value.accent-cyan {
color: var(--accent-cyan);
}
.sstv-general-strip-label {
font-family: var(--font-mono);
font-size: 8px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ============================================
MAIN ROW (Live Decode + Gallery)
============================================ */
.sstv-general-main-row {
display: flex;
flex-direction: row;
gap: 12px;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ============================================
LIVE DECODE SECTION
============================================ */
.sstv-general-live-section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-width: 300px;
}
.sstv-general-live-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--border-color);
}
.sstv-general-live-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.sstv-general-live-title svg {
color: var(--accent-cyan);
}
.sstv-general-live-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
min-height: 0;
}
.sstv-general-canvas-container {
position: relative;
background: #000;
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.sstv-general-decode-info {
width: 100%;
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sstv-general-mode-label {
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
text-align: center;
}
.sstv-general-progress-bar {
width: 100%;
height: 4px;
background: var(--bg-secondary);
border-radius: 2px;
overflow: hidden;
}
.sstv-general-progress-bar .progress {
height: 100%;
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
border-radius: 2px;
transition: width 0.3s ease;
}
.sstv-general-status-message {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
text-align: center;
}
/* Idle state */
.sstv-general-idle-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px 20px;
color: var(--text-dim);
}
.sstv-general-idle-state svg {
width: 64px;
height: 64px;
opacity: 0.3;
margin-bottom: 16px;
}
.sstv-general-idle-state h4 {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.sstv-general-idle-state p {
font-size: 12px;
max-width: 250px;
}
/* ============================================
GALLERY SECTION
============================================ */
.sstv-general-gallery-section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1.5;
min-width: 300px;
}
.sstv-general-gallery-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--border-color);
}
.sstv-general-gallery-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.sstv-general-gallery-count {
font-family: var(--font-mono);
font-size: 10px;
color: var(--accent-cyan);
background: var(--bg-secondary);
padding: 2px 8px;
border-radius: 10px;
}
.sstv-general-gallery-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
padding: 12px;
overflow-y: auto;
align-content: start;
}
.sstv-general-image-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
cursor: pointer;
}
.sstv-general-image-card:hover {
border-color: var(--accent-cyan);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
}
.sstv-general-image-preview {
width: 100%;
aspect-ratio: 4/3;
object-fit: cover;
background: #000;
display: block;
}
.sstv-general-image-info {
padding: 8px 10px;
border-top: 1px solid var(--border-color);
}
.sstv-general-image-mode {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
color: var(--accent-cyan);
margin-bottom: 4px;
}
.sstv-general-image-timestamp {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
}
/* Empty gallery state */
.sstv-general-gallery-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--text-dim);
grid-column: 1 / -1;
}
.sstv-general-gallery-empty svg {
width: 48px;
height: 48px;
opacity: 0.3;
margin-bottom: 12px;
}
/* ============================================
IMAGE MODAL
============================================ */
.sstv-general-image-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: none;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 40px;
}
.sstv-general-image-modal.show {
display: flex;
}
.sstv-general-image-modal img {
max-width: 100%;
max-height: 100%;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.sstv-general-modal-close {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: white;
font-size: 32px;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s;
}
.sstv-general-modal-close:hover {
opacity: 1;
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 1024px) {
.sstv-general-main-row {
flex-direction: column;
overflow-y: auto;
}
.sstv-general-live-section {
max-width: none;
min-height: 350px;
}
.sstv-general-gallery-section {
min-height: 300px;
}
}
@media (max-width: 768px) {
.sstv-general-stats-strip {
padding: 8px 12px;
gap: 8px;
flex-wrap: wrap;
}
.sstv-general-strip-divider {
display: none;
}
.sstv-general-gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
padding: 8px;
}
}
@keyframes sstv-general-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}

View File

@@ -0,0 +1,410 @@
/**
* SSTV General Mode
* Terrestrial Slow-Scan Television decoder interface
*/
const SSTVGeneral = (function() {
// State
let isRunning = false;
let eventSource = null;
let images = [];
let currentMode = null;
let progress = 0;
/**
* Initialize the SSTV General mode
*/
function init() {
checkStatus();
loadImages();
}
/**
* Select a preset frequency from the dropdown
*/
function selectPreset(value) {
if (!value) return;
const parts = value.split('|');
const freq = parseFloat(parts[0]);
const mod = parts[1];
const freqInput = document.getElementById('sstvGeneralFrequency');
const modSelect = document.getElementById('sstvGeneralModulation');
if (freqInput) freqInput.value = freq;
if (modSelect) modSelect.value = mod;
// Update strip display
const stripFreq = document.getElementById('sstvGeneralStripFreq');
const stripMod = document.getElementById('sstvGeneralStripMod');
if (stripFreq) stripFreq.textContent = freq.toFixed(3);
if (stripMod) stripMod.textContent = mod.toUpperCase();
}
/**
* Check current decoder status
*/
async function checkStatus() {
try {
const response = await fetch('/sstv-general/status');
const data = await response.json();
if (!data.available) {
updateStatusUI('unavailable', 'Decoder not installed');
showStatusMessage('SSTV decoder not available. Install slowrx: apt install slowrx', 'warning');
return;
}
if (data.running) {
isRunning = true;
updateStatusUI('listening', 'Listening...');
startStream();
} else {
updateStatusUI('idle', 'Idle');
}
updateImageCount(data.image_count || 0);
} catch (err) {
console.error('Failed to check SSTV General status:', err);
}
}
/**
* Start SSTV decoder
*/
async function start() {
const freqInput = document.getElementById('sstvGeneralFrequency');
const modSelect = document.getElementById('sstvGeneralModulation');
const deviceSelect = document.getElementById('deviceSelect');
const frequency = parseFloat(freqInput?.value || '14.230');
const modulation = modSelect?.value || 'usb';
const device = parseInt(deviceSelect?.value || '0', 10);
updateStatusUI('connecting', 'Starting...');
try {
const response = await fetch('/sstv-general/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, modulation, device })
});
const data = await response.json();
if (data.status === 'started' || data.status === 'already_running') {
isRunning = true;
updateStatusUI('listening', `${frequency} MHz ${modulation.toUpperCase()}`);
startStream();
showNotification('SSTV', `Listening on ${frequency} MHz ${modulation.toUpperCase()}`);
// Update strip
const stripFreq = document.getElementById('sstvGeneralStripFreq');
const stripMod = document.getElementById('sstvGeneralStripMod');
if (stripFreq) stripFreq.textContent = frequency.toFixed(3);
if (stripMod) stripMod.textContent = modulation.toUpperCase();
} else {
updateStatusUI('idle', 'Start failed');
showStatusMessage(data.message || 'Failed to start decoder', 'error');
}
} catch (err) {
console.error('Failed to start SSTV General:', err);
updateStatusUI('idle', 'Error');
showStatusMessage('Connection error: ' + err.message, 'error');
}
}
/**
* Stop SSTV decoder
*/
async function stop() {
try {
await fetch('/sstv-general/stop', { method: 'POST' });
isRunning = false;
stopStream();
updateStatusUI('idle', 'Stopped');
showNotification('SSTV', 'Decoder stopped');
} catch (err) {
console.error('Failed to stop SSTV General:', err);
}
}
/**
* Update status UI elements
*/
function updateStatusUI(status, text) {
const dot = document.getElementById('sstvGeneralStripDot');
const statusText = document.getElementById('sstvGeneralStripStatus');
const startBtn = document.getElementById('sstvGeneralStartBtn');
const stopBtn = document.getElementById('sstvGeneralStopBtn');
if (dot) {
dot.className = 'sstv-general-strip-dot';
if (status === 'listening' || status === 'detecting') {
dot.classList.add('listening');
} else if (status === 'decoding') {
dot.classList.add('decoding');
} else {
dot.classList.add('idle');
}
}
if (statusText) {
statusText.textContent = text || status;
}
if (startBtn && stopBtn) {
if (status === 'listening' || status === 'decoding') {
startBtn.style.display = 'none';
stopBtn.style.display = 'inline-block';
} else {
startBtn.style.display = 'inline-block';
stopBtn.style.display = 'none';
}
}
// Update live content area
const liveContent = document.getElementById('sstvGeneralLiveContent');
if (liveContent) {
if (status === 'idle' || status === 'unavailable') {
liveContent.innerHTML = renderIdleState();
}
}
}
/**
* Render idle state HTML
*/
function renderIdleState() {
return `
<div class="sstv-general-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>SSTV Decoder</h4>
<p>Select a frequency and click Start to listen for SSTV transmissions</p>
</div>
`;
}
/**
* Start SSE stream
*/
function startStream() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/sstv-general/stream');
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === 'sstv_progress') {
handleProgress(data);
}
} catch (err) {
console.error('Failed to parse SSE message:', err);
}
};
eventSource.onerror = () => {
console.warn('SSTV General SSE error, will reconnect...');
setTimeout(() => {
if (isRunning) startStream();
}, 3000);
};
}
/**
* Stop SSE stream
*/
function stopStream() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
/**
* Handle progress update
*/
function handleProgress(data) {
currentMode = data.mode || currentMode;
progress = data.progress || 0;
if (data.status === 'decoding') {
updateStatusUI('decoding', `Decoding ${currentMode || 'image'}...`);
renderDecodeProgress(data);
} else if (data.status === 'complete' && data.image) {
images.unshift(data.image);
updateImageCount(images.length);
renderGallery();
showNotification('SSTV', 'New image decoded!');
updateStatusUI('listening', 'Listening...');
} else if (data.status === 'detecting') {
updateStatusUI('listening', data.message || 'Listening...');
}
}
/**
* Render decode progress in live area
*/
function renderDecodeProgress(data) {
const liveContent = document.getElementById('sstvGeneralLiveContent');
if (!liveContent) return;
liveContent.innerHTML = `
<div class="sstv-general-canvas-container">
<canvas id="sstvGeneralCanvas" width="320" height="256"></canvas>
</div>
<div class="sstv-general-decode-info">
<div class="sstv-general-mode-label">${data.mode || 'Detecting mode...'}</div>
<div class="sstv-general-progress-bar">
<div class="progress" style="width: ${data.progress || 0}%"></div>
</div>
<div class="sstv-general-status-message">${data.message || 'Decoding...'}</div>
</div>
`;
}
/**
* Load decoded images
*/
async function loadImages() {
try {
const response = await fetch('/sstv-general/images');
const data = await response.json();
if (data.status === 'ok') {
images = data.images || [];
updateImageCount(images.length);
renderGallery();
}
} catch (err) {
console.error('Failed to load SSTV General images:', err);
}
}
/**
* Update image count display
*/
function updateImageCount(count) {
const countEl = document.getElementById('sstvGeneralImageCount');
const stripCount = document.getElementById('sstvGeneralStripImageCount');
if (countEl) countEl.textContent = count;
if (stripCount) stripCount.textContent = count;
}
/**
* Render image gallery
*/
function renderGallery() {
const gallery = document.getElementById('sstvGeneralGallery');
if (!gallery) return;
if (images.length === 0) {
gallery.innerHTML = `
<div class="sstv-general-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-general-image-card" onclick="SSTVGeneral.showImage('${escapeHtml(img.url)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-general-image-preview" loading="lazy">
<div class="sstv-general-image-info">
<div class="sstv-general-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
<div class="sstv-general-image-timestamp">${formatTimestamp(img.timestamp)}</div>
</div>
</div>
`).join('');
}
/**
* Show full-size image in modal
*/
function showImage(url) {
let modal = document.getElementById('sstvGeneralImageModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'sstvGeneralImageModal';
modal.className = 'sstv-general-image-modal';
modal.innerHTML = `
<button class="sstv-general-modal-close" onclick="SSTVGeneral.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('sstvGeneralImageModal');
if (modal) modal.classList.remove('show');
}
/**
* Format timestamp for display
*/
function formatTimestamp(isoString) {
if (!isoString) return '--';
try {
const date = new Date(isoString);
return date.toLocaleString();
} catch {
return isoString;
}
}
/**
* Escape HTML for safe display
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Show status message
*/
function showStatusMessage(message, type) {
if (typeof showNotification === 'function') {
showNotification('SSTV', message);
} else {
console.log(`[SSTV General ${type}] ${message}`);
}
}
// Public API
return {
init,
start,
stop,
loadImages,
showImage,
closeImage,
selectPreset
};
})();

View File

@@ -43,6 +43,8 @@
{% else %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
{% endif %}
<!-- Chart.js date adapter for time-scale axes -->
<script src="{{ url_for('static', filename='vendor/chartjs/chartjs-adapter-date-fns.bundle.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
@@ -57,6 +59,7 @@
<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/modes/sstv-general.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>
@@ -183,6 +186,14 @@
<span class="mode-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="mode-name">Meshtastic</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('dmr')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg></span>
<span class="mode-name">Digital Voice</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('websdr')">
<span class="mode-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"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>
<span class="mode-name">WebSDR</span>
</button>
</div>
</div>
@@ -224,6 +235,10 @@
<span class="mode-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="mode-name">ISS SSTV</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('sstv_general')">
<span class="mode-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="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span>
<span class="mode-name">HF SSTV</span>
</button>
</div>
</div>
</div>
@@ -506,6 +521,8 @@
{% include 'partials/modes/sstv.html' %}
{% include 'partials/modes/sstv-general.html' %}
{% include 'partials/modes/listening-post.html' %}
{% include 'partials/modes/tscm.html' %}
@@ -516,6 +533,10 @@
{% include 'partials/modes/meshtastic.html' %}
{% include 'partials/modes/dmr.html' %}
{% include 'partials/modes/websdr.html' %}
<button class="preset-btn" onclick="killAll()"
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
Kill All Processes
@@ -1372,6 +1393,16 @@
<div class="radio-module-box" style="grid-column: span 4; padding: 10px;">
<div id="listeningPostTimelineContainer"></div>
</div>
<!-- WATERFALL / SPECTROGRAM PANEL -->
<div id="waterfallPanel" class="radio-module-box" style="grid-column: span 4; padding: 10px; display: none;">
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
<span>WATERFALL / SPECTROGRAM</span>
<span id="waterfallFreqRange" style="font-size: 9px; color: var(--accent-cyan);"></span>
</div>
<canvas id="spectrumCanvas" width="800" height="120" style="width: 100%; height: 120px; border-radius: 4px; background: rgba(0,0,0,0.8);"></canvas>
<canvas id="waterfallCanvas" width="800" height="400" style="width: 100%; height: 400px; border-radius: 4px; margin-top: 4px; background: rgba(0,0,0,0.9);"></canvas>
</div>
</div>
<!-- Satellite Dashboard (Embedded) -->
@@ -1570,6 +1601,130 @@
</div>
</div>
</div>
<!-- Device Timelines Overview -->
<div class="tscm-panel" id="tscmDeviceTimelinesPanel" style="margin-top: 12px;">
<div class="tscm-panel-header" style="display: flex; justify-content: space-between; align-items: center;">
Device Timelines
<button class="preset-btn" onclick="loadDeviceTimelines()" style="font-size: 9px; padding: 3px 8px;">Refresh</button>
</div>
<div class="tscm-panel-content" id="tscmDeviceTimelinesList">
<div class="tscm-empty">Run a sweep to see device timelines</div>
</div>
</div>
</div>
<!-- DMR / Digital Voice Dashboard -->
<div id="dmrVisuals" style="display: none; padding: 12px; flex-direction: column; gap: 12px; flex: 1; min-height: 0;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<!-- Call History Panel -->
<div class="radio-module-box" style="padding: 10px;">
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
<span>CALL HISTORY</span>
<span id="dmrHistoryCount" style="color: var(--accent-cyan);">0 calls</span>
</div>
<div style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; font-size: 10px; border-collapse: collapse;">
<thead>
<tr style="color: var(--text-muted); border-bottom: 1px solid var(--border-color);">
<th style="text-align: left; padding: 4px;">Time</th>
<th style="text-align: left; padding: 4px;">Talkgroup</th>
<th style="text-align: left; padding: 4px;">Source</th>
<th style="text-align: left; padding: 4px;">Protocol</th>
</tr>
</thead>
<tbody id="dmrHistoryBody">
<tr><td colspan="4" style="padding: 15px; text-align: center; color: var(--text-muted);">No calls recorded</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Protocol Info Panel -->
<div class="radio-module-box" style="padding: 10px;">
<div class="module-header" style="margin-bottom: 8px; font-size: 10px;">
<span>DECODER STATUS</span>
</div>
<div style="display: flex; flex-direction: column; gap: 12px; padding: 10px;">
<div style="text-align: center;">
<div style="font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 2px; margin-bottom: 4px;">PROTOCOL</div>
<div id="dmrMainProtocol" style="font-size: 28px; font-weight: bold; color: var(--accent-cyan); font-family: var(--font-mono);">--</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<div style="text-align: center; padding: 8px; background: rgba(0,0,0,0.3); border-radius: 4px;">
<div style="font-size: 9px; color: var(--text-muted);">CALLS</div>
<div id="dmrMainCallCount" style="font-size: 22px; font-weight: bold; color: var(--accent-green); font-family: var(--font-mono);">0</div>
</div>
<div style="text-align: center; padding: 8px; background: rgba(0,0,0,0.3); border-radius: 4px;">
<div style="font-size: 9px; color: var(--text-muted);">SYNCS</div>
<div id="dmrSyncCount" style="font-size: 22px; font-weight: bold; color: var(--accent-orange); font-family: var(--font-mono);">0</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- WebSDR Dashboard -->
<div id="websdrVisuals" style="display: none; padding: 12px; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow: hidden;">
<!-- Audio Control Bar (hidden until connected) -->
<div id="kiwiAudioControls" class="radio-module-box" style="display: none; padding: 8px 12px; flex-shrink: 0;">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<!-- Live indicator -->
<div style="display: flex; align-items: center; gap: 5px;">
<div id="kiwiLiveIndicator" style="width: 8px; height: 8px; border-radius: 50%; background: var(--accent-green); animation: pulse 1.5s infinite;"></div>
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">LIVE</span>
</div>
<!-- Receiver name -->
<span id="kiwiBarReceiverName" style="font-size: 11px; color: var(--accent-cyan); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"></span>
<!-- Frequency input -->
<div style="display: flex; align-items: center; gap: 4px;">
<input type="number" id="kiwiBarFrequency" step="1" style="width: 80px; font-size: 12px; font-family: var(--font-mono); padding: 2px 6px; background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
<span style="font-size: 10px; color: var(--text-muted);">kHz</span>
</div>
<!-- Mode selector -->
<select id="kiwiBarMode" style="font-size: 11px; padding: 2px 6px; background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
<option value="am">AM</option>
<option value="usb">USB</option>
<option value="lsb">LSB</option>
<option value="cw">CW</option>
</select>
<!-- Tune button -->
<button class="preset-btn" onclick="tuneFromBar()" style="font-size: 10px; padding: 3px 10px;">Tune</button>
<!-- Volume -->
<div style="display: flex; align-items: center; gap: 4px;">
<span style="font-size: 10px; color: var(--text-muted);">VOL</span>
<input type="range" id="kiwiBarVolume" min="0" max="100" value="80" style="width: 60px;" oninput="setKiwiVolume(this.value)">
</div>
<!-- S-meter mini -->
<div style="display: flex; align-items: center; gap: 4px;">
<div style="width: 50px; height: 6px; background: rgba(0,0,0,0.5); border-radius: 3px; overflow: hidden;">
<div id="kiwiBarSmeter" style="height: 100%; width: 0%; background: linear-gradient(to right, var(--accent-green), var(--accent-orange)); transition: width 0.2s; border-radius: 3px;"></div>
</div>
<span id="kiwiBarSmeterValue" style="font-size: 9px; color: var(--text-muted); font-family: var(--font-mono); min-width: 20px;">S0</span>
</div>
<!-- Disconnect -->
<button class="stop-btn" onclick="disconnectFromReceiver()" style="font-size: 10px; padding: 3px 10px; margin-left: auto;">Disconnect</button>
</div>
</div>
<!-- Map and receiver list side by side -->
<div style="display: grid; grid-template-columns: 3fr 1fr; gap: 12px; flex: 1; min-height: 0; overflow: hidden;">
<!-- Map -->
<div class="radio-module-box" style="padding: 0; overflow: hidden; position: relative; min-height: 0;">
<div id="websdrMap" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0;"></div>
</div>
<!-- Receiver List -->
<div style="display: flex; flex-direction: column; gap: 12px; min-width: 0; min-height: 0; overflow: hidden;">
<div class="radio-module-box" style="padding: 10px; flex: 1; overflow-y: auto; min-height: 0;">
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
<span>RECEIVERS</span>
<span id="websdrReceiverCount" style="color: var(--accent-cyan);">0 found</span>
</div>
<div id="websdrReceiverList" style="font-size: 11px;">
<div style="color: var(--text-muted); text-align: center; padding: 20px;">Click "Find Receivers" to search</div>
</div>
</div>
</div>
</div>
</div>
<!-- Spy Stations Dashboard -->
@@ -1880,6 +2035,88 @@
</div>
</div>
<!-- SSTV General Decoder Dashboard -->
<div id="sstvGeneralVisuals" class="sstv-general-visuals-container" style="display: none;">
<!-- Status Strip -->
<div class="sstv-general-stats-strip">
<div class="sstv-general-strip-group">
<div class="sstv-general-strip-status">
<span class="sstv-general-strip-dot idle" id="sstvGeneralStripDot"></span>
<span class="sstv-general-strip-status-text" id="sstvGeneralStripStatus">Idle</span>
</div>
<button class="sstv-general-strip-btn start" id="sstvGeneralStartBtn" onclick="SSTVGeneral.start()">Start</button>
<button class="sstv-general-strip-btn stop" id="sstvGeneralStopBtn" onclick="SSTVGeneral.stop()" style="display: none;">Stop</button>
</div>
<div class="sstv-general-strip-divider"></div>
<div class="sstv-general-strip-group">
<div class="sstv-general-strip-stat">
<span class="sstv-general-strip-value accent-cyan" id="sstvGeneralStripFreq">14.230</span>
<span class="sstv-general-strip-label">MHZ</span>
</div>
<div class="sstv-general-strip-stat">
<span class="sstv-general-strip-value" id="sstvGeneralStripMod">USB</span>
<span class="sstv-general-strip-label">MOD</span>
</div>
<div class="sstv-general-strip-stat">
<span class="sstv-general-strip-value" id="sstvGeneralStripImageCount">0</span>
<span class="sstv-general-strip-label">IMAGES</span>
</div>
</div>
</div>
<!-- Main Row (Live + Gallery) -->
<div class="sstv-general-main-row">
<!-- Live Decode Section -->
<div class="sstv-general-live-section">
<div class="sstv-general-live-header">
<div class="sstv-general-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-general-live-content" id="sstvGeneralLiveContent">
<div class="sstv-general-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>SSTV Decoder</h4>
<p>Select a frequency and click Start to listen for SSTV transmissions</p>
</div>
</div>
</div>
<!-- Gallery Section -->
<div class="sstv-general-gallery-section">
<div class="sstv-general-gallery-header">
<div class="sstv-general-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-general-gallery-count" id="sstvGeneralImageCount">0</span>
</div>
<div class="sstv-general-gallery-grid" id="sstvGeneralGallery">
<div class="sstv-general-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;">
@@ -1967,6 +2204,9 @@
<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 src="{{ url_for('static', filename='js/modes/sstv-general.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/dmr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
<script>
// ============================================
@@ -2102,7 +2342,7 @@
const validModes = new Set([
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
'spystations', 'meshtastic', 'wifi', 'bluetooth',
'tscm', 'satellite', 'sstv'
'tscm', 'satellite', 'sstv', 'sstv_general', 'dmr', 'websdr'
]);
function getModeFromQuery() {
@@ -2524,7 +2764,7 @@
'tscm': 'security',
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
'meshtastic': 'sdr',
'satellite': 'space', 'sstv': 'space'
'satellite': 'space', 'sstv': 'space', 'sstv_general': 'space'
};
// Remove has-active from all dropdowns
@@ -2593,7 +2833,8 @@
const modeMap = {
'pager': 'pager', 'sensor': '433',
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
'listening': 'listening', 'aprs': 'aprs', 'tscm': 'tscm', 'meshtastic': 'meshtastic'
'listening': 'listening', 'aprs': 'aprs', 'tscm': 'tscm', 'meshtastic': 'meshtastic',
'dmr': 'dmr', 'websdr': 'websdr', 'sstv_general': 'hf sstv'
};
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
const label = btn.querySelector('.nav-label');
@@ -2606,6 +2847,7 @@
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('sstvGeneralMode')?.classList.toggle('active', mode === 'sstv_general');
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
document.getElementById('listeningPostMode')?.classList.toggle('active', mode === 'listening');
@@ -2614,6 +2856,8 @@
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
document.getElementById('dmrMode')?.classList.toggle('active', mode === 'dmr');
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
const pagerStats = document.getElementById('pagerStats');
const sensorStats = document.getElementById('sensorStats');
const satelliteStats = document.getElementById('satelliteStats');
@@ -2640,6 +2884,7 @@
'rtlamr': 'METERS',
'satellite': 'SATELLITE',
'sstv': 'ISS SSTV',
'sstv_general': 'HF SSTV',
'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST',
@@ -2647,7 +2892,9 @@
'tscm': 'TSCM',
'ais': 'AIS VESSELS',
'spystations': 'SPY STATIONS',
'meshtastic': 'MESHTASTIC'
'meshtastic': 'MESHTASTIC',
'dmr': 'DIGITAL VOICE',
'websdr': 'WEBSDR'
};
const activeModeIndicator = document.getElementById('activeModeIndicator');
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
@@ -2660,6 +2907,9 @@
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
const meshtasticVisuals = document.getElementById('meshtasticVisuals');
const sstvVisuals = document.getElementById('sstvVisuals');
const sstvGeneralVisuals = document.getElementById('sstvGeneralVisuals');
const dmrVisuals = document.getElementById('dmrVisuals');
const websdrVisuals = document.getElementById('websdrVisuals');
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';
@@ -2669,6 +2919,9 @@
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';
if (sstvGeneralVisuals) sstvGeneralVisuals.style.display = mode === 'sstv_general' ? 'flex' : 'none';
if (dmrVisuals) dmrVisuals.style.display = mode === 'dmr' ? 'flex' : 'none';
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
// Hide sidebar by default for Meshtastic mode, show for others
const mainContent = document.querySelector('.main-content');
@@ -2693,6 +2946,7 @@
'rtlamr': 'Utility Meter Monitor',
'satellite': 'Satellite Monitor',
'sstv': 'ISS SSTV Decoder',
'sstv_general': 'HF SSTV Decoder',
'wifi': 'WiFi Scanner',
'bluetooth': 'Bluetooth Scanner',
'listening': 'Listening Post',
@@ -2700,7 +2954,9 @@
'tscm': 'TSCM Counter-Surveillance',
'ais': 'AIS Vessel Tracker',
'spystations': 'Spy Stations',
'meshtastic': 'Meshtastic Mesh Monitor'
'meshtastic': 'Meshtastic Mesh Monitor',
'dmr': 'Digital Voice Decoder',
'websdr': 'HF/Shortwave WebSDR'
};
const outputTitle = document.getElementById('outputTitle');
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
@@ -2718,7 +2974,7 @@
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'sstv' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') {
if (mode === 'satellite' || mode === 'sstv' || mode === 'sstv_general' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') {
if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -2738,7 +2994,7 @@
// Show RTL-SDR device section for modes that use it
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv') ? 'block' : 'none';
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none';
// Toggle mode-specific tool status displays
const toolStatusPager = document.getElementById('toolStatusPager');
@@ -2749,8 +3005,8 @@
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite') ? 'none' : 'flex';
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
if (mode !== 'meshtastic') {
@@ -2797,6 +3053,12 @@
}, 100);
} else if (mode === 'sstv') {
SSTV.init();
} else if (mode === 'sstv_general') {
SSTVGeneral.init();
} else if (mode === 'dmr') {
if (typeof checkDmrTools === 'function') checkDmrTools();
} else if (mode === 'websdr') {
if (typeof initWebSDR === 'function') initWebSDR();
}
}
@@ -11067,6 +11329,23 @@
`;
}
// Signal Timeline Chart
html += `
<div class="device-detail-section">
<h4>Signal Timeline</h4>
<canvas id="deviceTimelineChart" width="600" height="180" style="width: 100%; max-height: 180px;"></canvas>
<div id="deviceTimelineMetrics" style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px;"></div>
</div>
`;
// Playbook section
html += `
<div class="device-detail-section" id="devicePlaybookSection" style="display: none;">
<h4>Recommended Playbook</h4>
<div id="devicePlaybookContent"></div>
</div>
`;
// Add disclaimer
html += `
<div class="device-detail-disclaimer">
@@ -11081,6 +11360,21 @@
const timelineIdentifier = tscmNormalizeIdentifier(id, protocol, device);
loadTscmTimeline(timelineIdentifier, protocol);
loadTscmAdvancedAnalysis(device, protocol);
// Load timeline chart
fetchDeviceTimelineChart(timelineIdentifier, protocol);
// Load playbook for this device
fetchDevicePlaybook(timelineIdentifier).then(playbook => {
if (playbook) {
const section = document.getElementById('devicePlaybookSection');
const pbContent = document.getElementById('devicePlaybookContent');
if (section && pbContent) {
section.style.display = 'block';
pbContent.innerHTML = renderPlaybook(playbook);
}
}
});
}
function tscmNormalizeIdentifier(identifier, protocol, device) {
@@ -11437,6 +11731,152 @@
}
}
async function loadDeviceTimelines() {
const container = document.getElementById('tscmDeviceTimelinesList');
if (!container) return;
container.innerHTML = '<div class="tscm-empty">Loading timelines...</div>';
try {
const response = await fetch('/tscm/timelines');
const data = await response.json();
if (data.status !== 'success' || !data.timelines || data.timelines.length === 0) {
container.innerHTML = '<div class="tscm-empty">No device timelines available</div>';
return;
}
const timelines = data.timelines;
let html = '';
timelines.forEach(tl => {
const identifier = tl.identifier || 'Unknown';
const protocol = tl.protocol || 'unknown';
const presencePct = tl.presence_ratio !== undefined ? Math.round(tl.presence_ratio * 100) : 0;
const pattern = tl.movement_pattern || 'UNKNOWN';
const patternColors = { 'STATIONARY': '#00e676', 'MOBILE': '#ff3366', 'INTERMITTENT': '#ff9800' };
const pColor = patternColors[pattern] || '#9e9e9e';
// Create a compact swim-lane row
html += `
<div style="display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; font-size: 10px;"
onclick="tscmShowInvestigateById('${escapeHtml(identifier)}', '${escapeHtml(protocol)}')">
<span style="width: 12px; text-transform: uppercase; color: var(--text-muted); font-size: 8px;">${protocol.charAt(0).toUpperCase()}</span>
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-primary); font-family: var(--font-mono);">${escapeHtml(identifier)}</span>
<div style="width: 100px; height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden;">
<div style="width: ${presencePct}%; height: 100%; background: var(--accent-cyan); border-radius: 3px;"></div>
</div>
<span style="width: 35px; text-align: right; color: var(--accent-cyan);">${presencePct}%</span>
<span style="padding: 1px 6px; background: ${pColor}22; color: ${pColor}; border-radius: 3px; font-size: 8px; font-weight: bold;">${pattern}</span>
</div>
`;
});
container.innerHTML = html;
} catch (e) {
console.error('Failed to load device timelines:', e);
container.innerHTML = '<div class="tscm-empty">Failed to load timelines</div>';
}
}
let deviceTimelineChartInstance = null;
async function fetchDeviceTimelineChart(identifier, protocol) {
try {
const response = await fetch(`/tscm/device/${encodeURIComponent(identifier)}/timeline?protocol=${encodeURIComponent(protocol)}&since_hours=24`);
const data = await response.json();
if (data.status !== 'success' || !data.timeline) return;
const timeline = data.timeline;
const observations = timeline.observations || [];
const metrics = timeline.metrics || {};
const signal = timeline.signal || {};
const movement = timeline.movement || {};
// Render Chart.js RSSI timeline
const canvas = document.getElementById('deviceTimelineChart');
if (canvas && typeof Chart !== 'undefined' && observations.length > 0) {
if (deviceTimelineChartInstance) {
deviceTimelineChartInstance.destroy();
}
const chartData = observations.map(o => ({
x: new Date(o.timestamp),
y: o.rssi !== null && o.rssi !== undefined ? o.rssi : null,
})).filter(d => d.y !== null);
const pointColors = chartData.map(d => d.y !== null ? 'rgba(0, 230, 118, 0.8)' : 'rgba(158, 158, 158, 0.5)');
deviceTimelineChartInstance = new Chart(canvas, {
type: 'line',
data: {
datasets: [{
label: 'RSSI (dBm)',
data: chartData,
borderColor: 'rgba(0, 212, 255, 0.8)',
backgroundColor: 'rgba(0, 212, 255, 0.1)',
fill: true,
pointBackgroundColor: pointColors,
pointRadius: 3,
pointHoverRadius: 5,
tension: 0.3,
borderWidth: 1.5,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
type: 'time',
time: { unit: 'hour', displayFormats: { hour: 'ha', minute: 'h:mm a' } },
ticks: { color: 'rgba(255,255,255,0.5)', font: { size: 9 } },
grid: { color: 'rgba(255,255,255,0.05)' },
},
y: {
title: { display: true, text: 'dBm', color: 'rgba(255,255,255,0.5)', font: { size: 9 } },
ticks: { color: 'rgba(255,255,255,0.5)', font: { size: 9 } },
grid: { color: 'rgba(255,255,255,0.05)' },
}
}
}
});
}
// Render metrics badges
renderTimelineMetrics(metrics, signal, movement);
} catch (e) {
console.error('Failed to load device timeline chart:', e);
}
}
function renderTimelineMetrics(metrics, signal, movement) {
const container = document.getElementById('deviceTimelineMetrics');
if (!container) return;
const badges = [];
if (metrics.total_observations !== undefined) {
badges.push(`<span style="padding: 4px 8px; background: rgba(0,212,255,0.15); color: var(--accent-cyan); border-radius: 4px; font-size: 10px;">${metrics.total_observations} observations</span>`);
}
if (metrics.presence_ratio !== undefined) {
const pct = Math.round(metrics.presence_ratio * 100);
badges.push(`<span style="padding: 4px 8px; background: rgba(0,230,118,0.15); color: #00e676; border-radius: 4px; font-size: 10px;">${pct}% presence</span>`);
}
if (signal.rssi_min !== undefined && signal.rssi_max !== undefined && signal.rssi_min !== null) {
badges.push(`<span style="padding: 4px 8px; background: rgba(255,152,0,0.15); color: #ff9800; border-radius: 4px; font-size: 10px;">${signal.rssi_min} to ${signal.rssi_max} dBm</span>`);
}
if (signal.stability !== undefined && signal.stability !== null) {
badges.push(`<span style="padding: 4px 8px; background: rgba(156,39,176,0.15); color: #ce93d8; border-radius: 4px; font-size: 10px;">${Math.round(signal.stability * 100)}% stability</span>`);
}
if (movement.pattern) {
const patternColors = { 'STATIONARY': '#00e676', 'MOBILE': '#ff3366', 'INTERMITTENT': '#ff9800' };
const color = patternColors[movement.pattern] || '#9e9e9e';
badges.push(`<span style="padding: 4px 8px; background: ${color}22; color: ${color}; border-radius: 4px; font-size: 10px; font-weight: bold;">${movement.pattern}</span>`);
}
container.innerHTML = badges.join('');
}
async function loadTscmAdvancedAnalysis(device, protocol) {
if (protocol === 'wifi') {
const section = document.getElementById('tscmWifiAdvancedSection');
@@ -13247,54 +13687,102 @@
if (data.status === 'success') {
const p = data.playbook;
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = `
<div class="device-detail-header classification-orange">
<h3>${escapeHtml(p.title || p.name || 'Playbook')}</h3>
</div>
<div class="device-detail-section">
<p>${escapeHtml(p.description || '')}</p>
</div>
<div class="device-detail-section">
<h4>Steps</h4>
<ol class="playbook-steps">
${(p.steps || []).map((step, i) => `
<li class="playbook-step">
<strong>${escapeHtml(step.action || step.title || `Step ${step.step || i + 1}`)}</strong>
<p>${escapeHtml(step.details || step.description || '')}</p>
${step.safety_note ? `<div class="playbook-warning">${escapeHtml(step.safety_note)}</div>` : ''}
</li>
`).join('')}
</ol>
</div>
${p.when_to_escalate ? `
<div class="device-detail-section">
<h4>When to Escalate</h4>
<p>${escapeHtml(p.when_to_escalate)}</p>
</div>
` : ''}
${p.documentation_required && p.documentation_required.length > 0 ? `
<div class="device-detail-section">
<h4>Documentation Required</h4>
<ul>
${p.documentation_required.map(d => `<li>${escapeHtml(d)}</li>`).join('')}
</ul>
</div>
` : ''}
${p.disclaimer ? `
<div class="device-detail-disclaimer">
<strong>Disclaimer:</strong> ${escapeHtml(p.disclaimer)}
</div>
` : ''}
<div style="margin-top: 16px;">
<button class="preset-btn" onclick="tscmShowPlaybooks()">← Back to Playbooks</button>
</div>
`;
content.innerHTML = renderPlaybook(p);
}
} catch (e) {
console.error('Failed to view playbook:', e);
}
}
function renderPlaybook(p) {
const riskColors = { 'critical': '#ff3366', 'high': '#ff6633', 'medium': '#ff9800', 'low': '#4caf50' };
const riskColor = riskColors[(p.risk_level || '').toLowerCase()] || '#ff9800';
return `
<div class="device-detail-header" style="border-left: 4px solid ${riskColor};">
<h3>${escapeHtml(p.title || p.name || 'Playbook')}</h3>
<span style="font-size: 10px; background: ${riskColor}; color: #000; padding: 2px 8px; border-radius: 3px; font-weight: bold; text-transform: uppercase;">${escapeHtml(p.risk_level || 'MEDIUM')}</span>
</div>
<div class="device-detail-section">
<p style="color: var(--text-muted);">${escapeHtml(p.description || '')}</p>
</div>
${p.when_to_escalate ? `
<div style="margin: 12px 0; padding: 10px 14px; background: rgba(255,51,102,0.1); border: 1px solid rgba(255,51,102,0.4); border-radius: 6px;">
<strong style="color: #ff3366; font-size: 11px; text-transform: uppercase;">Escalation Trigger</strong>
<p style="margin: 4px 0 0; font-size: 12px; color: #ff6666;">${escapeHtml(p.when_to_escalate)}</p>
</div>
` : ''}
<div class="device-detail-section">
<h4 style="margin-bottom: 10px;">Investigation Steps</h4>
<div class="playbook-checklist">
${(p.steps || []).map((step, i) => {
const stepNum = step.step_number || step.step || (i + 1);
return `
<div class="playbook-check-step" id="pbStep${i}" style="display: flex; gap: 10px; padding: 10px; margin-bottom: 8px; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; transition: border-color 0.3s;" onclick="togglePlaybookStep(${i})">
<div style="flex-shrink: 0; display: flex; align-items: flex-start; padding-top: 2px;">
<input type="checkbox" id="pbCheck${i}" style="width: 16px; height: 16px; accent-color: #00e676; cursor: pointer;" onclick="event.stopPropagation(); togglePlaybookStep(${i})">
</div>
<div style="flex: 1;">
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px;">
<span style="font-size: 10px; color: var(--accent-cyan); font-weight: bold;">STEP ${stepNum}</span>
<strong style="font-size: 12px;">${escapeHtml(step.action || step.title || '')}</strong>
</div>
<p style="font-size: 11px; color: var(--text-muted); margin: 0;">${escapeHtml(step.details || step.description || '')}</p>
${step.safety_note ? `<div style="margin-top: 6px; padding: 6px 8px; background: rgba(255,152,0,0.1); border-left: 3px solid #ff9800; border-radius: 3px; font-size: 10px; color: #ffb74d;"><strong>Safety:</strong> ${escapeHtml(step.safety_note)}</div>` : ''}
${step.evidence_needed && step.evidence_needed.length > 0 ? `<div style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"><strong>Evidence needed:</strong> ${step.evidence_needed.map(e => escapeHtml(e)).join(', ')}</div>` : ''}
</div>
</div>`;
}).join('')}
</div>
</div>
${p.documentation_required && p.documentation_required.length > 0 ? `
<div class="device-detail-section">
<h4>Documentation Required</h4>
<ul style="list-style: none; padding: 0;">
${p.documentation_required.map(d => `<li style="padding: 4px 0; font-size: 11px; color: var(--text-secondary);">&#9744; ${escapeHtml(d)}</li>`).join('')}
</ul>
</div>
` : ''}
${p.disclaimer ? `
<div class="device-detail-disclaimer">
<strong>Disclaimer:</strong> ${escapeHtml(p.disclaimer)}
</div>
` : ''}
<div style="margin-top: 16px;">
<button class="preset-btn" onclick="tscmShowPlaybooks()">&#8592; Back to Playbooks</button>
</div>
`;
}
function togglePlaybookStep(index) {
const checkbox = document.getElementById('pbCheck' + index);
const stepEl = document.getElementById('pbStep' + index);
if (!checkbox || !stepEl) return;
// Toggle if triggered from the row (not the checkbox itself)
if (document.activeElement !== checkbox) {
checkbox.checked = !checkbox.checked;
}
if (checkbox.checked) {
stepEl.style.borderColor = '#00e676';
stepEl.style.background = 'rgba(0, 230, 118, 0.05)';
} else {
stepEl.style.borderColor = 'var(--border-color)';
stepEl.style.background = 'rgba(0,0,0,0.2)';
}
}
async function fetchDevicePlaybook(identifier) {
try {
const response = await fetch(`/tscm/findings/${encodeURIComponent(identifier)}/playbook`);
const data = await response.json();
if (data.status === 'success' && data.playbook) {
return data.playbook;
}
} catch (e) {
console.error('Failed to fetch device playbook:', e);
}
return null;
}
// Report Downloads
async function tscmDownloadPdf() {
try {
@@ -13530,6 +14018,10 @@
scanner</span></div>
<div class="icon-item"><span class="icon">🔍</span><span class="desc">TSCM -
Counter-surveillance</span></div>
<div class="icon-item"><span class="icon">📺</span><span class="desc">ISS SSTV - Space station
images</span></div>
<div class="icon-item"><span class="icon">📺</span><span class="desc">HF SSTV - Terrestrial
SSTV images</span></div>
</div>
</div>
@@ -13649,6 +14141,26 @@
<li>View connected nodes and message history</li>
<li>Requires: Meshtastic device + <code>pip install meshtastic</code></li>
</ul>
<h3>ISS SSTV Mode</h3>
<ul class="tip-list">
<li>Decode Slow-Scan Television images from the International Space Station</li>
<li>ISS transmits on 145.800 MHz FM during special ARISS events</li>
<li>Real-time ISS tracking map with ground track overlay</li>
<li>Next-pass countdown with elevation and duration predictions</li>
<li>Optional Doppler shift compensation for improved reception</li>
<li>Requires: <code>slowrx</code> decoder + RTL-SDR</li>
</ul>
<h3>HF SSTV Mode</h3>
<ul class="tip-list">
<li>Decode terrestrial SSTV images on HF/VHF/UHF amateur radio frequencies</li>
<li>Predefined frequencies: 14.230 MHz USB (20m, most popular), 3.845/7.171 MHz LSB, and more</li>
<li>Supports USB, LSB, and FM demodulation modes</li>
<li>Auto-detects correct modulation when selecting a preset frequency</li>
<li>HF frequencies (below 30 MHz) require an upconverter with RTL-SDR</li>
<li>Requires: <code>slowrx</code> decoder + RTL-SDR (+ upconverter for HF)</li>
</ul>
</div>
<!-- WiFi Section -->

View File

@@ -0,0 +1,86 @@
<!-- SSTV GENERAL MODE -->
<div id="sstvGeneralMode" class="mode-content">
<div class="section">
<h3>SSTV Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Decode Slow-Scan Television images on common amateur radio HF/VHF/UHF frequencies.
Select a predefined frequency or enter a custom one.
</p>
<p class="info-text" style="font-size: 10px; color: var(--accent-yellow); margin-bottom: 8px;">
Note: HF frequencies (below 30 MHz) require an upconverter with RTL-SDR.
</p>
</div>
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Preset Frequency</label>
<select id="sstvGeneralPresetFreq" onchange="SSTVGeneral.selectPreset(this.value)" style="width: 100%; padding: 6px 8px; font-family: var(--font-mono); font-size: 11px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
<option value="">-- Select frequency --</option>
<optgroup label="80 m (HF)">
<option value="3.845|lsb">3.845 MHz LSB - US calling</option>
<option value="3.730|lsb">3.730 MHz LSB - Europe primary</option>
</optgroup>
<optgroup label="40 m (HF)">
<option value="7.171|lsb">7.171 MHz LSB - International</option>
<option value="7.040|lsb">7.040 MHz LSB - Alt US/EU</option>
</optgroup>
<optgroup label="30 m (HF)">
<option value="10.132|usb">10.132 MHz USB - Narrowband</option>
</optgroup>
<optgroup label="20 m (HF)">
<option value="14.230|usb">14.230 MHz USB - Most popular</option>
<option value="14.233|usb">14.233 MHz USB - Digital SSTV</option>
<option value="14.240|usb">14.240 MHz USB - Europe alt</option>
</optgroup>
<optgroup label="15 m (HF)">
<option value="21.340|usb">21.340 MHz USB - International</option>
</optgroup>
<optgroup label="10 m (HF)">
<option value="28.680|usb">28.680 MHz USB - International</option>
</optgroup>
<optgroup label="6 m (VHF)">
<option value="50.950|usb">50.950 MHz USB - SSTV calling</option>
</optgroup>
<optgroup label="2 m (VHF)">
<option value="145.625|fm">145.625 MHz FM - Simplex</option>
</optgroup>
<optgroup label="70 cm (UHF)">
<option value="433.775|fm">433.775 MHz FM - Simplex</option>
</optgroup>
</select>
</div>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="sstvGeneralFrequency" value="14.230" step="0.001" min="1" max="500">
</div>
<div class="form-group">
<label>Modulation</label>
<select id="sstvGeneralModulation" style="width: 100%; padding: 6px 8px; font-family: var(--font-mono); font-size: 11px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
<option value="usb">USB (Upper Sideband)</option>
<option value="lsb">LSB (Lower Sideband)</option>
<option value="fm">FM (Frequency Modulation)</option>
</select>
</div>
</div>
<div class="section">
<h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">
<a href="https://www.sigidwiki.com/wiki/Slow-Scan_Television_(SSTV)" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
SigID Wiki - SSTV
</a>
</div>
</div>
<div class="section">
<h3>About Terrestrial SSTV</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim);">
Amateur radio operators transmit SSTV images on HF bands worldwide.
The most popular frequency is 14.230 MHz USB on the 20m band.
</p>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: 8px;">
Common modes: PD120, PD180, Martin1, Scottie1, Robot36
</p>
</div>
</div>

View File

@@ -71,6 +71,8 @@
{{ mode_item('listening', 'Listening Post', '<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>') }}
{{ mode_item('spystations', 'Spy Stations', '<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>') }}
{{ mode_item('meshtastic', 'Meshtastic', '<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>') }}
{{ mode_item('dmr', 'Digital Voice', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>') }}
{{ mode_item('websdr', 'WebSDR', '<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"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
</div>
</div>
@@ -116,6 +118,7 @@
{{ mode_item('satellite', 'Satellite', '<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>', '/satellite/dashboard') }}
{% endif %}
{{ mode_item('sstv', 'ISS SSTV', '<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>') }}
{{ mode_item('sstv_general', 'HF SSTV', '<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="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
</div>
</div>
@@ -182,9 +185,12 @@
{{ mobile_item('satellite', 'Sat', '<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>', '/satellite/dashboard') }}
{% endif %}
{{ mobile_item('sstv', 'SSTV', '<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>') }}
{{ mobile_item('sstv_general', 'HF SSTV', '<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>') }}
{{ mobile_item('listening', 'Scanner', '<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>') }}
{{ mobile_item('spystations', 'Spy', '<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>') }}
{{ mobile_item('meshtastic', 'Mesh', '<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>') }}
{{ mobile_item('dmr', 'DMR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>') }}
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
</nav>
{# JavaScript stub for pages that don't have switchMode defined #}

View File

@@ -181,6 +181,7 @@ class SSTVImage:
timestamp: datetime
frequency: float
size_bytes: int = 0
url_prefix: str = '/sstv'
def to_dict(self) -> dict:
return {
@@ -190,7 +191,7 @@ class SSTVImage:
'timestamp': self.timestamp.isoformat(),
'frequency': self.frequency,
'size_bytes': self.size_bytes,
'url': f'/sstv/images/{self.filename}'
'url': f'{self.url_prefix}/images/{self.filename}'
}
@@ -227,29 +228,31 @@ class SSTVDecoder:
# How often to check/update Doppler (seconds)
DOPPLER_UPDATE_INTERVAL = 5
def __init__(self, output_dir: str | Path | None = None):
def __init__(self, output_dir: str | Path | None = None, url_prefix: str = '/sstv'):
self._process = None
self._rtl_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._url_prefix = url_prefix
self._images: list[SSTVImage] = []
self._reader_thread = None
self._watcher_thread = None
self._doppler_thread = None
self._frequency = ISS_SSTV_FREQ
self._modulation = 'fm'
self._current_tuned_freq_hz: int = 0
self._device_index = 0
# Doppler tracking
self._doppler_tracker = DopplerTracker('ISS')
self._doppler_enabled = False
self._last_doppler_info: DopplerInfo | None = None
self._file_decoder: str | None = None
# Ensure output directory exists
self._output_dir.mkdir(parents=True, exist_ok=True)
self._last_doppler_info: DopplerInfo | None = None
self._file_decoder: str | None = None
# Ensure output directory exists
self._output_dir.mkdir(parents=True, exist_ok=True)
# Detect available decoder
self._decoder = self._detect_decoder()
@@ -266,23 +269,23 @@ class SSTVDecoder:
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:
self._file_decoder = 'slowrx'
return 'slowrx'
except Exception:
pass
try:
result = subprocess.run(['which', 'slowrx'], capture_output=True, timeout=5)
if result.returncode == 0:
self._file_decoder = 'slowrx'
return 'slowrx'
except Exception:
pass
# Note: qsstv is GUI-only and not suitable for headless/server operation
# Check for Python sstv package
try:
import sstv
self._file_decoder = 'python-sstv'
return None
except ImportError:
pass
# Check for Python sstv package
try:
import sstv
self._file_decoder = 'python-sstv'
return None
except ImportError:
pass
logger.warning("No SSTV decoder found. Install slowrx (apt install slowrx) or python sstv package. Note: qsstv is GUI-only and not supported for headless operation.")
return None
@@ -297,6 +300,7 @@ class SSTVDecoder:
device_index: int = 0,
latitude: float | None = None,
longitude: float | None = None,
modulation: str = 'fm',
) -> bool:
"""
Start SSTV decoder listening on specified frequency.
@@ -306,6 +310,7 @@ class SSTVDecoder:
device_index: RTL-SDR device index
latitude: Observer latitude for Doppler correction (optional)
longitude: Observer longitude for Doppler correction (optional)
modulation: Demodulation mode for rtl_fm (fm, usb, lsb). Default: fm
Returns:
True if started successfully
@@ -324,6 +329,7 @@ class SSTVDecoder:
self._frequency = frequency
self._device_index = device_index
self._modulation = modulation
# Configure Doppler tracking if location provided
self._doppler_enabled = False
@@ -399,12 +405,12 @@ class SSTVDecoder:
def _start_rtl_fm_pipeline(self, freq_hz: int) -> None:
"""Start the rtl_fm -> slowrx pipeline at the specified frequency."""
# Build rtl_fm command for FM demodulation
# Build rtl_fm command for demodulation
rtl_cmd = [
'rtl_fm',
'-d', str(self._device_index),
'-f', str(freq_hz),
'-M', 'fm',
'-M', self._modulation,
'-s', '48000',
'-r', '48000',
'-l', '0', # No squelch
@@ -517,7 +523,7 @@ class SSTVDecoder:
'rtl_fm',
'-d', str(self._device_index),
'-f', str(new_freq_hz),
'-M', 'fm',
'-M', self._modulation,
'-s', '48000',
'-r', '48000',
'-l', '0',
@@ -607,7 +613,8 @@ class SSTVDecoder:
mode='Unknown', # Would need to parse from slowrx output
timestamp=datetime.now(timezone.utc),
frequency=self._frequency,
size_bytes=filepath.stat().st_size
size_bytes=filepath.stat().st_size,
url_prefix=self._url_prefix,
)
self._images.append(image)
@@ -665,8 +672,9 @@ class SSTVDecoder:
path=filepath,
mode='Unknown',
timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
frequency=ISS_SSTV_FREQ,
size_bytes=stat.st_size
frequency=self._frequency,
size_bytes=stat.st_size,
url_prefix=self._url_prefix,
)
self._images.append(image)
except Exception as e:
@@ -694,13 +702,13 @@ class SSTVDecoder:
if not audio_path.exists():
raise FileNotFoundError(f"Audio file not found: {audio_path}")
images = []
decoder = self._decoder or self._file_decoder
if decoder == 'slowrx':
# Use slowrx with file input
output_file = self._output_dir / f"sstv_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
images = []
decoder = self._decoder or self._file_decoder
if 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)
@@ -720,10 +728,10 @@ class SSTVDecoder:
)
images.append(image)
elif decoder == 'python-sstv':
# Use Python sstv library
try:
from sstv.decode import SSTVDecoder as PythonSSTVDecoder
elif decoder == 'python-sstv':
# Use Python sstv library
try:
from sstv.decode import SSTVDecoder as PythonSSTVDecoder
from PIL import Image
decoder = PythonSSTVDecoder(str(audio_path))
@@ -767,3 +775,18 @@ def is_sstv_available() -> bool:
"""Check if SSTV decoding is available."""
decoder = get_sstv_decoder()
return decoder.decoder_available is not None
# Global general SSTV decoder instance (separate from ISS)
_general_decoder: SSTVDecoder | None = None
def get_general_sstv_decoder() -> SSTVDecoder:
"""Get or create the global general SSTV decoder instance."""
global _general_decoder
if _general_decoder is None:
_general_decoder = SSTVDecoder(
output_dir='instance/sstv_general_images',
url_prefix='/sstv-general',
)
return _general_decoder