add test harness

This commit is contained in:
Mitch Ross
2026-02-08 14:45:12 -05:00
parent 1924203c19
commit ca15e227cd
7 changed files with 420 additions and 0 deletions

3
.gitignore vendored
View File

@@ -55,6 +55,9 @@ intercept_agent_*.cfg
/tmp/
*.tmp
# Weather satellite runtime data (decoded images, samples, SatDump output)
data/weather_sat/
# Env files
.env
.env.*

30
download-weather-sat-samples.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Download sample NOAA APT recordings for testing the weather satellite
# test-decode feature. These are FM-demodulated audio WAV files.
#
# Usage:
# ./download-weather-sat-samples.sh
# docker exec intercept /app/download-weather-sat-samples.sh
set -euo pipefail
SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples"
mkdir -p "$SAMPLE_DIR"
echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..."
# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV)
# Source: https://github.com/martinber/noaa-apt
if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then
echo " -> noaa_apt_argentina.wav (18 MB) ..."
curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \
"https://noaa-apt.mbernardi.com.ar/examples/argentina.wav"
else
echo " -> noaa_apt_argentina.wav (already exists)"
fi
echo ""
echo "Done. Test decode with:"
echo " Satellite: NOAA-18"
echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav"
echo " Sample rate: 11025 Hz"

View File

