mirror of
https://github.com/smittix/intercept.git
synced 2026-04-23 22:30:00 -07:00
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:
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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), \
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user