mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
@@ -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
288
routes/sstv_general.py
Normal 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
|
||||
477
static/css/modes/sstv-general.css
Normal file
477
static/css/modes/sstv-general.css
Normal 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; }
|
||||
}
|
||||
410
static/js/modes/sstv-general.js
Normal file
410
static/js/modes/sstv-general.js
Normal 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()">×</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
|
||||
};
|
||||
})();
|
||||
@@ -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);">☐ ${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>
|
||||
`;
|
||||
}
|
||||
|
||||
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 -->
|
||||
|
||||
86
templates/partials/modes/sstv-general.html
Normal file
86
templates/partials/modes/sstv-general.html
Normal 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>
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user