"""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/") 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//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/", 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()