mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
326
routes/wefax.py
326
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'])
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -44,18 +44,18 @@
|
||||
<label>Gain (dB)</label>
|
||||
<input type="number" id="wefaxGain" value="40" step="1" min="0" max="50">
|
||||
</div>
|
||||
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" id="wefaxDirectSampling" checked>
|
||||
<label for="wefaxDirectSampling" style="margin: 0; cursor: pointer;">Direct Sampling (Q-branch, required for HF)</label>
|
||||
</div>
|
||||
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" id="wefaxAutoUsbAlign" checked>
|
||||
<label for="wefaxAutoUsbAlign" style="margin: 0; cursor: pointer;">Auto USB align listed carrier frequencies (-1.9 kHz)</label>
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" id="wefaxDirectSampling" checked>
|
||||
<label for="wefaxDirectSampling" style="margin: 0; cursor: pointer;">Direct Sampling (Q-branch, required for HF)</label>
|
||||
</div>
|
||||
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" id="wefaxAutoUsbAlign" checked>
|
||||
<label for="wefaxAutoUsbAlign" style="margin: 0; cursor: pointer;">Auto USB align listed carrier frequencies (-1.9 kHz)</label>
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<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;">
|
||||
<strong style="color: #ffaa00; font-size: 12px;">Requirements</strong>
|
||||
<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);">Mode:</strong> USB (Upper Sideband) demodulation</li>
|
||||
<li><strong style="color: var(--text-primary);">Signals:</strong> Moderate — HF propagation varies by time of day</li>
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user