From ca15e227cd40accedb0b7f68884c7515dd5bf878 Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Sun, 8 Feb 2026 14:45:12 -0500 Subject: [PATCH] add test harness --- .gitignore | 3 + download-weather-sat-samples.sh | 30 ++++ routes/weather_sat.py | 120 ++++++++++++++ static/css/modes/weather-satellite.css | 23 +++ static/js/modes/weather-satellite.js | 56 +++++++ .../partials/modes/weather-satellite.html | 39 +++++ utils/weather_sat.py | 149 ++++++++++++++++++ 7 files changed, 420 insertions(+) create mode 100755 download-weather-sat-samples.sh diff --git a/.gitignore b/.gitignore index 18ae397..bc822b4 100644 --- a/.gitignore +++ b/.gitignore @@ -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.* diff --git a/download-weather-sat-samples.sh b/download-weather-sat-samples.sh new file mode 100755 index 0000000..ce13900 --- /dev/null +++ b/download-weather-sat-samples.sh @@ -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" diff --git a/routes/weather_sat.py b/routes/weather_sat.py index 649b7ac..dd0d5b3 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -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. diff --git a/static/css/modes/weather-satellite.css b/static/css/modes/weather-satellite.css index ea7e961..940b5f9 100644 --- a/static/css/modes/weather-satellite.css +++ b/static/css/modes/weather-satellite.css @@ -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); +} diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index 50e79ef..c53a2cd 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -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, diff --git a/templates/partials/modes/weather-satellite.html b/templates/partials/modes/weather-satellite.html index 039eba4..18aeee1 100644 --- a/templates/partials/modes/weather-satellite.html +++ b/templates/partials/modes/weather-satellite.html @@ -174,6 +174,45 @@ +
+

+ Test Decode (File) + +

+ +
+

Auto-Scheduler

diff --git a/utils/weather_sat.py b/utils/weather_sat.py index 2072374..a62b92e 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -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."""