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 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-25 16:45:07 +00:00
parent 2202e3ed98
commit f3158cbb69
4 changed files with 295 additions and 269 deletions

View File

@@ -11,17 +11,18 @@ import queue
from flask import Blueprint, Response, jsonify, request, send_file from flask import Blueprint, Response, jsonify, request, send_file
import app as app_module import app as app_module
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import sse_stream_fanout from utils.sdr import SDRType
from utils.validation import validate_frequency from utils.sse import sse_stream_fanout
from utils.wefax import get_wefax_decoder from utils.validation import validate_frequency
from utils.wefax_stations import ( from utils.wefax import get_wefax_decoder
WEFAX_USB_ALIGNMENT_OFFSET_KHZ, from utils.wefax_stations import (
get_current_broadcasts, WEFAX_USB_ALIGNMENT_OFFSET_KHZ,
get_station, get_current_broadcasts,
load_stations, get_station,
resolve_tuning_frequency_khz, load_stations,
) resolve_tuning_frequency_khz,
)
logger = get_logger('intercept.wefax') logger = get_logger('intercept.wefax')
@@ -34,29 +35,29 @@ _wefax_queue: queue.Queue = queue.Queue(maxsize=100)
wefax_active_device: int | None = None wefax_active_device: int | None = None
def _progress_callback(data: dict) -> None: def _progress_callback(data: dict) -> None:
"""Callback to queue progress updates for SSE stream.""" """Callback to queue progress updates for SSE stream."""
global wefax_active_device global wefax_active_device
try: try:
_wefax_queue.put_nowait(data) _wefax_queue.put_nowait(data)
except queue.Full: except queue.Full:
try: try:
_wefax_queue.get_nowait() _wefax_queue.get_nowait()
_wefax_queue.put_nowait(data) _wefax_queue.put_nowait(data)
except queue.Empty: except queue.Empty:
pass pass
# Ensure manually claimed SDR devices are always released when a # Ensure manually claimed SDR devices are always released when a
# decode session ends on its own (complete/error/stopped). # decode session ends on its own (complete/error/stopped).
if ( if (
isinstance(data, dict) isinstance(data, dict)
and data.get('type') == 'wefax_progress' and data.get('type') == 'wefax_progress'
and data.get('status') in ('complete', 'error', 'stopped') and data.get('status') in ('complete', 'error', 'stopped')
and wefax_active_device is not None and wefax_active_device is not None
): ):
app_module.release_sdr_device(wefax_active_device) app_module.release_sdr_device(wefax_active_device)
wefax_active_device = None wefax_active_device = None
@wefax_bp.route('/status') @wefax_bp.route('/status')
@@ -75,16 +76,16 @@ def start_decoder():
"""Start WeFax decoder. """Start WeFax decoder.
JSON body: JSON body:
{ {
"frequency_khz": 4298, "frequency_khz": 4298,
"station": "NOJ", "station": "NOJ",
"device": 0, "device": 0,
"gain": 40, "gain": 40,
"ioc": 576, "ioc": 576,
"lpm": 120, "lpm": 120,
"direct_sampling": true, "direct_sampling": true,
"frequency_reference": "auto" // auto, carrier, or dial "frequency_reference": "auto" // auto, carrier, or dial
} }
""" """
decoder = get_wefax_decoder() decoder = get_wefax_decoder()
@@ -122,36 +123,42 @@ def start_decoder():
'message': f'Invalid frequency: {e}', 'message': f'Invalid frequency: {e}',
}), 400 }), 400
station = str(data.get('station', '')).strip() station = str(data.get('station', '')).strip()
device_index = data.get('device', 0) device_index = data.get('device', 0)
gain = float(data.get('gain', 40.0)) gain = float(data.get('gain', 40.0))
ioc = int(data.get('ioc', 576)) ioc = int(data.get('ioc', 576))
lpm = int(data.get('lpm', 120)) lpm = int(data.get('lpm', 120))
direct_sampling = bool(data.get('direct_sampling', True)) direct_sampling = bool(data.get('direct_sampling', True))
frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower() frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower()
if not frequency_reference:
frequency_reference = 'auto' sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
try: sdr_type = SDRType(sdr_type_str)
tuned_frequency_khz, resolved_reference, usb_offset_applied = ( except ValueError:
resolve_tuning_frequency_khz( sdr_type = SDRType.RTL_SDR
listed_frequency_khz=frequency_khz, if not frequency_reference:
station_callsign=station, frequency_reference = 'auto'
frequency_reference=frequency_reference,
) try:
) tuned_frequency_khz, resolved_reference, usb_offset_applied = (
tuned_mhz = tuned_frequency_khz / 1000.0 resolve_tuning_frequency_khz(
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0) listed_frequency_khz=frequency_khz,
except ValueError as e: station_callsign=station,
return jsonify({ frequency_reference=frequency_reference,
'status': 'error', )
'message': f'Invalid frequency settings: {e}', )
}), 400 tuned_mhz = tuned_frequency_khz / 1000.0
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
# Validate IOC and LPM except ValueError as e:
if ioc not in (288, 576): return jsonify({
return jsonify({ 'status': 'error',
'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', 'message': 'IOC must be 288 or 576',
}), 400 }), 400
@@ -172,34 +179,35 @@ def start_decoder():
'message': error, 'message': error,
}), 409 }), 409
# Set callback and start # Set callback and start
decoder.set_callback(_progress_callback) decoder.set_callback(_progress_callback)
success = decoder.start( success = decoder.start(
frequency_khz=tuned_frequency_khz, frequency_khz=tuned_frequency_khz,
station=station, station=station,
device_index=device_int, device_index=device_int,
gain=gain, gain=gain,
ioc=ioc, ioc=ioc,
lpm=lpm, lpm=lpm,
direct_sampling=direct_sampling, direct_sampling=direct_sampling,
sdr_type=sdr_type_str,
) )
if success: if success:
wefax_active_device = device_int wefax_active_device = device_int
return jsonify({ return jsonify({
'status': 'started', 'status': 'started',
'frequency_khz': frequency_khz, 'frequency_khz': frequency_khz,
'tuned_frequency_khz': tuned_frequency_khz, 'tuned_frequency_khz': tuned_frequency_khz,
'frequency_reference': resolved_reference, 'frequency_reference': resolved_reference,
'usb_offset_applied': usb_offset_applied, 'usb_offset_applied': usb_offset_applied,
'usb_offset_khz': ( 'usb_offset_khz': (
WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0 WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0
), ),
'station': station, 'station': station,
'ioc': ioc, 'ioc': ioc,
'lpm': lpm, 'lpm': lpm,
'device': device_int, 'device': device_int,
}) })
else: else:
app_module.release_sdr_device(device_int) app_module.release_sdr_device(device_int)
return jsonify({ return jsonify({
@@ -322,16 +330,16 @@ def enable_schedule():
"""Enable auto-scheduling of WeFax broadcast captures. """Enable auto-scheduling of WeFax broadcast captures.
JSON body: JSON body:
{ {
"station": "NOJ", "station": "NOJ",
"frequency_khz": 4298, "frequency_khz": 4298,
"device": 0, "device": 0,
"gain": 40, "gain": 40,
"ioc": 576, "ioc": 576,
"lpm": 120, "lpm": 120,
"direct_sampling": true, "direct_sampling": true,
"frequency_reference": "auto" // auto, carrier, or dial "frequency_reference": "auto" // auto, carrier, or dial
} }
Returns: Returns:
JSON with scheduler status. JSON with scheduler status.
@@ -365,61 +373,61 @@ def enable_schedule():
}), 400 }), 400
device = int(data.get('device', 0)) device = int(data.get('device', 0))
gain = float(data.get('gain', 40.0)) gain = float(data.get('gain', 40.0))
ioc = int(data.get('ioc', 576)) ioc = int(data.get('ioc', 576))
lpm = int(data.get('lpm', 120)) lpm = int(data.get('lpm', 120))
direct_sampling = bool(data.get('direct_sampling', True)) direct_sampling = bool(data.get('direct_sampling', True))
frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower() frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower()
if not frequency_reference: if not frequency_reference:
frequency_reference = 'auto' frequency_reference = 'auto'
try: try:
tuned_frequency_khz, resolved_reference, usb_offset_applied = ( tuned_frequency_khz, resolved_reference, usb_offset_applied = (
resolve_tuning_frequency_khz( resolve_tuning_frequency_khz(
listed_frequency_khz=frequency_khz, listed_frequency_khz=frequency_khz,
station_callsign=station, station_callsign=station,
frequency_reference=frequency_reference, frequency_reference=frequency_reference,
) )
) )
tuned_mhz = tuned_frequency_khz / 1000.0 tuned_mhz = tuned_frequency_khz / 1000.0
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0) validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
except ValueError as e: except ValueError as e:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': f'Invalid frequency settings: {e}', 'message': f'Invalid frequency settings: {e}',
}), 400 }), 400
scheduler = get_wefax_scheduler() scheduler = get_wefax_scheduler()
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
try: try:
result = scheduler.enable( result = scheduler.enable(
station=station, station=station,
frequency_khz=tuned_frequency_khz, frequency_khz=tuned_frequency_khz,
device=device, device=device,
gain=gain, gain=gain,
ioc=ioc, ioc=ioc,
lpm=lpm, lpm=lpm,
direct_sampling=direct_sampling, direct_sampling=direct_sampling,
) )
except Exception: except Exception:
logger.exception("Failed to enable WeFax scheduler") logger.exception("Failed to enable WeFax scheduler")
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Failed to enable scheduler', 'message': 'Failed to enable scheduler',
}), 500 }), 500
return jsonify({ return jsonify({
'status': 'ok', 'status': 'ok',
**result, **result,
'frequency_khz': frequency_khz, 'frequency_khz': frequency_khz,
'tuned_frequency_khz': tuned_frequency_khz, 'tuned_frequency_khz': tuned_frequency_khz,
'frequency_reference': resolved_reference, 'frequency_reference': resolved_reference,
'usb_offset_applied': usb_offset_applied, 'usb_offset_applied': usb_offset_applied,
'usb_offset_khz': ( 'usb_offset_khz': (
WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0 WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0
), ),
}) })
@wefax_bp.route('/schedule/disable', methods=['POST']) @wefax_bp.route('/schedule/disable', methods=['POST'])

