From bdeb32e723b261e40bb7bc17355db112be9a7e36 Mon Sep 17 00:00:00 2001 From: Smittix Date: Sun, 1 Mar 2026 16:55:04 +0000 Subject: [PATCH] feat: add rtl_tcp remote SDR support to weather satellite decoder Extends the rtl_tcp support (added in c1339b6 for APRS, Morse, DSC) to the weather satellite mode. When a remote SDR host is provided, SatDump uses --source rtltcp instead of --source rtlsdr, local device claiming is skipped, and the frontend sends rtl_tcp params via getRemoteSDRConfig(). Closes #166 Co-Authored-By: Claude Opus 4.6 --- routes/weather_sat.py | 43 ++++++++++++------- static/js/modes/weather-satellite.js | 23 ++++++++--- tests/test_weather_sat_decoder.py | 62 ++++++++++++++++++++++++++++ tests/test_weather_sat_routes.py | 62 ++++++++++++++++++++++++++++ utils/weather_sat.py | 62 +++++++++++++++++++--------- 5 files changed, 213 insertions(+), 39 deletions(-) diff --git a/routes/weather_sat.py b/routes/weather_sat.py index 71fa96c..1e9c9f5 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -12,7 +12,7 @@ from flask import Blueprint, jsonify, request, Response, send_file from utils.logging import get_logger from utils.sse import sse_stream -from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation +from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation, validate_rtl_tcp_host, validate_rtl_tcp_port from utils.weather_sat import ( get_weather_sat_decoder, is_weather_sat_available, @@ -158,18 +158,30 @@ def start_capture(): bias_t = bool(data.get('bias_t', False)) - # Claim SDR device - try: - import app as app_module - error = app_module.claim_sdr_device(device_index, 'weather_sat') - if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error, - }), 409 - except ImportError: - pass + # 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: + 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 + + # Claim SDR device (skip for remote rtl_tcp) + if not rtl_tcp_host: + try: + import app as app_module + error = app_module.claim_sdr_device(device_index, 'weather_sat') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error, + }), 409 + except ImportError: + pass # Clear queue while not _weather_sat_queue.empty(): @@ -182,7 +194,8 @@ def start_capture(): decoder.set_callback(_progress_callback) def _release_device(): - _release_weather_sat_device(device_index) + if not rtl_tcp_host: + _release_weather_sat_device(device_index) decoder.set_on_complete(_release_device) @@ -192,6 +205,8 @@ def start_capture(): gain=gain, sample_rate=DEFAULT_SAMPLE_RATE, bias_t=bias_t, + rtl_tcp_host=rtl_tcp_host, + rtl_tcp_port=rtl_tcp_port, ) if success: diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index 2da8ff6..ea39d3f 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -250,15 +250,26 @@ const WeatherSat = (function() { updateStatusUI('connecting', 'Starting...'); try { + const config = { + satellite, + device, + gain, + bias_t: biasT, + }; + + // 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; + } + } + const response = await fetch('/weather-sat/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - satellite, - device, - gain, - bias_t: biasT, - }) + body: JSON.stringify(config) }); const data = await response.json(); diff --git a/tests/test_weather_sat_decoder.py b/tests/test_weather_sat_decoder.py index 8f8ebe2..36b6b7f 100644 --- a/tests/test_weather_sat_decoder.py +++ b/tests/test_weather_sat_decoder.py @@ -136,6 +136,68 @@ class TestWeatherSatDecoder: assert 'noaa_apt' in cmd assert '--bias' in cmd + @patch('subprocess.Popen') + @patch('pty.openpty') + @patch('utils.weather_sat.register_process') + def test_start_rtl_tcp_uses_rtltcp_source(self, mock_register, mock_pty, mock_popen): + """start() with rtl_tcp should use --source rtltcp instead of rtlsdr.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + mock_pty.return_value = (10, 11) + mock_process = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success, error_msg = decoder.start( + satellite='NOAA-18', + device_index=0, + gain=40.0, + rtl_tcp_host='192.168.1.100', + rtl_tcp_port=1234, + ) + + assert success is True + assert error_msg is None + + mock_popen.assert_called_once() + cmd = mock_popen.call_args[0][0] + assert '--source' in cmd + source_idx = cmd.index('--source') + assert cmd[source_idx + 1] == 'rtltcp' + assert '--ip_address' in cmd + assert '192.168.1.100' in cmd + assert '--port' in cmd + assert '1234' in cmd + # Should NOT have --source_id for remote + assert '--source_id' not in cmd + + @patch('subprocess.Popen') + @patch('pty.openpty') + @patch('utils.weather_sat.register_process') + def test_start_rtl_tcp_skips_device_resolve(self, mock_register, mock_pty, mock_popen): + """start() with rtl_tcp should skip _resolve_device_id.""" + with patch('shutil.which', return_value='/usr/bin/satdump'), \ + patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id') as mock_resolve: + mock_pty.return_value = (10, 11) + mock_process = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + decoder = WeatherSatDecoder() + + success, _ = decoder.start( + satellite='NOAA-18', + device_index=0, + gain=40.0, + rtl_tcp_host='10.0.0.1', + ) + + assert success is True + mock_resolve.assert_not_called() + @patch('subprocess.Popen') @patch('pty.openpty') def test_start_already_running(self, mock_pty, mock_popen): diff --git a/tests/test_weather_sat_routes.py b/tests/test_weather_sat_routes.py index f2c5672..8c4642f 100644 --- a/tests/test_weather_sat_routes.py +++ b/tests/test_weather_sat_routes.py @@ -226,6 +226,68 @@ class TestWeatherSatRoutes: assert data['error_type'] == 'DEVICE_BUSY' assert 'Device busy' in data['message'] + def test_start_capture_rtl_tcp_success(self, client): + """POST /weather-sat/start with rtl_tcp remote SDR.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('app.claim_sdr_device') as mock_claim: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.start.return_value = (True, None) + mock_get.return_value = mock_decoder + + payload = { + 'satellite': 'NOAA-18', + 'device': 0, + 'gain': 40.0, + 'rtl_tcp_host': '192.168.1.100', + 'rtl_tcp_port': 1234, + } + + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'started' + + # Device claim should NOT be called for remote SDR + mock_claim.assert_not_called() + + # Verify rtl_tcp params passed to decoder + mock_decoder.start.assert_called_once() + call_kwargs = mock_decoder.start.call_args + assert call_kwargs[1]['rtl_tcp_host'] == '192.168.1.100' + assert call_kwargs[1]['rtl_tcp_port'] == 1234 + + def test_start_capture_rtl_tcp_invalid_host(self, client): + """POST /weather-sat/start with invalid rtl_tcp host.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = { + 'satellite': 'NOAA-18', + 'rtl_tcp_host': 'not a valid host!@#', + } + + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + def test_start_capture_start_failure(self, client): """POST /weather-sat/start when decoder.start() fails.""" with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ diff --git a/utils/weather_sat.py b/utils/weather_sat.py index 29263a3..f11bd9d 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -356,6 +356,8 @@ class WeatherSatDecoder: gain: float = 40.0, sample_rate: int = DEFAULT_SAMPLE_RATE, bias_t: bool = False, + rtl_tcp_host: str | None = None, + rtl_tcp_port: int = 1234, ) -> tuple[bool, str | None]: """Start weather satellite capture and decode. @@ -365,6 +367,8 @@ class WeatherSatDecoder: gain: SDR gain in dB sample_rate: Sample rate in Hz bias_t: Enable bias-T power for LNA + rtl_tcp_host: Remote rtl_tcp server hostname/IP (None for local SDR) + rtl_tcp_port: Remote rtl_tcp server port (default 1234) Returns: Tuple of (success, error_message). error_message is None on success. @@ -382,7 +386,8 @@ class WeatherSatDecoder: # Resolve device ID BEFORE lock — this runs rtl_test which can # take up to 5s and has no side effects on instance state. - source_id = self._resolve_device_id(device_index) + # Skip for remote rtl_tcp connections. + source_id = None if rtl_tcp_host else self._resolve_device_id(device_index) with self._lock: if self._running: @@ -407,7 +412,8 @@ class WeatherSatDecoder: try: self._running = True - self._start_satdump(sat_info, device_index, gain, sample_rate, bias_t, source_id) + self._start_satdump(sat_info, device_index, gain, sample_rate, bias_t, source_id, + rtl_tcp_host=rtl_tcp_host, rtl_tcp_port=rtl_tcp_port) logger.info( f"Weather satellite capture started: {satellite} " @@ -444,6 +450,8 @@ class WeatherSatDecoder: sample_rate: int, bias_t: bool, source_id: str | None = None, + rtl_tcp_host: str | None = None, + rtl_tcp_port: int = 1234, ) -> None: """Start SatDump live capture and decode.""" # Create timestamped output directory for this capture @@ -454,25 +462,41 @@ class WeatherSatDecoder: freq_hz = int(sat_info['frequency'] * 1_000_000) - # Use pre-resolved source_id, or fall back to resolving now - if source_id is None: - source_id = self._resolve_device_id(device_index) + if rtl_tcp_host: + # Remote SDR via rtl_tcp + cmd = [ + 'satdump', 'live', + sat_info['pipeline'], + str(self._capture_output_dir), + '--source', 'rtltcp', + '--ip_address', rtl_tcp_host, + '--port', str(rtl_tcp_port), + '--samplerate', str(sample_rate), + '--frequency', str(freq_hz), + '--gain', str(int(gain)), + ] + logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") + else: + # Local RTL-SDR device + # Use pre-resolved source_id, or fall back to resolving now + if source_id is None: + source_id = self._resolve_device_id(device_index) - cmd = [ - 'satdump', 'live', - sat_info['pipeline'], - str(self._capture_output_dir), - '--source', 'rtlsdr', - '--samplerate', str(sample_rate), - '--frequency', str(freq_hz), - '--gain', str(int(gain)), - ] + cmd = [ + 'satdump', 'live', + sat_info['pipeline'], + str(self._capture_output_dir), + '--source', 'rtlsdr', + '--samplerate', str(sample_rate), + '--frequency', str(freq_hz), + '--gain', str(int(gain)), + ] - # Only pass --source_id if we have a real serial number. - # When _resolve_device_id returns None (no serial found), - # omit the flag so SatDump uses the first available device. - if source_id is not None: - cmd.extend(['--source_id', source_id]) + # Only pass --source_id if we have a real serial number. + # When _resolve_device_id returns None (no serial found), + # omit the flag so SatDump uses the first available device. + if source_id is not None: + cmd.extend(['--source_id', source_id]) if bias_t: cmd.append('--bias')