From ba4c6999a60ced423f2a12e54ae95bd6e6f7df3f Mon Sep 17 00:00:00 2001 From: Smittix Date: Mon, 5 Jan 2026 08:44:58 +0000 Subject: [PATCH] Add rtl_tcp (remote SDR) support v1.1.0 Features: - Add rtl_tcp support for pager and sensor decoding - Connect to remote RTL-SDR via rtl_tcp server - New UI toggle and host:port inputs in sidebar - Supports rtl_fm and rtl_433 with remote devices - Add remote dump1090 support for ADS-B tracking - Connect to dump1090 SBS output on remote machine - New "Remote" checkbox with host:port in ADS-B dashboard Backend changes: - Add rtl_tcp_host/port fields to SDRDevice dataclass - Add is_network property for detecting remote devices - Update RTLSDRCommandBuilder to use rtl_tcp:host:port format - Add create_network_device() to SDRFactory - Add validate_rtl_tcp_host/port validation functions - Update pager, sensor, and adsb routes to accept remote params Note: dump1090 doesn't support rtl_tcp directly - use remote dump1090's SBS output (port 30003) for remote ADS-B tracking. --- config.py | 2 +- routes/adsb.py | 24 +++++++++++- routes/pager.py | 27 +++++++++++-- routes/sensor.py | 27 +++++++++++-- templates/adsb_dashboard.html | 45 +++++++++++++++++++++- templates/index.html | 72 +++++++++++++++++++++++++++++++++++ utils/__init__.py | 2 + utils/sdr/__init__.py | 28 ++++++++++++++ utils/sdr/base.py | 14 ++++++- utils/sdr/rtlsdr.py | 28 ++++++++++++-- utils/validation.py | 26 +++++++++++++ 11 files changed, 279 insertions(+), 16 deletions(-) diff --git a/config.py b/config.py index ce25fd4..1fa1ed0 100644 --- a/config.py +++ b/config.py @@ -7,7 +7,7 @@ import os import sys # Application version -VERSION = "1.0.0" +VERSION = "1.1.0" def _get_env(key: str, default: str) -> str: diff --git a/routes/adsb.py b/routes/adsb.py index 3da51a1..81cd1c6 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -16,7 +16,10 @@ from flask import Blueprint, jsonify, request, Response, render_template import app as app_module from utils.logging import adsb_logger as logger -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.sse import format_sse from utils.sdr import SDRFactory, SDRType @@ -238,6 +241,25 @@ def start_adsb(): except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 + # Check for remote SBS connection (e.g., remote dump1090) + remote_sbs_host = data.get('remote_sbs_host') + remote_sbs_port = data.get('remote_sbs_port', 30003) + + if remote_sbs_host: + # Validate and connect to remote dump1090 SBS output + try: + remote_sbs_host = validate_rtl_tcp_host(remote_sbs_host) + remote_sbs_port = validate_rtl_tcp_port(remote_sbs_port) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + remote_addr = f"{remote_sbs_host}:{remote_sbs_port}" + logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}") + adsb_using_service = True + thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr,), daemon=True) + thread.start() + return jsonify({'status': 'started', 'message': f'Connected to remote dump1090 at {remote_addr}'}) + # Check if dump1090 is already running externally (e.g., user started it manually) existing_service = check_dump1090_service() if existing_service: diff --git a/routes/pager.py b/routes/pager.py index 7d79703..d68acc1 100644 --- a/routes/pager.py +++ b/routes/pager.py @@ -18,7 +18,10 @@ from flask import Blueprint, jsonify, request, Response import app as app_module from utils.logging import pager_logger as logger -from utils.validation import validate_frequency, validate_device_index, validate_gain, validate_ppm +from utils.validation import ( + validate_frequency, validate_device_index, validate_gain, validate_ppm, + validate_rtl_tcp_host, validate_rtl_tcp_port +) from utils.sse import format_sse from utils.process import safe_terminate, register_process from utils.sdr import SDRFactory, SDRType, SDRValidationError @@ -209,9 +212,25 @@ def start_decoding() -> Response: except ValueError: sdr_type = SDRType.RTL_SDR - # Create device object and get command builder - sdr_device = SDRFactory.create_default_device(sdr_type, index=device) - builder = SDRFactory.get_builder(sdr_type) + # Check for rtl_tcp (remote SDR) connection + rtl_tcp_host = data.get('rtl_tcp_host') + rtl_tcp_port = data.get('rtl_tcp_port', 1234) + + if rtl_tcp_host: + # Validate and create network device + 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: + # Create local device object + sdr_device = SDRFactory.create_default_device(sdr_type, index=device) + + builder = SDRFactory.get_builder(sdr_device.sdr_type) # Build FM demodulation command rtl_cmd = builder.build_fm_demod_command( diff --git a/routes/sensor.py b/routes/sensor.py index 3959168..aa21a53 100644 --- a/routes/sensor.py +++ b/routes/sensor.py @@ -14,7 +14,10 @@ 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_frequency, validate_device_index, validate_gain, validate_ppm +from utils.validation import ( + validate_frequency, validate_device_index, validate_gain, validate_ppm, + validate_rtl_tcp_host, validate_rtl_tcp_port +) from utils.sse import format_sse from utils.process import safe_terminate, register_process from utils.sdr import SDRFactory, SDRType @@ -90,9 +93,25 @@ def start_sensor() -> Response: except ValueError: sdr_type = SDRType.RTL_SDR - # Create device object and get command builder - sdr_device = SDRFactory.create_default_device(sdr_type, index=device) - builder = SDRFactory.get_builder(sdr_type) + # Check for rtl_tcp (remote SDR) connection + rtl_tcp_host = data.get('rtl_tcp_host') + rtl_tcp_port = data.get('rtl_tcp_port', 1234) + + if rtl_tcp_host: + # Validate and create network device + 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: + # Create local device object + sdr_device = SDRFactory.create_default_device(sdr_type, index=device) + + builder = SDRFactory.get_builder(sdr_device.sdr_type) # Build ISM band decoder command cmd = builder.build_ism_command( diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 19aca15..ebd12f6 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -159,6 +159,17 @@ +
+ +
+ @@ -1054,15 +1065,47 @@ // ============================================ // TRACKING CONTROL // ============================================ + + function toggleRemoteDump1090() { + const useRemote = document.getElementById('useRemoteDump1090').checked; + const controls = document.querySelector('.remote-dump1090-controls'); + controls.style.display = useRemote ? 'flex' : 'none'; + } + + function getRemoteDump1090Config() { + const useRemote = document.getElementById('useRemoteDump1090').checked; + if (!useRemote) return null; + + const host = document.getElementById('remoteSbsHost').value.trim(); + const port = parseInt(document.getElementById('remoteSbsPort').value) || 30003; + + if (!host) { + alert('Please enter remote dump1090 host address'); + return false; + } + + return { host, port }; + } + async function toggleTracking() { const btn = document.getElementById('startBtn'); if (!isTracking) { + // Check for remote dump1090 config + const remoteConfig = getRemoteDump1090Config(); + if (remoteConfig === false) return; + + const requestBody = {}; + if (remoteConfig) { + requestBody.remote_sbs_host = remoteConfig.host; + requestBody.remote_sbs_port = remoteConfig.port; + } + try { const response = await fetch('/adsb/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}) + body: JSON.stringify(requestBody) }); const text = await response.text(); diff --git a/templates/index.html b/templates/index.html index 8085b00..c1ff90b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -298,6 +298,29 @@ + + +
+ +
+ +
rtl_fm:{{ 'OK' if tools.rtl_fm else 'Missing' }} multimon-ng:{{ 'OK' if tools.multimon else 'Missing' }} @@ -1918,6 +1941,10 @@ const ppm = document.getElementById('sensorPpm').value; const device = getSelectedDevice(); + // Check for remote SDR + const remoteConfig = getRemoteSDRConfig(); + if (remoteConfig === false) return; // Validation failed + const config = { frequency: freq, gain: gain, @@ -1926,6 +1953,12 @@ sdr_type: getSelectedSDRType() }; + // Add rtl_tcp params if using remote SDR + if (remoteConfig) { + config.rtl_tcp_host = remoteConfig.host; + config.rtl_tcp_port = remoteConfig.port; + } + fetch('/start_sensor', { method: 'POST', headers: {'Content-Type': 'application/json'}, @@ -2403,6 +2436,35 @@ return document.getElementById('sdrTypeSelect').value; } + function toggleRemoteSDR() { + const useRemote = document.getElementById('useRemoteSDR').checked; + const configDiv = document.getElementById('remoteSDRConfig'); + const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect'); + + configDiv.style.display = useRemote ? 'block' : 'none'; + + // Dim local device controls when using remote + localControls.forEach(el => { + el.style.opacity = useRemote ? '0.5' : '1'; + el.disabled = useRemote; + }); + } + + function getRemoteSDRConfig() { + const useRemote = document.getElementById('useRemoteSDR').checked; + if (!useRemote) return null; + + const host = document.getElementById('rtlTcpHost').value.trim(); + const port = parseInt(document.getElementById('rtlTcpPort').value) || 1234; + + if (!host) { + alert('Please enter rtl_tcp host address'); + return false; + } + + return { host, port }; + } + function getSelectedProtocols() { const protocols = []; if (document.getElementById('proto_pocsag512').checked) protocols.push('POCSAG512'); @@ -2425,6 +2487,10 @@ return; } + // Check for remote SDR + const remoteConfig = getRemoteSDRConfig(); + if (remoteConfig === false) return; // Validation failed + const config = { frequency: freq, gain: gain, @@ -2435,6 +2501,12 @@ protocols: protocols }; + // Add rtl_tcp params if using remote SDR + if (remoteConfig) { + config.rtl_tcp_host = remoteConfig.host; + config.rtl_tcp_port = remoteConfig.port; + } + fetch('/start', { method: 'POST', headers: {'Content-Type': 'application/json'}, diff --git a/utils/__init__.py b/utils/__init__.py index aee0517..1d36916 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -26,6 +26,8 @@ from .validation import ( validate_longitude, validate_frequency, validate_device_index, + validate_rtl_tcp_host, + validate_rtl_tcp_port, validate_gain, validate_ppm, validate_hours, diff --git a/utils/sdr/__init__.py b/utils/sdr/__init__.py index d29f054..833ef56 100644 --- a/utils/sdr/__init__.py +++ b/utils/sdr/__init__.py @@ -172,6 +172,34 @@ class SDRFactory: capabilities=caps ) + @classmethod + def create_network_device( + cls, + host: str, + port: int = 1234 + ) -> SDRDevice: + """ + Create a network device for rtl_tcp connection. + + Args: + host: rtl_tcp server hostname or IP address + port: rtl_tcp server port (default 1234) + + Returns: + SDRDevice configured for rtl_tcp connection + """ + caps = cls.get_capabilities(SDRType.RTL_SDR) + return SDRDevice( + sdr_type=SDRType.RTL_SDR, + index=0, + name=f'{host}:{port}', + serial='rtl_tcp', + driver='rtl_tcp', + capabilities=caps, + rtl_tcp_host=host, + rtl_tcp_port=port + ) + # Export commonly used items at package level __all__ = [ diff --git a/utils/sdr/base.py b/utils/sdr/base.py index e03c2ef..5811ceb 100644 --- a/utils/sdr/base.py +++ b/utils/sdr/base.py @@ -46,15 +46,23 @@ class SDRDevice: serial: str driver: str # e.g., "rtlsdr", "lime", "hackrf" capabilities: SDRCapabilities + rtl_tcp_host: Optional[str] = None # Remote rtl_tcp server host + rtl_tcp_port: Optional[int] = None # Remote rtl_tcp server port + + @property + def is_network(self) -> bool: + """Check if this is a network/remote device.""" + return self.rtl_tcp_host is not None def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" - return { + result = { 'index': self.index, 'name': self.name, 'serial': self.serial, 'sdr_type': self.sdr_type.value, 'driver': self.driver, + 'is_network': self.is_network, 'capabilities': { 'freq_min_mhz': self.capabilities.freq_min_mhz, 'freq_max_mhz': self.capabilities.freq_max_mhz, @@ -66,6 +74,10 @@ class SDRDevice: 'tx_capable': self.capabilities.tx_capable, } } + if self.is_network: + result['rtl_tcp_host'] = self.rtl_tcp_host + result['rtl_tcp_port'] = self.rtl_tcp_port + return result class CommandBuilder(ABC): diff --git a/utils/sdr/rtlsdr.py b/utils/sdr/rtlsdr.py index a2997a2..6909314 100644 --- a/utils/sdr/rtlsdr.py +++ b/utils/sdr/rtlsdr.py @@ -27,6 +27,16 @@ class RTLSDRCommandBuilder(CommandBuilder): tx_capable=False ) + def _get_device_arg(self, device: SDRDevice) -> str: + """Get device argument for rtl_* tools. + + Returns rtl_tcp connection string for network devices, + or device index for local devices. + """ + if device.is_network: + return f"rtl_tcp:{device.rtl_tcp_host}:{device.rtl_tcp_port}" + return str(device.index) + def build_fm_demod_command( self, device: SDRDevice, @@ -40,11 +50,11 @@ class RTLSDRCommandBuilder(CommandBuilder): """ Build rtl_fm command for FM demodulation. - Used for pager decoding. + Used for pager decoding. Supports local devices and rtl_tcp connections. """ cmd = [ 'rtl_fm', - '-d', str(device.index), + '-d', self._get_device_arg(device), '-f', f'{frequency_mhz}M', '-M', modulation, '-s', str(sample_rate), @@ -73,7 +83,17 @@ class RTLSDRCommandBuilder(CommandBuilder): Build dump1090 command for ADS-B decoding. Uses dump1090 with network output for SBS data streaming. + + Note: dump1090 does not support rtl_tcp. For remote SDR, connect to + a remote dump1090's SBS output (port 30003) instead. """ + if device.is_network: + raise ValueError( + "dump1090 does not support rtl_tcp. " + "For remote ADS-B, run dump1090 on the remote machine and " + "connect to its SBS output (port 30003)." + ) + cmd = [ 'dump1090', '--net', @@ -96,11 +116,11 @@ class RTLSDRCommandBuilder(CommandBuilder): """ Build rtl_433 command for ISM band sensor decoding. - Outputs JSON for easy parsing. + Outputs JSON for easy parsing. Supports local devices and rtl_tcp connections. """ cmd = [ 'rtl_433', - '-d', str(device.index), + '-d', self._get_device_arg(device), '-f', f'{frequency_mhz}M', '-F', 'json' ] diff --git a/utils/validation.py b/utils/validation.py index a6bee0e..a19e4a3 100644 --- a/utils/validation.py +++ b/utils/validation.py @@ -66,6 +66,32 @@ def validate_device_index(device: Any) -> int: raise ValueError(f"Invalid device index: {device}") from e +def validate_rtl_tcp_host(host: Any) -> str: + """Validate and return rtl_tcp server hostname or IP address.""" + if not host or not isinstance(host, str): + raise ValueError("rtl_tcp host is required") + host = host.strip() + if not host: + raise ValueError("rtl_tcp host cannot be empty") + # Allow alphanumeric, dots, hyphens (valid for hostnames and IPs) + if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9.\-]*$', host): + raise ValueError(f"Invalid rtl_tcp host: {host}") + if len(host) > 253: + raise ValueError("rtl_tcp host too long") + return host + + +def validate_rtl_tcp_port(port: Any) -> int: + """Validate and return rtl_tcp server port.""" + try: + port_int = int(port) + if not 1 <= port_int <= 65535: + raise ValueError(f"Port must be between 1 and 65535, got {port_int}") + return port_int + except (ValueError, TypeError) as e: + raise ValueError(f"Invalid rtl_tcp port: {port}") from e + + def validate_gain(gain: Any) -> float: """Validate and return gain value.""" try: