"""WeFax (Weather Fax) decoder routes. Provides endpoints for decoding HF weather fax transmissions from maritime/aviation weather services worldwide. """ from __future__ import annotations import contextlib import queue from flask import Blueprint, Response, jsonify, request, send_file import app as app_module from utils.logging import get_logger from utils.responses import api_error from utils.sdr import SDRType from utils.sse import sse_stream_fanout from utils.validation import validate_frequency from utils.wefax import get_wefax_decoder from utils.wefax_stations import ( WEFAX_USB_ALIGNMENT_OFFSET_KHZ, get_current_broadcasts, get_station, load_stations, resolve_tuning_frequency_khz, ) logger = get_logger("intercept.wefax") wefax_bp = Blueprint("wefax", __name__, url_prefix="/wefax") # SSE progress queue _wefax_queue: queue.Queue = queue.Queue(maxsize=100) # Track active SDR device wefax_active_device: int | None = None wefax_active_sdr_type: str | None = None def _progress_callback(data: dict) -> None: """Callback to queue progress updates for SSE stream.""" global wefax_active_device, wefax_active_sdr_type try: _wefax_queue.put_nowait(data) except queue.Full: try: _wefax_queue.get_nowait() _wefax_queue.put_nowait(data) except queue.Empty: pass # Ensure manually claimed SDR devices are always released when a # decode session ends on its own (complete/error/stopped). if ( isinstance(data, dict) and data.get("type") == "wefax_progress" and data.get("status") in ("complete", "error", "stopped") and wefax_active_device is not None ): app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or "rtlsdr") wefax_active_device = None wefax_active_sdr_type = None @wefax_bp.route("/status") def get_status(): """Get WeFax decoder status.""" decoder = get_wefax_decoder() return jsonify( { "available": True, "running": decoder.is_running, "image_count": len(decoder.get_images()), } ) @wefax_bp.route("/start", methods=["POST"]) def start_decoder(): """Start WeFax decoder. JSON body: { "frequency_khz": 4298, "station": "NOJ", "device": 0, "gain": 40, "ioc": 576, "lpm": 120, "direct_sampling": true, "frequency_reference": "auto" // auto, carrier, or dial } """ decoder = get_wefax_decoder() if decoder.is_running: return jsonify( { "status": "already_running", "message": "WeFax decoder is already running", } ) # Clear queue while not _wefax_queue.empty(): try: _wefax_queue.get_nowait() except queue.Empty: break data = request.get_json(silent=True) or {} # Validate frequency (required) frequency_khz = data.get("frequency_khz") if frequency_khz is None: return api_error("frequency_khz is required", 400) try: frequency_khz = float(frequency_khz) # WeFax operates on HF: 2-30 MHz (2000-30000 kHz) freq_mhz = frequency_khz / 1000.0 validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0) except (TypeError, ValueError) as e: return api_error(f"Invalid frequency: {e}", 400) station = str(data.get("station", "")).strip() device_index = data.get("device", 0) gain = float(data.get("gain", 40.0)) ioc = int(data.get("ioc", 576)) lpm = int(data.get("lpm", 120)) direct_sampling = bool(data.get("direct_sampling", True)) frequency_reference = str(data.get("frequency_reference", "auto")).strip().lower() sdr_type_str = str(data.get("sdr_type", "rtlsdr")).lower() with contextlib.suppress(ValueError): SDRType(sdr_type_str) if not frequency_reference: frequency_reference = "auto" try: tuned_frequency_khz, resolved_reference, usb_offset_applied = resolve_tuning_frequency_khz( listed_frequency_khz=frequency_khz, station_callsign=station, frequency_reference=frequency_reference, ) tuned_mhz = tuned_frequency_khz / 1000.0 validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0) except ValueError as e: return api_error(f"Invalid frequency settings: {e}", 400) # Validate IOC and LPM if ioc not in (288, 576): return api_error("IOC must be 288 or 576", 400) if lpm not in (60, 120): return api_error("LPM must be 60 or 120", 400) # Claim SDR device global wefax_active_device, wefax_active_sdr_type device_int = int(device_index) error = app_module.claim_sdr_device(device_int, "wefax", 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_khz=tuned_frequency_khz, station=station, device_index=device_int, gain=gain, ioc=ioc, lpm=lpm, direct_sampling=direct_sampling, sdr_type=sdr_type_str, ) if success: wefax_active_device = device_int wefax_active_sdr_type = sdr_type_str return jsonify( { "status": "started", "frequency_khz": frequency_khz, "tuned_frequency_khz": tuned_frequency_khz, "frequency_reference": resolved_reference, "usb_offset_applied": usb_offset_applied, "usb_offset_khz": (WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0), "station": station, "ioc": ioc, "lpm": lpm, "device": device_int, } ) else: app_module.release_sdr_device(device_int, sdr_type_str) return api_error("Failed to start decoder", 500) @wefax_bp.route("/stop", methods=["POST"]) def stop_decoder(): """Stop WeFax decoder.""" global wefax_active_device, wefax_active_sdr_type decoder = get_wefax_decoder() decoder.stop() if wefax_active_device is not None: app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or "rtlsdr") wefax_active_device = None wefax_active_sdr_type = None return jsonify({"status": "stopped"}) @wefax_bp.route("/stream") def stream_progress(): """SSE stream of WeFax decode progress.""" response = Response( sse_stream_fanout( source_queue=_wefax_queue, channel_key="wefax", timeout=1.0, keepalive_interval=30.0, ), mimetype="text/event-stream", ) response.headers["Cache-Control"] = "no-cache" response.headers["X-Accel-Buffering"] = "no" response.headers["Connection"] = "keep-alive" return response @wefax_bp.route("/images") def list_images(): """Get list of decoded WeFax images.""" decoder = get_wefax_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), } ) @wefax_bp.route("/images/") def get_image(filename: str): """Get a decoded WeFax image file.""" decoder = get_wefax_decoder() 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") @wefax_bp.route("/images/", methods=["DELETE"]) def delete_image(filename: str): """Delete a decoded WeFax image.""" decoder = get_wefax_decoder() 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) @wefax_bp.route("/images", methods=["DELETE"]) def delete_all_images(): """Delete all decoded WeFax images.""" decoder = get_wefax_decoder() count = decoder.delete_all_images() return jsonify({"status": "ok", "deleted": count}) # ======================== # Auto-Scheduler Endpoints # ======================== def _scheduler_event_callback(event: dict) -> None: """Forward scheduler events to the SSE queue.""" try: _wefax_queue.put_nowait(event) except queue.Full: try: _wefax_queue.get_nowait() _wefax_queue.put_nowait(event) except queue.Empty: pass @wefax_bp.route("/schedule/enable", methods=["POST"]) def enable_schedule(): """Enable auto-scheduling of WeFax broadcast captures. JSON body: { "station": "NOJ", "frequency_khz": 4298, "device": 0, "gain": 40, "ioc": 576, "lpm": 120, "direct_sampling": true, "frequency_reference": "auto" // auto, carrier, or dial } Returns: JSON with scheduler status. """ from utils.wefax_scheduler import get_wefax_scheduler data = request.get_json(silent=True) or {} station = str(data.get("station", "")).strip() if not station: return api_error("station is required", 400) frequency_khz = data.get("frequency_khz") if frequency_khz is None: return api_error("frequency_khz is required", 400) try: frequency_khz = float(frequency_khz) freq_mhz = frequency_khz / 1000.0 validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0) except (TypeError, ValueError) as e: return api_error(f"Invalid frequency: {e}", 400) device = int(data.get("device", 0)) gain = float(data.get("gain", 40.0)) ioc = int(data.get("ioc", 576)) lpm = int(data.get("lpm", 120)) direct_sampling = bool(data.get("direct_sampling", True)) frequency_reference = str(data.get("frequency_reference", "auto")).strip().lower() if not frequency_reference: frequency_reference = "auto" try: tuned_frequency_khz, resolved_reference, usb_offset_applied = resolve_tuning_frequency_khz( listed_frequency_khz=frequency_khz, station_callsign=station, frequency_reference=frequency_reference, ) tuned_mhz = tuned_frequency_khz / 1000.0 validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0) except ValueError as e: return api_error(f"Invalid frequency settings: {e}", 400) scheduler = get_wefax_scheduler() scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) try: result = scheduler.enable( station=station, frequency_khz=tuned_frequency_khz, device=device, gain=gain, ioc=ioc, lpm=lpm, direct_sampling=direct_sampling, ) except Exception: logger.exception("Failed to enable WeFax scheduler") return api_error("Failed to enable scheduler", 500) return jsonify( { "status": "ok", **result, "frequency_khz": frequency_khz, "tuned_frequency_khz": tuned_frequency_khz, "frequency_reference": resolved_reference, "usb_offset_applied": usb_offset_applied, "usb_offset_khz": (WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0), } ) @wefax_bp.route("/schedule/disable", methods=["POST"]) def disable_schedule(): """Disable auto-scheduling.""" from utils.wefax_scheduler import get_wefax_scheduler scheduler = get_wefax_scheduler() result = scheduler.disable() return jsonify(result) @wefax_bp.route("/schedule/status") def schedule_status(): """Get current scheduler state.""" from utils.wefax_scheduler import get_wefax_scheduler scheduler = get_wefax_scheduler() return jsonify(scheduler.get_status()) @wefax_bp.route("/schedule/broadcasts") def schedule_broadcasts(): """List scheduled broadcasts.""" from utils.wefax_scheduler import get_wefax_scheduler scheduler = get_wefax_scheduler() broadcasts = scheduler.get_broadcasts() return jsonify( { "status": "ok", "broadcasts": broadcasts, "count": len(broadcasts), } ) @wefax_bp.route("/schedule/skip/", methods=["POST"]) def skip_broadcast(broadcast_id: str): """Skip a scheduled broadcast.""" from utils.wefax_scheduler import get_wefax_scheduler if not broadcast_id.replace("_", "").replace("-", "").isalnum(): return api_error("Invalid broadcast ID", 400) scheduler = get_wefax_scheduler() if scheduler.skip_broadcast(broadcast_id): return jsonify({"status": "skipped", "broadcast_id": broadcast_id}) else: return api_error("Broadcast not found or already processed", 404) @wefax_bp.route("/stations") def list_stations(): """Get all WeFax stations from the database.""" stations = load_stations() return jsonify( { "status": "ok", "stations": stations, "count": len(stations), } ) @wefax_bp.route("/stations/") def station_detail(callsign: str): """Get station detail including current schedule info.""" station = get_station(callsign) if not station: return api_error(f"Station {callsign} not found", 404) current = get_current_broadcasts(callsign) return jsonify( { "status": "ok", "station": station, "current_broadcasts": current, } )