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 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-03-01 16:55:04 +00:00
parent 2de592f798
commit bdeb32e723
5 changed files with 213 additions and 39 deletions

View File

@@ -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,7 +158,19 @@ def start_capture():
bias_t = bool(data.get('bias_t', False))
# Claim SDR device
# 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')
@@ -182,6 +194,7 @@ def start_capture():
decoder.set_callback(_progress_callback)
def _release_device():
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:

View File

@@ -250,15 +250,26 @@ const WeatherSat = (function() {
updateStatusUI('connecting', 'Starting...');
try {
const response = await fetch('/weather-sat/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
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(config)
});
const data = await response.json();

View File

@@ -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):

View File

@@ -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), \

View File

@@ -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,6 +462,22 @@ class WeatherSatDecoder:
freq_hz = int(sat_info['frequency'] * 1_000_000)
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)