From f3158cbb691823e5f5d363af64e5cb3145b66786 Mon Sep 17 00:00:00 2001 From: Smittix Date: Wed, 25 Feb 2026 16:45:07 +0000 Subject: [PATCH] Add multi-SDR support to WeFax decoder (HackRF, LimeSDR, Airspy, SDRPlay) Replace hardcoded rtl_fm with SDRFactory abstraction layer so WeFax works with any supported SDR hardware, matching the pattern used by APRS and other modes. RTL-SDR direct sampling flag preserved for HF reception. Co-Authored-By: Claude Opus 4.6 --- routes/wefax.py | 326 ++++++++++++++-------------- static/js/modes/wefax.js | 151 ++++++------- templates/partials/modes/wefax.html | 26 +-- utils/wefax.py | 61 ++++-- 4 files changed, 295 insertions(+), 269 deletions(-) diff --git a/routes/wefax.py b/routes/wefax.py index ccce86d..497cdf0 100644 --- a/routes/wefax.py +++ b/routes/wefax.py @@ -11,17 +11,18 @@ import queue from flask import Blueprint, Response, jsonify, request, send_file import app as app_module -from utils.logging import get_logger -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, -) +from utils.logging import get_logger +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') @@ -34,29 +35,29 @@ _wefax_queue: queue.Queue = queue.Queue(maxsize=100) wefax_active_device: int | None = None -def _progress_callback(data: dict) -> None: - """Callback to queue progress updates for SSE stream.""" - global wefax_active_device - - 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_device = None +def _progress_callback(data: dict) -> None: + """Callback to queue progress updates for SSE stream.""" + global wefax_active_device + + 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_device = None @wefax_bp.route('/status') @@ -75,16 +76,16 @@ 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 - } + { + "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() @@ -122,36 +123,42 @@ def start_decoder(): 'message': 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() - 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 jsonify({ - 'status': 'error', - 'message': f'Invalid frequency settings: {e}', - }), 400 - - # Validate IOC and LPM - if ioc not in (288, 576): - return jsonify({ - 'status': 'error', + 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() + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + 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 jsonify({ + 'status': 'error', + 'message': f'Invalid frequency settings: {e}', + }), 400 + + # Validate IOC and LPM + if ioc not in (288, 576): + return jsonify({ + 'status': 'error', 'message': 'IOC must be 288 or 576', }), 400 @@ -172,34 +179,35 @@ def start_decoder(): 'message': error, }), 409 - # 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, + # 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 - 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, - }) + 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) return jsonify({ @@ -322,16 +330,16 @@ 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 - } + { + "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. @@ -365,61 +373,61 @@ def enable_schedule(): }), 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 jsonify({ - 'status': 'error', - 'message': 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, + 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 jsonify({ + 'status': 'error', + 'message': 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 jsonify({ - 'status': 'error', - 'message': '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 - ), - }) + return jsonify({ + 'status': 'error', + 'message': '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']) diff --git a/static/js/modes/wefax.js b/static/js/modes/wefax.js index 3499af4..853bc72 100644 --- a/static/js/modes/wefax.js +++ b/static/js/modes/wefax.js @@ -148,20 +148,20 @@ var WeFax = (function () { opt.textContent = f.khz + ' kHz — ' + f.description; sel.appendChild(opt); }); - } - - // ---- Start / Stop ---- - - function selectedFrequencyReference() { - var alignCheckbox = document.getElementById('wefaxAutoUsbAlign'); - if (alignCheckbox && !alignCheckbox.checked) { - return 'dial'; - } - return 'auto'; - } - - function start() { - if (state.running) return; + } + + // ---- Start / Stop ---- + + function selectedFrequencyReference() { + var alignCheckbox = document.getElementById('wefaxAutoUsbAlign'); + if (alignCheckbox && !alignCheckbox.checked) { + return 'dial'; + } + return 'auto'; + } + + function start() { + if (state.running) return; var freqSel = document.getElementById('wefaxFrequency'); var freqKhz = freqSel ? parseFloat(freqSel.value) : 0; @@ -177,41 +177,42 @@ var WeFax = (function () { var gainInput = document.getElementById('wefaxGain'); var dsCheckbox = document.getElementById('wefaxDirectSampling'); - var deviceSel = document.getElementById('rtlDevice'); - var device = deviceSel ? parseInt(deviceSel.value, 10) || 0 : 0; + var device = (typeof getSelectedDevice === 'function') + ? parseInt(getSelectedDevice(), 10) || 0 : 0; var body = { frequency_khz: freqKhz, station: station, device: device, - gain: gainInput ? parseFloat(gainInput.value) || 40 : 40, - ioc: iocSel ? parseInt(iocSel.value, 10) : 576, - lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120, - direct_sampling: dsCheckbox ? dsCheckbox.checked : true, - frequency_reference: selectedFrequencyReference(), - }; + sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr', + gain: gainInput ? parseFloat(gainInput.value) || 40 : 40, + ioc: iocSel ? parseInt(iocSel.value, 10) : 576, + lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120, + direct_sampling: dsCheckbox ? dsCheckbox.checked : true, + frequency_reference: selectedFrequencyReference(), + }; fetch('/wefax/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (data.status === 'started' || data.status === 'already_running') { - var tunedKhz = Number(data.tuned_frequency_khz); - if (isNaN(tunedKhz) || tunedKhz <= 0) tunedKhz = freqKhz; - state.running = true; - updateButtons(true); - if (data.usb_offset_applied) { - setStatus('Scanning ' + tunedKhz + ' kHz (USB aligned from ' + freqKhz + ' kHz)...'); - } else { - setStatus('Scanning ' + tunedKhz + ' kHz...'); - } - setStripFreq(tunedKhz); - connectSSE(); - } else { - var errMsg = data.message || 'unknown error'; + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'started' || data.status === 'already_running') { + var tunedKhz = Number(data.tuned_frequency_khz); + if (isNaN(tunedKhz) || tunedKhz <= 0) tunedKhz = freqKhz; + state.running = true; + updateButtons(true); + if (data.usb_offset_applied) { + setStatus('Scanning ' + tunedKhz + ' kHz (USB aligned from ' + freqKhz + ' kHz)...'); + } else { + setStatus('Scanning ' + tunedKhz + ' kHz...'); + } + setStripFreq(tunedKhz); + connectSSE(); + } else { + var errMsg = data.message || 'unknown error'; setStatus('Error: ' + errMsg); showStripError(errMsg); } @@ -342,25 +343,25 @@ var WeFax = (function () { if (idleEl) idleEl.style.display = 'none'; } - // Image complete - if (data.status === 'complete' && data.image) { - scopeImageBurst = 1.0; - loadImages(); - setStatus('Image decoded: ' + (data.line_count || '?') + ' lines'); - } - - if (data.status === 'complete') { - state.running = false; - updateButtons(false); - if (!state.schedulerEnabled) { - disconnectSSE(); - } - } - - if (data.status === 'error') { - state.running = false; - updateButtons(false); - showStripError(data.message || 'Decode error'); + // Image complete + if (data.status === 'complete' && data.image) { + scopeImageBurst = 1.0; + loadImages(); + setStatus('Image decoded: ' + (data.line_count || '?') + ' lines'); + } + + if (data.status === 'complete') { + state.running = false; + updateButtons(false); + if (!state.schedulerEnabled) { + disconnectSSE(); + } + } + + if (data.status === 'error') { + state.running = false; + updateButtons(false); + showStripError(data.message || 'Decode error'); } if (data.status === 'stopped') { @@ -1062,24 +1063,24 @@ var WeFax = (function () { station: station, frequency_khz: freqKhz, device: device, - gain: gainInput ? parseFloat(gainInput.value) || 40 : 40, - ioc: iocSel ? parseInt(iocSel.value, 10) : 576, - lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120, - direct_sampling: dsCheckbox ? dsCheckbox.checked : true, - frequency_reference: selectedFrequencyReference(), - }), - }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (data.status === 'ok') { - var status = 'Auto-capture enabled — ' + (data.scheduled_count || 0) + ' broadcasts scheduled'; - if (data.usb_offset_applied && !isNaN(Number(data.tuned_frequency_khz))) { - status += ' (tuning ' + Number(data.tuned_frequency_khz) + ' kHz)'; - } - setStatus(status); - syncSchedulerCheckboxes(true); - state.schedulerEnabled = true; - connectSSE(); + gain: gainInput ? parseFloat(gainInput.value) || 40 : 40, + ioc: iocSel ? parseInt(iocSel.value, 10) : 576, + lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120, + direct_sampling: dsCheckbox ? dsCheckbox.checked : true, + frequency_reference: selectedFrequencyReference(), + }), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'ok') { + var status = 'Auto-capture enabled — ' + (data.scheduled_count || 0) + ' broadcasts scheduled'; + if (data.usb_offset_applied && !isNaN(Number(data.tuned_frequency_khz))) { + status += ' (tuning ' + Number(data.tuned_frequency_khz) + ' kHz)'; + } + setStatus(status); + syncSchedulerCheckboxes(true); + state.schedulerEnabled = true; + connectSSE(); startSchedulerPoll(); } else { setStatus('Scheduler error: ' + (data.message || 'unknown')); diff --git a/templates/partials/modes/wefax.html b/templates/partials/modes/wefax.html index e0dd170..55b3e50 100644 --- a/templates/partials/modes/wefax.html +++ b/templates/partials/modes/wefax.html @@ -44,18 +44,18 @@ -
- - -
-
- - -
-

