"""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, })