View File

@@ -148,20 +148,20 @@ var WeFax = (function () {
opt.textContent = f.khz + ' kHz — ' + f.description; opt.textContent = f.khz + ' kHz — ' + f.description;
sel.appendChild(opt); sel.appendChild(opt);
}); });
} }
// ---- Start / Stop ---- // ---- Start / Stop ----
function selectedFrequencyReference() { function selectedFrequencyReference() {
var alignCheckbox = document.getElementById('wefaxAutoUsbAlign'); var alignCheckbox = document.getElementById('wefaxAutoUsbAlign');
if (alignCheckbox && !alignCheckbox.checked) { if (alignCheckbox && !alignCheckbox.checked) {
return 'dial'; return 'dial';
} }
return 'auto'; return 'auto';
} }
function start() { function start() {
if (state.running) return; if (state.running) return;
var freqSel = document.getElementById('wefaxFrequency'); var freqSel = document.getElementById('wefaxFrequency');
var freqKhz = freqSel ? parseFloat(freqSel.value) : 0; var freqKhz = freqSel ? parseFloat(freqSel.value) : 0;
@@ -177,41 +177,42 @@ var WeFax = (function () {
var gainInput = document.getElementById('wefaxGain'); var gainInput = document.getElementById('wefaxGain');
var dsCheckbox = document.getElementById('wefaxDirectSampling'); var dsCheckbox = document.getElementById('wefaxDirectSampling');
var deviceSel = document.getElementById('rtlDevice'); var device = (typeof getSelectedDevice === 'function')
var device = deviceSel ? parseInt(deviceSel.value, 10) || 0 : 0; ? parseInt(getSelectedDevice(), 10) || 0 : 0;
var body = { var body = {
frequency_khz: freqKhz, frequency_khz: freqKhz,
station: station, station: station,
device: device, device: device,
gain: gainInput ? parseFloat(gainInput.value) || 40 : 40, sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
ioc: iocSel ? parseInt(iocSel.value, 10) : 576, gain: gainInput ? parseFloat(gainInput.value) || 40 : 40,
lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120, ioc: iocSel ? parseInt(iocSel.value, 10) : 576,
direct_sampling: dsCheckbox ? dsCheckbox.checked : true, lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120,
frequency_reference: selectedFrequencyReference(), direct_sampling: dsCheckbox ? dsCheckbox.checked : true,
}; frequency_reference: selectedFrequencyReference(),
};
fetch('/wefax/start', { fetch('/wefax/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (data) { .then(function (data) {
if (data.status === 'started' || data.status === 'already_running') { if (data.status === 'started' || data.status === 'already_running') {
var tunedKhz = Number(data.tuned_frequency_khz); var tunedKhz = Number(data.tuned_frequency_khz);
if (isNaN(tunedKhz) || tunedKhz <= 0) tunedKhz = freqKhz; if (isNaN(tunedKhz) || tunedKhz <= 0) tunedKhz = freqKhz;
state.running = true; state.running = true;
updateButtons(true); updateButtons(true);
if (data.usb_offset_applied) { if (data.usb_offset_applied) {
setStatus('Scanning ' + tunedKhz + ' kHz (USB aligned from ' + freqKhz + ' kHz)...'); setStatus('Scanning ' + tunedKhz + ' kHz (USB aligned from ' + freqKhz + ' kHz)...');
} else { } else {
setStatus('Scanning ' + tunedKhz + ' kHz...'); setStatus('Scanning ' + tunedKhz + ' kHz...');
} }
setStripFreq(tunedKhz); setStripFreq(tunedKhz);
connectSSE(); connectSSE();
} else { } else {
var errMsg = data.message || 'unknown error'; var errMsg = data.message || 'unknown error';
setStatus('Error: ' + errMsg); setStatus('Error: ' + errMsg);
showStripError(errMsg); showStripError(errMsg);
} }
@@ -342,25 +343,25 @@ var WeFax = (function () {
if (idleEl) idleEl.style.display = 'none'; if (idleEl) idleEl.style.display = 'none';
} }
// Image complete // Image complete
if (data.status === 'complete' && data.image) { if (data.status === 'complete' && data.image) {
scopeImageBurst = 1.0; scopeImageBurst = 1.0;
loadImages(); loadImages();
setStatus('Image decoded: ' + (data.line_count || '?') + ' lines'); setStatus('Image decoded: ' + (data.line_count || '?') + ' lines');
} }
if (data.status === 'complete') { if (data.status === 'complete') {
state.running = false; state.running = false;
updateButtons(false); updateButtons(false);
if (!state.schedulerEnabled) { if (!state.schedulerEnabled) {
disconnectSSE(); disconnectSSE();
} }
} }
if (data.status === 'error') { if (data.status === 'error') {
state.running = false; state.running = false;
updateButtons(false); updateButtons(false);
showStripError(data.message || 'Decode error'); showStripError(data.message || 'Decode error');
} }
if (data.status === 'stopped') { if (data.status === 'stopped') {
@@ -1062,24 +1063,24 @@ var WeFax = (function () {
station: station, station: station,
frequency_khz: freqKhz, frequency_khz: freqKhz,
device: device, device: device,
gain: gainInput ? parseFloat(gainInput.value) || 40 : 40, gain: gainInput ? parseFloat(gainInput.value) || 40 : 40,
ioc: iocSel ? parseInt(iocSel.value, 10) : 576, ioc: iocSel ? parseInt(iocSel.value, 10) : 576,
lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120, lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120,
direct_sampling: dsCheckbox ? dsCheckbox.checked : true, direct_sampling: dsCheckbox ? dsCheckbox.checked : true,
frequency_reference: selectedFrequencyReference(), frequency_reference: selectedFrequencyReference(),
}), }),
}) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (data) { .then(function (data) {
if (data.status === 'ok') { if (data.status === 'ok') {
var status = 'Auto-capture enabled — ' + (data.scheduled_count || 0) + ' broadcasts scheduled'; var status = 'Auto-capture enabled — ' + (data.scheduled_count || 0) + ' broadcasts scheduled';
if (data.usb_offset_applied && !isNaN(Number(data.tuned_frequency_khz))) { if (data.usb_offset_applied && !isNaN(Number(data.tuned_frequency_khz))) {
status += ' (tuning ' + Number(data.tuned_frequency_khz) + ' kHz)'; status += ' (tuning ' + Number(data.tuned_frequency_khz) + ' kHz)';
} }
setStatus(status); setStatus(status);
syncSchedulerCheckboxes(true); syncSchedulerCheckboxes(true);
state.schedulerEnabled = true; state.schedulerEnabled = true;
connectSSE(); connectSSE();
startSchedulerPoll(); startSchedulerPoll();
} else { } else {
setStatus('Scheduler error: ' + (data.message || 'unknown')); setStatus('Scheduler error: ' + (data.message || 'unknown'));

View File

@@ -44,18 +44,18 @@
<label>Gain (dB)</label> <label>Gain (dB)</label>
<input type="number" id="wefaxGain" value="40" step="1" min="0" max="50"> <input type="number" id="wefaxGain" value="40" step="1" min="0" max="50">
</div> </div>
<div class="form-group" style="display: flex; align-items: center; gap: 8px;"> <div class="form-group" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="wefaxDirectSampling" checked> <input type="checkbox" id="wefaxDirectSampling" checked>
<label for="wefaxDirectSampling" style="margin: 0; cursor: pointer;">Direct Sampling (Q-branch, required for HF)</label> <label for="wefaxDirectSampling" style="margin: 0; cursor: pointer;">Direct Sampling (Q-branch, required for HF)</label>
</div> </div>
<div class="form-group" style="display: flex; align-items: center; gap: 8px;"> <div class="form-group" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="wefaxAutoUsbAlign" checked> <input type="checkbox" id="wefaxAutoUsbAlign" checked>
<label for="wefaxAutoUsbAlign" style="margin: 0; cursor: pointer;">Auto USB align listed carrier frequencies (-1.9 kHz)</label> <label for="wefaxAutoUsbAlign" style="margin: 0; cursor: pointer;">Auto USB align listed carrier frequencies (-1.9 kHz)</label>
</div> </div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: -4px;"> <p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: -4px;">
Disable this if your source already provides USB dial frequencies. Disable this if your source already provides USB dial frequencies.
</p> </p>
</div> </div>
<div class="section"> <div class="section">
<h3>Auto Capture</h3> <h3>Auto Capture</h3>
@@ -80,7 +80,7 @@
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;"> <div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: #ffaa00; font-size: 12px;">Requirements</strong> <strong style="color: #ffaa00; font-size: 12px;">Requirements</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;"> <ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">SDR:</strong> RTL-SDR v3/v4 with direct sampling mode</li> <li><strong style="color: var(--text-primary);">SDR:</strong> RTL-SDR (direct sampling), HackRF, LimeSDR, Airspy, or SDRPlay</li>
<li><strong style="color: var(--text-primary);">Antenna:</strong> Long wire (10m+), random wire, or dipole for target band</li> <li><strong style="color: var(--text-primary);">Antenna:</strong> Long wire (10m+), random wire, or dipole for target band</li>
<li><strong style="color: var(--text-primary);">Mode:</strong> USB (Upper Sideband) demodulation</li> <li><strong style="color: var(--text-primary);">Mode:</strong> USB (Upper Sideband) demodulation</li>
<li><strong style="color: var(--text-primary);">Signals:</strong> Moderate &mdash; HF propagation varies by time of day</li> <li><strong style="color: var(--text-primary);">Signals:</strong> Moderate &mdash; HF propagation varies by time of day</li>

View File

@@ -1,10 +1,11 @@
"""WeFax (Weather Fax) decoder. """WeFax (Weather Fax) decoder.
Decodes HF radiofax (weather fax) transmissions using RTL-SDR direct Decodes HF radiofax (weather fax) transmissions using any supported SDR
sampling mode. The decoder implements the standard WeFax AM protocol: (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). 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 State machine: SCANNING -> PHASING -> RECEIVING -> COMPLETE
""" """
@@ -30,6 +31,7 @@ import numpy as np
from utils.dependencies import get_tool_path from utils.dependencies import get_tool_path
from utils.logging import get_logger from utils.logging import get_logger
from utils.sdr import SDRFactory, SDRType
logger = get_logger('intercept.wefax') logger = get_logger('intercept.wefax')
@@ -262,17 +264,19 @@ class WeFaxDecoder:
ioc: int = DEFAULT_IOC, ioc: int = DEFAULT_IOC,
lpm: int = DEFAULT_LPM, lpm: int = DEFAULT_LPM,
direct_sampling: bool = True, direct_sampling: bool = True,
sdr_type: str = 'rtlsdr',
) -> bool: ) -> bool:
"""Start WeFax decoder. """Start WeFax decoder.
Args: Args:
frequency_khz: Frequency in kHz (e.g. 4298 for NOJ). frequency_khz: Frequency in kHz (e.g. 4298 for NOJ).
station: Station callsign for metadata. station: Station callsign for metadata.
device_index: RTL-SDR device index. device_index: SDR device index.
gain: Receiver gain in dB. gain: Receiver gain in dB.
ioc: Index of Cooperation (576 or 288). ioc: Index of Cooperation (576 or 288).
lpm: Lines per minute (120 or 60). lpm: Lines per minute (120 or 60).
direct_sampling: Enable RTL-SDR direct sampling for HF. direct_sampling: Enable RTL-SDR direct sampling for HF.
sdr_type: SDR hardware type (rtlsdr, hackrf, limesdr, airspy, sdrplay).
Returns: Returns:
True if started successfully. True if started successfully.
@@ -288,6 +292,7 @@ class WeFaxDecoder:
self._device_index = device_index self._device_index = device_index
self._gain = gain self._gain = gain
self._direct_sampling = direct_sampling self._direct_sampling = direct_sampling
self._sdr_type = sdr_type
self._sample_rate = DEFAULT_SAMPLE_RATE self._sample_rate = DEFAULT_SAMPLE_RATE
try: try:
@@ -312,27 +317,39 @@ class WeFaxDecoder:
return False return False
def _start_pipeline(self) -> None: def _start_pipeline(self) -> None:
"""Start rtl_fm subprocess in USB mode for WeFax.""" """Start SDR FM demod subprocess in USB mode for WeFax."""
rtl_fm_path = get_tool_path('rtl_fm') try:
if not rtl_fm_path: sdr_type_enum = SDRType(self._sdr_type)
raise RuntimeError('rtl_fm not found') 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 = [ sdr_device = SDRFactory.create_default_device(
rtl_fm_path, sdr_type_enum, index=self._device_index)
'-d', str(self._device_index), builder = SDRFactory.get_builder(sdr_type_enum)
'-f', str(freq_hz), rtl_cmd = builder.build_fm_demod_command(
'-M', 'usb', device=sdr_device,
'-s', str(self._sample_rate), frequency_mhz=self._frequency_khz / 1000.0,
'-r', str(self._sample_rate), sample_rate=self._sample_rate,
'-g', str(self._gain), gain=self._gain,
] modulation='usb',
)
if self._direct_sampling: # RTL-SDR: append direct sampling flag for HF reception
rtl_cmd.extend(['-E', 'direct2']) if sdr_type_enum == SDRType.RTL_SDR and self._direct_sampling:
# Insert before trailing '-' stdout marker
rtl_cmd.append('-') 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)}") logger.info(f"Starting rtl_fm: {' '.join(rtl_cmd)}")