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
|
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'])
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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 — HF propagation varies by time of day</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.
|
"""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)}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user