diff --git a/routes/aprs.py b/routes/aprs.py index 93ce5e9..0961487 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -22,7 +22,13 @@ from flask import Blueprint, jsonify, request, Response import app as app_module 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.event_pipeline import process_event from utils.sdr import SDRFactory, SDRType @@ -1689,6 +1695,10 @@ def start_aprs() -> Response: except ValueError as e: 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() try: 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}.' }), 400 - # Reserve SDR device to prevent conflicts with other modes - error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str) - if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error - }), 409 - aprs_active_device = device - aprs_active_sdr_type = sdr_type_str + # Reserve SDR device to prevent conflicts (skip for remote rtl_tcp) + if not rtl_tcp_host: + error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str) + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + aprs_active_device = device + aprs_active_sdr_type = sdr_type_str # Get frequency for region 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. try: - sdr_device = SDRFactory.create_default_device(sdr_type, index=device) - builder = SDRFactory.get_builder(sdr_type) + 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 + 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( device=sdr_device, frequency_mhz=float(frequency), diff --git a/routes/dsc.py b/routes/dsc.py index 5ef13d1..668e34c 100644 --- a/routes/dsc.py +++ b/routes/dsc.py @@ -37,7 +37,12 @@ from utils.database import ( from utils.dsc.parser import parse_dsc_message from utils.sse import sse_stream_fanout 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.dependencies import get_tool_path from utils.process import register_process, unregister_process @@ -336,19 +341,29 @@ def start_decoding() -> Response: # Get SDR type from request sdr_type_str = data.get('sdr_type', 'rtlsdr') - # Check if device is available using centralized registry - global dsc_active_device, dsc_active_sdr_type - 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 + # Check for rtl_tcp (remote SDR) connection + rtl_tcp_host = data.get('rtl_tcp_host') + rtl_tcp_port = data.get('rtl_tcp_port', 1234) - dsc_active_device = device_int - dsc_active_sdr_type = sdr_type_str + try: + 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 while not app_module.dsc_queue.empty(): @@ -357,22 +372,32 @@ def start_decoding() -> Response: except queue.Empty: break - # Build rtl_fm command - rtl_fm_path = tools['rtl_fm']['path'] + # Build rtl_fm command via SDR abstraction layer decoder_path = tools['dsc_decoder']['path'] - # rtl_fm command for DSC decoding - # DSC uses narrow FM at 156.525 MHz with 48kHz sample rate - rtl_cmd = [ - rtl_fm_path, - '-f', f'{DSC_VHF_FREQUENCY_MHZ}M', - '-s', str(DSC_SAMPLE_RATE), - '-d', str(device), - '-g', str(gain), - '-M', 'fm', # FM demodulation - '-l', '0', # No squelch for DSC - '-E', 'dc' # DC blocking filter - ] + 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 + 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=int(device)) + + 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_cmd = [decoder_path] diff --git a/routes/morse.py b/routes/morse.py index 9188637..5da43c9 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -28,6 +28,8 @@ from utils.validation import ( validate_frequency, validate_gain, validate_ppm, + validate_rtl_tcp_host, + validate_rtl_tcp_port, ) morse_bp = Blueprint('morse', __name__) @@ -279,6 +281,10 @@ def start_morse() -> Response: 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: if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}: return jsonify({ @@ -287,17 +293,19 @@ def start_morse() -> Response: 'state': morse_state, }), 409 - device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str) - if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error, - }), 409 + # Reserve SDR device (skip for remote rtl_tcp) + if not rtl_tcp_host: + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str) + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error, + }), 409 - morse_active_device = device_int - morse_active_sdr_type = sdr_type_str + morse_active_device = device_int + morse_active_sdr_type = sdr_type_str morse_last_error = '' morse_session_id += 1 @@ -320,23 +328,35 @@ def start_morse() -> Response: except ValueError: 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) 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]] = {} candidate_device_indices: list[int] = [requested_device_index] - with contextlib.suppress(Exception): - detected_devices = SDRFactory.detect_devices() - same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type] - for d in same_type_devices: - device_catalog[d.index] = { - '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: - candidate_device_indices.append(d.index) + if not network_sdr_device: + with contextlib.suppress(Exception): + detected_devices = SDRFactory.detect_devices() + same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type] + for d in same_type_devices: + device_catalog[d.index] = { + '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: + candidate_device_indices.append(d.index) def _device_label(device_index: int) -> str: meta = device_catalog.get(device_index, {}) @@ -350,7 +370,7 @@ def start_morse() -> Response: tuned_frequency_mhz = max(0.5, float(freq)) else: 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] = { 'device': sdr_device, 'frequency_mhz': tuned_frequency_mhz, diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index 320ea5e..4839377 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -96,7 +96,7 @@ var MorseMode = (function () { } function collectConfig() { - return { + var config = { frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060', gain: (el('morseGain') && el('morseGain').value) || '40', ppm: (el('morsePPM') && el('morsePPM').value) || '0', @@ -117,6 +117,17 @@ var MorseMode = (function () { wpm: (el('morseWpm') && el('morseWpm').value) || '15', 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() { diff --git a/templates/ais_dashboard.html b/templates/ais_dashboard.html index aadcd42..aab463b 100644 --- a/templates/ais_dashboard.html +++ b/templates/ais_dashboard.html @@ -1179,6 +1179,19 @@ const isAgentMode = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local'; 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 const endpoint = isAgentMode ? `/controller/agents/${aisCurrentAgent}/dsc/start` @@ -1187,7 +1200,7 @@ fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ device, gain }) + body: JSON.stringify(requestBody) }) .then(r => r.json()) .then(data => { diff --git a/templates/index.html b/templates/index.html index adce6a3..208c6d6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -9797,6 +9797,10 @@ const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; 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 const requestBody = { region, @@ -9805,6 +9809,12 @@ 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 if (region === 'custom') { const customFreq = document.getElementById('aprsStripCustomFreq').value;