@@ -199,6 +199,126 @@ def start_capture():
}), 500
@weather_sat_bp.route('/test-decode', methods=['POST'])
def test_decode():
"""Start weather satellite decode from a pre-recorded file.
No SDR hardware is required — decodes an IQ baseband or WAV file
using SatDump offline mode.
JSON body:
{
"satellite": "NOAA-18", // Required: satellite key
"input_file": "/path/to/file", // Required: server-side file path
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
}
Returns:
JSON with start status.
"""
if not is_weather_sat_available():
return jsonify({
'status': 'error',
'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump'
}), 400
decoder = get_weather_sat_decoder()
if decoder.is_running:
return jsonify({
'status': 'already_running',
'satellite': decoder.current_satellite,
'frequency': decoder.current_frequency,
})
data = request.get_json(silent=True) or {}
# Validate satellite
satellite = data.get('satellite')
if not satellite or satellite not in WEATHER_SATELLITES:
return jsonify({
'status': 'error',
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
}), 400
# Validate input file
input_file = data.get('input_file')
if not input_file:
return jsonify({
'status': 'error',
'message': 'input_file is required'
}), 400
from pathlib import Path
input_path = Path(input_file)
# Security: restrict to data directory
allowed_base = Path('data').resolve()
try:
resolved = input_path.resolve()
if not str(resolved).startswith(str(allowed_base)):
return jsonify({
'status': 'error',
'message': 'input_file must be under the data/ directory'
}), 403
except (OSError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid file path'
}), 400
if not input_path.is_file():
return jsonify({
'status': 'error',
'message': f'File not found: {input_file}'
}), 404
# Validate sample rate
sample_rate = data.get('sample_rate', 1000000)
try:
sample_rate = int(sample_rate)
if sample_rate < 1000 or sample_rate > 20000000:
raise ValueError
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid sample_rate (1000-20000000)'
}), 400
# Clear queue
while not _weather_sat_queue.empty():
try:
_weather_sat_queue.get_nowait()
except queue.Empty:
break
# Set callback — no on_complete needed (no SDR to release)
decoder.set_callback(_progress_callback)
decoder.set_on_complete(None)
success = decoder.start_from_file(
satellite=satellite,
input_file=input_file,
sample_rate=sample_rate,
)
if success:
sat_info = WEATHER_SATELLITES[satellite]
return jsonify({
'status': 'started',
'satellite': satellite,
'frequency': sat_info['frequency'],
'mode': sat_info['mode'],
'source': 'file',
'input_file': str(input_file),
})
else:
return jsonify({
'status': 'error',
'message': 'Failed to start file decode'
}), 500
@weather_sat_bp.route('/stop', methods=['POST'])
def stop_capture():
"""Stop weather satellite capture.

View File

@@ -1058,3 +1058,26 @@
border-left-color: transparent;
color: var(--text-dim, #555);
}
/* Test Decode collapsible section */
.wxsat-test-decode-body {
transition: max-height 0.3s ease, opacity 0.2s ease, margin 0.3s ease;
max-height: 400px;
opacity: 1;
margin-top: 8px;
}
.wxsat-test-decode-body.collapsed {
max-height: 0;
opacity: 0;
margin-top: 0;
overflow: hidden;
}
.wxsat-collapse-icon {
transition: transform 0.2s ease;
}
.wxsat-collapse-icon.collapsed {
transform: rotate(-90deg);
}

View File

@@ -229,6 +229,61 @@ const WeatherSat = (function() {
}
}
/**
* Start test decode from a pre-recorded file
*/
async function testDecode() {
const satSelect = document.getElementById('wxsatTestSatSelect');
const fileInput = document.getElementById('wxsatTestFilePath');
const rateSelect = document.getElementById('wxsatTestSampleRate');
const satellite = satSelect?.value || 'NOAA-18';
const inputFile = (fileInput?.value || '').trim();
const sampleRate = parseInt(rateSelect?.value || '1000000', 10);
if (!inputFile) {
showNotification('Weather Sat', 'Enter a file path');
return;
}
clearConsole();
showConsole(true);
updatePhaseIndicator('decoding');
addConsoleEntry(`Test decode: ${inputFile}`, 'info');
updateStatusUI('connecting', 'Starting file decode...');
try {
const response = await fetch('/weather-sat/test-decode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
satellite,
input_file: inputFile,
sample_rate: sampleRate,
})
});
const data = await response.json();
if (data.status === 'started' || data.status === 'already_running') {
isRunning = true;
currentSatellite = data.satellite || satellite;
updateStatusUI('decoding', `Decoding ${data.satellite} from file`);
updateFreqDisplay(data.frequency, data.mode);
startStream();
showNotification('Weather Sat', `Decoding ${data.satellite} from file`);
} else {
updateStatusUI('idle', 'Decode failed');
showNotification('Weather Sat', data.message || 'Failed to start decode');
addConsoleEntry(data.message || 'Failed to start decode', 'error');
}
} catch (err) {
console.error('Failed to start test decode:', err);
updateStatusUI('idle', 'Error');
showNotification('Weather Sat', 'Connection error');
}
}
/**
* Update status UI
*/
@@ -1309,6 +1364,7 @@ const WeatherSat = (function() {
stop,
startPass,
selectPass,
testDecode,
loadImages,
loadPasses,
showImage,

View File

@@ -174,6 +174,45 @@
</div>
</div>
<div class="section">
<h3 onclick="this.parentElement.querySelector('.wxsat-test-decode-body').classList.toggle('collapsed'); this.querySelector('.wxsat-collapse-icon').classList.toggle('collapsed')" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;">
Test Decode (File)
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">&#9660;</span>
</h3>
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;">
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
Decode a pre-recorded IQ or WAV file without SDR hardware.
Run <code style="font-size: 10px;">./download-weather-sat-samples.sh</code> to fetch sample files.
</p>
<div class="form-group">
<label>Satellite</label>
<select id="wxsatTestSatSelect" class="mode-select">
<option value="NOAA-15">NOAA-15 (APT)</option>
<option value="NOAA-18" selected>NOAA-18 (APT)</option>
<option value="NOAA-19">NOAA-19 (APT)</option>
<option value="METEOR-M2-3">Meteor-M2-3 (LRPT)</option>
</select>
</div>
<div class="form-group">
<label>File Path (server-side)</label>
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/noaa_apt_argentina.wav" style="font-family: 'JetBrains Mono', monospace; font-size: 11px;">
</div>
<div class="form-group">
<label>Sample Rate</label>
<select id="wxsatTestSampleRate" class="mode-select">
<option value="11025">11025 Hz (WAV audio APT)</option>
<option value="48000">48000 Hz (WAV audio APT)</option>
<option value="500000">500 kHz (IQ LRPT)</option>
<option value="1000000" selected>1 MHz (IQ default)</option>
<option value="2000000">2 MHz (IQ wideband)</option>
</select>
</div>
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
Test Decode
</button>
</div>
</div>
<div class="section">
<h3>Auto-Scheduler</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">

View File

@@ -203,6 +203,92 @@ class WeatherSatDecoder:
"""Set callback invoked when capture process ends (for SDR release)."""
self._on_complete_callback = callback
def start_from_file(
self,
satellite: str,
input_file: str | Path,
sample_rate: int = DEFAULT_SAMPLE_RATE,
) -> bool:
"""Start weather satellite decode from a pre-recorded IQ/WAV file.
No SDR hardware is required — SatDump runs in offline mode.
Args:
satellite: Satellite key (e.g. 'NOAA-18', 'METEOR-M2-3')
input_file: Path to IQ baseband or WAV audio file
sample_rate: Sample rate of the recording in Hz
Returns:
True if started successfully
"""
with self._lock:
if self._running:
return True
if not self._decoder:
logger.error("No weather satellite decoder available")
self._emit_progress(CaptureProgress(
status='error',
message='SatDump not installed. Build from source or install via package manager.'
))
return False
sat_info = WEATHER_SATELLITES.get(satellite)
if not sat_info:
logger.error(f"Unknown satellite: {satellite}")
self._emit_progress(CaptureProgress(
status='error',
message=f'Unknown satellite: {satellite}'
))
return False
input_path = Path(input_file)
if not input_path.is_file():
logger.error(f"Input file not found: {input_file}")
self._emit_progress(CaptureProgress(
status='error',
message=f'Input file not found: {input_file}'
))
return False
self._current_satellite = satellite
self._current_frequency = sat_info['frequency']
self._current_mode = sat_info['mode']
self._capture_start_time = time.time()
self._capture_phase = 'decoding'
try:
self._running = True
self._start_satdump_offline(
sat_info, input_path, sample_rate,
)
logger.info(
f"Weather satellite file decode started: {satellite} "
f"({sat_info['mode']}) from {input_file}"
)
self._emit_progress(CaptureProgress(
status='decoding',
satellite=satellite,
frequency=sat_info['frequency'],
mode=sat_info['mode'],
message=f"Decoding {sat_info['name']} from file ({sat_info['mode']})...",
log_type='info',
capture_phase='decoding',
))
return True
except Exception as e:
self._running = False
logger.error(f"Failed to start file decode: {e}")
self._emit_progress(CaptureProgress(
status='error',
satellite=satellite,
message=str(e)
))
return False
def start(
self,
satellite: str,
@@ -377,6 +463,69 @@ class WeatherSatDecoder:
)
self._watcher_thread.start()
def _start_satdump_offline(
self,
sat_info: dict,
input_file: Path,
sample_rate: int,
) -> None:
"""Start SatDump offline decode from a recorded file."""
# Create timestamped output directory for this decode
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
sat_name = sat_info['tle_key'].replace(' ', '_')
self._capture_output_dir = self._output_dir / f"{sat_name}_{timestamp}"
self._capture_output_dir.mkdir(parents=True, exist_ok=True)
# Determine input level from file extension.
# WAV audio files (FM-demodulated) use 'audio_wav' level.
# Raw IQ baseband files use 'baseband' level.
suffix = input_file.suffix.lower()
if suffix in ('.wav', '.wave'):
input_level = 'audio_wav'
else:
input_level = 'baseband'
cmd = [
'satdump',
sat_info['pipeline'],
input_level,
str(input_file),
str(self._capture_output_dir),
'--samplerate', str(sample_rate),
]
logger.info(f"Starting SatDump offline: {' '.join(cmd)}")
# Use a pseudo-terminal so SatDump thinks it's writing to a real
# terminal — same approach as live mode for unbuffered output.
master_fd, slave_fd = pty.openpty()
self._pty_master_fd = master_fd
self._process = subprocess.Popen(
cmd,
stdout=slave_fd,
stderr=slave_fd,
stdin=subprocess.DEVNULL,
close_fds=True,
)
os.close(slave_fd) # parent doesn't need the slave side
# For offline mode, don't check for early exit — file decoding
# may complete very quickly and exit code 0 is normal success.
# The reader thread will handle output and detect errors.
# Start reader thread to monitor output
self._reader_thread = threading.Thread(
target=self._read_satdump_output, daemon=True
)
self._reader_thread.start()
# Start image watcher thread
self._watcher_thread = threading.Thread(
target=self._watch_images, daemon=True
)
self._watcher_thread.start()
@staticmethod
def _classify_log_type(line: str) -> str:
"""Classify a SatDump output line into a log type."""