mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
add test harness
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
30
download-weather-sat-samples.sh
Executable 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"
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;">▼</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;">
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user