- Disable this if your source already provides USB dial frequencies. -

- +
+ + +
+
+ + +
+

+ Disable this if your source already provides USB dial frequencies. +

+

Auto Capture

@@ -80,7 +80,7 @@
Requirements
    -
  • SDR: RTL-SDR v3/v4 with direct sampling mode
  • +
  • SDR: RTL-SDR (direct sampling), HackRF, LimeSDR, Airspy, or SDRPlay
  • Antenna: Long wire (10m+), random wire, or dipole for target band
  • Mode: USB (Upper Sideband) demodulation
  • Signals: Moderate — HF propagation varies by time of day
  • diff --git a/utils/wefax.py b/utils/wefax.py index cf457d3..8733f3c 100644 --- a/utils/wefax.py +++ b/utils/wefax.py @@ -1,10 +1,11 @@ """WeFax (Weather Fax) decoder. -Decodes HF radiofax (weather fax) transmissions using RTL-SDR direct -sampling mode. The decoder implements the standard WeFax AM protocol: +Decodes HF radiofax (weather fax) transmissions using any supported SDR +(RTL-SDR, HackRF, LimeSDR, Airspy, SDRPlay) via the SDRFactory +abstraction layer. The decoder implements the standard WeFax AM protocol: carrier 1900 Hz, deviation +/-400 Hz (black=1500, white=2300). -Pipeline: rtl_fm -M usb -E direct2 -> stdout PCM -> Python DSP state machine +Pipeline: rtl_fm/rx_fm -M usb -> stdout PCM -> Python DSP state machine State machine: SCANNING -> PHASING -> RECEIVING -> COMPLETE """ @@ -30,6 +31,7 @@ import numpy as np from utils.dependencies import get_tool_path from utils.logging import get_logger +from utils.sdr import SDRFactory, SDRType logger = get_logger('intercept.wefax') @@ -262,17 +264,19 @@ class WeFaxDecoder: ioc: int = DEFAULT_IOC, lpm: int = DEFAULT_LPM, direct_sampling: bool = True, + sdr_type: str = 'rtlsdr', ) -> bool: """Start WeFax decoder. Args: frequency_khz: Frequency in kHz (e.g. 4298 for NOJ). station: Station callsign for metadata. - device_index: RTL-SDR device index. + device_index: SDR device index. gain: Receiver gain in dB. ioc: Index of Cooperation (576 or 288). lpm: Lines per minute (120 or 60). direct_sampling: Enable RTL-SDR direct sampling for HF. + sdr_type: SDR hardware type (rtlsdr, hackrf, limesdr, airspy, sdrplay). Returns: True if started successfully. @@ -288,6 +292,7 @@ class WeFaxDecoder: self._device_index = device_index self._gain = gain self._direct_sampling = direct_sampling + self._sdr_type = sdr_type self._sample_rate = DEFAULT_SAMPLE_RATE try: @@ -312,27 +317,39 @@ class WeFaxDecoder: return False def _start_pipeline(self) -> None: - """Start rtl_fm subprocess in USB mode for WeFax.""" - rtl_fm_path = get_tool_path('rtl_fm') - if not rtl_fm_path: - raise RuntimeError('rtl_fm not found') + """Start SDR FM demod subprocess in USB mode for WeFax.""" + try: + sdr_type_enum = SDRType(self._sdr_type) + except ValueError: + sdr_type_enum = SDRType.RTL_SDR - freq_hz = int(self._frequency_khz * 1000) + # Validate that the required tool is available + if sdr_type_enum == SDRType.RTL_SDR: + if not get_tool_path('rtl_fm'): + raise RuntimeError('rtl_fm not found') + else: + if not get_tool_path('rx_fm'): + raise RuntimeError('rx_fm not found (required for non-RTL-SDR devices)') - rtl_cmd = [ - rtl_fm_path, - '-d', str(self._device_index), - '-f', str(freq_hz), - '-M', 'usb', - '-s', str(self._sample_rate), - '-r', str(self._sample_rate), - '-g', str(self._gain), - ] + sdr_device = SDRFactory.create_default_device( + sdr_type_enum, index=self._device_index) + builder = SDRFactory.get_builder(sdr_type_enum) + rtl_cmd = builder.build_fm_demod_command( + device=sdr_device, + frequency_mhz=self._frequency_khz / 1000.0, + sample_rate=self._sample_rate, + gain=self._gain, + modulation='usb', + ) - if self._direct_sampling: - rtl_cmd.extend(['-E', 'direct2']) - - rtl_cmd.append('-') + # RTL-SDR: append direct sampling flag for HF reception + if sdr_type_enum == SDRType.RTL_SDR and self._direct_sampling: + # Insert before trailing '-' stdout marker + if rtl_cmd and rtl_cmd[-1] == '-': + rtl_cmd.insert(-1, '-E') + rtl_cmd.insert(-1, 'direct2') + else: + rtl_cmd.extend(['-E', 'direct2', '-']) logger.info(f"Starting rtl_fm: {' '.join(rtl_cmd)}")