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/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
|
# Weather satellite runtime data (decoded images, samples, SatDump output)
|
||||||
|
data/weather_sat/
|
||||||
|
|
||||||
# Env files
|
# Env files
|
||||||
.env
|
.env
|
||||||
.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
|
}), 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'])
|
@weather_sat_bp.route('/stop', methods=['POST'])
|
||||||
def stop_capture():
|
def stop_capture():
|
||||||
"""Stop weather satellite capture.
|
"""Stop weather satellite capture.
|
||||||
|
|||||||
@@ -1058,3 +1058,26 @@
|
|||||||
border-left-color: transparent;
|
border-left-color: transparent;
|
||||||
color: var(--text-dim, #555);
|
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
|
* Update status UI
|
||||||
*/
|
*/
|
||||||
@@ -1309,6 +1364,7 @@ const WeatherSat = (function() {
|
|||||||
stop,
|
stop,
|
||||||
startPass,
|
startPass,
|
||||||
selectPass,
|
selectPass,
|
||||||
|
testDecode,
|
||||||
loadImages,
|
loadImages,
|
||||||
loadPasses,
|
loadPasses,
|
||||||
showImage,
|
showImage,
|
||||||
|
|||||||
@@ -174,6 +174,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="section">
|
||||||
<h3>Auto-Scheduler</h3>
|
<h3>Auto-Scheduler</h3>
|
||||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
|
<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)."""
|
"""Set callback invoked when capture process ends (for SDR release)."""
|
||||||
self._on_complete_callback = callback
|
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(
|
def start(
|
||||||
self,
|
self,
|
||||||
satellite: str,
|
satellite: str,
|
||||||
@@ -377,6 +463,69 @@ class WeatherSatDecoder:
|
|||||||
)
|
)
|
||||||
self._watcher_thread.start()
|
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
|
@staticmethod
|
||||||
def _classify_log_type(line: str) -> str:
|
def _classify_log_type(line: str) -> str:
|
||||||
"""Classify a SatDump output line into a log type."""
|
"""Classify a SatDump output line into a log type."""
|
||||||
|
|||||||
Reference in New Issue
Block a user