mirror of
https://github.com/smittix/intercept.git
synced 2026-07-06 00:28:12 -07:00
96172ca593
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>
481 lines
14 KiB
Python
481 lines
14 KiB
Python
"""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/<filename>")
|
|
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/<filename>", 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/<broadcast_id>", 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/<callsign>")
|
|
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,
|
|
}
|
|
)
|