Files
intercept/routes/sstv_general.py
T
James Smith 96172ca593 style: apply ruff-format to entire codebase
First-time run of ruff-format via pre-commit hook normalises quote
style, trailing commas, and whitespace across 188 Python files.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 14:48:11 +01:00

418 lines
12 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()