feat: add rtl_tcp remote SDR support to aprs, morse, and dsc routes

Closes #164. Only pager and sensor routes supported rtl_tcp connections.
Now aprs, morse, and dsc routes follow the same pattern: extract
rtl_tcp_host/port from the request, skip local device claiming for
remote connections, and use SDRFactory.create_network_device(). DSC also
refactored from manual rtl_fm command building to use SDRFactory's
builder abstraction. Frontend wired up for all three modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-28 19:21:28 +00:00
parent 153aacba03
commit c1339b6c65
6 changed files with 164 additions and 65 deletions

View File

@@ -22,7 +22,13 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm from utils.validation import (
validate_device_index,
validate_gain,
validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
@@ -1689,6 +1695,10 @@ def start_aprs() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower() sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try: try:
sdr_type = SDRType(sdr_type_str) sdr_type = SDRType(sdr_type_str)
@@ -1708,16 +1718,17 @@ def start_aprs() -> Response:
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.' 'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 400 }), 400
# Reserve SDR device to prevent conflicts with other modes # Reserve SDR device to prevent conflicts (skip for remote rtl_tcp)
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str) if not rtl_tcp_host:
if error: error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
return jsonify({ if error:
'status': 'error', return jsonify({
'error_type': 'DEVICE_BUSY', 'status': 'error',
'message': error 'error_type': 'DEVICE_BUSY',
}), 409 'message': error
aprs_active_device = device }), 409
aprs_active_sdr_type = sdr_type_str aprs_active_device = device
aprs_active_sdr_type = sdr_type_str
# Get frequency for region # Get frequency for region
region = data.get('region', 'north_america') region = data.get('region', 'north_america')
@@ -1741,8 +1752,17 @@ def start_aprs() -> Response:
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction. # Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
try: try:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device) if rtl_tcp_host:
builder = SDRFactory.get_builder(sdr_type) try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
else:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_device.sdr_type)
rtl_cmd = builder.build_fm_demod_command( rtl_cmd = builder.build_fm_demod_command(
device=sdr_device, device=sdr_device,
frequency_mhz=float(frequency), frequency_mhz=float(frequency),

View File

@@ -37,7 +37,12 @@ from utils.database import (
from utils.dsc.parser import parse_dsc_message from utils.dsc.parser import parse_dsc_message
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.validation import validate_device_index, validate_gain from utils.validation import (
validate_device_index,
validate_gain,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.dependencies import get_tool_path from utils.dependencies import get_tool_path
from utils.process import register_process, unregister_process from utils.process import register_process, unregister_process
@@ -336,19 +341,29 @@ def start_decoding() -> Response:
# Get SDR type from request # Get SDR type from request
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Check if device is available using centralized registry # Check for rtl_tcp (remote SDR) connection
global dsc_active_device, dsc_active_sdr_type rtl_tcp_host = data.get('rtl_tcp_host')
device_int = int(device) rtl_tcp_port = data.get('rtl_tcp_port', 1234)
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
dsc_active_device = device_int try:
dsc_active_sdr_type = sdr_type_str sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check if device is available using centralized registry (skip for remote rtl_tcp)
global dsc_active_device, dsc_active_sdr_type
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
dsc_active_device = device_int
dsc_active_sdr_type = sdr_type_str
# Clear queue # Clear queue
while not app_module.dsc_queue.empty(): while not app_module.dsc_queue.empty():
@@ -357,22 +372,32 @@ def start_decoding() -> Response:
except queue.Empty: except queue.Empty:
break break
# Build rtl_fm command # Build rtl_fm command via SDR abstraction layer
rtl_fm_path = tools['rtl_fm']['path']
decoder_path = tools['dsc_decoder']['path'] decoder_path = tools['dsc_decoder']['path']
# rtl_fm command for DSC decoding if rtl_tcp_host:
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate try:
rtl_cmd = [ rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_fm_path, rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M', except ValueError as e:
'-s', str(DSC_SAMPLE_RATE), return jsonify({'status': 'error', 'message': str(e)}), 400
'-d', str(device), sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
'-g', str(gain), logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
'-M', 'fm', # FM demodulation else:
'-l', '0', # No squelch for DSC sdr_device = SDRFactory.create_default_device(sdr_type, index=int(device))
'-E', 'dc' # DC blocking filter
] builder = SDRFactory.get_builder(sdr_device.sdr_type)
rtl_cmd = list(builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=DSC_VHF_FREQUENCY_MHZ,
sample_rate=DSC_SAMPLE_RATE,
gain=float(gain) if gain and str(gain) != '0' else None,
modulation='fm',
squelch=0,
))
# Ensure trailing '-' for stdin piping and add DC blocking filter
if rtl_cmd and rtl_cmd[-1] == '-':
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-']
# Decoder command # Decoder command
decoder_cmd = [decoder_path] decoder_cmd = [decoder_path]

View File

@@ -28,6 +28,8 @@ from utils.validation import (
validate_frequency, validate_frequency,
validate_gain, validate_gain,
validate_ppm, validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
) )
morse_bp = Blueprint('morse', __name__) morse_bp = Blueprint('morse', __name__)
@@ -279,6 +281,10 @@ def start_morse() -> Response:
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
with app_module.morse_lock: with app_module.morse_lock:
if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}: if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
return jsonify({ return jsonify({
@@ -287,17 +293,19 @@ def start_morse() -> Response:
'state': morse_state, 'state': morse_state,
}), 409 }), 409
device_int = int(device) # Reserve SDR device (skip for remote rtl_tcp)
error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str) if not rtl_tcp_host:
if error: device_int = int(device)
return jsonify({ error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str)
'status': 'error', if error:
'error_type': 'DEVICE_BUSY', return jsonify({
'message': error, 'status': 'error',
}), 409 'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
morse_active_device = device_int morse_active_device = device_int
morse_active_sdr_type = sdr_type_str morse_active_sdr_type = sdr_type_str
morse_last_error = '' morse_last_error = ''
morse_session_id += 1 morse_session_id += 1
@@ -320,23 +328,35 @@ def start_morse() -> Response:
except ValueError: except ValueError:
sdr_type = SDRType.RTL_SDR sdr_type = SDRType.RTL_SDR
# Create network or local SDR device
network_sdr_device = None
if rtl_tcp_host:
try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
network_sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
requested_device_index = int(device) requested_device_index = int(device)
active_device_index = requested_device_index active_device_index = requested_device_index
builder = SDRFactory.get_builder(sdr_type) builder = SDRFactory.get_builder(network_sdr_device.sdr_type if network_sdr_device else sdr_type)
device_catalog: dict[int, dict[str, str]] = {} device_catalog: dict[int, dict[str, str]] = {}
candidate_device_indices: list[int] = [requested_device_index] candidate_device_indices: list[int] = [requested_device_index]
with contextlib.suppress(Exception): if not network_sdr_device:
detected_devices = SDRFactory.detect_devices() with contextlib.suppress(Exception):
same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type] detected_devices = SDRFactory.detect_devices()
for d in same_type_devices: same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type]
device_catalog[d.index] = { for d in same_type_devices:
'name': str(d.name or f'SDR {d.index}'), device_catalog[d.index] = {
'serial': str(d.serial or 'Unknown'), 'name': str(d.name or f'SDR {d.index}'),
} 'serial': str(d.serial or 'Unknown'),
for d in sorted(same_type_devices, key=lambda dev: dev.index): }
if d.index not in candidate_device_indices: for d in sorted(same_type_devices, key=lambda dev: dev.index):
candidate_device_indices.append(d.index) if d.index not in candidate_device_indices:
candidate_device_indices.append(d.index)
def _device_label(device_index: int) -> str: def _device_label(device_index: int) -> str:
meta = device_catalog.get(device_index, {}) meta = device_catalog.get(device_index, {})
@@ -350,7 +370,7 @@ def start_morse() -> Response:
tuned_frequency_mhz = max(0.5, float(freq)) tuned_frequency_mhz = max(0.5, float(freq))
else: else:
tuned_frequency_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)) tuned_frequency_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0))
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_index) sdr_device = network_sdr_device or SDRFactory.create_default_device(sdr_type, index=device_index)
fm_kwargs: dict[str, Any] = { fm_kwargs: dict[str, Any] = {
'device': sdr_device, 'device': sdr_device,
'frequency_mhz': tuned_frequency_mhz, 'frequency_mhz': tuned_frequency_mhz,

View File

@@ -96,7 +96,7 @@ var MorseMode = (function () {
} }
function collectConfig() { function collectConfig() {
return { var config = {
frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060', frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060',
gain: (el('morseGain') && el('morseGain').value) || '40', gain: (el('morseGain') && el('morseGain').value) || '40',
ppm: (el('morsePPM') && el('morsePPM').value) || '0', ppm: (el('morsePPM') && el('morsePPM').value) || '0',
@@ -117,6 +117,17 @@ var MorseMode = (function () {
wpm: (el('morseWpm') && el('morseWpm').value) || '15', wpm: (el('morseWpm') && el('morseWpm').value) || '15',
wpm_lock: !!(el('morseWpmLock') && el('morseWpmLock').checked), wpm_lock: !!(el('morseWpmLock') && el('morseWpmLock').checked),
}; };
// Add rtl_tcp params if using remote SDR
if (typeof getRemoteSDRConfig === 'function') {
var remoteConfig = getRemoteSDRConfig();
if (remoteConfig) {
config.rtl_tcp_host = remoteConfig.host;
config.rtl_tcp_port = remoteConfig.port;
}
}
return config;
} }
function persistSettings() { function persistSettings() {

View File

@@ -1179,6 +1179,19 @@
const isAgentMode = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local'; const isAgentMode = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local';
dscCurrentAgent = isAgentMode ? aisCurrentAgent : null; dscCurrentAgent = isAgentMode ? aisCurrentAgent : null;
// Check for remote SDR (only for local mode)
const remoteConfig = (!isAgentMode && typeof getRemoteSDRConfig === 'function')
? getRemoteSDRConfig() : null;
if (remoteConfig === false) return; // Validation failed
const requestBody = { device, gain };
// Add rtl_tcp params if using remote SDR
if (remoteConfig) {
requestBody.rtl_tcp_host = remoteConfig.host;
requestBody.rtl_tcp_port = remoteConfig.port;
}
// Determine endpoint based on agent mode // Determine endpoint based on agent mode
const endpoint = isAgentMode const endpoint = isAgentMode
? `/controller/agents/${aisCurrentAgent}/dsc/start` ? `/controller/agents/${aisCurrentAgent}/dsc/start`
@@ -1187,7 +1200,7 @@
fetch(endpoint, { fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain }) body: JSON.stringify(requestBody)
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {

View File

@@ -9797,6 +9797,10 @@
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
aprsCurrentAgent = isAgentMode ? currentAgent : null; aprsCurrentAgent = isAgentMode ? currentAgent : null;
// Check for remote SDR (only for local mode)
const remoteConfig = isAgentMode ? null : getRemoteSDRConfig();
if (remoteConfig === false) return; // Validation failed
// Build request body // Build request body
const requestBody = { const requestBody = {
region, region,
@@ -9805,6 +9809,12 @@
sdr_type: sdrType sdr_type: sdrType
}; };
// Add rtl_tcp params if using remote SDR
if (remoteConfig) {
requestBody.rtl_tcp_host = remoteConfig.host;
requestBody.rtl_tcp_port = remoteConfig.port;
}
// Add custom frequency if selected // Add custom frequency if selected
if (region === 'custom') { if (region === 'custom') {
const customFreq = document.getElementById('aprsStripCustomFreq').value; const customFreq = document.getElementById('aprsStripCustomFreq').value;