mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
- Fix SSE fanout thread AttributeError when source queue is None during interpreter shutdown by snapshotting to local variable with null guard - Fix branded "i" logo rendering oversized on first page load (FOUC) by adding inline width/height to SVG elements across 10 templates - Bump version to 2.26.0 in config.py, pyproject.toml, and CHANGELOG.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
329 lines
11 KiB
Python
329 lines
11 KiB
Python
"""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 contextlib
|
|
import queue
|
|
from pathlib import Path
|
|
|
|
from flask import Blueprint, Response, jsonify, request, send_file
|
|
|
|
import app as app_module
|
|
from utils.event_pipeline import process_event
|
|
from utils.logging import get_logger
|
|
from utils.responses import api_error
|
|
from utils.sse import sse_stream_fanout
|
|
from utils.sstv import (
|
|
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)
|
|
|
|
# Track which device is being used
|
|
_sstv_general_active_device: int | None = None
|
|
_sstv_general_active_sdr_type: str = 'rtlsdr'
|
|
|
|
# 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.500, '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(data: dict) -> None:
|
|
"""Callback to queue progress/scope updates for SSE stream."""
|
|
try:
|
|
_sstv_general_queue.put_nowait(data)
|
|
except queue.Full:
|
|
try:
|
|
_sstv_general_queue.get_nowait()
|
|
_sstv_general_queue.put_nowait(data)
|
|
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 api_error('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 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 {}
|
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
|
|
if sdr_type_str != 'rtlsdr':
|
|
return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
|
|
|
|
frequency = data.get('frequency')
|
|
modulation = data.get('modulation')
|
|
device_index = data.get('device', 0)
|
|
|
|
# Validate frequency
|
|
if frequency is None:
|
|
return api_error('Frequency is required', 400)
|
|
|
|
try:
|
|
frequency = float(frequency)
|
|
if not (1 <= frequency <= 500):
|
|
return api_error('Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)', 400)
|
|
except (TypeError, ValueError):
|
|
return api_error('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 api_error('Modulation must be fm, usb, or lsb', 400)
|
|
|
|
# Claim SDR device
|
|
global _sstv_general_active_device, _sstv_general_active_sdr_type
|
|
device_int = int(device_index)
|
|
error = app_module.claim_sdr_device(device_int, 'sstv_general', sdr_type_str)
|
|
if error:
|
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
|
|
|
# Set callback and start
|
|
decoder.set_callback(_progress_callback)
|
|
success = decoder.start(
|
|
frequency=frequency,
|
|
device_index=device_index,
|
|
modulation=modulation,
|
|
)
|
|
|
|
if success:
|
|
_sstv_general_active_device = device_int
|
|
_sstv_general_active_sdr_type = sdr_type_str
|
|
return jsonify({
|
|
'status': 'started',
|
|
'frequency': frequency,
|
|
'modulation': modulation,
|
|
'device': device_index,
|
|
})
|
|
else:
|
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
|
return api_error('Failed to start decoder', 500)
|
|
|
|
|
|
@sstv_general_bp.route('/stop', methods=['POST'])
|
|
def stop_decoder():
|
|
"""Stop general SSTV decoder."""
|
|
global _sstv_general_active_device, _sstv_general_active_sdr_type
|
|
decoder = get_general_sstv_decoder()
|
|
decoder.stop()
|
|
|
|
if _sstv_general_active_device is not None:
|
|
app_module.release_sdr_device(_sstv_general_active_device, _sstv_general_active_sdr_type)
|
|
_sstv_general_active_device = None
|
|
|
|
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 api_error('Invalid filename', 400)
|
|
|
|
if not filename.endswith('.png'):
|
|
return api_error('Only PNG files supported', 400)
|
|
|
|
image_path = decoder._output_dir / filename
|
|
|
|
if not image_path.exists():
|
|
return api_error('Image not found', 404)
|
|
|
|
return send_file(image_path, mimetype='image/png')
|
|
|
|
|
|
@sstv_general_bp.route('/images/<filename>/download')
|
|
def download_image(filename: str):
|
|
"""Download 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 api_error('Invalid filename', 400)
|
|
|
|
if not filename.endswith('.png'):
|
|
return api_error('Only PNG files supported', 400)
|
|
|
|
image_path = decoder._output_dir / filename
|
|
|
|
if not image_path.exists():
|
|
return api_error('Image not found', 404)
|
|
|
|
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
|
|
|
|
|
@sstv_general_bp.route('/images/<filename>', methods=['DELETE'])
|
|
def delete_image(filename: str):
|
|
"""Delete a decoded SSTV image."""
|
|
decoder = get_general_sstv_decoder()
|
|
|
|
# Security: only allow alphanumeric filenames with .png extension
|
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
|
return api_error('Invalid filename', 400)
|
|
|
|
if not filename.endswith('.png'):
|
|
return api_error('Only PNG files supported', 400)
|
|
|
|
if decoder.delete_image(filename):
|
|
return jsonify({'status': 'ok'})
|
|
else:
|
|
return api_error('Image not found', 404)
|
|
|
|
|
|
@sstv_general_bp.route('/images', methods=['DELETE'])
|
|
def delete_all_images():
|
|
"""Delete all decoded SSTV images."""
|
|
decoder = get_general_sstv_decoder()
|
|
count = decoder.delete_all_images()
|
|
return jsonify({'status': 'ok', 'deleted': count})
|
|
|
|
|
|
@sstv_general_bp.route('/stream')
|
|
def stream_progress():
|
|
"""SSE stream of SSTV decode progress."""
|
|
def _on_msg(msg: dict[str, Any]) -> None:
|
|
process_event('sstv_general', msg, msg.get('type'))
|
|
|
|
response = Response(
|
|
sse_stream_fanout(
|
|
source_queue=_sstv_general_queue,
|
|
channel_key='sstv_general',
|
|
timeout=1.0,
|
|
keepalive_interval=30.0,
|
|
on_message=_on_msg,
|
|
),
|
|
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 api_error('No audio file provided', 400)
|
|
|
|
audio_file = request.files['audio']
|
|
|
|
if not audio_file.filename:
|
|
return api_error('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 api_error(str(e), 500)
|
|
|
|
finally:
|
|
with contextlib.suppress(Exception):
|
|
Path(tmp_path).unlink()
|